From 5469bfdd525300d4a898885be8b6d2c363d0f0e2 Mon Sep 17 00:00:00 2001 From: filtered <176114999+webfiltered@users.noreply.github.com> Date: Thu, 21 Nov 2024 13:50:58 +1100 Subject: [PATCH] Add ESLint, pre-commit hook & format all files (#319) * Add ESLint config * Add ESLint packages * Add prettier config * Fix ESLint package version * Format all files * Format static assets * Format project root config * Add pre-commit code formatting Formats .css & .js files automatically. If any .ts or .mts files are staged, the entire project is type-checked. Packages: - lint-staged - husky - prettier --- .eslintignore | 1 - .eslintrc.js | 56 - .husky/pre-commit | 5 + .prettierrc | 9 +- eslint.config.js | 158 + lint-staged.config.js | 17 + package-lock.json | 2319 +++++- package.json | 9 + public/css/litegraph.css | 786 +- src/CanvasPointer.ts | 4 + src/ContextMenu.ts | 671 +- src/CurveEditor.ts | 326 +- src/DragAndScale.ts | 435 +- src/LGraph.ts | 3211 +++---- src/LGraphBadge.ts | 84 +- src/LGraphCanvas.ts | 15613 ++++++++++++++++++----------------- src/LGraphGroup.ts | 582 +- src/LGraphNode.ts | 5010 +++++------ src/LLink.ts | 321 +- src/LiteGraphGlobal.ts | 1781 ++-- src/MapProxyHandler.ts | 110 +- src/Reroute.ts | 570 +- src/draw.ts | 98 +- src/interfaces.ts | 291 +- src/litegraph.ts | 152 +- src/measure.ts | 357 +- src/polyfills.ts | 140 +- src/strings.ts | 4 +- src/types/events.ts | 75 +- src/types/globalEnums.ts | 120 +- src/types/serialisation.ts | 164 +- src/types/widgets.ts | 163 +- src/utils/arrange.ts | 134 +- src/utils/collections.ts | 24 +- vite.config.mts | 30 +- 35 files changed, 18713 insertions(+), 15117 deletions(-) delete mode 100755 .eslintignore delete mode 100755 .eslintrc.js create mode 100644 .husky/pre-commit create mode 100644 eslint.config.js create mode 100644 lint-staged.config.js diff --git a/.eslintignore b/.eslintignore deleted file mode 100755 index 796b96d1..00000000 --- a/.eslintignore +++ /dev/null @@ -1 +0,0 @@ -/build diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100755 index 81e689e0..00000000 --- a/.eslintrc.js +++ /dev/null @@ -1,56 +0,0 @@ -module.exports = { - "env": { - "browser": true, - "es2021": true, - "node": true, - "jest/globals": true - }, - "extends": "eslint:recommended", - "overrides": [ - ], - "parserOptions": { - "ecmaVersion": "latest", - "sourceType": "module" - }, - "plugins": ["jest"], - "globals": { - "gl": true, - "GL": true, - "LS": true, - "Uint8Array": true, - "Uint32Array": true, - "Float32Array": true, - "LGraphCanvas": true, - "LGraph": true, - "LGraphNode": true, - "LiteGraph": true, - "LGraphTexture": true, - "Mesh": true, - "Shader": true, - "enableWebGLCanvas": true, - "vec2": true, - "vec3": true, - "vec4": true, - "DEG2RAD": true, - "isPowerOfTwo": true, - "cloneCanvas": true, - "createCanvas": true, - "hex2num": true, - "colorToString": true, - "showElement": true, - "quat": true, - "AudioSynth": true, - "SillyClient": true - }, - "rules": { - "no-console": "off", - "no-empty": "warn", - "no-redeclare": "warn", - "no-inner-declarations": "warn", - "no-constant-condition": "warn", - "no-unused-vars": "warn", - "no-mixed-spaces-and-tabs": "warn", - "no-unreachable": "warn", - "curly": ["warn", "all"] - } -} diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 00000000..a19f49ff --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,5 @@ +if [[ "$OS" == "Windows_NT" ]]; then + npx.cmd lint-staged +else + npx lint-staged +fi diff --git a/.prettierrc b/.prettierrc index ef6f9008..c3be0e54 100755 --- a/.prettierrc +++ b/.prettierrc @@ -1,5 +1,8 @@ { - "singleQuote": false, - "semi": true, - "tabWidth": 2 + "singleQuote": false, + "semi": false, + "tabWidth": 2, + "trailingComma": "all", + "overrides": [{ "files": "*.ts", "options": { "requirePragma": true } }], + "arrowParens": "avoid" } diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 00000000..9dd4d559 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,158 @@ +import globals from "globals" +import eslint from "@eslint/js" +import tseslint from "typescript-eslint" +import stylistic from "@stylistic/eslint-plugin" + +export default tseslint.config( + { files: ["**/*.{js,mjs,ts,mts}"] }, + eslint.configs.recommended, + ...tseslint.configs.recommendedTypeChecked, + stylistic.configs.customize({ + quotes: "double", + braceStyle: "1tbs", + commaDangle: "always-multiline", + }), + { + languageOptions: { + globals: { ...globals.browser }, + parserOptions: { + projectService: { + allowDefaultProject: [ + "eslint.config.js", + "lint-staged.config.js", + "vite.config.mts", + ], + }, + tsconfigRootDir: import.meta.dirname, + }, + }, + }, + { + ignores: ["./dist/**/*"], + }, + { + rules: { + // TODO: Update when TypeScript has been cleaned + "prefer-spread": "off", + "no-empty": "off", + "no-prototype-builtins": "off", + "no-var": "warn", + "no-fallthrough": "off", + + "no-empty-pattern": ["error", { allowObjectPatternsAsParameters: true }], + + "@typescript-eslint/ban-ts-comment": "off", + "@typescript-eslint/no-this-alias": "off", + + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unsafe-member-access": "off", + "@typescript-eslint/no-unsafe-assignment": "off", + "@typescript-eslint/no-unsafe-argument": "off", + "@typescript-eslint/no-unsafe-call": "off", + "@typescript-eslint/no-unsafe-return": "off", + + "@typescript-eslint/no-base-to-string": "off", + "@typescript-eslint/restrict-plus-operands": "off", + "@typescript-eslint/no-implied-eval": "off", + "@typescript-eslint/unbound-method": "off", + "@typescript-eslint/no-unsafe-enum-comparison": "off", + "@typescript-eslint/no-for-in-array": "off", + "@typescript-eslint/only-throw-error": "off", + "@typescript-eslint/no-duplicate-type-constituents": "off", + "@typescript-eslint/no-empty-object-type": "off", + + // "@typescript-eslint/prefer-readonly-parameter-types": "error", + "@typescript-eslint/no-unused-vars": "off", + // "@typescript-eslint/no-unsafe-function-type": "off", + + "@stylistic/max-len": [ + "warn", + { code: 100, comments: 130, ignoreStrings: true }, + ], + + // "@stylistic/multiline-comment-style": ["warn", "starred-block"], + "@stylistic/curly-newline": [ + "warn", + { consistent: true, multiline: true }, + ], + "@stylistic/object-curly-newline": [ + "warn", + { consistent: true, multiline: true }, + ], + // "@stylistic/object-property-newline": ["warn", { allowAllPropertiesOnSameLine: true }], + // "@stylistic/object-property-newline": "warn", + "@stylistic/one-var-declaration-per-line": "warn", + + "@stylistic/array-bracket-newline": ["warn", { multiline: true }], + "@stylistic/array-element-newline": [ + "warn", + { consistent: true, multiline: true }, + ], + + "@stylistic/function-paren-newline": ["warn", "multiline-arguments"], + "@stylistic/newline-per-chained-call": "warn", + + "@stylistic/array-bracket-spacing": "warn", + "@stylistic/arrow-parens": "warn", + "@stylistic/arrow-spacing": "warn", + "@stylistic/block-spacing": "warn", + "@stylistic/brace-style": "warn", + "@stylistic/comma-dangle": "warn", + "@stylistic/comma-spacing": "warn", + "@stylistic/comma-style": "warn", + "@stylistic/computed-property-spacing": "warn", + "@stylistic/dot-location": "warn", + "@stylistic/eol-last": "warn", + "@stylistic/indent": ["warn", 2, { VariableDeclarator: "first" }], + "@stylistic/indent-binary-ops": "warn", + "@stylistic/key-spacing": "warn", + "@stylistic/keyword-spacing": "warn", + "@stylistic/lines-between-class-members": "warn", + "@stylistic/max-statements-per-line": "warn", + "@stylistic/member-delimiter-style": "warn", + "@stylistic/multiline-ternary": "warn", + "@stylistic/new-parens": "warn", + "@stylistic/no-extra-parens": "warn", + "@stylistic/no-floating-decimal": "warn", + "@stylistic/no-mixed-operators": "warn", + "@stylistic/no-mixed-spaces-and-tabs": "warn", + "@stylistic/no-multi-spaces": "warn", + "@stylistic/no-multiple-empty-lines": "warn", + "@stylistic/no-tabs": "warn", + "@stylistic/no-trailing-spaces": "warn", + "@stylistic/no-whitespace-before-property": "warn", + "@stylistic/object-curly-spacing": "warn", + "@stylistic/operator-linebreak": [ + "warn", + "after", + { overrides: { "?": "before", ":": "before" } }, + ], + "@stylistic/padded-blocks": "warn", + "@stylistic/quote-props": "warn", + "@stylistic/quotes": "warn", + "@stylistic/rest-spread-spacing": "warn", + "@stylistic/semi": "warn", + "@stylistic/semi-spacing": "warn", + "@stylistic/semi-style": ["warn", "first"], + "@stylistic/space-before-blocks": "warn", + "@stylistic/space-before-function-paren": "warn", + "@stylistic/space-in-parens": "warn", + "@stylistic/space-infix-ops": "warn", + "@stylistic/space-unary-ops": "warn", + "@stylistic/spaced-comment": "warn", + "@stylistic/template-curly-spacing": "warn", + "@stylistic/template-tag-spacing": "warn", + "@stylistic/type-annotation-spacing": "warn", + "@stylistic/type-generic-spacing": "warn", + "@stylistic/type-named-tuple-spacing": "warn", + "@stylistic/wrap-iife": "warn", + "@stylistic/yield-star-spacing": "warn", + }, + }, + { + rules: { + "@typescript-eslint/no-unused-vars": "error", + }, + files: ["test/**/*.ts"], + }, +) diff --git a/lint-staged.config.js b/lint-staged.config.js new file mode 100644 index 00000000..08be5a98 --- /dev/null +++ b/lint-staged.config.js @@ -0,0 +1,17 @@ +export default { + "*.css": stagedFiles => `prettier --write ${stagedFiles.join(" ")}`, + + "*.js": stagedFiles => prettierAndEslint(stagedFiles), + + "*.{ts,mts}": stagedFiles => [ + ...prettierAndEslint(stagedFiles), + `tsc --noEmit`, + ], +} + +function prettierAndEslint(fileNames) { + return [ + `prettier --write ${fileNames.join(" ")}`, + `eslint --fix ${fileNames.join(" ")}`, + ] +} diff --git a/package-lock.json b/package-lock.json index 1dc0ea96..fedfe9e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,10 +9,19 @@ "version": "0.8.31", "license": "MIT", "devDependencies": { + "@eslint/js": "^9.14.0", + "@stylistic/eslint-plugin": "^2.10.1", + "@types/eslint__js": "^8.42.3", "@types/node": "^22.1.0", + "eslint": "^9.14.0", + "globals": "^15.12.0", + "husky": "^9.1.7", "jsdom": "^25.0.1", + "lint-staged": "^15.2.10", + "prettier": "^3.3.3", "ts-node": "^10.9.2", "typescript": "^5.6.3", + "typescript-eslint": "^8.14.0", "vite": "^5.3.4", "vite-plugin-dts": "^4.3.0", "vitest": "^2.1.4" @@ -484,6 +493,259 @@ "node": ">=12" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", + "integrity": "sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.18.0.tgz", + "integrity": "sha512-fTxvnS1sRMu3+JjXwJG0j/i4RT9u4qJ+lqS/yCGap4lH4zZGzQ7tu+xZqQmcMZq5OBZDL4QRxQzRjkWcGt8IVw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.4", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/core": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.7.0.tgz", + "integrity": "sha512-xp5Jirz5DyPYlPiKat8jaq0EmYvDXKKpzTbxXMpT9eqlRJkRKIz9AGMdlvYjih+im+QlhWrpvVjl8IPC/lHlUw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.2.0.tgz", + "integrity": "sha512-grOjVNN8P3hjJn/eIETF1wwd12DdnwFDoyceUJLYYdkpbwq3nLi+4fqrTAONx7XDALqlL220wC/RHSC/QTI/0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.14.0.tgz", + "integrity": "sha512-pFoEtFWCPyDOl+C6Ift+wC7Ro89otjigCf5vcuWqWgqNSQbRrpjSvdeE6ofLz4dHmyxD5f7gIdGT4+p36L6Twg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.4.tgz", + "integrity": "sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.3.tgz", + "integrity": "sha512-2b/g5hRmpbb1o4GnTZax9N9m0FXzz9OV42ZzI4rDDMDuHUqigAiQCEWChBWCY4ztAGVRjoWT19v0yMmc5/L5kA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.1.tgz", + "integrity": "sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -621,6 +883,44 @@ "resolve": "~1.22.2" } }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/@rollup/pluginutils": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.0.tgz", @@ -1005,6 +1305,39 @@ "string-argv": "~0.3.1" } }, + "node_modules/@stylistic/eslint-plugin": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-2.10.1.tgz", + "integrity": "sha512-U+4yzNXElTf9q0kEfnloI9XbOyD4cnEQCxjUI94q0+W++0GAEQvJ/slwEj9lwjDHfGADRSr+Tco/z0XJvmDfCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^8.12.2", + "eslint-visitor-keys": "^4.2.0", + "espree": "^10.3.0", + "estraverse": "^5.3.0", + "picomatch": "^4.0.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "eslint": ">=8.40.0" + } + }, + "node_modules/@stylistic/eslint-plugin/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", @@ -1040,6 +1373,27 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint__js": { + "version": "8.42.3", + "resolved": "https://registry.npmjs.org/@types/eslint__js/-/eslint__js-8.42.3.tgz", + "integrity": "sha512-alfG737uhmPdnvkrLdZLcEKJ/B8s9Y4hrZ+YAdzUeoArBlSUERA2E87ROfOaS4jd/C45fzOoZzidLc1IPwLqOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/eslint": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -1047,6 +1401,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.1.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.1.0.tgz", @@ -1057,73 +1418,276 @@ "undici-types": "~6.13.0" } }, - "node_modules/@vitest/expect": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.4.tgz", - "integrity": "sha512-DOETT0Oh1avie/D/o2sgMHGrzYUFFo3zqESB2Hn70z6QB1HrS2IQ9z5DfyTqU8sg4Bpu13zZe9V4+UTNQlUeQA==", + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.14.0.tgz", + "integrity": "sha512-tqp8H7UWFaZj0yNO6bycd5YjMwxa6wIHOLZvWPkidwbgLCsBMetQoGj7DPuAlWa2yGO3H48xmPwjhsSPPCGU5w==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "2.1.4", - "@vitest/utils": "2.1.4", - "chai": "^5.1.2", - "tinyrainbow": "^1.2.0" + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.14.0", + "@typescript-eslint/type-utils": "8.14.0", + "@typescript-eslint/utils": "8.14.0", + "@typescript-eslint/visitor-keys": "8.14.0", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://opencollective.com/vitest" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", + "eslint": "^8.57.0 || ^9.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, - "node_modules/@vitest/mocker": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.4.tgz", - "integrity": "sha512-Ky/O1Lc0QBbutJdW0rqLeFNbuLEyS+mIPiNdlVlp2/yhJ0SbyYqObS5IHdhferJud8MbbwMnexg4jordE5cCoQ==", + "node_modules/@typescript-eslint/parser": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.14.0.tgz", + "integrity": "sha512-2p82Yn9juUJq0XynBXtFCyrBDb6/dJombnz6vbo6mgQEtWHfvHbQuEa9kAOVIt1c9YFwi7H6WxtPj1kg+80+RA==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", "dependencies": { - "@vitest/spy": "2.1.4", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.12" + "@typescript-eslint/scope-manager": "8.14.0", + "@typescript-eslint/types": "8.14.0", + "@typescript-eslint/typescript-estree": "8.14.0", + "@typescript-eslint/visitor-keys": "8.14.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://opencollective.com/vitest" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0" + "eslint": "^8.57.0 || ^9.0.0" }, "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { + "typescript": { "optional": true } } }, - "node_modules/@vitest/mocker/node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.14.0.tgz", + "integrity": "sha512-aBbBrnW9ARIDn92Zbo7rguLnqQ/pOrUguVpbUwzOhkFg2npFDwTgPGqFqE0H5feXcOoJOfX3SxlJaKEVtq54dw==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "^1.0.0" + "@typescript-eslint/types": "8.14.0", + "@typescript-eslint/visitor-keys": "8.14.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@vitest/pretty-format": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.4.tgz", - "integrity": "sha512-L95zIAkEuTDbUX1IsjRl+vyBSLh3PwLLgKpghl37aCK9Jvw0iP+wKwIFhfjdUtA2myLgjrG6VU6JCFLv8q/3Ww==", + "node_modules/@typescript-eslint/type-utils": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.14.0.tgz", + "integrity": "sha512-Xcz9qOtZuGusVOH5Uk07NGs39wrKkf3AxlkK79RBK6aJC1l03CobXjJbwBPSidetAOV+5rEVuiT1VSBUOAsanQ==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^1.2.0" + "@typescript-eslint/typescript-estree": "8.14.0", + "@typescript-eslint/utils": "8.14.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.14.0.tgz", + "integrity": "sha512-yjeB9fnO/opvLJFAsPNYlKPnEM8+z4og09Pk504dkqonT02AyL5Z9SSqlE0XqezS93v6CXn49VHvB2G7XSsl0g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.14.0.tgz", + "integrity": "sha512-OPXPLYKGZi9XS/49rdaCbR5j/S14HazviBlUQFvSKz3npr3NikF+mrgK7CFVur6XEt95DZp/cmke9d5i3vtVnQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "8.14.0", + "@typescript-eslint/visitor-keys": "8.14.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.14.0.tgz", + "integrity": "sha512-OGqj6uB8THhrHj0Fk27DcHPojW7zKwKkPmHXHvQ58pLYp4hy8CSUdTKykKeh+5vFqTTVmjz0zCOOPKRovdsgHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "8.14.0", + "@typescript-eslint/types": "8.14.0", + "@typescript-eslint/typescript-estree": "8.14.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.14.0.tgz", + "integrity": "sha512-vG0XZo8AdTH9OE6VFRwAZldNc7qtJ/6NLGWak+BtENuEUXGZgFpihILPiBvKXvJ2nFu27XNGC6rKiwuaoMbYzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.14.0", + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.4.tgz", + "integrity": "sha512-DOETT0Oh1avie/D/o2sgMHGrzYUFFo3zqESB2Hn70z6QB1HrS2IQ9z5DfyTqU8sg4Bpu13zZe9V4+UTNQlUeQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.4", + "@vitest/utils": "2.1.4", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.4.tgz", + "integrity": "sha512-Ky/O1Lc0QBbutJdW0rqLeFNbuLEyS+mIPiNdlVlp2/yhJ0SbyYqObS5IHdhferJud8MbbwMnexg4jordE5cCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.4.tgz", + "integrity": "sha512-L95zIAkEuTDbUX1IsjRl+vyBSLh3PwLLgKpghl37aCK9Jvw0iP+wKwIFhfjdUtA2myLgjrG6VU6JCFLv8q/3Ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.4.tgz", "integrity": "sha512-sKRautINI9XICAMl2bjxQM8VfCMTB0EbsBc/EDFA57V6UQevEKY/TOPOF5nzcvCALltiLfXWbq4MaAwWx/YxIA==", @@ -1270,32 +1834,6 @@ } } }, - "node_modules/@vue/language-core/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@vue/language-core/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@vue/shared": { "version": "3.5.4", "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.4.tgz", @@ -1304,9 +1842,9 @@ "license": "MIT" }, "node_modules/acorn": { - "version": "8.12.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", - "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", "dev": true, "license": "MIT", "bin": { @@ -1316,6 +1854,16 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "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", @@ -1392,6 +1940,51 @@ } } }, + "node_modules/ansi-escapes": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.0.0.tgz", + "integrity": "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -1444,6 +2037,19 @@ "concat-map": "0.0.1" } }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -1454,6 +2060,16 @@ "node": ">=8" } }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/chai": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/chai/-/chai-5.1.2.tgz", @@ -1471,6 +2087,23 @@ "node": ">=12" } }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/check-error": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", @@ -1481,6 +2114,66 @@ "node": ">= 16" } }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", + "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^5.0.0", + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -1494,6 +2187,16 @@ "node": ">= 0.8" } }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/compare-versions": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.1.tgz", @@ -1529,10 +2232,25 @@ "dev": true, "license": "MIT" }, - "node_modules/cssstyle": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.1.0.tgz", - "integrity": "sha512-h66W1URKpBS5YMI/V8PyXvTMFT8SupJ1IzoIV8IeBC/ji8WVmrO8dGlTi+2dh6whmdk6BiKJLD/ZBkhWbcg6nA==", + "node_modules/cross-spawn": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.5.tgz", + "integrity": "sha512-ZVJrKKYunU38/76t0RMOulHOnUcbU9GbpWKAOZ0mhjr7CX6FVrH+4FrAapSOekrgFQ3f/8gwMEuIft0aKq6Hug==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssstyle": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.1.0.tgz", + "integrity": "sha512-h66W1URKpBS5YMI/V8PyXvTMFT8SupJ1IzoIV8IeBC/ji8WVmrO8dGlTi+2dh6whmdk6BiKJLD/ZBkhWbcg6nA==", "dev": true, "license": "MIT", "dependencies": { @@ -1598,6 +2316,13 @@ "node": ">=6" } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -1618,6 +2343,13 @@ "node": ">=0.3.1" } }, + "node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "dev": true, + "license": "MIT" + }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -1631,6 +2363,19 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -1670,6 +2415,201 @@ "@esbuild/win32-x64": "0.21.5" } }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.14.0.tgz", + "integrity": "sha512-c2FHsVBr87lnUtjP4Yhvk4yEhKrQavGafRA/Se1ouse8PfbfC/Qh9Mxa00yWsZRlqeUB9raXip0aiiUZkgnr9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.18.0", + "@eslint/core": "^0.7.0", + "@eslint/eslintrc": "^3.1.0", + "@eslint/js": "9.14.0", + "@eslint/plugin-kit": "^0.2.0", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.0", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.2.0", + "eslint-visitor-keys": "^4.2.0", + "espree": "^10.3.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.2.0.tgz", + "integrity": "sha512-PHlWUfG6lvPc3yvP5A4PNyBL1W8fkDUccmI21JUu/+GKZBoH/W5u6usENXUrWFRsyoW5ACUjFGgAFQp5gUlb/A==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", + "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.14.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, "node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", @@ -1677,6 +2617,47 @@ "dev": true, "license": "MIT" }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "dev": true, + "license": "MIT" + }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, "node_modules/expect-type": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.1.0.tgz", @@ -1694,6 +2675,124 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-glob": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", + "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", + "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", + "dev": true, + "license": "ISC" + }, "node_modules/form-data": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", @@ -1759,6 +2858,58 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-east-asian-width": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz", + "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "15.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.12.0.tgz", + "integrity": "sha512-1+gLErljJFhbOVyaetcwJiJ4+eLe45S2E7P5UiZ9xGfeq3ATQf5DOv9G7MH3gGbKQLkzmNh2DxfZwLdw+j6oTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -1766,6 +2917,13 @@ "dev": true, "license": "ISC" }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -1840,19 +2998,72 @@ "node": ">= 14" } }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" } }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/import-lazy": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-4.0.0.tgz", @@ -1863,6 +3074,16 @@ "node": ">=8" } }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/is-core-module": { "version": "2.15.0", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.0.tgz", @@ -1879,6 +3100,52 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, "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", @@ -1886,6 +3153,26 @@ "dev": true, "license": "MIT" }, + "node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, "node_modules/jju": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz", @@ -1893,6 +3180,26 @@ "dev": true, "license": "MIT" }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/js-yaml/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, "node_modules/jsdom": { "version": "25.0.1", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", @@ -1934,6 +3241,13 @@ } } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -1941,6 +3255,13 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/jsonfile": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", @@ -1951,6 +3272,16 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, "node_modules/kolorist": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz", @@ -1958,6 +3289,92 @@ "dev": true, "license": "MIT" }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.2.tgz", + "integrity": "sha512-eop+wDAvpItUys0FWkHIKeC9ybYrTGbU41U5K7+bttZZeohvnY7M9dZ5kB21GNWiFT2q1OoPTvncPCgSOVO5ow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lint-staged": { + "version": "15.2.10", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.2.10.tgz", + "integrity": "sha512-5dY5t743e1byO19P9I4b3x8HJwalIznL5E1FWYnU6OWw33KxNBSLAc6Cy7F2PsFEO8FKnLwjwm5hx7aMF0jzZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "~5.3.0", + "commander": "~12.1.0", + "debug": "~4.3.6", + "execa": "~8.0.1", + "lilconfig": "~3.1.2", + "listr2": "~8.2.4", + "micromatch": "~4.0.8", + "pidtree": "~0.6.0", + "string-argv": "~0.3.2", + "yaml": "~2.5.0" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" + } + }, + "node_modules/lint-staged/node_modules/chalk": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", + "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/listr2": { + "version": "8.2.5", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.2.5.tgz", + "integrity": "sha512-iyAZCeyD+c1gPyE9qpFu8af0Y+MRtmKOncdGoA2S5EY8iFq99dmmvkNnHiWo+pj0s7yH7l3KPIgee77tKpXPWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^4.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/local-pkg": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.0.tgz", @@ -1975,6 +3392,22 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", @@ -1982,6 +3415,79 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/log-update/node_modules/is-fullwidth-code-point": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.0.0.tgz", + "integrity": "sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.0.tgz", + "integrity": "sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, "node_modules/loupe": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.2.tgz", @@ -2006,6 +3512,37 @@ "dev": true, "license": "ISC" }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -2029,6 +3566,58 @@ "node": ">= 0.6" } }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimatch/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/mlly": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.1.tgz", @@ -2075,6 +3664,42 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/nwsapi": { "version": "2.2.13", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.13.tgz", @@ -2082,6 +3707,85 @@ "dev": true, "license": "MIT" }, + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/parse5": { "version": "7.2.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.2.1.tgz", @@ -2102,6 +3806,26 @@ "dev": true, "license": "MIT" }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -2146,6 +3870,19 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pidtree": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", + "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", + "dev": true, + "license": "MIT", + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/pkg-types": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.2.0.tgz", @@ -2184,7 +3921,33 @@ "source-map-js": "^1.2.1" }, "engines": { - "node": "^10 || ^12 || >=14" + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", + "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" } }, "node_modules/punycode": { @@ -2197,6 +3960,27 @@ "node": ">=6" } }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -2225,6 +4009,67 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, "node_modules/rollup": { "version": "4.24.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.24.0.tgz", @@ -2268,6 +4113,30 @@ "dev": true, "license": "MIT" }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -2288,6 +4157,42 @@ "node": ">=v12.22.7" } }, + "node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -2295,6 +4200,49 @@ "dev": true, "license": "ISC" }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slice-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", + "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -2346,6 +4294,53 @@ "node": ">=0.6.19" } }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -2359,6 +4354,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -2379,6 +4387,13 @@ "dev": true, "license": "MIT" }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -2453,6 +4468,19 @@ "node": ">=4" } }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/tough-cookie": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.0.0.tgz", @@ -2479,6 +4507,19 @@ "node": ">=18" } }, + "node_modules/ts-api-utils": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.0.tgz", + "integrity": "sha512-032cPxaEKwM+GT3vA5JXNzIaizx388rhsSW79vGRNGXfRRAdEAn2mvk36PvK5HnOchyWZ7afLEXqYCvPCrzuzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, "node_modules/ts-node": { "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", @@ -2523,6 +4564,19 @@ } } }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/typescript": { "version": "5.6.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", @@ -2537,6 +4591,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.14.0.tgz", + "integrity": "sha512-K8fBJHxVL3kxMmwByvz8hNdBJ8a0YqKzKDX6jRlrjMuNXyd5T2V02HIq37+OiWXvUUOXgOOGiSSOh26Mh8pC3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.14.0", + "@typescript-eslint/parser": "8.14.0", + "@typescript-eslint/utils": "8.14.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/ufo": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.5.4.tgz", @@ -2813,6 +4891,22 @@ "node": ">=18" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -2830,6 +4924,47 @@ "node": ">=8" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", + "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/ws": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", @@ -2869,6 +5004,19 @@ "dev": true, "license": "MIT" }, + "node_modules/yaml": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.1.tgz", + "integrity": "sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", @@ -2878,6 +5026,19 @@ "engines": { "node": ">=6" } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index 3426f7c6..e2da5c97 100755 --- a/package.json +++ b/package.json @@ -41,10 +41,19 @@ }, "homepage": "https://github.com/Comfy-Org/litegraph.js", "devDependencies": { + "@eslint/js": "^9.14.0", + "@stylistic/eslint-plugin": "^2.10.1", + "@types/eslint__js": "^8.42.3", "@types/node": "^22.1.0", + "eslint": "^9.14.0", + "globals": "^15.12.0", + "husky": "^9.1.7", "jsdom": "^25.0.1", + "lint-staged": "^15.2.10", + "prettier": "^3.3.3", "ts-node": "^10.9.2", "typescript": "^5.6.3", + "typescript-eslint": "^8.14.0", "vite": "^5.3.4", "vite-plugin-dts": "^4.3.0", "vitest": "^2.1.4" diff --git a/public/css/litegraph.css b/public/css/litegraph.css index 5524e24b..ebaacaaa 100644 --- a/public/css/litegraph.css +++ b/public/css/litegraph.css @@ -1,693 +1,699 @@ /* this CSS contains only the basic CSS needed to run the app and use it */ .lgraphcanvas { - /*cursor: crosshair;*/ - user-select: none; - -moz-user-select: none; - -webkit-user-select: none; - outline: none; - font-family: Tahoma, sans-serif; + /*cursor: crosshair;*/ + user-select: none; + -moz-user-select: none; + -webkit-user-select: none; + outline: none; + font-family: Tahoma, sans-serif; } .lgraphcanvas * { - box-sizing: border-box; + box-sizing: border-box; } .litegraph.litecontextmenu { - font-family: Tahoma, sans-serif; - position: fixed; - top: 100px; - left: 100px; - min-width: 100px; - color: #aaf; - padding: 0; - box-shadow: 0 0 10px black !important; - background-color: #2e2e2e !important; - z-index: 10; + font-family: Tahoma, sans-serif; + position: fixed; + top: 100px; + left: 100px; + min-width: 100px; + color: #aaf; + padding: 0; + box-shadow: 0 0 10px black !important; + background-color: #2e2e2e !important; + z-index: 10; } .litegraph.litecontextmenu.dark { - background-color: #000 !important; + background-color: #000 !important; } .litegraph.litecontextmenu .litemenu-title img { - margin-top: 2px; - margin-left: 2px; - margin-right: 4px; + margin-top: 2px; + margin-left: 2px; + margin-right: 4px; } .litegraph.litecontextmenu .litemenu-entry { - margin: 2px; - padding: 2px; + margin: 2px; + padding: 2px; } .litegraph.litecontextmenu .litemenu-entry.submenu { - background-color: #2e2e2e !important; + background-color: #2e2e2e !important; } .litegraph.litecontextmenu.dark .litemenu-entry.submenu { - background-color: #000 !important; + background-color: #000 !important; } .litegraph .litemenubar ul { - font-family: Tahoma, sans-serif; - margin: 0; - padding: 0; + font-family: Tahoma, sans-serif; + margin: 0; + padding: 0; } .litegraph .litemenubar li { - font-size: 14px; - color: #999; - display: inline-block; - min-width: 50px; - padding-left: 10px; - padding-right: 10px; - user-select: none; - -moz-user-select: none; - -webkit-user-select: none; - cursor: pointer; + font-size: 14px; + color: #999; + display: inline-block; + min-width: 50px; + padding-left: 10px; + padding-right: 10px; + user-select: none; + -moz-user-select: none; + -webkit-user-select: none; + cursor: pointer; } .litegraph .litemenubar li:hover { - background-color: #777; - color: #eee; + background-color: #777; + color: #eee; } .litegraph .litegraph .litemenubar-panel { - position: absolute; - top: 5px; - left: 5px; - min-width: 100px; - background-color: #444; - box-shadow: 0 0 3px black; - padding: 4px; - border-bottom: 2px solid #aaf; - z-index: 10; + position: absolute; + top: 5px; + left: 5px; + min-width: 100px; + background-color: #444; + box-shadow: 0 0 3px black; + padding: 4px; + border-bottom: 2px solid #aaf; + z-index: 10; } .litegraph .litemenu-entry, .litemenu-title { - font-size: 12px; - color: #aaa; - padding: 0 0 0 4px; - margin: 2px; - padding-left: 2px; - -moz-user-select: none; - -webkit-user-select: none; - user-select: none; - cursor: pointer; + font-size: 12px; + color: #aaa; + padding: 0 0 0 4px; + margin: 2px; + padding-left: 2px; + -moz-user-select: none; + -webkit-user-select: none; + user-select: none; + cursor: pointer; } .litegraph .litemenu-entry .icon { - display: inline-block; - width: 12px; - height: 12px; - margin: 2px; - vertical-align: top; + display: inline-block; + width: 12px; + height: 12px; + margin: 2px; + vertical-align: top; } .litegraph .litemenu-entry.checked .icon { - background-color: #aaf; + background-color: #aaf; } .litegraph .litemenu-entry .more { - float: right; - padding-right: 5px; + float: right; + padding-right: 5px; } .litegraph .litemenu-entry.disabled { - opacity: 0.5; - cursor: default; + opacity: 0.5; + cursor: default; } .litegraph .litemenu-entry.separator { - display: block; - border-top: 1px solid #333; - border-bottom: 1px solid #666; - width: 100%; - height: 0px; - margin: 3px 0 2px 0; - background-color: transparent; - padding: 0 !important; - cursor: default !important; + display: block; + border-top: 1px solid #333; + border-bottom: 1px solid #666; + width: 100%; + height: 0px; + margin: 3px 0 2px 0; + background-color: transparent; + padding: 0 !important; + cursor: default !important; } .litegraph .litemenu-entry.has_submenu { - border-right: 2px solid cyan; + border-right: 2px solid cyan; } .litegraph .litemenu-title { - color: #dde; - background-color: #111; - margin: 0; - padding: 2px; - cursor: default; + color: #dde; + background-color: #111; + margin: 0; + padding: 2px; + cursor: default; } .litegraph .litemenu-entry:hover:not(.disabled):not(.separator) { - background-color: #444 !important; - color: #eee; - transition: all 0.2s; + background-color: #444 !important; + color: #eee; + transition: all 0.2s; } .litegraph .litemenu-entry .property_name { - display: inline-block; - text-align: left; - min-width: 80px; - min-height: 1.2em; + display: inline-block; + text-align: left; + min-width: 80px; + min-height: 1.2em; } .litegraph .litemenu-entry .property_value { - display: inline-block; - background-color: rgba(0, 0, 0, 0.5); - text-align: right; - min-width: 80px; - min-height: 1.2em; - vertical-align: middle; - padding-right: 10px; + display: inline-block; + background-color: rgba(0, 0, 0, 0.5); + text-align: right; + min-width: 80px; + min-height: 1.2em; + vertical-align: middle; + padding-right: 10px; } .litegraph.litesearchbox { - font-family: Tahoma, sans-serif; - position: absolute; - background-color: rgba(0, 0, 0, 0.5); - padding-top: 4px; + font-family: Tahoma, sans-serif; + position: absolute; + background-color: rgba(0, 0, 0, 0.5); + padding-top: 4px; } .litegraph.litesearchbox input, .litegraph.litesearchbox select { - margin-top: 3px; - min-width: 60px; - min-height: 1.5em; - background-color: black; - border: 0; - color: white; - padding-left: 10px; - margin-right: 5px; - max-width: 300px; + margin-top: 3px; + min-width: 60px; + min-height: 1.5em; + background-color: black; + border: 0; + color: white; + padding-left: 10px; + margin-right: 5px; + max-width: 300px; } .litegraph.litesearchbox .name { - display: inline-block; - min-width: 60px; - min-height: 1.5em; - padding-left: 10px; + display: inline-block; + min-width: 60px; + min-height: 1.5em; + padding-left: 10px; } .litegraph.litesearchbox .helper { - overflow: auto; - max-height: 200px; - margin-top: 2px; + overflow: auto; + max-height: 200px; + margin-top: 2px; } .litegraph.lite-search-item { - font-family: Tahoma, sans-serif; - background-color: rgba(0, 0, 0, 0.5); - color: white; - padding-top: 2px; + font-family: Tahoma, sans-serif; + background-color: rgba(0, 0, 0, 0.5); + color: white; + padding-top: 2px; } -.litegraph.lite-search-item.not_in_filter{ - /*background-color: rgba(50, 50, 50, 0.5);*/ - /*color: #999;*/ - color: #B99; - font-style: italic; +.litegraph.lite-search-item.not_in_filter { + /*background-color: rgba(50, 50, 50, 0.5);*/ + /*color: #999;*/ + color: #b99; + font-style: italic; } -.litegraph.lite-search-item.generic_type{ - /*background-color: rgba(50, 50, 50, 0.5);*/ - /*color: #DD9;*/ - color: #999; - font-style: italic; +.litegraph.lite-search-item.generic_type { + /*background-color: rgba(50, 50, 50, 0.5);*/ + /*color: #DD9;*/ + color: #999; + font-style: italic; } .litegraph.lite-search-item:hover, .litegraph.lite-search-item.selected { - cursor: pointer; - background-color: white; - color: black; + cursor: pointer; + background-color: white; + color: black; } .litegraph.lite-search-item-type { - display: inline-block; - background: rgba(0,0,0,0.2); - margin-left: 5px; - font-size: 14px; - padding: 2px 5px; - position: relative; - top: -2px; - opacity: 0.8; - border-radius: 4px; - } + display: inline-block; + background: rgba(0, 0, 0, 0.2); + margin-left: 5px; + font-size: 14px; + padding: 2px 5px; + position: relative; + top: -2px; + opacity: 0.8; + border-radius: 4px; +} /* DIALOGs ******/ .litegraph .dialog { - position: absolute; - top: 50%; - left: 50%; - margin-top: -150px; - margin-left: -200px; + position: absolute; + top: 50%; + left: 50%; + margin-top: -150px; + margin-left: -200px; - background-color: #2A2A2A; + background-color: #2a2a2a; - min-width: 400px; - min-height: 200px; - box-shadow: 0 0 4px #111; - border-radius: 6px; + min-width: 400px; + min-height: 200px; + box-shadow: 0 0 4px #111; + border-radius: 6px; } .litegraph .dialog.settings { - left: 10px; - top: 10px; - height: calc( 100% - 20px ); - margin: auto; - max-width: 50%; + left: 10px; + top: 10px; + height: calc(100% - 20px); + margin: auto; + max-width: 50%; } .litegraph .dialog.centered { - top: 50px; - left: 50%; - position: absolute; - transform: translateX(-50%); - min-width: 600px; - min-height: 300px; - height: calc( 100% - 100px ); - margin: auto; + top: 50px; + left: 50%; + position: absolute; + transform: translateX(-50%); + min-width: 600px; + min-height: 300px; + height: calc(100% - 100px); + margin: auto; } .litegraph .dialog .close { - float: right; - margin: 4px; - margin-right: 10px; - cursor: pointer; - font-size: 1.4em; + float: right; + margin: 4px; + margin-right: 10px; + cursor: pointer; + font-size: 1.4em; } .litegraph .dialog .close:hover { - color: white; + color: white; } .litegraph .dialog .dialog-header { - color: #AAA; - border-bottom: 1px solid #161616; + color: #aaa; + border-bottom: 1px solid #161616; } -.litegraph .dialog .dialog-header { height: 40px; } -.litegraph .dialog .dialog-footer { height: 50px; padding: 10px; border-top: 1px solid #1a1a1a;} +.litegraph .dialog .dialog-header { + height: 40px; +} +.litegraph .dialog .dialog-footer { + height: 50px; + padding: 10px; + border-top: 1px solid #1a1a1a; +} .litegraph .dialog .dialog-header .dialog-title { - font: 20px "Arial"; - margin: 4px; - padding: 4px 10px; - display: inline-block; + font: 20px "Arial"; + margin: 4px; + padding: 4px 10px; + display: inline-block; } -.litegraph .dialog .dialog-content, .litegraph .dialog .dialog-alt-content { - height: calc(100% - 90px); - width: 100%; - min-height: 100px; - display: inline-block; - color: #AAA; - /*background-color: black;*/ - overflow: auto; +.litegraph .dialog .dialog-content, +.litegraph .dialog .dialog-alt-content { + height: calc(100% - 90px); + width: 100%; + min-height: 100px; + display: inline-block; + color: #aaa; + /*background-color: black;*/ + overflow: auto; } .litegraph .dialog .dialog-content h3 { - margin: 10px; + margin: 10px; } .litegraph .dialog .dialog-content .connections { - flex-direction: row; + flex-direction: row; } .litegraph .dialog .dialog-content .connections .connections_side { - width: calc(50% - 5px); - min-height: 100px; - background-color: black; - display: flex; + width: calc(50% - 5px); + min-height: 100px; + background-color: black; + display: flex; } .litegraph .dialog .node_type { - font-size: 1.2em; - display: block; - margin: 10px; + font-size: 1.2em; + display: block; + margin: 10px; } .litegraph .dialog .node_desc { - opacity: 0.5; - display: block; - margin: 10px; + opacity: 0.5; + display: block; + margin: 10px; } .litegraph .dialog .separator { - display: block; - width: calc( 100% - 4px ); - height: 1px; - border-top: 1px solid #000; - border-bottom: 1px solid #333; - margin: 10px 2px; - padding: 0; + display: block; + width: calc(100% - 4px); + height: 1px; + border-top: 1px solid #000; + border-bottom: 1px solid #333; + margin: 10px 2px; + padding: 0; } .litegraph .dialog .property { - margin-bottom: 2px; - padding: 4px; + margin-bottom: 2px; + padding: 4px; } .litegraph .dialog .property:hover { - background: #545454; + background: #545454; } .litegraph .dialog .property_name { - color: #737373; - display: inline-block; - text-align: left; - vertical-align: top; - width: 160px; - padding-left: 4px; - overflow: hidden; - margin-right: 6px; + color: #737373; + display: inline-block; + text-align: left; + vertical-align: top; + width: 160px; + padding-left: 4px; + overflow: hidden; + margin-right: 6px; } .litegraph .dialog .property:hover .property_name { - color: white; + color: white; } .litegraph .dialog .property_value { - display: inline-block; - text-align: right; - color: #AAA; - background-color: #1A1A1A; - /*width: calc( 100% - 122px );*/ - max-width: calc( 100% - 162px ); - min-width: 200px; - max-height: 300px; - min-height: 20px; - padding: 4px; - padding-right: 12px; - overflow: hidden; - cursor: pointer; - border-radius: 3px; + display: inline-block; + text-align: right; + color: #aaa; + background-color: #1a1a1a; + /*width: calc( 100% - 122px );*/ + max-width: calc(100% - 162px); + min-width: 200px; + max-height: 300px; + min-height: 20px; + padding: 4px; + padding-right: 12px; + overflow: hidden; + cursor: pointer; + border-radius: 3px; } .litegraph .dialog .property_value:hover { - color: white; + color: white; } .litegraph .dialog .property.boolean .property_value { - padding-right: 30px; - color: #A88; - /*width: auto; + padding-right: 30px; + color: #a88; + /*width: auto; float: right;*/ } -.litegraph .dialog .property.boolean.bool-on .property_name{ - color: #8A8; +.litegraph .dialog .property.boolean.bool-on .property_name { + color: #8a8; } -.litegraph .dialog .property.boolean.bool-on .property_value{ - color: #8A8; +.litegraph .dialog .property.boolean.bool-on .property_value { + color: #8a8; } .litegraph .dialog .btn { - border: 0; - border-radius: 4px; - padding: 4px 20px; - margin-left: 0px; - background-color: #060606; - color: #8e8e8e; + border: 0; + border-radius: 4px; + padding: 4px 20px; + margin-left: 0px; + background-color: #060606; + color: #8e8e8e; } .litegraph .dialog .btn:hover { - background-color: #111; - color: #FFF; + background-color: #111; + color: #fff; } .litegraph .dialog .btn.delete:hover { - background-color: #F33; - color: black; + background-color: #f33; + color: black; } .litegraph .subgraph_property { - padding: 4px; + padding: 4px; } .litegraph .subgraph_property:hover { - background-color: #333; + background-color: #333; } .litegraph .subgraph_property.extra { - margin-top: 8px; + margin-top: 8px; } .litegraph .subgraph_property span.name { - font-size: 1.3em; - padding-left: 4px; + font-size: 1.3em; + padding-left: 4px; } .litegraph .subgraph_property span.type { - opacity: 0.5; - margin-right: 20px; - padding-left: 4px; + opacity: 0.5; + margin-right: 20px; + padding-left: 4px; } .litegraph .subgraph_property span.label { - display: inline-block; - width: 60px; - padding: 0px 10px; + display: inline-block; + width: 60px; + padding: 0px 10px; } .litegraph .subgraph_property input { - width: 140px; - color: #999; - background-color: #1A1A1A; - border-radius: 4px; - border: 0; - margin-right: 10px; - padding: 4px; - padding-left: 10px; + width: 140px; + color: #999; + background-color: #1a1a1a; + border-radius: 4px; + border: 0; + margin-right: 10px; + padding: 4px; + padding-left: 10px; } .litegraph .subgraph_property button { - background-color: #1c1c1c; - color: #aaa; - border: 0; - border-radius: 2px; - padding: 4px 10px; - cursor: pointer; + background-color: #1c1c1c; + color: #aaa; + border: 0; + border-radius: 2px; + padding: 4px 10px; + cursor: pointer; } .litegraph .subgraph_property.extra { - color: #ccc; + color: #ccc; } .litegraph .subgraph_property.extra input { - background-color: #111; + background-color: #111; } .litegraph .bullet_icon { - margin-left: 10px; - border-radius: 10px; - width: 12px; - height: 12px; - background-color: #666; - display: inline-block; - margin-top: 2px; - margin-right: 4px; - transition: background-color 0.1s ease 0s; - -moz-transition: background-color 0.1s ease 0s; + margin-left: 10px; + border-radius: 10px; + width: 12px; + height: 12px; + background-color: #666; + display: inline-block; + margin-top: 2px; + margin-right: 4px; + transition: background-color 0.1s ease 0s; + -moz-transition: background-color 0.1s ease 0s; } .litegraph .bullet_icon:hover { - background-color: #698; - cursor: pointer; -} + background-color: #698; + cursor: pointer; +} /* OLD */ .graphcontextmenu { - padding: 4px; - min-width: 100px; + padding: 4px; + min-width: 100px; } .graphcontextmenu-title { - color: #dde; - background-color: #222; - margin: 0; - padding: 2px; - cursor: default; + color: #dde; + background-color: #222; + margin: 0; + padding: 2px; + cursor: default; } .graphmenu-entry { - box-sizing: border-box; - margin: 2px; - padding-left: 20px; - user-select: none; - -moz-user-select: none; - -webkit-user-select: none; - transition: all linear 0.3s; + box-sizing: border-box; + margin: 2px; + padding-left: 20px; + user-select: none; + -moz-user-select: none; + -webkit-user-select: none; + transition: all linear 0.3s; } .graphmenu-entry.event, .litemenu-entry.event { - border-left: 8px solid orange; - padding-left: 12px; + border-left: 8px solid orange; + padding-left: 12px; } .graphmenu-entry.disabled { - opacity: 0.3; + opacity: 0.3; } .graphmenu-entry.submenu { - border-right: 2px solid #eee; + border-right: 2px solid #eee; } .graphmenu-entry:hover { - background-color: #555; + background-color: #555; } .graphmenu-entry.separator { - background-color: #111; - border-bottom: 1px solid #666; - height: 1px; - width: calc(100% - 20px); - -moz-width: calc(100% - 20px); - -webkit-width: calc(100% - 20px); + background-color: #111; + border-bottom: 1px solid #666; + height: 1px; + width: calc(100% - 20px); + -moz-width: calc(100% - 20px); + -webkit-width: calc(100% - 20px); } .graphmenu-entry .property_name { - display: inline-block; - text-align: left; - min-width: 80px; - min-height: 1.2em; + display: inline-block; + text-align: left; + min-width: 80px; + min-height: 1.2em; } .graphmenu-entry .property_value, .litemenu-entry .property_value { - display: inline-block; - background-color: rgba(0, 0, 0, 0.5); - text-align: right; - min-width: 80px; - min-height: 1.2em; - vertical-align: middle; - padding-right: 10px; + display: inline-block; + background-color: rgba(0, 0, 0, 0.5); + text-align: right; + min-width: 80px; + min-height: 1.2em; + vertical-align: middle; + padding-right: 10px; } .graphdialog { - position: absolute; - top: 10px; - left: 10px; - min-height: 2em; - background-color: #333; - font-size: 1.2em; - box-shadow: 0 0 10px black !important; - z-index: 10; + position: absolute; + top: 10px; + left: 10px; + min-height: 2em; + background-color: #333; + font-size: 1.2em; + box-shadow: 0 0 10px black !important; + z-index: 10; } .graphdialog.rounded { - border-radius: 12px; - padding-right: 2px; + border-radius: 12px; + padding-right: 2px; } .graphdialog .name { - display: inline-block; - min-width: 60px; - min-height: 1.5em; - padding-left: 10px; + display: inline-block; + min-width: 60px; + min-height: 1.5em; + padding-left: 10px; } .graphdialog input, .graphdialog textarea, .graphdialog select { - margin: 3px; - min-width: 60px; - min-height: 1.5em; - background-color: black; - border: 0; - color: white; - padding-left: 10px; - outline: none; + margin: 3px; + min-width: 60px; + min-height: 1.5em; + background-color: black; + border: 0; + color: white; + padding-left: 10px; + outline: none; } .graphdialog textarea { - min-height: 150px; + min-height: 150px; } .graphdialog button { - margin-top: 3px; - vertical-align: top; - background-color: #999; - border: 0; + margin-top: 3px; + vertical-align: top; + background-color: #999; + border: 0; } .graphdialog button.rounded, .graphdialog input.rounded { - border-radius: 0 12px 12px 0; + border-radius: 0 12px 12px 0; } .graphdialog .helper { - overflow: auto; - max-height: 200px; + overflow: auto; + max-height: 200px; } .graphdialog .help-item { - padding-left: 10px; + padding-left: 10px; } .graphdialog .help-item:hover, .graphdialog .help-item.selected { - cursor: pointer; - background-color: white; - color: black; + cursor: pointer; + background-color: white; + color: black; } .litegraph .dialog { - min-height: 0; + min-height: 0; } .litegraph .dialog .dialog-content { -display: block; + display: block; } .litegraph .dialog .dialog-content .subgraph_property { -padding: 5px; + padding: 5px; } .litegraph .dialog .dialog-footer { -margin: 0; + margin: 0; } .litegraph .dialog .dialog-footer .subgraph_property { -margin-top: 0; -display: flex; -align-items: center; -padding: 5px; + margin-top: 0; + display: flex; + align-items: center; + padding: 5px; } .litegraph .dialog .dialog-footer .subgraph_property .name { -flex: 1; + flex: 1; } .litegraph .graphdialog { -display: flex; -align-items: center; -border-radius: 20px; -padding: 4px 10px; -position: fixed; + display: flex; + align-items: center; + border-radius: 20px; + padding: 4px 10px; + position: fixed; } .litegraph .graphdialog .name { -padding: 0; -min-height: 0; -font-size: 16px; -vertical-align: middle; + padding: 0; + min-height: 0; + font-size: 16px; + vertical-align: middle; } .litegraph .graphdialog .value { -font-size: 16px; -min-height: 0; -margin: 0 10px; -padding: 2px 5px; + font-size: 16px; + min-height: 0; + margin: 0 10px; + padding: 2px 5px; } .litegraph .graphdialog input[type="checkbox"] { -width: 16px; -height: 16px; + width: 16px; + height: 16px; } .litegraph .graphdialog button { -padding: 4px 18px; -border-radius: 20px; -cursor: pointer; + padding: 4px 18px; + border-radius: 20px; + cursor: pointer; } - diff --git a/src/CanvasPointer.ts b/src/CanvasPointer.ts index d1aa0c97..eaec9219 100644 --- a/src/CanvasPointer.ts +++ b/src/CanvasPointer.ts @@ -29,10 +29,12 @@ export class CanvasPointer { static get maxClickDrift() { return this.#maxClickDrift } + static set maxClickDrift(value) { this.#maxClickDrift = value this.#maxClickDrift2 = value * value } + static #maxClickDrift = 6 /** {@link maxClickDrift} squared. Used to calculate click drift without `sqrt`. */ static #maxClickDrift2 = this.#maxClickDrift ** 2 @@ -107,6 +109,7 @@ export class CanvasPointer { get finally() { return this.#finally } + set finally(value) { try { this.#finally?.() @@ -114,6 +117,7 @@ export class CanvasPointer { this.#finally = value } } + #finally?: () => unknown constructor(element: Element) { diff --git a/src/ContextMenu.ts b/src/ContextMenu.ts index 39bc1e0d..65603303 100644 --- a/src/ContextMenu.ts +++ b/src/ContextMenu.ts @@ -2,13 +2,13 @@ import type { IContextMenuOptions, IContextMenuValue } from "./interfaces" import { LiteGraph } from "./litegraph" interface ContextMenuDivElement extends HTMLDivElement { - value?: IContextMenuValue | string - onclick_callback?: never - closing_timer?: number + value?: IContextMenuValue | string + onclick_callback?: never + closing_timer?: number } export interface ContextMenu { - constructor: new (...args: ConstructorParameters) => ContextMenu + constructor: new (...args: ConstructorParameters) => ContextMenu } /** @@ -24,354 +24,379 @@ export interface ContextMenu { * - event: you can pass a MouseEvent, this way the ContextMenu appears in that position */ export class ContextMenu { - options?: IContextMenuOptions - parentMenu?: ContextMenu - root: ContextMenuDivElement - current_submenu?: ContextMenu - lock?: boolean - - // TODO: Interface for values requires functionality change - currently accepts an array of strings, functions, objects, nulls, or undefined. - constructor(values: (IContextMenuValue | string)[], options: IContextMenuOptions) { - options ||= {} - this.options = options - - //to link a menu with its parent - const parent = options.parentMenu - if (parent) { - if (!(parent instanceof ContextMenu)) { - console.error("parentMenu must be of class ContextMenu, ignoring it") - options.parentMenu = null - } else { - this.parentMenu = parent - this.parentMenu.lock = true - this.parentMenu.current_submenu = this - } - if (parent.options?.className === "dark") { - options.className = "dark" - } - } + options?: IContextMenuOptions + parentMenu?: ContextMenu + root: ContextMenuDivElement + current_submenu?: ContextMenu + lock?: boolean + + // TODO: Interface for values requires functionality change - currently accepts + // an array of strings, functions, objects, nulls, or undefined. + constructor(values: (IContextMenuValue | string)[], options: IContextMenuOptions) { + options ||= {} + this.options = options + + // to link a menu with its parent + const parent = options.parentMenu + if (parent) { + if (!(parent instanceof ContextMenu)) { + console.error("parentMenu must be of class ContextMenu, ignoring it") + options.parentMenu = null + } else { + this.parentMenu = parent + this.parentMenu.lock = true + this.parentMenu.current_submenu = this + } + if (parent.options?.className === "dark") { + options.className = "dark" + } + } - //use strings because comparing classes between windows doesnt work - const eventClass = options.event - ? options.event.constructor.name - : null - if (eventClass !== "MouseEvent" && - eventClass !== "CustomEvent" && - eventClass !== "PointerEvent") { - console.error(`Event passed to ContextMenu is not of type MouseEvent or CustomEvent. Ignoring it. (${eventClass})`) - options.event = null + // use strings because comparing classes between windows doesnt work + const eventClass = options.event + ? options.event.constructor.name + : null + if ( + eventClass !== "MouseEvent" && + eventClass !== "CustomEvent" && + eventClass !== "PointerEvent" + ) { + console.error(`Event passed to ContextMenu is not of type MouseEvent or CustomEvent. Ignoring it. (${eventClass})`) + options.event = null + } + + const root: ContextMenuDivElement = document.createElement("div") + let classes = "litegraph litecontextmenu litemenubar-panel" + if (options.className) classes += " " + options.className + root.className = classes + root.style.minWidth = "100" + root.style.minHeight = "100" + // TODO: Fix use of timer in place of events + root.style.pointerEvents = "none" + setTimeout(function () { + root.style.pointerEvents = "auto" + }, 100) // delay so the mouse up event is not caught by this element + + // this prevents the default context browser menu to open in case this menu was created when pressing right button + LiteGraph.pointerListenerAdd( + root, + "up", + function (e: MouseEvent) { + // console.log("pointerevents: ContextMenu up root prevent"); + e.preventDefault() + return true + }, + true, + ) + root.addEventListener( + "contextmenu", + function (e: MouseEvent) { + // right button + if (e.button != 2) return false + e.preventDefault() + return false + }, + true, + ) + + LiteGraph.pointerListenerAdd( + root, + "down", + (e: MouseEvent) => { + // console.log("pointerevents: ContextMenu down"); + if (e.button == 2) { + this.close() + e.preventDefault() + return true } + }, + true, + ) + + function on_mouse_wheel(e: WheelEvent) { + const pos = parseInt(root.style.top) + root.style.top = (pos + e.deltaY * options.scroll_speed).toFixed() + "px" + e.preventDefault() + return true + } - const root: ContextMenuDivElement = document.createElement("div") - let classes = "litegraph litecontextmenu litemenubar-panel" - if (options.className) classes += " " + options.className - root.className = classes - root.style.minWidth = "100" - root.style.minHeight = "100" - // TODO: Fix use of timer in place of events - root.style.pointerEvents = "none" - setTimeout(function () { - root.style.pointerEvents = "auto" - }, 100) //delay so the mouse up event is not caught by this element - - //this prevents the default context browser menu to open in case this menu was created when pressing right button - LiteGraph.pointerListenerAdd(root, "up", - function (e: MouseEvent) { - //console.log("pointerevents: ContextMenu up root prevent"); - e.preventDefault() - return true - }, - true - ) - root.addEventListener( - "contextmenu", - function (e: MouseEvent) { - //right button - if (e.button != 2) return false - e.preventDefault() - return false - }, - true - ) + if (!options.scroll_speed) { + options.scroll_speed = 0.1 + } - LiteGraph.pointerListenerAdd(root, "down", - (e: MouseEvent) => { - //console.log("pointerevents: ContextMenu down"); - if (e.button == 2) { - this.close() - e.preventDefault() - return true - } - }, - true - ) + root.addEventListener("wheel", on_mouse_wheel, true) - function on_mouse_wheel(e: WheelEvent) { - const pos = parseInt(root.style.top) - root.style.top = - (pos + e.deltaY * options.scroll_speed).toFixed() + "px" - e.preventDefault() - return true - } + this.root = root - if (!options.scroll_speed) { - options.scroll_speed = 0.1 - } + // title + if (options.title) { + const element = document.createElement("div") + element.className = "litemenu-title" + element.innerHTML = options.title + root.appendChild(element) + } - root.addEventListener("wheel", on_mouse_wheel, true) + // entries + for (let i = 0; i < values.length; i++) { + const value = values[i] + let name = Array.isArray(values) ? value : String(i) - this.root = root + if (typeof name !== "string") { + name = name != null + ? name.content === undefined ? String(name) : name.content + : name as null | undefined + } - //title - if (options.title) { - const element = document.createElement("div") - element.className = "litemenu-title" - element.innerHTML = options.title - root.appendChild(element) - } + this.addItem(name, value, options) + } - //entries - for (let i = 0; i < values.length; i++) { - const value = values[i] - let name = Array.isArray(values) ? value : String(i) + LiteGraph.pointerListenerAdd(root, "enter", function () { + if (root.closing_timer) { + clearTimeout(root.closing_timer) + } + }) + + // insert before checking position + const ownerDocument = (options.event?.target as Node).ownerDocument + const root_document = ownerDocument || document + + if (root_document.fullscreenElement) + root_document.fullscreenElement.appendChild(root) + else + root_document.body.appendChild(root) + + // compute best position + let left = options.left || 0 + let top = options.top || 0 + if (options.event) { + left = options.event.clientX - 10 + top = options.event.clientY - 10 + if (options.title) top -= 20 + + if (parent) { + const rect = parent.root.getBoundingClientRect() + left = rect.left + rect.width + } + + const body_rect = document.body.getBoundingClientRect() + const root_rect = root.getBoundingClientRect() + if (body_rect.height == 0) + console.error("document.body height is 0. That is dangerous, set html,body { height: 100%; }") + + if (body_rect.width && left > body_rect.width - root_rect.width - 10) + left = body_rect.width - root_rect.width - 10 + if (body_rect.height && top > body_rect.height - root_rect.height - 10) + top = body_rect.height - root_rect.height - 10 + } - if (typeof name !== "string") { - name = name != null - ? name.content === undefined ? String(name) : name.content - : name as null | undefined - } + root.style.left = left + "px" + root.style.top = top + "px" - this.addItem(name, value, options) - } + if (options.scale) root.style.transform = `scale(${options.scale})` + } - LiteGraph.pointerListenerAdd(root, "enter", function () { - if (root.closing_timer) { - clearTimeout(root.closing_timer) - } - }) - - //insert before checking position - const ownerDocument = (options.event?.target as Node).ownerDocument - const root_document = ownerDocument || document - - if (root_document.fullscreenElement) - root_document.fullscreenElement.appendChild(root) - else - root_document.body.appendChild(root) - - //compute best position - let left = options.left || 0 - let top = options.top || 0 - if (options.event) { - left = options.event.clientX - 10 - top = options.event.clientY - 10 - if (options.title) top -= 20 - - if (parent) { - const rect = parent.root.getBoundingClientRect() - left = rect.left + rect.width - } - - const body_rect = document.body.getBoundingClientRect() - const root_rect = root.getBoundingClientRect() - if (body_rect.height == 0) - console.error("document.body height is 0. That is dangerous, set html,body { height: 100%; }") - - if (body_rect.width && left > body_rect.width - root_rect.width - 10) - left = body_rect.width - root_rect.width - 10 - if (body_rect.height && top > body_rect.height - root_rect.height - 10) - top = body_rect.height - root_rect.height - 10 - } + addItem( + name: string, + value: IContextMenuValue | string, + options: IContextMenuOptions, + ): HTMLElement { + options ||= {} - root.style.left = left + "px" - root.style.top = top + "px" + const element: ContextMenuDivElement = document.createElement("div") + element.className = "litemenu-entry submenu" - if (options.scale) - root.style.transform = `scale(${options.scale})` - } + let disabled = false - addItem(name: string, value: IContextMenuValue | string, options: IContextMenuOptions): HTMLElement { - options ||= {} - - const element: ContextMenuDivElement = document.createElement("div") - element.className = "litemenu-entry submenu" - - let disabled = false - - if (value === null) { - element.classList.add("separator") - } else { - if (typeof value === "string") { - element.innerHTML = name - } else { - element.innerHTML = value?.title ?? name - - if (value.disabled) { - disabled = true - element.classList.add("disabled") - element.setAttribute("aria-disabled", "true") - } - if (value.submenu || value.has_submenu) { - element.classList.add("has_submenu") - element.setAttribute("aria-haspopup", "true") - element.setAttribute("aria-expanded", "false") - } - if (value.className) - element.className += " " + value.className - } - element.value = value - element.setAttribute("role", "menuitem") - - if (typeof value === "function") { - element.dataset["value"] = name - element.onclick_callback = value - } else { - element.dataset["value"] = String(value) - } - } + if (value === null) { + element.classList.add("separator") + } else { + if (typeof value === "string") { + element.innerHTML = name + } else { + element.innerHTML = value?.title ?? name - this.root.appendChild(element) - if (!disabled) element.addEventListener("click", inner_onclick) - if (!disabled && options.autoopen) - LiteGraph.pointerListenerAdd(element, "enter", inner_over) - - const setAriaExpanded = () => { - const entries = this.root.querySelectorAll("div.litemenu-entry.has_submenu") - if (entries) { - for (let i = 0; i < entries.length; i++) { - entries[i].setAttribute("aria-expanded", "false") - } - } - element.setAttribute("aria-expanded", "true") + if (value.disabled) { + disabled = true + element.classList.add("disabled") + element.setAttribute("aria-disabled", "true") } - - function inner_over(this: ContextMenuDivElement, e: MouseEvent) { - const value = this.value - if (!value || !(value as IContextMenuValue).has_submenu) return - - //if it is a submenu, autoopen like the item was clicked - inner_onclick.call(this, e) - setAriaExpanded() + if (value.submenu || value.has_submenu) { + element.classList.add("has_submenu") + element.setAttribute("aria-haspopup", "true") + element.setAttribute("aria-expanded", "false") } + if (value.className) element.className += " " + value.className + } + element.value = value + element.setAttribute("role", "menuitem") + + if (typeof value === "function") { + element.dataset["value"] = name + element.onclick_callback = value + } else { + element.dataset["value"] = String(value) + } + } - //menu option clicked - const that = this - function inner_onclick(this: ContextMenuDivElement, e: MouseEvent) { - const value = this.value - let close_parent = true - - that.current_submenu?.close(e) - if ((value as IContextMenuValue)?.has_submenu || (value as IContextMenuValue)?.submenu) setAriaExpanded() - - //global callback - if (options.callback) { - const r = options.callback.call( - this, - value, - options, - e, - that, - options.node - ) - if (r === true) close_parent = false - } - - //special cases - if (typeof value === "object") { - if (value.callback && - !options.ignore_item_callbacks && - value.disabled !== true) { - //item callback - const r = value.callback.call( - this, - value, - options, - e, - that, - options.extra - ) - if (r === true) close_parent = false - } - if (value.submenu) { - if (!value.submenu.options) - throw "ContextMenu submenu needs options" - - new that.constructor(value.submenu.options, { - callback: value.submenu.callback, - event: e, - parentMenu: that, - ignore_item_callbacks: value.submenu.ignore_item_callbacks, - title: value.submenu.title, - extra: value.submenu.extra, - autoopen: options.autoopen - }) - close_parent = false - } - } - - if (close_parent && !that.lock) - that.close() - } + this.root.appendChild(element) + if (!disabled) element.addEventListener("click", inner_onclick) + if (!disabled && options.autoopen) + LiteGraph.pointerListenerAdd(element, "enter", inner_over) - return element + const setAriaExpanded = () => { + const entries = this.root.querySelectorAll("div.litemenu-entry.has_submenu") + if (entries) { + for (let i = 0; i < entries.length; i++) { + entries[i].setAttribute("aria-expanded", "false") + } + } + element.setAttribute("aria-expanded", "true") } - close(e?: MouseEvent, ignore_parent_menu?: boolean): void { - this.root.parentNode?.removeChild(this.root) - if (this.parentMenu && !ignore_parent_menu) { - this.parentMenu.lock = false - this.parentMenu.current_submenu = null - if (e === undefined) { - this.parentMenu.close() - } else if (e && - !ContextMenu.isCursorOverElement(e, this.parentMenu.root)) { - ContextMenu.trigger(this.parentMenu.root, LiteGraph.pointerevents_method + "leave", e) - } - } - this.current_submenu?.close(e, true) + function inner_over(this: ContextMenuDivElement, e: MouseEvent) { + const value = this.value + if (!value || !(value as IContextMenuValue).has_submenu) return - if (this.root.closing_timer) - clearTimeout(this.root.closing_timer) + // if it is a submenu, autoopen like the item was clicked + inner_onclick.call(this, e) + setAriaExpanded() } - //this code is used to trigger events easily (used in the context menu mouseleave - static trigger(element: HTMLDivElement, event_name: string, params: MouseEvent, origin?: unknown): CustomEvent { - const evt = document.createEvent("CustomEvent") - evt.initCustomEvent(event_name, true, true, params) //canBubble, cancelable, detail - // @ts-expect-error - evt.srcElement = origin - if (element.dispatchEvent) element.dispatchEvent(evt) - // @ts-expect-error - else if (element.__events) element.__events.dispatchEvent(evt) - //else nothing seems binded here so nothing to do - return evt - } + // menu option clicked + const that = this + function inner_onclick(this: ContextMenuDivElement, e: MouseEvent) { + const value = this.value + let close_parent = true + + that.current_submenu?.close(e) + if ( + (value as IContextMenuValue)?.has_submenu || + (value as IContextMenuValue)?.submenu + ) { + setAriaExpanded() + } + + // global callback + if (options.callback) { + const r = options.callback.call( + this, + value, + options, + e, + that, + options.node, + ) + if (r === true) close_parent = false + } + + // special cases + if (typeof value === "object") { + if ( + value.callback && + !options.ignore_item_callbacks && + value.disabled !== true + ) { + // item callback + const r = value.callback.call( + this, + value, + options, + e, + that, + options.extra, + ) + if (r === true) close_parent = false + } + if (value.submenu) { + if (!value.submenu.options) throw "ContextMenu submenu needs options" + + new that.constructor(value.submenu.options, { + callback: value.submenu.callback, + event: e, + parentMenu: that, + ignore_item_callbacks: value.submenu.ignore_item_callbacks, + title: value.submenu.title, + extra: value.submenu.extra, + autoopen: options.autoopen, + }) + close_parent = false + } + } - //returns the top most menu - getTopMenu(): ContextMenu { - return this.options.parentMenu - ? this.options.parentMenu.getTopMenu() - : this + if (close_parent && !that.lock) that.close() } - getFirstEvent(): MouseEvent { - return this.options.parentMenu - ? this.options.parentMenu.getFirstEvent() - : this.options.event + return element + } + + close(e?: MouseEvent, ignore_parent_menu?: boolean): void { + this.root.parentNode?.removeChild(this.root) + if (this.parentMenu && !ignore_parent_menu) { + this.parentMenu.lock = false + this.parentMenu.current_submenu = null + if (e === undefined) { + this.parentMenu.close() + } else if (e && !ContextMenu.isCursorOverElement(e, this.parentMenu.root)) { + ContextMenu.trigger( + this.parentMenu.root, + LiteGraph.pointerevents_method + "leave", + e, + ) + } } - - static isCursorOverElement(event: MouseEvent, element: HTMLDivElement): boolean { - const left = event.clientX - const top = event.clientY - const rect = element.getBoundingClientRect() - if (!rect) return false - - if (top > rect.top && - top < rect.top + rect.height && - left > rect.left && - left < rect.left + rect.width) { - return true - } - return false + this.current_submenu?.close(e, true) + + if (this.root.closing_timer) clearTimeout(this.root.closing_timer) + } + + // this code is used to trigger events easily (used in the context menu mouseleave + static trigger( + element: HTMLDivElement, + event_name: string, + params: MouseEvent, + origin?: unknown, + ): CustomEvent { + const evt = document.createEvent("CustomEvent") + evt.initCustomEvent(event_name, true, true, params) // canBubble, cancelable, detail + // @ts-expect-error + evt.srcElement = origin + if (element.dispatchEvent) element.dispatchEvent(evt) + // @ts-expect-error + else if (element.__events) element.__events.dispatchEvent(evt) + // else nothing seems binded here so nothing to do + return evt + } + + // returns the top most menu + getTopMenu(): ContextMenu { + return this.options.parentMenu + ? this.options.parentMenu.getTopMenu() + : this + } + + getFirstEvent(): MouseEvent { + return this.options.parentMenu + ? this.options.parentMenu.getFirstEvent() + : this.options.event + } + + static isCursorOverElement( + event: MouseEvent, + element: HTMLDivElement, + ): boolean { + const left = event.clientX + const top = event.clientY + const rect = element.getBoundingClientRect() + if (!rect) return false + + if ( + top > rect.top && + top < rect.top + rect.height && + left > rect.left && + left < rect.left + rect.width + ) { + return true } + return false + } } diff --git a/src/CurveEditor.ts b/src/CurveEditor.ts index fb252d88..e5553448 100644 --- a/src/CurveEditor.ts +++ b/src/CurveEditor.ts @@ -2,172 +2,190 @@ import type { Point, Rect } from "./interfaces" import { clamp, LGraphCanvas } from "./litegraph" import { distance } from "./measure" -//used by some widgets to render a curve editor +// used by some widgets to render a curve editor export class CurveEditor { - points: Point[] - selected: number - nearest: number - size: Rect - must_update: boolean - margin: number - _nearest: number - - constructor(points: Point[]) { - this.points = points - this.selected = -1 - this.nearest = -1 - this.size = null //stores last size used - this.must_update = true - this.margin = 5 - } + points: Point[] + selected: number + nearest: number + size: Rect + must_update: boolean + margin: number + _nearest: number - static sampleCurve(f: number, points: Point[]): number { - if (!points) - return - for (let i = 0; i < points.length - 1; ++i) { - const p = points[i] - const pn = points[i + 1] - if (pn[0] < f) - continue - const r = (pn[0] - p[0]) - if (Math.abs(r) < 0.00001) - return p[1] - const local_f = (f - p[0]) / r - return p[1] * (1.0 - local_f) + pn[1] * local_f - } - return 0 - } + constructor(points: Point[]) { + this.points = points + this.selected = -1 + this.nearest = -1 + this.size = null // stores last size used + this.must_update = true + this.margin = 5 + } - draw(ctx: CanvasRenderingContext2D, size: Rect, graphcanvas?: LGraphCanvas, background_color?: string, line_color?: string, inactive = false): void { - const points = this.points - if (!points) - return - this.size = size - const w = size[0] - this.margin * 2 - const h = size[1] - this.margin * 2 - - line_color = line_color || "#666" - - ctx.save() - ctx.translate(this.margin, this.margin) - - if (background_color) { - ctx.fillStyle = "#111" - ctx.fillRect(0, 0, w, h) - ctx.fillStyle = "#222" - ctx.fillRect(w * 0.5, 0, 1, h) - ctx.strokeStyle = "#333" - ctx.strokeRect(0, 0, w, h) - } - ctx.strokeStyle = line_color - if (inactive) - ctx.globalAlpha = 0.5 - ctx.beginPath() - for (let i = 0; i < points.length; ++i) { - const p = points[i] - ctx.lineTo(p[0] * w, (1.0 - p[1]) * h) - } - ctx.stroke() - ctx.globalAlpha = 1 - if (!inactive) - for (let i = 0; i < points.length; ++i) { - const p = points[i] - ctx.fillStyle = this.selected == i ? "#FFF" : (this.nearest == i ? "#DDD" : "#AAA") - ctx.beginPath() - ctx.arc(p[0] * w, (1.0 - p[1]) * h, 2, 0, Math.PI * 2) - ctx.fill() - } - ctx.restore() + static sampleCurve(f: number, points: Point[]): number { + if (!points) return + + for (let i = 0; i < points.length - 1; ++i) { + const p = points[i] + const pn = points[i + 1] + if (pn[0] < f) continue + + const r = pn[0] - p[0] + if (Math.abs(r) < 0.00001) return p[1] + + const local_f = (f - p[0]) / r + return p[1] * (1.0 - local_f) + pn[1] * local_f } + return 0 + } + + draw( + ctx: CanvasRenderingContext2D, + size: Rect, + graphcanvas?: LGraphCanvas, + background_color?: string, + line_color?: string, + inactive = false, + ): void { + const points = this.points + if (!points) return + + this.size = size + const w = size[0] - this.margin * 2 + const h = size[1] - this.margin * 2 + + line_color = line_color || "#666" - //localpos is mouse in curve editor space - onMouseDown(localpos: Point, graphcanvas: LGraphCanvas): boolean { - const points = this.points - if (!points) - return - if (localpos[1] < 0) - return - - //this.captureInput(true); - const w = this.size[0] - this.margin * 2 - const h = this.size[1] - this.margin * 2 - const x = localpos[0] - this.margin - const y = localpos[1] - this.margin - const pos: Point = [x, y] - const max_dist = 30 / graphcanvas.ds.scale - //search closer one - this.selected = this.getCloserPoint(pos, max_dist) - //create one - if (this.selected == -1) { - const point: Point = [x / w, 1 - y / h] - points.push(point) - points.sort(function (a, b) { return a[0] - b[0] }) - this.selected = points.indexOf(point) - this.must_update = true - } - if (this.selected != -1) - return true + ctx.save() + ctx.translate(this.margin, this.margin) + + if (background_color) { + ctx.fillStyle = "#111" + ctx.fillRect(0, 0, w, h) + ctx.fillStyle = "#222" + ctx.fillRect(w * 0.5, 0, 1, h) + ctx.strokeStyle = "#333" + ctx.strokeRect(0, 0, w, h) + } + ctx.strokeStyle = line_color + if (inactive) ctx.globalAlpha = 0.5 + ctx.beginPath() + for (let i = 0; i < points.length; ++i) { + const p = points[i] + ctx.lineTo(p[0] * w, (1.0 - p[1]) * h) } + ctx.stroke() + ctx.globalAlpha = 1 + if (!inactive) + for (let i = 0; i < points.length; ++i) { + const p = points[i] + ctx.fillStyle = this.selected == i + ? "#FFF" + : this.nearest == i ? "#DDD" : "#AAA" + ctx.beginPath() + ctx.arc(p[0] * w, (1.0 - p[1]) * h, 2, 0, Math.PI * 2) + ctx.fill() + } + ctx.restore() + } - onMouseMove(localpos: Point, graphcanvas: LGraphCanvas): void { - const points = this.points - if (!points) - return - const s = this.selected - if (s < 0) - return - const x = (localpos[0] - this.margin) / (this.size[0] - this.margin * 2) - const y = (localpos[1] - this.margin) / (this.size[1] - this.margin * 2) - const curvepos: Point = [(localpos[0] - this.margin), (localpos[1] - this.margin)] - const max_dist = 30 / graphcanvas.ds.scale - this._nearest = this.getCloserPoint(curvepos, max_dist) - const point = points[s] - if (point) { - const is_edge_point = s == 0 || s == points.length - 1 - if (!is_edge_point && (localpos[0] < -10 || localpos[0] > this.size[0] + 10 || localpos[1] < -10 || localpos[1] > this.size[1] + 10)) { - points.splice(s, 1) - this.selected = -1 - return - } - if (!is_edge_point) //not edges - point[0] = clamp(x, 0, 1) - else - point[0] = s == 0 ? 0 : 1 - point[1] = 1.0 - clamp(y, 0, 1) - points.sort(function (a, b) { return a[0] - b[0] }) - this.selected = points.indexOf(point) - this.must_update = true - } + // localpos is mouse in curve editor space + onMouseDown(localpos: Point, graphcanvas: LGraphCanvas): boolean { + const points = this.points + if (!points) return + if (localpos[1] < 0) return + + // this.captureInput(true); + const w = this.size[0] - this.margin * 2 + const h = this.size[1] - this.margin * 2 + const x = localpos[0] - this.margin + const y = localpos[1] - this.margin + const pos: Point = [x, y] + const max_dist = 30 / graphcanvas.ds.scale + // search closer one + this.selected = this.getCloserPoint(pos, max_dist) + // create one + if (this.selected == -1) { + const point: Point = [x / w, 1 - y / h] + points.push(point) + points.sort(function (a, b) { + return a[0] - b[0] + }) + this.selected = points.indexOf(point) + this.must_update = true } + if (this.selected != -1) return true + } + + onMouseMove(localpos: Point, graphcanvas: LGraphCanvas): void { + const points = this.points + if (!points) return - // Former params: localpos, graphcanvas - onMouseUp(): boolean { + const s = this.selected + if (s < 0) return + + const x = (localpos[0] - this.margin) / (this.size[0] - this.margin * 2) + const y = (localpos[1] - this.margin) / (this.size[1] - this.margin * 2) + const curvepos: Point = [ + localpos[0] - this.margin, + localpos[1] - this.margin, + ] + const max_dist = 30 / graphcanvas.ds.scale + this._nearest = this.getCloserPoint(curvepos, max_dist) + const point = points[s] + if (point) { + const is_edge_point = s == 0 || s == points.length - 1 + if ( + !is_edge_point && + (localpos[0] < -10 || + localpos[0] > this.size[0] + 10 || + localpos[1] < -10 || + localpos[1] > this.size[1] + 10) + ) { + points.splice(s, 1) this.selected = -1 - return false + return + } + // not edges + if (!is_edge_point) point[0] = clamp(x, 0, 1) + else point[0] = s == 0 ? 0 : 1 + point[1] = 1.0 - clamp(y, 0, 1) + points.sort(function (a, b) { + return a[0] - b[0] + }) + this.selected = points.indexOf(point) + this.must_update = true } + } + + // Former params: localpos, graphcanvas + onMouseUp(): boolean { + this.selected = -1 + return false + } + + getCloserPoint(pos: Point, max_dist: number): number { + const points = this.points + if (!points) return -1 + + max_dist = max_dist || 30 + const w = this.size[0] - this.margin * 2 + const h = this.size[1] - this.margin * 2 + const num = points.length + const p2: Point = [0, 0] + let min_dist = 1000000 + let closest = -1 + + for (let i = 0; i < num; ++i) { + const p = points[i] + p2[0] = p[0] * w + p2[1] = (1.0 - p[1]) * h + const dist = distance(pos, p2) + if (dist > min_dist || dist > max_dist) continue - getCloserPoint(pos: Point, max_dist: number): number { - const points = this.points - if (!points) - return -1 - max_dist = max_dist || 30 - const w = (this.size[0] - this.margin * 2) - const h = (this.size[1] - this.margin * 2) - const num = points.length - const p2: Point = [0, 0] - let min_dist = 1000000 - let closest = -1 - for (let i = 0; i < num; ++i) { - const p = points[i] - p2[0] = p[0] * w - p2[1] = (1.0 - p[1]) * h - const dist = distance(pos, p2) - if (dist > min_dist || dist > max_dist) - continue - closest = i - min_dist = dist - } - return closest + closest = i + min_dist = dist } + return closest + } } diff --git a/src/DragAndScale.ts b/src/DragAndScale.ts index 88f54848..a088a2a8 100644 --- a/src/DragAndScale.ts +++ b/src/DragAndScale.ts @@ -4,252 +4,247 @@ import { LiteGraph } from "./litegraph" import { isInRect } from "./measure" export interface DragAndScaleState { - offset: Point - scale: number + offset: Point + scale: number } export class DragAndScale { - /** - * The state of this DragAndScale instance. - * - * Implemented as a POCO that can be proxied without side-effects. - */ - state: DragAndScaleState - - /** Maximum scale (zoom in) */ - max_scale: number - /** Minimum scale (zoom out) */ - min_scale: number - enabled: boolean - last_mouse: Point - element?: HTMLCanvasElement - visible_area: Rect32 - _binded_mouse_callback - dragging?: boolean - viewport?: Rect - - onredraw?(das: DragAndScale): void - /** @deprecated */ - onmouse?(e: unknown): boolean - - get offset(): Point { - return this.state.offset + /** + * The state of this DragAndScale instance. + * + * Implemented as a POCO that can be proxied without side-effects. + */ + state: DragAndScaleState + + /** Maximum scale (zoom in) */ + max_scale: number + /** Minimum scale (zoom out) */ + min_scale: number + enabled: boolean + last_mouse: Point + element?: HTMLCanvasElement + visible_area: Rect32 + _binded_mouse_callback + dragging?: boolean + viewport?: Rect + + onredraw?(das: DragAndScale): void + /** @deprecated */ + onmouse?(e: unknown): boolean + + get offset(): Point { + return this.state.offset + } + + set offset(value: Point) { + this.state.offset = value + } + + get scale(): number { + return this.state.scale + } + + set scale(value: number) { + this.state.scale = value + } + + constructor(element?: HTMLCanvasElement, skip_events?: boolean) { + this.state = { + offset: new Float32Array([0, 0]), + scale: 1, } - set offset(value: Point) { - this.state.offset = value + this.max_scale = 10 + this.min_scale = 0.1 + this.onredraw = null + this.enabled = true + this.last_mouse = [0, 0] + this.element = null + this.visible_area = new Float32Array(4) + + if (element) { + this.element = element + if (!skip_events) { + this.bindEvents(element) + } } + } - get scale(): number { - return this.state.scale - } - set scale(value: number) { - this.state.scale = value - } + /** @deprecated Has not been kept up to date */ + bindEvents(element: Node): void { + this.last_mouse = new Float32Array(2) - constructor(element?: HTMLCanvasElement, skip_events?: boolean) { - this.state = { - offset: new Float32Array([0, 0]), - scale: 1 - } - this.max_scale = 10 - this.min_scale = 0.1 - this.onredraw = null - this.enabled = true - this.last_mouse = [0, 0] - this.element = null - this.visible_area = new Float32Array(4) - - if (element) { - this.element = element - if (!skip_events) { - this.bindEvents(element) - } - } - } - - /** @deprecated Has not been kept up to date */ - bindEvents(element: Node): void { - this.last_mouse = new Float32Array(2) + this._binded_mouse_callback = this.onMouse.bind(this) - this._binded_mouse_callback = this.onMouse.bind(this) + LiteGraph.pointerListenerAdd(element, "down", this._binded_mouse_callback) + LiteGraph.pointerListenerAdd(element, "move", this._binded_mouse_callback) + LiteGraph.pointerListenerAdd(element, "up", this._binded_mouse_callback) - LiteGraph.pointerListenerAdd(element, "down", this._binded_mouse_callback) - LiteGraph.pointerListenerAdd(element, "move", this._binded_mouse_callback) - LiteGraph.pointerListenerAdd(element, "up", this._binded_mouse_callback) + element.addEventListener("mousewheel", this._binded_mouse_callback, false) + element.addEventListener("wheel", this._binded_mouse_callback, false) + } - element.addEventListener( - "mousewheel", - this._binded_mouse_callback, - false - ) - element.addEventListener("wheel", this._binded_mouse_callback, false) + computeVisibleArea(viewport: Rect): void { + if (!this.element) { + this.visible_area[0] = this.visible_area[1] = this.visible_area[2] = this.visible_area[3] = 0 + return } - - computeVisibleArea(viewport: Rect): void { - if (!this.element) { - this.visible_area[0] = this.visible_area[1] = this.visible_area[2] = this.visible_area[3] = 0 - return - } - let width = this.element.width - let height = this.element.height - let startx = -this.offset[0] - let starty = -this.offset[1] - if (viewport) { - startx += viewport[0] / this.scale - starty += viewport[1] / this.scale - width = viewport[2] - height = viewport[3] - } - const endx = startx + width / this.scale - const endy = starty + height / this.scale - this.visible_area[0] = startx - this.visible_area[1] = starty - this.visible_area[2] = endx - startx - this.visible_area[3] = endy - starty + let width = this.element.width + let height = this.element.height + let startx = -this.offset[0] + let starty = -this.offset[1] + if (viewport) { + startx += viewport[0] / this.scale + starty += viewport[1] / this.scale + width = viewport[2] + height = viewport[3] } - - /** @deprecated Has not been kept up to date */ - onMouse(e: CanvasMouseEvent) { - if (!this.enabled) { - return - } - - const canvas = this.element - const rect = canvas.getBoundingClientRect() - const x = e.clientX - rect.left - const y = e.clientY - rect.top - // FIXME: "canvasx" / y are not referenced anywhere - wrong case - // @ts-expect-error Incorrect case - e.canvasx = x - // @ts-expect-error Incorrect case - e.canvasy = y - e.dragging = this.dragging - - const is_inside = !this.viewport || isInRect(x, y, this.viewport) - - let ignore = false - if (this.onmouse) { - ignore = this.onmouse(e) - } - - if (e.type == LiteGraph.pointerevents_method + "down" && is_inside) { - this.dragging = true - LiteGraph.pointerListenerRemove(canvas, "move", this._binded_mouse_callback) - LiteGraph.pointerListenerAdd(document, "move", this._binded_mouse_callback) - LiteGraph.pointerListenerAdd(document, "up", this._binded_mouse_callback) - } else if (e.type == LiteGraph.pointerevents_method + "move") { - if (!ignore) { - const deltax = x - this.last_mouse[0] - const deltay = y - this.last_mouse[1] - if (this.dragging) { - this.mouseDrag(deltax, deltay) - } - } - } else if (e.type == LiteGraph.pointerevents_method + "up") { - this.dragging = false - LiteGraph.pointerListenerRemove(document, "move", this._binded_mouse_callback) - LiteGraph.pointerListenerRemove(document, "up", this._binded_mouse_callback) - LiteGraph.pointerListenerAdd(canvas, "move", this._binded_mouse_callback) - } else if (is_inside && - (e.type == "mousewheel" || - e.type == "wheel" || - e.type == "DOMMouseScroll")) { - // @ts-expect-error Deprecated - e.eventType = "mousewheel" - // @ts-expect-error Deprecated - if (e.type == "wheel") e.wheel = -e.deltaY - // @ts-expect-error Deprecated - else e.wheel = e.wheelDeltaY != null ? e.wheelDeltaY : e.detail * -60 - - //from stack overflow - // @ts-expect-error Deprecated - e.delta = e.wheelDelta - // @ts-expect-error Deprecated - ? e.wheelDelta / 40 - : e.deltaY - ? -e.deltaY / 3 - : 0 - // @ts-expect-error Deprecated - this.changeDeltaScale(1.0 + e.delta * 0.05) - } - - this.last_mouse[0] = x - this.last_mouse[1] = y - - if (is_inside) { - e.preventDefault() - e.stopPropagation() - return false - } + const endx = startx + width / this.scale + const endy = starty + height / this.scale + this.visible_area[0] = startx + this.visible_area[1] = starty + this.visible_area[2] = endx - startx + this.visible_area[3] = endy - starty + } + + /** @deprecated Has not been kept up to date */ + onMouse(e: CanvasMouseEvent) { + if (!this.enabled) { + return } - toCanvasContext(ctx: CanvasRenderingContext2D): void { - ctx.scale(this.scale, this.scale) - ctx.translate(this.offset[0], this.offset[1]) + const canvas = this.element + const rect = canvas.getBoundingClientRect() + const x = e.clientX - rect.left + const y = e.clientY - rect.top + // FIXME: "canvasx" / y are not referenced anywhere - wrong case + // @ts-expect-error Incorrect case + e.canvasx = x + // @ts-expect-error Incorrect case + e.canvasy = y + e.dragging = this.dragging + + const is_inside = !this.viewport || isInRect(x, y, this.viewport) + + let ignore = false + if (this.onmouse) { + ignore = this.onmouse(e) } - convertOffsetToCanvas(pos: Point): Point { - return [ - (pos[0] + this.offset[0]) * this.scale, - (pos[1] + this.offset[1]) * this.scale - ] - } - - convertCanvasToOffset(pos: Point, out?: Point): Point { - out = out || [0, 0] - out[0] = pos[0] / this.scale - this.offset[0] - out[1] = pos[1] / this.scale - this.offset[1] - return out + if (e.type == LiteGraph.pointerevents_method + "down" && is_inside) { + this.dragging = true + LiteGraph.pointerListenerRemove(canvas, "move", this._binded_mouse_callback) + LiteGraph.pointerListenerAdd(document, "move", this._binded_mouse_callback) + LiteGraph.pointerListenerAdd(document, "up", this._binded_mouse_callback) + } else if (e.type == LiteGraph.pointerevents_method + "move") { + if (!ignore) { + const deltax = x - this.last_mouse[0] + const deltay = y - this.last_mouse[1] + if (this.dragging) { + this.mouseDrag(deltax, deltay) + } + } + } else if (e.type == LiteGraph.pointerevents_method + "up") { + this.dragging = false + LiteGraph.pointerListenerRemove(document, "move", this._binded_mouse_callback) + LiteGraph.pointerListenerRemove(document, "up", this._binded_mouse_callback) + LiteGraph.pointerListenerAdd(canvas, "move", this._binded_mouse_callback) + } else if ( + is_inside && + (e.type == "mousewheel" || e.type == "wheel" || e.type == "DOMMouseScroll") + ) { + // @ts-expect-error Deprecated + e.eventType = "mousewheel" + // @ts-expect-error Deprecated + if (e.type == "wheel") e.wheel = -e.deltaY + // @ts-expect-error Deprecated + else e.wheel = e.wheelDeltaY != null ? e.wheelDeltaY : e.detail * -60 + + // from stack overflow + // @ts-expect-error Deprecated + e.delta = e.wheelDelta + // @ts-expect-error Deprecated + ? e.wheelDelta / 40 + : e.deltaY + ? -e.deltaY / 3 + : 0 + // @ts-expect-error Deprecated + this.changeDeltaScale(1.0 + e.delta * 0.05) } - /** @deprecated Has not been kept up to date */ - mouseDrag(x: number, y: number): void { - this.offset[0] += x / this.scale - this.offset[1] += y / this.scale + this.last_mouse[0] = x + this.last_mouse[1] = y - this.onredraw?.(this) + if (is_inside) { + e.preventDefault() + e.stopPropagation() + return false + } + } + + toCanvasContext(ctx: CanvasRenderingContext2D): void { + ctx.scale(this.scale, this.scale) + ctx.translate(this.offset[0], this.offset[1]) + } + + convertOffsetToCanvas(pos: Point): Point { + return [ + (pos[0] + this.offset[0]) * this.scale, + (pos[1] + this.offset[1]) * this.scale, + ] + } + + convertCanvasToOffset(pos: Point, out?: Point): Point { + out = out || [0, 0] + out[0] = pos[0] / this.scale - this.offset[0] + out[1] = pos[1] / this.scale - this.offset[1] + return out + } + + /** @deprecated Has not been kept up to date */ + mouseDrag(x: number, y: number): void { + this.offset[0] += x / this.scale + this.offset[1] += y / this.scale + + this.onredraw?.(this) + } + + changeScale(value: number, zooming_center?: Point): void { + if (value < this.min_scale) { + value = this.min_scale + } else if (value > this.max_scale) { + value = this.max_scale } - changeScale(value: number, zooming_center?: Point): void { - if (value < this.min_scale) { - value = this.min_scale - } else if (value > this.max_scale) { - value = this.max_scale - } - - if (value == this.scale) return - if (!this.element) return + if (value == this.scale) return + if (!this.element) return - const rect = this.element.getBoundingClientRect() - if (!rect) return + const rect = this.element.getBoundingClientRect() + if (!rect) return - zooming_center = zooming_center || [ - rect.width * 0.5, - rect.height * 0.5 - ] - const center = this.convertCanvasToOffset(zooming_center) - this.scale = value - if (Math.abs(this.scale - 1) < 0.01) this.scale = 1 + zooming_center = zooming_center || [rect.width * 0.5, rect.height * 0.5] + const center = this.convertCanvasToOffset(zooming_center) + this.scale = value + if (Math.abs(this.scale - 1) < 0.01) this.scale = 1 - const new_center = this.convertCanvasToOffset(zooming_center) - const delta_offset = [ - new_center[0] - center[0], - new_center[1] - center[1] - ] + const new_center = this.convertCanvasToOffset(zooming_center) + const delta_offset = [ + new_center[0] - center[0], + new_center[1] - center[1], + ] - this.offset[0] += delta_offset[0] - this.offset[1] += delta_offset[1] + this.offset[0] += delta_offset[0] + this.offset[1] += delta_offset[1] - this.onredraw?.(this) - } + this.onredraw?.(this) + } - changeDeltaScale(value: number, zooming_center?: Point): void { - this.changeScale(this.scale * value, zooming_center) - } + changeDeltaScale(value: number, zooming_center?: Point): void { + this.changeScale(this.scale * value, zooming_center) + } - reset(): void { - this.scale = 1 - this.offset[0] = 0 - this.offset[1] = 0 - } + reset(): void { + this.scale = 1 + this.offset[0] = 0 + this.offset[1] = 0 + } } diff --git a/src/LGraph.ts b/src/LGraph.ts index 5dc9ccae..568e29ac 100644 --- a/src/LGraph.ts +++ b/src/LGraph.ts @@ -1,5 +1,19 @@ -import type { Dictionary, IContextMenuValue, LinkNetwork, ISlotType, MethodNames, Point, LinkSegment, Positionable } from "./interfaces" -import type { ISerialisedGraph, Serialisable, SerialisableGraph, SerialisableReroute } from "./types/serialisation" +import type { + Dictionary, + IContextMenuValue, + LinkNetwork, + ISlotType, + MethodNames, + Point, + LinkSegment, + Positionable, +} from "./interfaces" +import type { + ISerialisedGraph, + Serialisable, + SerialisableGraph, + SerialisableReroute, +} from "./types/serialisation" import { Reroute, RerouteId } from "./Reroute" import { LGraphEventMode } from "./types/globalEnums" import { LiteGraph } from "./litegraph" @@ -12,38 +26,41 @@ import { isSortaInsideOctagon } from "./measure" import { getAllNestedItems } from "./utils/collections" interface IGraphInput { - name: string - type: string - value?: unknown + name: string + type: string + value?: unknown } export interface LGraphState { - lastGroupId: number - lastNodeId: number - lastLinkId: number - lastRerouteId: number + lastGroupId: number + lastNodeId: number + lastLinkId: number + lastRerouteId: number } -type ParamsArray, K extends MethodNames> = Parameters[1] extends undefined ? Parameters | Parameters[0] : Parameters +type ParamsArray, K extends MethodNames> = + Parameters[1] extends undefined + ? Parameters | Parameters[0] + : Parameters /** Configuration used by {@link LGraph} `config`. */ export interface LGraphConfig { - /** @deprecated Legacy config - unused */ - align_to_grid?: any - /** - * When set to a positive number, when nodes are moved their positions will - * be rounded to the nearest multiple of this value. Half up. - * Default: `undefined` - * @todo Not implemented - see {@link LiteGraph.CANVAS_GRID_SIZE} - */ - snapToGrid?: number - /** - * If `true`, items always snap to the grid - modifier keys are ignored. - * When {@link snapToGrid} is falsy, a value of `1` is used. - * Default: `false` - */ - alwaysSnapToGrid?: boolean - links_ontop?: any + /** @deprecated Legacy config - unused */ + align_to_grid?: any + /** + * When set to a positive number, when nodes are moved their positions will + * be rounded to the nearest multiple of this value. Half up. + * Default: `undefined` + * @todo Not implemented - see {@link LiteGraph.CANVAS_GRID_SIZE} + */ + snapToGrid?: number + /** + * If `true`, items always snap to the grid - modifier keys are ignored. + * When {@link snapToGrid} is falsy, a value of `1` is used. + * Default: `false` + */ + alwaysSnapToGrid?: boolean + links_ontop?: any } /** @@ -54,1567 +71,1659 @@ export interface LGraphConfig { + onNodeConnectionChange: some connection has changed in the graph (connected or disconnected) */ export class LGraph implements LinkNetwork, Serialisable { - static serialisedSchemaVersion = 1 as const - - //default supported types - static supported_types = ["number", "string", "boolean"] - static STATUS_STOPPED = 1 - static STATUS_RUNNING = 2 - - _version: number - /** The backing store for links. Keys are wrapped in String() */ - _links: Map = new Map() - /** - * Indexed property access is deprecated. - * Backwards compatibility with a Proxy has been added, but will eventually be removed. - * - * Use {@link Map} methods: - * ``` - * const linkId = 123 - * const link = graph.links.get(linkId) - * // Deprecated: const link = graph.links[linkId] - * ``` - */ - links: Map & Record - list_of_graphcanvas: LGraphCanvas[] | null - status: number - - state: LGraphState - - _nodes: LGraphNode[] - _nodes_by_id: Record - _nodes_in_order: LGraphNode[] - _nodes_executable: LGraphNode[] | null - _groups: LGraphGroup[] - iteration: number - globaltime: number - runningtime: number - fixedtime: number - fixedtime_lapse: number - elapsed_time: number - last_update_time: number - starttime: number - catch_errors: boolean - execution_timer_id: number | null - errors_in_execution: boolean - execution_time: number - _last_trigger_time?: number - filter?: string - _subgraph_node?: LGraphNode - /** Must contain serialisable values, e.g. primitive types */ - config: LGraphConfig - vars: Dictionary - nodes_executing: boolean[] - nodes_actioning: (string | boolean)[] - nodes_executedAction: string[] - extra: Record - inputs: Dictionary - outputs: Dictionary - - #reroutes = new Map() - /** All reroutes in this graph. */ - public get reroutes(): Map { - return this.#reroutes - } - public set reroutes(value: Map) { - if (!value) throw new TypeError("Attempted to set LGraph.reroutes to a falsy value.") - - const reroutes = this.#reroutes - if (value.size === 0) { - reroutes.clear() - return - } - - for (const rerouteId of reroutes.keys()) { - if (!value.has(rerouteId)) reroutes.delete(rerouteId) - } - for (const [id, reroute] of value) { - reroutes.set(id, reroute) - } - } - - /** @deprecated See {@link state}.{@link LGraphState.lastNodeId lastNodeId} */ - get last_node_id() { - return this.state.lastNodeId - } - set last_node_id(value) { - this.state.lastNodeId = value - } - /** @deprecated See {@link state}.{@link LGraphState.lastLinkId lastLinkId} */ - get last_link_id() { - return this.state.lastLinkId - } - set last_link_id(value) { - this.state.lastLinkId = value - } - - onInputsOutputsChange?(): void - onInputAdded?(name: string, type: string): void - onAfterStep?(): void - onBeforeStep?(): void - onPlayEvent?(): void - onStopEvent?(): void - onAfterExecute?(): void - onExecuteStep?(): void - onNodeAdded?(node: LGraphNode): void - onNodeRemoved?(node: LGraphNode): void - onTrigger?(action: string, param: unknown): void - onInputRenamed?(old_name: string, name: string): void - onInputTypeChanged?(name: string, type: string): void - onInputRemoved?(name: string): void - onOutputAdded?(name: string, type: string): void - onOutputRenamed?(old_name: string, name: string): void - onOutputTypeChanged?(name: string, type: string): void - onOutputRemoved?(name: string): void - onBeforeChange?(graph: LGraph, info?: LGraphNode): void - onAfterChange?(graph: LGraph, info?: LGraphNode): void - onConnectionChange?(node: LGraphNode): void - on_change?(graph: LGraph): void - onSerialize?(data: ISerialisedGraph | SerialisableGraph): void - onConfigure?(data: ISerialisedGraph | SerialisableGraph): void - onGetNodeMenuOptions?(options: IContextMenuValue[], node: LGraphNode): void - onNodeConnectionChange?(nodeSlotType: ISlotType, targetNode: LGraphNode, slotIndex: number, sourceNode?: LGraphNode, sourceSlotIndex?: number): void - - private _input_nodes?: LGraphNode[] - - /** - * See {@link LGraph} - * @param o data from previous serialization [optional] - */ - constructor(o?: ISerialisedGraph | SerialisableGraph) { - if (LiteGraph.debug) console.log("Graph created") - - /** @see MapProxyHandler */ - const links = this._links - MapProxyHandler.bindAllMethods(links) - const handler = new MapProxyHandler() - this.links = new Proxy(links, handler) as Map & Record - - this.list_of_graphcanvas = null - this.clear() - - if (o) this.configure(o) - } - - // TODO: Remove - //used to know which types of connections support this graph (some graphs do not allow certain types) - getSupportedTypes(): string[] { - // @ts-expect-error - return this.supported_types || LGraph.supported_types - } - /** - * Removes all nodes from this graph - */ - clear(): void { - this.stop() - this.status = LGraph.STATUS_STOPPED - - this.state = { - lastGroupId: 0, - lastNodeId: 0, - lastLinkId: 0, - lastRerouteId: 0 - } - - this._version = -1 //used to detect changes - - //safe clear - if (this._nodes) { - for (let i = 0; i < this._nodes.length; ++i) { - this._nodes[i].onRemoved?.() - } - } - - //nodes - this._nodes = [] - this._nodes_by_id = {} - this._nodes_in_order = [] //nodes sorted in execution order - this._nodes_executable = null //nodes that contain onExecute sorted in execution order - - this._links.clear() - this.reroutes.clear() - - //other scene stuff - this._groups = [] - - //iterations - this.iteration = 0 - - //custom data - this.config = {} - this.vars = {} - this.extra = {} //to store custom data - - //timing - this.globaltime = 0 - this.runningtime = 0 - this.fixedtime = 0 - this.fixedtime_lapse = 0.01 - this.elapsed_time = 0.01 - this.last_update_time = 0 - this.starttime = 0 - - this.catch_errors = true - - this.nodes_executing = [] - this.nodes_actioning = [] - this.nodes_executedAction = [] - - //subgraph_data - this.inputs = {} - this.outputs = {} - - //notify canvas to redraw - this.change() - - this.canvasAction(c => c.clear()) - } - - get nodes() { - return this._nodes - } - - get groups() { - return this._groups - } - - /** - * Attach Canvas to this graph - * @param {GraphCanvas} graph_canvas - */ - attachCanvas(graphcanvas: LGraphCanvas): void { - if (graphcanvas.constructor != LGraphCanvas) - throw "attachCanvas expects a LGraphCanvas instance" - if (graphcanvas.graph != this) - graphcanvas.graph?.detachCanvas(graphcanvas) - - graphcanvas.graph = this - - this.list_of_graphcanvas ||= [] - this.list_of_graphcanvas.push(graphcanvas) - } - /** - * Detach Canvas from this graph - * @param {GraphCanvas} graph_canvas - */ - detachCanvas(graphcanvas: LGraphCanvas): void { - if (!this.list_of_graphcanvas) return - - const pos = this.list_of_graphcanvas.indexOf(graphcanvas) - if (pos == -1) return - - graphcanvas.graph = null - this.list_of_graphcanvas.splice(pos, 1) - } - /** - * Starts running this graph every interval milliseconds. - * @param {number} interval amount of milliseconds between executions, if 0 then it renders to the monitor refresh rate - */ - start(interval?: number): void { - if (this.status == LGraph.STATUS_RUNNING) return - this.status = LGraph.STATUS_RUNNING - - this.onPlayEvent?.() - this.sendEventToAllNodes("onStart") - - //launch - this.starttime = LiteGraph.getTime() - this.last_update_time = this.starttime - interval ||= 0 - const that = this - - //execute once per frame - if (interval == 0 && typeof window != "undefined" && window.requestAnimationFrame) { - function on_frame() { - if (that.execution_timer_id != -1) return - - window.requestAnimationFrame(on_frame) - that.onBeforeStep?.() - that.runStep(1, !that.catch_errors) - that.onAfterStep?.() - } - this.execution_timer_id = -1 - on_frame() - } else { //execute every 'interval' ms - // @ts-expect-error - this.execution_timer_id = setInterval(function () { - //execute - that.onBeforeStep?.() - that.runStep(1, !that.catch_errors) - that.onAfterStep?.() - }, interval) - } - } - /** - * Stops the execution loop of the graph - */ - stop(): void { - if (this.status == LGraph.STATUS_STOPPED) return - - this.status = LGraph.STATUS_STOPPED - - this.onStopEvent?.() - - if (this.execution_timer_id != null) { - if (this.execution_timer_id != -1) { - clearInterval(this.execution_timer_id) - } - this.execution_timer_id = null - } - - this.sendEventToAllNodes("onStop") - } - /** - * Run N steps (cycles) of the graph - * @param {number} num number of steps to run, default is 1 - * @param {Boolean} do_not_catch_errors [optional] if you want to try/catch errors - * @param {number} limit max number of nodes to execute (used to execute from start to a node) - */ - runStep(num: number, do_not_catch_errors: boolean, limit?: number): void { - num = num || 1 - - const start = LiteGraph.getTime() - this.globaltime = 0.001 * (start - this.starttime) - - const nodes = this._nodes_executable - ? this._nodes_executable - : this._nodes - if (!nodes) return - - limit = limit || nodes.length - - if (do_not_catch_errors) { - //iterations - for (let i = 0; i < num; i++) { - for (let j = 0; j < limit; ++j) { - const node = nodes[j] - // FIXME: Looks like copy/paste broken logic - checks for "on", executes "do" - if (node.mode == LGraphEventMode.ALWAYS && node.onExecute) { - //wrap node.onExecute(); - node.doExecute?.() - } - } - - this.fixedtime += this.fixedtime_lapse - this.onExecuteStep?.() - } - - this.onAfterExecute?.() - } else { - try { - //iterations - for (let i = 0; i < num; i++) { - for (let j = 0; j < limit; ++j) { - const node = nodes[j] - if (node.mode == LGraphEventMode.ALWAYS) { - node.onExecute?.() - } - } - - this.fixedtime += this.fixedtime_lapse - this.onExecuteStep?.() - } - - this.onAfterExecute?.() - this.errors_in_execution = false - } catch (err) { - this.errors_in_execution = true - if (LiteGraph.throw_errors) throw err - - if (LiteGraph.debug) console.log("Error during execution: " + err) - this.stop() - } - } - - const now = LiteGraph.getTime() - let elapsed = now - start - if (elapsed == 0) elapsed = 1 - - this.execution_time = 0.001 * elapsed - this.globaltime += 0.001 * elapsed - this.iteration += 1 - this.elapsed_time = (now - this.last_update_time) * 0.001 - this.last_update_time = now - this.nodes_executing = [] - this.nodes_actioning = [] - this.nodes_executedAction = [] - } - /** - * Updates the graph execution order according to relevance of the nodes (nodes with only outputs have more relevance than - * nodes with only inputs. - */ - updateExecutionOrder(): void { - this._nodes_in_order = this.computeExecutionOrder(false) - this._nodes_executable = [] - for (let i = 0; i < this._nodes_in_order.length; ++i) { - if (this._nodes_in_order[i].onExecute) { - this._nodes_executable.push(this._nodes_in_order[i]) - } - } - } - //This is more internal, it computes the executable nodes in order and returns it - computeExecutionOrder(only_onExecute: boolean, set_level?: boolean): LGraphNode[] { - const L: LGraphNode[] = [] - const S: LGraphNode[] = [] - const M: Dictionary = {} - const visited_links: Record = {} //to avoid repeating links - const remaining_links: Record = {} //to a - - //search for the nodes without inputs (starting nodes) - for (let i = 0, l = this._nodes.length; i < l; ++i) { - const node = this._nodes[i] - if (only_onExecute && !node.onExecute) { - continue - } - - M[node.id] = node //add to pending nodes - - let num = 0 //num of input connections - if (node.inputs) { - for (let j = 0, l2 = node.inputs.length; j < l2; j++) { - if (node.inputs[j]?.link != null) { - num += 1 - } - } - } - - if (num == 0) { - //is a starting node - S.push(node) - if (set_level) node._level = 1 - } //num of input links - else { - if (set_level) node._level = 0 - remaining_links[node.id] = num - } - } - - while (true) { - //get an starting node - const node = S.shift() - if (node === undefined) break - - L.push(node) //add to ordered list - delete M[node.id] //remove from the pending nodes - - if (!node.outputs) continue - - //for every output - for (let i = 0; i < node.outputs.length; i++) { - const output = node.outputs[i] - //not connected - // TODO: Confirm functionality, clean condition - if (output?.links == null || output.links.length == 0) - continue - - //for every connection - for (let j = 0; j < output.links.length; j++) { - const link_id = output.links[j] - const link = this._links.get(link_id) - if (!link) continue - - //already visited link (ignore it) - if (visited_links[link.id]) continue - - const target_node = this.getNodeById(link.target_id) - if (target_node == null) { - visited_links[link.id] = true - continue - } - - if (set_level && (!target_node._level || target_node._level <= node._level)) { - target_node._level = node._level + 1 - } - - //mark as visited - visited_links[link.id] = true - //reduce the number of links remaining - remaining_links[target_node.id] -= 1 - - //if no more links, then add to starters array - if (remaining_links[target_node.id] == 0) S.push(target_node) - } - } - } - - //the remaining ones (loops) - for (const i in M) { - L.push(M[i]) - } - - if (L.length != this._nodes.length && LiteGraph.debug) - console.warn("something went wrong, nodes missing") - - const l = L.length - - /** Ensure type is set */ - type OrderedLGraphNode = LGraphNode & { order: number } - - /** Sets the order property of each provided node to its index in {@link nodes}. */ - function setOrder(nodes: LGraphNode[]): asserts nodes is OrderedLGraphNode[] { - const l = nodes.length - for (let i = 0; i < l; ++i) { - nodes[i].order = i - } - } - - //save order number in the node - setOrder(L) - - //sort now by priority - L.sort(function (A, B) { - // @ts-expect-error ctor props - const Ap = A.constructor.priority || A.priority || 0 - // @ts-expect-error ctor props - const Bp = B.constructor.priority || B.priority || 0 - //if same priority, sort by order - - return Ap == Bp - ? A.order - B.order - : Ap - Bp - }) - - //save order number in the node, again... - setOrder(L) - - return L - } - /** - * Returns all the nodes that could affect this one (ancestors) by crawling all the inputs recursively. - * It doesn't include the node itself - * @return {Array} an array with all the LGraphNodes that affect this node, in order of execution - */ - getAncestors(node: LGraphNode): LGraphNode[] { - const ancestors: LGraphNode[] = [] - const pending = [node] - const visited: Dictionary = {} - - while (pending.length) { - const current = pending.shift() - if (!current?.inputs) continue - - if (!visited[current.id] && current != node) { - visited[current.id] = true - ancestors.push(current) - } - - for (let i = 0; i < current.inputs.length; ++i) { - const input = current.getInputNode(i) - if (input && ancestors.indexOf(input) == -1) { - pending.push(input) - } - } - } - - ancestors.sort(function (a, b) { - return a.order - b.order - }) - return ancestors - } - /** - * Positions every node in a more readable manner - */ - arrange(margin?: number, layout?: string): void { - margin = margin || 100 - - const nodes = this.computeExecutionOrder(false, true) - const columns: LGraphNode[][] = [] - for (let i = 0; i < nodes.length; ++i) { - const node = nodes[i] - const col = node._level || 1 - columns[col] ||= [] - columns[col].push(node) + static serialisedSchemaVersion = 1 as const + + // default supported types + static supported_types = ["number", "string", "boolean"] + static STATUS_STOPPED = 1 + static STATUS_RUNNING = 2 + + _version: number + /** The backing store for links. Keys are wrapped in String() */ + _links: Map = new Map() + /** + * Indexed property access is deprecated. + * Backwards compatibility with a Proxy has been added, but will eventually be removed. + * + * Use {@link Map} methods: + * ``` + * const linkId = 123 + * const link = graph.links.get(linkId) + * // Deprecated: const link = graph.links[linkId] + * ``` + */ + links: Map & Record + list_of_graphcanvas: LGraphCanvas[] | null + status: number + + state: LGraphState + + _nodes: LGraphNode[] + _nodes_by_id: Record + _nodes_in_order: LGraphNode[] + _nodes_executable: LGraphNode[] | null + _groups: LGraphGroup[] + iteration: number + globaltime: number + runningtime: number + fixedtime: number + fixedtime_lapse: number + elapsed_time: number + last_update_time: number + starttime: number + catch_errors: boolean + execution_timer_id: number | null + errors_in_execution: boolean + execution_time: number + _last_trigger_time?: number + filter?: string + _subgraph_node?: LGraphNode + /** Must contain serialisable values, e.g. primitive types */ + config: LGraphConfig + vars: Dictionary + nodes_executing: boolean[] + nodes_actioning: (string | boolean)[] + nodes_executedAction: string[] + extra: Record + inputs: Dictionary + outputs: Dictionary + + #reroutes = new Map() + /** All reroutes in this graph. */ + public get reroutes(): Map { + return this.#reroutes + } + + public set reroutes(value: Map) { + if (!value) throw new TypeError("Attempted to set LGraph.reroutes to a falsy value.") + + const reroutes = this.#reroutes + if (value.size === 0) { + reroutes.clear() + return + } + + for (const rerouteId of reroutes.keys()) { + if (!value.has(rerouteId)) reroutes.delete(rerouteId) + } + for (const [id, reroute] of value) { + reroutes.set(id, reroute) + } + } + + /** @deprecated See {@link state}.{@link LGraphState.lastNodeId lastNodeId} */ + get last_node_id() { + return this.state.lastNodeId + } + + set last_node_id(value) { + this.state.lastNodeId = value + } + + /** @deprecated See {@link state}.{@link LGraphState.lastLinkId lastLinkId} */ + get last_link_id() { + return this.state.lastLinkId + } + + set last_link_id(value) { + this.state.lastLinkId = value + } + + onInputsOutputsChange?(): void + onInputAdded?(name: string, type: string): void + onAfterStep?(): void + onBeforeStep?(): void + onPlayEvent?(): void + onStopEvent?(): void + onAfterExecute?(): void + onExecuteStep?(): void + onNodeAdded?(node: LGraphNode): void + onNodeRemoved?(node: LGraphNode): void + onTrigger?(action: string, param: unknown): void + onInputRenamed?(old_name: string, name: string): void + onInputTypeChanged?(name: string, type: string): void + onInputRemoved?(name: string): void + onOutputAdded?(name: string, type: string): void + onOutputRenamed?(old_name: string, name: string): void + onOutputTypeChanged?(name: string, type: string): void + onOutputRemoved?(name: string): void + onBeforeChange?(graph: LGraph, info?: LGraphNode): void + onAfterChange?(graph: LGraph, info?: LGraphNode): void + onConnectionChange?(node: LGraphNode): void + on_change?(graph: LGraph): void + onSerialize?(data: ISerialisedGraph | SerialisableGraph): void + onConfigure?(data: ISerialisedGraph | SerialisableGraph): void + onGetNodeMenuOptions?(options: IContextMenuValue[], node: LGraphNode): void + onNodeConnectionChange?( + nodeSlotType: ISlotType, + targetNode: LGraphNode, + slotIndex: number, + sourceNode?: LGraphNode, + sourceSlotIndex?: number, + ): void + + private _input_nodes?: LGraphNode[] + + /** + * See {@link LGraph} + * @param o data from previous serialization [optional] + */ + constructor(o?: ISerialisedGraph | SerialisableGraph) { + if (LiteGraph.debug) console.log("Graph created") + + /** @see MapProxyHandler */ + const links = this._links + MapProxyHandler.bindAllMethods(links) + const handler = new MapProxyHandler() + this.links = new Proxy(links, handler) as Map & Record + + this.list_of_graphcanvas = null + this.clear() + + if (o) this.configure(o) + } + + // TODO: Remove + // used to know which types of connections support this graph (some graphs do not allow certain types) + getSupportedTypes(): string[] { + // @ts-expect-error + return this.supported_types || LGraph.supported_types + } + + /** + * Removes all nodes from this graph + */ + clear(): void { + this.stop() + this.status = LGraph.STATUS_STOPPED + + this.state = { + lastGroupId: 0, + lastNodeId: 0, + lastLinkId: 0, + lastRerouteId: 0, + } + + this._version = -1 // used to detect changes + + // safe clear + if (this._nodes) { + for (let i = 0; i < this._nodes.length; ++i) { + this._nodes[i].onRemoved?.() + } + } + + // nodes + this._nodes = [] + this._nodes_by_id = {} + this._nodes_in_order = [] // nodes sorted in execution order + this._nodes_executable = null // nodes that contain onExecute sorted in execution order + + this._links.clear() + this.reroutes.clear() + + // other scene stuff + this._groups = [] + + // iterations + this.iteration = 0 + + // custom data + this.config = {} + this.vars = {} + this.extra = {} // to store custom data + + // timing + this.globaltime = 0 + this.runningtime = 0 + this.fixedtime = 0 + this.fixedtime_lapse = 0.01 + this.elapsed_time = 0.01 + this.last_update_time = 0 + this.starttime = 0 + + this.catch_errors = true + + this.nodes_executing = [] + this.nodes_actioning = [] + this.nodes_executedAction = [] + + // subgraph_data + this.inputs = {} + this.outputs = {} + + // notify canvas to redraw + this.change() + + this.canvasAction(c => c.clear()) + } + + get nodes() { + return this._nodes + } + + get groups() { + return this._groups + } + + /** + * Attach Canvas to this graph + * @param {GraphCanvas} graph_canvas + */ + attachCanvas(graphcanvas: LGraphCanvas): void { + if (graphcanvas.constructor != LGraphCanvas) + throw "attachCanvas expects a LGraphCanvas instance" + if (graphcanvas.graph != this) + graphcanvas.graph?.detachCanvas(graphcanvas) + + graphcanvas.graph = this + + this.list_of_graphcanvas ||= [] + this.list_of_graphcanvas.push(graphcanvas) + } + + /** + * Detach Canvas from this graph + * @param {GraphCanvas} graph_canvas + */ + detachCanvas(graphcanvas: LGraphCanvas): void { + if (!this.list_of_graphcanvas) return + + const pos = this.list_of_graphcanvas.indexOf(graphcanvas) + if (pos == -1) return + + graphcanvas.graph = null + this.list_of_graphcanvas.splice(pos, 1) + } + + /** + * Starts running this graph every interval milliseconds. + * @param {number} interval amount of milliseconds between executions, if 0 then it renders to the monitor refresh rate + */ + start(interval?: number): void { + if (this.status == LGraph.STATUS_RUNNING) return + this.status = LGraph.STATUS_RUNNING + + this.onPlayEvent?.() + this.sendEventToAllNodes("onStart") + + // launch + this.starttime = LiteGraph.getTime() + this.last_update_time = this.starttime + interval ||= 0 + const that = this + + // execute once per frame + if ( + interval == 0 && + typeof window != "undefined" && + window.requestAnimationFrame + ) { + function on_frame() { + if (that.execution_timer_id != -1) return + + window.requestAnimationFrame(on_frame) + that.onBeforeStep?.() + that.runStep(1, !that.catch_errors) + that.onAfterStep?.() + } + this.execution_timer_id = -1 + on_frame() + } else { + // execute every 'interval' ms + // @ts-expect-error + this.execution_timer_id = setInterval(function () { + // execute + that.onBeforeStep?.() + that.runStep(1, !that.catch_errors) + that.onAfterStep?.() + }, interval) + } + } + + /** + * Stops the execution loop of the graph + */ + stop(): void { + if (this.status == LGraph.STATUS_STOPPED) return + + this.status = LGraph.STATUS_STOPPED + + this.onStopEvent?.() + + if (this.execution_timer_id != null) { + if (this.execution_timer_id != -1) { + clearInterval(this.execution_timer_id) + } + this.execution_timer_id = null + } + + this.sendEventToAllNodes("onStop") + } + + /** + * Run N steps (cycles) of the graph + * @param {number} num number of steps to run, default is 1 + * @param {Boolean} do_not_catch_errors [optional] if you want to try/catch errors + * @param {number} limit max number of nodes to execute (used to execute from start to a node) + */ + runStep(num: number, do_not_catch_errors: boolean, limit?: number): void { + num = num || 1 + + const start = LiteGraph.getTime() + this.globaltime = 0.001 * (start - this.starttime) + + const nodes = this._nodes_executable + ? this._nodes_executable + : this._nodes + if (!nodes) return + + limit = limit || nodes.length + + if (do_not_catch_errors) { + // iterations + for (let i = 0; i < num; i++) { + for (let j = 0; j < limit; ++j) { + const node = nodes[j] + // FIXME: Looks like copy/paste broken logic - checks for "on", executes "do" + if (node.mode == LGraphEventMode.ALWAYS && node.onExecute) { + // wrap node.onExecute(); + node.doExecute?.() + } } - let x = margin - - for (let i = 0; i < columns.length; ++i) { - const column = columns[i] - if (!column) continue - - let max_size = 100 - let y = margin + LiteGraph.NODE_TITLE_HEIGHT - for (let j = 0; j < column.length; ++j) { - const node = column[j] - node.pos[0] = (layout == LiteGraph.VERTICAL_LAYOUT) ? y : x - node.pos[1] = (layout == LiteGraph.VERTICAL_LAYOUT) ? x : y - const max_size_index = (layout == LiteGraph.VERTICAL_LAYOUT) ? 1 : 0 - if (node.size[max_size_index] > max_size) { - max_size = node.size[max_size_index] - } - const node_size_index = (layout == LiteGraph.VERTICAL_LAYOUT) ? 0 : 1 - y += node.size[node_size_index] + margin + LiteGraph.NODE_TITLE_HEIGHT - } - x += max_size + margin - } + this.fixedtime += this.fixedtime_lapse + this.onExecuteStep?.() + } - this.setDirtyCanvas(true, true) - } - /** - * Returns the amount of time the graph has been running in milliseconds - * @return {number} number of milliseconds the graph has been running - */ - getTime(): number { - return this.globaltime - } - /** - * Returns the amount of time accumulated using the fixedtime_lapse var. This is used in context where the time increments should be constant - * @return {number} number of milliseconds the graph has been running - */ - getFixedTime(): number { - return this.fixedtime - } - /** - * Returns the amount of time it took to compute the latest iteration. Take into account that this number could be not correct - * if the nodes are using graphical actions - * @return {number} number of milliseconds it took the last cycle - */ - getElapsedTime(): number { - return this.elapsed_time - } - /** - * Sends an event to all the nodes, useful to trigger stuff - * @param {String} eventname the name of the event (function to be called) - * @param {Array} params parameters in array format - */ - sendEventToAllNodes(eventname: string, params?: object | object[], mode?: LGraphEventMode): void { - mode = mode || LGraphEventMode.ALWAYS - - const nodes = this._nodes_in_order ? this._nodes_in_order : this._nodes - if (!nodes) return - - for (let j = 0, l = nodes.length; j < l; ++j) { + this.onAfterExecute?.() + } else { + try { + // iterations + for (let i = 0; i < num; i++) { + for (let j = 0; j < limit; ++j) { const node = nodes[j] - - // @ts-expect-error - if (node.constructor === LiteGraph.Subgraph && eventname != "onExecute") { - if (node.mode == mode) { - // @ts-expect-error Subgraph - not currently in use - node.sendEventToAllNodes(eventname, params, mode) - } - continue - } - - if (!node[eventname] || node.mode != mode) continue - if (params === undefined) { - node[eventname]() - } else if (params && params.constructor === Array) { - node[eventname].apply(node, params) - } else { - node[eventname](params) - } - } - } - - /** - * Runs an action on every canvas registered to this graph. - * @param action Action to run for every canvas - */ - canvasAction(action: (canvas: LGraphCanvas) => void): void { - this.list_of_graphcanvas?.forEach(action) - } - - /** @deprecated See {@link LGraph.canvasAction} */ - sendActionToCanvas>(action: T, params?: ParamsArray): void { - if (!this.list_of_graphcanvas) return - - for (let i = 0; i < this.list_of_graphcanvas.length; ++i) { - const c = this.list_of_graphcanvas[i] - c[action]?.apply(c, params) - } - } - /** - * Adds a new node instance to this graph - * @param {LGraphNode} node the instance of the node - */ - add(node: LGraphNode | LGraphGroup, skip_compute_order?: boolean): LGraphNode | null | undefined { - if (!node) return - const { state } = this - - // Ensure created items are snapped - if (this.config.alwaysSnapToGrid) { - const snapTo = this.getSnapToGridSize() - if (snapTo) node.snapToGrid(snapTo) - } - - // LEGACY: This was changed from constructor === LGraphGroup - //groups - if (node instanceof LGraphGroup) { - // Assign group ID - if (node.id == null || node.id === -1) node.id = ++state.lastGroupId - if (node.id > state.lastGroupId) state.lastGroupId = node.id - - this._groups.push(node) - this.setDirtyCanvas(true) - this.change() - node.graph = this - this._version++ - return - } - - //nodes - if (node.id != -1 && this._nodes_by_id[node.id] != null) { - console.warn( - "LiteGraph: there is already a node with this ID, changing it" - ) - node.id = LiteGraph.use_uuids - ? LiteGraph.uuidv4() - : ++state.lastNodeId - } - - if (this._nodes.length >= LiteGraph.MAX_NUMBER_OF_NODES) { - throw "LiteGraph: max number of nodes in a graph reached" - } - - //give him an id - if (LiteGraph.use_uuids) { - if (node.id == null || node.id == -1) - node.id = LiteGraph.uuidv4() - } - else { - if (node.id == null || node.id == -1) { - node.id = ++state.lastNodeId - } else if (typeof node.id === "number" && state.lastNodeId < node.id) { - state.lastNodeId = node.id - } - } - - node.graph = this - this._version++ - - this._nodes.push(node) - this._nodes_by_id[node.id] = node - - node.onAdded?.(this) - - if (this.config.align_to_grid) node.alignToGrid() - - if (!skip_compute_order) this.updateExecutionOrder() - - this.onNodeAdded?.(node) - - this.setDirtyCanvas(true) - this.change() - - return node //to chain actions - } - /** - * Removes a node from the graph - * @param {LGraphNode} node the instance of the node - */ - remove(node: LGraphNode | LGraphGroup): void { - // LEGACY: This was changed from constructor === LiteGraph.LGraphGroup - if (node instanceof LGraphGroup) { - const index = this._groups.indexOf(node) - if (index != -1) { - this._groups.splice(index, 1) - } - node.graph = null - this._version++ - this.setDirtyCanvas(true, true) - this.change() - return - } - - //not found - if (this._nodes_by_id[node.id] == null) return - //cannot be removed - if (node.ignore_remove) return - - this.beforeChange() //sure? - almost sure is wrong - - //disconnect inputs - if (node.inputs) { - for (let i = 0; i < node.inputs.length; i++) { - const slot = node.inputs[i] - if (slot.link != null) - node.disconnectInput(i) + if (node.mode == LGraphEventMode.ALWAYS) { + node.onExecute?.() } - } + } - //disconnect outputs - if (node.outputs) { - for (let i = 0; i < node.outputs.length; i++) { - const slot = node.outputs[i] - if (slot.links?.length) - node.disconnectOutput(i) - } + this.fixedtime += this.fixedtime_lapse + this.onExecuteStep?.() } - //callback - node.onRemoved?.() + this.onAfterExecute?.() + this.errors_in_execution = false + } catch (err) { + this.errors_in_execution = true + if (LiteGraph.throw_errors) throw err - node.graph = null - this._version++ - - //remove from canvas render - if (this.list_of_graphcanvas) { - for (let i = 0; i < this.list_of_graphcanvas.length; ++i) { - const canvas = this.list_of_graphcanvas[i] - if (canvas.selected_nodes[node.id]) - delete canvas.selected_nodes[node.id] - } - } - - //remove from containers - const pos = this._nodes.indexOf(node) - if (pos != -1) this._nodes.splice(pos, 1) - - delete this._nodes_by_id[node.id] - - this.onNodeRemoved?.(node) - - //close panels - this.canvasAction(c => c.checkPanels()) - - this.setDirtyCanvas(true, true) - this.afterChange() //sure? - almost sure is wrong - this.change() - - this.updateExecutionOrder() - } - /** - * Returns a node by its id. - * @param {Number} id - */ - getNodeById(id: NodeId): LGraphNode | null { - return id != null - ? this._nodes_by_id[id] - : null - } - /** - * Returns a list of nodes that matches a class - * @param {Class} classObject the class itself (not an string) - * @return {Array} a list with all the nodes of this type - */ - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type - findNodesByClass(classObject: Function, result?: LGraphNode[]): LGraphNode[] { - result = result || [] - result.length = 0 - for (let i = 0, l = this._nodes.length; i < l; ++i) { - if (this._nodes[i].constructor === classObject) - result.push(this._nodes[i]) - } - return result - } - /** - * Returns a list of nodes that matches a type - * @param {String} type the name of the node type - * @return {Array} a list with all the nodes of this type - */ - findNodesByType(type: string, result: LGraphNode[]): LGraphNode[] { - const matchType = type.toLowerCase() - result = result || [] - result.length = 0 - for (let i = 0, l = this._nodes.length; i < l; ++i) { - if (this._nodes[i].type?.toLowerCase() == matchType) - result.push(this._nodes[i]) - } - return result - } - /** - * Returns the first node that matches a name in its title - * @param {String} name the name of the node to search - * @return {Node} the node or null - */ - findNodeByTitle(title: string): LGraphNode | null { - for (let i = 0, l = this._nodes.length; i < l; ++i) { - if (this._nodes[i].title == title) - return this._nodes[i] - } - return null - } - /** - * Returns a list of nodes that matches a name - * @param {String} name the name of the node to search - * @return {Array} a list with all the nodes with this name - */ - findNodesByTitle(title: string): LGraphNode[] { - const result: LGraphNode[] = [] - for (let i = 0, l = this._nodes.length; i < l; ++i) { - if (this._nodes[i].title == title) - result.push(this._nodes[i]) + if (LiteGraph.debug) console.log("Error during execution: " + err) + this.stop() + } + } + + const now = LiteGraph.getTime() + let elapsed = now - start + if (elapsed == 0) elapsed = 1 + + this.execution_time = 0.001 * elapsed + this.globaltime += 0.001 * elapsed + this.iteration += 1 + this.elapsed_time = (now - this.last_update_time) * 0.001 + this.last_update_time = now + this.nodes_executing = [] + this.nodes_actioning = [] + this.nodes_executedAction = [] + } + + /** + * Updates the graph execution order according to relevance of the nodes (nodes with only outputs have more relevance than + * nodes with only inputs. + */ + updateExecutionOrder(): void { + this._nodes_in_order = this.computeExecutionOrder(false) + this._nodes_executable = [] + for (let i = 0; i < this._nodes_in_order.length; ++i) { + if (this._nodes_in_order[i].onExecute) { + this._nodes_executable.push(this._nodes_in_order[i]) + } + } + } + + // This is more internal, it computes the executable nodes in order and returns it + computeExecutionOrder( + only_onExecute: boolean, + set_level?: boolean, + ): LGraphNode[] { + const L: LGraphNode[] = [] + const S: LGraphNode[] = [] + const M: Dictionary = {} + const visited_links: Record = {} // to avoid repeating links + const remaining_links: Record = {} // to a + + // search for the nodes without inputs (starting nodes) + for (let i = 0, l = this._nodes.length; i < l; ++i) { + const node = this._nodes[i] + if (only_onExecute && !node.onExecute) { + continue + } + + M[node.id] = node // add to pending nodes + + let num = 0 // num of input connections + if (node.inputs) { + for (let j = 0, l2 = node.inputs.length; j < l2; j++) { + if (node.inputs[j]?.link != null) { + num += 1 + } } - return result - } - /** - * Returns the top-most node in this position of the canvas - * @param {number} x the x coordinate in canvas space - * @param {number} y the y coordinate in canvas space - * @param {Array} nodeList a list with all the nodes to search from, by default is all the nodes in the graph - * @return {LGraphNode} the node at this position or null - */ - getNodeOnPos(x: number, y: number, nodeList?: LGraphNode[]): LGraphNode | null { - const nodes = nodeList || this._nodes - let i = nodes.length - while (--i >= 0) { - const node = nodes[i] - if (node.isPointInside(x, y)) return node + } + + if (num == 0) { + // is a starting node + S.push(node) + if (set_level) node._level = 1 + } // num of input links + else { + if (set_level) node._level = 0 + remaining_links[node.id] = num + } + } + + while (true) { + // get an starting node + const node = S.shift() + if (node === undefined) break + + L.push(node) // add to ordered list + delete M[node.id] // remove from the pending nodes + + if (!node.outputs) continue + + // for every output + for (let i = 0; i < node.outputs.length; i++) { + const output = node.outputs[i] + // not connected + // TODO: Confirm functionality, clean condition + if (output?.links == null || output.links.length == 0) + continue + + // for every connection + for (let j = 0; j < output.links.length; j++) { + const link_id = output.links[j] + const link = this._links.get(link_id) + if (!link) continue + + // already visited link (ignore it) + if (visited_links[link.id]) continue + + const target_node = this.getNodeById(link.target_id) + if (target_node == null) { + visited_links[link.id] = true + continue + } + + if (set_level && (!target_node._level || target_node._level <= node._level)) { + target_node._level = node._level + 1 + } + + // mark as visited + visited_links[link.id] = true + // reduce the number of links remaining + remaining_links[target_node.id] -= 1 + + // if no more links, then add to starters array + if (remaining_links[target_node.id] == 0) S.push(target_node) } - return null - } - /** - * Returns the top-most group in that position - * @param x The x coordinate in canvas space - * @param y The y coordinate in canvas space - * @return The group or null - */ - getGroupOnPos(x: number, y: number): LGraphGroup | undefined { - return this._groups.toReversed().find(g => g.isPointInside(x, y)) + } } - /** - * Returns the top-most group with a titlebar in the provided position. - * @param x The x coordinate in canvas space - * @param y The y coordinate in canvas space - * @return The group or null - */ - getGroupTitlebarOnPos(x: number, y: number): LGraphGroup | undefined { - return this._groups.toReversed().find(g => g.isPointInTitlebar(x, y)) + // the remaining ones (loops) + for (const i in M) { + L.push(M[i]) } - /** - * Finds a reroute a the given graph point - * @param x X co-ordinate in graph space - * @param y Y co-ordinate in graph space - * @returns The first reroute under the given co-ordinates, or undefined - */ - getRerouteOnPos(x: number, y: number): Reroute | undefined { - for (const reroute of this.reroutes.values()) { - const pos = reroute.pos - - if (isSortaInsideOctagon(x - pos[0], y - pos[1], 20)) - return reroute - } - } + if (L.length != this._nodes.length && LiteGraph.debug) + console.warn("something went wrong, nodes missing") - /** - * Snaps the provided items to a grid. - * - * Item positions are reounded to the nearest multiple of {@link LiteGraph.CANVAS_GRID_SIZE}. - * - * When {@link config}.{@link LGraphConfig.alwaysSnapToGrid alwaysSnapToGrid} is enabled - * and the grid size is falsy, a default of 1 is used. - * @param items The items to snap to the grid - * @todo Currently only snaps nodes. - */ - snapToGrid(items: Set): void { - const snapTo = this.getSnapToGridSize() - if (!snapTo) return - - getAllNestedItems(items).forEach(item => { - if (!item.pinned) item.snapToGrid(snapTo) - }) - } + const l = L.length - /** - * Finds the size of the grid that items should be snapped to when moved. - * @returns The size of the grid that items should be snapped to - */ - getSnapToGridSize(): number { - // Default to 1 when always snapping - return this.config.alwaysSnapToGrid - ? LiteGraph.CANVAS_GRID_SIZE || 1 - : LiteGraph.CANVAS_GRID_SIZE - } + /** Ensure type is set */ + type OrderedLGraphNode = LGraphNode & { order: number } - /** - * Checks that the node type matches the node type registered, used when replacing a nodetype by a newer version during execution - * this replaces the ones using the old version with the new version - */ - checkNodeTypes() { - for (let i = 0; i < this._nodes.length; i++) { - const node = this._nodes[i] - const ctor = LiteGraph.registered_node_types[node.type] - if (node.constructor == ctor) continue - - console.log("node being replaced by newer version: " + node.type) - const newnode = LiteGraph.createNode(node.type) - this._nodes[i] = newnode - newnode.configure(node.serialize()) - newnode.graph = this - this._nodes_by_id[newnode.id] = newnode - - if (node.inputs) newnode.inputs = node.inputs.concat() - if (node.outputs) newnode.outputs = node.outputs.concat() - } - this.updateExecutionOrder() + /** Sets the order property of each provided node to its index in {@link nodes}. */ + function setOrder(nodes: LGraphNode[]): asserts nodes is OrderedLGraphNode[] { + const l = nodes.length + for (let i = 0; i < l; ++i) { + nodes[i].order = i + } } - // ********** GLOBALS ***************** - onAction(action: string, param: unknown, options: { action_call?: string }): void { - this._input_nodes = this.findNodesByClass( - // @ts-expect-error Never impl. - LiteGraph.GraphInput, - this._input_nodes - ) - for (let i = 0; i < this._input_nodes.length; ++i) { - const node = this._input_nodes[i] - if (node.properties.name != action) continue - - //wrap node.onAction(action, param); - node.actionDo(action, param, options) - break - } - } - trigger(action: string, param: unknown) { - this.onTrigger?.(action, param) - } - /** - * Tell this graph it has a global graph input of this type - * @param {String} name - * @param {String} type - * @param {*} value [optional] - */ - addInput(name: string, type: string, value?: unknown): void { - const input = this.inputs[name] - //already exist - if (input) return - - this.beforeChange() - this.inputs[name] = { name: name, type: type, value: value } - this._version++ - this.afterChange() - - this.onInputAdded?.(name, type) - this.onInputsOutputsChange?.() - } - /** - * Assign a data to the global graph input - * @param {String} name - * @param {*} data - */ - setInputData(name: string, data: unknown): void { - const input = this.inputs[name] - if (!input) return - input.value = data - } - /** - * Returns the current value of a global graph input - * @param {String} name - * @return {*} the data - */ - getInputData(name: string): unknown { - const input = this.inputs[name] - return input - ? input.value - : null - } - /** - * Changes the name of a global graph input - * @param {String} old_name - * @param {String} new_name - */ - renameInput(old_name: string, name: string): boolean | undefined { - if (name == old_name) return - - if (!this.inputs[old_name]) return false - - if (this.inputs[name]) { - console.error("there is already one input with that name") - return false - } - this.inputs[name] = this.inputs[old_name] - delete this.inputs[old_name] - this._version++ + // save order number in the node + setOrder(L) - this.onInputRenamed?.(old_name, name) - this.onInputsOutputsChange?.() - } - /** - * Changes the type of a global graph input - * @param {String} name - * @param {String} type - */ - changeInputType(name: string, type: string): boolean | undefined { - if (!this.inputs[name]) return false - - if (this.inputs[name].type && - String(this.inputs[name].type).toLowerCase() == - String(type).toLowerCase()) { - return - } + // sort now by priority + L.sort(function (A, B) { + // @ts-expect-error ctor props + const Ap = A.constructor.priority || A.priority || 0 + // @ts-expect-error ctor props + const Bp = B.constructor.priority || B.priority || 0 + // if same priority, sort by order - this.inputs[name].type = type - this._version++ - this.onInputTypeChanged?.(name, type) - } - /** - * Removes a global graph input - * @param {String} name - * @param {String} type - */ - removeInput(name: string): boolean { - if (!this.inputs[name]) return false - - delete this.inputs[name] - this._version++ - - this.onInputRemoved?.(name) - this.onInputsOutputsChange?.() - return true - } - /** - * Creates a global graph output - * @param {String} name - * @param {String} type - * @param {*} value - */ - addOutput(name: string, type: string, value: unknown): void { - this.outputs[name] = { name: name, type: type, value: value } - this._version++ - - this.onOutputAdded?.(name, type) - - this.onInputsOutputsChange?.() - } - /** - * Assign a data to the global output - * @param {String} name - * @param {String} value - */ - setOutputData(name: string, value: unknown): void { - const output = this.outputs[name] - if (!output) return - output.value = value - } - /** - * Returns the current value of a global graph output - * @param {String} name - * @return {*} the data - */ - getOutputData(name: string): unknown { - const output = this.outputs[name] - if (!output) return null - return output.value - } - /** - * Renames a global graph output - * @param {String} old_name - * @param {String} new_name - */ - renameOutput(old_name: string, name: string): boolean | undefined { - if (!this.outputs[old_name]) return false - - if (this.outputs[name]) { - console.error("there is already one output with that name") - return false - } + return Ap == Bp + ? A.order - B.order + : Ap - Bp + }) - this.outputs[name] = this.outputs[old_name] - delete this.outputs[old_name] - this._version++ + // save order number in the node, again... + setOrder(L) - this.onOutputRenamed?.(old_name, name) + return L + } - this.onInputsOutputsChange?.() - } - /** - * Changes the type of a global graph output - * @param {String} name - * @param {String} type - */ - changeOutputType(name: string, type: string): boolean | undefined { - if (!this.outputs[name]) return false - - if (this.outputs[name].type && - String(this.outputs[name].type).toLowerCase() == - String(type).toLowerCase()) { - return - } - - this.outputs[name].type = type - this._version++ - this.onOutputTypeChanged?.(name, type) - } - /** - * Removes a global graph output - * @param {String} name - */ - removeOutput(name: string): boolean { - if (!this.outputs[name]) return false + /** + * Returns all the nodes that could affect this one (ancestors) by crawling all the inputs recursively. + * It doesn't include the node itself + * @return {Array} an array with all the LGraphNodes that affect this node, in order of execution + */ + getAncestors(node: LGraphNode): LGraphNode[] { + const ancestors: LGraphNode[] = [] + const pending = [node] + const visited: Dictionary = {} - delete this.outputs[name] - this._version++ + while (pending.length) { + const current = pending.shift() + if (!current?.inputs) continue - this.onOutputRemoved?.(name) + if (!visited[current.id] && current != node) { + visited[current.id] = true + ancestors.push(current) + } - this.onInputsOutputsChange?.() - return true - } - /** @todo Clean up - never implemented. */ - triggerInput(name: string, value: any): void { - const nodes = this.findNodesByTitle(name) - for (let i = 0; i < nodes.length; ++i) { - // @ts-expect-error - nodes[i].onTrigger(value) - } - } - /** @todo Clean up - never implemented. */ - setCallback(name: string, func: any): void { - const nodes = this.findNodesByTitle(name) - for (let i = 0; i < nodes.length; ++i) { - // @ts-expect-error - nodes[i].setTrigger(func) - } - } - //used for undo, called before any change is made to the graph - beforeChange(info?: LGraphNode): void { - this.onBeforeChange?.(this, info) - this.canvasAction(c => c.onBeforeChange?.(this)) - } - //used to resend actions, called after any change is made to the graph - afterChange(info?: LGraphNode): void { - this.onAfterChange?.(this, info) - this.canvasAction(c => c.onAfterChange?.(this)) - } - connectionChange(node: LGraphNode): void { - this.updateExecutionOrder() - this.onConnectionChange?.(node) - this._version++ - // TODO: Interface never implemented - any consumers? - // @ts-expect-error - this.canvasAction(c => c.onConnectionChange?.()) - } - /** - * clears the triggered slot animation in all links (stop visual animation) - */ - clearTriggeredSlots(): void { - for (const link_info of this._links.values()) { - if (!link_info) continue - - if (link_info._last_time) - link_info._last_time = 0 + for (let i = 0; i < current.inputs.length; ++i) { + const input = current.getInputNode(i) + if (input && ancestors.indexOf(input) == -1) { + pending.push(input) } - } - /* Called when something visually changed (not the graph!) */ - change(): void { - if (LiteGraph.debug) { - console.log("Graph changed") + } + } + + ancestors.sort(function (a, b) { + return a.order - b.order + }) + return ancestors + } + + /** + * Positions every node in a more readable manner + */ + arrange(margin?: number, layout?: string): void { + margin = margin || 100 + + const nodes = this.computeExecutionOrder(false, true) + const columns: LGraphNode[][] = [] + for (let i = 0; i < nodes.length; ++i) { + const node = nodes[i] + const col = node._level || 1 + columns[col] ||= [] + columns[col].push(node) + } + + let x = margin + + for (let i = 0; i < columns.length; ++i) { + const column = columns[i] + if (!column) continue + + let max_size = 100 + let y = margin + LiteGraph.NODE_TITLE_HEIGHT + for (let j = 0; j < column.length; ++j) { + const node = column[j] + node.pos[0] = layout == LiteGraph.VERTICAL_LAYOUT ? y : x + node.pos[1] = layout == LiteGraph.VERTICAL_LAYOUT ? x : y + const max_size_index = layout == LiteGraph.VERTICAL_LAYOUT ? 1 : 0 + if (node.size[max_size_index] > max_size) { + max_size = node.size[max_size_index] } - this.canvasAction(c => c.setDirty(true, true)) - this.on_change?.(this) - } - setDirtyCanvas(fg: boolean, bg?: boolean): void { - this.canvasAction(c => c.setDirty(fg, bg)) - } - - /** - * Configures a reroute on the graph where ID is already known (probably deserialisation). - * Creates the object if it does not exist. - * @param id Reroute ID - * @param pos Position in graph space - * @param linkIds IDs of links that pass through this reroute - */ - setReroute({ id, parentId, pos, linkIds }: SerialisableReroute): Reroute { - id ??= ++this.state.lastRerouteId - if (id > this.state.lastRerouteId) this.state.lastRerouteId = id - - const reroute = this.reroutes.get(id) ?? new Reroute(id, this) - reroute.update(parentId, pos, linkIds) - this.reroutes.set(id, reroute) - return reroute - } - - /** - * Creates a new reroute and adds it to the graph. - * @param pos Position in graph space - * @param links The links that will use this reroute (e.g. if from an output with multiple outputs, and all will use it) - * @param afterRerouteId If set, this reroute will be shown after the specified ID. Otherwise, the reroute will be added as the last on the link. - * @returns The newly created reroute - typically ignored. - */ - createReroute(pos: Point, before: LinkSegment): Reroute { - const rerouteId = ++this.state.lastRerouteId - const linkIds = before instanceof Reroute - ? before.linkIds - : [before.id] - const reroute = new Reroute(rerouteId, this, pos, before.parentId, linkIds) - this.reroutes.set(rerouteId, reroute) - for (const linkId of linkIds) { - const link = this._links.get(linkId) - if (!link) continue - if (link.parentId === before.parentId) link.parentId = rerouteId - LLink.getReroutes(this, link) - ?.filter(x => x.parentId === before.parentId) - .forEach(x => x.parentId = rerouteId) + const node_size_index = layout == LiteGraph.VERTICAL_LAYOUT ? 0 : 1 + y += node.size[node_size_index] + margin + LiteGraph.NODE_TITLE_HEIGHT + } + x += max_size + margin + } + + this.setDirtyCanvas(true, true) + } + + /** + * Returns the amount of time the graph has been running in milliseconds + * @return {number} number of milliseconds the graph has been running + */ + getTime(): number { + return this.globaltime + } + + /** + * Returns the amount of time accumulated using the fixedtime_lapse var. + * This is used in context where the time increments should be constant + * @return {number} number of milliseconds the graph has been running + */ + getFixedTime(): number { + return this.fixedtime + } + + /** + * Returns the amount of time it took to compute the latest iteration. + * Take into account that this number could be not correct + * if the nodes are using graphical actions + * @return {number} number of milliseconds it took the last cycle + */ + getElapsedTime(): number { + return this.elapsed_time + } + + /** + * Sends an event to all the nodes, useful to trigger stuff + * @param {String} eventname the name of the event (function to be called) + * @param {Array} params parameters in array format + */ + sendEventToAllNodes( + eventname: string, + params?: object | object[], + mode?: LGraphEventMode, + ): void { + mode = mode || LGraphEventMode.ALWAYS + + const nodes = this._nodes_in_order ? this._nodes_in_order : this._nodes + if (!nodes) return + + for (let j = 0, l = nodes.length; j < l; ++j) { + const node = nodes[j] + + // @ts-expect-error + if (node.constructor === LiteGraph.Subgraph && eventname != "onExecute") { + if (node.mode == mode) { + // @ts-expect-error Subgraph - not currently in use + node.sendEventToAllNodes(eventname, params, mode) } - + continue + } + + if (!node[eventname] || node.mode != mode) continue + if (params === undefined) { + node[eventname]() + } else if (params && params.constructor === Array) { + node[eventname].apply(node, params) + } else { + node[eventname](params) + } + } + } + + /** + * Runs an action on every canvas registered to this graph. + * @param action Action to run for every canvas + */ + canvasAction(action: (canvas: LGraphCanvas) => void): void { + this.list_of_graphcanvas?.forEach(action) + } + + /** @deprecated See {@link LGraph.canvasAction} */ + sendActionToCanvas>( + action: T, + params?: ParamsArray, + ): void { + if (!this.list_of_graphcanvas) return + + for (let i = 0; i < this.list_of_graphcanvas.length; ++i) { + const c = this.list_of_graphcanvas[i] + c[action]?.apply(c, params) + } + } + + /** + * Adds a new node instance to this graph + * @param {LGraphNode} node the instance of the node + */ + add( + node: LGraphNode | LGraphGroup, + skip_compute_order?: boolean, + ): LGraphNode | null | undefined { + if (!node) return + const { state } = this + + // Ensure created items are snapped + if (this.config.alwaysSnapToGrid) { + const snapTo = this.getSnapToGridSize() + if (snapTo) node.snapToGrid(snapTo) + } + + // LEGACY: This was changed from constructor === LGraphGroup + // groups + if (node instanceof LGraphGroup) { + // Assign group ID + if (node.id == null || node.id === -1) node.id = ++state.lastGroupId + if (node.id > state.lastGroupId) state.lastGroupId = node.id + + this._groups.push(node) + this.setDirtyCanvas(true) + this.change() + node.graph = this + this._version++ + return + } + + // nodes + if (node.id != -1 && this._nodes_by_id[node.id] != null) { + console.warn( + "LiteGraph: there is already a node with this ID, changing it", + ) + node.id = LiteGraph.use_uuids + ? LiteGraph.uuidv4() + : ++state.lastNodeId + } + + if (this._nodes.length >= LiteGraph.MAX_NUMBER_OF_NODES) { + throw "LiteGraph: max number of nodes in a graph reached" + } + + // give him an id + if (LiteGraph.use_uuids) { + if (node.id == null || node.id == -1) + node.id = LiteGraph.uuidv4() + } else { + if (node.id == null || node.id == -1) { + node.id = ++state.lastNodeId + } else if (typeof node.id === "number" && state.lastNodeId < node.id) { + state.lastNodeId = node.id + } + } + + node.graph = this + this._version++ + + this._nodes.push(node) + this._nodes_by_id[node.id] = node + + node.onAdded?.(this) + + if (this.config.align_to_grid) node.alignToGrid() + + if (!skip_compute_order) this.updateExecutionOrder() + + this.onNodeAdded?.(node) + + this.setDirtyCanvas(true) + this.change() + + return node // to chain actions + } + + /** + * Removes a node from the graph + * @param {LGraphNode} node the instance of the node + */ + remove(node: LGraphNode | LGraphGroup): void { + // LEGACY: This was changed from constructor === LiteGraph.LGraphGroup + if (node instanceof LGraphGroup) { + const index = this._groups.indexOf(node) + if (index != -1) { + this._groups.splice(index, 1) + } + node.graph = null + this._version++ + this.setDirtyCanvas(true, true) + this.change() + return + } + + // not found + if (this._nodes_by_id[node.id] == null) return + // cannot be removed + if (node.ignore_remove) return + + this.beforeChange() // sure? - almost sure is wrong + + // disconnect inputs + if (node.inputs) { + for (let i = 0; i < node.inputs.length; i++) { + const slot = node.inputs[i] + if (slot.link != null) node.disconnectInput(i) + } + } + + // disconnect outputs + if (node.outputs) { + for (let i = 0; i < node.outputs.length; i++) { + const slot = node.outputs[i] + if (slot.links?.length) node.disconnectOutput(i) + } + } + + // callback + node.onRemoved?.() + + node.graph = null + this._version++ + + // remove from canvas render + if (this.list_of_graphcanvas) { + for (let i = 0; i < this.list_of_graphcanvas.length; ++i) { + const canvas = this.list_of_graphcanvas[i] + if (canvas.selected_nodes[node.id]) + delete canvas.selected_nodes[node.id] + } + } + + // remove from containers + const pos = this._nodes.indexOf(node) + if (pos != -1) this._nodes.splice(pos, 1) + + delete this._nodes_by_id[node.id] + + this.onNodeRemoved?.(node) + + // close panels + this.canvasAction(c => c.checkPanels()) + + this.setDirtyCanvas(true, true) + this.afterChange() // sure? - almost sure is wrong + this.change() + + this.updateExecutionOrder() + } + + /** + * Returns a node by its id. + * @param {Number} id + */ + getNodeById(id: NodeId): LGraphNode | null { + return id != null + ? this._nodes_by_id[id] + : null + } + + /** + * Returns a list of nodes that matches a class + * @param {Class} classObject the class itself (not an string) + * @return {Array} a list with all the nodes of this type + */ + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + findNodesByClass(classObject: Function, result?: LGraphNode[]): LGraphNode[] { + result = result || [] + result.length = 0 + for (let i = 0, l = this._nodes.length; i < l; ++i) { + if (this._nodes[i].constructor === classObject) + result.push(this._nodes[i]) + } + return result + } + + /** + * Returns a list of nodes that matches a type + * @param {String} type the name of the node type + * @return {Array} a list with all the nodes of this type + */ + findNodesByType(type: string, result: LGraphNode[]): LGraphNode[] { + const matchType = type.toLowerCase() + result = result || [] + result.length = 0 + for (let i = 0, l = this._nodes.length; i < l; ++i) { + if (this._nodes[i].type?.toLowerCase() == matchType) + result.push(this._nodes[i]) + } + return result + } + + /** + * Returns the first node that matches a name in its title + * @param {String} name the name of the node to search + * @return {Node} the node or null + */ + findNodeByTitle(title: string): LGraphNode | null { + for (let i = 0, l = this._nodes.length; i < l; ++i) { + if (this._nodes[i].title == title) + return this._nodes[i] + } + return null + } + + /** + * Returns a list of nodes that matches a name + * @param {String} name the name of the node to search + * @return {Array} a list with all the nodes with this name + */ + findNodesByTitle(title: string): LGraphNode[] { + const result: LGraphNode[] = [] + for (let i = 0, l = this._nodes.length; i < l; ++i) { + if (this._nodes[i].title == title) + result.push(this._nodes[i]) + } + return result + } + + /** + * Returns the top-most node in this position of the canvas + * @param {number} x the x coordinate in canvas space + * @param {number} y the y coordinate in canvas space + * @param {Array} nodeList a list with all the nodes to search from, by default is all the nodes in the graph + * @return {LGraphNode} the node at this position or null + */ + getNodeOnPos( + x: number, + y: number, + nodeList?: LGraphNode[], + ): LGraphNode | null { + const nodes = nodeList || this._nodes + let i = nodes.length + while (--i >= 0) { + const node = nodes[i] + if (node.isPointInside(x, y)) return node + } + return null + } + + /** + * Returns the top-most group in that position + * @param x The x coordinate in canvas space + * @param y The y coordinate in canvas space + * @return The group or null + */ + getGroupOnPos(x: number, y: number): LGraphGroup | undefined { + return this._groups.toReversed().find(g => g.isPointInside(x, y)) + } + + /** + * Returns the top-most group with a titlebar in the provided position. + * @param x The x coordinate in canvas space + * @param y The y coordinate in canvas space + * @return The group or null + */ + getGroupTitlebarOnPos(x: number, y: number): LGraphGroup | undefined { + return this._groups.toReversed().find(g => g.isPointInTitlebar(x, y)) + } + + /** + * Finds a reroute a the given graph point + * @param x X co-ordinate in graph space + * @param y Y co-ordinate in graph space + * @returns The first reroute under the given co-ordinates, or undefined + */ + getRerouteOnPos(x: number, y: number): Reroute | undefined { + for (const reroute of this.reroutes.values()) { + const pos = reroute.pos + + if (isSortaInsideOctagon(x - pos[0], y - pos[1], 20)) return reroute } - - /** - * Removes a reroute from the graph - * @param id ID of reroute to remove - */ - removeReroute(id: RerouteId): void { - const { reroutes } = this - const reroute = reroutes.get(id) - if (!reroute) return - - // Extract reroute from the reroute chain - const { parentId, linkIds } = reroute - for (const reroute of reroutes.values()) { - if (reroute.parentId === id) reroute.parentId = parentId - } - - for (const linkId of linkIds) { - const link = this._links.get(linkId) - if (link && link.parentId === id) link.parentId = parentId - } - - reroutes.delete(id) - this.setDirtyCanvas(false, true) - } - - /** - * Destroys a link - * @param {Number} link_id - */ - removeLink(link_id: LinkId): void { - const link = this._links.get(link_id) - if (!link) return - - const node = this.getNodeById(link.target_id) - node?.disconnectInput(link.target_slot) - - link.disconnect(this) - } - - /** - * Creates a Object containing all the info about this graph, it can be serialized - * @deprecated Use {@link asSerialisable}, which returns the newer schema version. - * - * @return {Object} value of the node - */ - serialize(option?: { sortNodes: boolean }): ISerialisedGraph { - const { config, state, groups, nodes, reroutes, extra } = this.asSerialisable(option) - const linkArray = [...this._links.values()] - const links = linkArray.map(x => x.serialize()) - - if (reroutes.length) { - extra.reroutes = reroutes - - // Link parent IDs cannot go in 0.4 schema arrays - extra.linkExtensions = linkArray - .filter(x => x.parentId !== undefined) - .map(x => ({ id: x.id, parentId: x.parentId })) - } - return { - last_node_id: state.lastNodeId, - last_link_id: state.lastLinkId, - nodes, - links, - groups, - config, - extra, - version: LiteGraph.VERSION, - } - } - - /** - * Prepares a shallow copy of this object for immediate serialisation or structuredCloning. - * The return value should be discarded immediately. - * @param options Serialise options = currently `sortNodes: boolean`, whether to sort nodes by ID. - * @returns A shallow copy of parts of this graph, with shallow copies of its serialisable objects. - * Mutating the properties of the return object may result in changes to your graph. - * It is intended for use with {@link structuredClone} or {@link JSON.stringify}. - */ - asSerialisable(options?: { sortNodes: boolean }): SerialisableGraph { - const { config, state, extra } = this - - const nodeList = !LiteGraph.use_uuids && options?.sortNodes - // @ts-expect-error If LiteGraph.use_uuids is false, ids are numbers. - ? [...this._nodes].sort((a, b) => a.id - b.id) - : this._nodes - - const nodes = nodeList.map(node => node.serialize()) - const groups = this._groups.map(x => x.serialize()) - - const links = [...this._links.values()].map(x => x.asSerialisable()) - const reroutes = [...this.reroutes.values()].map(x => x.asSerialisable()) - - const data: SerialisableGraph = { - version: LGraph.serialisedSchemaVersion, - config, - state, - groups, - nodes, - links, - reroutes, - extra - } - - this.onSerialize?.(data) - return data - } - - /** - * Configure a graph from a JSON string - * @param {String} str configure a graph from a JSON string - * @param {Boolean} returns if there was any error parsing - */ - configure(data: ISerialisedGraph | SerialisableGraph, keep_old?: boolean): boolean | undefined { - // TODO: Finish typing configure() - if (!data) return - if (!keep_old) this.clear() - - const { extra } = data - let reroutes: SerialisableReroute[] | undefined - - // TODO: Determine whether this should this fall back to 0.4. - if (data.version === 0.4) { - // Deprecated - old schema version, links are arrays - if (Array.isArray(data.links)) { - for (const linkData of data.links) { - const link = LLink.createFromArray(linkData) - this._links.set(link.id, link) - } - } - //#region `extra` embeds for v0.4 - - // LLink parentIds - if (Array.isArray(extra?.linkExtensions)) { - for (const linkEx of extra.linkExtensions) { - const link = this._links.get(linkEx.id) - if (link) link.parentId = linkEx.parentId - } - } - - // Reroutes - reroutes = extra?.reroutes - - //#endregion `extra` embeds for v0.4 - } else { - // New schema - one version so far, no check required. - - // State - if (data.state) { - const { state: { lastGroupId, lastLinkId, lastNodeId, lastRerouteId } } = data - if (lastGroupId != null) this.state.lastGroupId = lastGroupId - if (lastLinkId != null) this.state.lastLinkId = lastLinkId - if (lastNodeId != null) this.state.lastNodeId = lastNodeId - if (lastRerouteId != null) this.state.lastRerouteId = lastRerouteId - } - - // Links - if (Array.isArray(data.links)) { - for (const linkData of data.links) { - const link = LLink.create(linkData) - this._links.set(link.id, link) - } - } - - reroutes = data.reroutes + } + + /** + * Snaps the provided items to a grid. + * + * Item positions are reounded to the nearest multiple of {@link LiteGraph.CANVAS_GRID_SIZE}. + * + * When {@link config}.{@link LGraphConfig.alwaysSnapToGrid alwaysSnapToGrid} is enabled + * and the grid size is falsy, a default of 1 is used. + * @param items The items to snap to the grid + * @todo Currently only snaps nodes. + */ + snapToGrid(items: Set): void { + const snapTo = this.getSnapToGridSize() + if (!snapTo) return + + getAllNestedItems(items).forEach((item) => { + if (!item.pinned) item.snapToGrid(snapTo) + }) + } + + /** + * Finds the size of the grid that items should be snapped to when moved. + * @returns The size of the grid that items should be snapped to + */ + getSnapToGridSize(): number { + // Default to 1 when always snapping + return this.config.alwaysSnapToGrid + ? LiteGraph.CANVAS_GRID_SIZE || 1 + : LiteGraph.CANVAS_GRID_SIZE + } + + /** + * Checks that the node type matches the node type registered, + * used when replacing a nodetype by a newer version during execution + * this replaces the ones using the old version with the new version + */ + checkNodeTypes() { + for (let i = 0; i < this._nodes.length; i++) { + const node = this._nodes[i] + const ctor = LiteGraph.registered_node_types[node.type] + if (node.constructor == ctor) continue + + console.log("node being replaced by newer version: " + node.type) + const newnode = LiteGraph.createNode(node.type) + this._nodes[i] = newnode + newnode.configure(node.serialize()) + newnode.graph = this + this._nodes_by_id[newnode.id] = newnode + + if (node.inputs) newnode.inputs = node.inputs.concat() + if (node.outputs) newnode.outputs = node.outputs.concat() + } + this.updateExecutionOrder() + } + + // ********** GLOBALS ***************** + onAction( + action: string, + param: unknown, + options: { action_call?: string }, + ): void { + this._input_nodes = this.findNodesByClass( + // @ts-expect-error Never impl. + LiteGraph.GraphInput, + this._input_nodes, + ) + for (let i = 0; i < this._input_nodes.length; ++i) { + const node = this._input_nodes[i] + if (node.properties.name != action) continue + + // wrap node.onAction(action, param); + node.actionDo(action, param, options) + break + } + } + + trigger(action: string, param: unknown) { + this.onTrigger?.(action, param) + } + + /** + * Tell this graph it has a global graph input of this type + * @param {String} name + * @param {String} type + * @param {*} value [optional] + */ + addInput(name: string, type: string, value?: unknown): void { + const input = this.inputs[name] + // already exist + if (input) return + + this.beforeChange() + this.inputs[name] = { name: name, type: type, value: value } + this._version++ + this.afterChange() + + this.onInputAdded?.(name, type) + this.onInputsOutputsChange?.() + } + + /** + * Assign a data to the global graph input + * @param {String} name + * @param {*} data + */ + setInputData(name: string, data: unknown): void { + const input = this.inputs[name] + if (!input) return + input.value = data + } + + /** + * Returns the current value of a global graph input + * @param {String} name + * @return {*} the data + */ + getInputData(name: string): unknown { + const input = this.inputs[name] + return input + ? input.value + : null + } + + /** + * Changes the name of a global graph input + * @param {String} old_name + * @param {String} new_name + */ + renameInput(old_name: string, name: string): boolean | undefined { + if (name == old_name) return + + if (!this.inputs[old_name]) return false + + if (this.inputs[name]) { + console.error("there is already one input with that name") + return false + } + + this.inputs[name] = this.inputs[old_name] + delete this.inputs[old_name] + this._version++ + + this.onInputRenamed?.(old_name, name) + this.onInputsOutputsChange?.() + } + + /** + * Changes the type of a global graph input + * @param {String} name + * @param {String} type + */ + changeInputType(name: string, type: string): boolean | undefined { + if (!this.inputs[name]) return false + + if ( + this.inputs[name].type && + String(this.inputs[name].type).toLowerCase() == String(type).toLowerCase() + ) { + return + } + + this.inputs[name].type = type + this._version++ + this.onInputTypeChanged?.(name, type) + } + + /** + * Removes a global graph input + * @param {String} name + * @param {String} type + */ + removeInput(name: string): boolean { + if (!this.inputs[name]) return false + + delete this.inputs[name] + this._version++ + + this.onInputRemoved?.(name) + this.onInputsOutputsChange?.() + return true + } + + /** + * Creates a global graph output + * @param {String} name + * @param {String} type + * @param {*} value + */ + addOutput(name: string, type: string, value: unknown): void { + this.outputs[name] = { name: name, type: type, value: value } + this._version++ + + this.onOutputAdded?.(name, type) + + this.onInputsOutputsChange?.() + } + + /** + * Assign a data to the global output + * @param {String} name + * @param {String} value + */ + setOutputData(name: string, value: unknown): void { + const output = this.outputs[name] + if (!output) return + output.value = value + } + + /** + * Returns the current value of a global graph output + * @param {String} name + * @return {*} the data + */ + getOutputData(name: string): unknown { + const output = this.outputs[name] + if (!output) return null + return output.value + } + + /** + * Renames a global graph output + * @param {String} old_name + * @param {String} new_name + */ + renameOutput(old_name: string, name: string): boolean | undefined { + if (!this.outputs[old_name]) return false + + if (this.outputs[name]) { + console.error("there is already one output with that name") + return false + } + + this.outputs[name] = this.outputs[old_name] + delete this.outputs[old_name] + this._version++ + + this.onOutputRenamed?.(old_name, name) + + this.onInputsOutputsChange?.() + } + + /** + * Changes the type of a global graph output + * @param {String} name + * @param {String} type + */ + changeOutputType(name: string, type: string): boolean | undefined { + if (!this.outputs[name]) return false + + if ( + this.outputs[name].type && + String(this.outputs[name].type).toLowerCase() == String(type).toLowerCase() + ) { + return + } + + this.outputs[name].type = type + this._version++ + this.onOutputTypeChanged?.(name, type) + } + + /** + * Removes a global graph output + * @param {String} name + */ + removeOutput(name: string): boolean { + if (!this.outputs[name]) return false + + delete this.outputs[name] + this._version++ + + this.onOutputRemoved?.(name) + + this.onInputsOutputsChange?.() + return true + } + + /** @todo Clean up - never implemented. */ + triggerInput(name: string, value: any): void { + const nodes = this.findNodesByTitle(name) + for (let i = 0; i < nodes.length; ++i) { + // @ts-expect-error + nodes[i].onTrigger(value) + } + } + + /** @todo Clean up - never implemented. */ + setCallback(name: string, func: any): void { + const nodes = this.findNodesByTitle(name) + for (let i = 0; i < nodes.length; ++i) { + // @ts-expect-error + nodes[i].setTrigger(func) + } + } + + // used for undo, called before any change is made to the graph + beforeChange(info?: LGraphNode): void { + this.onBeforeChange?.(this, info) + this.canvasAction(c => c.onBeforeChange?.(this)) + } + + // used to resend actions, called after any change is made to the graph + afterChange(info?: LGraphNode): void { + this.onAfterChange?.(this, info) + this.canvasAction(c => c.onAfterChange?.(this)) + } + + connectionChange(node: LGraphNode): void { + this.updateExecutionOrder() + this.onConnectionChange?.(node) + this._version++ + // TODO: Interface never implemented - any consumers? + // @ts-expect-error + this.canvasAction(c => c.onConnectionChange?.()) + } + + /** + * clears the triggered slot animation in all links (stop visual animation) + */ + clearTriggeredSlots(): void { + for (const link_info of this._links.values()) { + if (!link_info) continue + + if (link_info._last_time) link_info._last_time = 0 + } + } + + /* Called when something visually changed (not the graph!) */ + change(): void { + if (LiteGraph.debug) { + console.log("Graph changed") + } + this.canvasAction(c => c.setDirty(true, true)) + this.on_change?.(this) + } + + setDirtyCanvas(fg: boolean, bg?: boolean): void { + this.canvasAction(c => c.setDirty(fg, bg)) + } + + /** + * Configures a reroute on the graph where ID is already known (probably deserialisation). + * Creates the object if it does not exist. + * @param id Reroute ID + * @param pos Position in graph space + * @param linkIds IDs of links that pass through this reroute + */ + setReroute({ id, parentId, pos, linkIds }: SerialisableReroute): Reroute { + id ??= ++this.state.lastRerouteId + if (id > this.state.lastRerouteId) this.state.lastRerouteId = id + + const reroute = this.reroutes.get(id) ?? new Reroute(id, this) + reroute.update(parentId, pos, linkIds) + this.reroutes.set(id, reroute) + return reroute + } + + /** + * Creates a new reroute and adds it to the graph. + * @param pos Position in graph space + * @param links The links that will use this reroute (e.g. if from an output with multiple outputs, and all will use it) + * @param afterRerouteId If set, this reroute will be shown after the specified ID. + * Otherwise, the reroute will be added as the last on the link. + * @returns The newly created reroute - typically ignored. + */ + createReroute(pos: Point, before: LinkSegment): Reroute { + const rerouteId = ++this.state.lastRerouteId + const linkIds = before instanceof Reroute + ? before.linkIds + : [before.id] + const reroute = new Reroute(rerouteId, this, pos, before.parentId, linkIds) + this.reroutes.set(rerouteId, reroute) + for (const linkId of linkIds) { + const link = this._links.get(linkId) + if (!link) continue + if (link.parentId === before.parentId) link.parentId = rerouteId + LLink.getReroutes(this, link) + ?.filter(x => x.parentId === before.parentId) + .forEach(x => x.parentId = rerouteId) + } + + return reroute + } + + /** + * Removes a reroute from the graph + * @param id ID of reroute to remove + */ + removeReroute(id: RerouteId): void { + const { reroutes } = this + const reroute = reroutes.get(id) + if (!reroute) return + + // Extract reroute from the reroute chain + const { parentId, linkIds } = reroute + for (const reroute of reroutes.values()) { + if (reroute.parentId === id) reroute.parentId = parentId + } + + for (const linkId of linkIds) { + const link = this._links.get(linkId) + if (link && link.parentId === id) link.parentId = parentId + } + + reroutes.delete(id) + this.setDirtyCanvas(false, true) + } + + /** + * Destroys a link + * @param {Number} link_id + */ + removeLink(link_id: LinkId): void { + const link = this._links.get(link_id) + if (!link) return + + const node = this.getNodeById(link.target_id) + node?.disconnectInput(link.target_slot) + + link.disconnect(this) + } + + /** + * Creates a Object containing all the info about this graph, it can be serialized + * @deprecated Use {@link asSerialisable}, which returns the newer schema version. + * + * @return {Object} value of the node + */ + serialize(option?: { sortNodes: boolean }): ISerialisedGraph { + const { config, state, groups, nodes, reroutes, extra } = this.asSerialisable(option) + const linkArray = [...this._links.values()] + const links = linkArray.map(x => x.serialize()) + + if (reroutes.length) { + extra.reroutes = reroutes + + // Link parent IDs cannot go in 0.4 schema arrays + extra.linkExtensions = linkArray + .filter(x => x.parentId !== undefined) + .map(x => ({ id: x.id, parentId: x.parentId })) + } + return { + last_node_id: state.lastNodeId, + last_link_id: state.lastLinkId, + nodes, + links, + groups, + config, + extra, + version: LiteGraph.VERSION, + } + } + + /** + * Prepares a shallow copy of this object for immediate serialisation or structuredCloning. + * The return value should be discarded immediately. + * @param options Serialise options = currently `sortNodes: boolean`, whether to sort nodes by ID. + * @returns A shallow copy of parts of this graph, with shallow copies of its serialisable objects. + * Mutating the properties of the return object may result in changes to your graph. + * It is intended for use with {@link structuredClone} or {@link JSON.stringify}. + */ + asSerialisable(options?: { sortNodes: boolean }): SerialisableGraph { + const { config, state, extra } = this + + const nodeList = !LiteGraph.use_uuids && options?.sortNodes + // @ts-expect-error If LiteGraph.use_uuids is false, ids are numbers. + ? [...this._nodes].sort((a, b) => a.id - b.id) + : this._nodes + + const nodes = nodeList.map(node => node.serialize()) + const groups = this._groups.map(x => x.serialize()) + + const links = [...this._links.values()].map(x => x.asSerialisable()) + const reroutes = [...this.reroutes.values()].map(x => x.asSerialisable()) + + const data: SerialisableGraph = { + version: LGraph.serialisedSchemaVersion, + config, + state, + groups, + nodes, + links, + reroutes, + extra, + } + + this.onSerialize?.(data) + return data + } + + /** + * Configure a graph from a JSON string + * @param {String} str configure a graph from a JSON string + * @param {Boolean} returns if there was any error parsing + */ + configure( + data: ISerialisedGraph | SerialisableGraph, + keep_old?: boolean, + ): boolean | undefined { + // TODO: Finish typing configure() + if (!data) return + if (!keep_old) this.clear() + + const { extra } = data + let reroutes: SerialisableReroute[] | undefined + + // TODO: Determine whether this should this fall back to 0.4. + if (data.version === 0.4) { + // Deprecated - old schema version, links are arrays + if (Array.isArray(data.links)) { + for (const linkData of data.links) { + const link = LLink.createFromArray(linkData) + this._links.set(link.id, link) } - - // Reroutes - if (Array.isArray(reroutes)) { - for (const rerouteData of reroutes) { - const reroute = this.setReroute(rerouteData) - - // Drop broken links, and ignore reroutes with no valid links - if (!reroute.validateLinks(this._links)) - this.reroutes.delete(rerouteData.id) - } + } + // #region `extra` embeds for v0.4 + + // LLink parentIds + if (Array.isArray(extra?.linkExtensions)) { + for (const linkEx of extra.linkExtensions) { + const link = this._links.get(linkEx.id) + if (link) link.parentId = linkEx.parentId } - - const nodesData = data.nodes - - //copy all stored fields - for (const i in data) { - //links must be accepted - if (i == "nodes" || i == "groups" || i == "links" || i === "state" || i === "reroutes") - continue - this[i] = data[i] + } + + // Reroutes + reroutes = extra?.reroutes + + // #endregion `extra` embeds for v0.4 + } else { + // New schema - one version so far, no check required. + + // State + if (data.state) { + const { state: { lastGroupId, lastLinkId, lastNodeId, lastRerouteId } } = data + if (lastGroupId != null) this.state.lastGroupId = lastGroupId + if (lastLinkId != null) this.state.lastLinkId = lastLinkId + if (lastNodeId != null) this.state.lastNodeId = lastNodeId + if (lastRerouteId != null) this.state.lastRerouteId = lastRerouteId + } + + // Links + if (Array.isArray(data.links)) { + for (const linkData of data.links) { + const link = LLink.create(linkData) + this._links.set(link.id, link) } - - let error = false - - //create nodes - this._nodes = [] - if (nodesData) { - for (let i = 0, l = nodesData.length; i < l; ++i) { - const n_info = nodesData[i] //stored info - let node = LiteGraph.createNode(n_info.type, n_info.title) - if (!node) { - if (LiteGraph.debug) console.log("Node not found or has errors: " + n_info.type) - - //in case of error we create a replacement node to avoid losing info - node = new LGraphNode(undefined) - node.last_serialization = n_info - node.has_errors = true - error = true - //continue; - } - - node.id = n_info.id //id it or it will create a new id - this.add(node, true) //add before configure, otherwise configure cannot create links - } - - //configure nodes afterwards so they can reach each other - for (let i = 0, l = nodesData.length; i < l; ++i) { - const n_info = nodesData[i] - const node = this.getNodeById(n_info.id) - node?.configure(n_info) - } - } - - //groups - this._groups.length = 0 - if (data.groups) { - for (let i = 0; i < data.groups.length; ++i) { - // TODO: Search/remove these global object refs - const group = new LiteGraph.LGraphGroup() - group.configure(data.groups[i]) - this.add(group) - } + } + + reroutes = data.reroutes + } + + // Reroutes + if (Array.isArray(reroutes)) { + for (const rerouteData of reroutes) { + const reroute = this.setReroute(rerouteData) + + // Drop broken links, and ignore reroutes with no valid links + if (!reroute.validateLinks(this._links)) + this.reroutes.delete(rerouteData.id) + } + } + + const nodesData = data.nodes + + // copy all stored fields + for (const i in data) { + // links must be accepted + if ( + i == "nodes" || + i == "groups" || + i == "links" || + i === "state" || + i === "reroutes" + ) + continue + this[i] = data[i] + } + + let error = false + + // create nodes + this._nodes = [] + if (nodesData) { + for (let i = 0, l = nodesData.length; i < l; ++i) { + const n_info = nodesData[i] // stored info + let node = LiteGraph.createNode(n_info.type, n_info.title) + if (!node) { + if (LiteGraph.debug) console.log("Node not found or has errors: " + n_info.type) + + // in case of error we create a replacement node to avoid losing info + node = new LGraphNode(undefined) + node.last_serialization = n_info + node.has_errors = true + error = true + // continue; } - this.updateExecutionOrder() - - this.extra = data.extra || {} - - this.onConfigure?.(data) - this._version++ - this.setDirtyCanvas(true, true) - return error - } - load(url: string | Blob | URL | File, callback: () => void) { - const that = this - - // LEGACY: This was changed from constructor === File/Blob - //from file - if (url instanceof Blob || url instanceof File) { - const reader = new FileReader() - reader.addEventListener('load', function (event) { - const data = JSON.parse(event.target.result.toString()) - that.configure(data) - callback?.() - }) - - reader.readAsText(url) - return - } - - //is a string, then an URL - const req = new XMLHttpRequest() - req.open("GET", url, true) - req.send(null) - req.onload = function () { - if (req.status !== 200) { - console.error("Error loading graph:", req.status, req.response) - return - } - const data = JSON.parse(req.response) - that.configure(data) - callback?.() - } - req.onerror = function (err) { - console.error("Error loading graph:", err) - } - } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - onNodeTrace(node?: LGraphNode, msg?: string) { - //TODO - } + node.id = n_info.id // id it or it will create a new id + this.add(node, true) // add before configure, otherwise configure cannot create links + } + + // configure nodes afterwards so they can reach each other + for (let i = 0, l = nodesData.length; i < l; ++i) { + const n_info = nodesData[i] + const node = this.getNodeById(n_info.id) + node?.configure(n_info) + } + } + + // groups + this._groups.length = 0 + if (data.groups) { + for (let i = 0; i < data.groups.length; ++i) { + // TODO: Search/remove these global object refs + const group = new LiteGraph.LGraphGroup() + group.configure(data.groups[i]) + this.add(group) + } + } + + this.updateExecutionOrder() + + this.extra = data.extra || {} + + this.onConfigure?.(data) + this._version++ + this.setDirtyCanvas(true, true) + return error + } + + load(url: string | Blob | URL | File, callback: () => void) { + const that = this + + // LEGACY: This was changed from constructor === File/Blob + // from file + if (url instanceof Blob || url instanceof File) { + const reader = new FileReader() + reader.addEventListener("load", function (event) { + const data = JSON.parse(event.target.result.toString()) + that.configure(data) + callback?.() + }) + + reader.readAsText(url) + return + } + + // is a string, then an URL + const req = new XMLHttpRequest() + req.open("GET", url, true) + req.send(null) + req.onload = function () { + if (req.status !== 200) { + console.error("Error loading graph:", req.status, req.response) + return + } + const data = JSON.parse(req.response) + that.configure(data) + callback?.() + } + req.onerror = function (err) { + console.error("Error loading graph:", err) + } + } + + onNodeTrace(node?: LGraphNode, msg?: string) { + // TODO + } } diff --git a/src/LGraphBadge.ts b/src/LGraphBadge.ts index 0d0c3b0a..38aa718f 100644 --- a/src/LGraphBadge.ts +++ b/src/LGraphBadge.ts @@ -4,23 +4,23 @@ export enum BadgePosition { } export interface LGraphBadgeOptions { - text: string; - fgColor?: string; - bgColor?: string; - fontSize?: number; - padding?: number; - height?: number; - cornerRadius?: number; + text: string + fgColor?: string + bgColor?: string + fontSize?: number + padding?: number + height?: number + cornerRadius?: number } export class LGraphBadge { - text: string; - fgColor: string; - bgColor: string; - fontSize: number; - padding: number; - height: number; - cornerRadius: number; + text: string + fgColor: string + bgColor: string + fontSize: number + padding: number + height: number + cornerRadius: number constructor({ text, @@ -31,27 +31,27 @@ export class LGraphBadge { height = 20, cornerRadius = 5, }: LGraphBadgeOptions) { - this.text = text; - this.fgColor = fgColor; - this.bgColor = bgColor; - this.fontSize = fontSize; - this.padding = padding; - this.height = height; - this.cornerRadius = cornerRadius; + this.text = text + this.fgColor = fgColor + this.bgColor = bgColor + this.fontSize = fontSize + this.padding = padding + this.height = height + this.cornerRadius = cornerRadius } get visible() { - return this.text.length > 0; + return this.text.length > 0 } getWidth(ctx: CanvasRenderingContext2D) { - if (!this.visible) return 0; + if (!this.visible) return 0 - ctx.save(); - ctx.font = `${this.fontSize}px sans-serif`; - const textWidth = ctx.measureText(this.text).width; - ctx.restore(); - return textWidth + this.padding * 2; + ctx.save() + ctx.font = `${this.fontSize}px sans-serif` + const textWidth = ctx.measureText(this.text).width + ctx.restore() + return textWidth + this.padding * 2 } draw( @@ -59,32 +59,32 @@ export class LGraphBadge { x: number, y: number, ): void { - if (!this.visible) return; + if (!this.visible) return - ctx.save(); - ctx.font = `${this.fontSize}px sans-serif`; - const badgeWidth = this.getWidth(ctx); - const badgeX = 0; + ctx.save() + ctx.font = `${this.fontSize}px sans-serif` + const badgeWidth = this.getWidth(ctx) + const badgeX = 0 // Draw badge background - ctx.fillStyle = this.bgColor; - ctx.beginPath(); + ctx.fillStyle = this.bgColor + ctx.beginPath() if (ctx.roundRect) { - ctx.roundRect(x + badgeX, y, badgeWidth, this.height, this.cornerRadius); + ctx.roundRect(x + badgeX, y, badgeWidth, this.height, this.cornerRadius) } else { // Fallback for browsers that don't support roundRect - ctx.rect(x + badgeX, y, badgeWidth, this.height); + ctx.rect(x + badgeX, y, badgeWidth, this.height) } - ctx.fill(); + ctx.fill() // Draw badge text - ctx.fillStyle = this.fgColor; + ctx.fillStyle = this.fgColor ctx.fillText( this.text, x + badgeX + this.padding, - y + this.height - this.padding - ); + y + this.height - this.padding, + ) - ctx.restore(); + ctx.restore() } } diff --git a/src/LGraphCanvas.ts b/src/LGraphCanvas.ts index 61bac5d1..d545e8c6 100644 --- a/src/LGraphCanvas.ts +++ b/src/LGraphCanvas.ts @@ -1,14 +1,62 @@ -import type { CanvasColour, Dictionary, Direction, IBoundaryNodes, IContextMenuOptions, INodeSlot, INodeInputSlot, INodeOutputSlot, IOptionalSlotData, Point, Rect, Rect32, Size, IContextMenuValue, ISlotType, ConnectingLink, NullableProperties, Positionable, LinkSegment, ReadOnlyPoint, ReadOnlyRect } from "./interfaces" +import type { + CanvasColour, + Dictionary, + Direction, + IBoundaryNodes, + IContextMenuOptions, + INodeSlot, + INodeInputSlot, + INodeOutputSlot, + IOptionalSlotData, + Point, + Rect, + Rect32, + Size, + IContextMenuValue, + ISlotType, + ConnectingLink, + NullableProperties, + Positionable, + LinkSegment, + ReadOnlyPoint, + ReadOnlyRect, +} from "./interfaces" import type { IWidget, TWidgetValue } from "./types/widgets" import { LGraphNode, type NodeId } from "./LGraphNode" -import type { CanvasDragEvent, CanvasMouseEvent, CanvasEventDetail, CanvasPointerEvent, ICanvasPosition, IDeltaPosition } from "./types/events" +import type { + CanvasDragEvent, + CanvasMouseEvent, + CanvasEventDetail, + CanvasPointerEvent, + ICanvasPosition, + IDeltaPosition, +} from "./types/events" import type { ClipboardItems } from "./types/serialisation" import { LLink, type LinkId } from "./LLink" import type { LGraph } from "./LGraph" import type { ContextMenu } from "./ContextMenu" -import { CanvasItem, EaseFunction, LGraphEventMode, LinkDirection, LinkMarkerShape, LinkRenderType, RenderShape, TitleMode } from "./types/globalEnums" +import { + CanvasItem, + EaseFunction, + LGraphEventMode, + LinkDirection, + LinkMarkerShape, + LinkRenderType, + RenderShape, + TitleMode, +} from "./types/globalEnums" import { LGraphGroup } from "./LGraphGroup" -import { distance, overlapBounding, isPointInRect, findPointOnCurve, containsRect, isInRectangle, createBounds, isInRect, snapPoint } from "./measure" +import { + distance, + overlapBounding, + isPointInRect, + findPointOnCurve, + containsRect, + isInRectangle, + createBounds, + isInRect, + snapPoint, +} from "./measure" import { drawSlot, LabelPosition } from "./draw" import { DragAndScale } from "./DragAndScale" import { LinkReleaseContextExtended, LiteGraph, clamp } from "./litegraph" @@ -19,91 +67,94 @@ import { getAllNestedItems, findFirstNode } from "./utils/collections" import { CanvasPointer } from "./CanvasPointer" interface IShowSearchOptions { - node_to?: LGraphNode - node_from?: LGraphNode - slot_from: number | INodeOutputSlot | INodeInputSlot - type_filter_in?: ISlotType - type_filter_out?: ISlotType | false - - // TODO check for registered_slot_[in/out]_types not empty // this will be checked for functionality enabled : filter on slot type, in and out - do_type_filter?: boolean - show_general_if_none_on_typefilter?: boolean - show_general_after_typefiltered?: boolean - hide_on_mouse_leave?: boolean - show_all_if_empty?: boolean - show_all_on_open?: boolean + node_to?: LGraphNode + node_from?: LGraphNode + slot_from: number | INodeOutputSlot | INodeInputSlot + type_filter_in?: ISlotType + type_filter_out?: ISlotType | false + + // TODO check for registered_slot_[in/out]_types not empty // this will be checked for functionality enabled : filter on slot type, in and out + do_type_filter?: boolean + show_general_if_none_on_typefilter?: boolean + show_general_after_typefiltered?: boolean + hide_on_mouse_leave?: boolean + show_all_if_empty?: boolean + show_all_on_open?: boolean } interface ICreateNodeOptions { - /** input */ - nodeFrom?: LGraphNode - /** input */ - slotFrom?: number | INodeOutputSlot | INodeInputSlot - /** output */ - nodeTo?: LGraphNode - /** output */ - slotTo?: number | INodeOutputSlot | INodeInputSlot - /** pass the event coords */ - - // FIXME: Should not be optional - /** Position of new node */ - position?: Point - /** Create the connection from a reroute */ - afterRerouteId?: RerouteId - - // FIXME: Should not be optional - /** choose a nodetype to add, AUTO to set at first good */ - nodeType?: string //nodeNewType - /** adjust x,y */ - posAdd?: Point //-alphaPosY*30] - /** alpha, adjust the position x,y based on the new node size w,h */ - posSizeFix?: Point //-alphaPosY*2*/ - e?: CanvasMouseEvent - allow_searchbox?: boolean - /** See {@link LGraphCanvas.showSearchBox} */ - showSearchBox?: ((event: MouseEvent, options?: IShowSearchOptions) => HTMLDivElement | void) + /** input */ + nodeFrom?: LGraphNode + /** input */ + slotFrom?: number | INodeOutputSlot | INodeInputSlot + /** output */ + nodeTo?: LGraphNode + /** output */ + slotTo?: number | INodeOutputSlot | INodeInputSlot + /** pass the event coords */ + + // FIXME: Should not be optional + /** Position of new node */ + position?: Point + /** Create the connection from a reroute */ + afterRerouteId?: RerouteId + + // FIXME: Should not be optional + /** choose a nodetype to add, AUTO to set at first good */ + nodeType?: string // nodeNewType + /** adjust x,y */ + posAdd?: Point // -alphaPosY*30] + /** alpha, adjust the position x,y based on the new node size w,h */ + posSizeFix?: Point // -alphaPosY*2*/ + e?: CanvasMouseEvent + allow_searchbox?: boolean + /** See {@link LGraphCanvas.showSearchBox} */ + showSearchBox?: ( + event: MouseEvent, + options?: IShowSearchOptions, + ) => HTMLDivElement | void } interface ICloseableDiv extends HTMLDivElement { - close?(): void + close?(): void } interface IDialog extends ICloseableDiv { - modified?(): void - close?(): void - is_modified?: boolean + modified?(): void + close?(): void + is_modified?: boolean } interface IDialogOptions { - position?: Point - event?: MouseEvent - checkForInput?: boolean - closeOnLeave?: boolean - onclose?(): void + position?: Point + event?: MouseEvent + checkForInput?: boolean + closeOnLeave?: boolean + onclose?(): void } interface IDrawSelectionBoundingOptions { - shape?: RenderShape - title_height?: number - title_mode?: TitleMode - fgcolor?: CanvasColour - padding?: number - collapsed?: boolean + shape?: RenderShape + title_height?: number + title_mode?: TitleMode + fgcolor?: CanvasColour + padding?: number + collapsed?: boolean } /** @inheritdoc {@link LGraphCanvas.state} */ export interface LGraphCanvasState { - /** {@link Positionable} items are being dragged on the canvas. */ - draggingItems: boolean - /** The canvas itself is being dragged. */ - draggingCanvas: boolean - /** The canvas is read-only, preventing changes to nodes, disconnecting links, moving items, etc. */ - readOnly: boolean - - /** Bit flags indicating what is currently below the pointer. */ - hoveringOver: CanvasItem - /** If `true`, pointer move events will set the canvas cursor style. */ - shouldSetCursor: boolean + /** {@link Positionable} items are being dragged on the canvas. */ + draggingItems: boolean + /** The canvas itself is being dragged. */ + draggingCanvas: boolean + /** The canvas is read-only, preventing changes to nodes, disconnecting links, moving items, etc. */ + readOnly: boolean + + /** Bit flags indicating what is currently below the pointer. */ + hoveringOver: CanvasItem + /** If `true`, pointer move events will set the canvas cursor style. */ + shouldSetCursor: boolean } /** @@ -111,14 +162,14 @@ export interface LGraphCanvasState { * Includes maps of original copied IDs to newly created items. */ interface ClipboardPasteResult { - /** All successfully created items */ - created: Positionable[] - /** Map: original node IDs to newly created nodes */ - nodes: Map - /** Map: original link IDs to new link IDs */ - links: Map - /** Map: original reroute IDs to newly created reroutes */ - reroutes: Map + /** All successfully created items */ + created: Positionable[] + /** Map: original node IDs to newly created nodes */ + nodes: Map + /** Map: original link IDs to new link IDs */ + links: Map + /** Map: original reroute IDs to newly created reroutes */ + reroutes: Map } /** @@ -130,7727 +181,8181 @@ interface ClipboardPasteResult { * @param {Object} options [optional] { skip_rendering, autoresize, viewport } */ export class LGraphCanvas { - - /* Interaction */ - static #temp = new Float32Array(4) - static #temp_vec2 = new Float32Array(2) - static #tmp_area = new Float32Array(4) - static #margin_area = new Float32Array(4) - static #link_bounding = new Float32Array(4) - static #tempA = new Float32Array(2) - static #tempB = new Float32Array(2) - static #lTempA: Point = new Float32Array(2) - static #lTempB: Point = new Float32Array(2) - static #lTempC: Point = new Float32Array(2) - - static DEFAULT_BACKGROUND_IMAGE = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAQBJREFUeNrs1rEKwjAUhlETUkj3vP9rdmr1Ysammk2w5wdxuLgcMHyptfawuZX4pJSWZTnfnu/lnIe/jNNxHHGNn//HNbbv+4dr6V+11uF527arU7+u63qfa/bnmh8sWLBgwYJlqRf8MEptXPBXJXa37BSl3ixYsGDBMliwFLyCV/DeLIMFCxYsWLBMwSt4Be/NggXLYMGCBUvBK3iNruC9WbBgwYJlsGApeAWv4L1ZBgsWLFiwYJmCV/AK3psFC5bBggULloJX8BpdwXuzYMGCBctgwVLwCl7Be7MMFixYsGDBsu8FH1FaSmExVfAxBa/gvVmwYMGCZbBg/W4vAQYA5tRF9QYlv/QAAAAASUVORK5CYII=" - - /** Initialised from LiteGraphGlobal static block to avoid circular dependency. */ - static link_type_colors: Record - static gradients: Record = {} //cache of gradients - - static search_limit = -1 - static node_colors = { - red: { color: "#322", bgcolor: "#533", groupcolor: "#A88" }, - brown: { color: "#332922", bgcolor: "#593930", groupcolor: "#b06634" }, - green: { color: "#232", bgcolor: "#353", groupcolor: "#8A8" }, - blue: { color: "#223", bgcolor: "#335", groupcolor: "#88A" }, - pale_blue: { - color: "#2a363b", - bgcolor: "#3f5159", - groupcolor: "#3f789e" - }, - cyan: { color: "#233", bgcolor: "#355", groupcolor: "#8AA" }, - purple: { color: "#323", bgcolor: "#535", groupcolor: "#a1309b" }, - yellow: { color: "#432", bgcolor: "#653", groupcolor: "#b58b2a" }, - black: { color: "#222", bgcolor: "#000", groupcolor: "#444" } - } - - /** - * The state of this canvas, e.g. whether it is being dragged, or read-only. - * - * Implemented as a POCO that can be proxied without side-effects. - */ - state: LGraphCanvasState = { - draggingItems: false, - draggingCanvas: false, - readOnly: false, - hoveringOver: CanvasItem.Nothing, - shouldSetCursor: true, - } - - // Whether the canvas was previously being dragged prior to pressing space key. - // null if space key is not pressed. - private _previously_dragging_canvas: boolean | null = null - - //#region Legacy accessors - /** @deprecated @inheritdoc {@link LGraphCanvasState.readOnly} */ - get read_only(): boolean { - return this.state.readOnly - } - set read_only(value: boolean) { - this.state.readOnly = value - } - - get isDragging(): boolean { - return this.state.draggingItems - } - set isDragging(value: boolean) { - this.state.draggingItems = value - } - - /** @deprecated Replace all references with {@link pointer}.{@link CanvasPointer.isDown isDown}. */ - get pointer_is_down() { return this.pointer.isDown } - /** @deprecated Replace all references with {@link pointer}.{@link CanvasPointer.isDouble isDouble}. */ - get pointer_is_double() { return this.pointer.isDouble } - - - /** @deprecated @inheritdoc {@link LGraphCanvasState.draggingCanvas} */ - get dragging_canvas(): boolean { - return this.state.draggingCanvas - } - set dragging_canvas(value: boolean) { - this.state.draggingCanvas = value - } - //#endregion Legacy accessors - - - get title_text_font(): string { - return `${LiteGraph.NODE_TEXT_SIZE}px Arial` - } - - get inner_text_font(): string { - return `normal ${LiteGraph.NODE_SUBTEXT_SIZE}px Arial` - } - - options: { skip_events?: any; viewport?: any; skip_render?: any; autoresize?: any } - background_image: string - readonly ds: DragAndScale - readonly pointer: CanvasPointer - zoom_modify_alpha: boolean - zoom_speed: number - node_title_color: string - default_link_color: string - default_connection_color: { - input_off: string; input_on: string //"#BBD" - output_off: string; output_on: string //"#BBD" - } - default_connection_color_byType: Dictionary - default_connection_color_byTypeOff: Dictionary - highquality_render: boolean - use_gradients: boolean - editor_alpha: number - pause_rendering: boolean - clear_background: boolean - clear_background_color: string - render_only_selected: boolean - show_info: boolean - allow_dragcanvas: boolean - allow_dragnodes: boolean - allow_interaction: boolean - multi_select: boolean - allow_searchbox: boolean - allow_reconnect_links: boolean - align_to_grid: boolean - drag_mode: boolean - dragging_rectangle: Rect | null - filter?: string | null - set_canvas_dirty_on_mouse_event: boolean - always_render_background: boolean - render_shadows: boolean - render_canvas_border: boolean - render_connections_shadows: boolean - render_connections_border: boolean - render_curved_connections: boolean - render_connection_arrows: boolean - render_collapsed_slots: boolean - render_execution_order: boolean - render_title_colored: boolean - render_link_tooltip: boolean - - /** Controls whether reroutes are rendered at all. */ - reroutesEnabled: boolean = false - - /** Shape of the markers shown at the midpoint of links. Default: Circle */ - linkMarkerShape: LinkMarkerShape = LinkMarkerShape.Circle - links_render_mode: number - /** mouse in canvas coordinates, where 0,0 is the top-left corner of the blue rectangle */ - readonly mouse: Point - /** mouse in graph coordinates, where 0,0 is the top-left corner of the blue rectangle */ - readonly graph_mouse: Point - /** @deprecated LEGACY: REMOVE THIS, USE {@link graph_mouse} INSTEAD */ - canvas_mouse: Point - /** to personalize the search box */ - onSearchBox?: (helper: Element, str: string, canvas: LGraphCanvas) => any - onSearchBoxSelection?: (name: any, event: any, canvas: LGraphCanvas) => void - onMouse?: (e: CanvasMouseEvent) => boolean - /** to render background objects (behind nodes and connections) in the canvas affected by transform */ - onDrawBackground?: (ctx: CanvasRenderingContext2D, visible_area: any) => void - /** to render foreground objects (above nodes and connections) in the canvas affected by transform */ - onDrawForeground?: (arg0: CanvasRenderingContext2D, arg1: any) => void - connections_width: number - round_radius: number - /** The current node being drawn by {@link drawNode}. This should NOT be used to determine the currently selected node. See {@link selectedItems} */ - current_node: LGraphNode | null - /** used for widgets */ - node_widget?: [LGraphNode, IWidget] | null - /** The link to draw a tooltip for. */ - over_link_center: LinkSegment | null - last_mouse_position: Point - /** The visible area of this canvas. Tightly coupled with {@link ds}. */ - visible_area?: Rect32 - /** Contains all links and reroutes that were rendered. Repopulated every render cycle. */ - renderedPaths: Set = new Set() - visible_links?: LLink[] - connecting_links: ConnectingLink[] | null - /** The viewport of this canvas. Tightly coupled with {@link ds}. */ - readonly viewport?: Rect - autoresize: boolean - static active_canvas: LGraphCanvas - static onMenuNodeOutputs?(entries: IOptionalSlotData[]): IOptionalSlotData[] - frame = 0 - last_draw_time = 0 - render_time = 0 - fps = 0 - /** @deprecated See {@link LGraphCanvas.selectedItems} */ - selected_nodes: Dictionary = {} - /** All selected nodes, groups, and reroutes */ - selectedItems: Set = new Set() - /** The group currently being resized. */ - resizingGroup: LGraphGroup | null = null - /** @deprecated See {@link LGraphCanvas.selectedItems} */ - selected_group: LGraphGroup | null = null - visible_nodes: LGraphNode[] = [] - node_over?: LGraphNode - node_capturing_input?: LGraphNode - highlighted_links: Dictionary = {} - link_over_widget?: IWidget - link_over_widget_type?: string - - dirty_canvas: boolean = true - dirty_bgcanvas: boolean = true - /** A map of nodes that require selective-redraw */ - dirty_nodes = new Map() - dirty_area?: Rect - /** @deprecated Unused */ - node_in_panel?: LGraphNode - last_mouse: ReadOnlyPoint = [0, 0] - last_mouseclick: number = 0 - graph!: LGraph - _graph_stack: LGraph[] | null = null - canvas: HTMLCanvasElement - bgcanvas: HTMLCanvasElement - ctx?: CanvasRenderingContext2D - _events_binded?: boolean - _mousedown_callback?(e: PointerEvent): boolean - _mousewheel_callback?(e: WheelEvent): boolean - _mousemove_callback?(e: PointerEvent): boolean - _mouseup_callback?(e: PointerEvent): boolean - _mouseout_callback?(e: PointerEvent): boolean - _mousecancel_callback?(e: PointerEvent): boolean - _key_callback?(e: KeyboardEvent): boolean - _ondrop_callback?(e: DragEvent): unknown - /** @deprecated WebGL */ - gl?: never - bgctx?: CanvasRenderingContext2D - is_rendering?: boolean - /** @deprecated Panels */ - block_click?: boolean - /** @deprecated Panels */ - last_click_position?: Point - resizing_node?: LGraphNode - /** @deprecated See {@link LGraphCanvas.resizingGroup} */ - selected_group_resizing?: boolean - /** @deprecated See {@link pointer}.{@link CanvasPointer.dragStarted dragStarted} */ - last_mouse_dragging: boolean - onMouseDown: (arg0: CanvasMouseEvent) => void - _highlight_pos?: Point - _highlight_input?: INodeInputSlot - // TODO: Check if panels are used - /** @deprecated Panels */ - node_panel - /** @deprecated Panels */ - options_panel - onDropItem: (e: Event) => any - _bg_img: HTMLImageElement - _pattern?: CanvasPattern - _pattern_img: HTMLImageElement - // TODO: This looks like another panel thing - prompt_box: IDialog - search_box: HTMLDivElement - /** @deprecated Panels */ - SELECTED_NODE: LGraphNode - /** @deprecated Panels */ - NODEPANEL_IS_OPEN: boolean - - /** Once per frame check of snap to grid value. @todo Update on change. */ - #snapToGrid?: number - /** Set on keydown, keyup. @todo */ - #shiftDown: boolean = false - - getMenuOptions?(): IContextMenuValue[] - getExtraMenuOptions?(canvas: LGraphCanvas, options: IContextMenuValue[]): IContextMenuValue[] - static active_node: LGraphNode - /** called before modifying the graph */ - onBeforeChange?(graph: LGraph): void - /** called after modifying the graph */ - onAfterChange?(graph: LGraph): void - onClear?: () => void - /** called after moving a node @deprecated Does not handle multi-node move, and can return the wrong node. */ - onNodeMoved?: (node_dragged: LGraphNode) => void - /** called if the selection changes */ - onSelectionChange?: (selected_nodes: Dictionary) => void - /** called when rendering a tooltip */ - onDrawLinkTooltip?: (ctx: CanvasRenderingContext2D, link: LLink, canvas?: LGraphCanvas) => boolean - /** to render foreground objects not affected by transform (for GUIs) */ - onDrawOverlay?: (ctx: CanvasRenderingContext2D) => void - onRenderBackground?: (canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D) => boolean - onNodeDblClicked?: (n: LGraphNode) => void - onShowNodePanel?: (n: LGraphNode) => void - onNodeSelected?: (node: LGraphNode) => void - onNodeDeselected?: (node: LGraphNode) => void - onRender?: (canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D) => void - /** Implement this function to allow conversion of widget types to input types, e.g. number -> INT or FLOAT for widget link validation checks */ - getWidgetLinkType?: (widget: IWidget, node: LGraphNode) => string | null | undefined - - /** - * Creates a new instance of LGraphCanvas. - * @param canvas The canvas HTML element (or its id) to use, or null / undefined to leave blank. - * @param graph The graph that owns this canvas. - * @param options - */ - constructor(canvas: HTMLCanvasElement, graph: LGraph, options?: LGraphCanvas["options"]) { - options ||= {} - this.options = options - - //if(graph === undefined) - // throw ("No graph assigned"); - this.background_image = LGraphCanvas.DEFAULT_BACKGROUND_IMAGE - - this.ds = new DragAndScale() - this.pointer = new CanvasPointer(this.canvas) - this.zoom_modify_alpha = true //otherwise it generates ugly patterns when scaling down too much - this.zoom_speed = 1.1 // in range (1.01, 2.5). Less than 1 will invert the zoom direction - - this.node_title_color = LiteGraph.NODE_TITLE_COLOR - this.default_link_color = LiteGraph.LINK_COLOR - this.default_connection_color = { - input_off: "#778", - input_on: "#7F7", //"#BBD" - output_off: "#778", - output_on: "#7F7" //"#BBD" - } - this.default_connection_color_byType = { - /*number: "#7F7", + /* Interaction */ + static #temp = new Float32Array(4) + static #temp_vec2 = new Float32Array(2) + static #tmp_area = new Float32Array(4) + static #margin_area = new Float32Array(4) + static #link_bounding = new Float32Array(4) + static #tempA = new Float32Array(2) + static #tempB = new Float32Array(2) + static #lTempA: Point = new Float32Array(2) + static #lTempB: Point = new Float32Array(2) + static #lTempC: Point = new Float32Array(2) + + static DEFAULT_BACKGROUND_IMAGE = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAQBJREFUeNrs1rEKwjAUhlETUkj3vP9rdmr1Ysammk2w5wdxuLgcMHyptfawuZX4pJSWZTnfnu/lnIe/jNNxHHGNn//HNbbv+4dr6V+11uF527arU7+u63qfa/bnmh8sWLBgwYJlqRf8MEptXPBXJXa37BSl3ixYsGDBMliwFLyCV/DeLIMFCxYsWLBMwSt4Be/NggXLYMGCBUvBK3iNruC9WbBgwYJlsGApeAWv4L1ZBgsWLFiwYJmCV/AK3psFC5bBggULloJX8BpdwXuzYMGCBctgwVLwCl7Be7MMFixYsGDBsu8FH1FaSmExVfAxBa/gvVmwYMGCZbBg/W4vAQYA5tRF9QYlv/QAAAAASUVORK5CYII=" + + /** Initialised from LiteGraphGlobal static block to avoid circular dependency. */ + static link_type_colors: Record + static gradients: Record = {} // cache of gradients + + static search_limit = -1 + static node_colors = { + red: { color: "#322", bgcolor: "#533", groupcolor: "#A88" }, + brown: { color: "#332922", bgcolor: "#593930", groupcolor: "#b06634" }, + green: { color: "#232", bgcolor: "#353", groupcolor: "#8A8" }, + blue: { color: "#223", bgcolor: "#335", groupcolor: "#88A" }, + pale_blue: { + color: "#2a363b", + bgcolor: "#3f5159", + groupcolor: "#3f789e", + }, + cyan: { color: "#233", bgcolor: "#355", groupcolor: "#8AA" }, + purple: { color: "#323", bgcolor: "#535", groupcolor: "#a1309b" }, + yellow: { color: "#432", bgcolor: "#653", groupcolor: "#b58b2a" }, + black: { color: "#222", bgcolor: "#000", groupcolor: "#444" }, + } + + /** + * The state of this canvas, e.g. whether it is being dragged, or read-only. + * + * Implemented as a POCO that can be proxied without side-effects. + */ + state: LGraphCanvasState = { + draggingItems: false, + draggingCanvas: false, + readOnly: false, + hoveringOver: CanvasItem.Nothing, + shouldSetCursor: true, + } + + // Whether the canvas was previously being dragged prior to pressing space key. + // null if space key is not pressed. + private _previously_dragging_canvas: boolean | null = null + + // #region Legacy accessors + /** @deprecated @inheritdoc {@link LGraphCanvasState.readOnly} */ + get read_only(): boolean { + return this.state.readOnly + } + + set read_only(value: boolean) { + this.state.readOnly = value + } + + get isDragging(): boolean { + return this.state.draggingItems + } + + set isDragging(value: boolean) { + this.state.draggingItems = value + } + + /** @deprecated Replace all references with {@link pointer}.{@link CanvasPointer.isDown isDown}. */ + get pointer_is_down() { + return this.pointer.isDown + } + + /** @deprecated Replace all references with {@link pointer}.{@link CanvasPointer.isDouble isDouble}. */ + get pointer_is_double() { + return this.pointer.isDouble + } + + /** @deprecated @inheritdoc {@link LGraphCanvasState.draggingCanvas} */ + get dragging_canvas(): boolean { + return this.state.draggingCanvas + } + + set dragging_canvas(value: boolean) { + this.state.draggingCanvas = value + } + // #endregion Legacy accessors + + get title_text_font(): string { + return `${LiteGraph.NODE_TEXT_SIZE}px Arial` + } + + get inner_text_font(): string { + return `normal ${LiteGraph.NODE_SUBTEXT_SIZE}px Arial` + } + + options: { + skip_events?: any + viewport?: any + skip_render?: any + autoresize?: any + } + + background_image: string + readonly ds: DragAndScale + readonly pointer: CanvasPointer + zoom_modify_alpha: boolean + zoom_speed: number + node_title_color: string + default_link_color: string + default_connection_color: { + input_off: string + input_on: string // "#BBD" + output_off: string + output_on: string // "#BBD" + } + + default_connection_color_byType: Dictionary + default_connection_color_byTypeOff: Dictionary + highquality_render: boolean + use_gradients: boolean + editor_alpha: number + pause_rendering: boolean + clear_background: boolean + clear_background_color: string + render_only_selected: boolean + show_info: boolean + allow_dragcanvas: boolean + allow_dragnodes: boolean + allow_interaction: boolean + multi_select: boolean + allow_searchbox: boolean + allow_reconnect_links: boolean + align_to_grid: boolean + drag_mode: boolean + dragging_rectangle: Rect | null + filter?: string | null + set_canvas_dirty_on_mouse_event: boolean + always_render_background: boolean + render_shadows: boolean + render_canvas_border: boolean + render_connections_shadows: boolean + render_connections_border: boolean + render_curved_connections: boolean + render_connection_arrows: boolean + render_collapsed_slots: boolean + render_execution_order: boolean + render_title_colored: boolean + render_link_tooltip: boolean + + /** Controls whether reroutes are rendered at all. */ + reroutesEnabled: boolean = false + + /** Shape of the markers shown at the midpoint of links. Default: Circle */ + linkMarkerShape: LinkMarkerShape = LinkMarkerShape.Circle + links_render_mode: number + /** mouse in canvas coordinates, where 0,0 is the top-left corner of the blue rectangle */ + readonly mouse: Point + /** mouse in graph coordinates, where 0,0 is the top-left corner of the blue rectangle */ + readonly graph_mouse: Point + /** @deprecated LEGACY: REMOVE THIS, USE {@link graph_mouse} INSTEAD */ + canvas_mouse: Point + /** to personalize the search box */ + onSearchBox?: (helper: Element, str: string, canvas: LGraphCanvas) => any + onSearchBoxSelection?: (name: any, event: any, canvas: LGraphCanvas) => void + onMouse?: (e: CanvasMouseEvent) => boolean + /** to render background objects (behind nodes and connections) in the canvas affected by transform */ + onDrawBackground?: (ctx: CanvasRenderingContext2D, visible_area: any) => void + /** to render foreground objects (above nodes and connections) in the canvas affected by transform */ + onDrawForeground?: (arg0: CanvasRenderingContext2D, arg1: any) => void + connections_width: number + round_radius: number + /** The current node being drawn by {@link drawNode}. This should NOT be used to determine the currently selected node. See {@link selectedItems} */ + current_node: LGraphNode | null + /** used for widgets */ + node_widget?: [LGraphNode, IWidget] | null + /** The link to draw a tooltip for. */ + over_link_center: LinkSegment | null + last_mouse_position: Point + /** The visible area of this canvas. Tightly coupled with {@link ds}. */ + visible_area?: Rect32 + /** Contains all links and reroutes that were rendered. Repopulated every render cycle. */ + renderedPaths: Set = new Set() + visible_links?: LLink[] + connecting_links: ConnectingLink[] | null + /** The viewport of this canvas. Tightly coupled with {@link ds}. */ + readonly viewport?: Rect + autoresize: boolean + static active_canvas: LGraphCanvas + static onMenuNodeOutputs?( + entries: IOptionalSlotData[], + ): IOptionalSlotData[] + frame = 0 + last_draw_time = 0 + render_time = 0 + fps = 0 + /** @deprecated See {@link LGraphCanvas.selectedItems} */ + selected_nodes: Dictionary = {} + /** All selected nodes, groups, and reroutes */ + selectedItems: Set = new Set() + /** The group currently being resized. */ + resizingGroup: LGraphGroup | null = null + /** @deprecated See {@link LGraphCanvas.selectedItems} */ + selected_group: LGraphGroup | null = null + visible_nodes: LGraphNode[] = [] + node_over?: LGraphNode + node_capturing_input?: LGraphNode + highlighted_links: Dictionary = {} + link_over_widget?: IWidget + link_over_widget_type?: string + + dirty_canvas: boolean = true + dirty_bgcanvas: boolean = true + /** A map of nodes that require selective-redraw */ + dirty_nodes = new Map() + dirty_area?: Rect + /** @deprecated Unused */ + node_in_panel?: LGraphNode + last_mouse: ReadOnlyPoint = [0, 0] + last_mouseclick: number = 0 + graph!: LGraph + _graph_stack: LGraph[] | null = null + canvas: HTMLCanvasElement + bgcanvas: HTMLCanvasElement + ctx?: CanvasRenderingContext2D + _events_binded?: boolean + _mousedown_callback?(e: PointerEvent): boolean + _mousewheel_callback?(e: WheelEvent): boolean + _mousemove_callback?(e: PointerEvent): boolean + _mouseup_callback?(e: PointerEvent): boolean + _mouseout_callback?(e: PointerEvent): boolean + _mousecancel_callback?(e: PointerEvent): boolean + _key_callback?(e: KeyboardEvent): boolean + _ondrop_callback?(e: DragEvent): unknown + /** @deprecated WebGL */ + gl?: never + bgctx?: CanvasRenderingContext2D + is_rendering?: boolean + /** @deprecated Panels */ + block_click?: boolean + /** @deprecated Panels */ + last_click_position?: Point + resizing_node?: LGraphNode + /** @deprecated See {@link LGraphCanvas.resizingGroup} */ + selected_group_resizing?: boolean + /** @deprecated See {@link pointer}.{@link CanvasPointer.dragStarted dragStarted} */ + last_mouse_dragging: boolean + onMouseDown: (arg0: CanvasMouseEvent) => void + _highlight_pos?: Point + _highlight_input?: INodeInputSlot + // TODO: Check if panels are used + /** @deprecated Panels */ + node_panel + /** @deprecated Panels */ + options_panel + onDropItem: (e: Event) => any + _bg_img: HTMLImageElement + _pattern?: CanvasPattern + _pattern_img: HTMLImageElement + // TODO: This looks like another panel thing + prompt_box: IDialog + search_box: HTMLDivElement + /** @deprecated Panels */ + SELECTED_NODE: LGraphNode + /** @deprecated Panels */ + NODEPANEL_IS_OPEN: boolean + + /** Once per frame check of snap to grid value. @todo Update on change. */ + #snapToGrid?: number + /** Set on keydown, keyup. @todo */ + #shiftDown: boolean = false + + getMenuOptions?(): IContextMenuValue[] + getExtraMenuOptions?( + canvas: LGraphCanvas, + options: IContextMenuValue[], + ): IContextMenuValue[] + static active_node: LGraphNode + /** called before modifying the graph */ + onBeforeChange?(graph: LGraph): void + /** called after modifying the graph */ + onAfterChange?(graph: LGraph): void + onClear?: () => void + /** called after moving a node @deprecated Does not handle multi-node move, and can return the wrong node. */ + onNodeMoved?: (node_dragged: LGraphNode) => void + /** called if the selection changes */ + onSelectionChange?: (selected_nodes: Dictionary) => void + /** called when rendering a tooltip */ + onDrawLinkTooltip?: ( + ctx: CanvasRenderingContext2D, + link: LLink, + canvas?: LGraphCanvas, + ) => boolean + + /** to render foreground objects not affected by transform (for GUIs) */ + onDrawOverlay?: (ctx: CanvasRenderingContext2D) => void + onRenderBackground?: ( + canvas: HTMLCanvasElement, + ctx: CanvasRenderingContext2D, + ) => boolean + + onNodeDblClicked?: (n: LGraphNode) => void + onShowNodePanel?: (n: LGraphNode) => void + onNodeSelected?: (node: LGraphNode) => void + onNodeDeselected?: (node: LGraphNode) => void + onRender?: (canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D) => void + /** Implement this function to allow conversion of widget types to input types, e.g. number -> INT or FLOAT for widget link validation checks */ + getWidgetLinkType?: ( + widget: IWidget, + node: LGraphNode, + ) => string | null | undefined + + /** + * Creates a new instance of LGraphCanvas. + * @param canvas The canvas HTML element (or its id) to use, or null / undefined to leave blank. + * @param graph The graph that owns this canvas. + * @param options + */ + constructor( + canvas: HTMLCanvasElement, + graph: LGraph, + options?: LGraphCanvas["options"], + ) { + options ||= {} + this.options = options + + // if(graph === undefined) + // throw ("No graph assigned"); + this.background_image = LGraphCanvas.DEFAULT_BACKGROUND_IMAGE + + this.ds = new DragAndScale() + this.pointer = new CanvasPointer(this.canvas) + this.zoom_modify_alpha = true // otherwise it generates ugly patterns when scaling down too much + this.zoom_speed = 1.1 // in range (1.01, 2.5). Less than 1 will invert the zoom direction + + this.node_title_color = LiteGraph.NODE_TITLE_COLOR + this.default_link_color = LiteGraph.LINK_COLOR + this.default_connection_color = { + input_off: "#778", + input_on: "#7F7", // "#BBD" + output_off: "#778", + output_on: "#7F7", // "#BBD" + } + this.default_connection_color_byType = { + /* number: "#7F7", string: "#77F", - boolean: "#F77",*/ - } - this.default_connection_color_byTypeOff = { - /*number: "#474", + boolean: "#F77", */ + } + this.default_connection_color_byTypeOff = { + /* number: "#474", string: "#447", - boolean: "#744",*/ + boolean: "#744", */ + } + + this.highquality_render = true + this.use_gradients = false // set to true to render titlebar with gradients + this.editor_alpha = 1 // used for transition + this.pause_rendering = false + this.clear_background = true + this.clear_background_color = "#222" + + this.render_only_selected = true + this.show_info = true + this.allow_dragcanvas = true + this.allow_dragnodes = true + this.allow_interaction = true // allow to control widgets, buttons, collapse, etc + this.multi_select = false // allow selecting multi nodes without pressing extra keys + this.allow_searchbox = true + this.allow_reconnect_links = true // allows to change a connection with having to redo it again + this.align_to_grid = false // snap to grid + + this.drag_mode = false + this.dragging_rectangle = null + + this.filter = null // allows to filter to only accept some type of nodes in a graph + + this.set_canvas_dirty_on_mouse_event = true // forces to redraw the canvas on mouse events (except move) + this.always_render_background = false + this.render_shadows = true + this.render_canvas_border = true + this.render_connections_shadows = false // too much cpu + this.render_connections_border = true + this.render_curved_connections = false + this.render_connection_arrows = false + this.render_collapsed_slots = true + this.render_execution_order = false + this.render_title_colored = true + this.render_link_tooltip = true + + this.links_render_mode = LinkRenderType.SPLINE_LINK + + this.mouse = [0, 0] + this.graph_mouse = [0, 0] + this.canvas_mouse = this.graph_mouse + + // to personalize the search box + this.onSearchBox = null + this.onSearchBoxSelection = null + + // callbacks + this.onMouse = null + this.onDrawBackground = null + this.onDrawForeground = null + this.onDrawOverlay = null + this.onDrawLinkTooltip = null + this.onNodeMoved = null + this.onSelectionChange = null + // FIXME: Typo, does nothing + // called before any link changes + // @ts-expect-error + this.onConnectingChange = null + this.onBeforeChange = null + this.onAfterChange = null + + this.connections_width = 3 + this.round_radius = 8 + + this.current_node = null + this.node_widget = null + this.over_link_center = null + this.last_mouse_position = [0, 0] + this.visible_area = this.ds.visible_area + this.visible_links = [] + this.connecting_links = null // Explicitly null-checked + + this.viewport = options.viewport || null // to constraint render area to a portion of the canvas + + // link canvas and graph + graph?.attachCanvas(this) + + this.setCanvas(canvas, options.skip_events) + this.clear() + + if (!options.skip_render) { + this.startRendering() + } + + this.autoresize = options.autoresize + } + + static getFileExtension(url: string): string { + const question = url.indexOf("?") + if (question !== -1) url = url.substring(0, question) + + const point = url.lastIndexOf(".") + return point === -1 + ? "" + : url.substring(point + 1).toLowerCase() + } + + static onGroupAdd(info: unknown, entry: unknown, mouse_event: MouseEvent): void { + const canvas = LGraphCanvas.active_canvas + + const group = new LiteGraph.LGraphGroup() + group.pos = canvas.convertEventToCanvasOffset(mouse_event) + canvas.graph.add(group) + } + + /** + * @deprecated Functionality moved to {@link getBoundaryNodes}. The new function returns null on failure, instead of an object with all null properties. + * Determines the furthest nodes in each direction + * @param {Dictionary} nodes the nodes to from which boundary nodes will be extracted + * @return {{left: LGraphNode, top: LGraphNode, right: LGraphNode, bottom: LGraphNode}} + */ + static getBoundaryNodes( + nodes: LGraphNode[] | Dictionary, + ): NullableProperties { + const _nodes = Array.isArray(nodes) ? nodes : Object.values(nodes) + return ( + getBoundaryNodes(_nodes) ?? { + top: null, + right: null, + bottom: null, + left: null, + } + ) + } + + /** + * @deprecated Functionality moved to {@link alignNodes}. The new function does not set dirty canvas. + * @param {Dictionary} nodes a list of nodes + * @param {"top"|"bottom"|"left"|"right"} direction Direction to align the nodes + * @param {LGraphNode?} align_to Node to align to (if null, align to the furthest node in the given direction) + */ + static alignNodes( + nodes: Dictionary, + direction: Direction, + align_to?: LGraphNode, + ): void { + alignNodes(Object.values(nodes), direction, align_to) + LGraphCanvas.active_canvas.setDirty(true, true) + } + + static onNodeAlign( + value: IContextMenuValue, + options: IContextMenuOptions, + event: MouseEvent, + prev_menu: ContextMenu, + node: LGraphNode, + ): void { + new LiteGraph.ContextMenu(["Top", "Bottom", "Left", "Right"], { + event: event, + callback: inner_clicked, + parentMenu: prev_menu, + }) + + function inner_clicked(value: string) { + alignNodes( + Object.values(LGraphCanvas.active_canvas.selected_nodes), + value.toLowerCase() as Direction, + node, + ) + LGraphCanvas.active_canvas.setDirty(true, true) + } + } + + static onGroupAlign( + value: IContextMenuValue, + options: IContextMenuOptions, + event: MouseEvent, + prev_menu: ContextMenu, + ): void { + new LiteGraph.ContextMenu(["Top", "Bottom", "Left", "Right"], { + event: event, + callback: inner_clicked, + parentMenu: prev_menu, + }) + + function inner_clicked(value: string) { + alignNodes( + Object.values(LGraphCanvas.active_canvas.selected_nodes), + value.toLowerCase() as Direction, + ) + LGraphCanvas.active_canvas.setDirty(true, true) + } + } + + static createDistributeMenu( + value: IContextMenuValue, + options: IContextMenuOptions, + event: MouseEvent, + prev_menu: ContextMenu, + node: LGraphNode, + ): void { + new LiteGraph.ContextMenu(["Vertically", "Horizontally"], { + event, + callback: inner_clicked, + parentMenu: prev_menu, + }) + + function inner_clicked(value: string) { + const canvas = LGraphCanvas.active_canvas + distributeNodes(Object.values(canvas.selected_nodes), value === "Horizontally") + canvas.setDirty(true, true) + } + } + + static onMenuAdd( + node: LGraphNode, + options: IContextMenuOptions, + e: MouseEvent, + prev_menu: ContextMenu, + callback?: (node: LGraphNode) => void, + ): boolean { + const canvas = LGraphCanvas.active_canvas + const ref_window = canvas.getCanvasWindow() + const graph = canvas.graph + if (!graph) return + + function inner_onMenuAdded(base_category: string, prev_menu: ContextMenu): void { + const categories = LiteGraph + .getNodeTypesCategories(canvas.filter || graph.filter) + .filter(function (category) { + return category.startsWith(base_category) + }) + const entries = [] + + categories.map(function (category) { + if (!category) return + + const base_category_regex = new RegExp("^(" + base_category + ")") + const category_name = category + .replace(base_category_regex, "") + .split("/")[0] + const category_path = + base_category === "" + ? category_name + "/" + : base_category + category_name + "/" + + let name = category_name + if (name.indexOf("::") != -1) + // in case it has a namespace like "shader::math/rand" it hides the namespace + name = name.split("::")[1] + + const index = entries.findIndex(function (entry) { + return entry.value === category_path + }) + if (index === -1) { + entries.push({ + value: category_path, + content: name, + has_submenu: true, + callback: function (value, event, mouseEvent, contextMenu) { + inner_onMenuAdded(value.value, contextMenu) + }, + }) + } + }) + + const nodes = LiteGraph.getNodeTypesInCategory( + base_category.slice(0, -1), + canvas.filter || graph.filter, + ) + nodes.map(function (node) { + if (node.skip_list) return + + const entry = { + value: node.type, + content: node.title, + has_submenu: false, + callback: function (value, event, mouseEvent, contextMenu) { + const first_event = contextMenu.getFirstEvent() + canvas.graph.beforeChange() + const node = LiteGraph.createNode(value.value) + if (node) { + node.pos = canvas.convertEventToCanvasOffset(first_event) + canvas.graph.add(node) + } + + callback?.(node) + canvas.graph.afterChange() + }, } - this.highquality_render = true - this.use_gradients = false //set to true to render titlebar with gradients - this.editor_alpha = 1 //used for transition - this.pause_rendering = false - this.clear_background = true - this.clear_background_color = "#222" - - this.render_only_selected = true - this.show_info = true - this.allow_dragcanvas = true - this.allow_dragnodes = true - this.allow_interaction = true //allow to control widgets, buttons, collapse, etc - this.multi_select = false //allow selecting multi nodes without pressing extra keys - this.allow_searchbox = true - this.allow_reconnect_links = true //allows to change a connection with having to redo it again - this.align_to_grid = false //snap to grid - - this.drag_mode = false - this.dragging_rectangle = null - - this.filter = null //allows to filter to only accept some type of nodes in a graph - - this.set_canvas_dirty_on_mouse_event = true //forces to redraw the canvas on mouse events (except move) - this.always_render_background = false - this.render_shadows = true - this.render_canvas_border = true - this.render_connections_shadows = false //too much cpu - this.render_connections_border = true - this.render_curved_connections = false - this.render_connection_arrows = false - this.render_collapsed_slots = true - this.render_execution_order = false - this.render_title_colored = true - this.render_link_tooltip = true - - this.links_render_mode = LinkRenderType.SPLINE_LINK - - this.mouse = [0, 0] - this.graph_mouse = [0, 0] - this.canvas_mouse = this.graph_mouse - - //to personalize the search box - this.onSearchBox = null - this.onSearchBoxSelection = null - - //callbacks - this.onMouse = null - this.onDrawBackground = null - this.onDrawForeground = null - this.onDrawOverlay = null - this.onDrawLinkTooltip = null - this.onNodeMoved = null - this.onSelectionChange = null - // FIXME: Typo, does nothing - //called before any link changes - // @ts-expect-error - this.onConnectingChange = null - this.onBeforeChange = null - this.onAfterChange = null + entries.push(entry) + }) + + // @ts-expect-error Remove param ref_window - unused + new LiteGraph.ContextMenu(entries, { event: e, parentMenu: prev_menu }, ref_window) + } + + inner_onMenuAdded("", prev_menu) + return false + } - this.connections_width = 3 - this.round_radius = 8 + static onMenuCollapseAll() {} + static onMenuNodeEdit() {} - this.current_node = null - this.node_widget = null - this.over_link_center = null - this.last_mouse_position = [0, 0] - this.visible_area = this.ds.visible_area - this.visible_links = [] - this.connecting_links = null // Explicitly null-checked + /** @param options Parameter is never used */ + static showMenuNodeOptionalInputs( + v: unknown, + options: INodeInputSlot[], + e: MouseEvent, + prev_menu: ContextMenu, + node: LGraphNode, + ): boolean { + if (!node) return - this.viewport = options.viewport || null //to constraint render area to a portion of the canvas + // FIXME: Static function this + const that = this + const canvas = LGraphCanvas.active_canvas + const ref_window = canvas.getCanvasWindow() - //link canvas and graph - graph?.attachCanvas(this) + options = node.onGetInputs + ? node.onGetInputs() + : node.optional_inputs - this.setCanvas(canvas, options.skip_events) - this.clear() + let entries: IOptionalSlotData[] = [] + if (options) { + for (let i = 0; i < options.length; i++) { + const entry = options[i] + if (!entry) { + entries.push(null) + continue + } + let label = entry[0] + entry[2] ||= {} - if (!options.skip_render) { - this.startRendering() + if (entry[2].label) { + label = entry[2].label } - this.autoresize = options.autoresize + entry[2].removable = true + const data: IOptionalSlotData = { content: label, value: entry } + if (entry[1] == LiteGraph.ACTION) { + data.className = "event" + } + entries.push(data) + } } - static getFileExtension(url: string): string { - const question = url.indexOf("?") - if (question !== -1) url = url.substring(0, question) + const retEntries = node.onMenuNodeInputs?.(entries) + if (retEntries) entries = retEntries - const point = url.lastIndexOf(".") - return point === -1 - ? "" - : url.substring(point + 1).toLowerCase() + if (!entries.length) { + console.log("no input entries") + return } - static onGroupAdd(info: unknown, entry: unknown, mouse_event: MouseEvent): void { - const canvas = LGraphCanvas.active_canvas + new LiteGraph.ContextMenu( + entries, + { + event: e, + callback: inner_clicked, + parentMenu: prev_menu, + node: node, + }, + // @ts-expect-error Unused param + ref_window, + ) - const group = new LiteGraph.LGraphGroup() - group.pos = canvas.convertEventToCanvasOffset(mouse_event) - canvas.graph.add(group) - } - /** - * @deprecated Functionality moved to {@link getBoundaryNodes}. The new function returns null on failure, instead of an object with all null properties. - * Determines the furthest nodes in each direction - * @param {Dictionary} nodes the nodes to from which boundary nodes will be extracted - * @return {{left: LGraphNode, top: LGraphNode, right: LGraphNode, bottom: LGraphNode}} - */ - static getBoundaryNodes(nodes: LGraphNode[] | Dictionary): NullableProperties { - const _nodes = Array.isArray(nodes) ? nodes : Object.values(nodes) - return getBoundaryNodes(_nodes) ?? { - top: null, - right: null, - bottom: null, - left: null - } - } - /** - * @deprecated Functionality moved to {@link alignNodes}. The new function does not set dirty canvas. - * @param {Dictionary} nodes a list of nodes - * @param {"top"|"bottom"|"left"|"right"} direction Direction to align the nodes - * @param {LGraphNode?} align_to Node to align to (if null, align to the furthest node in the given direction) - */ - static alignNodes(nodes: Dictionary, direction: Direction, align_to?: LGraphNode): void { - alignNodes(Object.values(nodes), direction, align_to) - LGraphCanvas.active_canvas.setDirty(true, true) - } - static onNodeAlign(value: IContextMenuValue, options: IContextMenuOptions, event: MouseEvent, prev_menu: ContextMenu, node: LGraphNode): void { - new LiteGraph.ContextMenu(["Top", "Bottom", "Left", "Right"], { - event: event, - callback: inner_clicked, - parentMenu: prev_menu, - }) + function inner_clicked(v, e, prev) { + if (!node) return - function inner_clicked(value: string) { - alignNodes(Object.values(LGraphCanvas.active_canvas.selected_nodes), (value.toLowerCase() as Direction), node) - LGraphCanvas.active_canvas.setDirty(true, true) - } + v.callback?.call(that, node, v, e, prev) + + if (!v.value) return + node.graph.beforeChange() + node.addInput(v.value[0], v.value[1], v.value[2]) + + // callback to the node when adding a slot + node.onNodeInputAdd?.(v.value) + canvas.setDirty(true, true) + node.graph.afterChange() } - static onGroupAlign(value: IContextMenuValue, options: IContextMenuOptions, event: MouseEvent, prev_menu: ContextMenu): void { - new LiteGraph.ContextMenu(["Top", "Bottom", "Left", "Right"], { - event: event, - callback: inner_clicked, - parentMenu: prev_menu, - }) - function inner_clicked(value: string) { - alignNodes(Object.values(LGraphCanvas.active_canvas.selected_nodes), (value.toLowerCase() as Direction)) - LGraphCanvas.active_canvas.setDirty(true, true) + return false + } + + /** @param options Parameter is never used */ + static showMenuNodeOptionalOutputs( + v: unknown, + options: INodeOutputSlot[], + e: unknown, + prev_menu: ContextMenu, + node: LGraphNode, + ): boolean { + if (!node) return + + const that = this + const canvas = LGraphCanvas.active_canvas + const ref_window = canvas.getCanvasWindow() + + options = node.onGetOutputs + ? node.onGetOutputs() + : node.optional_outputs + + let entries: IOptionalSlotData[] = [] + if (options) { + for (let i = 0; i < options.length; i++) { + const entry = options[i] + if (!entry) { + // separator? + entries.push(null) + continue + } + + if ( + node.flags && + node.flags.skip_repeated_outputs && + node.findOutputSlot(entry[0]) != -1 + ) { + continue + } // skip the ones already on + let label = entry[0] + entry[2] ||= {} + if (entry[2].label) { + label = entry[2].label + } + entry[2].removable = true + const data: IOptionalSlotData = { content: label, value: entry } + if (entry[1] == LiteGraph.EVENT) { + data.className = "event" + } + entries.push(data) + } + } + + if (this.onMenuNodeOutputs) entries = this.onMenuNodeOutputs(entries) + if (LiteGraph.do_add_triggers_slots) { + // canvas.allow_addOutSlot_onExecuted + if (node.findOutputSlot("onExecuted") == -1) { + // @ts-expect-error Events + entries.push({ content: "On Executed", value: ["onExecuted", LiteGraph.EVENT, { nameLocked: true }], className: "event" }) // , opts: {} + } + } + // add callback for modifing the menu elements onMenuNodeOutputs + const retEntries = node.onMenuNodeOutputs?.(entries) + if (retEntries) entries = retEntries + + if (!entries.length) return + + new LiteGraph.ContextMenu( + entries, + { + event: e, + callback: inner_clicked, + parentMenu: prev_menu, + node: node, + }, + // @ts-expect-error Unused + ref_window, + ) + + function inner_clicked(v, e, prev) { + if (!node) return + + // TODO: This is a static method, so the below "that" appears broken. + if (v.callback) v.callback.call(that, node, v, e, prev) + + if (!v.value) return + + const value = v.value[1] + + if (value && + (typeof value === "object" || Array.isArray(value))) { + // submenu why? + const entries = [] + for (const i in value) { + entries.push({ content: i, value: value[i] }) + } + new LiteGraph.ContextMenu(entries, { + event: e, + callback: inner_clicked, + parentMenu: prev_menu, + node: node, + }) + return false + } + + const graph = node.graph + graph.beforeChange() + node.addOutput(v.value[0], v.value[1], v.value[2]) + + // a callback to the node when adding a slot + node.onNodeOutputAdd?.(v.value) + canvas.setDirty(true, true) + graph.afterChange() + } + + return false + } + + /** @param value Parameter is never used */ + static onShowMenuNodeProperties( + value: unknown, + options: unknown, + e: MouseEvent, + prev_menu: ContextMenu, + node: LGraphNode, + ): boolean { + if (!node || !node.properties) return + + const canvas = LGraphCanvas.active_canvas + const ref_window = canvas.getCanvasWindow() + + const entries = [] + for (const i in node.properties) { + value = node.properties[i] !== undefined ? node.properties[i] : " " + if (typeof value == "object") + value = JSON.stringify(value) + const info = node.getPropertyInfo(i) + if (info.type == "enum" || info.type == "combo") + value = LGraphCanvas.getPropertyPrintableValue(value, info.values) + + // value could contain invalid html characters, clean that + value = LGraphCanvas.decodeHTML(stringOrNull(value)) + entries.push({ + content: "" + + (info.label || i) + + "" + + "" + + value + + "", + value: i, + }) + } + if (!entries.length) { + return + } + + new LiteGraph.ContextMenu( + entries, + { + event: e, + callback: inner_clicked, + parentMenu: prev_menu, + allow_html: true, + node: node, + }, + // @ts-expect-error Unused + ref_window, + ) + + function inner_clicked(v: { value: any }) { + if (!node) return + + const rect = this.getBoundingClientRect() + canvas.showEditPropertyValue(node, v.value, { + position: [rect.left, rect.top], + }) + } + + return false + } + + static decodeHTML(str: string): string { + const e = document.createElement("div") + e.innerText = str + return e.innerHTML + } + + static onMenuResizeNode( + value: IContextMenuValue, + options: IContextMenuOptions, + e: MouseEvent, + menu: ContextMenu, + node: LGraphNode, + ): void { + if (!node) return + + const fApplyMultiNode = function (node: LGraphNode) { + node.size = node.computeSize() + node.onResize?.(node.size) + } + + const canvas = LGraphCanvas.active_canvas + if (!canvas.selected_nodes || Object.keys(canvas.selected_nodes).length <= 1) { + fApplyMultiNode(node) + } else { + for (const i in canvas.selected_nodes) { + fApplyMultiNode(canvas.selected_nodes[i]) + } + } + + canvas.setDirty(true, true) + } + + // TODO refactor :: this is used fot title but not for properties! + static onShowPropertyEditor( + item: { property: string, type: string }, + options: IContextMenuOptions, + e: MouseEvent, + menu: ContextMenu, + node: LGraphNode, + ): void { + const property = item.property || "title" + const value = node[property] + + // TODO: Remove "any" kludge + // TODO refactor :: use createDialog ? + const dialog: any = document.createElement("div") + dialog.is_modified = false + dialog.className = "graphdialog" + dialog.innerHTML = + "" + dialog.close = function () { + dialog.parentNode?.removeChild(dialog) + } + const title = dialog.querySelector(".name") + title.innerText = property + const input = dialog.querySelector(".value") + if (input) { + input.value = value + input.addEventListener("blur", function () { + this.focus() + }) + input.addEventListener("keydown", function (e: KeyboardEvent) { + dialog.is_modified = true + if (e.keyCode == 27) { + // ESC + dialog.close() + } else if (e.keyCode == 13) { + inner() // save + // @ts-expect-error Intentional - undefined if not present + } else if (e.keyCode != 13 && e.target.localName != "textarea") { + return } + e.preventDefault() + e.stopPropagation() + }) } - static createDistributeMenu(value: IContextMenuValue, options: IContextMenuOptions, event: MouseEvent, prev_menu: ContextMenu, node: LGraphNode): void { - new LiteGraph.ContextMenu(["Vertically", "Horizontally"], { - event, - callback: inner_clicked, - parentMenu: prev_menu, - }) - function inner_clicked(value: string) { - const canvas = LGraphCanvas.active_canvas - distributeNodes(Object.values(canvas.selected_nodes), value === "Horizontally") - canvas.setDirty(true, true) + const canvas = LGraphCanvas.active_canvas + const canvasEl = canvas.canvas + + const rect = canvasEl.getBoundingClientRect() + let offsetx = -20 + let offsety = -20 + if (rect) { + offsetx -= rect.left + offsety -= rect.top + } + + if (e) { + dialog.style.left = e.clientX + offsetx + "px" + dialog.style.top = e.clientY + offsety + "px" + } else { + dialog.style.left = canvasEl.width * 0.5 + offsetx + "px" + dialog.style.top = canvasEl.height * 0.5 + offsety + "px" + } + + const button = dialog.querySelector("button") + button.addEventListener("click", inner) + canvasEl.parentNode.appendChild(dialog) + + input?.focus() + + let dialogCloseTimer = null + dialog.addEventListener("mouseleave", function () { + if (LiteGraph.dialog_close_on_mouse_leave) + if (!dialog.is_modified && LiteGraph.dialog_close_on_mouse_leave) + dialogCloseTimer = setTimeout( + dialog.close, + LiteGraph.dialog_close_on_mouse_leave_delay, + ) // dialog.close(); + }) + dialog.addEventListener("mouseenter", function () { + if (LiteGraph.dialog_close_on_mouse_leave) + if (dialogCloseTimer) clearTimeout(dialogCloseTimer) + }) + + function inner() { + if (input) setValue(input.value) + } + + function setValue(value) { + if (item.type == "Number") { + value = Number(value) + } else if (item.type == "Boolean") { + value = Boolean(value) + } + node[property] = value + dialog.parentNode?.removeChild(dialog) + canvas.setDirty(true, true) + } + } + + static getPropertyPrintableValue(value: unknown, values: unknown[] | object): string { + if (!values) return String(value) + + if (Array.isArray(values)) { + return String(value) + } + + if (typeof values === "object") { + let desc_value = "" + for (const k in values) { + if (values[k] != value) continue + + desc_value = k + break + } + return String(value) + " (" + desc_value + ")" + } + } + + static onMenuNodeCollapse( + value: IContextMenuValue, + options: IContextMenuOptions, + e: MouseEvent, + menu: ContextMenu, + node: LGraphNode, + ): void { + node.graph.beforeChange(/* ? */) + + const fApplyMultiNode = function (node) { + node.collapse() + } + + const graphcanvas = LGraphCanvas.active_canvas + if (!graphcanvas.selected_nodes || Object.keys(graphcanvas.selected_nodes).length <= 1) { + fApplyMultiNode(node) + } else { + for (const i in graphcanvas.selected_nodes) { + fApplyMultiNode(graphcanvas.selected_nodes[i]) + } + } + + node.graph.afterChange(/* ? */) + } + + static onMenuToggleAdvanced( + value: IContextMenuValue, + options: IContextMenuOptions, + e: MouseEvent, + menu: ContextMenu, + node: LGraphNode, + ): void { + node.graph.beforeChange(/* ? */) + const fApplyMultiNode = function (node: LGraphNode) { + node.toggleAdvanced() + } + + const graphcanvas = LGraphCanvas.active_canvas + if (!graphcanvas.selected_nodes || Object.keys(graphcanvas.selected_nodes).length <= 1) { + fApplyMultiNode(node) + } else { + for (const i in graphcanvas.selected_nodes) { + fApplyMultiNode(graphcanvas.selected_nodes[i]) + } + } + node.graph.afterChange(/* ? */) + } + + static onMenuNodePin( + value: IContextMenuValue, + options: IContextMenuOptions, + e: MouseEvent, + menu: ContextMenu, + node: LGraphNode, + ): void {} + + static onMenuNodeMode( + value: IContextMenuValue, + options: IContextMenuOptions, + e: MouseEvent, + menu: ContextMenu, + node: LGraphNode, + ): boolean { + new LiteGraph.ContextMenu( + LiteGraph.NODE_MODES, + { event: e, callback: inner_clicked, parentMenu: menu, node: node }, + ) + + function inner_clicked(v) { + if (!node) return + + const kV = Object.values(LiteGraph.NODE_MODES).indexOf(v) + const fApplyMultiNode = function (node) { + if (kV >= 0 && LiteGraph.NODE_MODES[kV]) + node.changeMode(kV) + else { + console.warn("unexpected mode: " + v) + node.changeMode(LGraphEventMode.ALWAYS) + } + } + + const graphcanvas = LGraphCanvas.active_canvas + if (!graphcanvas.selected_nodes || Object.keys(graphcanvas.selected_nodes).length <= 1) { + fApplyMultiNode(node) + } else { + for (const i in graphcanvas.selected_nodes) { + fApplyMultiNode(graphcanvas.selected_nodes[i]) + } + } + } + + return false + } + + /** @param value Parameter is never used */ + static onMenuNodeColors( + value: IContextMenuValue, + options: IContextMenuOptions, + e: MouseEvent, + menu: ContextMenu, + node: LGraphNode, + ): boolean { + if (!node) throw "no node for color" + + const values: IContextMenuValue[] = [] + values.push({ + value: null, + content: "No color", + }) + + for (const i in LGraphCanvas.node_colors) { + const color = LGraphCanvas.node_colors[i] + value = { + value: i, + content: "" + + i + + "", + } + values.push(value) + } + new LiteGraph.ContextMenu(values, { + event: e, + callback: inner_clicked, + parentMenu: menu, + node: node, + }) + + function inner_clicked(v: { value: string | number }) { + if (!node) return + + const color = v.value ? LGraphCanvas.node_colors[v.value] : null + + const fApplyColor = function (node: LGraphNode) { + if (color) { + if (node instanceof LGraphGroup) { + node.color = color.groupcolor + } else { + node.color = color.color + node.bgcolor = color.bgcolor + } + } else { + delete node.color + delete node.bgcolor } + } + + const canvas = LGraphCanvas.active_canvas + if (!canvas.selected_nodes || Object.keys(canvas.selected_nodes).length <= 1) { + fApplyColor(node) + } else { + for (const i in canvas.selected_nodes) { + fApplyColor(canvas.selected_nodes[i]) + } + } + canvas.setDirty(true, true) } - static onMenuAdd(node: LGraphNode, options: IContextMenuOptions, e: MouseEvent, prev_menu: ContextMenu, callback?: (node: LGraphNode) => void): boolean { - const canvas = LGraphCanvas.active_canvas - const ref_window = canvas.getCanvasWindow() - const graph = canvas.graph - if (!graph) - return + return false + } + + static onMenuNodeShapes( + value: IContextMenuValue, + options: IContextMenuOptions, + e: MouseEvent, + menu: ContextMenu, + node: LGraphNode, + ): boolean { + if (!node) throw "no node passed" + + new LiteGraph.ContextMenu(LiteGraph.VALID_SHAPES, { + event: e, + callback: inner_clicked, + parentMenu: menu, + node: node, + }) - function inner_onMenuAdded(base_category: string, prev_menu: ContextMenu): void { + function inner_clicked(v) { + if (!node) return + + node.graph.beforeChange(/* ? */) // node - const categories = LiteGraph.getNodeTypesCategories(canvas.filter || graph.filter).filter(function (category) { return category.startsWith(base_category) }) - const entries = [] + const fApplyMultiNode = function (node) { + node.shape = v + } + + const canvas = LGraphCanvas.active_canvas + if (!canvas.selected_nodes || Object.keys(canvas.selected_nodes).length <= 1) { + fApplyMultiNode(node) + } else { + for (const i in canvas.selected_nodes) { + fApplyMultiNode(canvas.selected_nodes[i]) + } + } - categories.map(function (category) { + node.graph.afterChange(/* ? */) // node + canvas.setDirty(true) + } - if (!category) - return + return false + } - const base_category_regex = new RegExp('^(' + base_category + ')') - const category_name = category.replace(base_category_regex, "").split('/')[0] - const category_path = base_category === '' ? category_name + '/' : base_category + category_name + '/' + static onMenuNodeRemove( + value: IContextMenuValue, + options: IContextMenuOptions, + e: MouseEvent, + menu: ContextMenu, + node: LGraphNode, + ): void { + if (!node) throw "no node passed" - let name = category_name - if (name.indexOf("::") != -1) //in case it has a namespace like "shader::math/rand" it hides the namespace - name = name.split("::")[1] + const graph = node.graph + graph.beforeChange() - const index = entries.findIndex(function (entry) { return entry.value === category_path }) - if (index === -1) { - entries.push({ - value: category_path, content: name, has_submenu: true, callback: function (value, event, mouseEvent, contextMenu) { - inner_onMenuAdded(value.value, contextMenu) - } - }) - } + const fApplyMultiNode = function (node: LGraphNode) { + if (node.removable === false) return - }) + graph.remove(node) + } - const nodes = LiteGraph.getNodeTypesInCategory(base_category.slice(0, -1), canvas.filter || graph.filter) - nodes.map(function (node) { + const canvas = LGraphCanvas.active_canvas + if (!canvas.selected_nodes || Object.keys(canvas.selected_nodes).length <= 1) { + fApplyMultiNode(node) + } else { + for (const i in canvas.selected_nodes) { + fApplyMultiNode(canvas.selected_nodes[i]) + } + } - if (node.skip_list) - return + graph.afterChange() + canvas.setDirty(true, true) + } - const entry = { - value: node.type, content: node.title, has_submenu: false, callback: function (value, event, mouseEvent, contextMenu) { + static onMenuNodeToSubgraph( + value: IContextMenuValue, + options: IContextMenuOptions, + e: MouseEvent, + menu: ContextMenu, + node: LGraphNode, + ): void { + const graph = node.graph + const canvas = LGraphCanvas.active_canvas + if (!canvas) return - const first_event = contextMenu.getFirstEvent() - canvas.graph.beforeChange() - const node = LiteGraph.createNode(value.value) - if (node) { - node.pos = canvas.convertEventToCanvasOffset(first_event) - canvas.graph.add(node) - } + let nodes_list = Object.values(canvas.selected_nodes || {}) + if (!nodes_list.length) nodes_list = [node] - callback?.(node) - canvas.graph.afterChange() + const subgraph_node = LiteGraph.createNode("graph/subgraph") + // @ts-expect-error Refactor this to use typed array. + subgraph_node.pos = node.pos.concat() + graph.add(subgraph_node) - } - } + // @ts-expect-error Doesn't exist anywhere... + subgraph_node.buildFromNodes(nodes_list) - entries.push(entry) + canvas.deselectAll() + canvas.setDirty(true, true) + } - }) + static onMenuNodeClone( + value: IContextMenuValue, + options: IContextMenuOptions, + e: MouseEvent, + menu: ContextMenu, + node: LGraphNode, + ): void { + const graph = node.graph + graph.beforeChange() - // @ts-expect-error Remove param ref_window - unused - new LiteGraph.ContextMenu(entries, { event: e, parentMenu: prev_menu }, ref_window) + const newSelected = new Set() - } + const fApplyMultiNode = function (node: LGraphNode, newNodes: Set): void { + if (node.clonable === false) return - inner_onMenuAdded('', prev_menu) - return false + const newnode = node.clone() + if (!newnode) return + + newnode.pos = [node.pos[0] + 5, node.pos[1] + 5] + node.graph.add(newnode) + newNodes.add(newnode) + } + const canvas = LGraphCanvas.active_canvas + if (!canvas.selected_nodes || Object.keys(canvas.selected_nodes).length <= 1) { + fApplyMultiNode(node, newSelected) + } else { + for (const i in canvas.selected_nodes) { + fApplyMultiNode(canvas.selected_nodes[i], newSelected) + } + } + + if (newSelected.size) { + canvas.selectNodes([...newSelected]) + } + + graph.afterChange() + + canvas.setDirty(true, true) + } + + /** + * clears all the data inside + * + */ + clear(): void { + this.frame = 0 + this.last_draw_time = 0 + this.render_time = 0 + this.fps = 0 + + // this.scale = 1; + // this.offset = [0,0]; + this.dragging_rectangle = null + + this.selected_nodes = {} + this.selected_group = null + + this.visible_nodes = [] + this.node_over = null + this.node_capturing_input = null + this.connecting_links = null + this.highlighted_links = {} + + this.dragging_canvas = false + + this.#dirty() + this.dirty_area = null + + this.node_in_panel = null + this.node_widget = null + + this.last_mouse = [0, 0] + this.last_mouseclick = 0 + this.pointer.reset() + this.visible_area.set([0, 0, 0, 0]) + + this.onClear?.() + } + + /** + * assigns a graph, you can reassign graphs to the same canvas + * + * @param {LGraph} graph + */ + setGraph(graph: LGraph, skip_clear: boolean): void { + if (this.graph == graph) return + + if (!skip_clear) this.clear() + + if (!graph && this.graph) { + this.graph.detachCanvas(this) + return + } + + graph.attachCanvas(this) + + // remove the graph stack in case a subgraph was open + this._graph_stack &&= null + + this.setDirty(true, true) + } + + /** + * returns the top level graph (in case there are subgraphs open on the canvas) + * + * @return {LGraph} graph + */ + getTopGraph(): LGraph { + return this._graph_stack.length + ? this._graph_stack[0] + : this.graph + } + + /** + * returns the visually active graph (in case there are more in the stack) + * @return {LGraph} the active graph + */ + getCurrentGraph(): LGraph { + return this.graph + } + + /** + * Finds the canvas if required, throwing on failure. + * @param canvas Canvas element, or its element ID + * @returns The canvas element + * @throws If {@link canvas} is an element ID that does not belong to a valid HTML canvas element + */ + #validateCanvas( + canvas: string | HTMLCanvasElement, + ): HTMLCanvasElement & { data?: LGraphCanvas } { + if (typeof canvas === "string") { + const el = document.getElementById(canvas) + if (!(el instanceof HTMLCanvasElement)) throw "Error validating LiteGraph canvas: Canvas element not found" + return el + } + return canvas + } + + /** + * Sets the current HTML canvas element. + * Calls bindEvents to add input event listeners, and (re)creates the background canvas. + * + * @param canvas The canvas element to assign, or its HTML element ID. If null or undefined, the current reference is cleared. + * @param skip_events If true, events on the previous canvas will not be removed. Has no effect on the first invocation. + */ + setCanvas(canvas: string | HTMLCanvasElement, skip_events?: boolean) { + const element = this.#validateCanvas(canvas) + if (element === this.canvas) return + // maybe detach events from old_canvas + if (!element && this.canvas && !skip_events) this.unbindEvents() + + this.canvas = element + this.ds.element = element + this.pointer.element = element + + if (!element) return + + // TODO: classList.add + element.className += " lgraphcanvas" + element.data = this + // @ts-expect-error Likely safe to remove. A decent default, but expectation is to be configured by calling app. + element.tabindex = "1" // to allow key events + + // Background canvas: To render objects behind nodes (background, links, groups) + this.bgcanvas = null + if (!this.bgcanvas) { + this.bgcanvas = document.createElement("canvas") + this.bgcanvas.width = this.canvas.width + this.bgcanvas.height = this.canvas.height + } + if (element.getContext == null) { + if (element.localName != "canvas") { + throw "Element supplied for LGraphCanvas must be a element, you passed a " + + element.localName + } + throw "This browser doesn't support Canvas" + } + + const ctx = (this.ctx = element.getContext("2d")) + if (ctx == null) { + // @ts-expect-error WebGL + if (!element.webgl_enabled) { + console.warn("This canvas seems to be WebGL, enabling WebGL renderer") + } + this.enableWebGL() + } + + if (!skip_events) this.bindEvents() + } + + /** Captures an event and prevents default - returns false. */ + _doNothing(e: Event): boolean { + // console.log("pointerevents: _doNothing "+e.type); + e.preventDefault() + return false + } + + /** Captures an event and prevents default - returns true. */ + _doReturnTrue(e: Event): boolean { + e.preventDefault() + return true + } + + /** + * binds mouse, keyboard, touch and drag events to the canvas + **/ + bindEvents(): void { + if (this._events_binded) { + console.warn("LGraphCanvas: events already binded") + return + } + + // console.log("pointerevents: bindEvents"); + const canvas = this.canvas + + const ref_window = this.getCanvasWindow() + const document = ref_window.document // hack used when moving canvas between windows + + this._mousedown_callback = this.processMouseDown.bind(this) + this._mousewheel_callback = this.processMouseWheel.bind(this) + // why mousemove and mouseup were not binded here? + this._mousemove_callback = this.processMouseMove.bind(this) + this._mouseup_callback = this.processMouseUp.bind(this) + this._mouseout_callback = this.processMouseOut.bind(this) + this._mousecancel_callback = this.processMouseCancel.bind(this) + + LiteGraph.pointerListenerAdd(canvas, "down", this._mousedown_callback, true) // down do not need to store the binded + canvas.addEventListener("mousewheel", this._mousewheel_callback, false) + + LiteGraph.pointerListenerAdd(canvas, "up", this._mouseup_callback, true) // CHECK: ??? binded or not + LiteGraph.pointerListenerAdd(canvas, "move", this._mousemove_callback) + canvas.addEventListener("pointerout", this._mouseout_callback) + canvas.addEventListener("pointercancel", this._mousecancel_callback, true) + + canvas.addEventListener("contextmenu", this._doNothing) + canvas.addEventListener( + "DOMMouseScroll", + this._mousewheel_callback, + false, + ) + + // Keyboard ****************** + this._key_callback = this.processKey.bind(this) + + canvas.addEventListener("keydown", this._key_callback, true) + document.addEventListener("keyup", this._key_callback, true) // in document, otherwise it doesn't fire keyup + + // Dropping Stuff over nodes ************************************ + this._ondrop_callback = this.processDrop.bind(this) + + canvas.addEventListener("dragover", this._doNothing, false) + canvas.addEventListener("dragend", this._doNothing, false) + canvas.addEventListener("drop", this._ondrop_callback, false) + canvas.addEventListener("dragenter", this._doReturnTrue, false) + + this._events_binded = true + } + + /** + * unbinds mouse events from the canvas + **/ + unbindEvents(): void { + if (!this._events_binded) { + console.warn("LGraphCanvas: no events binded") + return + } + + // console.log("pointerevents: unbindEvents"); + const ref_window = this.getCanvasWindow() + const document = ref_window.document + + this.canvas.removeEventListener("pointercancel", this._mousecancel_callback) + this.canvas.removeEventListener("pointerout", this._mouseout_callback) + LiteGraph.pointerListenerRemove(this.canvas, "move", this._mousemove_callback) + LiteGraph.pointerListenerRemove(this.canvas, "up", this._mouseup_callback) + LiteGraph.pointerListenerRemove(this.canvas, "down", this._mousedown_callback) + this.canvas.removeEventListener( + "mousewheel", + this._mousewheel_callback, + ) + this.canvas.removeEventListener( + "DOMMouseScroll", + this._mousewheel_callback, + ) + this.canvas.removeEventListener("keydown", this._key_callback) + document.removeEventListener("keyup", this._key_callback) + this.canvas.removeEventListener("contextmenu", this._doNothing) + this.canvas.removeEventListener("drop", this._ondrop_callback) + this.canvas.removeEventListener("dragenter", this._doReturnTrue) + + this._mousedown_callback = null + this._mousewheel_callback = null + this._key_callback = null + this._ondrop_callback = null + + this._events_binded = false + } + + /** + * this function allows to render the canvas using WebGL instead of Canvas2D + * this is useful if you plant to render 3D objects inside your nodes, it uses litegl.js for webgl and canvas2DtoWebGL to emulate the Canvas2D calls in webGL + **/ + enableWebGL(): void { + // TODO: Delete or move all webgl to a module and never load it. + // @ts-expect-error + if (typeof GL === "undefined") { + throw "litegl.js must be included to use a WebGL canvas" + } + // @ts-expect-error + if (typeof enableWebGLCanvas === "undefined") { + throw "webglCanvas.js must be included to use this feature" + } + + // @ts-expect-error + this.gl = this.ctx = enableWebGLCanvas(this.canvas) + // @ts-expect-error + this.ctx.webgl = true + this.bgcanvas = this.canvas + this.bgctx = this.gl + // @ts-expect-error + this.canvas.webgl_enabled = true + + /* + GL.create({ canvas: this.bgcanvas }); + this.bgctx = enableWebGLCanvas( this.bgcanvas ); + window.gl = this.gl; + */ + } + + /** + * Ensures the canvas will be redrawn on the next frame by setting the dirty flag(s). + * Without parameters, this function does nothing. + * @todo Impl. `setDirty()` or similar as shorthand to redraw everything. + * + * @param fgcanvas If true, marks the foreground canvas as dirty (nodes and anything drawn on top of them). Default: false + * @param bgcanvas If true, mark the background canvas as dirty (background, groups, links). Default: false + */ + setDirty(fgcanvas: boolean, bgcanvas?: boolean): void { + if (fgcanvas) this.dirty_canvas = true + if (bgcanvas) this.dirty_bgcanvas = true + } + + /** Marks the entire canvas as dirty. */ + #dirty(): void { + this.dirty_canvas = true + this.dirty_bgcanvas = true + } + + /** + * Used to attach the canvas in a popup + * + * @return {window} returns the window where the canvas is attached (the DOM root node) + */ + getCanvasWindow(): Window { + if (!this.canvas) return window + + const doc = this.canvas.ownerDocument + // @ts-expect-error Check if required + return doc.defaultView || doc.parentWindow + } + + /** + * starts rendering the content of the canvas when needed + * + */ + startRendering(): void { + // already rendering + if (this.is_rendering) return + + this.is_rendering = true + renderFrame.call(this) + + function renderFrame(this: LGraphCanvas) { + if (!this.pause_rendering) { + this.draw() + } + + const window = this.getCanvasWindow() + if (this.is_rendering) { + window.requestAnimationFrame(renderFrame.bind(this)) + } + } + } + + /** + * stops rendering the content of the canvas (to save resources) + * + */ + stopRendering(): void { + this.is_rendering = false + /* + if(this.rendering_timer_id) + { + clearInterval(this.rendering_timer_id); + this.rendering_timer_id = null; + } + */ + } + + /* LiteGraphCanvas input */ + // used to block future mouse events (because of im gui) + blockClick(): void { + this.block_click = true + this.last_mouseclick = 0 + } + + /** + * Gets the widget at the current cursor position + * @param node Optional node to check for widgets under cursor + * @returns The widget located at the current cursor position or null + */ + getWidgetAtCursor(node?: LGraphNode): IWidget | null { + node ??= this.node_over + + if (!node.widgets) return null + + const graphPos = this.graph_mouse + const x = graphPos[0] - node.pos[0] + const y = graphPos[1] - node.pos[1] + + for (const widget of node.widgets) { + if (widget.hidden || (widget.advanced && !node.showAdvanced)) continue + + let widgetWidth, widgetHeight + if (widget.computeSize) { + ([widgetWidth, widgetHeight] = widget.computeSize(node.size[0])) + } else { + widgetWidth = widget.width || node.size[0] + widgetHeight = LiteGraph.NODE_WIDGET_HEIGHT + } + + if ( + widget.last_y !== undefined && + x >= 6 && + x <= widgetWidth - 12 && + y >= widget.last_y && + y <= widget.last_y + widgetHeight + ) { + return widget + } + } + + return null + } + + /** + * Clears highlight and mouse-over information from nodes that should not have it. + * + * Intended to be called when the pointer moves away from a node. + * @param {LGraphNode} node The node that the mouse is now over + * @param {MouseEvent} e MouseEvent that is triggering this + */ + updateMouseOverNodes(node: LGraphNode, e: CanvasMouseEvent): void { + const nodes = this.graph._nodes + const l = nodes.length + for (let i = 0; i < l; ++i) { + if (nodes[i].mouseOver && node != nodes[i]) { + // mouse leave + nodes[i].mouseOver = null + this._highlight_input = null + this._highlight_pos = null + this.link_over_widget = null + + // Hover transitions + // TODO: Implement single lerp ease factor for current progress on hover in/out. + // In drawNode, multiply by ease factor and differential value (e.g. bg alpha +0.5). + nodes[i].lostFocusAt = LiteGraph.getTime() + + this.node_over?.onMouseLeave?.(e) + this.node_over = null + this.dirty_canvas = true + } } + } - static onMenuCollapseAll() { } - static onMenuNodeEdit() { } + processMouseDown(e: PointerEvent): void { + const { graph, pointer } = this + this.adjustMouseEvent(e) + if (e.isPrimary) pointer.down(e) - /** @param options Parameter is never used */ - static showMenuNodeOptionalInputs(v: unknown, options: INodeInputSlot[], e: MouseEvent, prev_menu: ContextMenu, node: LGraphNode): boolean { - if (!node) return + if (this.set_canvas_dirty_on_mouse_event) this.dirty_canvas = true - // FIXME: Static function this - const that = this - const canvas = LGraphCanvas.active_canvas - const ref_window = canvas.getCanvasWindow() + if (!graph) return - options = node.onGetInputs - ? node.onGetInputs() - : node.optional_inputs + const ref_window = this.getCanvasWindow() + LGraphCanvas.active_canvas = this - let entries: IOptionalSlotData[] = [] - if (options) { - for (let i = 0; i < options.length; i++) { - const entry = options[i] - if (!entry) { - entries.push(null) - continue - } - let label = entry[0] - entry[2] ||= {} + const x = e.clientX + const y = e.clientY + this.ds.viewport = this.viewport + const is_inside = !this.viewport || isInRect(x, y, this.viewport) - if (entry[2].label) { - label = entry[2].label - } + if (!is_inside) return - entry[2].removable = true - const data: IOptionalSlotData = { content: label, value: entry } - if (entry[1] == LiteGraph.ACTION) { - data.className = "event" - } - entries.push(data) - } - } + const node = graph.getNodeOnPos(e.canvasX, e.canvasY, this.visible_nodes) - const retEntries = node.onMenuNodeInputs?.(entries) - if (retEntries) entries = retEntries + this.mouse[0] = x + this.mouse[1] = y + this.graph_mouse[0] = e.canvasX + this.graph_mouse[1] = e.canvasY + this.last_click_position = [this.mouse[0], this.mouse[1]] - if (!entries.length) { - console.log("no input entries") - return - } + pointer.isDouble = pointer.isDown && e.isPrimary + pointer.isDown = true - new LiteGraph.ContextMenu( - entries, - { - event: e, - callback: inner_clicked, - parentMenu: prev_menu, - node: node - }, - // @ts-expect-error Unused param - ref_window - ) + this.canvas.focus() - function inner_clicked(v, e, prev) { - if (!node) return + LiteGraph.closeAllContextMenus(ref_window) - v.callback?.call(that, node, v, e, prev) + if (this.onMouse?.(e) == true) return - if (!v.value) return - node.graph.beforeChange() - node.addInput(v.value[0], v.value[1], v.value[2]) + // left button mouse / single finger + if (e.button === 0 && !pointer.isDouble) { + this.#processPrimaryButton(e, node) + } else if (e.button === 1) { + this.#processMiddleButton(e, node) + } else if ( + (e.button === 2 || pointer.isDouble) && + this.allow_interaction && + !this.read_only + ) { + // Right / aux button - // callback to the node when adding a slot - node.onNodeInputAdd?.(v.value) - canvas.setDirty(true, true) - node.graph.afterChange() - } + // Sticky select - won't remove single nodes + if (node) this.processSelect(node, e, true) - return false + // Show context menu for the node or group under the pointer + this.processContextMenu(node, e) } - /** @param options Parameter is never used */ - static showMenuNodeOptionalOutputs(v: unknown, options: INodeOutputSlot[], e: unknown, prev_menu: ContextMenu, node: LGraphNode): boolean { - if (!node) return + this.last_mouse = [x, y] + this.last_mouseclick = LiteGraph.getTime() + this.last_mouse_dragging = true - const that = this - const canvas = LGraphCanvas.active_canvas - const ref_window = canvas.getCanvasWindow() + graph.change() - options = node.onGetOutputs - ? node.onGetOutputs() - : node.optional_outputs + // this is to ensure to defocus(blur) if a text input element is on focus + if ( + !ref_window.document.activeElement || + (ref_window.document.activeElement.nodeName.toLowerCase() != "input" && + ref_window.document.activeElement.nodeName.toLowerCase() != "textarea") + ) { + e.preventDefault() + } + e.stopPropagation() + + this.onMouseDown?.(e) + } + + #processPrimaryButton(e: CanvasPointerEvent, node: LGraphNode) { + const { pointer, graph } = this + const x = e.canvasX + const y = e.canvasY + + // Modifiers + const ctrlOrMeta = e.ctrlKey || e.metaKey + + // Multi-select drag rectangle + if (ctrlOrMeta && !e.altKey) { + const dragRect = new Float32Array(4) + dragRect[0] = x + dragRect[1] = y + dragRect[2] = 1 + dragRect[3] = 1 + + pointer.onClick = (eUp) => { + // Click, not drag + const clickedItem = node ?? + (this.reroutesEnabled ? graph.getRerouteOnPos(eUp.canvasX, eUp.canvasY) : null) ?? + graph.getGroupTitlebarOnPos(eUp.canvasX, eUp.canvasY) + this.processSelect(clickedItem, eUp) + } + pointer.onDragStart = () => this.dragging_rectangle = dragRect + pointer.onDragEnd = upEvent => this.#handleMultiSelect(upEvent, dragRect) + pointer.finally = () => this.dragging_rectangle = null + return + } + + if (this.read_only) { + pointer.finally = () => this.dragging_canvas = false + this.dragging_canvas = true + return + } + + // clone node ALT dragging + if (LiteGraph.alt_drag_do_clone_nodes && e.altKey && !e.ctrlKey && node && this.allow_interaction) { + const node_data = node.clone()?.serialize() + const cloned = LiteGraph.createNode(node_data.type) + if (cloned) { + cloned.configure(node_data) + cloned.pos[0] += 5 + cloned.pos[1] += 5 + + if (this.allow_dragnodes) { + pointer.onDragStart = (pointer) => { + graph.add(cloned, false) + this.#startDraggingItems(cloned, pointer) + } + pointer.onDragEnd = e => this.#processDraggedItems(e) + } else { + // TODO: Check if before/after change are necessary here. + graph.beforeChange() + graph.add(cloned, false) + graph.afterChange() + } - let entries: IOptionalSlotData[] = [] - if (options) { - for (let i = 0; i < options.length; i++) { - const entry = options[i] - if (!entry) { - //separator? - entries.push(null) - continue - } + return + } + } + + // Node clicked + if (node && (this.allow_interaction || node.flags.allow_interaction)) { + this.#processNodeClick(e, ctrlOrMeta, node) + } else { + // Reroutes + if (this.reroutesEnabled) { + const reroute = graph.getRerouteOnPos(x, y) + if (reroute) { + if (e.shiftKey) { + // Connect new link from reroute + const link = graph._links.get(reroute.linkIds.values().next().value) + + const outputNode = graph.getNodeById(link.origin_id) + const slot = link.origin_slot + const connecting: ConnectingLink = { + node: outputNode, + slot, + input: null, + pos: outputNode.getConnectionPos(false, slot), + afterRerouteId: reroute.id, + } + this.connecting_links = [connecting] + pointer.onDragStart = () => connecting.output = outputNode.outputs[slot] + // pointer.finally = () => this.connecting_links = null + + this.dirty_bgcanvas = true + } + + pointer.onClick = () => this.processSelect(reroute, e) + if (!pointer.onDragStart) { + pointer.onDragStart = pointer => this.#startDraggingItems(reroute, pointer, true) + pointer.onDragEnd = e => this.#processDraggedItems(e) + } + return + } + } + + // Links - paths of links & reroutes + // Set the width of the line for isPointInStroke checks + const { lineWidth } = this.ctx + this.ctx.lineWidth = this.connections_width + 7 + + for (const linkSegment of this.renderedPaths) { + const centre = linkSegment._pos + if (!centre) continue + + // If we shift click on a link then start a link from that input + if ( + (e.shiftKey || e.altKey) && + linkSegment.path && + this.ctx.isPointInStroke(linkSegment.path, x, y) + ) { + if (e.shiftKey && !e.altKey) { + const slot = linkSegment.origin_slot + const originNode = graph._nodes_by_id[linkSegment.origin_id] - if (node.flags && - node.flags.skip_repeated_outputs && - node.findOutputSlot(entry[0]) != -1) { - continue - } //skip the ones already on - let label = entry[0] - entry[2] ||= {} - if (entry[2].label) { - label = entry[2].label - } - entry[2].removable = true - const data: IOptionalSlotData = { content: label, value: entry } - if (entry[1] == LiteGraph.EVENT) { - data.className = "event" - } - entries.push(data) + const connecting: ConnectingLink = { + node: originNode, + slot, + pos: originNode.getConnectionPos(false, slot), } - } + this.connecting_links = [connecting] + if (linkSegment.parentId) connecting.afterRerouteId = linkSegment.parentId - if (this.onMenuNodeOutputs) entries = this.onMenuNodeOutputs(entries) - if (LiteGraph.do_add_triggers_slots) { //canvas.allow_addOutSlot_onExecuted - if (node.findOutputSlot("onExecuted") == -1) { - // @ts-expect-error Events - entries.push({ content: "On Executed", value: ["onExecuted", LiteGraph.EVENT, { nameLocked: true }], className: "event" }) //, opts: {} - } + pointer.onDragStart = () => connecting.output = originNode.outputs[slot] + // pointer.finally = () => this.connecting_links = null + + return + } else if (this.reroutesEnabled && e.altKey && !e.shiftKey) { + const newReroute = graph.createReroute([x, y], linkSegment) + pointer.onDragStart = pointer => this.#startDraggingItems(newReroute, pointer) + pointer.onDragEnd = e => this.#processDraggedItems(e) + return + } + } else if (isInRectangle(x, y, centre[0] - 4, centre[1] - 4, 8, 8)) { + pointer.onClick = () => this.showLinkMenu(linkSegment, e) + pointer.onDragStart = () => this.dragging_canvas = true + pointer.finally = () => this.dragging_canvas = false + + // clear tooltip + this.over_link_center = null + return + } + } + + // Restore line width + this.ctx.lineWidth = lineWidth + + // Groups + const group = graph.getGroupOnPos(x, y) + this.selected_group = group + if (group) { + if (group.isInResize(x, y)) { + pointer.onDragStart = () => this.resizingGroup = group + pointer.finally = () => this.resizingGroup = null + } else { + const f = group.font_size || LiteGraph.DEFAULT_GROUP_FONT_SIZE + const headerHeight = f * 1.4 + if ( + isInRectangle( + x, + y, + group.pos[0], + group.pos[1], + group.size[0], + headerHeight, + ) + ) { + // In title bar + pointer.onDragStart = (pointer) => { + group.recomputeInsideNodes() + this.#startDraggingItems(group, pointer, true) + } + pointer.onDragEnd = e => this.#processDraggedItems(e) + } + } + + pointer.onDoubleClick = () => { + this.emitEvent({ + subType: "group-double-click", + originalEvent: e, + group, + }) + } + } else { + pointer.onDoubleClick = () => { + // Double click within group should not trigger the searchbox. + if (this.allow_searchbox) { + this.showSearchBox(e) + e.preventDefault() + } + this.emitEvent({ + subType: "empty-double-click", + originalEvent: e, + }) } - // add callback for modifing the menu elements onMenuNodeOutputs - const retEntries = node.onMenuNodeOutputs?.(entries) - if (retEntries) entries = retEntries + } + } - if (!entries.length) return + if ( + !pointer.onDragStart && + !pointer.onClick && + !pointer.onDrag && + this.allow_dragcanvas + ) { + pointer.onClick = () => this.processSelect(null, e) + pointer.finally = () => this.dragging_canvas = false + this.dragging_canvas = true + } + } + + /** + * Processes a pointerdown event inside the bounds of a node. Part of {@link processMouseDown}. + * @param ctrlOrMeta Ctrl or meta key is pressed + * @param e The pointerdown event + * @param node The node to process a click event for + */ + #processNodeClick( + e: CanvasPointerEvent, + ctrlOrMeta: boolean, + node: LGraphNode, + ): void { + const { pointer, graph } = this + const x = e.canvasX + const y = e.canvasY + + pointer.onClick = () => this.processSelect(node, e) + + // Immediately bring to front + if (!node.flags.pinned) { + this.bringToFront(node) + } + + // Collapse toggle + const inCollapse = node.isPointInCollapse(x, y) + if (inCollapse) { + pointer.onClick = () => { + node.collapse() + this.setDirty(true, true) + } + } else if (!node.flags.collapsed) { + // Resize node + if (node.resizable !== false && node.inResizeCorner(x, y)) { + pointer.onDragStart = () => { + graph.beforeChange() + this.resizing_node = node + } + pointer.onDragEnd = (upEvent) => { + this.#dirty() + graph.afterChange(this.resizing_node) + } + pointer.finally = () => this.resizing_node = null + this.canvas.style.cursor = "se-resize" + return + } + + // Outputs + if (node.outputs) { + for (let i = 0, l = node.outputs.length; i < l; ++i) { + const output = node.outputs[i] + const link_pos = node.getConnectionPos(false, i) + if (isInRectangle(x, y, link_pos[0] - 15, link_pos[1] - 10, 30, 20)) { + // Drag multiple output links + if (e.shiftKey && output.links?.length > 0) { + this.connecting_links = [] + for (const linkId of output.links) { + const link = graph._links.get(linkId) + const slot = link.target_slot + const linked_node = graph._nodes_by_id[link.target_id] + const input = linked_node.inputs[slot] + const pos = linked_node.getConnectionPos(true, slot) + + this.connecting_links.push({ + node: linked_node, + slot: slot, + input: input, + output: null, + pos: pos, + direction: node.horizontal !== true ? LinkDirection.RIGHT : LinkDirection.CENTER, + }) + } - new LiteGraph.ContextMenu( - entries, - { - event: e, - callback: inner_clicked, - parentMenu: prev_menu, - node: node - }, - // @ts-expect-error Unused - ref_window - ) + return + } - function inner_clicked(v, e, prev) { - if (!node) return + output.slot_index = i + this.connecting_links = [ + { + node: node, + slot: i, + input: null, + output: output, + pos: link_pos, + }, + ] - // TODO: This is a static method, so the below "that" appears broken. - if (v.callback) v.callback.call(that, node, v, e, prev) + if (LiteGraph.shift_click_do_break_link_from) { + if (e.shiftKey) { + node.disconnectOutput(i) + } + } else if (LiteGraph.ctrl_alt_click_do_break_link) { + if (ctrlOrMeta && e.altKey && !e.shiftKey) { + node.disconnectOutput(i) + } + } - if (!v.value) return + // TODO: Move callbacks to the start of this closure (onInputClick is already correct). + pointer.onDoubleClick = () => node.onOutputDblClick?.(i, e) + pointer.onClick = () => node.onOutputClick?.(i, e) - const value = v.value[1] + return + } + } + } + + // Inputs + if (node.inputs) { + for (let i = 0, l = node.inputs.length; i < l; ++i) { + const input = node.inputs[i] + const link_pos = node.getConnectionPos(true, i) + if (isInRectangle(x, y, link_pos[0] - 15, link_pos[1] - 10, 30, 20)) { + pointer.onDoubleClick = () => node.onInputDblClick?.(i, e) + pointer.onClick = () => node.onInputClick?.(i, e) + + if (input.link !== null) { + // before disconnecting + const link_info = graph._links.get(input.link) + const slot = link_info.origin_slot + const linked_node = graph._nodes_by_id[link_info.origin_id] + if ( + LiteGraph.click_do_break_link_to || + (LiteGraph.ctrl_alt_click_do_break_link && + ctrlOrMeta && + e.altKey && + !e.shiftKey) + ) { + node.disconnectInput(i) + } else if (e.shiftKey || this.allow_reconnect_links) { + const connecting: ConnectingLink = { + node: linked_node, + slot, + output: linked_node.outputs[slot], + pos: linked_node.getConnectionPos(false, slot), + } + this.connecting_links = [connecting] - if (value && - (typeof value === "object" || Array.isArray(value))) { - //submenu why? - const entries = [] - for (const i in value) { - entries.push({ content: i, value: value[i] }) + pointer.onDragStart = () => { + if (this.allow_reconnect_links && !LiteGraph.click_do_break_link_to) + node.disconnectInput(i) + connecting.output = linked_node.outputs[slot] } - new LiteGraph.ContextMenu(entries, { - event: e, - callback: inner_clicked, - parentMenu: prev_menu, - node: node - }) - return false + + this.dirty_bgcanvas = true + } } + if (!pointer.onDragStart) { + // Connect from input to output + const connecting: ConnectingLink = { + node, + slot: i, + output: null, + pos: link_pos, + } + this.connecting_links = [connecting] + pointer.onDragStart = () => connecting.input = input - const graph = node.graph - graph.beforeChange() - node.addOutput(v.value[0], v.value[1], v.value[2]) + this.dirty_bgcanvas = true + } - // a callback to the node when adding a slot - node.onNodeOutputAdd?.(v.value) - canvas.setDirty(true, true) - graph.afterChange() - } + // pointer.finally = () => this.connecting_links = null + return + } + } + } + } + + // Click was inside the node, but not on input/output, or the resize corner + const pos: Point = [x - node.pos[0], y - node.pos[1]] + + // Widget + const widget = node.getWidgetOnPos(x, y) + if (widget) { + this.#processWidgetClick(e, node, widget) + this.node_widget = [node, widget] + } else { + pointer.onDoubleClick = () => { + // Double-click + // Check if it's a double click on the title bar + // Note: pos[1] is the y-coordinate of the node's body + // If clicking on node header (title), pos[1] is negative + if (pos[1] < 0 && !inCollapse) { + node.onNodeTitleDblClick?.(e, pos, this) + } + node.onDblClick?.(e, pos, this) + this.processNodeDblClicked(node) + } + + // Mousedown callback - can block drag + if (node.onMouseDown?.(e, pos, this) || !this.allow_dragnodes) + return - return false + // Drag node + pointer.onDragStart = pointer => this.#startDraggingItems(node, pointer, true) + pointer.onDragEnd = e => this.#processDraggedItems(e) } - /** @param value Parameter is never used */ - static onShowMenuNodeProperties(value: unknown, options: unknown, e: MouseEvent, prev_menu: ContextMenu, node: LGraphNode): boolean { - if (!node || !node.properties) return + this.dirty_canvas = true + } - const canvas = LGraphCanvas.active_canvas - const ref_window = canvas.getCanvasWindow() + #processWidgetClick(e: CanvasPointerEvent, node: LGraphNode, widget: IWidget) { + const { pointer } = this + const width = widget.width || node.width - const entries = [] - for (const i in node.properties) { - value = node.properties[i] !== undefined ? node.properties[i] : " " - if (typeof value == "object") - value = JSON.stringify(value) - const info = node.getPropertyInfo(i) - if (info.type == "enum" || info.type == "combo") - value = LGraphCanvas.getPropertyPrintableValue(value, info.values) - - //value could contain invalid html characters, clean that - value = LGraphCanvas.decodeHTML(stringOrNull(value)) - entries.push({ - content: "" + - (info.label || i) + - "" + - "" + - value + - "", - value: i - }) - } - if (!entries.length) { - return + const oldValue = widget.value + + const pos = this.graph_mouse + const x = pos[0] - node.pos[0] + const y = pos[1] - node.pos[1] + + switch (widget.type) { + case "button": + pointer.onClick = () => { + widget.callback?.(widget, this, node, pos, e) + widget.clicked = true + this.dirty_canvas = true + } + break + case "slider": { + if (widget.options.read_only) break + + pointer.onDrag = (eMove) => { + const slideFactor = clamp((x - 15) / (width - 30), 0, 1) + widget.value = widget.options.min + (widget.options.max - widget.options.min) * slideFactor + if (oldValue != widget.value) { + setWidgetValue(this, node, widget, widget.value) } + this.dirty_canvas = true + } + break + } + case "number": { + const delta = x < 40 + ? -1 + : x > width - 40 + ? 1 + : 0 + pointer.onClick = (upEvent) => { + // Left/right arrows + widget.value += delta * 0.1 * (widget.options.step || 1) + if (widget.options.min != null && widget.value < widget.options.min) { + widget.value = widget.options.min + } + if (widget.options.max != null && widget.value > widget.options.max) { + widget.value = widget.options.max + } + + if (delta !== 0) return + + // Click in widget centre area - prompt user for input + this.prompt("Value", widget.value, (v: string) => { + // check if v is a valid equation or a number + if (/^[0-9+\-*/()\s]+|\d+\.\d+$/.test(v)) { + // solve the equation if possible + try { + v = eval(v) + } catch { } + } + widget.value = Number(v) + setWidgetValue(this, node, widget, widget.value) + }, e) + this.dirty_canvas = true + } - new LiteGraph.ContextMenu( - entries, - { - event: e, - callback: inner_clicked, - parentMenu: prev_menu, - allow_html: true, - node: node - }, - // @ts-expect-error Unused - ref_window - ) + // Click & drag from widget centre area + pointer.onDrag = (eMove) => { + const x = eMove.canvasX - node.pos[0] + if (delta && (x > -3 && x < width + 3)) return - function inner_clicked(v: { value: any }) { - if (!node) return + if (eMove.deltaX) widget.value += eMove.deltaX * 0.1 * (widget.options.step || 1) - const rect = this.getBoundingClientRect() - canvas.showEditPropertyValue(node, v.value, { - position: [rect.left, rect.top] - }) + if (widget.options.min != null && widget.value < widget.options.min) { + widget.value = widget.options.min } - - return false - } - static decodeHTML(str: string): string { - const e = document.createElement("div") - e.innerText = str - return e.innerHTML + if (widget.options.max != null && widget.value > widget.options.max) { + widget.value = widget.options.max + } + } + break } - static onMenuResizeNode(value: IContextMenuValue, options: IContextMenuOptions, e: MouseEvent, menu: ContextMenu, node: LGraphNode): void { - if (!node) return + case "combo": { + // TODO: Type checks on widget values + let values: string[] + let values_list: string[] - const fApplyMultiNode = function (node: LGraphNode) { - node.size = node.computeSize() - node.onResize?.(node.size) - } + pointer.onClick = (upEvent) => { + const delta = x < 40 + ? -1 + : x > width - 40 + ? 1 + : 0 - const canvas = LGraphCanvas.active_canvas - if (!canvas.selected_nodes || Object.keys(canvas.selected_nodes).length <= 1) { - fApplyMultiNode(node) - } else { - for (const i in canvas.selected_nodes) { - fApplyMultiNode(canvas.selected_nodes[i]) - } + // Combo buttons + values = widget.options.values + if (typeof values === "function") { + // @ts-expect-error + values = values(widget, node) } + values_list = null - canvas.setDirty(true, true) - } - // TODO refactor :: this is used fot title but not for properties! - static onShowPropertyEditor(item: { property: string; type: string }, options: IContextMenuOptions, e: MouseEvent, menu: ContextMenu, node: LGraphNode): void { - const property = item.property || "title" - const value = node[property] + values_list = Array.isArray(values) ? values : Object.keys(values) - // TODO: Remove "any" kludge - // TODO refactor :: use createDialog ? - const dialog: any = document.createElement("div") - dialog.is_modified = false - dialog.className = "graphdialog" - dialog.innerHTML = - "" - dialog.close = function () { - dialog.parentNode?.removeChild(dialog) - } - const title = dialog.querySelector(".name") - title.innerText = property - const input = dialog.querySelector(".value") - if (input) { - input.value = value - input.addEventListener("blur", function () { - this.focus() - }) - input.addEventListener("keydown", function (e: KeyboardEvent) { - dialog.is_modified = true - if (e.keyCode == 27) { - //ESC - dialog.close() - } else if (e.keyCode == 13) { - inner() // save - // @ts-expect-error Intentional - undefined if not present - } else if (e.keyCode != 13 && e.target.localName != "textarea") { - return - } - e.preventDefault() - e.stopPropagation() - }) - } + // Left/right arrows + if (delta) { + let index = -1 + this.last_mouseclick = 0 // avoids dobl click event + index = typeof values === "object" + ? values_list.indexOf(String(widget.value)) + delta + // @ts-expect-error + : values_list.indexOf(widget.value) + delta + + if (index >= values_list.length) index = values_list.length - 1 + if (index < 0) index = 0 + + widget.value = Array.isArray(values) + ? values[index] + : index + + if (oldValue != widget.value) setWidgetValue(this, node, widget, widget.value) + this.dirty_canvas = true + return + } + const text_values = values != values_list ? Object.values(values) : values + new LiteGraph.ContextMenu(text_values, { + scale: Math.max(1, this.ds.scale), + event: e, + className: "dark", + callback: (value: string) => { + widget.value = values != values_list + ? text_values.indexOf(value) + : value + + setWidgetValue(this, node, widget, widget.value) + this.dirty_canvas = true + return false + }, + }) + } + break + } + case "toggle": + pointer.onClick = () => { + widget.value = !widget.value + setWidgetValue(this, node, widget, widget.value) + } + break + case "string": + case "text": + pointer.onClick = () => this.prompt( + "Value", + widget.value, + (v: any) => setWidgetValue(this, node, widget, v), + e, + widget.options ? widget.options.multiline : false, + ) + break + default: + if (widget.mouse) this.dirty_canvas = widget.mouse(e, [x, y], node) + break + } + + // value changed + if (oldValue != widget.value) { + node.onWidgetChanged?.(widget.name, widget.value, oldValue, widget) + node.graph._version++ + } + + pointer.finally = () => this.node_widget = null + + function setWidgetValue( + canvas: LGraphCanvas, + node: LGraphNode, + widget: IWidget, + value: TWidgetValue, + ) { + const v = widget.type === "number" ? Number(value) : value + widget.value = v + if ( + widget.options?.property && + node.properties[widget.options.property] !== undefined + ) { + node.setProperty(widget.options.property, v) + } + widget.callback?.(widget.value, canvas, node, pos, e) + } + } + + /** + * Pointer middle button click processing. Part of {@link processMouseDown}. + * @param e The pointerdown event + * @param node The node to process a click event for + */ + #processMiddleButton(e: CanvasPointerEvent, node: LGraphNode) { + const { pointer } = this + + if ( + LiteGraph.middle_click_slot_add_default_node && + node && + this.allow_interaction && + !this.read_only && + !this.connecting_links && + !node.flags.collapsed + ) { + // not dragging mouse to connect two slots + let mClikSlot: INodeSlot | false = false + let mClikSlot_index: number | false = false + let mClikSlot_isOut: boolean = false + // search for outputs + if (node.outputs) { + for (let i = 0, l = node.outputs.length; i < l; ++i) { + const output = node.outputs[i] + const link_pos = node.getConnectionPos(false, i) + if (isInRectangle(e.canvasX, e.canvasY, link_pos[0] - 15, link_pos[1] - 10, 30, 20)) { + mClikSlot = output + mClikSlot_index = i + mClikSlot_isOut = true + break + } + } + } + + // search for inputs + if (node.inputs) { + for (let i = 0, l = node.inputs.length; i < l; ++i) { + const input = node.inputs[i] + const link_pos = node.getConnectionPos(true, i) + if (isInRectangle(e.canvasX, e.canvasY, link_pos[0] - 15, link_pos[1] - 10, 30, 20)) { + mClikSlot = input + mClikSlot_index = i + mClikSlot_isOut = false + break + } + } + } + // Middle clicked a slot + if (mClikSlot && mClikSlot_index !== false) { + const alphaPosY = + 0.5 - + (mClikSlot_index + 1) / + (mClikSlot_isOut ? node.outputs.length : node.inputs.length) + const node_bounding = node.getBounding() + // estimate a position: this is a bad semi-bad-working mess .. REFACTOR with + // a correct autoplacement that knows about the others slots and nodes + const posRef: Point = [ + !mClikSlot_isOut + ? node_bounding[0] + : node_bounding[0] + node_bounding[2], + e.canvasY - 80, + ] - const canvas = LGraphCanvas.active_canvas - const canvasEl = canvas.canvas + pointer.onClick = () => this.createDefaultNodeForSlot({ + nodeFrom: !mClikSlot_isOut ? null : node, + slotFrom: !mClikSlot_isOut ? null : mClikSlot_index, + nodeTo: !mClikSlot_isOut ? node : null, + slotTo: !mClikSlot_isOut ? mClikSlot_index : null, + position: posRef, + nodeType: "AUTO", + posAdd: [!mClikSlot_isOut ? -30 : 30, -alphaPosY * 130], + posSizeFix: [!mClikSlot_isOut ? -1 : 0, 0], + }) + } + } + + // Drag canvas using middle mouse button + if (this.allow_dragcanvas) { + pointer.onDragStart = () => this.dragging_canvas = true + pointer.finally = () => this.dragging_canvas = false + } + } + + /** + * Called when a mouse move event has to be processed + **/ + processMouseMove(e: PointerEvent): void { + if (this.autoresize) this.resize() + + if (this.set_canvas_dirty_on_mouse_event) this.dirty_canvas = true + + if (!this.graph) return + + LGraphCanvas.active_canvas = this + this.adjustMouseEvent(e) + const mouse: ReadOnlyPoint = [e.clientX, e.clientY] + this.mouse[0] = mouse[0] + this.mouse[1] = mouse[1] + const delta = [ + mouse[0] - this.last_mouse[0], + mouse[1] - this.last_mouse[1], + ] + this.last_mouse = mouse + this.graph_mouse[0] = e.canvasX + this.graph_mouse[1] = e.canvasY + + if (e.isPrimary) this.pointer.move(e) + + if (this.block_click) { + e.preventDefault() + return + } + + e.dragging = this.last_mouse_dragging + + /** See {@link state}.{@link LGraphCanvasState.hoveringOver hoveringOver} */ + let underPointer = CanvasItem.Nothing + // get node over + const node = this.graph.getNodeOnPos( + e.canvasX, + e.canvasY, + this.visible_nodes, + ) + const { resizingGroup } = this + + const dragRect = this.dragging_rectangle + if (dragRect) { + dragRect[2] = e.canvasX - dragRect[0] + dragRect[3] = e.canvasY - dragRect[1] + this.dirty_canvas = true + } else if (resizingGroup && !this.read_only) { + // Resizing a group + const resized = resizingGroup.resize( + e.canvasX - resizingGroup.pos[0], + e.canvasY - resizingGroup.pos[1], + ) + underPointer |= CanvasItem.ResizeSe | CanvasItem.Group + if (resized) this.dirty_bgcanvas = true + } else if (this.dragging_canvas) { + this.ds.offset[0] += delta[0] / this.ds.scale + this.ds.offset[1] += delta[1] / this.ds.scale + this.#dirty() + } else if ( + (this.allow_interaction || (node && node.flags.allow_interaction)) && + !this.read_only + ) { + if (this.connecting_links) this.dirty_canvas = true - const rect = canvasEl.getBoundingClientRect() - let offsetx = -20 - let offsety = -20 - if (rect) { - offsetx -= rect.left - offsety -= rect.top - } + // remove mouseover flag + this.updateMouseOverNodes(node, e) - if (e) { - dialog.style.left = e.clientX + offsetx + "px" - dialog.style.top = e.clientY + offsety + "px" - } else { - dialog.style.left = canvasEl.width * 0.5 + offsetx + "px" - dialog.style.top = canvasEl.height * 0.5 + offsety + "px" - } + // mouse over a node + if (node) { + underPointer |= CanvasItem.Node - const button = dialog.querySelector("button") - button.addEventListener("click", inner) - canvasEl.parentNode.appendChild(dialog) + if (node.redraw_on_mouse) this.dirty_canvas = true - input?.focus() + // For input/output hovering + // to store the output of isOverNodeInput + const pos: Point = [0, 0] + const inputId = this.isOverNodeInput(node, e.canvasX, e.canvasY, pos) + const outputId = this.isOverNodeOutput(node, e.canvasX, e.canvasY, pos) + const overWidget = this.getWidgetAtCursor(node) - let dialogCloseTimer = null - dialog.addEventListener("mouseleave", function () { - if (LiteGraph.dialog_close_on_mouse_leave) - if (!dialog.is_modified && LiteGraph.dialog_close_on_mouse_leave) - dialogCloseTimer = setTimeout(dialog.close, LiteGraph.dialog_close_on_mouse_leave_delay) //dialog.close(); - }) - dialog.addEventListener("mouseenter", function () { - if (LiteGraph.dialog_close_on_mouse_leave) - if (dialogCloseTimer) clearTimeout(dialogCloseTimer) - }) + if (!node.mouseOver) { + // mouse enter + node.mouseOver = { + inputId: null, + outputId: null, + overWidget: null, + } + this.node_over = node + this.dirty_canvas = true - function inner() { - if (input) setValue(input.value) + node.onMouseEnter?.(e) } - function setValue(value) { - if (item.type == "Number") { - value = Number(value) - } else if (item.type == "Boolean") { - value = Boolean(value) + // in case the node wants to do something + node.onMouseMove?.(e, [e.canvasX - node.pos[0], e.canvasY - node.pos[1]], this) + + // The input the mouse is over has changed + if ( + node.mouseOver.inputId !== inputId || + node.mouseOver.outputId !== outputId || + node.mouseOver.overWidget !== overWidget + ) { + node.mouseOver.inputId = inputId + node.mouseOver.outputId = outputId + node.mouseOver.overWidget = overWidget + + // Check if link is over anything it could connect to - record position of valid target for snap / highlight + if (this.connecting_links?.length) { + const firstLink = this.connecting_links[0] + + // Default: nothing highlighted + let highlightPos: Point = null + let highlightInput: INodeInputSlot = null + let linkOverWidget: IWidget = null + + if (firstLink.node === node) { + // Cannot connect link from a node to itself + } else if (firstLink.output) { + // Connecting from an output to an input + if (inputId === -1 && outputId === -1) { + // Allow support for linking to widgets, handled externally to LiteGraph + if (this.getWidgetLinkType && overWidget) { + const widgetLinkType = this.getWidgetLinkType(overWidget, node) + if ( + widgetLinkType && + LiteGraph.isValidConnection(firstLink.output.type, widgetLinkType) + ) { + if (firstLink.node.isValidWidgetLink?.(firstLink.output.slot_index, node, overWidget) !== false) { + linkOverWidget = overWidget + this.link_over_widget_type = widgetLinkType + } + } + } + // Node background / title under the pointer + if (!linkOverWidget) { + const targetSlotId = firstLink.node.findConnectByTypeSlot(true, node, firstLink.output.type) + if (targetSlotId !== null && targetSlotId >= 0) { + node.getConnectionPos(true, targetSlotId, pos) + highlightPos = pos + highlightInput = node.inputs[targetSlotId] + } + } + } else if ( + inputId != -1 && + node.inputs[inputId] && + LiteGraph.isValidConnection(firstLink.output.type, node.inputs[inputId].type) + ) { + // check if I have a slot below de mouse + if ( + inputId != -1 && + node.inputs[inputId] && + LiteGraph.isValidConnection(firstLink.output.type, node.inputs[inputId].type) + ) { + highlightPos = pos + highlightInput = node.inputs[inputId] // XXX CHECK THIS + } + } + } else if (firstLink.input) { + // Connecting from an input to an output + if (inputId === -1 && outputId === -1) { + const targetSlotId = firstLink.node.findConnectByTypeSlot(false, node, firstLink.input.type) + + if (targetSlotId !== null && targetSlotId >= 0) { + node.getConnectionPos(false, targetSlotId, pos) + highlightPos = pos + } + } else { + // check if I have a slot below de mouse + if ( + outputId != -1 && + node.outputs[outputId] && + LiteGraph.isValidConnection(firstLink.input.type, node.outputs[outputId].type) + ) { + highlightPos = pos + } + } } - node[property] = value - dialog.parentNode?.removeChild(dialog) - canvas.setDirty(true, true) - } - } - static getPropertyPrintableValue(value: unknown, values: unknown[] | object): string { - if (!values) - return String(value) + this._highlight_pos = highlightPos + this._highlight_input = highlightInput + this.link_over_widget = linkOverWidget + } - if (Array.isArray(values)) { - return String(value) + this.dirty_canvas = true } - if (typeof values === "object") { - let desc_value = "" - for (const k in values) { - if (values[k] != value) - continue - desc_value = k - break - } - return String(value) + " (" + desc_value + ")" + // Resize corner + if (this.canvas && !e.ctrlKey) { + if (node.inResizeCorner(e.canvasX, e.canvasY)) underPointer |= CanvasItem.ResizeSe } - } - static onMenuNodeCollapse(value: IContextMenuValue, options: IContextMenuOptions, e: MouseEvent, menu: ContextMenu, node: LGraphNode): void { - node.graph.beforeChange( /*?*/) - - const fApplyMultiNode = function (node) { - node.collapse() + } else { + // Not over a node + const segment = this.#getLinkCentreOnPos(e) + if (this.over_link_center !== segment) { + underPointer |= CanvasItem.Link + this.over_link_center = segment + this.dirty_bgcanvas = true } - const graphcanvas = LGraphCanvas.active_canvas - if (!graphcanvas.selected_nodes || Object.keys(graphcanvas.selected_nodes).length <= 1) { - fApplyMultiNode(node) - } else { - for (const i in graphcanvas.selected_nodes) { - fApplyMultiNode(graphcanvas.selected_nodes[i]) - } - } + if (this.canvas) { + const group = this.graph.getGroupOnPos(e.canvasX, e.canvasY) + if ( + group && + !e.ctrlKey && + !this.read_only && + group.isInResize(e.canvasX, e.canvasY) + ) { + underPointer |= CanvasItem.ResizeSe + } + } + } // end + + // send event to node if capturing input (used with widgets that allow drag outside of the area of the node) + if (this.node_capturing_input && this.node_capturing_input != node) { + this.node_capturing_input.onMouseMove?.( + e, + [ + e.canvasX - this.node_capturing_input.pos[0], + e.canvasY - this.node_capturing_input.pos[1], + ], + this, + ) + } - node.graph.afterChange( /*?*/) - } - static onMenuToggleAdvanced(value: IContextMenuValue, options: IContextMenuOptions, e: MouseEvent, menu: ContextMenu, node: LGraphNode): void { - node.graph.beforeChange( /*?*/) - const fApplyMultiNode = function (node: LGraphNode) { - node.toggleAdvanced() - } + // Items being dragged + if (this.isDragging) { + const selected = this.selectedItems + const allItems = e.ctrlKey ? selected : getAllNestedItems(selected) - const graphcanvas = LGraphCanvas.active_canvas - if (!graphcanvas.selected_nodes || Object.keys(graphcanvas.selected_nodes).length <= 1) { - fApplyMultiNode(node) - } else { - for (const i in graphcanvas.selected_nodes) { - fApplyMultiNode(graphcanvas.selected_nodes[i]) - } + const deltaX = delta[0] / this.ds.scale + const deltaY = delta[1] / this.ds.scale + for (const item of allItems) { + item.move(deltaX, deltaY, true) } - node.graph.afterChange( /*?*/) - } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - static onMenuNodePin(value: IContextMenuValue, options: IContextMenuOptions, e: MouseEvent, menu: ContextMenu, node: LGraphNode): void { - } - static onMenuNodeMode(value: IContextMenuValue, options: IContextMenuOptions, e: MouseEvent, menu: ContextMenu, node: LGraphNode): boolean { - new LiteGraph.ContextMenu( - LiteGraph.NODE_MODES, - { event: e, callback: inner_clicked, parentMenu: menu, node: node } - ) - function inner_clicked(v) { - if (!node) return + this.#dirty() + } - const kV = Object.values(LiteGraph.NODE_MODES).indexOf(v) - const fApplyMultiNode = function (node) { - if (kV >= 0 && LiteGraph.NODE_MODES[kV]) - node.changeMode(kV) - else { - console.warn("unexpected mode: " + v) - node.changeMode(LGraphEventMode.ALWAYS) - } - } + if (this.resizing_node) { + // convert mouse to node space + const desired_size: Size = [ + e.canvasX - this.resizing_node.pos[0], + e.canvasY - this.resizing_node.pos[1], + ] + const min_size = this.resizing_node.computeSize() + desired_size[0] = Math.max(min_size[0], desired_size[0]) + desired_size[1] = Math.max(min_size[1], desired_size[1]) + this.resizing_node.setSize(desired_size) - const graphcanvas = LGraphCanvas.active_canvas - if (!graphcanvas.selected_nodes || Object.keys(graphcanvas.selected_nodes).length <= 1) { - fApplyMultiNode(node) + underPointer |= CanvasItem.ResizeSe + this.#dirty() + } + } + + this.state.hoveringOver = underPointer + + if (this.state.shouldSetCursor) { + if (!underPointer) { + this.canvas.style.cursor = "" + } else if (underPointer & CanvasItem.ResizeSe) { + this.canvas.style.cursor = "se-resize" + } else if (underPointer & CanvasItem.Node) { + this.canvas.style.cursor = "crosshair" + } + } + + e.preventDefault() + return + } + + /** + * Start dragging an item, optionally including all other selected items. + * + * ** This function sets the {@link CanvasPointer.finally}() callback. ** + * @param item The item that the drag event started on + * @param pointer The pointer event that initiated the drag, e.g. pointerdown + * @param sticky If `true`, the item is added to the selection - see {@link processSelect} + */ + #startDraggingItems(item: Positionable, pointer: CanvasPointer, sticky = false): void { + this.emitBeforeChange() + this.graph.beforeChange() + // Ensure that dragging is properly cleaned up, on success or failure. + pointer.finally = () => { + this.isDragging = false + this.graph.afterChange() + this.emitAfterChange() + } + + this.processSelect(item, pointer.eDown, sticky) + this.isDragging = true + } + + /** + * Handles shared clean up and placement after items have been dragged. + * @param e The event that completed the drag, e.g. pointerup, pointermove + */ + #processDraggedItems(e: CanvasPointerEvent): void { + const { graph } = this + if (e.shiftKey || graph.config.alwaysSnapToGrid) + graph.snapToGrid(this.selectedItems) + + this.dirty_canvas = true + this.dirty_bgcanvas = true + + // TODO: Replace legacy behaviour: callbacks were never extended for multiple items + this.onNodeMoved?.(findFirstNode(this.selectedItems)) + } + + /** + * Called when a mouse up event has to be processed + **/ + processMouseUp(e: PointerEvent): void { + // early exit for extra pointer + if (e.isPrimary === false) return + + const { graph, pointer } = this + if (!graph) return + + LGraphCanvas.active_canvas = this + + this.adjustMouseEvent(e) + + /** The mouseup event occurred near the mousedown event. */ + /** Normal-looking click event - mouseUp occurred near mouseDown, without dragging. */ + const isClick = pointer.up(e) + if (isClick === true) { + pointer.isDown = false + pointer.isDouble = false + // Required until all link behaviour is added to Pointer API + this.connecting_links = null + this.dragging_canvas = false + + graph.change() + + e.stopPropagation() + e.preventDefault() + return + } + + const now = LiteGraph.getTime() + e.click_time = now - this.last_mouseclick + this.last_mouse_dragging = false + this.last_click_position = null + + // used to avoid sending twice a click in an immediate button + this.block_click &&= false + + if (e.button === 0) { + // left button + this.selected_group = null + + this.isDragging = false + + const x = e.canvasX + const y = e.canvasY + const node = graph.getNodeOnPos(x, y, this.visible_nodes) + + if (this.connecting_links?.length) { + // node below mouse + const firstLink = this.connecting_links[0] + if (node) { + for (const link of this.connecting_links) { + // dragging a connection + this.#dirty() + + // slot below mouse? connect + if (link.output) { + const slot = this.isOverNodeInput(node, x, y) + if (slot != -1) { + link.node.connect(link.slot, node, slot, link.afterRerouteId) + } else if (this.link_over_widget) { + this.emitEvent({ + subType: "connectingWidgetLink", + link, + node, + widget: this.link_over_widget, + }) + this.link_over_widget = null + } else { + // not on top of an input + // look for a good slot + link.node.connectByType(link.slot, node, link.output.type, { + afterRerouteId: link.afterRerouteId, + }) + } + } else if (link.input) { + const slot = this.isOverNodeOutput(node, x, y) + + if (slot != -1) { + node.connect(slot, link.node, link.slot, link.afterRerouteId) // this is inverted has output-input nature like + } else { + // not on top of an input + // look for a good slot + link.node.connectByTypeOutput( + link.slot, + node, + link.input.type, + { afterRerouteId: link.afterRerouteId }, + ) + } + } + } + } else if (firstLink.input || firstLink.output) { + const linkReleaseContext = firstLink.output + ? { + node_from: firstLink.node, + slot_from: firstLink.output, + type_filter_in: firstLink.output.type, + } + : { + node_to: firstLink.node, + slot_from: firstLink.input, + type_filter_out: firstLink.input.type, + } + // For external event only. + const linkReleaseContextExtended: LinkReleaseContextExtended = { + links: this.connecting_links, + } + this.emitEvent({ + subType: "empty-release", + originalEvent: e, + linkReleaseContext: linkReleaseContextExtended, + }) + // No longer in use + // add menu when releasing link in empty space + if (LiteGraph.release_link_on_empty_shows_menu) { + if (e.shiftKey) { + if (this.allow_searchbox) { + this.showSearchBox(e, linkReleaseContext) + } } else { - for (const i in graphcanvas.selected_nodes) { - fApplyMultiNode(graphcanvas.selected_nodes[i]) - } + if (firstLink.output) { + this.showConnectionMenu({ nodeFrom: firstLink.node, slotFrom: firstLink.output, e: e }) + } else if (firstLink.input) { + this.showConnectionMenu({ nodeTo: firstLink.node, slotTo: firstLink.input, e: e }) + } } + } } + } else { + this.dirty_canvas = true - return false + // @ts-expect-error Unused param + this.node_over?.onMouseUp?.(e, [x - this.node_over.pos[0], y - this.node_over.pos[1]], this) + this.node_capturing_input?.onMouseUp?.(e, [ + x - this.node_capturing_input.pos[0], + y - this.node_capturing_input.pos[1], + ]) + } + + this.connecting_links = null + } else if (e.button === 1) { + // middle button + this.dirty_canvas = true + this.dragging_canvas = false + } else if (e.button === 2) { + // right button + this.dirty_canvas = true + } + + pointer.isDown = false + pointer.isDouble = false + + graph.change() + + e.stopPropagation() + e.preventDefault() + return + } + + /** + * Called when the mouse moves off the canvas. Clears all node hover states. + * @param e + */ + processMouseOut(e: MouseEvent): void { + // TODO: Check if document.contains(e.relatedTarget) - handle mouseover node textarea etc. + this.adjustMouseEvent(e) + this.updateMouseOverNodes(null, e) + } + + processMouseCancel(e: PointerEvent): void { + console.warn("Pointer cancel!") + this.pointer.reset() + } + + /** + * Called when a mouse wheel event has to be processed + **/ + processMouseWheel(e: WheelEvent): void { + if (!this.graph || !this.allow_dragcanvas) return + + // TODO: Mouse wheel zoom rewrite + // @ts-expect-error + const delta = e.wheelDeltaY ?? e.detail * -60 + + this.adjustMouseEvent(e) + + const pos: Point = [e.clientX, e.clientY] + if (this.viewport && !isPointInRect(pos, this.viewport)) return + + let scale = this.ds.scale + + if (delta > 0) scale *= this.zoom_speed + else if (delta < 0) scale *= 1 / this.zoom_speed + + this.ds.changeScale(scale, [e.clientX, e.clientY]) + + this.graph.change() + + e.preventDefault() + return + } + + /** + * returns the INDEX if a position (in graph space) is on top of a node input slot + **/ + isOverNodeInput( + node: LGraphNode, + canvasx: number, + canvasy: number, + slot_pos?: Point, + ): number { + if (node.inputs) { + for (let i = 0, l = node.inputs.length; i < l; ++i) { + const input = node.inputs[i] + const link_pos = node.getConnectionPos(true, i) + let is_inside = false + if (node.horizontal) { + is_inside = isInRectangle( + canvasx, + canvasy, + link_pos[0] - 5, + link_pos[1] - 10, + 10, + 20, + ) + } else { + // TODO: Find a cheap way to measure text, and do it on node label change instead of here + // Input icon width + text approximation + const width = + 20 + ((input.label?.length ?? input.name?.length) || 3) * 7 + is_inside = isInRectangle( + canvasx, + canvasy, + link_pos[0] - 10, + link_pos[1] - 10, + width, + 20, + ) + } + if (is_inside) { + if (slot_pos) { + slot_pos[0] = link_pos[0] + slot_pos[1] = link_pos[1] + } + return i + } + } + } + return -1 + } + + /** + * returns the INDEX if a position (in graph space) is on top of a node output slot + **/ + isOverNodeOutput( + node: LGraphNode, + canvasx: number, + canvasy: number, + slot_pos?: Point, + ): number { + if (node.outputs) { + for (let i = 0, l = node.outputs.length; i < l; ++i) { + const link_pos = node.getConnectionPos(false, i) + let is_inside = false + if (node.horizontal) { + is_inside = isInRectangle( + canvasx, + canvasy, + link_pos[0] - 5, + link_pos[1] - 10, + 10, + 20, + ) + } else { + is_inside = isInRectangle( + canvasx, + canvasy, + link_pos[0] - 10, + link_pos[1] - 10, + 40, + 20, + ) + } + if (is_inside) { + if (slot_pos) { + slot_pos[0] = link_pos[0] + slot_pos[1] = link_pos[1] + } + return i + } + } + } + return -1 + } + + /** + * process a key event + **/ + processKey(e: KeyboardEvent): boolean | null { + this.#shiftDown = e.shiftKey + if (!this.graph) return + + let block_default = false + // console.log(e); //debug + // @ts-expect-error + if (e.target.localName == "input") return + + if (e.type == "keydown") { + // TODO: Switch + if (e.keyCode == 32) { + // space + this.read_only = true + if (this._previously_dragging_canvas === null) { + this._previously_dragging_canvas = this.dragging_canvas + } + this.dragging_canvas = this.pointer.isDown + block_default = true + } else if (e.keyCode == 27) { + // esc + this.node_panel?.close() + this.options_panel?.close() + block_default = true + } + + // select all Control A + else if (e.keyCode == 65 && e.ctrlKey) { + this.selectItems() + block_default = true + } else if (e.keyCode === 67 && (e.metaKey || e.ctrlKey) && !e.shiftKey) { + // copy + if (this.selected_nodes) { + this.copyToClipboard() + block_default = true + } + } else if (e.keyCode === 86 && (e.metaKey || e.ctrlKey)) { + // paste + this.pasteFromClipboard(e.shiftKey) + } + + // delete or backspace + else if (e.keyCode == 46 || e.keyCode == 8) { + // @ts-expect-error + if (e.target.localName != "input" && e.target.localName != "textarea") { + this.deleteSelected() + block_default = true + } + } + + // collapse + // ... + // TODO + if (this.selected_nodes) { + for (const i in this.selected_nodes) { + this.selected_nodes[i].onKeyDown?.(e) + } + } + } else if (e.type == "keyup") { + if (e.keyCode == 32) { + // space + this.read_only = false + this.dragging_canvas = this._previously_dragging_canvas ?? false + this._previously_dragging_canvas = null + } + + if (this.selected_nodes) { + for (const i in this.selected_nodes) { + this.selected_nodes[i].onKeyUp?.(e) + } + } + } + + // TODO: Do we need to remeasure and recalculate everything on every key down/up? + this.graph.change() + + if (block_default) { + e.preventDefault() + e.stopImmediatePropagation() + return false + } + } + + /** + * Copies canvas items to an internal, app-specific clipboard backed by local storage. + * When called without parameters, it copies {@link selectedItems}. + * @param items The items to copy. If nullish, all selected items are copied. + */ + copyToClipboard(items?: Iterable): void { + const serialisable: ClipboardItems = { + nodes: [], + groups: [], + reroutes: [], + links: [], + } + + // Create serialisable objects + for (const item of items ?? this.selectedItems) { + if (item instanceof LGraphNode) { + // Nodes + if (item.clonable === false) continue + + const cloned = item.clone()?.serialize() + if (!cloned) continue + + cloned.id = item.id + serialisable.nodes.push(cloned) + + // Links + const links = item.inputs + ?.map(input => this.graph._links.get(input?.link)?.asSerialisable()) + .filter(x => !!x) + + if (!links) continue + serialisable.links.push(...links) + } else if (item instanceof LGraphGroup) { + // Groups + serialisable.groups.push(item.serialize()) + } else if (this.reroutesEnabled && item instanceof Reroute) { + // Reroutes + serialisable.reroutes.push(item.asSerialisable()) + } + } + + localStorage.setItem( + "litegrapheditor_clipboard", + JSON.stringify(serialisable), + ) + } + + emitEvent(detail: CanvasEventDetail): void { + this.canvas.dispatchEvent( + new CustomEvent("litegraph:canvas", { + bubbles: true, + detail, + }), + ) + } + + /** @todo Refactor to where it belongs - e.g. Deleting / creating nodes is not actually canvas event. */ + emitBeforeChange(): void { + this.emitEvent({ + subType: "before-change", + }) + } + + /** @todo See {@link emitBeforeChange} */ + emitAfterChange(): void { + this.emitEvent({ + subType: "after-change", + }) + } + + /** + * Pastes the items from the canvas "clipbaord" - a local storage variable. + * @param connectInputs If `true`, always attempt to connect inputs of pasted nodes - including to nodes that were not pasted. + */ + _pasteFromClipboard(connectInputs = false): ClipboardPasteResult { + // if ctrl + shift + v is off, return when isConnectUnselected is true (shift is pressed) to maintain old behavior + if (!LiteGraph.ctrl_shift_v_paste_connect_unselected_outputs && connectInputs) return + + const data = localStorage.getItem("litegrapheditor_clipboard") + if (!data) return + + const { graph } = this + graph.beforeChange() + + // Parse & initialise + const parsed: ClipboardItems = JSON.parse(data) + parsed.nodes ??= [] + parsed.groups ??= [] + parsed.reroutes ??= [] + parsed.links ??= [] + + // Find top-left-most boundary + let offsetX = Infinity + let offsetY = Infinity + for (const item of [...parsed.nodes, ...parsed.reroutes]) { + if (item.pos[0] < offsetX) offsetX = item.pos[0] + if (item.pos[1] < offsetY) offsetY = item.pos[1] + } + + // TODO: Remove when implementing `asSerialisable` + if (parsed.groups) { + for (const group of parsed.groups) { + if (group.bounding[0] < offsetX) offsetX = group.bounding[0] + if (group.bounding[1] < offsetY) offsetY = group.bounding[1] + } + } + + const results: ClipboardPasteResult = { + created: [], + nodes: new Map(), + links: new Map(), + reroutes: new Map(), + } + const { created, nodes, links, reroutes } = results + + // const failedNodes: ISerialisedNode[] = [] + + // Groups + for (const info of parsed.groups) { + info.id = undefined + + const group = new LGraphGroup() + group.configure(info) + graph.add(group) + created.push(group) + } + + // Nodes + for (const info of parsed.nodes) { + const node = LiteGraph.createNode(info.type) + if (!node) { + // failedNodes.push(info) + continue + } + + nodes.set(info.id, node) + info.id = undefined + + node.configure(info) + graph.add(node) + + created.push(node) + } + + // Reroutes + for (const info of parsed.reroutes) { + const { id } = info + info.id = undefined + + const reroute = graph.setReroute(info) + created.push(reroute) + reroutes.set(id, reroute) + } + + // Remap reroute parentIds for pasted reroutes + for (const reroute of reroutes.values()) { + const mapped = reroutes.get(reroute.parentId) + if (mapped) reroute.parentId = mapped.id + } + + // Links + for (const info of parsed.links) { + // Find the copied node / reroute ID + let outNode = nodes.get(info.origin_id) + let afterRerouteId = reroutes.get(info.parentId)?.id + + // If it wasn't copied, use the original graph value + if (connectInputs && LiteGraph.ctrl_shift_v_paste_connect_unselected_outputs) { + outNode ??= graph.getNodeById(info.origin_id) + afterRerouteId ??= info.parentId + } + + const inNode = nodes.get(info.target_id) + if (inNode) { + const link = outNode?.connect( + info.origin_slot, + inNode, + info.target_slot, + afterRerouteId, + ) + if (link) links.set(info.id, link) + } } - /** @param value Parameter is never used */ - static onMenuNodeColors(value: IContextMenuValue, options: IContextMenuOptions, e: MouseEvent, menu: ContextMenu, node: LGraphNode): boolean { - if (!node) throw "no node for color" + // Remap linkIds + for (const reroute of reroutes.values()) { + const ids = [...reroute.linkIds].map(x => links.get(x)?.id ?? x) + reroute.update(reroute.parentId, undefined, ids) - const values: IContextMenuValue[] = [] - values.push({ - value: null, - content: "No color" - }) + // Remove any invalid items + if (!reroute.validateLinks(graph.links)) graph.removeReroute(reroute.id) + } - for (const i in LGraphCanvas.node_colors) { - const color = LGraphCanvas.node_colors[i] - value = { - value: i, - content: "" + - i + - "" - } - values.push(value) - } - new LiteGraph.ContextMenu(values, { - event: e, - callback: inner_clicked, - parentMenu: menu, - node: node - }) + // Adjust positions + for (const item of created) { + item.pos[0] += this.graph_mouse[0] - offsetX + item.pos[1] += this.graph_mouse[1] - offsetY + } - function inner_clicked(v: { value: string | number }) { - if (!node) return + // TODO: Report failures, i.e. `failedNodes` - const color = v.value ? LGraphCanvas.node_colors[v.value] : null + this.selectItems(created) - const fApplyColor = function (node: LGraphNode) { - if (color) { - if (node instanceof LGraphGroup) { - node.color = color.groupcolor - } else { - node.color = color.color - node.bgcolor = color.bgcolor - } - } else { - delete node.color - delete node.bgcolor - } - } + graph.afterChange() - const canvas = LGraphCanvas.active_canvas - if (!canvas.selected_nodes || Object.keys(canvas.selected_nodes).length <= 1) { - fApplyColor(node) - } else { - for (const i in canvas.selected_nodes) { - fApplyColor(canvas.selected_nodes[i]) - } - } - canvas.setDirty(true, true) - } + return results + } - return false + pasteFromClipboard(isConnectUnselected = false): void { + this.emitBeforeChange() + try { + this._pasteFromClipboard(isConnectUnselected) + } finally { + this.emitAfterChange() } - static onMenuNodeShapes(value: IContextMenuValue, options: IContextMenuOptions, e: MouseEvent, menu: ContextMenu, node: LGraphNode): boolean { - if (!node) throw "no node passed" + } - new LiteGraph.ContextMenu(LiteGraph.VALID_SHAPES, { - event: e, - callback: inner_clicked, - parentMenu: menu, - node: node - }) + /** + * process a item drop event on top the canvas + **/ + processDrop(e: DragEvent): boolean { + e.preventDefault() + this.adjustMouseEvent(e) + const x = e.clientX + const y = e.clientY + const is_inside = !this.viewport || isInRect(x, y, this.viewport) + if (!is_inside) return - function inner_clicked(v) { - if (!node) return + const pos = [e.canvasX, e.canvasY] + const node = this.graph ? this.graph.getNodeOnPos(pos[0], pos[1]) : null - node.graph.beforeChange( /*?*/) //node + if (!node) { + const r = this.onDropItem?.(e) + if (!r) this.checkDropItem(e) + return + } + + if (node.onDropFile || node.onDropData) { + const files = e.dataTransfer.files + if (files && files.length) { + for (let i = 0; i < files.length; i++) { + const file = e.dataTransfer.files[0] + const filename = file.name + node.onDropFile?.(file) - const fApplyMultiNode = function (node) { - node.shape = v + if (node.onDropData) { + // prepare reader + const reader = new FileReader() + reader.onload = function (event) { + const data = event.target.result + node.onDropData(data, filename, file) } - const canvas = LGraphCanvas.active_canvas - if (!canvas.selected_nodes || Object.keys(canvas.selected_nodes).length <= 1) { - fApplyMultiNode(node) + // read data + const type = file.type.split("/")[0] + if (type == "text" || type == "") { + reader.readAsText(file) + } else if (type == "image") { + reader.readAsDataURL(file) } else { - for (const i in canvas.selected_nodes) { - fApplyMultiNode(canvas.selected_nodes[i]) - } + reader.readAsArrayBuffer(file) } - - node.graph.afterChange( /*?*/) //node - canvas.setDirty(true) - } - - return false + } + } + } + } + + if (node.onDropItem?.(e)) return true + + return this.onDropItem + ? this.onDropItem(e) + : false + } + + // called if the graph doesn't have a default drop item behaviour + checkDropItem(e: CanvasDragEvent): void { + if (!e.dataTransfer.files.length) return + + const file = e.dataTransfer.files[0] + const ext = LGraphCanvas.getFileExtension(file.name).toLowerCase() + const nodetype = LiteGraph.node_types_by_file_extension[ext] + if (!nodetype) return + + this.graph.beforeChange() + const node = LiteGraph.createNode(nodetype.type) + node.pos = [e.canvasX, e.canvasY] + this.graph.add(node) + node.onDropFile?.(file) + this.graph.afterChange() + } + + processNodeDblClicked(n: LGraphNode): void { + this.onShowNodePanel?.(n) + this.onNodeDblClicked?.(n) + + this.setDirty(true) + } + + #handleMultiSelect(e: CanvasPointerEvent, dragRect: Float32Array) { + // Process drag + // Convert Point pair (pos, offset) to Rect + const { graph, selectedItems } = this + + const w = Math.abs(dragRect[2]) + const h = Math.abs(dragRect[3]) + if (dragRect[2] < 0) dragRect[0] -= w + if (dragRect[3] < 0) dragRect[1] -= h + dragRect[2] = w + dragRect[3] = h + + // Select nodes - any part of the node is in the select area + const isSelected: Positionable[] = [] + const notSelected: Positionable[] = [] + for (const nodeX of graph._nodes) { + if (!overlapBounding(dragRect, nodeX.boundingRect)) continue + + if (!nodeX.selected || !selectedItems.has(nodeX)) + notSelected.push(nodeX) + else isSelected.push(nodeX) + } + + // Select groups - the group is wholly inside the select area + for (const group of graph.groups) { + if (!containsRect(dragRect, group._bounding)) continue + group.recomputeInsideNodes() + + if (!group.selected || !selectedItems.has(group)) + notSelected.push(group) + else isSelected.push(group) + } + + // Select reroutes - the centre point is inside the select area + for (const reroute of graph.reroutes.values()) { + if (!isPointInRect(reroute.pos, dragRect)) continue + + selectedItems.add(reroute) + reroute.selected = true + + if (!reroute.selected || !selectedItems.has(reroute)) + notSelected.push(reroute) + else isSelected.push(reroute) + } + + if (e.shiftKey) { + // Add to selection + for (const item of notSelected) this.select(item) + } else if (e.altKey) { + // Remove from selection + for (const item of isSelected) this.deselect(item) + } else { + // Replace selection + for (const item of selectedItems.values()) { + if (!isSelected.includes(item)) this.deselect(item) + } + for (const item of notSelected) this.select(item) + } + } + + /** + * Determines whether to select or deselect an item that has received a pointer event. Will deselect other nodes if + * @param item Canvas item to select/deselect + * @param e The MouseEvent to handle + * @param sticky Prevents deselecting individual nodes (as used by aux/right-click) + * @remarks + * Accessibility: anyone using {@link mutli_select} always deselects when clicking empty space. + */ + processSelect( + item: TPositionable | null, + e: CanvasMouseEvent, + sticky: boolean = false, + ): void { + const addModifier = e?.shiftKey + const subtractModifier = e != null && (e.metaKey || e.ctrlKey) + const eitherModifier = addModifier || subtractModifier + const modifySelection = eitherModifier || this.multi_select + + if (!item) { + if (!eitherModifier || this.multi_select) this.deselectAll() + } else if (!item.selected || !this.selectedItems.has(item)) { + if (!modifySelection) this.deselectAll(item) + this.select(item) + } else if (modifySelection && !sticky) { + this.deselect(item) + } else if (!sticky) { + this.deselectAll(item) + } else { + return + } + this.onSelectionChange?.(this.selected_nodes) + this.setDirty(true) + } + + /** + * Selects a {@link Positionable} item. + * @param item The canvas item to add to the selection. + */ + select(item: TPositionable): void { + if (item.selected && this.selectedItems.has(item)) return + + item.selected = true + this.selectedItems.add(item) + if (!(item instanceof LGraphNode)) return + + // Node-specific handling + item.onSelected?.() + this.selected_nodes[item.id] = item + + this.onNodeSelected?.(item) + + // Highlight links + item.inputs?.forEach(input => this.highlighted_links[input.link] = true) + item.outputs + ?.flatMap(x => x.links) + .forEach(id => this.highlighted_links[id] = true) + } + + /** + * Deselects a {@link Positionable} item. + * @param item The canvas item to remove from the selection. + */ + deselect(item: TPositionable): void { + if (!item.selected && !this.selectedItems.has(item)) return + + item.selected = false + this.selectedItems.delete(item) + if (!(item instanceof LGraphNode)) return + + // Node-specific handling + item.onDeselected?.() + delete this.selected_nodes[item.id] + + this.onNodeDeselected?.(item) + + // Clear link highlight + item.inputs?.forEach(input => delete this.highlighted_links[input.link]) + item.outputs + ?.flatMap(x => x.links) + .forEach(id => delete this.highlighted_links[id]) + } + + /** @deprecated See {@link LGraphCanvas.processSelect} */ + processNodeSelected(item: LGraphNode, e: CanvasMouseEvent): void { + this.processSelect( + item, + e, + e && (e.shiftKey || e.metaKey || e.ctrlKey || this.multi_select), + ) + } + + /** @deprecated See {@link LGraphCanvas.select} */ + selectNode(node: LGraphNode, add_to_current_selection?: boolean): void { + if (node == null) { + this.deselectAll() + } else { + this.selectNodes([node], add_to_current_selection) + } + } + + /** + * @returns All items on the canvas that can be selected + */ + get positionableItems(): Positionable[] { + return [ + ...this.graph._nodes, + ...this.graph._groups, + ...this.graph.reroutes.values(), + ] + } + + /** + * Selects several items. + * @param items Items to select - if falsy, all items on the canvas will be selected + * @param add_to_current_selection If set, the items will be added to the current selection instead of replacing it + */ + selectItems(items?: Positionable[], add_to_current_selection?: boolean): void { + const itemsToSelect = items ?? this.positionableItems + if (!add_to_current_selection) this.deselectAll() + for (const item of itemsToSelect) this.select(item) + this.onSelectionChange?.(this.selected_nodes) + this.setDirty(true) + } + + /** + * selects several nodes (or adds them to the current selection) + * @deprecated See {@link LGraphCanvas.selectItems} + **/ + selectNodes(nodes?: LGraphNode[], add_to_current_selection?: boolean): void { + this.selectItems(nodes, add_to_current_selection) + } + + /** @deprecated See {@link LGraphCanvas.deselect} */ + deselectNode(node: LGraphNode): void { + this.deselect(node) + } + + /** + * Deselects all items on the canvas. + * @param keepSelected If set, this item will not be removed from the selection. + */ + deselectAll(keepSelected?: Positionable): void { + if (!this.graph) return + + const selected = this.selectedItems + let wasSelected: Positionable + for (const sel of selected) { + if (sel === keepSelected) { + wasSelected = sel + continue + } + sel.onDeselected?.() + sel.selected = false + } + selected.clear() + if (wasSelected) selected.add(wasSelected) + + this.setDirty(true) + + // Legacy code + const oldNode = keepSelected?.id == null ? null : this.selected_nodes[keepSelected.id] + this.selected_nodes = {} + this.current_node = null + this.highlighted_links = {} + + if (keepSelected instanceof LGraphNode) { + // Handle old object lookup + if (oldNode) this.selected_nodes[oldNode.id] = oldNode + + // Highlight links + keepSelected.inputs?.forEach(input => this.highlighted_links[input.link] = true) + keepSelected.outputs?.flatMap(x => x.links) + .forEach(id => this.highlighted_links[id] = true) + } + + this.onSelectionChange?.(this.selected_nodes) + } + + /** @deprecated See {@link LGraphCanvas.deselectAll} */ + deselectAllNodes(): void { + this.deselectAll() + } + + /** + * Deletes all selected items from the graph. + * + * @todo Refactor deletion task to LGraph. Selection is a canvas property, delete is a graph action. + */ + deleteSelected(): void { + const { graph } = this + this.emitBeforeChange() + graph.beforeChange() + + for (const item of this.selectedItems) { + if (item instanceof LGraphNode) { + const node = item + if (node.block_delete) continue + node.connectInputToOutput() + graph.remove(node) + this.onNodeDeselected?.(node) + } else if (item instanceof LGraphGroup) { + graph.remove(item) + } else if (item instanceof Reroute) { + graph.removeReroute(item.id) + } + } + + this.selectedItems.clear() + this.selected_nodes = {} + this.selectedItems.clear() + this.current_node = null + this.highlighted_links = {} + this.setDirty(true) + graph.afterChange() + this.emitAfterChange() + } + + /** + * deletes all nodes in the current selection from the graph + * @deprecated See {@link LGraphCanvas.deleteSelected} + **/ + deleteSelectedNodes(): void { + this.deleteSelected() + } + + /** + * centers the camera on a given node + **/ + centerOnNode(node: LGraphNode): void { + const dpi = window?.devicePixelRatio || 1 + this.ds.offset[0] = + -node.pos[0] - + node.size[0] * 0.5 + + (this.canvas.width * 0.5) / (this.ds.scale * dpi) + this.ds.offset[1] = + -node.pos[1] - + node.size[1] * 0.5 + + (this.canvas.height * 0.5) / (this.ds.scale * dpi) + this.setDirty(true, true) + } + + /** + * adds some useful properties to a mouse event, like the position in graph coordinates + **/ + adjustMouseEvent( + e: T & Partial, + ): asserts e is T & CanvasMouseEvent { + let clientX_rel = e.clientX + let clientY_rel = e.clientY + + if (this.canvas) { + const b = this.canvas.getBoundingClientRect() + clientX_rel -= b.left + clientY_rel -= b.top + } + + // TODO: Find a less brittle way to do this + + // Only set deltaX and deltaY if not already set. + // If deltaX and deltaY are already present, they are read-only. + // Setting them would result browser error => zoom in/out feature broken. + if (e.deltaX === undefined) + e.deltaX = clientX_rel - this.last_mouse_position[0] + if (e.deltaY === undefined) + e.deltaY = clientY_rel - this.last_mouse_position[1] + + this.last_mouse_position[0] = clientX_rel + this.last_mouse_position[1] = clientY_rel + + e.canvasX = clientX_rel / this.ds.scale - this.ds.offset[0] + e.canvasY = clientY_rel / this.ds.scale - this.ds.offset[1] + } + + /** + * changes the zoom level of the graph (default is 1), you can pass also a place used to pivot the zoom + **/ + setZoom(value: number, zooming_center: Point) { + this.ds.changeScale(value, zooming_center) + this.#dirty() + } + + /** + * converts a coordinate from graph coordinates to canvas2D coordinates + **/ + convertOffsetToCanvas(pos: Point, out: Point): Point { + // @ts-expect-error Unused param + return this.ds.convertOffsetToCanvas(pos, out) + } + + /** + * converts a coordinate from Canvas2D coordinates to graph space + **/ + convertCanvasToOffset(pos: Point, out?: Point): Point { + return this.ds.convertCanvasToOffset(pos, out) + } + + // converts event coordinates from canvas2D to graph coordinates + convertEventToCanvasOffset(e: MouseEvent): Point { + const rect = this.canvas.getBoundingClientRect() + // TODO: -> this.ds.convertCanvasToOffset + return this.convertCanvasToOffset([ + e.clientX - rect.left, + e.clientY - rect.top, + ]) + } + + /** + * brings a node to front (above all other nodes) + **/ + bringToFront(node: LGraphNode): void { + const i = this.graph._nodes.indexOf(node) + if (i == -1) return + + this.graph._nodes.splice(i, 1) + this.graph._nodes.push(node) + } + + /** + * sends a node to the back (below all other nodes) + **/ + sendToBack(node: LGraphNode): void { + const i = this.graph._nodes.indexOf(node) + if (i == -1) return + + this.graph._nodes.splice(i, 1) + this.graph._nodes.unshift(node) + } + + /** + * Determines which nodes are visible and populates {@link out} with the results. + * @param nodes The list of nodes to check - if falsy, all nodes in the graph will be checked + * @param out Array to write visible nodes into - if falsy, a new array is created instead + * @returns {LGraphNode[]} Array passed ({@link out}), or a new array containing all visible nodes + */ + computeVisibleNodes(nodes?: LGraphNode[], out?: LGraphNode[]): LGraphNode[] { + const visible_nodes = out || [] + visible_nodes.length = 0 + + const _nodes = nodes || this.graph._nodes + for (const node of _nodes) { + node.updateArea() + // Not in visible area + if (!overlapBounding(this.visible_area, node.renderArea)) continue + + visible_nodes.push(node) + } + return visible_nodes + } + + /** + * renders the whole canvas content, by rendering in two separated canvas, one containing the background grid and the connections, and one containing the nodes) + **/ + draw(force_canvas?: boolean, force_bgcanvas?: boolean): void { + if (!this.canvas || this.canvas.width == 0 || this.canvas.height == 0) return + + // fps counting + const now = LiteGraph.getTime() + this.render_time = (now - this.last_draw_time) * 0.001 + this.last_draw_time = now + + if (this.graph) this.ds.computeVisibleArea(this.viewport) + + // Compute node size before drawing links. + if (this.dirty_canvas || force_canvas) + this.computeVisibleNodes(null, this.visible_nodes) + + if ( + this.dirty_bgcanvas || + force_bgcanvas || + this.always_render_background || + (this.graph?._last_trigger_time && + now - this.graph._last_trigger_time < 1000) + ) { + this.drawBackCanvas() } - static onMenuNodeRemove(value: IContextMenuValue, options: IContextMenuOptions, e: MouseEvent, menu: ContextMenu, node: LGraphNode): void { - if (!node) throw "no node passed" - const graph = node.graph - graph.beforeChange() + if (this.dirty_canvas || force_canvas) this.drawFrontCanvas() - const fApplyMultiNode = function (node: LGraphNode) { - if (node.removable === false) return + this.fps = this.render_time ? 1.0 / this.render_time : 0 + this.frame++ + } - graph.remove(node) - } + /** + * draws the front canvas (the one containing all the nodes) + **/ + drawFrontCanvas(): void { + this.dirty_canvas = false - const canvas = LGraphCanvas.active_canvas - if (!canvas.selected_nodes || Object.keys(canvas.selected_nodes).length <= 1) { - fApplyMultiNode(node) - } else { - for (const i in canvas.selected_nodes) { - fApplyMultiNode(canvas.selected_nodes[i]) - } - } + if (!this.ctx) { + this.ctx = this.bgcanvas.getContext("2d") + } + const ctx = this.ctx + // maybe is using webgl... + if (!ctx) return - graph.afterChange() - canvas.setDirty(true, true) + const canvas = this.canvas + // @ts-expect-error + if (ctx.start2D && !this.viewport) { + // @ts-expect-error + ctx.start2D() + ctx.restore() + ctx.setTransform(1, 0, 0, 1, 0, 0) } - static onMenuNodeToSubgraph(value: IContextMenuValue, options: IContextMenuOptions, e: MouseEvent, menu: ContextMenu, node: LGraphNode): void { - const graph = node.graph - const canvas = LGraphCanvas.active_canvas - if (!canvas) return - let nodes_list = Object.values(canvas.selected_nodes || {}) - if (!nodes_list.length) - nodes_list = [node] + // clip dirty area if there is one, otherwise work in full canvas + const area = this.viewport || this.dirty_area + if (area) { + ctx.save() + ctx.beginPath() + ctx.rect(area[0], area[1], area[2], area[3]) + ctx.clip() + } - const subgraph_node = LiteGraph.createNode("graph/subgraph") - // @ts-expect-error Refactor this to use typed array. - subgraph_node.pos = node.pos.concat() - graph.add(subgraph_node) + // TODO: Set snapping value when changed instead of once per frame + this.#snapToGrid = this.#shiftDown || this.graph.config.alwaysSnapToGrid + ? this.graph.getSnapToGridSize() + : undefined - // @ts-expect-error Doesn't exist anywhere... - subgraph_node.buildFromNodes(nodes_list) + // clear + // canvas.width = canvas.width; + if (this.clear_background) { + if (area) ctx.clearRect(area[0], area[1], area[2], area[3]) + else ctx.clearRect(0, 0, canvas.width, canvas.height) + } - canvas.deselectAll() - canvas.setDirty(true, true) + // draw bg canvas + if (this.bgcanvas == this.canvas) { + this.drawBackCanvas() + } else { + const scale = window.devicePixelRatio + ctx.drawImage( + this.bgcanvas, + 0, + 0, + this.bgcanvas.width / scale, + this.bgcanvas.height / scale, + ) } - static onMenuNodeClone(value: IContextMenuValue, options: IContextMenuOptions, e: MouseEvent, menu: ContextMenu, node: LGraphNode): void { - const graph = node.graph - graph.beforeChange() + // rendering + this.onRender?.(canvas, ctx) - const newSelected = new Set() + // info widget + if (this.show_info) { + this.renderInfo(ctx, area ? area[0] : 0, area ? area[1] : 0) + } - const fApplyMultiNode = function (node: LGraphNode, newNodes: Set): void { - if (node.clonable === false) return + if (this.graph) { + // apply transformations + ctx.save() + this.ds.toCanvasContext(ctx) - const newnode = node.clone() - if (!newnode) return + // draw nodes + const visible_nodes = this.visible_nodes + const drawSnapGuides = this.#snapToGrid && this.isDragging - newnode.pos = [node.pos[0] + 5, node.pos[1] + 5] - node.graph.add(newnode) - newNodes.add(newnode) - } + for (let i = 0; i < visible_nodes.length; ++i) { + const node = visible_nodes[i] - const canvas = LGraphCanvas.active_canvas - if (!canvas.selected_nodes || Object.keys(canvas.selected_nodes).length <= 1) { - fApplyMultiNode(node, newSelected) - } else { - for (const i in canvas.selected_nodes) { - fApplyMultiNode(canvas.selected_nodes[i], newSelected) - } - } + ctx.save() - if (newSelected.size) { - canvas.selectNodes([...newSelected]) - } + // Draw snap shadow + if (drawSnapGuides && this.selectedItems.has(node)) + this.drawSnapGuide(ctx, node) - graph.afterChange() + // Localise co-ordinates to node position + ctx.translate(node.pos[0], node.pos[1]) - canvas.setDirty(true, true) - } - /** - * clears all the data inside - * - */ - clear(): void { - this.frame = 0 - this.last_draw_time = 0 - this.render_time = 0 - this.fps = 0 - - //this.scale = 1; - //this.offset = [0,0]; - this.dragging_rectangle = null - - this.selected_nodes = {} - this.selected_group = null - - this.visible_nodes = [] - this.node_over = null - this.node_capturing_input = null - this.connecting_links = null - this.highlighted_links = {} - - this.dragging_canvas = false - - this.#dirty() - this.dirty_area = null - - this.node_in_panel = null - this.node_widget = null - - this.last_mouse = [0, 0] - this.last_mouseclick = 0 - this.pointer.reset() - this.visible_area.set([0, 0, 0, 0]) - - this.onClear?.() - } - /** - * assigns a graph, you can reassign graphs to the same canvas - * - * @param {LGraph} graph - */ - setGraph(graph: LGraph, skip_clear: boolean): void { - if (this.graph == graph) return - - if (!skip_clear) this.clear() - - if (!graph && this.graph) { - this.graph.detachCanvas(this) - return - } + // Draw + this.drawNode(node, ctx) - graph.attachCanvas(this) - - //remove the graph stack in case a subgraph was open - this._graph_stack &&= null + ctx.restore() + } + + // on top (debug) + if (this.render_execution_order) { + this.drawExecutionOrder(ctx) + } + + // connections ontop? + if (this.graph.config.links_ontop) { + this.drawConnections(ctx) + } + + if (this.connecting_links?.length) { + // current connection (the one being dragged by the mouse) + for (const link of this.connecting_links) { + ctx.lineWidth = this.connections_width + let link_color = null + + const connInOrOut = link.output || link.input + + const connType = connInOrOut?.type + let connDir = connInOrOut?.dir + if (connDir == null) { + if (link.output) + connDir = link.node.horizontal + ? LinkDirection.DOWN + : LinkDirection.RIGHT + else + connDir = link.node.horizontal + ? LinkDirection.UP + : LinkDirection.LEFT + } + const connShape = connInOrOut?.shape + + switch (connType) { + case LiteGraph.EVENT: + link_color = LiteGraph.EVENT_LINK_COLOR + break + default: + link_color = LiteGraph.CONNECTING_LINK_COLOR + } + + // If not using reroutes, link.afterRerouteId should be undefined. + const pos = this.graph.reroutes.get(link.afterRerouteId)?.pos ?? link.pos + const highlightPos = this.#getHighlightPosition() + // the connection being dragged by the mouse + this.renderLink( + ctx, + pos, + highlightPos, + null, + false, + null, + link_color, + connDir, + link.direction ?? LinkDirection.CENTER, + ) + + ctx.beginPath() + if (connType === LiteGraph.EVENT || connShape === RenderShape.BOX) { + ctx.rect(pos[0] - 6 + 0.5, pos[1] - 5 + 0.5, 14, 10) + ctx.fill() + ctx.beginPath() + ctx.rect( + this.graph_mouse[0] - 6 + 0.5, + this.graph_mouse[1] - 5 + 0.5, + 14, + 10, + ) + } else if (connShape === RenderShape.ARROW) { + ctx.moveTo(pos[0] + 8, pos[1] + 0.5) + ctx.lineTo(pos[0] - 4, pos[1] + 6 + 0.5) + ctx.lineTo(pos[0] - 4, pos[1] - 6 + 0.5) + ctx.closePath() + } else { + ctx.arc(pos[0], pos[1], 4, 0, Math.PI * 2) + ctx.fill() + ctx.beginPath() + ctx.arc(this.graph_mouse[0], this.graph_mouse[1], 4, 0, Math.PI * 2) + } + ctx.fill() + + // Gradient half-border over target node + this.#renderSnapHighlight(ctx, highlightPos) + } + } + + // the selection rectangle + if (this.dragging_rectangle) { + ctx.strokeStyle = "#FFF" + ctx.strokeRect( + this.dragging_rectangle[0], + this.dragging_rectangle[1], + this.dragging_rectangle[2], + this.dragging_rectangle[3], + ) + } + + // on top of link center + if (this.over_link_center && this.render_link_tooltip) + this.drawLinkTooltip(ctx, this.over_link_center) + // to remove + else + this.onDrawLinkTooltip?.(ctx, null) + + // custom info + this.onDrawForeground?.(ctx, this.visible_area) + + ctx.restore() + } + + this.onDrawOverlay?.(ctx) + + if (area) ctx.restore() + + // FIXME: Remove this hook + // this is a function I use in webgl renderer + // @ts-expect-error + if (ctx.finish2D) ctx.finish2D() + } + + /** @returns If the pointer is over a link centre marker, the link segment it belongs to. Otherwise, `undefined`. */ + #getLinkCentreOnPos(e: CanvasMouseEvent): LinkSegment | undefined { + for (const linkSegment of this.renderedPaths) { + const centre = linkSegment._pos + if (!centre) continue + + if (isInRectangle(e.canvasX, e.canvasY, centre[0] - 4, centre[1] - 4, 8, 8)) { + return linkSegment + } + } + } + + /** Get the target snap / highlight point in graph space */ + #getHighlightPosition(): ReadOnlyPoint { + return LiteGraph.snaps_for_comfy + ? this._highlight_pos ?? this.graph_mouse + : this.graph_mouse + } + + /** + * Renders indicators showing where a link will connect if released. + * Partial border over target node and a highlight over the slot itself. + * @param ctx Canvas 2D context + */ + #renderSnapHighlight( + ctx: CanvasRenderingContext2D, + highlightPos: ReadOnlyPoint, + ): void { + if (!this._highlight_pos) return + + ctx.fillStyle = "#ffcc00" + ctx.beginPath() + const shape = this._highlight_input?.shape + + if (shape === RenderShape.ARROW) { + ctx.moveTo(highlightPos[0] + 8, highlightPos[1] + 0.5) + ctx.lineTo(highlightPos[0] - 4, highlightPos[1] + 6 + 0.5) + ctx.lineTo(highlightPos[0] - 4, highlightPos[1] - 6 + 0.5) + ctx.closePath() + } else { + ctx.arc(highlightPos[0], highlightPos[1], 6, 0, Math.PI * 2) + } + ctx.fill() + + if (!LiteGraph.snap_highlights_node) return + + // Ensure we're mousing over a node and connecting a link + const node = this.node_over + if (!(node && this.connecting_links?.[0])) return + + const { strokeStyle, lineWidth } = ctx + + const area = node.boundingRect + const gap = 3 + const radius = this.round_radius + gap + + const x = area[0] - gap + const y = area[1] - gap + const width = area[2] + gap * 2 + const height = area[3] + gap * 2 + + ctx.beginPath() + ctx.roundRect(x, y, width, height, radius) + + // TODO: Currently works on LTR slots only. Add support for other directions. + const start = this.connecting_links[0].output === null ? 0 : 1 + const inverter = start ? -1 : 1 + + // Radial highlight centred on highlight pos + const hx = highlightPos[0] + const hy = highlightPos[1] + const gRadius = width < height + ? width + : width * Math.max(height / width, 0.5) + + const gradient = ctx.createRadialGradient(hx, hy, 0, hx, hy, gRadius) + gradient.addColorStop(1, "#00000000") + gradient.addColorStop(0, "#ffcc00aa") + + // Linear gradient over half the node. + const linearGradient = ctx.createLinearGradient(x, y, x + width, y) + linearGradient.addColorStop(0.5, "#00000000") + linearGradient.addColorStop(start + 0.67 * inverter, "#ddeeff33") + linearGradient.addColorStop(start + inverter, "#ffcc0055") - this.setDirty(true, true) - } - /** - * returns the top level graph (in case there are subgraphs open on the canvas) - * - * @return {LGraph} graph - */ - getTopGraph(): LGraph { - return this._graph_stack.length - ? this._graph_stack[0] - : this.graph - } - /** - * returns the visually active graph (in case there are more in the stack) - * @return {LGraph} the active graph - */ - getCurrentGraph(): LGraph { - return this.graph - } - /** - * Finds the canvas if required, throwing on failure. - * @param canvas Canvas element, or its element ID - * @returns The canvas element - * @throws If {@link canvas} is an element ID that does not belong to a valid HTML canvas element - */ - #validateCanvas(canvas: string | HTMLCanvasElement): HTMLCanvasElement & { data?: LGraphCanvas } { - if (typeof canvas === "string") { - const el = document.getElementById(canvas) - if (!(el instanceof HTMLCanvasElement)) throw "Error validating LiteGraph canvas: Canvas element not found" - return el - } - return canvas - } /** - * Sets the current HTML canvas element. - * Calls bindEvents to add input event listeners, and (re)creates the background canvas. - * - * @param canvas The canvas element to assign, or its HTML element ID. If null or undefined, the current reference is cleared. - * @param skip_events If true, events on the previous canvas will not be removed. Has no effect on the first invocation. + * Workaround for a canvas render issue. + * In Chromium 129 (2024-10-15), rounded corners can be rendered with the wrong part of a gradient colour. + * Occurs only at certain thicknesses / arc sizes. */ - setCanvas(canvas: string | HTMLCanvasElement, skip_events?: boolean) { - const element = this.#validateCanvas(canvas) - if (element === this.canvas) return - //maybe detach events from old_canvas - if (!element && this.canvas && !skip_events) this.unbindEvents() - - this.canvas = element - this.ds.element = element - this.pointer.element = element - - if (!element) return - - // TODO: classList.add - element.className += " lgraphcanvas" - element.data = this - // @ts-expect-error Likely safe to remove. A decent default, but expectation is to be configured by calling app. - element.tabindex = "1" //to allow key events - - // Background canvas: To render objects behind nodes (background, links, groups) - this.bgcanvas = null - if (!this.bgcanvas) { - this.bgcanvas = document.createElement("canvas") - this.bgcanvas.width = this.canvas.width - this.bgcanvas.height = this.canvas.height - } - if (element.getContext == null) { - if (element.localName != "canvas") { - throw "Element supplied for LGraphCanvas must be a element, you passed a " + - element.localName - } - throw "This browser doesn't support Canvas" - } - - const ctx = (this.ctx = element.getContext("2d")) - if (ctx == null) { - // @ts-expect-error WebGL - if (!element.webgl_enabled) { - console.warn( - "This canvas seems to be WebGL, enabling WebGL renderer" - ) - } - this.enableWebGL() - } - - if (!skip_events) this.bindEvents() - } - /** Captures an event and prevents default - returns false. */ - _doNothing(e: Event): boolean { - //console.log("pointerevents: _doNothing "+e.type); - e.preventDefault() - return false - } - /** Captures an event and prevents default - returns true. */ - _doReturnTrue(e: Event): boolean { - e.preventDefault() - return true - } - /** - * binds mouse, keyboard, touch and drag events to the canvas - **/ - bindEvents(): void { - if (this._events_binded) { - console.warn("LGraphCanvas: events already binded") - return - } - - //console.log("pointerevents: bindEvents"); - const canvas = this.canvas - - const ref_window = this.getCanvasWindow() - const document = ref_window.document //hack used when moving canvas between windows - - this._mousedown_callback = this.processMouseDown.bind(this) - this._mousewheel_callback = this.processMouseWheel.bind(this) - // why mousemove and mouseup were not binded here? - this._mousemove_callback = this.processMouseMove.bind(this) - this._mouseup_callback = this.processMouseUp.bind(this) - this._mouseout_callback = this.processMouseOut.bind(this) - this._mousecancel_callback = this.processMouseCancel.bind(this) - - LiteGraph.pointerListenerAdd(canvas, "down", this._mousedown_callback, true) //down do not need to store the binded - canvas.addEventListener("mousewheel", this._mousewheel_callback, false) - - LiteGraph.pointerListenerAdd(canvas, "up", this._mouseup_callback, true) // CHECK: ??? binded or not - LiteGraph.pointerListenerAdd(canvas, "move", this._mousemove_callback) - canvas.addEventListener("pointerout", this._mouseout_callback) - canvas.addEventListener("pointercancel", this._mousecancel_callback, true) - - canvas.addEventListener("contextmenu", this._doNothing) - canvas.addEventListener( - "DOMMouseScroll", - this._mousewheel_callback, - false + ctx.setLineDash([radius, radius * 0.001]) + + ctx.lineWidth = 1 + ctx.strokeStyle = linearGradient + ctx.stroke() + + ctx.strokeStyle = gradient + ctx.stroke() + + ctx.setLineDash([]) + ctx.lineWidth = lineWidth + ctx.strokeStyle = strokeStyle + } + + /** + * draws some useful stats in the corner of the canvas + **/ + renderInfo(ctx: CanvasRenderingContext2D, x: number, y: number): void { + x = x || 10 + y = y || this.canvas.offsetHeight - 80 + + ctx.save() + ctx.translate(x, y) + + ctx.font = "10px Arial" + ctx.fillStyle = "#888" + ctx.textAlign = "left" + if (this.graph) { + ctx.fillText("T: " + this.graph.globaltime.toFixed(2) + "s", 5, 13 * 1) + ctx.fillText("I: " + this.graph.iteration, 5, 13 * 2) + ctx.fillText("N: " + this.graph._nodes.length + " [" + this.visible_nodes.length + "]", 5, 13 * 3) + ctx.fillText("V: " + this.graph._version, 5, 13 * 4) + ctx.fillText("FPS:" + this.fps.toFixed(2), 5, 13 * 5) + } else { + ctx.fillText("No graph selected", 5, 13 * 1) + } + ctx.restore() + } + + /** + * draws the back canvas (the one containing the background and the connections) + **/ + drawBackCanvas(): void { + const canvas = this.bgcanvas + if ( + canvas.width != this.canvas.width || + canvas.height != this.canvas.height + ) { + canvas.width = this.canvas.width + canvas.height = this.canvas.height + } + + if (!this.bgctx) { + this.bgctx = this.bgcanvas.getContext("2d") + } + const ctx = this.bgctx + // TODO: Remove this + // @ts-expect-error + if (ctx.start) ctx.start() + + const viewport = this.viewport || [0, 0, ctx.canvas.width, ctx.canvas.height] + + // clear + if (this.clear_background) { + ctx.clearRect(viewport[0], viewport[1], viewport[2], viewport[3]) + } + + // show subgraph stack header + if (this._graph_stack?.length) { + ctx.save() + const subgraph_node = this.graph._subgraph_node + ctx.strokeStyle = subgraph_node.bgcolor + ctx.lineWidth = 10 + ctx.strokeRect(1, 1, canvas.width - 2, canvas.height - 2) + ctx.lineWidth = 1 + ctx.font = "40px Arial" + ctx.textAlign = "center" + ctx.fillStyle = subgraph_node.bgcolor || "#AAA" + let title = "" + for (let i = 1; i < this._graph_stack.length; ++i) { + title += this._graph_stack[i]._subgraph_node.getTitle() + " >> " + } + ctx.fillText(title + subgraph_node.getTitle(), canvas.width * 0.5, 40) + ctx.restore() + } + + const bg_already_painted = this.onRenderBackground + ? this.onRenderBackground(canvas, ctx) + : false + + // reset in case of error + if (!this.viewport) { + const scale = window.devicePixelRatio + ctx.restore() + ctx.setTransform(scale, 0, 0, scale, 0, 0) + } + this.visible_links.length = 0 + + if (this.graph) { + // apply transformations + ctx.save() + this.ds.toCanvasContext(ctx) + + // render BG + if ( + this.ds.scale < 1.5 && + !bg_already_painted && + this.clear_background_color + ) { + ctx.fillStyle = this.clear_background_color + ctx.fillRect( + this.visible_area[0], + this.visible_area[1], + this.visible_area[2], + this.visible_area[3], ) + } - //Keyboard ****************** - this._key_callback = this.processKey.bind(this) - - canvas.addEventListener("keydown", this._key_callback, true) - document.addEventListener("keyup", this._key_callback, true) //in document, otherwise it doesn't fire keyup - - //Dropping Stuff over nodes ************************************ - this._ondrop_callback = this.processDrop.bind(this) - - canvas.addEventListener("dragover", this._doNothing, false) - canvas.addEventListener("dragend", this._doNothing, false) - canvas.addEventListener("drop", this._ondrop_callback, false) - canvas.addEventListener("dragenter", this._doReturnTrue, false) - - this._events_binded = true - } - /** - * unbinds mouse events from the canvas - **/ - unbindEvents(): void { - if (!this._events_binded) { - console.warn("LGraphCanvas: no events binded") - return + if (this.background_image && this.ds.scale > 0.5 && !bg_already_painted) { + if (this.zoom_modify_alpha) { + ctx.globalAlpha = (1.0 - 0.5 / this.ds.scale) * this.editor_alpha + } else { + ctx.globalAlpha = this.editor_alpha + } + ctx.imageSmoothingEnabled = false + if (!this._bg_img || this._bg_img.name != this.background_image) { + this._bg_img = new Image() + this._bg_img.name = this.background_image + this._bg_img.src = this.background_image + const that = this + this._bg_img.onload = function () { + that.draw(true, true) + } + } + + let pattern = this._pattern + if (pattern == null && this._bg_img.width > 0) { + pattern = ctx.createPattern(this._bg_img, "repeat") + this._pattern_img = this._bg_img + this._pattern = pattern + } + + // NOTE: This ridiculous kludge provides a significant performance increase when rendering many large (> canvas width) paths in HTML canvas. + // I could find no documentation or explanation. Requires that the BG image is set. + if (pattern) { + ctx.fillStyle = pattern + ctx.fillRect( + this.visible_area[0], + this.visible_area[1], + this.visible_area[2], + this.visible_area[3], + ) + ctx.fillStyle = "transparent" } - //console.log("pointerevents: unbindEvents"); - const ref_window = this.getCanvasWindow() - const document = ref_window.document - - this.canvas.removeEventListener("pointercancel", this._mousecancel_callback) - this.canvas.removeEventListener("pointerout", this._mouseout_callback) - LiteGraph.pointerListenerRemove(this.canvas, "move", this._mousemove_callback) - LiteGraph.pointerListenerRemove(this.canvas, "up", this._mouseup_callback) - LiteGraph.pointerListenerRemove(this.canvas, "down", this._mousedown_callback) - this.canvas.removeEventListener( - "mousewheel", - this._mousewheel_callback - ) - this.canvas.removeEventListener( - "DOMMouseScroll", - this._mousewheel_callback + ctx.globalAlpha = 1.0 + ctx.imageSmoothingEnabled = true + } + + // groups + if (this.graph._groups.length) { + this.drawGroups(canvas, ctx) + } + + this.onDrawBackground?.(ctx, this.visible_area) + + // DEBUG: show clipping area + // ctx.fillStyle = "red"; + // ctx.fillRect( this.visible_area[0] + 10, this.visible_area[1] + 10, this.visible_area[2] - 20, this.visible_area[3] - 20); + // bg + if (this.render_canvas_border) { + ctx.strokeStyle = "#235" + ctx.strokeRect(0, 0, canvas.width, canvas.height) + } + + if (this.render_connections_shadows) { + ctx.shadowColor = "#000" + ctx.shadowOffsetX = 0 + ctx.shadowOffsetY = 0 + ctx.shadowBlur = 6 + } else { + ctx.shadowColor = "rgba(0,0,0,0)" + } + + // draw connections + this.drawConnections(ctx) + + ctx.shadowColor = "rgba(0,0,0,0)" + + // restore state + ctx.restore() + } + + // TODO: Remove this + // @ts-expect-error + ctx.finish?.() + + this.dirty_bgcanvas = false + // Forces repaint of the front canvas. + this.dirty_canvas = true + } + + /** + * draws the given node inside the canvas + **/ + drawNode(node: LGraphNode, ctx: CanvasRenderingContext2D): void { + this.current_node = node + + const color = node.color || node.constructor.color || LiteGraph.NODE_DEFAULT_COLOR + let bgcolor = node.bgcolor || node.constructor.bgcolor || LiteGraph.NODE_DEFAULT_BGCOLOR + + const low_quality = this.ds.scale < 0.6 // zoomed out + const editor_alpha = this.editor_alpha + ctx.globalAlpha = editor_alpha + + if (this.render_shadows && !low_quality) { + ctx.shadowColor = LiteGraph.DEFAULT_SHADOW_COLOR + ctx.shadowOffsetX = 2 * this.ds.scale + ctx.shadowOffsetY = 2 * this.ds.scale + ctx.shadowBlur = 3 * this.ds.scale + } else { + ctx.shadowColor = "transparent" + } + + // custom draw collapsed method (draw after shadows because they are affected) + if (node.flags.collapsed && node.onDrawCollapsed?.(ctx, this) == true) + return + + // clip if required (mask) + const shape = node._shape || RenderShape.BOX + const size = LGraphCanvas.#temp_vec2 + LGraphCanvas.#temp_vec2.set(node.size) + const horizontal = node.horizontal // || node.flags.horizontal; + + if (node.flags.collapsed) { + ctx.font = this.inner_text_font + const title = node.getTitle ? node.getTitle() : node.title + if (title != null) { + node._collapsed_width = Math.min( + node.size[0], + ctx.measureText(title).width + LiteGraph.NODE_TITLE_HEIGHT * 2, + ) // LiteGraph.NODE_COLLAPSED_WIDTH; + size[0] = node._collapsed_width + size[1] = 0 + } + } + + if (node.clip_area) { + // Start clipping + ctx.save() + ctx.beginPath() + if (shape == RenderShape.BOX) { + ctx.rect(0, 0, size[0], size[1]) + } else if (shape == RenderShape.ROUND) { + ctx.roundRect(0, 0, size[0], size[1], [10]) + } else if (shape == RenderShape.CIRCLE) { + ctx.arc(size[0] * 0.5, size[1] * 0.5, size[0] * 0.5, 0, Math.PI * 2) + } + ctx.clip() + } + + // draw shape + if (node.has_errors) { + bgcolor = "red" + } + this.drawNodeShape( + node, + ctx, + size, + color, + bgcolor, + node.selected, + ) + + if (!low_quality) { + node.drawBadges(ctx) + } + + ctx.shadowColor = "transparent" + + // draw foreground + node.onDrawForeground?.(ctx, this, this.canvas) + + // connection slots + ctx.textAlign = horizontal ? "center" : "left" + ctx.font = this.inner_text_font + + const render_text = !low_quality + const highlightColour = + LiteGraph.NODE_TEXT_HIGHLIGHT_COLOR ?? + LiteGraph.NODE_SELECTED_TITLE_COLOR ?? + LiteGraph.NODE_TEXT_COLOR + + const out_slot = this.connecting_links?.[0]?.output + const in_slot = this.connecting_links?.[0]?.input + ctx.lineWidth = 1 + + let max_y = 0 + const slot_pos = new Float32Array(2) // to reuse + + // render inputs and outputs + if (!node.flags.collapsed) { + // input connection slots + if (node.inputs) { + for (let i = 0; i < node.inputs.length; i++) { + const slot = node.inputs[i] + + const slot_type = slot.type + + // change opacity of incompatible slots when dragging a connection + const isValid = + !this.connecting_links || + (out_slot && LiteGraph.isValidConnection(slot.type, out_slot.type)) + const highlight = isValid && node.mouseOver?.inputId === i + const label_color = highlight + ? highlightColour + : LiteGraph.NODE_TEXT_COLOR + ctx.globalAlpha = isValid ? editor_alpha : 0.4 * editor_alpha + + ctx.fillStyle = + slot.link != null + ? slot.color_on || + this.default_connection_color_byType[slot_type] || + this.default_connection_color.input_on + : slot.color_off || + this.default_connection_color_byTypeOff[slot_type] || + this.default_connection_color_byType[slot_type] || + this.default_connection_color.input_off + + const pos = node.getConnectionPos(true, i, slot_pos) + pos[0] -= node.pos[0] + pos[1] -= node.pos[1] + if (max_y < pos[1] + LiteGraph.NODE_SLOT_HEIGHT * 0.5) { + max_y = pos[1] + LiteGraph.NODE_SLOT_HEIGHT * 0.5 + } + + drawSlot(ctx, slot, pos, { + horizontal, + low_quality, + render_text, + label_color, + label_position: LabelPosition.Right, + // Input slot is not stroked. + do_stroke: false, + highlight, + }) + } + } + + // output connection slots + ctx.textAlign = horizontal ? "center" : "right" + ctx.strokeStyle = "black" + if (node.outputs) { + for (let i = 0; i < node.outputs.length; i++) { + const slot = node.outputs[i] + + const slot_type = slot.type + + // change opacity of incompatible slots when dragging a connection + const isValid = + !this.connecting_links || + (in_slot && LiteGraph.isValidConnection(slot_type, in_slot.type)) + const highlight = isValid && node.mouseOver?.outputId === i + const label_color = highlight + ? highlightColour + : LiteGraph.NODE_TEXT_COLOR + ctx.globalAlpha = isValid ? editor_alpha : 0.4 * editor_alpha + + const pos = node.getConnectionPos(false, i, slot_pos) + pos[0] -= node.pos[0] + pos[1] -= node.pos[1] + if (max_y < pos[1] + LiteGraph.NODE_SLOT_HEIGHT * 0.5) { + max_y = pos[1] + LiteGraph.NODE_SLOT_HEIGHT * 0.5 + } + + ctx.fillStyle = + slot.links && slot.links.length + ? slot.color_on || + this.default_connection_color_byType[slot_type] || + this.default_connection_color.output_on + : slot.color_off || + this.default_connection_color_byTypeOff[slot_type] || + this.default_connection_color_byType[slot_type] || + this.default_connection_color.output_off + + drawSlot(ctx, slot, pos, { + horizontal, + low_quality, + render_text, + label_color, + label_position: LabelPosition.Left, + do_stroke: true, + highlight, + }) + } + } + + ctx.textAlign = "left" + ctx.globalAlpha = 1 + + if (node.widgets) { + let widgets_y = max_y + if (horizontal || node.widgets_up) { + widgets_y = 2 + } + if (node.widgets_start_y != null) widgets_y = node.widgets_start_y + this.drawNodeWidgets( + node, + widgets_y, + ctx, + this.node_widget && this.node_widget[0] == node + ? this.node_widget[1] + : null, ) - this.canvas.removeEventListener("keydown", this._key_callback) - document.removeEventListener("keyup", this._key_callback) - this.canvas.removeEventListener("contextmenu", this._doNothing) - this.canvas.removeEventListener("drop", this._ondrop_callback) - this.canvas.removeEventListener("dragenter", this._doReturnTrue) - - this._mousedown_callback = null - this._mousewheel_callback = null - this._key_callback = null - this._ondrop_callback = null - - this._events_binded = false - } - /** - * this function allows to render the canvas using WebGL instead of Canvas2D - * this is useful if you plant to render 3D objects inside your nodes, it uses litegl.js for webgl and canvas2DtoWebGL to emulate the Canvas2D calls in webGL - **/ - enableWebGL(): void { - // TODO: Delete or move all webgl to a module and never load it. - // @ts-expect-error - if (typeof GL === "undefined") { - throw "litegl.js must be included to use a WebGL canvas" - } - // @ts-expect-error - if (typeof enableWebGLCanvas === "undefined") { - throw "webglCanvas.js must be included to use this feature" - } - - // @ts-expect-error - this.gl = this.ctx = enableWebGLCanvas(this.canvas) - // @ts-expect-error - this.ctx.webgl = true - this.bgcanvas = this.canvas - this.bgctx = this.gl - // @ts-expect-error - this.canvas.webgl_enabled = true - - /* - GL.create({ canvas: this.bgcanvas }); - this.bgctx = enableWebGLCanvas( this.bgcanvas ); - window.gl = this.gl; - */ - } - /** - * Ensures the canvas will be redrawn on the next frame by setting the dirty flag(s). - * Without parameters, this function does nothing. - * @todo Impl. `setDirty()` or similar as shorthand to redraw everything. - * - * @param fgcanvas If true, marks the foreground canvas as dirty (nodes and anything drawn on top of them). Default: false - * @param bgcanvas If true, mark the background canvas as dirty (background, groups, links). Default: false - */ - setDirty(fgcanvas: boolean, bgcanvas?: boolean): void { - if (fgcanvas) this.dirty_canvas = true - if (bgcanvas) this.dirty_bgcanvas = true - } - - /** Marks the entire canvas as dirty. */ - #dirty(): void { - this.dirty_canvas = true - this.dirty_bgcanvas = true - } - - /** - * Used to attach the canvas in a popup - * - * @return {window} returns the window where the canvas is attached (the DOM root node) - */ - getCanvasWindow(): Window { - if (!this.canvas) return window - - const doc = this.canvas.ownerDocument - // @ts-expect-error Check if required - return doc.defaultView || doc.parentWindow - } - /** - * starts rendering the content of the canvas when needed - * - */ - startRendering(): void { - //already rendering - if (this.is_rendering) return - - this.is_rendering = true - renderFrame.call(this) - - function renderFrame(this: LGraphCanvas) { - if (!this.pause_rendering) { - this.draw() - } - - const window = this.getCanvasWindow() - if (this.is_rendering) { - window.requestAnimationFrame(renderFrame.bind(this)) - } - } - } - /** - * stops rendering the content of the canvas (to save resources) - * - */ - stopRendering(): void { - this.is_rendering = false - /* - if(this.rendering_timer_id) - { - clearInterval(this.rendering_timer_id); - this.rendering_timer_id = null; - } - */ - } - /* LiteGraphCanvas input */ - //used to block future mouse events (because of im gui) - blockClick(): void { - this.block_click = true - this.last_mouseclick = 0 - } - - /** - * Gets the widget at the current cursor position - * @param node Optional node to check for widgets under cursor - * @returns The widget located at the current cursor position or null - */ - getWidgetAtCursor(node?: LGraphNode): IWidget | null { - node ??= this.node_over - - if (!node.widgets) return null - - const graphPos = this.graph_mouse - const x = graphPos[0] - node.pos[0] - const y = graphPos[1] - node.pos[1] - - for (const widget of node.widgets) { - if (widget.hidden || (widget.advanced && !node.showAdvanced)) continue - - let widgetWidth, widgetHeight - if (widget.computeSize) { - ([widgetWidth, widgetHeight] = widget.computeSize(node.size[0])) - } else { - widgetWidth = (widget).width || node.size[0] - widgetHeight = LiteGraph.NODE_WIDGET_HEIGHT - } - - if ( - widget.last_y !== undefined && - x >= 6 && - x <= widgetWidth - 12 && - y >= widget.last_y && - y <= widget.last_y + widgetHeight - ) { - return widget - } - } - - return null - } - - /** - * Clears highlight and mouse-over information from nodes that should not have it. - * - * Intended to be called when the pointer moves away from a node. - * @param {LGraphNode} node The node that the mouse is now over - * @param {MouseEvent} e MouseEvent that is triggering this - */ - updateMouseOverNodes(node: LGraphNode, e: CanvasMouseEvent): void { - const nodes = this.graph._nodes - const l = nodes.length - for (let i = 0; i < l; ++i) { - if (nodes[i].mouseOver && node != nodes[i]) { - //mouse leave - nodes[i].mouseOver = null - this._highlight_input = null - this._highlight_pos = null - this.link_over_widget = null - - // Hover transitions - // TODO: Implement single lerp ease factor for current progress on hover in/out. In drawNode, multiply by ease factor and differential value (e.g. bg alpha +0.5). - nodes[i].lostFocusAt = LiteGraph.getTime() - - this.node_over?.onMouseLeave?.(e) - this.node_over = null - this.dirty_canvas = true - } - } - } - - processMouseDown(e: PointerEvent): void { - const { graph, pointer } = this - this.adjustMouseEvent(e) - if (e.isPrimary) pointer.down(e) - - if (this.set_canvas_dirty_on_mouse_event) - this.dirty_canvas = true - - if (!graph) return - - const ref_window = this.getCanvasWindow() - LGraphCanvas.active_canvas = this - - const x = e.clientX - const y = e.clientY - this.ds.viewport = this.viewport - const is_inside = !this.viewport || isInRect(x, y, this.viewport) - - if (!is_inside) return - - const node = graph.getNodeOnPos(e.canvasX, e.canvasY, this.visible_nodes) - - this.mouse[0] = x - this.mouse[1] = y - this.graph_mouse[0] = e.canvasX - this.graph_mouse[1] = e.canvasY - this.last_click_position = [this.mouse[0], this.mouse[1]] - - pointer.isDouble = pointer.isDown && e.isPrimary - pointer.isDown = true - - this.canvas.focus() - - LiteGraph.closeAllContextMenus(ref_window) - - if (this.onMouse?.(e) == true) return - - //left button mouse / single finger - if (e.button === 0 && !pointer.isDouble) { - this.#processPrimaryButton(e, node) - } else if (e.button === 1) { - this.#processMiddleButton(e, node) - } else if ((e.button === 2 || pointer.isDouble) && this.allow_interaction && !this.read_only) { - // Right / aux button - - // Sticky select - won't remove single nodes - if (node) this.processSelect(node, e, true) - - // Show context menu for the node or group under the pointer - this.processContextMenu(node, e) - } - - this.last_mouse = [x, y] - this.last_mouseclick = LiteGraph.getTime() - this.last_mouse_dragging = true - - graph.change() - - //this is to ensure to defocus(blur) if a text input element is on focus - if (!ref_window.document.activeElement || - (ref_window.document.activeElement.nodeName.toLowerCase() != "input" && - ref_window.document.activeElement.nodeName.toLowerCase() != "textarea")) { - e.preventDefault() - } - e.stopPropagation() - - this.onMouseDown?.(e) - } - - #processPrimaryButton(e: CanvasPointerEvent, node: LGraphNode) { - const { pointer, graph } = this - const x = e.canvasX - const y = e.canvasY - - // Modifiers - const ctrlOrMeta = e.ctrlKey || e.metaKey - - // Multi-select drag rectangle - if (ctrlOrMeta && !e.altKey) { - const dragRect = new Float32Array(4) - dragRect[0] = x - dragRect[1] = y - dragRect[2] = 1 - dragRect[3] = 1 - - pointer.onClick = eUp => { - // Click, not drag - const clickedItem = node - ?? (this.reroutesEnabled ? graph.getRerouteOnPos(eUp.canvasX, eUp.canvasY) : null) - ?? graph.getGroupTitlebarOnPos(eUp.canvasX, eUp.canvasY) - this.processSelect(clickedItem, eUp) - } - pointer.onDragStart = () => this.dragging_rectangle = dragRect - pointer.onDragEnd = upEvent => this.#handleMultiSelect(upEvent, dragRect) - pointer.finally = () => this.dragging_rectangle = null - return - } - - if (this.read_only) { - pointer.finally = () => this.dragging_canvas = false - this.dragging_canvas = true - return + } + } else if (this.render_collapsed_slots) { + // if collapsed + let input_slot = null + let output_slot = null + let slot + + // get first connected slot to render + if (node.inputs) { + for (let i = 0; i < node.inputs.length; i++) { + slot = node.inputs[i] + if (slot.link == null) { + continue + } + input_slot = slot + break + } + } + if (node.outputs) { + for (let i = 0; i < node.outputs.length; i++) { + slot = node.outputs[i] + if (!slot.links || !slot.links.length) { + continue + } + output_slot = slot + } + } + + if (input_slot) { + let x = 0 + let y = LiteGraph.NODE_TITLE_HEIGHT * -0.5 // center + if (horizontal) { + x = node._collapsed_width * 0.5 + y = -LiteGraph.NODE_TITLE_HEIGHT + } + ctx.fillStyle = "#686" + ctx.beginPath() + if (slot.type === LiteGraph.EVENT || slot.shape === RenderShape.BOX) { + ctx.rect(x - 7 + 0.5, y - 4, 14, 8) + } else if (slot.shape === RenderShape.ARROW) { + ctx.moveTo(x + 8, y) + ctx.lineTo(x + -4, y - 4) + ctx.lineTo(x + -4, y + 4) + ctx.closePath() + } else { + ctx.arc(x, y, 4, 0, Math.PI * 2) } + ctx.fill() + } - // clone node ALT dragging - if (LiteGraph.alt_drag_do_clone_nodes && e.altKey && !e.ctrlKey && node && this.allow_interaction) { - const node_data = node.clone()?.serialize() - const cloned = LiteGraph.createNode(node_data.type) - if (cloned) { - cloned.configure(node_data) - cloned.pos[0] += 5 - cloned.pos[1] += 5 - - if (this.allow_dragnodes) { - pointer.onDragStart = (pointer) => { - graph.add(cloned, false) - this.#startDraggingItems(cloned, pointer) - } - pointer.onDragEnd = (e) => this.#processDraggedItems(e) - } else { - // TODO: Check if before/after change are necessary here. - graph.beforeChange() - graph.add(cloned, false) - graph.afterChange() - } - - return - } + if (output_slot) { + let x = node._collapsed_width + let y = LiteGraph.NODE_TITLE_HEIGHT * -0.5 // center + if (horizontal) { + x = node._collapsed_width * 0.5 + y = 0 } - - // Node clicked - if (node && (this.allow_interaction || node.flags.allow_interaction)) { - this.#processNodeClick(e, ctrlOrMeta, node) + ctx.fillStyle = "#686" + ctx.strokeStyle = "black" + ctx.beginPath() + if (slot.type === LiteGraph.EVENT || slot.shape === RenderShape.BOX) { + ctx.rect(x - 7 + 0.5, y - 4, 14, 8) + } else if (slot.shape === RenderShape.ARROW) { + ctx.moveTo(x + 6, y) + ctx.lineTo(x - 6, y - 4) + ctx.lineTo(x - 6, y + 4) + ctx.closePath() } else { - // Reroutes - if (this.reroutesEnabled) { - const reroute = graph.getRerouteOnPos(x, y) - if (reroute) { - if (e.shiftKey) { - // Connect new link from reroute - const link = graph._links.get(reroute.linkIds.values().next().value) - - const outputNode = graph.getNodeById(link.origin_id) - const slot = link.origin_slot - const connecting: ConnectingLink = { - node: outputNode, - slot, - input: null, - pos: outputNode.getConnectionPos(false, slot), - afterRerouteId: reroute.id, - } - this.connecting_links = [connecting] - pointer.onDragStart = () => connecting.output = outputNode.outputs[slot] - // pointer.finally = () => this.connecting_links = null - - this.dirty_bgcanvas = true - } - - pointer.onClick = () => this.processSelect(reroute, e) - if (!pointer.onDragStart) { - pointer.onDragStart = (pointer) => this.#startDraggingItems(reroute, pointer, true) - pointer.onDragEnd = (e) => this.#processDraggedItems(e) - } - return - } - } - - // Links - paths of links & reroutes - // Set the width of the line for isPointInStroke checks - const { lineWidth } = this.ctx - this.ctx.lineWidth = this.connections_width + 7 - - for (const linkSegment of this.renderedPaths) { - const centre = linkSegment._pos - if (!centre) continue - - // If we shift click on a link then start a link from that input - if ((e.shiftKey || e.altKey) && linkSegment.path && this.ctx.isPointInStroke(linkSegment.path, x, y)) { - if (e.shiftKey && !e.altKey) { - const slot = linkSegment.origin_slot - const originNode = graph._nodes_by_id[linkSegment.origin_id] - - const connecting: ConnectingLink = { - node: originNode, - slot, - pos: originNode.getConnectionPos(false, slot), - } - this.connecting_links = [connecting] - if (linkSegment.parentId) connecting.afterRerouteId = linkSegment.parentId - - pointer.onDragStart = () => connecting.output = originNode.outputs[slot] - // pointer.finally = () => this.connecting_links = null - - return - } else if (this.reroutesEnabled && e.altKey && !e.shiftKey) { - const newReroute = graph.createReroute([x, y], linkSegment) - pointer.onDragStart = (pointer) => this.#startDraggingItems(newReroute, pointer) - pointer.onDragEnd = (e) => this.#processDraggedItems(e) - return - } - } else if (isInRectangle(x, y, centre[0] - 4, centre[1] - 4, 8, 8)) { - pointer.onClick = () => this.showLinkMenu(linkSegment, e) - pointer.onDragStart = () => this.dragging_canvas = true - pointer.finally = () => this.dragging_canvas = false - - //clear tooltip - this.over_link_center = null - return - } - } - - // Restore line width - this.ctx.lineWidth = lineWidth - - // Groups - const group = graph.getGroupOnPos(x, y) - this.selected_group = group - if (group) { - if (group.isInResize(x, y)) { - pointer.onDragStart = () => this.resizingGroup = group - pointer.finally = () => this.resizingGroup = null - } else { - const f = group.font_size || LiteGraph.DEFAULT_GROUP_FONT_SIZE - const headerHeight = f * 1.4 - if (isInRectangle(x, y, group.pos[0], group.pos[1], group.size[0], headerHeight)) { - // In title bar - pointer.onDragStart = (pointer) => { - group.recomputeInsideNodes() - this.#startDraggingItems(group, pointer, true) - } - pointer.onDragEnd = (e) => this.#processDraggedItems(e) - } - } - - pointer.onDoubleClick = () => { - this.emitEvent({ - subType: "group-double-click", - originalEvent: e, - group, - }) - } - } else { - pointer.onDoubleClick = () => { - // Double click within group should not trigger the searchbox. - if (this.allow_searchbox) { - this.showSearchBox(e) - e.preventDefault() - } - this.emitEvent({ - subType: "empty-double-click", - originalEvent: e, - }) - } - } + ctx.arc(x, y, 4, 0, Math.PI * 2) } + ctx.fill() + // ctx.stroke(); + } + } + + if (node.clip_area) { + ctx.restore() + } + + ctx.globalAlpha = 1.0 + } + + /** + * Draws the link mouseover effect and tooltip. + * @param ctx Canvas 2D context to draw on + * @param link The link to render the mouseover effect for + * @remarks + * Called against {@link LGraphCanvas.over_link_center}. + * @todo Split tooltip from hover, so it can be drawn / eased separately + */ + drawLinkTooltip(ctx: CanvasRenderingContext2D, link: LinkSegment): void { + const pos = link._pos + ctx.fillStyle = "black" + ctx.beginPath() + if (this.linkMarkerShape === LinkMarkerShape.Arrow) { + const transform = ctx.getTransform() + ctx.translate(pos[0], pos[1]) + if (Number.isFinite(link._centreAngle)) ctx.rotate(link._centreAngle) + ctx.moveTo(-2, -3) + ctx.lineTo(+4, 0) + ctx.lineTo(-2, +3) + ctx.setTransform(transform) + } else if ( + this.linkMarkerShape == null || + this.linkMarkerShape === LinkMarkerShape.Circle + ) { + ctx.arc(pos[0], pos[1], 3, 0, Math.PI * 2) + } + ctx.fill() + + // @ts-expect-error TODO: Better value typing + const data = link.data + if (data == null) return + + // @ts-expect-error TODO: Better value typing + if (this.onDrawLinkTooltip?.(ctx, link, this) == true) return + + let text: string = null + + if (typeof data === "number") + text = data.toFixed(2) + else if (typeof data === "string") + text = "\"" + data + "\"" + else if (typeof data === "boolean") + text = String(data) + else if (data.toToolTip) + text = data.toToolTip() + else + text = "[" + data.constructor.name + "]" + + if (text == null) return + + // Hard-coded tooltip limit + text = text.substring(0, 30) + + ctx.font = "14px Courier New" + const info = ctx.measureText(text) + const w = info.width + 20 + const h = 24 + ctx.shadowColor = "black" + ctx.shadowOffsetX = 2 + ctx.shadowOffsetY = 2 + ctx.shadowBlur = 3 + ctx.fillStyle = "#454" + ctx.beginPath() + ctx.roundRect(pos[0] - w * 0.5, pos[1] - 15 - h, w, h, [3]) + ctx.moveTo(pos[0] - 10, pos[1] - 15) + ctx.lineTo(pos[0] + 10, pos[1] - 15) + ctx.lineTo(pos[0], pos[1] - 5) + ctx.fill() + ctx.shadowColor = "transparent" + ctx.textAlign = "center" + ctx.fillStyle = "#CEC" + ctx.fillText(text, pos[0], pos[1] - 15 - h * 0.3) + } + + /** + * Draws the shape of the given node on the canvas + * @param node The node to draw + * @param ctx 2D canvas rendering context used to draw + * @param size Size of the background to draw, in graph units. Differs from node size if collapsed, etc. + * @param fgcolor Foreground colour - used for text + * @param bgcolor Background colour of the node + * @param selected Whether to render the node as selected. Likely to be removed in future, as current usage is simply the selected property of the node. + * @param mouse_over Deprecated + */ + drawNodeShape( + node: LGraphNode, + ctx: CanvasRenderingContext2D, + size: Size, + fgcolor: CanvasColour, + bgcolor: CanvasColour, + selected: boolean, + ): void { + // Rendering options + ctx.strokeStyle = fgcolor + ctx.fillStyle = bgcolor + + const title_height = LiteGraph.NODE_TITLE_HEIGHT + const low_quality = this.ds.scale < 0.5 + + const shape = node._shape || node.constructor.shape || RenderShape.ROUND + const title_mode = node.constructor.title_mode + + const render_title = title_mode == TitleMode.TRANSPARENT_TITLE || title_mode == TitleMode.NO_TITLE + ? false + : true + + // Normalised node dimensions + const area = LGraphCanvas.#tmp_area + node.measure(area) + area[0] -= node.pos[0] + area[1] -= node.pos[1] + + const old_alpha = ctx.globalAlpha + + // Draw node background (shape) + { + ctx.beginPath() + if (shape == RenderShape.BOX || low_quality) { + ctx.fillRect(area[0], area[1], area[2], area[3]) + } else if (shape == RenderShape.ROUND || shape == RenderShape.CARD) { + ctx.roundRect( + area[0], + area[1], + area[2], + area[3], + shape == RenderShape.CARD + ? [this.round_radius, this.round_radius, 0, 0] + : [this.round_radius], + ) + } else if (shape == RenderShape.CIRCLE) { + ctx.arc(size[0] * 0.5, size[1] * 0.5, size[0] * 0.5, 0, Math.PI * 2) + } + ctx.fill() - if (!pointer.onDragStart && !pointer.onClick && !pointer.onDrag && this.allow_dragcanvas) { - pointer.onClick = () => this.processSelect(null, e) - pointer.finally = () => this.dragging_canvas = false - this.dragging_canvas = true - } + // separator + if (!node.flags.collapsed && render_title) { + ctx.shadowColor = "transparent" + ctx.fillStyle = "rgba(0,0,0,0.2)" + ctx.fillRect(0, -1, area[2], 2) + } } + ctx.shadowColor = "transparent" - /** - * Processes a pointerdown event inside the bounds of a node. Part of {@link processMouseDown}. - * @param ctrlOrMeta Ctrl or meta key is pressed - * @param e The pointerdown event - * @param node The node to process a click event for - */ - #processNodeClick(e: CanvasPointerEvent, ctrlOrMeta: boolean, node: LGraphNode): void { - const { pointer, graph } = this - const x = e.canvasX - const y = e.canvasY + node.onDrawBackground?.(ctx, this, this.canvas, this.graph_mouse) - pointer.onClick = () => this.processSelect(node, e) + // title bg (remember, it is rendered ABOVE the node) + if (render_title || title_mode == TitleMode.TRANSPARENT_TITLE) { + // title bar + if (node.onDrawTitleBar) { + node.onDrawTitleBar(ctx, title_height, size, this.ds.scale, fgcolor) + } else if ( + title_mode != TitleMode.TRANSPARENT_TITLE && + (node.constructor.title_color || this.render_title_colored) + ) { + const title_color = node.constructor.title_color || fgcolor - // Immediately bring to front - if (!node.flags.pinned) { - this.bringToFront(node) + if (node.flags.collapsed) { + ctx.shadowColor = LiteGraph.DEFAULT_SHADOW_COLOR + } + + //* gradient test + if (this.use_gradients) { + // TODO: This feature may not have been completed. Could finish or remove. + // Original impl. may cause CanvasColour to be used as index key. Also, colour requires validation before blindly passing on. + // @ts-expect-error Fix or remove gradient feature + let grad = LGraphCanvas.gradients[title_color] + if (!grad) { + // @ts-expect-error Fix or remove gradient feature + grad = LGraphCanvas.gradients[title_color] = + ctx.createLinearGradient(0, 0, 400, 0) + grad.addColorStop(0, title_color) + grad.addColorStop(1, "#000") + } + ctx.fillStyle = grad + } else { + ctx.fillStyle = title_color } - // Collapse toggle - const inCollapse = node.isPointInCollapse(x, y) - if (inCollapse) { - pointer.onClick = () => { - node.collapse() - this.setDirty(true, true) - } - } else if (!node.flags.collapsed) { - // Resize node - if (node.resizable !== false && node.inResizeCorner(x, y)) { - pointer.onDragStart = () => { - graph.beforeChange() - this.resizing_node = node - } - pointer.onDragEnd = upEvent => { - this.#dirty() - graph.afterChange(this.resizing_node) - } - pointer.finally = () => this.resizing_node = null - this.canvas.style.cursor = "se-resize" - return - } - - // Outputs - if (node.outputs) { - for (let i = 0, l = node.outputs.length; i < l; ++i) { - const output = node.outputs[i] - const link_pos = node.getConnectionPos(false, i) - if (isInRectangle( - x, - y, - link_pos[0] - 15, - link_pos[1] - 10, - 30, - 20 - )) { - // Drag multiple output links - if (e.shiftKey && output.links?.length > 0) { - - this.connecting_links = [] - for (const linkId of output.links) { - const link = graph._links.get(linkId) - const slot = link.target_slot - const linked_node = graph._nodes_by_id[link.target_id] - const input = linked_node.inputs[slot] - const pos = linked_node.getConnectionPos(true, slot) - - this.connecting_links.push({ - node: linked_node, - slot: slot, - input: input, - output: null, - pos: pos, - direction: node.horizontal !== true ? LinkDirection.RIGHT : LinkDirection.CENTER, - }) - } - - return - } - - output.slot_index = i - this.connecting_links = [{ - node: node, - slot: i, - input: null, - output: output, - pos: link_pos, - }] - - if (LiteGraph.shift_click_do_break_link_from) { - if (e.shiftKey) { - node.disconnectOutput(i) - } - } else if (LiteGraph.ctrl_alt_click_do_break_link) { - if (ctrlOrMeta && e.altKey && !e.shiftKey) { - node.disconnectOutput(i) - } - } - - // TODO: Move callbacks to the start of this closure (onInputClick is already correct). - pointer.onDoubleClick = () => node.onOutputDblClick?.(i, e) - pointer.onClick = () => node.onOutputClick?.(i, e) - - return - } - } - } - - // Inputs - if (node.inputs) { - for (let i = 0, l = node.inputs.length; i < l; ++i) { - const input = node.inputs[i] - const link_pos = node.getConnectionPos(true, i) - if (isInRectangle( - x, - y, - link_pos[0] - 15, - link_pos[1] - 10, - 30, - 20 - )) { - pointer.onDoubleClick = () => node.onInputDblClick?.(i, e) - pointer.onClick = () => node.onInputClick?.(i, e) - - if (input.link !== null) { - //before disconnecting - const link_info = graph._links.get(input.link) - const slot = link_info.origin_slot - const linked_node = graph._nodes_by_id[link_info.origin_id] - if (LiteGraph.click_do_break_link_to || (LiteGraph.ctrl_alt_click_do_break_link && ctrlOrMeta && e.altKey && !e.shiftKey)) { - node.disconnectInput(i) - } else if (e.shiftKey || this.allow_reconnect_links) { - const connecting: ConnectingLink = { - node: linked_node, - slot, - output: linked_node.outputs[slot], - pos: linked_node.getConnectionPos(false, slot), - } - this.connecting_links = [connecting] - - pointer.onDragStart = () => { - if (this.allow_reconnect_links && !LiteGraph.click_do_break_link_to) - node.disconnectInput(i) - connecting.output = linked_node.outputs[slot] - } - - this.dirty_bgcanvas = true - } - } - if (!pointer.onDragStart) { - // Connect from input to output - const connecting: ConnectingLink = { - node, - slot: i, - output: null, - pos: link_pos, - } - this.connecting_links = [connecting] - pointer.onDragStart = () => connecting.input = input - - this.dirty_bgcanvas = true - } - - // pointer.finally = () => this.connecting_links = null - return - } - } - } + // ctx.globalAlpha = 0.5 * old_alpha; + ctx.beginPath() + if (shape == RenderShape.BOX || low_quality) { + ctx.rect(0, -title_height, size[0], title_height) + } else if (shape == RenderShape.ROUND || shape == RenderShape.CARD) { + ctx.roundRect( + 0, + -title_height, + size[0], + title_height, + node.flags.collapsed + ? [this.round_radius] + : [this.round_radius, this.round_radius, 0, 0], + ) } - - // Click was inside the node, but not on input/output, or the resize corner - const pos: Point = [x - node.pos[0], y - node.pos[1]] - - // Widget - const widget = node.getWidgetOnPos(x, y) - if (widget) { - this.#processWidgetClick(e, node, widget) - this.node_widget = [node, widget] + ctx.fill() + ctx.shadowColor = "transparent" + } + + let colState: string | boolean = false + if (LiteGraph.node_box_coloured_by_mode) { + if (LiteGraph.NODE_MODES_COLORS[node.mode]) { + colState = LiteGraph.NODE_MODES_COLORS[node.mode] + } + } + if (LiteGraph.node_box_coloured_when_on) { + colState = node.action_triggered + ? "#FFF" + : node.execute_triggered + ? "#AAA" + : colState + } + + // title box + const box_size = 10 + if (node.onDrawTitleBox) { + node.onDrawTitleBox(ctx, title_height, size, this.ds.scale) + } else if ( + shape == RenderShape.ROUND || + shape == RenderShape.CIRCLE || + shape == RenderShape.CARD + ) { + if (low_quality) { + ctx.fillStyle = "black" + ctx.beginPath() + ctx.arc( + title_height * 0.5, + title_height * -0.5, + box_size * 0.5 + 1, + 0, + Math.PI * 2, + ) + ctx.fill() + } + + ctx.fillStyle = + node.boxcolor || colState || LiteGraph.NODE_DEFAULT_BOXCOLOR + if (low_quality) + ctx.fillRect( + title_height * 0.5 - box_size * 0.5, + title_height * -0.5 - box_size * 0.5, + box_size, + box_size, + ) + else { + ctx.beginPath() + ctx.arc( + title_height * 0.5, + title_height * -0.5, + box_size * 0.5, + 0, + Math.PI * 2, + ) + ctx.fill() + } + } else { + if (low_quality) { + ctx.fillStyle = "black" + ctx.fillRect( + (title_height - box_size) * 0.5 - 1, + (title_height + box_size) * -0.5 - 1, + box_size + 2, + box_size + 2, + ) + } + ctx.fillStyle = node.boxcolor || colState || LiteGraph.NODE_DEFAULT_BOXCOLOR + ctx.fillRect( + (title_height - box_size) * 0.5, + (title_height + box_size) * -0.5, + box_size, + box_size, + ) + } + ctx.globalAlpha = old_alpha + + // title text + if (node.onDrawTitleText) { + node.onDrawTitleText( + ctx, + title_height, + size, + this.ds.scale, + this.title_text_font, + selected, + ) + } + if (!low_quality) { + ctx.font = this.title_text_font + const title = String(node.getTitle()) + (node.pinned ? "📌" : "") + if (title) { + if (selected) { + ctx.fillStyle = LiteGraph.NODE_SELECTED_TITLE_COLOR + } else { + ctx.fillStyle = node.constructor.title_text_color || this.node_title_color + } + if (node.flags.collapsed) { + ctx.textAlign = "left" + // const measure = ctx.measureText(title) + ctx.fillText( + title.substr(0, 20), // avoid urls too long + title_height, // + measure.width * 0.5, + LiteGraph.NODE_TITLE_TEXT_Y - title_height, + ) + ctx.textAlign = "left" + } else { + ctx.textAlign = "left" + ctx.fillText( + title, + title_height, + LiteGraph.NODE_TITLE_TEXT_Y - title_height, + ) + } + } + } + + // subgraph box + if ( + !node.flags.collapsed && + node.subgraph && + !node.skip_subgraph_button + ) { + const w = LiteGraph.NODE_TITLE_HEIGHT + const x = node.size[0] - w + const over = LiteGraph.isInsideRectangle( + this.graph_mouse[0] - node.pos[0], + this.graph_mouse[1] - node.pos[1], + x + 2, + -w + 2, + w - 4, + w - 4, + ) + ctx.fillStyle = over ? "#888" : "#555" + if (shape == RenderShape.BOX || low_quality) { + ctx.fillRect(x + 2, -w + 2, w - 4, w - 4) } else { - pointer.onDoubleClick = () => { - // Double-click - // Check if it's a double click on the title bar - // Note: pos[1] is the y-coordinate of the node's body - // If clicking on node header (title), pos[1] is negative - if (pos[1] < 0 && !inCollapse) { - node.onNodeTitleDblClick?.(e, pos, this) - } - node.onDblClick?.(e, pos, this) - this.processNodeDblClicked(node) - } - - // Mousedown callback - can block drag - if (node.onMouseDown?.(e, pos, this) || !this.allow_dragnodes) - return - - // Drag node - pointer.onDragStart = (pointer) => this.#startDraggingItems(node, pointer, true) - pointer.onDragEnd = (e) => this.#processDraggedItems(e) + ctx.beginPath() + ctx.roundRect(x + 2, -w + 2, w - 4, w - 4, [4]) + ctx.fill() } + ctx.fillStyle = "#333" + ctx.beginPath() + ctx.moveTo(x + w * 0.2, -w * 0.6) + ctx.lineTo(x + w * 0.8, -w * 0.6) + ctx.lineTo(x + w * 0.5, -w * 0.3) + ctx.fill() + } + + // custom title render + node.onDrawTitle?.(ctx) + } + + // render selection marker + if (selected) { + node.onBounding?.(area) + + this.drawSelectionBounding(ctx, area, { + shape, + title_height, + title_mode, + fgcolor, + collapsed: node.flags?.collapsed, + }) + } + + // these counter helps in conditioning drawing based on if the node has been executed or an action occurred + if (node.execute_triggered > 0) node.execute_triggered-- + if (node.action_triggered > 0) node.action_triggered-- + } + + /** + * Draws the selection bounding of an area. + * @param {CanvasRenderingContext2D} ctx + * @param {Vector4} area + * @param {{ + * shape: LiteGraph.Shape, + * title_height: number, + * title_mode: LiteGraph.TitleMode, + * fgcolor: string, + * padding: number, + * }} options + */ + drawSelectionBounding( + ctx: CanvasRenderingContext2D, + area: Rect, + { + shape = RenderShape.BOX, + title_height = LiteGraph.NODE_TITLE_HEIGHT, + title_mode = TitleMode.NORMAL_TITLE, + fgcolor = LiteGraph.NODE_BOX_OUTLINE_COLOR, + padding = 6, + collapsed = false, + }: IDrawSelectionBoundingOptions = {}, + ) { + // Adjust area if title is transparent + if (title_mode === TitleMode.TRANSPARENT_TITLE) { + area[1] -= title_height + area[3] += title_height + } + + // Set up context + ctx.lineWidth = 1 + ctx.globalAlpha = 0.8 + ctx.beginPath() + + // Draw shape based on type + const [x, y, width, height] = area + switch (shape) { + case RenderShape.BOX: { + ctx.rect( + x - padding, + y - padding, + width + 2 * padding, + height + 2 * padding, + ) + break + } + case RenderShape.ROUND: + case RenderShape.CARD: { + const radius = this.round_radius * 2 + const isCollapsed = shape === RenderShape.CARD && collapsed + const cornerRadii = + isCollapsed || shape === RenderShape.ROUND + ? [radius] + : [radius, 2, radius, 2] + ctx.roundRect( + x - padding, + y - padding, + width + 2 * padding, + height + 2 * padding, + cornerRadii, + ) + break + } + case RenderShape.CIRCLE: { + const centerX = x + width / 2 + const centerY = y + height / 2 + const radius = Math.max(width, height) / 2 + padding + ctx.arc(centerX, centerY, radius, 0, Math.PI * 2) + break + } + } + + // Stroke the shape + ctx.strokeStyle = LiteGraph.NODE_BOX_OUTLINE_COLOR + ctx.stroke() + + // Reset context + ctx.strokeStyle = fgcolor + ctx.globalAlpha = 1 + } + + /** + * Draws a snap guide for a {@link Positionable} item. + * + * Initial design was a simple white rectangle representing the location the + * item would land if dropped. + * @param ctx The 2D canvas context to draw on + * @param item The item to draw a snap guide for + * @param snapTo The grid size to snap to + * @todo Update to align snapping with boundingRect + * @todo Shapes + */ + drawSnapGuide( + ctx: CanvasRenderingContext2D, + item: Positionable, + shape = RenderShape.ROUND, + ) { + const snapGuide = LGraphCanvas.#temp + snapGuide.set(item.boundingRect) + + // Not all items have pos equal to top-left of bounds + const { pos } = item + const offsetX = pos[0] - snapGuide[0] + const offsetY = pos[1] - snapGuide[1] + + // Normalise boundingRect to pos to snap + snapGuide[0] += offsetX + snapGuide[1] += offsetY + snapPoint(snapGuide, this.#snapToGrid) + snapGuide[0] -= offsetX + snapGuide[1] -= offsetY + + const { globalAlpha } = ctx + ctx.globalAlpha = 1 + ctx.beginPath() + const [x, y, w, h] = snapGuide + if (shape === RenderShape.CIRCLE) { + const midX = x + (w * 0.5) + const midY = y + (h * 0.5) + const radius = Math.min(w * 0.5, h * 0.5) + ctx.arc(midX, midY, radius, 0, Math.PI * 2) + } else { + ctx.rect(x, y, w, h) + } + + ctx.lineWidth = 0.5 + ctx.strokeStyle = "#FFFFFF66" + ctx.fillStyle = "#FFFFFF22" + ctx.fill() + ctx.stroke() + ctx.globalAlpha = globalAlpha + } + + drawConnections(ctx: CanvasRenderingContext2D): void { + const rendered = this.renderedPaths + rendered.clear() + const visibleReroutes: Reroute[] = [] + + const now = LiteGraph.getTime() + const visible_area = this.visible_area + LGraphCanvas.#margin_area[0] = visible_area[0] - 20 + LGraphCanvas.#margin_area[1] = visible_area[1] - 20 + LGraphCanvas.#margin_area[2] = visible_area[2] + 40 + LGraphCanvas.#margin_area[3] = visible_area[3] + 40 + + // draw connections + ctx.lineWidth = this.connections_width + + ctx.fillStyle = "#AAA" + ctx.strokeStyle = "#AAA" + ctx.globalAlpha = this.editor_alpha + // for every node + const nodes = this.graph._nodes + for (let n = 0, l = nodes.length; n < l; ++n) { + const node = nodes[n] + // for every input (we render just inputs because it is easier as every slot can only have one input) + if (!node.inputs || !node.inputs.length) continue + + for (let i = 0; i < node.inputs.length; ++i) { + const input = node.inputs[i] + if (!input || input.link == null) continue + + const link_id = input.link + const link = this.graph._links.get(link_id) + if (!link) continue + + // find link info + const start_node = this.graph.getNodeById(link.origin_id) + if (start_node == null) continue + + const outputId = link.origin_slot + const start_node_slotpos: Point = outputId == -1 + ? [start_node.pos[0] + 10, start_node.pos[1] + 10] + : start_node.getConnectionPos(false, outputId, LGraphCanvas.#tempA) + + const end_node_slotpos = node.getConnectionPos(true, i, LGraphCanvas.#tempB) + + // Get all points this link passes through + const reroutes = this.reroutesEnabled + ? LLink.getReroutes(this.graph, link) + : [] + const points = [ + start_node_slotpos, + ...reroutes.map(x => x.pos), + end_node_slotpos, + ] - this.dirty_canvas = true - } - - #processWidgetClick(e: CanvasPointerEvent, node: LGraphNode, widget: IWidget) { - const { pointer } = this - const width = widget.width || node.width - - const oldValue = widget.value - - const pos = this.graph_mouse - const x = pos[0] - node.pos[0] - const y = pos[1] - node.pos[1] - - switch (widget.type) { - case "button": - pointer.onClick = () => { - widget.callback?.(widget, this, node, pos, e) - widget.clicked = true - this.dirty_canvas = true - } - break - case "slider": { - if (widget.options.read_only) break - - pointer.onDrag = eMove => { - const slideFactor = clamp((x - 15) / (width - 30), 0, 1) - widget.value = widget.options.min + (widget.options.max - widget.options.min) * slideFactor - if (oldValue != widget.value) { - setWidgetValue(this, node, widget, widget.value) - } - this.dirty_canvas = true - } - break - } - case "number": { - const delta = x < 40 - ? -1 - : x > width - 40 - ? 1 - : 0 - pointer.onClick = (upEvent) => { - // Left/right arrows - widget.value += delta * 0.1 * (widget.options.step || 1) - if (widget.options.min != null && widget.value < widget.options.min) { - widget.value = widget.options.min - } - if (widget.options.max != null && widget.value > widget.options.max) { - widget.value = widget.options.max - } - - if (delta !== 0) return - - // Click in widget centre area - prompt user for input - this.prompt("Value", widget.value, (v: string) => { - // check if v is a valid equation or a number - if (/^[0-9+\-*/()\s]+|\d+\.\d+$/.test(v)) { - //solve the equation if possible - try { - v = eval(v) - } catch { } - } - widget.value = Number(v) - setWidgetValue(this, node, widget, widget.value) - }, e) - this.dirty_canvas = true - } - - // Click & drag from widget centre area - pointer.onDrag = eMove => { - const x = eMove.canvasX - node.pos[0] - if (delta && (x > -3 && x < width + 3)) return - - if (eMove.deltaX) widget.value += eMove.deltaX * 0.1 * (widget.options.step || 1) - - if (widget.options.min != null && widget.value < widget.options.min) { - widget.value = widget.options.min - } - if (widget.options.max != null && widget.value > widget.options.max) { - widget.value = widget.options.max - } - } - break - } - case "combo": { - // TODO: Type checks on widget values - let values: string[] - let values_list: string[] - - pointer.onClick = (upEvent) => { - const delta = x < 40 - ? -1 - : x > width - 40 - ? 1 - : 0 - - // Combo buttons - values = widget.options.values - if (typeof values === "function") { - // @ts-expect-error - values = values(widget, node) - } - values_list = null - - values_list = Array.isArray(values) ? values : Object.keys(values) - - // Left/right arrows - if (delta) { - let index = -1 - this.last_mouseclick = 0 //avoids dobl click event - index = typeof values === "object" - ? values_list.indexOf(String(widget.value)) + delta - // @ts-expect-error - : values_list.indexOf(widget.value) + delta - - if (index >= values_list.length) index = values_list.length - 1 - if (index < 0) index = 0 - - widget.value = Array.isArray(values) - ? values[index] - : index - - if (oldValue != widget.value) setWidgetValue(this, node, widget, widget.value) - this.dirty_canvas = true - return - } - const text_values = values != values_list ? Object.values(values) : values - new LiteGraph.ContextMenu(text_values, { - scale: Math.max(1, this.ds.scale), - event: e, - className: "dark", - callback: (value: string) => { - widget.value = values != values_list - ? text_values.indexOf(value) - : value - - setWidgetValue(this, node, widget, widget.value) - this.dirty_canvas = true - return false - } - }) - } - break - } - case "toggle": - pointer.onClick = () => { - widget.value = !widget.value - setWidgetValue(this, node, widget, widget.value) - } - break - case "string": - case "text": - pointer.onClick = () => this.prompt( - "Value", - widget.value, - (v: any) => setWidgetValue(this, node, widget, v), - e, - widget.options ? widget.options.multiline : false - ) - break - default: - if (widget.mouse) this.dirty_canvas = widget.mouse(e, [x, y], node) - break - } - - //value changed - if (oldValue != widget.value) { - node.onWidgetChanged?.(widget.name, widget.value, oldValue, widget) - node.graph._version++ - } - - pointer.finally = () => this.node_widget = null - - function setWidgetValue(canvas: LGraphCanvas, node: LGraphNode, widget: IWidget, value: TWidgetValue) { - const v = widget.type === "number" ? Number(value) : value - widget.value = v - if (widget.options?.property && node.properties[widget.options.property] !== undefined) { - node.setProperty(widget.options.property, v) - } - widget.callback?.(widget.value, canvas, node, pos, e) - } - } - - /** - * Pointer middle button click processing. Part of {@link processMouseDown}. - * @param e The pointerdown event - * @param node The node to process a click event for - */ - #processMiddleButton(e: CanvasPointerEvent, node: LGraphNode) { - const { pointer } = this - - if (LiteGraph.middle_click_slot_add_default_node && - node && - this.allow_interaction && - !this.read_only && - !this.connecting_links && - !node.flags.collapsed - ) { - //not dragging mouse to connect two slots - let mClikSlot: INodeSlot | false = false - let mClikSlot_index: number | false = false - let mClikSlot_isOut: boolean = false - //search for outputs - if (node.outputs) { - for (let i = 0, l = node.outputs.length; i < l; ++i) { - const output = node.outputs[i] - const link_pos = node.getConnectionPos(false, i) - if (isInRectangle(e.canvasX, e.canvasY, link_pos[0] - 15, link_pos[1] - 10, 30, 20)) { - mClikSlot = output - mClikSlot_index = i - mClikSlot_isOut = true - break - } - } - } - - //search for inputs - if (node.inputs) { - for (let i = 0, l = node.inputs.length; i < l; ++i) { - const input = node.inputs[i] - const link_pos = node.getConnectionPos(true, i) - if (isInRectangle(e.canvasX, e.canvasY, link_pos[0] - 15, link_pos[1] - 10, 30, 20)) { - mClikSlot = input - mClikSlot_index = i - mClikSlot_isOut = false - break - } - } - } - // Middle clicked a slot - if (mClikSlot && mClikSlot_index !== false) { - - const alphaPosY = 0.5 - ((mClikSlot_index + 1) / ((mClikSlot_isOut ? node.outputs.length : node.inputs.length))) - const node_bounding = node.getBounding() - // estimate a position: this is a bad semi-bad-working mess .. REFACTOR with a correct autoplacement that knows about the others slots and nodes - const posRef: Point = [ - (!mClikSlot_isOut ? node_bounding[0] : node_bounding[0] + node_bounding[2]), - e.canvasY - 80 - ] - // eslint-disable-next-line @typescript-eslint/no-unused-vars - pointer.onClick = () => this.createDefaultNodeForSlot({ - nodeFrom: !mClikSlot_isOut ? null : node, - slotFrom: !mClikSlot_isOut ? null : mClikSlot_index, - nodeTo: !mClikSlot_isOut ? node : null, - slotTo: !mClikSlot_isOut ? mClikSlot_index : null, - position: posRef, - nodeType: "AUTO", - posAdd: [!mClikSlot_isOut ? -30 : 30, -alphaPosY * 130], - posSizeFix: [!mClikSlot_isOut ? -1 : 0, 0] - }) - } - } - - // Drag canvas using middle mouse button - if (this.allow_dragcanvas) { - pointer.onDragStart = () => this.dragging_canvas = true - pointer.finally = () => this.dragging_canvas = false - } - } - - /** - * Called when a mouse move event has to be processed - **/ - processMouseMove(e: PointerEvent): void { - if (this.autoresize) this.resize() - - if (this.set_canvas_dirty_on_mouse_event) - this.dirty_canvas = true - - if (!this.graph) return - - LGraphCanvas.active_canvas = this - this.adjustMouseEvent(e) - const mouse: ReadOnlyPoint = [e.clientX, e.clientY] - this.mouse[0] = mouse[0] - this.mouse[1] = mouse[1] - const delta = [ - mouse[0] - this.last_mouse[0], - mouse[1] - this.last_mouse[1] - ] - this.last_mouse = mouse - this.graph_mouse[0] = e.canvasX - this.graph_mouse[1] = e.canvasY - - if (e.isPrimary) this.pointer.move(e) - - if (this.block_click) { - e.preventDefault() - return - } - - e.dragging = this.last_mouse_dragging - - /** See {@link state}.{@link LGraphCanvasState.hoveringOver hoveringOver} */ - let underPointer = CanvasItem.Nothing - //get node over - const node = this.graph.getNodeOnPos(e.canvasX, e.canvasY, this.visible_nodes) - const { resizingGroup } = this - - const dragRect = this.dragging_rectangle - if (dragRect) { - dragRect[2] = e.canvasX - dragRect[0] - dragRect[3] = e.canvasY - dragRect[1] - this.dirty_canvas = true - } - else if (resizingGroup && !this.read_only) { - // Resizing a group - const resized = resizingGroup.resize( - e.canvasX - resizingGroup.pos[0], - e.canvasY - resizingGroup.pos[1] - ) - underPointer |= CanvasItem.ResizeSe | CanvasItem.Group - if (resized) this.dirty_bgcanvas = true - } else if (this.dragging_canvas) { - this.ds.offset[0] += delta[0] / this.ds.scale - this.ds.offset[1] += delta[1] / this.ds.scale - this.#dirty() - } else if ((this.allow_interaction || (node && node.flags.allow_interaction)) && !this.read_only) { - if (this.connecting_links) this.dirty_canvas = true - - //remove mouseover flag - this.updateMouseOverNodes(node, e) - - //mouse over a node - if (node) { - underPointer |= CanvasItem.Node - - if (node.redraw_on_mouse) - this.dirty_canvas = true - - // For input/output hovering - //to store the output of isOverNodeInput - const pos: Point = [0, 0] - const inputId = this.isOverNodeInput(node, e.canvasX, e.canvasY, pos) - const outputId = this.isOverNodeOutput(node, e.canvasX, e.canvasY, pos) - const overWidget = this.getWidgetAtCursor(node) - - if (!node.mouseOver) { - //mouse enter - node.mouseOver = { - inputId: null, - outputId: null, - overWidget: null, - } - this.node_over = node - this.dirty_canvas = true - - node.onMouseEnter?.(e) - } - - //in case the node wants to do something - node.onMouseMove?.(e, [e.canvasX - node.pos[0], e.canvasY - node.pos[1]], this) - - // The input the mouse is over has changed - if (node.mouseOver.inputId !== inputId || node.mouseOver.outputId !== outputId || node.mouseOver.overWidget !== overWidget) { - node.mouseOver.inputId = inputId - node.mouseOver.outputId = outputId - node.mouseOver.overWidget = overWidget - - // Check if link is over anything it could connect to - record position of valid target for snap / highlight - if (this.connecting_links?.length) { - const firstLink = this.connecting_links[0] - - // Default: nothing highlighted - let highlightPos: Point = null - let highlightInput: INodeInputSlot = null - let linkOverWidget: IWidget = null - - if (firstLink.node === node) { - // Cannot connect link from a node to itself - } else if (firstLink.output) { - // Connecting from an output to an input - if (inputId === -1 && outputId === -1) { - // Allow support for linking to widgets, handled externally to LiteGraph - if (this.getWidgetLinkType && overWidget) { - const widgetLinkType = this.getWidgetLinkType(overWidget, node) - if (widgetLinkType && LiteGraph.isValidConnection(firstLink.output.type, widgetLinkType)) { - if (firstLink.node.isValidWidgetLink?.(firstLink.output.slot_index, node, overWidget) !== false) { - linkOverWidget = overWidget - this.link_over_widget_type = widgetLinkType - } - } - } - // Node background / title under the pointer - if (!linkOverWidget) { - const targetSlotId = firstLink.node.findConnectByTypeSlot(true, node, firstLink.output.type) - if (targetSlotId !== null && targetSlotId >= 0) { - node.getConnectionPos(true, targetSlotId, pos) - highlightPos = pos - highlightInput = node.inputs[targetSlotId] - } - } - } else if (inputId != -1 && node.inputs[inputId] && LiteGraph.isValidConnection(firstLink.output.type, node.inputs[inputId].type)) { - //check if I have a slot below de mouse - if (inputId != -1 && node.inputs[inputId] && LiteGraph.isValidConnection(firstLink.output.type, node.inputs[inputId].type)) { - highlightPos = pos - highlightInput = node.inputs[inputId] // XXX CHECK THIS - } - } - - } else if (firstLink.input) { - // Connecting from an input to an output - if (inputId === -1 && outputId === -1) { - const targetSlotId = firstLink.node.findConnectByTypeSlot(false, node, firstLink.input.type) - - if (targetSlotId !== null && targetSlotId >= 0) { - node.getConnectionPos(false, targetSlotId, pos) - highlightPos = pos - } - } else { - //check if I have a slot below de mouse - if (outputId != -1 && node.outputs[outputId] && LiteGraph.isValidConnection(firstLink.input.type, node.outputs[outputId].type)) { - highlightPos = pos - } - } - } - this._highlight_pos = highlightPos - this._highlight_input = highlightInput - this.link_over_widget = linkOverWidget - } - - this.dirty_canvas = true - } - - // Resize corner - if (this.canvas && !e.ctrlKey) { - if (node.inResizeCorner(e.canvasX, e.canvasY)) underPointer |= CanvasItem.ResizeSe - } - } else { - // Not over a node - const segment = this.#getLinkCentreOnPos(e) - if (this.over_link_center !== segment) { - underPointer |= CanvasItem.Link - this.over_link_center = segment - this.dirty_bgcanvas = true - } - - if (this.canvas) { - const group = this.graph.getGroupOnPos(e.canvasX, e.canvasY) - if (group && !e.ctrlKey && !this.read_only && group.isInResize(e.canvasX, e.canvasY)) { - underPointer |= CanvasItem.ResizeSe - } - } - } //end - - //send event to node if capturing input (used with widgets that allow drag outside of the area of the node) - if (this.node_capturing_input && this.node_capturing_input != node) { - this.node_capturing_input.onMouseMove?.(e, [e.canvasX - this.node_capturing_input.pos[0], e.canvasY - this.node_capturing_input.pos[1]], this) - } - - // Items being dragged - if (this.isDragging) { - const selected = this.selectedItems - const allItems = e.ctrlKey ? selected : getAllNestedItems(selected) - - const deltaX = delta[0] / this.ds.scale - const deltaY = delta[1] / this.ds.scale - for (const item of allItems) { - item.move(deltaX, deltaY, true) - } - - this.#dirty() - } - - if (this.resizing_node) { - //convert mouse to node space - const desired_size: Size = [e.canvasX - this.resizing_node.pos[0], e.canvasY - this.resizing_node.pos[1]] - const min_size = this.resizing_node.computeSize() - desired_size[0] = Math.max(min_size[0], desired_size[0]) - desired_size[1] = Math.max(min_size[1], desired_size[1]) - this.resizing_node.setSize(desired_size) - - underPointer |= CanvasItem.ResizeSe - this.#dirty() - } - } - - this.state.hoveringOver = underPointer - - if (this.state.shouldSetCursor) { - if (!underPointer) { - this.canvas.style.cursor = "" - } else if (underPointer & CanvasItem.ResizeSe) { - this.canvas.style.cursor = "se-resize" - } else if (underPointer & CanvasItem.Node) { - this.canvas.style.cursor = "crosshair" - } - } - - e.preventDefault() - return - } - - /** - * Start dragging an item, optionally including all other selected items. - * - * ** This function sets the {@link CanvasPointer.finally}() callback. ** - * @param item The item that the drag event started on - * @param pointer The pointer event that initiated the drag, e.g. pointerdown - * @param sticky If `true`, the item is added to the selection - see {@link processSelect} - */ - #startDraggingItems(item: Positionable, pointer: CanvasPointer, sticky = false): void { - this.emitBeforeChange() - this.graph.beforeChange() - // Ensure that dragging is properly cleaned up, on success or failure. - pointer.finally = () => { - this.isDragging = false - this.graph.afterChange() - this.emitAfterChange() - } - - this.processSelect(item, pointer.eDown, sticky) - this.isDragging = true - } - - /** - * Handles shared clean up and placement after items have been dragged. - * @param e The event that completed the drag, e.g. pointerup, pointermove - */ - #processDraggedItems(e: CanvasPointerEvent): void { - const { graph } = this - if (e.shiftKey || graph.config.alwaysSnapToGrid) - graph.snapToGrid(this.selectedItems) - - this.dirty_canvas = true - this.dirty_bgcanvas = true - - // TODO: Replace legacy behaviour: callbacks were never extended for multiple items - this.onNodeMoved?.(findFirstNode(this.selectedItems)) - } - - /** - * Called when a mouse up event has to be processed - **/ - processMouseUp(e: PointerEvent): void { - //early exit for extra pointer - if (e.isPrimary === false) return - - const { graph, pointer } = this - if (!graph) return - - LGraphCanvas.active_canvas = this - - this.adjustMouseEvent(e) - - /** The mouseup event occurred near the mousedown event. */ - /** Normal-looking click event - mouseUp occurred near mouseDown, without dragging. */ - const isClick = pointer.up(e) - if (isClick === true) { - pointer.isDown = false - pointer.isDouble = false - // Required until all link behaviour is added to Pointer API - this.connecting_links = null - this.dragging_canvas = false - - graph.change() - - e.stopPropagation() - e.preventDefault() - return - } - - const now = LiteGraph.getTime() - e.click_time = now - this.last_mouseclick - this.last_mouse_dragging = false - this.last_click_position = null - - //used to avoid sending twice a click in an immediate button - this.block_click &&= false - - if (e.button === 0) { - //left button - this.selected_group = null - - this.isDragging = false - - const x = e.canvasX - const y = e.canvasY - const node = graph.getNodeOnPos(x, y, this.visible_nodes) - - if (this.connecting_links?.length) { - //node below mouse - const firstLink = this.connecting_links[0] - if (node) { - for (const link of this.connecting_links) { - - //dragging a connection - this.#dirty() - - //slot below mouse? connect - if (link.output) { - - const slot = this.isOverNodeInput(node, x, y) - if (slot != -1) { - link.node.connect(link.slot, node, slot, link.afterRerouteId) - } else if (this.link_over_widget) { - this.emitEvent({ - subType: "connectingWidgetLink", - link, - node, - widget: this.link_over_widget - }) - this.link_over_widget = null - } else { - //not on top of an input - // look for a good slot - link.node.connectByType(link.slot, node, link.output.type, { afterRerouteId: link.afterRerouteId }) - } - } else if (link.input) { - const slot = this.isOverNodeOutput(node, x, y) - - if (slot != -1) { - node.connect(slot, link.node, link.slot, link.afterRerouteId) // this is inverted has output-input nature like - } else { - //not on top of an input - // look for a good slot - link.node.connectByTypeOutput(link.slot, node, link.input.type, { afterRerouteId: link.afterRerouteId }) - } - } - } - } else if (firstLink.input || firstLink.output) { - const linkReleaseContext = firstLink.output ? { - node_from: firstLink.node, - slot_from: firstLink.output, - type_filter_in: firstLink.output.type - } : { - node_to: firstLink.node, - slot_from: firstLink.input, - type_filter_out: firstLink.input.type - } - // For external event only. - const linkReleaseContextExtended: LinkReleaseContextExtended = { - links: this.connecting_links, - } - this.emitEvent({ - subType: "empty-release", - originalEvent: e, - linkReleaseContext: linkReleaseContextExtended, - }) - // No longer in use - // add menu when releasing link in empty space - if (LiteGraph.release_link_on_empty_shows_menu) { - if (e.shiftKey) { - if (this.allow_searchbox) { - this.showSearchBox(e, linkReleaseContext) - } - } else { - if (firstLink.output) { - this.showConnectionMenu({ nodeFrom: firstLink.node, slotFrom: firstLink.output, e: e }) - } else if (firstLink.input) { - this.showConnectionMenu({ nodeTo: firstLink.node, slotTo: firstLink.input, e: e }) - } - } - } - } - } else { - this.dirty_canvas = true - - // @ts-expect-error Unused param - this.node_over?.onMouseUp?.(e, [x - this.node_over.pos[0], y - this.node_over.pos[1]], this) - this.node_capturing_input?.onMouseUp?.(e, [ - x - this.node_capturing_input.pos[0], - y - this.node_capturing_input.pos[1] - ]) - } - - this.connecting_links = null - } else if (e.button === 1) { - //middle button - this.dirty_canvas = true - this.dragging_canvas = false - } else if (e.button === 2) { - //right button - this.dirty_canvas = true - } - - pointer.isDown = false - pointer.isDouble = false - - graph.change() - - e.stopPropagation() - e.preventDefault() - return - } - - /** - * Called when the mouse moves off the canvas. Clears all node hover states. - * @param e - */ - processMouseOut(e: MouseEvent): void { - // TODO: Check if document.contains(e.relatedTarget) - handle mouseover node textarea etc. - this.adjustMouseEvent(e) - this.updateMouseOverNodes(null, e) - } - - processMouseCancel(e: PointerEvent): void { - console.warn("Pointer cancel!") - this.pointer.reset() - } - - /** - * Called when a mouse wheel event has to be processed - **/ - processMouseWheel(e: WheelEvent): void { - if (!this.graph || !this.allow_dragcanvas) return - - // TODO: Mouse wheel zoom rewrite - // @ts-expect-error - const delta = e.wheelDeltaY ?? e.detail * -60 - - this.adjustMouseEvent(e) - - const pos: Point = [e.clientX, e.clientY] - if (this.viewport && !isPointInRect(pos, this.viewport)) return - - let scale = this.ds.scale - - if (delta > 0) scale *= this.zoom_speed - else if (delta < 0) scale *= 1 / this.zoom_speed - - this.ds.changeScale(scale, [e.clientX, e.clientY]) - - this.graph.change() - - e.preventDefault() - return - } - - /** - * returns the INDEX if a position (in graph space) is on top of a node input slot - **/ - isOverNodeInput(node: LGraphNode, canvasx: number, canvasy: number, slot_pos?: Point): number { - if (node.inputs) { - for (let i = 0, l = node.inputs.length; i < l; ++i) { - const input = node.inputs[i] - const link_pos = node.getConnectionPos(true, i) - let is_inside = false - if (node.horizontal) { - is_inside = isInRectangle( - canvasx, - canvasy, - link_pos[0] - 5, - link_pos[1] - 10, - 10, - 20 - ) - } else { - // TODO: Find a cheap way to measure text, and do it on node label change instead of here - // Input icon width + text approximation - const width = 20 + (((input.label?.length ?? input.name?.length) || 3) * 7) - is_inside = isInRectangle( - canvasx, - canvasy, - link_pos[0] - 10, - link_pos[1] - 10, - width, - 20 - ) - } - if (is_inside) { - if (slot_pos) { - slot_pos[0] = link_pos[0] - slot_pos[1] = link_pos[1] - } - return i - } - } - } - return -1 - } - /** - * returns the INDEX if a position (in graph space) is on top of a node output slot - **/ - isOverNodeOutput(node: LGraphNode, canvasx: number, canvasy: number, slot_pos?: Point): number { - if (node.outputs) { - for (let i = 0, l = node.outputs.length; i < l; ++i) { - const link_pos = node.getConnectionPos(false, i) - let is_inside = false - if (node.horizontal) { - is_inside = isInRectangle( - canvasx, - canvasy, - link_pos[0] - 5, - link_pos[1] - 10, - 10, - 20 - ) - } else { - is_inside = isInRectangle( - canvasx, - canvasy, - link_pos[0] - 10, - link_pos[1] - 10, - 40, - 20 - ) - } - if (is_inside) { - if (slot_pos) { - slot_pos[0] = link_pos[0] - slot_pos[1] = link_pos[1] - } - return i - } - } - } - return -1 - } - /** - * process a key event - **/ - processKey(e: KeyboardEvent): boolean | null { - this.#shiftDown = e.shiftKey - if (!this.graph) return - - let block_default = false - //console.log(e); //debug - // @ts-expect-error - if (e.target.localName == "input") return - - if (e.type == "keydown") { - // TODO: Switch - if (e.keyCode == 32) { - // space - this.read_only = true - if (this._previously_dragging_canvas === null) { - this._previously_dragging_canvas = this.dragging_canvas - } - this.dragging_canvas = this.pointer.isDown - block_default = true - } - - else if (e.keyCode == 27) { - //esc - this.node_panel?.close() - this.options_panel?.close() - block_default = true - } - - //select all Control A - else if (e.keyCode == 65 && e.ctrlKey) { - this.selectItems() - block_default = true - } - - else if ((e.keyCode === 67) && (e.metaKey || e.ctrlKey) && !e.shiftKey) { - //copy - if (this.selected_nodes) { - this.copyToClipboard() - block_default = true - } - } - - else if ((e.keyCode === 86) && (e.metaKey || e.ctrlKey)) { - //paste - this.pasteFromClipboard(e.shiftKey) - } - - //delete or backspace - else if (e.keyCode == 46 || e.keyCode == 8) { - // @ts-expect-error - if (e.target.localName != "input" && e.target.localName != "textarea") { - this.deleteSelected() - block_default = true - } - } - - //collapse - //... - //TODO - if (this.selected_nodes) { - for (const i in this.selected_nodes) { - this.selected_nodes[i].onKeyDown?.(e) - } - } - } else if (e.type == "keyup") { - if (e.keyCode == 32) { - // space - this.read_only = false - this.dragging_canvas = this._previously_dragging_canvas ?? false - this._previously_dragging_canvas = null - } - - if (this.selected_nodes) { - for (const i in this.selected_nodes) { - this.selected_nodes[i].onKeyUp?.(e) - } - } - } - - // TODO: Do we need to remeasure and recalculate everything on every key down/up? - this.graph.change() - - if (block_default) { - e.preventDefault() - e.stopImmediatePropagation() - return false - } - } - - /** - * Copies canvas items to an internal, app-specific clipboard backed by local storage. - * When called without parameters, it copies {@link selectedItems}. - * @param items The items to copy. If nullish, all selected items are copied. - */ - copyToClipboard(items?: Iterable): void { - const serialisable: ClipboardItems = { - nodes: [], - groups: [], - reroutes: [], - links: [] - } - - // Create serialisable objects - for (const item of items ?? this.selectedItems) { - if (item instanceof LGraphNode) { - // Nodes - if (item.clonable === false) continue - - const cloned = item.clone()?.serialize() - if (!cloned) continue - - cloned.id = item.id - serialisable.nodes.push(cloned) - - // Links - const links = item.inputs - ?.map(input => this.graph._links.get(input?.link)?.asSerialisable()) - .filter(x => !!x) - - if (!links) continue - serialisable.links.push(...links) - } else if (item instanceof LGraphGroup) { - // Groups - serialisable.groups.push(item.serialize()) - } else if (this.reroutesEnabled && item instanceof Reroute) { - // Reroutes - serialisable.reroutes.push(item.asSerialisable()) - } - } - - localStorage.setItem( - "litegrapheditor_clipboard", - JSON.stringify(serialisable) - ) - } - - emitEvent(detail: CanvasEventDetail): void { - this.canvas.dispatchEvent(new CustomEvent( - "litegraph:canvas", - { - bubbles: true, - detail - } - )) - } - - /** @todo Refactor to where it belongs - e.g. Deleting / creating nodes is not actually canvas event. */ - emitBeforeChange(): void { - this.emitEvent({ - subType: "before-change", - }) - } - - /** @todo See {@link emitBeforeChange} */ - emitAfterChange(): void { - this.emitEvent({ - subType: "after-change", - }) - } - - /** - * Pastes the items from the canvas "clipbaord" - a local storage variable. - * @param connectInputs If `true`, always attempt to connect inputs of pasted nodes - including to nodes that were not pasted. - */ - _pasteFromClipboard(connectInputs = false): ClipboardPasteResult { - // if ctrl + shift + v is off, return when isConnectUnselected is true (shift is pressed) to maintain old behavior - if (!LiteGraph.ctrl_shift_v_paste_connect_unselected_outputs && connectInputs) return - - const data = localStorage.getItem("litegrapheditor_clipboard") - if (!data) return - - const { graph } = this - graph.beforeChange() - - // Parse & initialise - const parsed: ClipboardItems = JSON.parse(data) - parsed.nodes ??= [] - parsed.groups ??= [] - parsed.reroutes ??= [] - parsed.links ??= [] - - // Find top-left-most boundary - let offsetX = Infinity - let offsetY = Infinity - for (const item of [...parsed.nodes, ...parsed.reroutes]) { - if (item.pos[0] < offsetX) offsetX = item.pos[0] - if (item.pos[1] < offsetY) offsetY = item.pos[1] - } - - // TODO: Remove when implementing `asSerialisable` - if (parsed.groups) { - for (const group of parsed.groups) { - if (group.bounding[0] < offsetX) offsetX = group.bounding[0] - if (group.bounding[1] < offsetY) offsetY = group.bounding[1] - } - } - - const results: ClipboardPasteResult = { - created: [], - nodes: new Map(), - links: new Map(), - reroutes: new Map(), - } - const { created, nodes, links, reroutes } = results - - // const failedNodes: ISerialisedNode[] = [] - - // Groups - for (const info of parsed.groups) { - info.id = undefined - - const group = new LGraphGroup() - group.configure(info) - graph.add(group) - created.push(group) - } - - // Nodes - for (const info of parsed.nodes) { - const node = LiteGraph.createNode(info.type) - if (!node) { - // failedNodes.push(info) - continue - } - - nodes.set(info.id, node) - info.id = undefined - - node.configure(info) - graph.add(node) - - created.push(node) - } - - // Reroutes - for (const info of parsed.reroutes) { - const { id } = info - info.id = undefined - - const reroute = graph.setReroute(info) - created.push(reroute) - reroutes.set(id, reroute) - } - - // Remap reroute parentIds for pasted reroutes - for (const reroute of reroutes.values()) { - const mapped = reroutes.get(reroute.parentId) - if (mapped) reroute.parentId = mapped.id - } - - // Links - for (const info of parsed.links) { - // Find the copied node / reroute ID - let outNode = nodes.get(info.origin_id) - let afterRerouteId = reroutes.get(info.parentId)?.id - - // If it wasn't copied, use the original graph value - if (connectInputs && LiteGraph.ctrl_shift_v_paste_connect_unselected_outputs) { - outNode ??= graph.getNodeById(info.origin_id) - afterRerouteId ??= info.parentId - } - - const inNode = nodes.get(info.target_id) - if (inNode) { - const link = outNode?.connect(info.origin_slot, inNode, info.target_slot, afterRerouteId) - if (link) links.set(info.id, link) - } - } - - // Remap linkIds - for (const reroute of reroutes.values()) { - const ids = [...reroute.linkIds].map(x => links.get(x)?.id ?? x) - reroute.update(reroute.parentId, undefined, ids) - - // Remove any invalid items - if (!reroute.validateLinks(graph.links)) graph.removeReroute(reroute.id) - } - - // Adjust positions - for (const item of created) { - item.pos[0] += this.graph_mouse[0] - offsetX - item.pos[1] += this.graph_mouse[1] - offsetY - } - - // TODO: Report failures, i.e. `failedNodes` - - this.selectItems(created) - - graph.afterChange() - - return results - } - - pasteFromClipboard(isConnectUnselected = false): void { - this.emitBeforeChange() - try { - this._pasteFromClipboard(isConnectUnselected) - } finally { - this.emitAfterChange() - } - } - /** - * process a item drop event on top the canvas - **/ - processDrop(e: DragEvent): boolean { - e.preventDefault() - this.adjustMouseEvent(e) - const x = e.clientX - const y = e.clientY - const is_inside = !this.viewport || isInRect(x, y, this.viewport) - if (!is_inside) return - - const pos = [e.canvasX, e.canvasY] - const node = this.graph ? this.graph.getNodeOnPos(pos[0], pos[1]) : null - - if (!node) { - const r = this.onDropItem?.(e) - if (!r) this.checkDropItem(e) - return - } - - if (node.onDropFile || node.onDropData) { - const files = e.dataTransfer.files - if (files && files.length) { - for (let i = 0; i < files.length; i++) { - const file = e.dataTransfer.files[0] - const filename = file.name - node.onDropFile?.(file) - - if (node.onDropData) { - //prepare reader - const reader = new FileReader() - reader.onload = function (event) { - const data = event.target.result - node.onDropData(data, filename, file) - } - - //read data - const type = file.type.split("/")[0] - if (type == "text" || type == "") { - reader.readAsText(file) - } else if (type == "image") { - reader.readAsDataURL(file) - } else { - reader.readAsArrayBuffer(file) - } - } - } - } - } - - if (node.onDropItem?.(e)) return true - - return this.onDropItem - ? this.onDropItem(e) - : false - } - //called if the graph doesn't have a default drop item behaviour - checkDropItem(e: CanvasDragEvent): void { - if (!e.dataTransfer.files.length) return - - const file = e.dataTransfer.files[0] - const ext = LGraphCanvas.getFileExtension(file.name).toLowerCase() - const nodetype = LiteGraph.node_types_by_file_extension[ext] - if (!nodetype) return - - this.graph.beforeChange() - const node = LiteGraph.createNode(nodetype.type) - node.pos = [e.canvasX, e.canvasY] - this.graph.add(node) - node.onDropFile?.(file) - this.graph.afterChange() - } - processNodeDblClicked(n: LGraphNode): void { - this.onShowNodePanel?.(n) - this.onNodeDblClicked?.(n) - - this.setDirty(true) - } - - #handleMultiSelect(e: CanvasPointerEvent, dragRect: Float32Array) { - // Process drag - // Convert Point pair (pos, offset) to Rect - const { graph, selectedItems } = this - - const w = Math.abs(dragRect[2]) - const h = Math.abs(dragRect[3]) - if (dragRect[2] < 0) dragRect[0] -= w - if (dragRect[3] < 0) dragRect[1] -= h - dragRect[2] = w - dragRect[3] = h - - // Select nodes - any part of the node is in the select area - const isSelected: Positionable[] = [] - const notSelected: Positionable[] = [] - for (const nodeX of graph._nodes) { - if (!overlapBounding(dragRect, nodeX.boundingRect)) continue - - if (!nodeX.selected || !selectedItems.has(nodeX)) - notSelected.push(nodeX) - else isSelected.push(nodeX) - } - - // Select groups - the group is wholly inside the select area - for (const group of graph.groups) { - if (!containsRect(dragRect, group._bounding)) continue - group.recomputeInsideNodes() - - if (!group.selected || !selectedItems.has(group)) - notSelected.push(group) - else isSelected.push(group) - } - - // Select reroutes - the centre point is inside the select area - for (const reroute of graph.reroutes.values()) { - if (!isPointInRect(reroute.pos, dragRect)) continue - - selectedItems.add(reroute) - reroute.selected = true - - if (!reroute.selected || !selectedItems.has(reroute)) - notSelected.push(reroute) - else isSelected.push(reroute) - } - - if (e.shiftKey) { - // Add to selection - for (const item of notSelected) this.select(item) - } else if (e.altKey) { - // Remove from selection - for (const item of isSelected) this.deselect(item) - } else { - // Replace selection - for (const item of selectedItems.values()) { - if (!isSelected.includes(item)) this.deselect(item) - } - for (const item of notSelected) this.select(item) - } - } - - /** - * Determines whether to select or deselect an item that has received a pointer event. Will deselect other nodes if - * @param item Canvas item to select/deselect - * @param e The MouseEvent to handle - * @param sticky Prevents deselecting individual nodes (as used by aux/right-click) - * @remarks - * Accessibility: anyone using {@link mutli_select} always deselects when clicking empty space. - */ - processSelect(item: TPositionable | null, e: CanvasMouseEvent, sticky: boolean = false): void { - const addModifier = e?.shiftKey - const subtractModifier = e != null && (e.metaKey || e.ctrlKey) - const eitherModifier = addModifier || subtractModifier - const modifySelection = eitherModifier || this.multi_select - - if (!item) { - if (!eitherModifier || this.multi_select) this.deselectAll() - - } else if (!item.selected || !this.selectedItems.has(item)) { - if (!modifySelection) this.deselectAll(item) - this.select(item) - } else if (modifySelection && !sticky) { - this.deselect(item) - } else if (!sticky) { - this.deselectAll(item) - } else { - return - } - this.onSelectionChange?.(this.selected_nodes) - this.setDirty(true) - } - - /** - * Selects a {@link Positionable} item. - * @param item The canvas item to add to the selection. - */ - select(item: TPositionable): void { - if (item.selected && this.selectedItems.has(item)) return - - item.selected = true - this.selectedItems.add(item) - if (!(item instanceof LGraphNode)) return - - // Node-specific handling - item.onSelected?.() - this.selected_nodes[item.id] = item - - this.onNodeSelected?.(item) - - // Highlight links - item.inputs?.forEach(input => this.highlighted_links[input.link] = true) - item.outputs?.flatMap(x => x.links) - .forEach(id => this.highlighted_links[id] = true) - } - - /** - * Deselects a {@link Positionable} item. - * @param item The canvas item to remove from the selection. - */ - deselect(item: TPositionable): void { - if (!item.selected && !this.selectedItems.has(item)) return - - item.selected = false - this.selectedItems.delete(item) - if (!(item instanceof LGraphNode)) return - - // Node-specific handling - item.onDeselected?.() - delete this.selected_nodes[item.id] - - this.onNodeDeselected?.(item) - - // Clear link highlight - item.inputs?.forEach(input => delete this.highlighted_links[input.link]) - item.outputs?.flatMap(x => x.links) - .forEach(id => delete this.highlighted_links[id]) - } - - /** @deprecated See {@link LGraphCanvas.processSelect} */ - processNodeSelected(item: LGraphNode, e: CanvasMouseEvent): void { - this.processSelect(item, e, e && (e.shiftKey || e.metaKey || e.ctrlKey || this.multi_select)) - } - - /** @deprecated See {@link LGraphCanvas.select} */ - selectNode(node: LGraphNode, add_to_current_selection?: boolean): void { - if (node == null) { - this.deselectAll() - } else { - this.selectNodes([node], add_to_current_selection) - } - } - - /** - * @returns All items on the canvas that can be selected - */ - get positionableItems(): Positionable[] { - return [...this.graph._nodes, ...this.graph._groups, ...this.graph.reroutes.values()] - } - - /** - * Selects several items. - * @param items Items to select - if falsy, all items on the canvas will be selected - * @param add_to_current_selection If set, the items will be added to the current selection instead of replacing it - */ - selectItems(items?: Positionable[], add_to_current_selection?: boolean): void { - const itemsToSelect = items ?? this.positionableItems - if (!add_to_current_selection) this.deselectAll() - for (const item of itemsToSelect) this.select(item) - this.onSelectionChange?.(this.selected_nodes) - this.setDirty(true) - } - - /** - * selects several nodes (or adds them to the current selection) - * @deprecated See {@link LGraphCanvas.selectItems} - **/ - selectNodes(nodes?: LGraphNode[], add_to_current_selection?: boolean): void { - this.selectItems(nodes, add_to_current_selection) - } - - /** @deprecated See {@link LGraphCanvas.deselect} */ - deselectNode(node: LGraphNode): void { - this.deselect(node) - } - - /** - * Deselects all items on the canvas. - * @param keepSelected If set, this item will not be removed from the selection. - */ - deselectAll(keepSelected?: Positionable): void { - if (!this.graph) return - - const selected = this.selectedItems - let wasSelected: Positionable - for (const sel of selected) { - if (sel === keepSelected) { - wasSelected = sel - continue - } - sel.onDeselected?.() - sel.selected = false - } - selected.clear() - if (wasSelected) selected.add(wasSelected) - - this.setDirty(true) - - // Legacy code - const oldNode = keepSelected?.id == null ? null : this.selected_nodes[keepSelected.id] - this.selected_nodes = {} - this.current_node = null - this.highlighted_links = {} - - if (keepSelected instanceof LGraphNode) { - // Handle old object lookup - if (oldNode) this.selected_nodes[oldNode.id] = oldNode - - // Highlight links - keepSelected.inputs?.forEach(input => this.highlighted_links[input.link] = true) - keepSelected.outputs?.flatMap(x => x.links) - .forEach(id => this.highlighted_links[id] = true) - } - - this.onSelectionChange?.(this.selected_nodes) - } - - /** @deprecated See {@link LGraphCanvas.deselectAll} */ - deselectAllNodes(): void { - this.deselectAll() - } - - /** - * Deletes all selected items from the graph. - * - * @todo Refactor deletion task to LGraph. Selection is a canvas property, delete is a graph action. - */ - deleteSelected(): void { - const { graph } = this - this.emitBeforeChange() - graph.beforeChange() - - for (const item of this.selectedItems) { - if (item instanceof LGraphNode) { - const node = item - if (node.block_delete) continue - node.connectInputToOutput() - graph.remove(node) - this.onNodeDeselected?.(node) - } else if (item instanceof LGraphGroup) { - graph.remove(item) - } else if (item instanceof Reroute) { - graph.removeReroute(item.id) - } - } - - this.selectedItems.clear() - this.selected_nodes = {} - this.selectedItems.clear() - this.current_node = null - this.highlighted_links = {} - this.setDirty(true) - graph.afterChange() - this.emitAfterChange() - } - - /** - * deletes all nodes in the current selection from the graph - * @deprecated See {@link LGraphCanvas.deleteSelected} - **/ - deleteSelectedNodes(): void { - this.deleteSelected() - } - - /** - * centers the camera on a given node - **/ - centerOnNode(node: LGraphNode): void { - const dpi = window?.devicePixelRatio || 1 - this.ds.offset[0] = - -node.pos[0] - - node.size[0] * 0.5 + - (this.canvas.width * 0.5) / (this.ds.scale * dpi) - this.ds.offset[1] = - -node.pos[1] - - node.size[1] * 0.5 + - (this.canvas.height * 0.5) / (this.ds.scale * dpi) - this.setDirty(true, true) - } - /** - * adds some useful properties to a mouse event, like the position in graph coordinates - **/ - adjustMouseEvent(e: T & Partial): asserts e is T & CanvasMouseEvent { - let clientX_rel = e.clientX - let clientY_rel = e.clientY - - if (this.canvas) { - const b = this.canvas.getBoundingClientRect() - clientX_rel -= b.left - clientY_rel -= b.top - } - - // TODO: Find a less brittle way to do this - - // Only set deltaX and deltaY if not already set. - // If deltaX and deltaY are already present, they are read-only. - // Setting them would result browser error => zoom in/out feature broken. - if (e.deltaX === undefined) e.deltaX = clientX_rel - this.last_mouse_position[0] - if (e.deltaY === undefined) e.deltaY = clientY_rel - this.last_mouse_position[1] - - this.last_mouse_position[0] = clientX_rel - this.last_mouse_position[1] = clientY_rel - - e.canvasX = clientX_rel / this.ds.scale - this.ds.offset[0] - e.canvasY = clientY_rel / this.ds.scale - this.ds.offset[1] - } - /** - * changes the zoom level of the graph (default is 1), you can pass also a place used to pivot the zoom - **/ - setZoom(value: number, zooming_center: Point) { - this.ds.changeScale(value, zooming_center) - this.#dirty() - } - /** - * converts a coordinate from graph coordinates to canvas2D coordinates - **/ - convertOffsetToCanvas(pos: Point, out: Point): Point { - // @ts-expect-error Unused param - return this.ds.convertOffsetToCanvas(pos, out) - } - /** - * converts a coordinate from Canvas2D coordinates to graph space - **/ - convertCanvasToOffset(pos: Point, out?: Point): Point { - return this.ds.convertCanvasToOffset(pos, out) - } - //converts event coordinates from canvas2D to graph coordinates - convertEventToCanvasOffset(e: MouseEvent): Point { - const rect = this.canvas.getBoundingClientRect() - // TODO: -> this.ds.convertCanvasToOffset - return this.convertCanvasToOffset([ - e.clientX - rect.left, - e.clientY - rect.top - ]) - } - /** - * brings a node to front (above all other nodes) - **/ - bringToFront(node: LGraphNode): void { - const i = this.graph._nodes.indexOf(node) - if (i == -1) return - - this.graph._nodes.splice(i, 1) - this.graph._nodes.push(node) - } - /** - * sends a node to the back (below all other nodes) - **/ - sendToBack(node: LGraphNode): void { - const i = this.graph._nodes.indexOf(node) - if (i == -1) return - - this.graph._nodes.splice(i, 1) - this.graph._nodes.unshift(node) - } - - /** - * Determines which nodes are visible and populates {@link out} with the results. - * @param nodes The list of nodes to check - if falsy, all nodes in the graph will be checked - * @param out Array to write visible nodes into - if falsy, a new array is created instead - * @returns {LGraphNode[]} Array passed ({@link out}), or a new array containing all visible nodes - */ - computeVisibleNodes(nodes?: LGraphNode[], out?: LGraphNode[]): LGraphNode[] { - const visible_nodes = out || [] - visible_nodes.length = 0 - - const _nodes = nodes || this.graph._nodes - for (const node of _nodes) { - node.updateArea() - // Not in visible area - if (!overlapBounding(this.visible_area, node.renderArea)) continue - - visible_nodes.push(node) - } - return visible_nodes - } - - /** - * renders the whole canvas content, by rendering in two separated canvas, one containing the background grid and the connections, and one containing the nodes) - **/ - draw(force_canvas?: boolean, force_bgcanvas?: boolean): void { - if (!this.canvas || this.canvas.width == 0 || this.canvas.height == 0) return - - //fps counting - const now = LiteGraph.getTime() - this.render_time = (now - this.last_draw_time) * 0.001 - this.last_draw_time = now - - if (this.graph) this.ds.computeVisibleArea(this.viewport) - - // Compute node size before drawing links. - if (this.dirty_canvas || force_canvas) - this.computeVisibleNodes(null, this.visible_nodes) - - if (this.dirty_bgcanvas || - force_bgcanvas || - this.always_render_background || - (this.graph?._last_trigger_time && - now - this.graph._last_trigger_time < 1000)) { - this.drawBackCanvas() - } - - if (this.dirty_canvas || force_canvas) this.drawFrontCanvas() - - this.fps = this.render_time ? 1.0 / this.render_time : 0 - this.frame++ - } - /** - * draws the front canvas (the one containing all the nodes) - **/ - drawFrontCanvas(): void { - this.dirty_canvas = false - - if (!this.ctx) { - this.ctx = this.bgcanvas.getContext("2d") - } - const ctx = this.ctx - //maybe is using webgl... - if (!ctx) return - - const canvas = this.canvas - // @ts-expect-error - if (ctx.start2D && !this.viewport) { - // @ts-expect-error - ctx.start2D() - ctx.restore() - ctx.setTransform(1, 0, 0, 1, 0, 0) - } - - //clip dirty area if there is one, otherwise work in full canvas - const area = this.viewport || this.dirty_area - if (area) { - ctx.save() - ctx.beginPath() - ctx.rect(area[0], area[1], area[2], area[3]) - ctx.clip() - } - - // TODO: Set snapping value when changed instead of once per frame - this.#snapToGrid = this.#shiftDown || this.graph.config.alwaysSnapToGrid - ? this.graph.getSnapToGridSize() - : undefined - - //clear - //canvas.width = canvas.width; - if (this.clear_background) { - if (area) - ctx.clearRect(area[0], area[1], area[2], area[3]) - else - ctx.clearRect(0, 0, canvas.width, canvas.height) - } - - //draw bg canvas - if (this.bgcanvas == this.canvas) { - this.drawBackCanvas() - } else { - const scale = window.devicePixelRatio - ctx.drawImage(this.bgcanvas, 0, 0, this.bgcanvas.width / scale, this.bgcanvas.height / scale) - } - - //rendering - this.onRender?.(canvas, ctx) - - //info widget - if (this.show_info) { - this.renderInfo(ctx, area ? area[0] : 0, area ? area[1] : 0) - } - - if (this.graph) { - //apply transformations - ctx.save() - this.ds.toCanvasContext(ctx) - - //draw nodes - const visible_nodes = this.visible_nodes - const drawSnapGuides = this.#snapToGrid && this.isDragging - - for (let i = 0; i < visible_nodes.length; ++i) { - const node = visible_nodes[i] - - ctx.save() - - // Draw snap shadow - if (drawSnapGuides && this.selectedItems.has(node)) - this.drawSnapGuide(ctx, node) - - // Localise co-ordinates to node position - ctx.translate(node.pos[0], node.pos[1]) - - // Draw - this.drawNode(node, ctx) - - ctx.restore() - } - - //on top (debug) - if (this.render_execution_order) { - this.drawExecutionOrder(ctx) - } - - //connections ontop? - if (this.graph.config.links_ontop) { - this.drawConnections(ctx) - } - - if (this.connecting_links?.length) { - //current connection (the one being dragged by the mouse) - for (const link of this.connecting_links) { - ctx.lineWidth = this.connections_width - let link_color = null - - const connInOrOut = link.output || link.input - - const connType = connInOrOut?.type - let connDir = connInOrOut?.dir - if (connDir == null) { - if (link.output) - connDir = link.node.horizontal ? LinkDirection.DOWN : LinkDirection.RIGHT - - else - connDir = link.node.horizontal ? LinkDirection.UP : LinkDirection.LEFT - } - const connShape = connInOrOut?.shape - - switch (connType) { - case LiteGraph.EVENT: - link_color = LiteGraph.EVENT_LINK_COLOR - break - default: - link_color = LiteGraph.CONNECTING_LINK_COLOR - } - - // If not using reroutes, link.afterRerouteId should be undefined. - const pos = this.graph.reroutes.get(link.afterRerouteId)?.pos ?? link.pos - const highlightPos = this.#getHighlightPosition() - //the connection being dragged by the mouse - this.renderLink( - ctx, - pos, - highlightPos, - null, - false, - null, - link_color, - connDir, - link.direction ?? LinkDirection.CENTER - ) - - ctx.beginPath() - if (connType === LiteGraph.EVENT || - connShape === RenderShape.BOX) { - ctx.rect( - pos[0] - 6 + 0.5, - pos[1] - 5 + 0.5, - 14, - 10 - ) - ctx.fill() - ctx.beginPath() - ctx.rect( - this.graph_mouse[0] - 6 + 0.5, - this.graph_mouse[1] - 5 + 0.5, - 14, - 10 - ) - } else if (connShape === RenderShape.ARROW) { - ctx.moveTo(pos[0] + 8, pos[1] + 0.5) - ctx.lineTo(pos[0] - 4, pos[1] + 6 + 0.5) - ctx.lineTo(pos[0] - 4, pos[1] - 6 + 0.5) - ctx.closePath() - } - else { - ctx.arc( - pos[0], - pos[1], - 4, - 0, - Math.PI * 2 - ) - ctx.fill() - ctx.beginPath() - ctx.arc( - this.graph_mouse[0], - this.graph_mouse[1], - 4, - 0, - Math.PI * 2 - ) - } - ctx.fill() - - // Gradient half-border over target node - this.#renderSnapHighlight(ctx, highlightPos) - } - } - - //the selection rectangle - if (this.dragging_rectangle) { - ctx.strokeStyle = "#FFF" - ctx.strokeRect( - this.dragging_rectangle[0], - this.dragging_rectangle[1], - this.dragging_rectangle[2], - this.dragging_rectangle[3] - ) - } - - //on top of link center - if (this.over_link_center && this.render_link_tooltip) - this.drawLinkTooltip(ctx, this.over_link_center) - - //to remove - else - this.onDrawLinkTooltip?.(ctx, null) - - //custom info - this.onDrawForeground?.(ctx, this.visible_area) - - ctx.restore() - } - - this.onDrawOverlay?.(ctx) - - if (area) ctx.restore() - - // FIXME: Remove this hook - //this is a function I use in webgl renderer - // @ts-expect-error - if (ctx.finish2D) ctx.finish2D() - } - - /** @returns If the pointer is over a link centre marker, the link segment it belongs to. Otherwise, `undefined`. */ - #getLinkCentreOnPos(e: CanvasMouseEvent): LinkSegment | undefined { - for (const linkSegment of this.renderedPaths) { - const centre = linkSegment._pos - if (!centre) continue - - if (isInRectangle(e.canvasX, e.canvasY, centre[0] - 4, centre[1] - 4, 8, 8)) { - return linkSegment - } - } - } - - /** Get the target snap / highlight point in graph space */ - #getHighlightPosition(): ReadOnlyPoint { - return LiteGraph.snaps_for_comfy - ? this._highlight_pos ?? this.graph_mouse - : this.graph_mouse - } - - /** - * Renders indicators showing where a link will connect if released. - * Partial border over target node and a highlight over the slot itself. - * @param ctx Canvas 2D context - */ - #renderSnapHighlight(ctx: CanvasRenderingContext2D, highlightPos: ReadOnlyPoint): void { - if (!this._highlight_pos) return - - ctx.fillStyle = "#ffcc00" - ctx.beginPath() - const shape = this._highlight_input?.shape - - if (shape === RenderShape.ARROW) { - ctx.moveTo(highlightPos[0] + 8, highlightPos[1] + 0.5) - ctx.lineTo(highlightPos[0] - 4, highlightPos[1] + 6 + 0.5) - ctx.lineTo(highlightPos[0] - 4, highlightPos[1] - 6 + 0.5) - ctx.closePath() - } else { - ctx.arc( - highlightPos[0], - highlightPos[1], - 6, - 0, - Math.PI * 2 - ) - } - ctx.fill() - - if (!LiteGraph.snap_highlights_node) return - - // Ensure we're mousing over a node and connecting a link - const node = this.node_over - if (!(node && this.connecting_links?.[0])) return - - const { strokeStyle, lineWidth } = ctx - - const area = node.boundingRect - const gap = 3 - const radius = this.round_radius + gap - - const x = area[0] - gap - const y = area[1] - gap - const width = area[2] + (gap * 2) - const height = area[3] + (gap * 2) - - ctx.beginPath() - ctx.roundRect(x, y, width, height, radius) - - // TODO: Currently works on LTR slots only. Add support for other directions. - const start = this.connecting_links[0].output === null ? 0 : 1 - const inverter = start ? -1 : 1 - - // Radial highlight centred on highlight pos - const hx = highlightPos[0] - const hy = highlightPos[1] - const gRadius = width < height - ? width - : width * Math.max(height / width, 0.5) - - const gradient = ctx.createRadialGradient(hx, hy, 0, hx, hy, gRadius) - gradient.addColorStop(1, "#00000000") - gradient.addColorStop(0, "#ffcc00aa") - - // Linear gradient over half the node. - const linearGradient = ctx.createLinearGradient(x, y, x + width, y) - linearGradient.addColorStop(0.5, "#00000000") - linearGradient.addColorStop(start + (0.67 * inverter), "#ddeeff33") - linearGradient.addColorStop(start + inverter, "#ffcc0055") - - /** - * Workaround for a canvas render issue. - * In Chromium 129 (2024-10-15), rounded corners can be rendered with the wrong part of a gradient colour. - * Occurs only at certain thicknesses / arc sizes. - */ - ctx.setLineDash([radius, radius * 0.001]) - - ctx.lineWidth = 1 - ctx.strokeStyle = linearGradient - ctx.stroke() - - ctx.strokeStyle = gradient - ctx.stroke() - - ctx.setLineDash([]) - ctx.lineWidth = lineWidth - ctx.strokeStyle = strokeStyle - } - - /** - * draws some useful stats in the corner of the canvas - **/ - renderInfo(ctx: CanvasRenderingContext2D, x: number, y: number): void { - x = x || 10 - y = y || this.canvas.offsetHeight - 80 - - ctx.save() - ctx.translate(x, y) - - ctx.font = "10px Arial" - ctx.fillStyle = "#888" - ctx.textAlign = "left" - if (this.graph) { - ctx.fillText("T: " + this.graph.globaltime.toFixed(2) + "s", 5, 13 * 1) - ctx.fillText("I: " + this.graph.iteration, 5, 13 * 2) - ctx.fillText("N: " + this.graph._nodes.length + " [" + this.visible_nodes.length + "]", 5, 13 * 3) - ctx.fillText("V: " + this.graph._version, 5, 13 * 4) - ctx.fillText("FPS:" + this.fps.toFixed(2), 5, 13 * 5) - } else { - ctx.fillText("No graph selected", 5, 13 * 1) - } - ctx.restore() - } - /** - * draws the back canvas (the one containing the background and the connections) - **/ - drawBackCanvas(): void { - const canvas = this.bgcanvas - if (canvas.width != this.canvas.width || - canvas.height != this.canvas.height) { - canvas.width = this.canvas.width - canvas.height = this.canvas.height - } - - if (!this.bgctx) { - this.bgctx = this.bgcanvas.getContext("2d") - } - const ctx = this.bgctx - // TODO: Remove this - // @ts-expect-error - if (ctx.start) ctx.start() - - const viewport = this.viewport || [0, 0, ctx.canvas.width, ctx.canvas.height] - - //clear - if (this.clear_background) { - ctx.clearRect(viewport[0], viewport[1], viewport[2], viewport[3]) - } - - //show subgraph stack header - if (this._graph_stack?.length) { - ctx.save() - const subgraph_node = this.graph._subgraph_node - ctx.strokeStyle = subgraph_node.bgcolor - ctx.lineWidth = 10 - ctx.strokeRect(1, 1, canvas.width - 2, canvas.height - 2) - ctx.lineWidth = 1 - ctx.font = "40px Arial" - ctx.textAlign = "center" - ctx.fillStyle = subgraph_node.bgcolor || "#AAA" - let title = "" - for (let i = 1; i < this._graph_stack.length; ++i) { - title += - this._graph_stack[i]._subgraph_node.getTitle() + " >> " - } - ctx.fillText( - title + subgraph_node.getTitle(), - canvas.width * 0.5, - 40 - ) - ctx.restore() - } - - const bg_already_painted = this.onRenderBackground - ? this.onRenderBackground(canvas, ctx) - : false - - //reset in case of error - if (!this.viewport) { - const scale = window.devicePixelRatio - ctx.restore() - ctx.setTransform(scale, 0, 0, scale, 0, 0) - } - this.visible_links.length = 0 - - if (this.graph) { - //apply transformations - ctx.save() - this.ds.toCanvasContext(ctx) - - //render BG - if (this.ds.scale < 1.5 && !bg_already_painted && this.clear_background_color) { - ctx.fillStyle = this.clear_background_color - ctx.fillRect( - this.visible_area[0], - this.visible_area[1], - this.visible_area[2], - this.visible_area[3] - ) - } - - if (this.background_image && - this.ds.scale > 0.5 && - !bg_already_painted) { - if (this.zoom_modify_alpha) { - ctx.globalAlpha = - (1.0 - 0.5 / this.ds.scale) * this.editor_alpha - } else { - ctx.globalAlpha = this.editor_alpha - } - ctx.imageSmoothingEnabled = false - if (!this._bg_img || - this._bg_img.name != this.background_image) { - this._bg_img = new Image() - this._bg_img.name = this.background_image - this._bg_img.src = this.background_image - const that = this - this._bg_img.onload = function () { - that.draw(true, true) - } - } - - let pattern = this._pattern - if (pattern == null && this._bg_img.width > 0) { - pattern = ctx.createPattern(this._bg_img, "repeat") - this._pattern_img = this._bg_img - this._pattern = pattern - } - - // NOTE: This ridiculous kludge provides a significant performance increase when rendering many large (> canvas width) paths in HTML canvas. - // I could find no documentation or explanation. Requires that the BG image is set. - if (pattern) { - ctx.fillStyle = pattern - ctx.fillRect( - this.visible_area[0], - this.visible_area[1], - this.visible_area[2], - this.visible_area[3] - ) - ctx.fillStyle = "transparent" - } - - ctx.globalAlpha = 1.0 - ctx.imageSmoothingEnabled = true - } - - //groups - if (this.graph._groups.length) { - this.drawGroups(canvas, ctx) - } - - this.onDrawBackground?.(ctx, this.visible_area) - - //DEBUG: show clipping area - //ctx.fillStyle = "red"; - //ctx.fillRect( this.visible_area[0] + 10, this.visible_area[1] + 10, this.visible_area[2] - 20, this.visible_area[3] - 20); - //bg - if (this.render_canvas_border) { - ctx.strokeStyle = "#235" - ctx.strokeRect(0, 0, canvas.width, canvas.height) - } - - if (this.render_connections_shadows) { - ctx.shadowColor = "#000" - ctx.shadowOffsetX = 0 - ctx.shadowOffsetY = 0 - ctx.shadowBlur = 6 - } else { - ctx.shadowColor = "rgba(0,0,0,0)" - } - - //draw connections - this.drawConnections(ctx) - - ctx.shadowColor = "rgba(0,0,0,0)" - - //restore state - ctx.restore() - } - - // TODO: Remove this - // @ts-expect-error - ctx.finish?.() - - this.dirty_bgcanvas = false - // Forces repaint of the front canvas. - this.dirty_canvas = true - } - /** - * draws the given node inside the canvas - **/ - drawNode(node: LGraphNode, ctx: CanvasRenderingContext2D): void { - this.current_node = node - - const color = node.color || node.constructor.color || LiteGraph.NODE_DEFAULT_COLOR - let bgcolor = node.bgcolor || node.constructor.bgcolor || LiteGraph.NODE_DEFAULT_BGCOLOR - - const low_quality = this.ds.scale < 0.6 //zoomed out - const editor_alpha = this.editor_alpha - ctx.globalAlpha = editor_alpha - - if (this.render_shadows && !low_quality) { - ctx.shadowColor = LiteGraph.DEFAULT_SHADOW_COLOR - ctx.shadowOffsetX = 2 * this.ds.scale - ctx.shadowOffsetY = 2 * this.ds.scale - ctx.shadowBlur = 3 * this.ds.scale - } else { - ctx.shadowColor = "transparent" - } - - //custom draw collapsed method (draw after shadows because they are affected) - if (node.flags.collapsed && node.onDrawCollapsed?.(ctx, this) == true) - return - - //clip if required (mask) - const shape = node._shape || RenderShape.BOX - const size = LGraphCanvas.#temp_vec2 - LGraphCanvas.#temp_vec2.set(node.size) - const horizontal = node.horizontal // || node.flags.horizontal; - - if (node.flags.collapsed) { - ctx.font = this.inner_text_font - const title = node.getTitle ? node.getTitle() : node.title - if (title != null) { - node._collapsed_width = Math.min( - node.size[0], - ctx.measureText(title).width + - LiteGraph.NODE_TITLE_HEIGHT * 2 - ) //LiteGraph.NODE_COLLAPSED_WIDTH; - size[0] = node._collapsed_width - size[1] = 0 - } - } - - if (node.clip_area) { - //Start clipping - ctx.save() - ctx.beginPath() - if (shape == RenderShape.BOX) { - ctx.rect(0, 0, size[0], size[1]) - } else if (shape == RenderShape.ROUND) { - ctx.roundRect(0, 0, size[0], size[1], [10]) - } else if (shape == RenderShape.CIRCLE) { - ctx.arc( - size[0] * 0.5, - size[1] * 0.5, - size[0] * 0.5, - 0, - Math.PI * 2 - ) - } - ctx.clip() - } - - //draw shape - if (node.has_errors) { - bgcolor = "red" - } - this.drawNodeShape( - node, - ctx, - size, - color, - bgcolor, - node.selected - ) - - if (!low_quality) { - node.drawBadges(ctx) - } - - ctx.shadowColor = "transparent" - - //draw foreground - node.onDrawForeground?.(ctx, this, this.canvas) - - //connection slots - ctx.textAlign = horizontal ? "center" : "left" - ctx.font = this.inner_text_font - - const render_text = !low_quality - const highlightColour = LiteGraph.NODE_TEXT_HIGHLIGHT_COLOR ?? LiteGraph.NODE_SELECTED_TITLE_COLOR ?? LiteGraph.NODE_TEXT_COLOR - - const out_slot = this.connecting_links?.[0]?.output - const in_slot = this.connecting_links?.[0]?.input - ctx.lineWidth = 1 - - let max_y = 0 - const slot_pos = new Float32Array(2) //to reuse - - //render inputs and outputs - if (!node.flags.collapsed) { - //input connection slots - if (node.inputs) { - for (let i = 0; i < node.inputs.length; i++) { - const slot = node.inputs[i] - - const slot_type = slot.type - - //change opacity of incompatible slots when dragging a connection - const isValid = !this.connecting_links || (out_slot && LiteGraph.isValidConnection(slot.type, out_slot.type)) - const highlight = isValid && node.mouseOver?.inputId === i - const label_color = highlight ? highlightColour : LiteGraph.NODE_TEXT_COLOR - ctx.globalAlpha = isValid ? editor_alpha : 0.4 * editor_alpha - - ctx.fillStyle = - slot.link != null - ? slot.color_on || - this.default_connection_color_byType[slot_type] || - this.default_connection_color.input_on - : slot.color_off || - this.default_connection_color_byTypeOff[slot_type] || - this.default_connection_color_byType[slot_type] || - this.default_connection_color.input_off - - const pos = node.getConnectionPos(true, i, slot_pos) - pos[0] -= node.pos[0] - pos[1] -= node.pos[1] - if (max_y < pos[1] + LiteGraph.NODE_SLOT_HEIGHT * 0.5) { - max_y = pos[1] + LiteGraph.NODE_SLOT_HEIGHT * 0.5 - } - - drawSlot(ctx, slot, pos, { - horizontal, - low_quality, - render_text, - label_color, - label_position: LabelPosition.Right, - // Input slot is not stroked. - do_stroke: false, - highlight, - }) - } - } - - //output connection slots - ctx.textAlign = horizontal ? "center" : "right" - ctx.strokeStyle = "black" - if (node.outputs) { - for (let i = 0; i < node.outputs.length; i++) { - const slot = node.outputs[i] - - const slot_type = slot.type - - //change opacity of incompatible slots when dragging a connection - const isValid = !this.connecting_links || (in_slot && LiteGraph.isValidConnection(slot_type, in_slot.type)) - const highlight = isValid && node.mouseOver?.outputId === i - const label_color = highlight ? highlightColour : LiteGraph.NODE_TEXT_COLOR - ctx.globalAlpha = isValid ? editor_alpha : 0.4 * editor_alpha - - const pos = node.getConnectionPos(false, i, slot_pos) - pos[0] -= node.pos[0] - pos[1] -= node.pos[1] - if (max_y < pos[1] + LiteGraph.NODE_SLOT_HEIGHT * 0.5) { - max_y = pos[1] + LiteGraph.NODE_SLOT_HEIGHT * 0.5 - } - - ctx.fillStyle = - slot.links && slot.links.length - ? slot.color_on || - this.default_connection_color_byType[slot_type] || - this.default_connection_color.output_on - : slot.color_off || - this.default_connection_color_byTypeOff[slot_type] || - this.default_connection_color_byType[slot_type] || - this.default_connection_color.output_off - - drawSlot(ctx, slot, pos, { - horizontal, - low_quality, - render_text, - label_color, - label_position: LabelPosition.Left, - do_stroke: true, - highlight, - }) - } - } - - ctx.textAlign = "left" - ctx.globalAlpha = 1 - - if (node.widgets) { - let widgets_y = max_y - if (horizontal || node.widgets_up) { - widgets_y = 2 - } - if (node.widgets_start_y != null) - widgets_y = node.widgets_start_y - this.drawNodeWidgets( - node, - widgets_y, - ctx, - this.node_widget && this.node_widget[0] == node - ? this.node_widget[1] - : null - ) - } - } else if (this.render_collapsed_slots) { - //if collapsed - let input_slot = null - let output_slot = null - let slot - - //get first connected slot to render - if (node.inputs) { - for (let i = 0; i < node.inputs.length; i++) { - slot = node.inputs[i] - if (slot.link == null) { - continue - } - input_slot = slot - break - } - } - if (node.outputs) { - for (let i = 0; i < node.outputs.length; i++) { - slot = node.outputs[i] - if (!slot.links || !slot.links.length) { - continue - } - output_slot = slot - } - } - - if (input_slot) { - let x = 0 - let y = LiteGraph.NODE_TITLE_HEIGHT * -0.5 //center - if (horizontal) { - x = node._collapsed_width * 0.5 - y = -LiteGraph.NODE_TITLE_HEIGHT - } - ctx.fillStyle = "#686" - ctx.beginPath() - if (slot.type === LiteGraph.EVENT || - slot.shape === RenderShape.BOX) { - ctx.rect(x - 7 + 0.5, y - 4, 14, 8) - } else if (slot.shape === RenderShape.ARROW) { - ctx.moveTo(x + 8, y) - ctx.lineTo(x + -4, y - 4) - ctx.lineTo(x + -4, y + 4) - ctx.closePath() - } else { - ctx.arc(x, y, 4, 0, Math.PI * 2) - } - ctx.fill() - } - - if (output_slot) { - let x = node._collapsed_width - let y = LiteGraph.NODE_TITLE_HEIGHT * -0.5 //center - if (horizontal) { - x = node._collapsed_width * 0.5 - y = 0 - } - ctx.fillStyle = "#686" - ctx.strokeStyle = "black" - ctx.beginPath() - if (slot.type === LiteGraph.EVENT || - slot.shape === RenderShape.BOX) { - ctx.rect(x - 7 + 0.5, y - 4, 14, 8) - } else if (slot.shape === RenderShape.ARROW) { - ctx.moveTo(x + 6, y) - ctx.lineTo(x - 6, y - 4) - ctx.lineTo(x - 6, y + 4) - ctx.closePath() - } else { - ctx.arc(x, y, 4, 0, Math.PI * 2) - } - ctx.fill() - //ctx.stroke(); - } - } - - if (node.clip_area) { - ctx.restore() - } - - ctx.globalAlpha = 1.0 - } - - /** - * Draws the link mouseover effect and tooltip. - * @param ctx Canvas 2D context to draw on - * @param link The link to render the mouseover effect for - * @remarks - * Called against {@link LGraphCanvas.over_link_center}. - * @todo Split tooltip from hover, so it can be drawn / eased separately - */ - drawLinkTooltip(ctx: CanvasRenderingContext2D, link: LinkSegment): void { - const pos = link._pos - ctx.fillStyle = "black" - ctx.beginPath() - if (this.linkMarkerShape === LinkMarkerShape.Arrow) { - const transform = ctx.getTransform() - ctx.translate(pos[0], pos[1]) - if (Number.isFinite(link._centreAngle)) ctx.rotate(link._centreAngle) - ctx.moveTo(-2, -3) - ctx.lineTo(+4, 0) - ctx.lineTo(-2, +3) - ctx.setTransform(transform) - } else if (this.linkMarkerShape == null || this.linkMarkerShape === LinkMarkerShape.Circle) { - ctx.arc(pos[0], pos[1], 3, 0, Math.PI * 2) - } - ctx.fill() - - // @ts-expect-error TODO: Better value typing - const data = link.data - if (data == null) return - - // @ts-expect-error TODO: Better value typing - if (this.onDrawLinkTooltip?.(ctx, link, this) == true) - return - - let text: string = null - - if (typeof data === "number") - text = data.toFixed(2) - else if (typeof data === "string") - text = "\"" + data + "\"" - else if (typeof data === "boolean") - text = String(data) - else if (data.toToolTip) - text = data.toToolTip() - else - text = "[" + data.constructor.name + "]" - - if (text == null) return - - // Hard-coded tooltip limit - text = text.substring(0, 30) - - ctx.font = "14px Courier New" - const info = ctx.measureText(text) - const w = info.width + 20 - const h = 24 - ctx.shadowColor = "black" - ctx.shadowOffsetX = 2 - ctx.shadowOffsetY = 2 - ctx.shadowBlur = 3 - ctx.fillStyle = "#454" - ctx.beginPath() - ctx.roundRect(pos[0] - w * 0.5, pos[1] - 15 - h, w, h, [3]) - ctx.moveTo(pos[0] - 10, pos[1] - 15) - ctx.lineTo(pos[0] + 10, pos[1] - 15) - ctx.lineTo(pos[0], pos[1] - 5) - ctx.fill() - ctx.shadowColor = "transparent" - ctx.textAlign = "center" - ctx.fillStyle = "#CEC" - ctx.fillText(text, pos[0], pos[1] - 15 - h * 0.3) - } - /** - * Draws the shape of the given node on the canvas - * @param node The node to draw - * @param ctx 2D canvas rendering context used to draw - * @param size Size of the background to draw, in graph units. Differs from node size if collapsed, etc. - * @param fgcolor Foreground colour - used for text - * @param bgcolor Background colour of the node - * @param selected Whether to render the node as selected. Likely to be removed in future, as current usage is simply the selected property of the node. - * @param mouse_over Deprecated - */ - drawNodeShape( - node: LGraphNode, - ctx: CanvasRenderingContext2D, - size: Size, - fgcolor: CanvasColour, - bgcolor: CanvasColour, - selected: boolean - ): void { - // Rendering options - ctx.strokeStyle = fgcolor - ctx.fillStyle = bgcolor - - const title_height = LiteGraph.NODE_TITLE_HEIGHT - const low_quality = this.ds.scale < 0.5 - - const shape = node._shape || node.constructor.shape || RenderShape.ROUND - const title_mode = node.constructor.title_mode - - const render_title = title_mode == TitleMode.TRANSPARENT_TITLE || title_mode == TitleMode.NO_TITLE - ? false - : true - - // Normalised node dimensions - const area = LGraphCanvas.#tmp_area - node.measure(area) - area[0] -= node.pos[0] - area[1] -= node.pos[1] - - const old_alpha = ctx.globalAlpha - - // Draw node background (shape) - { - ctx.beginPath() - if (shape == RenderShape.BOX || low_quality) { - ctx.fillRect(area[0], area[1], area[2], area[3]) - } else if (shape == RenderShape.ROUND || - shape == RenderShape.CARD) { - ctx.roundRect( - area[0], - area[1], - area[2], - area[3], - shape == RenderShape.CARD ? [this.round_radius, this.round_radius, 0, 0] : [this.round_radius] - ) - } else if (shape == RenderShape.CIRCLE) { - ctx.arc( - size[0] * 0.5, - size[1] * 0.5, - size[0] * 0.5, - 0, - Math.PI * 2 - ) - } - ctx.fill() - - //separator - if (!node.flags.collapsed && render_title) { - ctx.shadowColor = "transparent" - ctx.fillStyle = "rgba(0,0,0,0.2)" - ctx.fillRect(0, -1, area[2], 2) - } - } - ctx.shadowColor = "transparent" - - node.onDrawBackground?.(ctx, this, this.canvas, this.graph_mouse) - - //title bg (remember, it is rendered ABOVE the node) - if (render_title || title_mode == TitleMode.TRANSPARENT_TITLE) { - //title bar - if (node.onDrawTitleBar) { - node.onDrawTitleBar(ctx, title_height, size, this.ds.scale, fgcolor) - } else if ( - title_mode != TitleMode.TRANSPARENT_TITLE && - (node.constructor.title_color || this.render_title_colored) - ) { - const title_color = node.constructor.title_color || fgcolor - - if (node.flags.collapsed) { - ctx.shadowColor = LiteGraph.DEFAULT_SHADOW_COLOR - } - - //* gradient test - if (this.use_gradients) { - // TODO: This feature may not have been completed. Could finish or remove. - // Original impl. may cause CanvasColour to be used as index key. Also, colour requires validation before blindly passing on. - // @ts-expect-error Fix or remove gradient feature - let grad = LGraphCanvas.gradients[title_color] - if (!grad) { - // @ts-expect-error Fix or remove gradient feature - grad = LGraphCanvas.gradients[title_color] = ctx.createLinearGradient(0, 0, 400, 0) - grad.addColorStop(0, title_color) - grad.addColorStop(1, "#000") - } - ctx.fillStyle = grad - } else { - ctx.fillStyle = title_color - } - - //ctx.globalAlpha = 0.5 * old_alpha; - ctx.beginPath() - if (shape == RenderShape.BOX || low_quality) { - ctx.rect(0, -title_height, size[0], title_height) - } else if (shape == RenderShape.ROUND || shape == RenderShape.CARD) { - ctx.roundRect( - 0, - -title_height, - size[0], - title_height, - node.flags.collapsed ? [this.round_radius] : [this.round_radius, this.round_radius, 0, 0] - ) - } - ctx.fill() - ctx.shadowColor = "transparent" - } - - let colState: string | boolean = false - if (LiteGraph.node_box_coloured_by_mode) { - if (LiteGraph.NODE_MODES_COLORS[node.mode]) { - colState = LiteGraph.NODE_MODES_COLORS[node.mode] - } - } - if (LiteGraph.node_box_coloured_when_on) { - colState = node.action_triggered ? "#FFF" : (node.execute_triggered ? "#AAA" : colState) - } - - //title box - const box_size = 10 - if (node.onDrawTitleBox) { - node.onDrawTitleBox(ctx, title_height, size, this.ds.scale) - } else if (shape == RenderShape.ROUND || - shape == RenderShape.CIRCLE || - shape == RenderShape.CARD) { - if (low_quality) { - ctx.fillStyle = "black" - ctx.beginPath() - ctx.arc( - title_height * 0.5, - title_height * -0.5, - box_size * 0.5 + 1, - 0, - Math.PI * 2 - ) - ctx.fill() - } - - ctx.fillStyle = node.boxcolor || colState || LiteGraph.NODE_DEFAULT_BOXCOLOR - if (low_quality) - ctx.fillRect(title_height * 0.5 - box_size * 0.5, title_height * -0.5 - box_size * 0.5, box_size, box_size) - - else { - ctx.beginPath() - ctx.arc( - title_height * 0.5, - title_height * -0.5, - box_size * 0.5, - 0, - Math.PI * 2 - ) - ctx.fill() - } - } else { - if (low_quality) { - ctx.fillStyle = "black" - ctx.fillRect( - (title_height - box_size) * 0.5 - 1, - (title_height + box_size) * -0.5 - 1, - box_size + 2, - box_size + 2 - ) - } - ctx.fillStyle = node.boxcolor || colState || LiteGraph.NODE_DEFAULT_BOXCOLOR - ctx.fillRect( - (title_height - box_size) * 0.5, - (title_height + box_size) * -0.5, - box_size, - box_size - ) - } - ctx.globalAlpha = old_alpha - - //title text - if (node.onDrawTitleText) { - node.onDrawTitleText( - ctx, - title_height, - size, - this.ds.scale, - this.title_text_font, - selected - ) - } - if (!low_quality) { - ctx.font = this.title_text_font - const title = String(node.getTitle()) + (node.pinned ? "📌" : "") - if (title) { - if (selected) { - ctx.fillStyle = LiteGraph.NODE_SELECTED_TITLE_COLOR - } else { - ctx.fillStyle = - node.constructor.title_text_color || this.node_title_color - } - if (node.flags.collapsed) { - ctx.textAlign = "left" - // const measure = ctx.measureText(title) - ctx.fillText( - title.substr(0, 20), //avoid urls too long - title_height, // + measure.width * 0.5, - LiteGraph.NODE_TITLE_TEXT_Y - title_height - ) - ctx.textAlign = "left" - } else { - ctx.textAlign = "left" - ctx.fillText( - title, - title_height, - LiteGraph.NODE_TITLE_TEXT_Y - title_height - ) - } - } - } - - //subgraph box - if (!node.flags.collapsed && node.subgraph && !node.skip_subgraph_button) { - const w = LiteGraph.NODE_TITLE_HEIGHT - const x = node.size[0] - w - const over = LiteGraph.isInsideRectangle(this.graph_mouse[0] - node.pos[0], this.graph_mouse[1] - node.pos[1], x + 2, -w + 2, w - 4, w - 4) - ctx.fillStyle = over ? "#888" : "#555" - if (shape == RenderShape.BOX || low_quality) { - ctx.fillRect(x + 2, -w + 2, w - 4, w - 4) - } - else { - ctx.beginPath() - ctx.roundRect(x + 2, -w + 2, w - 4, w - 4, [4]) - ctx.fill() - } - ctx.fillStyle = "#333" - ctx.beginPath() - ctx.moveTo(x + w * 0.2, -w * 0.6) - ctx.lineTo(x + w * 0.8, -w * 0.6) - ctx.lineTo(x + w * 0.5, -w * 0.3) - ctx.fill() - } - - //custom title render - node.onDrawTitle?.(ctx) - } - - //render selection marker - if (selected) { - node.onBounding?.(area) - - this.drawSelectionBounding( - ctx, - area, - { - shape, - title_height, - title_mode, - fgcolor, - collapsed: node.flags?.collapsed - } - ) - } - - // these counter helps in conditioning drawing based on if the node has been executed or an action occurred - if (node.execute_triggered > 0) node.execute_triggered-- - if (node.action_triggered > 0) node.action_triggered-- - } - - /** - * Draws the selection bounding of an area. - * @param {CanvasRenderingContext2D} ctx - * @param {Vector4} area - * @param {{ - * shape: LiteGraph.Shape, - * title_height: number, - * title_mode: LiteGraph.TitleMode, - * fgcolor: string, - * padding: number, - * }} options - */ - drawSelectionBounding( - ctx: CanvasRenderingContext2D, - area: Rect, - { - shape = RenderShape.BOX, - title_height = LiteGraph.NODE_TITLE_HEIGHT, - title_mode = TitleMode.NORMAL_TITLE, - fgcolor = LiteGraph.NODE_BOX_OUTLINE_COLOR, - padding = 6, - collapsed = false, - }: IDrawSelectionBoundingOptions = {} - ) { - // Adjust area if title is transparent - if (title_mode === TitleMode.TRANSPARENT_TITLE) { - area[1] -= title_height - area[3] += title_height - } - - // Set up context - ctx.lineWidth = 1 - ctx.globalAlpha = 0.8 - ctx.beginPath() - - // Draw shape based on type - const [x, y, width, height] = area - switch (shape) { - case RenderShape.BOX: { - ctx.rect(x - padding, y - padding, width + 2 * padding, height + 2 * padding) - break - } - case RenderShape.ROUND: - case RenderShape.CARD: { - const radius = this.round_radius * 2 - const isCollapsed = shape === RenderShape.CARD && collapsed - const cornerRadii = isCollapsed || shape === RenderShape.ROUND ? [radius] : [radius, 2, radius, 2] - ctx.roundRect(x - padding, y - padding, width + 2 * padding, height + 2 * padding, cornerRadii) - break - } - case RenderShape.CIRCLE: { - const centerX = x + width / 2 - const centerY = y + height / 2 - const radius = Math.max(width, height) / 2 + padding - ctx.arc(centerX, centerY, radius, 0, Math.PI * 2) - break - } - } - - // Stroke the shape - ctx.strokeStyle = LiteGraph.NODE_BOX_OUTLINE_COLOR - ctx.stroke() - - // Reset context - ctx.strokeStyle = fgcolor - ctx.globalAlpha = 1 - } - - /** - * Draws a snap guide for a {@link Positionable} item. - * - * Initial design was a simple white rectangle representing the location the - * item would land if dropped. - * @param ctx The 2D canvas context to draw on - * @param item The item to draw a snap guide for - * @param snapTo The grid size to snap to - * @todo Update to align snapping with boundingRect - * @todo Shapes - */ - drawSnapGuide(ctx: CanvasRenderingContext2D, item: Positionable, shape = RenderShape.ROUND) { - const snapGuide = LGraphCanvas.#temp - snapGuide.set(item.boundingRect) - - // Not all items have pos equal to top-left of bounds - const { pos } = item - const offsetX = pos[0] - snapGuide[0] - const offsetY = pos[1] - snapGuide[1] - - // Normalise boundingRect to pos to snap - snapGuide[0] += offsetX - snapGuide[1] += offsetY - snapPoint(snapGuide, this.#snapToGrid) - snapGuide[0] -= offsetX - snapGuide[1] -= offsetY - - const { globalAlpha } = ctx - ctx.globalAlpha = 1 - ctx.beginPath() - const [x, y, w, h] = snapGuide - if (shape === RenderShape.CIRCLE) { - const midX = x + (w * 0.5) - const midY = y + (h * 0.5) - const radius = Math.min(w * 0.5, h * 0.5) - ctx.arc(midX, midY, radius, 0, Math.PI * 2) - } else { - ctx.rect(x, y, w, h) - } - - ctx.lineWidth = 0.5 - ctx.strokeStyle = "#FFFFFF66" - ctx.fillStyle = "#FFFFFF22" - ctx.fill() - ctx.stroke() - ctx.globalAlpha = globalAlpha - } - - drawConnections(ctx: CanvasRenderingContext2D): void { - const rendered = this.renderedPaths - rendered.clear() - const visibleReroutes: Reroute[] = [] - - const now = LiteGraph.getTime() - const visible_area = this.visible_area - LGraphCanvas.#margin_area[0] = visible_area[0] - 20 - LGraphCanvas.#margin_area[1] = visible_area[1] - 20 - LGraphCanvas.#margin_area[2] = visible_area[2] + 40 - LGraphCanvas.#margin_area[3] = visible_area[3] + 40 - - //draw connections - ctx.lineWidth = this.connections_width - - ctx.fillStyle = "#AAA" - ctx.strokeStyle = "#AAA" - ctx.globalAlpha = this.editor_alpha - //for every node - const nodes = this.graph._nodes - for (let n = 0, l = nodes.length; n < l; ++n) { - const node = nodes[n] - //for every input (we render just inputs because it is easier as every slot can only have one input) - if (!node.inputs || !node.inputs.length) continue - - for (let i = 0; i < node.inputs.length; ++i) { - const input = node.inputs[i] - if (!input || input.link == null) continue - - const link_id = input.link - const link = this.graph._links.get(link_id) - if (!link) continue - - //find link info - const start_node = this.graph.getNodeById(link.origin_id) - if (start_node == null) continue - - const outputId = link.origin_slot - const start_node_slotpos: Point = outputId == -1 - ? [start_node.pos[0] + 10, start_node.pos[1] + 10] - : start_node.getConnectionPos(false, outputId, LGraphCanvas.#tempA) - - const end_node_slotpos = node.getConnectionPos(true, i, LGraphCanvas.#tempB) - - // Get all points this link passes through - const reroutes = this.reroutesEnabled ? LLink.getReroutes(this.graph, link) : [] - const points = [start_node_slotpos, ...reroutes.map(x => x.pos), end_node_slotpos] - - // Bounding box of all points (bezier overshoot on long links will be cut) - const pointsX = points.map(x => x[0]) - const pointsY = points.map(x => x[1]) - LGraphCanvas.#link_bounding[0] = Math.min(...pointsX) - LGraphCanvas.#link_bounding[1] = Math.min(...pointsY) - LGraphCanvas.#link_bounding[2] = Math.max(...pointsX) - LGraphCanvas.#link_bounding[0] - LGraphCanvas.#link_bounding[3] = Math.max(...pointsY) - LGraphCanvas.#link_bounding[1] - - //skip links outside of the visible area of the canvas - if (!overlapBounding(LGraphCanvas.#link_bounding, LGraphCanvas.#margin_area)) - continue - - const start_slot = start_node.outputs[outputId] - const end_slot = node.inputs[i] - if (!start_slot || !end_slot) - continue - const start_dir = start_slot.dir || - (start_node.horizontal ? LinkDirection.DOWN : LinkDirection.RIGHT) - const end_dir = end_slot.dir || - (node.horizontal ? LinkDirection.UP : LinkDirection.LEFT) - - // Has reroutes - if (reroutes.length) { - let startControl: Point - - const l = reroutes.length - for (let j = 0; j < l; j++) { - const reroute = reroutes[j] - - // Only render once - if (!rendered.has(reroute)) { - rendered.add(reroute) - visibleReroutes.push(reroute) - reroute._colour = link.color || - LGraphCanvas.link_type_colors[link.type] || - this.default_link_color - - const prevReroute = this.graph.reroutes.get(reroute.parentId) - const startPos = prevReroute?.pos ?? start_node_slotpos - reroute.calculateAngle(this.last_draw_time, this.graph, startPos) - - this.renderLink( - ctx, - startPos, - reroute.pos, - link, - false, - 0, - null, - start_dir, - end_dir, - { - startControl, - endControl: reroute.controlPoint, - reroute, - }, - ) - } - - // Calculate start control for the next iter control point - const nextPos = reroutes[j + 1]?.pos ?? end_node_slotpos - const dist = Math.min(80, distance(reroute.pos, nextPos) * 0.25) - startControl = [dist * reroute.cos, dist * reroute.sin] - } - - // Render final link segment - this.renderLink( - ctx, - points.at(-2), - points.at(-1), - link, - false, - 0, - null, - start_dir, - end_dir, - { startControl }, - ) - } else { - this.renderLink( - ctx, - start_node_slotpos, - end_node_slotpos, - link, - false, - 0, - null, - start_dir, - end_dir - ) - } - rendered.add(link) - - //event triggered rendered on top - if (link && link._last_time && now - link._last_time < 1000) { - const f = 2.0 - (now - link._last_time) * 0.002 - const tmp = ctx.globalAlpha - ctx.globalAlpha = tmp * f - this.renderLink( - ctx, - start_node_slotpos, - end_node_slotpos, - link, - true, - f, - "white", - start_dir, - end_dir - ) - ctx.globalAlpha = tmp - } - } - } - - // Render the reroute circles - for (const reroute of visibleReroutes) { - if (this.#snapToGrid && this.isDragging && this.selectedItems.has(reroute)) - this.drawSnapGuide(ctx, reroute, RenderShape.CIRCLE) - reroute.draw(ctx) - } - ctx.globalAlpha = 1 - } - - /** - * draws a link between two points - * @param ctx Canvas 2D rendering context - * @param {vec2} a start pos - * @param {vec2} b end pos - * @param {Object} link the link object with all the link info - * @param {boolean} skip_border ignore the shadow of the link - * @param {boolean} flow show flow animation (for events) - * @param {string} color the color for the link - * @param {LinkDirection} start_dir the direction enum - * @param {LinkDirection} end_dir the direction enum - * @param {number} num_sublines number of sublines (useful to represent vec3 or rgb) - */ - renderLink( - ctx: CanvasRenderingContext2D, - a: ReadOnlyPoint, - b: ReadOnlyPoint, - link: LLink, - skip_border: boolean, - flow: number, - color: CanvasColour, - start_dir: LinkDirection, - end_dir: LinkDirection, - { - startControl, - endControl, - reroute, - num_sublines = 1 - }: { - /** When defined, render data will be saved to this reroute instead of the {@link link}. */ - reroute?: Reroute - /** Offset of the bezier curve control point from {@link a point a} (output side) */ - startControl?: ReadOnlyPoint - /** Offset of the bezier curve control point from {@link b point b} (input side) */ - endControl?: ReadOnlyPoint - /** Number of sublines (useful to represent vec3 or rgb) @todo If implemented, refactor calculations out of the loop */ - num_sublines?: number - } = {}, - ): void { - if (link) this.visible_links.push(link) - - const linkColour = link != null && this.highlighted_links[link.id] - ? "#FFF" - : color || link?.color || LGraphCanvas.link_type_colors[link.type] || this.default_link_color - const startDir = start_dir || LinkDirection.RIGHT - const endDir = end_dir || LinkDirection.LEFT - - const dist = this.links_render_mode == LinkRenderType.SPLINE_LINK && (!endControl || !startControl) - ? distance(a, b) - : null - - // TODO: Subline code below was inserted in the wrong place - should be before this statement - if (this.render_connections_border && this.ds.scale > 0.6) { - ctx.lineWidth = this.connections_width + 4 - } - ctx.lineJoin = "round" - num_sublines ||= 1 - if (num_sublines > 1) ctx.lineWidth = 0.5 - - //begin line shape - const path = new Path2D() - - /** The link or reroute we're currently rendering */ - const linkSegment = reroute ?? link - if (linkSegment) linkSegment.path = path - - const innerA = LGraphCanvas.#lTempA - const innerB = LGraphCanvas.#lTempB - - /** Reference to {@link reroute._pos} if present, or {@link link._pos} if present. Caches the centre point of the link. */ - const pos: Point = linkSegment?._pos ?? [0, 0] - - for (let i = 0; i < num_sublines; i += 1) { - const offsety = (i - (num_sublines - 1) * 0.5) * 5 - innerA[0] = a[0] - innerA[1] = a[1] - innerB[0] = b[0] - innerB[1] = b[1] - - if (this.links_render_mode == LinkRenderType.SPLINE_LINK) { - if (endControl) { - innerB[0] = b[0] + endControl[0] - innerB[1] = b[1] + endControl[1] - } else { - this.#addSplineOffset(innerB, endDir, dist) - } - if (startControl) { - innerA[0] = a[0] + startControl[0] - innerA[1] = a[1] + startControl[1] - } else { - this.#addSplineOffset(innerA, startDir, dist) - } - path.moveTo(a[0], a[1] + offsety) - path.bezierCurveTo( - innerA[0], - innerA[1] + offsety, - innerB[0], - innerB[1] + offsety, - b[0], - b[1] + offsety - ) - - // Calculate centre point - findPointOnCurve(pos, a, b, innerA, innerB, 0.5) - - if (linkSegment && this.linkMarkerShape === LinkMarkerShape.Arrow) { - const justPastCentre = LGraphCanvas.#lTempC - findPointOnCurve(justPastCentre, a, b, innerA, innerB, 0.51) - - linkSegment._centreAngle = Math.atan2(justPastCentre[1] - pos[1], justPastCentre[0] - pos[0]) - } - } else if (this.links_render_mode == LinkRenderType.LINEAR_LINK) { - const l = 15 - switch (startDir) { - case LinkDirection.LEFT: - innerA[0] += -l - break - case LinkDirection.RIGHT: - innerA[0] += l - break - case LinkDirection.UP: - innerA[1] += -l - break - case LinkDirection.DOWN: - innerA[1] += l - break - } - switch (endDir) { - case LinkDirection.LEFT: - innerB[0] += -l - break - case LinkDirection.RIGHT: - innerB[0] += l - break - case LinkDirection.UP: - innerB[1] += -l - break - case LinkDirection.DOWN: - innerB[1] += l - break - } - path.moveTo(a[0], a[1] + offsety) - path.lineTo(innerA[0], innerA[1] + offsety) - path.lineTo(innerB[0], innerB[1] + offsety) - path.lineTo(b[0], b[1] + offsety) - - // Calculate centre point - pos[0] = (innerA[0] + innerB[0]) * 0.5 - pos[1] = (innerA[1] + innerB[1]) * 0.5 - - if (linkSegment && this.linkMarkerShape === LinkMarkerShape.Arrow) { - linkSegment._centreAngle = Math.atan2(innerB[1] - innerA[1], innerB[0] - innerA[0]) - } - } else if (this.links_render_mode == LinkRenderType.STRAIGHT_LINK) { - if (startDir == LinkDirection.RIGHT) { - innerA[0] += 10 - } else { - innerA[1] += 10 - } - if (endDir == LinkDirection.LEFT) { - innerB[0] -= 10 - } else { - innerB[1] -= 10 - } - const midX = (innerA[0] + innerB[0]) * 0.5 - - path.moveTo(a[0], a[1]) - path.lineTo(innerA[0], innerA[1]) - path.lineTo(midX, innerA[1]) - path.lineTo(midX, innerB[1]) - path.lineTo(innerB[0], innerB[1]) - path.lineTo(b[0], b[1]) - - // Calculate centre point - pos[0] = midX - pos[1] = (innerA[1] + innerB[1]) * 0.5 - - if (linkSegment && this.linkMarkerShape === LinkMarkerShape.Arrow) { - const diff = innerB[1] - innerA[1] - if (Math.abs(diff) < 4) linkSegment._centreAngle = 0 - else if (diff > 0) linkSegment._centreAngle = Math.PI * 0.5 - else linkSegment._centreAngle = -(Math.PI * 0.5) - } - } else { - return - } //unknown - } - - //rendering the outline of the connection can be a little bit slow - if (this.render_connections_border && - this.ds.scale > 0.6 && - !skip_border) { - ctx.strokeStyle = "rgba(0,0,0,0.5)" - ctx.stroke(path) - } - - ctx.lineWidth = this.connections_width - ctx.fillStyle = ctx.strokeStyle = linkColour - ctx.stroke(path) - - //render arrow in the middle - if (this.ds.scale >= 0.6 && - this.highquality_render && - linkSegment && - // TODO: Re-assess this usage - likely a workaround that linkSegment truthy check resolves - endDir != LinkDirection.CENTER) { - //render arrow - if (this.render_connection_arrows) { - //compute two points in the connection - const posA = this.computeConnectionPoint( - a, - b, - 0.25, - startDir, - endDir - ) - const posB = this.computeConnectionPoint( - a, - b, - 0.26, - startDir, - endDir - ) - const posC = this.computeConnectionPoint( - a, - b, - 0.75, - startDir, - endDir - ) - const posD = this.computeConnectionPoint( - a, - b, - 0.76, - startDir, - endDir - ) - - //compute the angle between them so the arrow points in the right direction - let angleA = 0 - let angleB = 0 - if (this.render_curved_connections) { - angleA = -Math.atan2(posB[0] - posA[0], posB[1] - posA[1]) - angleB = -Math.atan2(posD[0] - posC[0], posD[1] - posC[1]) - } else { - angleB = angleA = b[1] > a[1] ? 0 : Math.PI - } - - //render arrow - const transform = ctx.getTransform() - ctx.translate(posA[0], posA[1]) - ctx.rotate(angleA) - ctx.beginPath() - ctx.moveTo(-5, -3) - ctx.lineTo(0, +7) - ctx.lineTo(+5, -3) - ctx.fill() - ctx.setTransform(transform) - - ctx.translate(posC[0], posC[1]) - ctx.rotate(angleB) - ctx.beginPath() - ctx.moveTo(-5, -3) - ctx.lineTo(0, +7) - ctx.lineTo(+5, -3) - ctx.fill() - ctx.setTransform(transform) - } - - // Draw link centre marker - ctx.beginPath() - if (this.linkMarkerShape === LinkMarkerShape.Arrow) { - const transform = ctx.getTransform() - ctx.translate(pos[0], pos[1]) - ctx.rotate(linkSegment._centreAngle) - // The math is off, but it currently looks better in chromium - ctx.moveTo(-3.2, -5) - ctx.lineTo(+7, 0) - ctx.lineTo(-3.2, +5) - ctx.fill() - ctx.setTransform(transform) - } else if (this.linkMarkerShape == null || this.linkMarkerShape === LinkMarkerShape.Circle) { - ctx.arc(pos[0], pos[1], 5, 0, Math.PI * 2) - } - ctx.fill() - } - - //render flowing points - if (flow) { - ctx.fillStyle = linkColour - for (let i = 0; i < 5; ++i) { - const f = (LiteGraph.getTime() * 0.001 + i * 0.2) % 1 - const flowPos = this.computeConnectionPoint( - a, - b, - f, - startDir, - endDir - ) - ctx.beginPath() - ctx.arc(flowPos[0], flowPos[1], 5, 0, 2 * Math.PI) - ctx.fill() - } - } - } - - /** - * Finds a point along a spline represented by a to b, with spline endpoint directions dictacted by start_dir and end_dir. - * @param a Start point - * @param b End point - * @param t Time: distance between points (e.g 0.25 is 25% along the line) - * @param start_dir Spline start direction - * @param end_dir Spline end direction - * @returns The point at {@link t} distance along the spline a-b. - */ - computeConnectionPoint( - a: ReadOnlyPoint, - b: ReadOnlyPoint, - t: number, - start_dir: LinkDirection, - end_dir: LinkDirection - ): Point { - start_dir ||= LinkDirection.RIGHT - end_dir ||= LinkDirection.LEFT - - const dist = distance(a, b) - const pa: Point = [a[0], a[1]] - const pb: Point = [b[0], b[1]] - - this.#addSplineOffset(pa, start_dir, dist) - this.#addSplineOffset(pb, end_dir, dist) - - const c1 = (1 - t) * (1 - t) * (1 - t) - const c2 = 3 * ((1 - t) * (1 - t)) * t - const c3 = 3 * (1 - t) * (t * t) - const c4 = t * t * t - - const x = c1 * a[0] + c2 * pa[0] + c3 * pb[0] + c4 * b[0] - const y = c1 * a[1] + c2 * pa[1] + c3 * pb[1] + c4 * b[1] - return [x, y] - } - - /** - * Modifies an existing point, adding a single-axis offset. - * @param point The point to add the offset to - * @param direction The direction to add the offset in - * @param dist Distance to offset - * @param factor Distance is mulitplied by this value. Default: 0.25 - */ - #addSplineOffset(point: Point, direction: LinkDirection, dist: number, factor = 0.25): void { - switch (direction) { - case LinkDirection.LEFT: - point[0] += dist * -factor - break - case LinkDirection.RIGHT: - point[0] += dist * factor - break - case LinkDirection.UP: - point[1] += dist * -factor - break - case LinkDirection.DOWN: - point[1] += dist * factor - break - } - } - - drawExecutionOrder(ctx: CanvasRenderingContext2D): void { - ctx.shadowColor = "transparent" - ctx.globalAlpha = 0.25 - - ctx.textAlign = "center" - ctx.strokeStyle = "white" - ctx.globalAlpha = 0.75 - - const visible_nodes = this.visible_nodes - for (let i = 0; i < visible_nodes.length; ++i) { - const node = visible_nodes[i] - ctx.fillStyle = "black" - ctx.fillRect( - node.pos[0] - LiteGraph.NODE_TITLE_HEIGHT, - node.pos[1] - LiteGraph.NODE_TITLE_HEIGHT, - LiteGraph.NODE_TITLE_HEIGHT, - LiteGraph.NODE_TITLE_HEIGHT - ) - if (node.order == 0) { - ctx.strokeRect( - node.pos[0] - LiteGraph.NODE_TITLE_HEIGHT + 0.5, - node.pos[1] - LiteGraph.NODE_TITLE_HEIGHT + 0.5, - LiteGraph.NODE_TITLE_HEIGHT, - LiteGraph.NODE_TITLE_HEIGHT - ) - } - ctx.fillStyle = "#FFF" - ctx.fillText( - stringOrEmpty(node.order), - node.pos[0] + LiteGraph.NODE_TITLE_HEIGHT * -0.5, - node.pos[1] - 6 - ) - } - ctx.globalAlpha = 1 - } - /** - * draws the widgets stored inside a node - **/ - drawNodeWidgets(node: LGraphNode, - posY: number, - ctx: CanvasRenderingContext2D, - active_widget: IWidget) { - if (!node.widgets || !node.widgets.length) return 0 - const width = node.size[0] - const widgets = node.widgets - posY += 2 - const H = LiteGraph.NODE_WIDGET_HEIGHT - const show_text = this.ds.scale > 0.5 - ctx.save() - ctx.globalAlpha = this.editor_alpha - const background_color = LiteGraph.WIDGET_BGCOLOR - const text_color = LiteGraph.WIDGET_TEXT_COLOR - const secondary_text_color = LiteGraph.WIDGET_SECONDARY_TEXT_COLOR - const margin = 15 - - for (let i = 0; i < widgets.length; ++i) { - const w = widgets[i] - if (w.hidden || (w.advanced && !node.showAdvanced)) continue - const y = w.y || posY - const outline_color = w.advanced ? LiteGraph.WIDGET_ADVANCED_OUTLINE_COLOR : LiteGraph.WIDGET_OUTLINE_COLOR - - if (w === this.link_over_widget) { - ctx.fillStyle = this.default_connection_color_byType[this.link_over_widget_type] || - this.default_connection_color.input_on - - // Manually draw a slot next to the widget simulating an input - drawSlot(ctx, {}, [10, y + 10], {}) - } - - w.last_y = y - ctx.strokeStyle = outline_color - ctx.fillStyle = "#222" - ctx.textAlign = "left" - //ctx.lineWidth = 2; - if (w.disabled) - ctx.globalAlpha *= 0.5 - const widget_width = w.width || width - - switch (w.type) { - case "button": - ctx.fillStyle = background_color - if (w.clicked) { - ctx.fillStyle = "#AAA" - w.clicked = false - this.dirty_canvas = true - } - ctx.fillRect(margin, y, widget_width - margin * 2, H) - if (show_text && !w.disabled) - ctx.strokeRect(margin, y, widget_width - margin * 2, H) - if (show_text) { - ctx.textAlign = "center" - ctx.fillStyle = text_color - ctx.fillText(w.label || w.name, widget_width * 0.5, y + H * 0.7) - } - break - case "toggle": - ctx.textAlign = "left" - ctx.strokeStyle = outline_color - ctx.fillStyle = background_color - ctx.beginPath() - if (show_text) - ctx.roundRect(margin, y, widget_width - margin * 2, H, [H * 0.5]) - - else - ctx.rect(margin, y, widget_width - margin * 2, H) - ctx.fill() - if (show_text && !w.disabled) - ctx.stroke() - ctx.fillStyle = w.value ? "#89A" : "#333" - ctx.beginPath() - ctx.arc(widget_width - margin * 2, y + H * 0.5, H * 0.36, 0, Math.PI * 2) - ctx.fill() - if (show_text) { - ctx.fillStyle = secondary_text_color - const label = w.label || w.name - if (label != null) { - ctx.fillText(label, margin * 2, y + H * 0.7) - } - ctx.fillStyle = w.value ? text_color : secondary_text_color - ctx.textAlign = "right" - ctx.fillText( - w.value - ? w.options.on || "true" - : w.options.off || "false", - widget_width - 40, - y + H * 0.7 - ) - } - break - case "slider": { - ctx.fillStyle = background_color - ctx.fillRect(margin, y, widget_width - margin * 2, H) - const range = w.options.max - w.options.min - let nvalue = (w.value - w.options.min) / range - if (nvalue < 0.0) nvalue = 0.0 - if (nvalue > 1.0) nvalue = 1.0 - ctx.fillStyle = w.options.hasOwnProperty("slider_color") ? w.options.slider_color : (active_widget == w ? "#89A" : "#678") - ctx.fillRect(margin, y, nvalue * (widget_width - margin * 2), H) - if (show_text && !w.disabled) - ctx.strokeRect(margin, y, widget_width - margin * 2, H) - if (w.marker) { - let marker_nvalue = (w.marker - w.options.min) / range - if (marker_nvalue < 0.0) marker_nvalue = 0.0 - if (marker_nvalue > 1.0) marker_nvalue = 1.0 - ctx.fillStyle = w.options.hasOwnProperty("marker_color") ? w.options.marker_color : "#AA9" - ctx.fillRect(margin + marker_nvalue * (widget_width - margin * 2), y, 2, H) - } - if (show_text) { - ctx.textAlign = "center" - ctx.fillStyle = text_color - ctx.fillText( - w.label || w.name + " " + Number(w.value).toFixed( - w.options.precision != null - ? w.options.precision - : 3 - ), - widget_width * 0.5, - y + H * 0.7 - ) - } - break - } - case "number": - case "combo": - ctx.textAlign = "left" - ctx.strokeStyle = outline_color - ctx.fillStyle = background_color - ctx.beginPath() - if (show_text) - ctx.roundRect(margin, y, widget_width - margin * 2, H, [H * 0.5]) - - else - ctx.rect(margin, y, widget_width - margin * 2, H) - ctx.fill() - if (show_text) { - if (!w.disabled) - ctx.stroke() - ctx.fillStyle = text_color - if (!w.disabled) { - ctx.beginPath() - ctx.moveTo(margin + 16, y + 5) - ctx.lineTo(margin + 6, y + H * 0.5) - ctx.lineTo(margin + 16, y + H - 5) - ctx.fill() - ctx.beginPath() - ctx.moveTo(widget_width - margin - 16, y + 5) - ctx.lineTo(widget_width - margin - 6, y + H * 0.5) - ctx.lineTo(widget_width - margin - 16, y + H - 5) - ctx.fill() - } - ctx.fillStyle = secondary_text_color - ctx.fillText(w.label || w.name, margin * 2 + 5, y + H * 0.7) - ctx.fillStyle = text_color - ctx.textAlign = "right" - if (w.type == "number") { - ctx.fillText( - Number(w.value).toFixed( - w.options.precision !== undefined - ? w.options.precision - : 3 - ), - widget_width - margin * 2 - 20, - y + H * 0.7 - ) - } else { - let v = typeof w.value === "number" ? String(w.value) : w.value - if (w.options.values) { - let values = w.options.values - if (typeof values === "function") - // @ts-expect-error - values = values() - if (values && !Array.isArray(values)) - v = values[w.value] - } - const labelWidth = ctx.measureText(w.label || w.name).width + margin * 2 - const inputWidth = widget_width - margin * 4 - const availableWidth = inputWidth - labelWidth - const textWidth = ctx.measureText(v).width - if (textWidth > availableWidth) { - const ELLIPSIS = "\u2026" - const ellipsisWidth = ctx.measureText(ELLIPSIS).width - const charWidthAvg = ctx.measureText("a").width - if (availableWidth <= ellipsisWidth) { - v = "\u2024" // One dot leader - } else { - v = `${v}` - const overflowWidth = (textWidth + ellipsisWidth) - availableWidth - // Only first 3 characters need to be measured precisely - if (overflowWidth + charWidthAvg * 3 > availableWidth) { - const preciseRange = availableWidth + charWidthAvg * 3 - const preTruncateCt = Math.floor((preciseRange - ellipsisWidth) / charWidthAvg) - v = v.substr(0, preTruncateCt) - } - while (ctx.measureText(v).width + ellipsisWidth > availableWidth) { - v = v.substr(0, v.length - 1) - } - v += ELLIPSIS - } - } - ctx.fillText( - v, - widget_width - margin * 2 - 20, - y + H * 0.7 - ) - } - } - break - case "string": - case "text": - ctx.textAlign = "left" - ctx.strokeStyle = outline_color - ctx.fillStyle = background_color - ctx.beginPath() - - if (show_text) - ctx.roundRect(margin, y, widget_width - margin * 2, H, [H * 0.5]) - else - ctx.rect(margin, y, widget_width - margin * 2, H) - ctx.fill() - if (show_text) { - if (!w.disabled) - ctx.stroke() - ctx.save() - ctx.beginPath() - ctx.rect(margin, y, widget_width - margin * 2, H) - ctx.clip() - - //ctx.stroke(); - ctx.fillStyle = secondary_text_color - const label = w.label || w.name - if (label != null) - ctx.fillText(label, margin * 2, y + H * 0.7) - ctx.fillStyle = text_color - ctx.textAlign = "right" - ctx.fillText(String(w.value).substr(0, 30), widget_width - margin * 2, y + H * 0.7) //30 chars max - ctx.restore() - } - break - // Custom widgets - default: - w.draw?.(ctx, node, widget_width, y, H) - break - } - posY += (w.computeSize ? w.computeSize(widget_width)[1] : H) + 4 - ctx.globalAlpha = this.editor_alpha - - } - ctx.restore() - ctx.textAlign = "left" - } - - /** - * draws every group area in the background - **/ - drawGroups(canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D): void { - if (!this.graph) return - - const groups = this.graph._groups - - ctx.save() - ctx.globalAlpha = 0.5 * this.editor_alpha - const drawSnapGuides = this.#snapToGrid && this.isDragging - - for (let i = 0; i < groups.length; ++i) { - const group = groups[i] - - if (!overlapBounding(this.visible_area, group._bounding)) { - continue - } //out of the visible area - - // Draw snap shadow - if (drawSnapGuides && this.selectedItems.has(group)) - this.drawSnapGuide(ctx, group) - - group.draw(this, ctx) - } - - ctx.restore() - } - adjustNodesSize(): void { - const nodes = this.graph._nodes - for (let i = 0; i < nodes.length; ++i) { - nodes[i].size = nodes[i].computeSize() - } - this.setDirty(true, true) - } - /** - * resizes the canvas to a given size, if no size is passed, then it tries to fill the parentNode - * @todo Remove or rewrite - **/ - resize(width?: number, height?: number): void { - if (!width && !height) { - const parent = this.canvas.parentElement - width = parent.offsetWidth - height = parent.offsetHeight - } - - if (this.canvas.width == width && this.canvas.height == height) - return - - this.canvas.width = width - this.canvas.height = height - this.bgcanvas.width = this.canvas.width - this.bgcanvas.height = this.canvas.height - this.setDirty(true, true) - } - - onNodeSelectionChange(): void { } - - /** - * Determines the furthest nodes in each direction for the currently selected nodes - * @return {{left: LGraphNode, top: LGraphNode, right: LGraphNode, bottom: LGraphNode}} - */ - boundaryNodesForSelection(): NullableProperties { - return LGraphCanvas.getBoundaryNodes(this.selected_nodes) - } - - showLinkMenu(segment: LinkSegment, e: CanvasMouseEvent): boolean { - const { graph } = this - const node_left = graph.getNodeById(segment.origin_id) - const fromType = node_left?.outputs?.[segment.origin_slot]?.type ?? "*" - - const options = ["Add Node", null, "Delete", null] - if (this.reroutesEnabled) options.splice(1, 0, "Add Reroute") - - const title = "data" in segment && segment.data != null - ? segment.data.constructor.name - : null - const menu = new LiteGraph.ContextMenu(options, { - event: e, - title, - callback: inner_clicked.bind(this) - }) - - function inner_clicked(this: LGraphCanvas, v: string, options: unknown, e: MouseEvent) { - switch (v) { - case "Add Node": - LGraphCanvas.onMenuAdd(null, null, e, menu, function (node) { - if (!node.inputs?.length || !node.outputs?.length) return - - // leave the connection type checking inside connectByType - const options = this.reroutesEnabled - ? { afterRerouteId: segment.parentId } - : undefined - if (node_left.connectByType(segment.origin_slot, node, fromType, options)) { - node.pos[0] -= node.size[0] * 0.5 - } - }) - break - - case "Add Reroute": { - this.adjustMouseEvent(e) - graph.createReroute([e.canvasX, e.canvasY], segment) - this.setDirty(false, true) - break - } - - case "Delete": - graph.removeLink(segment.id) - break - default: - } - } - - return false - } - - createDefaultNodeForSlot(optPass: ICreateNodeOptions): boolean { - const opts = Object.assign({ - nodeFrom: null, - slotFrom: null, - nodeTo: null, - slotTo: null, - position: [0, 0], - nodeType: null, - posAdd: [0, 0], - posSizeFix: [0, 0] - }, optPass || {}) - const { afterRerouteId } = opts - - const isFrom = opts.nodeFrom && opts.slotFrom !== null - const isTo = !isFrom && opts.nodeTo && opts.slotTo !== null - - if (!isFrom && !isTo) { - console.warn("No data passed to createDefaultNodeForSlot " + opts.nodeFrom + " " + opts.slotFrom + " " + opts.nodeTo + " " + opts.slotTo) - return false - } - if (!opts.nodeType) { - console.warn("No type to createDefaultNodeForSlot") - return false - } - - const nodeX = isFrom ? opts.nodeFrom : opts.nodeTo - let slotX = isFrom ? opts.slotFrom : opts.slotTo - - let iSlotConn: number | false = false - switch (typeof slotX) { - case "string": - iSlotConn = isFrom ? nodeX.findOutputSlot(slotX, false) : nodeX.findInputSlot(slotX, false) - slotX = isFrom ? nodeX.outputs[slotX] : nodeX.inputs[slotX] - break - case "object": - // ok slotX - iSlotConn = isFrom ? nodeX.findOutputSlot(slotX.name) : nodeX.findInputSlot(slotX.name) - break - case "number": - iSlotConn = slotX - slotX = isFrom ? nodeX.outputs[slotX] : nodeX.inputs[slotX] - break - case "undefined": - default: - console.warn("Cant get slot information " + slotX) - return false - } - - // check for defaults nodes for this slottype - const fromSlotType = slotX.type == LiteGraph.EVENT ? "_event_" : slotX.type - const slotTypesDefault = isFrom ? LiteGraph.slot_types_default_out : LiteGraph.slot_types_default_in - if (slotTypesDefault?.[fromSlotType]) { - // TODO: Remove "any" kludge - let nodeNewType: any = false - if (typeof slotTypesDefault[fromSlotType] == "object") { - for (const typeX in slotTypesDefault[fromSlotType]) { - if (opts.nodeType == slotTypesDefault[fromSlotType][typeX] || opts.nodeType == "AUTO") { - nodeNewType = slotTypesDefault[fromSlotType][typeX] - break - } - } - } else if (opts.nodeType == slotTypesDefault[fromSlotType] || opts.nodeType == "AUTO") { - nodeNewType = slotTypesDefault[fromSlotType] - } - if (nodeNewType) { - // TODO: Remove "any" kludge - let nodeNewOpts: any = false - if (typeof nodeNewType == "object" && nodeNewType.node) { - nodeNewOpts = nodeNewType - nodeNewType = nodeNewType.node - } - - //that.graph.beforeChange(); - const newNode = LiteGraph.createNode(nodeNewType) - if (newNode) { - // if is object pass options - if (nodeNewOpts) { - if (nodeNewOpts.properties) { - for (const i in nodeNewOpts.properties) { - newNode.addProperty(i, nodeNewOpts.properties[i]) - } - } - if (nodeNewOpts.inputs) { - newNode.inputs = [] - for (const i in nodeNewOpts.inputs) { - newNode.addOutput( - nodeNewOpts.inputs[i][0], - nodeNewOpts.inputs[i][1] - ) - } - } - if (nodeNewOpts.outputs) { - newNode.outputs = [] - for (const i in nodeNewOpts.outputs) { - newNode.addOutput( - nodeNewOpts.outputs[i][0], - nodeNewOpts.outputs[i][1] - ) - } - } - if (nodeNewOpts.title) { - newNode.title = nodeNewOpts.title - } - if (nodeNewOpts.json) { - newNode.configure(nodeNewOpts.json) - } - - } - - // add the node - this.graph.add(newNode) - newNode.pos = [ - opts.position[0] + opts.posAdd[0] + (opts.posSizeFix[0] ? opts.posSizeFix[0] * newNode.size[0] : 0), - opts.position[1] + opts.posAdd[1] + (opts.posSizeFix[1] ? opts.posSizeFix[1] * newNode.size[1] : 0) - ] - - // connect the two! - if (isFrom) { - opts.nodeFrom.connectByType(iSlotConn, newNode, fromSlotType, { afterRerouteId }) - } else { - opts.nodeTo.connectByTypeOutput(iSlotConn, newNode, fromSlotType, { afterRerouteId }) - } - - // if connecting in between - if (isFrom && isTo) { - // TODO - } - - return true - } - console.log("failed creating " + nodeNewType) - } - } - return false - } - showConnectionMenu(optPass: Partial): void { - const opts = Object.assign({ - nodeFrom: null, - slotFrom: null, - nodeTo: null, - slotTo: null, - e: null, - allow_searchbox: this.allow_searchbox, - showSearchBox: this.showSearchBox, - }, optPass || {}) - const that = this - const { afterRerouteId } = opts - - const isFrom = opts.nodeFrom && opts.slotFrom - const isTo = !isFrom && opts.nodeTo && opts.slotTo - - if (!isFrom && !isTo) { - console.warn("No data passed to showConnectionMenu") - return - } - - const nodeX = isFrom ? opts.nodeFrom : opts.nodeTo - let slotX = isFrom ? opts.slotFrom : opts.slotTo - - let iSlotConn: number - switch (typeof slotX) { - case "string": - iSlotConn = isFrom ? nodeX.findOutputSlot(slotX, false) : nodeX.findInputSlot(slotX, false) - slotX = isFrom ? nodeX.outputs[slotX] : nodeX.inputs[slotX] - break - case "object": - // ok slotX - iSlotConn = isFrom ? nodeX.findOutputSlot(slotX.name) : nodeX.findInputSlot(slotX.name) - break - case "number": - iSlotConn = slotX - slotX = isFrom ? nodeX.outputs[slotX] : nodeX.inputs[slotX] - break - default: - console.warn("Cant get slot information " + slotX) - return - } - - const options = ["Add Node", null] - - if (opts.allow_searchbox) { - options.push("Search") - options.push(null) - } - - // get defaults nodes for this slottype - const fromSlotType = slotX.type == LiteGraph.EVENT ? "_event_" : slotX.type - const slotTypesDefault = isFrom ? LiteGraph.slot_types_default_out : LiteGraph.slot_types_default_in - if (slotTypesDefault?.[fromSlotType]) { - if (typeof slotTypesDefault[fromSlotType] == "object") { - for (const typeX in slotTypesDefault[fromSlotType]) { - options.push(slotTypesDefault[fromSlotType][typeX]) - } - } else { - options.push(slotTypesDefault[fromSlotType]) - } - } - - // build menu - const menu = new LiteGraph.ContextMenu(options, { - event: opts.e, - title: (slotX && slotX.name != "" ? (slotX.name + (fromSlotType ? " | " : "")) : "") + (slotX && fromSlotType ? fromSlotType : ""), - callback: inner_clicked - }) - - // callback - function inner_clicked(v: string, options: unknown, e: MouseEvent) { - //console.log("Process showConnectionMenu selection"); - switch (v) { - case "Add Node": - LGraphCanvas.onMenuAdd(null, null, e, menu, function (node) { - if (isFrom) { - opts.nodeFrom.connectByType(iSlotConn, node, fromSlotType, { afterRerouteId }) - } else { - opts.nodeTo.connectByTypeOutput(iSlotConn, node, fromSlotType, { afterRerouteId }) - } - }) - break - case "Search": - if (isFrom) { - opts.showSearchBox(e, { node_from: opts.nodeFrom, slot_from: slotX, type_filter_in: fromSlotType }) - } else { - opts.showSearchBox(e, { node_to: opts.nodeTo, slot_from: slotX, type_filter_out: fromSlotType }) - } - break - default: { - // check for defaults nodes for this slottype - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const nodeCreated = that.createDefaultNodeForSlot(Object.assign(opts, { - position: [opts.e.canvasX, opts.e.canvasY], - nodeType: v, - afterRerouteId, - })) - break - } - } - } - } - // refactor: there are different dialogs, some uses createDialog some dont - prompt(title: string, value: any, callback: (arg0: any) => void, event: CanvasMouseEvent, multiline?: boolean): HTMLDivElement { - const that = this - title = title || "" - - const dialog: IDialog = document.createElement("div") - dialog.is_modified = false - dialog.className = "graphdialog rounded" - dialog.innerHTML = multiline - ? " " - : " " - dialog.close = function () { - that.prompt_box = null - if (dialog.parentNode) { - dialog.parentNode.removeChild(dialog) - } - } - - const graphcanvas = LGraphCanvas.active_canvas - const canvas = graphcanvas.canvas - canvas.parentNode.appendChild(dialog) - - if (this.ds.scale > 1) dialog.style.transform = "scale(" + this.ds.scale + ")" - - let dialogCloseTimer = null - let prevent_timeout = 0 - LiteGraph.pointerListenerAdd(dialog, "leave", function () { - if (prevent_timeout) - return - if (LiteGraph.dialog_close_on_mouse_leave) - if (!dialog.is_modified && LiteGraph.dialog_close_on_mouse_leave) - dialogCloseTimer = setTimeout(dialog.close, LiteGraph.dialog_close_on_mouse_leave_delay) //dialog.close(); - }) - LiteGraph.pointerListenerAdd(dialog, "enter", function () { - if (LiteGraph.dialog_close_on_mouse_leave && dialogCloseTimer) - clearTimeout(dialogCloseTimer) - }) - const selInDia = dialog.querySelectorAll("select") - if (selInDia) { - // if filtering, check focus changed to comboboxes and prevent closing - for (const selIn of selInDia) { - selIn.addEventListener("click", function () { prevent_timeout++ }) - selIn.addEventListener("blur", function () { prevent_timeout = 0 }) - selIn.addEventListener("change", function () { prevent_timeout = -1 }) - } - } - this.prompt_box?.close() - this.prompt_box = dialog - - const name_element: HTMLSpanElement = dialog.querySelector(".name") - name_element.innerText = title - const value_element: HTMLTextAreaElement | HTMLInputElement = dialog.querySelector(".value") - value_element.value = value - value_element.select() - - const input = value_element - input.addEventListener("keydown", function (e: KeyboardEvent) { - dialog.is_modified = true - if (e.keyCode == 27) { - //ESC - dialog.close() - } else if (e.keyCode == 13 && (e.target as Element).localName != "textarea") { - if (callback) { - callback(this.value) - } - dialog.close() - } else { - return - } - e.preventDefault() - e.stopPropagation() - }) - - const button = dialog.querySelector("button") - button.addEventListener("click", function () { - callback?.(input.value) - that.setDirty(true) - dialog.close() - }) - - const rect = canvas.getBoundingClientRect() - let offsetx = -20 - let offsety = -20 - if (rect) { - offsetx -= rect.left - offsety -= rect.top - } - - if (event) { - dialog.style.left = event.clientX + offsetx + "px" - dialog.style.top = event.clientY + offsety + "px" - } else { - dialog.style.left = canvas.width * 0.5 + offsetx + "px" - dialog.style.top = canvas.height * 0.5 + offsety + "px" - } - - setTimeout(function () { - input.focus() - const clickTime = Date.now() - function handleOutsideClick(e: MouseEvent) { - if (e.target === canvas && Date.now() - clickTime > 256) { - dialog.close() - canvas.parentNode.removeEventListener("click", handleOutsideClick) - canvas.parentNode.removeEventListener("touchend", handleOutsideClick) - } - } - canvas.parentNode.addEventListener("click", handleOutsideClick) - canvas.parentNode.addEventListener("touchend", handleOutsideClick) - }, 10) - - return dialog - } - showSearchBox(event: MouseEvent, options?: IShowSearchOptions): HTMLDivElement { - // proposed defaults - const def_options: IShowSearchOptions = { - slot_from: null, - node_from: null, - node_to: null, - do_type_filter: LiteGraph.search_filter_enabled // TODO check for registered_slot_[in/out]_types not empty // this will be checked for functionality enabled : filter on slot type, in and out - , - - // @ts-expect-error - type_filter_in: false // these are default: pass to set initially set values - , - - type_filter_out: false, - show_general_if_none_on_typefilter: true, - show_general_after_typefiltered: true, - hide_on_mouse_leave: LiteGraph.search_hide_on_mouse_leave, - show_all_if_empty: true, - show_all_on_open: LiteGraph.search_show_all_on_open - } - options = Object.assign(def_options, options || {}) - - //console.log(options); - const that = this - const graphcanvas = LGraphCanvas.active_canvas - const canvas = graphcanvas.canvas - const root_document = canvas.ownerDocument || document - - const dialog = document.createElement("div") - dialog.className = "litegraph litesearchbox graphdialog rounded" - dialog.innerHTML = "Search " - if (options.do_type_filter) { - dialog.innerHTML += "" - dialog.innerHTML += "" - } - dialog.innerHTML += "
" - - if (root_document.fullscreenElement) - root_document.fullscreenElement.appendChild(dialog) - - else { - root_document.body.appendChild(dialog) - root_document.body.style.overflow = "hidden" - } - // dialog element has been appended - let selIn - let selOut - if (options.do_type_filter) { - selIn = dialog.querySelector(".slot_in_type_filter") - selOut = dialog.querySelector(".slot_out_type_filter") - } - - // @ts-expect-error Panel? - dialog.close = function () { - that.search_box = null - this.blur() - canvas.focus() - root_document.body.style.overflow = "" - - setTimeout(function () { - that.canvas.focus() - }, 20) //important, if canvas loses focus keys wont be captured - dialog.parentNode?.removeChild(dialog) - } - - if (this.ds.scale > 1) { - dialog.style.transform = "scale(" + this.ds.scale + ")" - } - - // hide on mouse leave - if (options.hide_on_mouse_leave) { - // FIXME: Remove "any" kludge - let prevent_timeout: any = false - let timeout_close = null - LiteGraph.pointerListenerAdd(dialog, "enter", function () { - if (timeout_close) { - clearTimeout(timeout_close) - timeout_close = null - } - }) - LiteGraph.pointerListenerAdd(dialog, "leave", function () { - if (prevent_timeout) - return - timeout_close = setTimeout(function () { - // @ts-expect-error Panel? - dialog.close() - }, typeof options.hide_on_mouse_leave === "number" ? options.hide_on_mouse_leave : 500) - }) - // if filtering, check focus changed to comboboxes and prevent closing - if (options.do_type_filter) { - selIn.addEventListener("click", function () { - prevent_timeout++ - }) - selIn.addEventListener("blur", function () { - prevent_timeout = 0 - }) - selIn.addEventListener("change", function () { - prevent_timeout = -1 - }) - selOut.addEventListener("click", function () { - prevent_timeout++ - }) - selOut.addEventListener("blur", function () { - prevent_timeout = 0 - }) - selOut.addEventListener("change", function () { - prevent_timeout = -1 - }) - } - } - - // @ts-expect-error Panel? - that.search_box?.close() - that.search_box = dialog - - const helper = dialog.querySelector(".helper") - - let first = null - let timeout = null - let selected = null - - const input = dialog.querySelector("input") - if (input) { - input.addEventListener("blur", function () { - this.focus() - }) - input.addEventListener("keydown", function (e) { - if (e.keyCode == 38) { - //UP - changeSelection(false) - } else if (e.keyCode == 40) { - //DOWN - changeSelection(true) - } else if (e.keyCode == 27) { - //ESC - // @ts-expect-error Panel? - dialog.close() - } else if (e.keyCode == 13) { - if (selected) { - select(unescape(selected.dataset["type"])) - } else if (first) { - select(first) - } else { - // @ts-expect-error Panel? - dialog.close() - } - } else { - if (timeout) { - clearInterval(timeout) - } - timeout = setTimeout(refreshHelper, 10) - return - } - e.preventDefault() - e.stopPropagation() - e.stopImmediatePropagation() - return true - }) - } - - // if should filter on type, load and fill selected and choose elements if passed - if (options.do_type_filter) { - if (selIn) { - const aSlots = LiteGraph.slot_types_in - const nSlots = aSlots.length // this for object :: Object.keys(aSlots).length; - - if (options.type_filter_in == LiteGraph.EVENT || options.type_filter_in == LiteGraph.ACTION) - options.type_filter_in = "_event_" - /* this will filter on * .. but better do it manually in case - else if(options.type_filter_in === "" || options.type_filter_in === 0) - options.type_filter_in = "*";*/ - for (let iK = 0; iK < nSlots; iK++) { - const opt = document.createElement('option') - opt.value = aSlots[iK] - opt.innerHTML = aSlots[iK] - selIn.appendChild(opt) - // @ts-expect-error - if (options.type_filter_in !== false && (options.type_filter_in + "").toLowerCase() == (aSlots[iK] + "").toLowerCase()) { - //selIn.selectedIndex .. - opt.selected = true - //console.log("comparing IN "+options.type_filter_in+" :: "+aSlots[iK]); - } else { - //console.log("comparing OUT "+options.type_filter_in+" :: "+aSlots[iK]); - } - } - selIn.addEventListener("change", function () { - refreshHelper() - }) - } - if (selOut) { - const aSlots = LiteGraph.slot_types_out - const nSlots = aSlots.length // this for object :: Object.keys(aSlots).length; - - if (options.type_filter_out == LiteGraph.EVENT || options.type_filter_out == LiteGraph.ACTION) - options.type_filter_out = "_event_" - /* this will filter on * .. but better do it manually in case - else if(options.type_filter_out === "" || options.type_filter_out === 0) - options.type_filter_out = "*";*/ - for (let iK = 0; iK < nSlots; iK++) { - const opt = document.createElement('option') - opt.value = aSlots[iK] - opt.innerHTML = aSlots[iK] - selOut.appendChild(opt) - if (options.type_filter_out !== false && (options.type_filter_out + "").toLowerCase() == (aSlots[iK] + "").toLowerCase()) - opt.selected = true - } - selOut.addEventListener("change", function () { - refreshHelper() - }) - } - } - - //compute best position - const rect = canvas.getBoundingClientRect() - - const left = (event ? event.clientX : (rect.left + rect.width * 0.5)) - 80 - const top = (event ? event.clientY : (rect.top + rect.height * 0.5)) - 20 - dialog.style.left = left + "px" - dialog.style.top = top + "px" - - //To avoid out of screen problems - if (event.layerY > (rect.height - 200)) - // @ts-expect-error - helper.style.maxHeight = (rect.height - event.layerY - 20) + "px" - - requestAnimationFrame(function () { - input.focus() - }) - if (options.show_all_on_open) refreshHelper() - - function select(name) { - if (name) { - if (that.onSearchBoxSelection) { - that.onSearchBoxSelection(name, event, graphcanvas) - } else { - const extra = LiteGraph.searchbox_extras[name.toLowerCase()] - if (extra) - name = extra.type - - graphcanvas.graph.beforeChange() - const node = LiteGraph.createNode(name) - if (node) { - node.pos = graphcanvas.convertEventToCanvasOffset( - event - ) - graphcanvas.graph.add(node, false) - } - - if (extra?.data) { - if (extra.data.properties) { - for (const i in extra.data.properties) { - node.addProperty(i, extra.data.properties[i]) - } - } - if (extra.data.inputs) { - node.inputs = [] - for (const i in extra.data.inputs) { - node.addOutput( - extra.data.inputs[i][0], - extra.data.inputs[i][1] - ) - } - } - if (extra.data.outputs) { - node.outputs = [] - for (const i in extra.data.outputs) { - node.addOutput( - extra.data.outputs[i][0], - extra.data.outputs[i][1] - ) - } - } - if (extra.data.title) { - node.title = extra.data.title - } - if (extra.data.json) { - node.configure(extra.data.json) - } - - } - - // join node after inserting - if (options.node_from) { - // FIXME: any - let iS: any = false - switch (typeof options.slot_from) { - case "string": - iS = options.node_from.findOutputSlot(options.slot_from) - break - case "object": - iS = options.slot_from.name - ? options.node_from.findOutputSlot(options.slot_from.name) - : -1 - // @ts-expect-error change interface check - if (iS == -1 && typeof options.slot_from.slot_index !== "undefined") iS = options.slot_from.slot_index - break - case "number": - iS = options.slot_from - break - default: - iS = 0 // try with first if no name set - } - if (typeof options.node_from.outputs[iS] !== "undefined") { - if (iS !== false && iS > -1) { - options.node_from.connectByType(iS, node, options.node_from.outputs[iS].type) - } - } else { - // console.warn("cant find slot " + options.slot_from); - } - } - if (options.node_to) { - // FIXME: any - let iS: any = false - switch (typeof options.slot_from) { - case "string": - iS = options.node_to.findInputSlot(options.slot_from) - break - case "object": - iS = options.slot_from.name - ? options.node_to.findInputSlot(options.slot_from.name) - : -1 - // @ts-expect-error change interface check - if (iS == -1 && typeof options.slot_from.slot_index !== "undefined") iS = options.slot_from.slot_index - break - case "number": - iS = options.slot_from - break - default: - iS = 0 // try with first if no name set - } - if (typeof options.node_to.inputs[iS] !== "undefined") { - if (iS !== false && iS > -1) { - // try connection - options.node_to.connectByTypeOutput(iS, node, options.node_to.inputs[iS].type) - } - } else { - // console.warn("cant find slot_nodeTO " + options.slot_from); - } - } - - graphcanvas.graph.afterChange() - } - } - - // @ts-expect-error Panel? - dialog.close() - } - - function changeSelection(forward) { - const prev = selected - if (!selected) { - selected = forward - ? helper.childNodes[0] - : helper.childNodes[helper.childNodes.length] - } else { - selected.classList.remove("selected") - selected = forward - ? selected.nextSibling - : selected.previousSibling - selected ||= prev - } - if (!selected) return - - selected.classList.add("selected") - selected.scrollIntoView({ block: "end", behavior: "smooth" }) - } - - function refreshHelper() { - timeout = null - let str = input.value - first = null - helper.innerHTML = "" - if (!str && !options.show_all_if_empty) return - - if (that.onSearchBox) { - const list = that.onSearchBox(helper, str, graphcanvas) - if (list) { - for (let i = 0; i < list.length; ++i) { - addResult(list[i]) - } - } - } else { - let c = 0 - str = str.toLowerCase() - const filter = graphcanvas.filter || graphcanvas.graph.filter - - // FIXME: any - // filter by type preprocess - let sIn: any = false - let sOut: any = false - if (options.do_type_filter && that.search_box) { - sIn = that.search_box.querySelector(".slot_in_type_filter") - sOut = that.search_box.querySelector(".slot_out_type_filter") - } - - //extras - for (const i in LiteGraph.searchbox_extras) { - const extra = LiteGraph.searchbox_extras[i] - if ((!options.show_all_if_empty || str) && extra.desc.toLowerCase().indexOf(str) === -1) - continue - const ctor = LiteGraph.registered_node_types[extra.type] - if (ctor && ctor.filter != filter) - continue - if (!inner_test_filter(extra.type)) - continue - addResult(extra.desc, "searchbox_extra") - if (LGraphCanvas.search_limit !== -1 && c++ > LGraphCanvas.search_limit) { - break - } - } - - let filtered = null - if (Array.prototype.filter) { //filter supported - const keys = Object.keys(LiteGraph.registered_node_types) //types - filtered = keys.filter(inner_test_filter) - } else { - filtered = [] - for (const i in LiteGraph.registered_node_types) { - if (inner_test_filter(i)) - filtered.push(i) - } - } - - for (let i = 0; i < filtered.length; i++) { - addResult(filtered[i]) - if (LGraphCanvas.search_limit !== -1 && c++ > LGraphCanvas.search_limit) - break - } - - // add general type if filtering - if (options.show_general_after_typefiltered - && (sIn.value || sOut.value)) { - // FIXME: Undeclared variable again - // @ts-expect-error - filtered_extra = [] - for (const i in LiteGraph.registered_node_types) { - if (inner_test_filter(i, { inTypeOverride: sIn && sIn.value ? "*" : false, outTypeOverride: sOut && sOut.value ? "*" : false })) - // @ts-expect-error - filtered_extra.push(i) - } - // @ts-expect-error - for (let i = 0; i < filtered_extra.length; i++) { - // @ts-expect-error - addResult(filtered_extra[i], "generic_type") - if (LGraphCanvas.search_limit !== -1 && c++ > LGraphCanvas.search_limit) - break - } - } - - // check il filtering gave no results - if ((sIn.value || sOut.value) && - ((helper.childNodes.length == 0 && options.show_general_if_none_on_typefilter))) { - // @ts-expect-error - filtered_extra = [] - for (const i in LiteGraph.registered_node_types) { - if (inner_test_filter(i, { skipFilter: true })) - // @ts-expect-error - filtered_extra.push(i) - } - // @ts-expect-error - for (let i = 0; i < filtered_extra.length; i++) { - // @ts-expect-error - addResult(filtered_extra[i], "not_in_filter") - if (LGraphCanvas.search_limit !== -1 && c++ > LGraphCanvas.search_limit) - break - } - } - - function inner_test_filter(type: string, optsIn?: number | { inTypeOverride?: string | boolean; outTypeOverride?: string | boolean; skipFilter?: boolean }): boolean { - optsIn = optsIn || {} - const optsDef = { - skipFilter: false, - inTypeOverride: false, - outTypeOverride: false - } - const opts = Object.assign(optsDef, optsIn) - const ctor = LiteGraph.registered_node_types[type] - if (filter && ctor.filter != filter) - return false - if ((!options.show_all_if_empty || str) && type.toLowerCase().indexOf(str) === -1 && (!ctor.title || ctor.title.toLowerCase().indexOf(str) === -1)) - return false - - // filter by slot IN, OUT types - if (options.do_type_filter && !opts.skipFilter) { - const sType = type - - let sV = opts.inTypeOverride !== false - ? opts.inTypeOverride - : sIn.value - // type is stored - if (sIn && sV && LiteGraph.registered_slot_in_types[sV]?.nodes) { - const doesInc = LiteGraph.registered_slot_in_types[sV].nodes.includes(sType) - if (doesInc === false) return false - } - - sV = sOut.value - if (opts.outTypeOverride !== false) sV = opts.outTypeOverride - // type is stored - if (sOut && sV && LiteGraph.registered_slot_out_types[sV]?.nodes) { - const doesInc = LiteGraph.registered_slot_out_types[sV].nodes.includes(sType) - if (doesInc === false) return false - } - } - return true - } - } - - function addResult(type: string, className?: string): void { - const help = document.createElement("div") - first ||= type - - const nodeType = LiteGraph.registered_node_types[type] - if (nodeType?.title) { - help.innerText = nodeType?.title - const typeEl = document.createElement("span") - typeEl.className = "litegraph lite-search-item-type" - typeEl.textContent = type - help.append(typeEl) - } else { - help.innerText = type - } - - help.dataset["type"] = escape(type) - help.className = "litegraph lite-search-item" - if (className) { - help.className += " " + className - } - help.addEventListener("click", function () { - select(unescape(this.dataset["type"])) - }) - helper.appendChild(help) - } - } - - return dialog - } - showEditPropertyValue(node: LGraphNode, property: string, options: IDialogOptions): IDialog { - if (!node || node.properties[property] === undefined) return - - options = options || {} - - const info = node.getPropertyInfo(property) - const type = info.type - - let input_html = "" - - if (type == "string" || type == "number" || type == "array" || type == "object") { - input_html = "" - } else if ((type == "enum" || type == "combo") && info.values) { - input_html = "" - } else if (type == "boolean" || type == "toggle") { - input_html = - "" - } else { - console.warn("unknown type: " + type) - return - } - - const dialog = this.createDialog( - "" + - (info.label || property) + - "" + - input_html + - "", - options - ) - - let input: HTMLInputElement | HTMLSelectElement - if ((type == "enum" || type == "combo") && info.values) { - input = dialog.querySelector("select") - input.addEventListener("change", function (e) { - dialog.modified() - setValue((e.target as HTMLSelectElement)?.value) - }) - } else if (type == "boolean" || type == "toggle") { - input = dialog.querySelector("input") - input?.addEventListener("click", function () { - dialog.modified() - // @ts-expect-error - setValue(!!input.checked) - }) - } else { - input = dialog.querySelector("input") - if (input) { - input.addEventListener("blur", function () { - this.focus() - }) - - let v = node.properties[property] !== undefined ? node.properties[property] : "" - if (type !== 'string') { - v = JSON.stringify(v) - } - - // @ts-expect-error - input.value = v - input.addEventListener("keydown", function (e) { - if (e.keyCode == 27) { - //ESC - dialog.close() - } else if (e.keyCode == 13) { - // ENTER - inner() // save - } else if (e.keyCode != 13) { - dialog.modified() - return - } - e.preventDefault() - e.stopPropagation() - }) - } - } - input?.focus() - - const button = dialog.querySelector("button") - button.addEventListener("click", inner) - - function inner() { - setValue(input.value) - } - - function setValue(value: string | number) { - - if (info?.values && typeof info.values === "object" && info.values[value] != undefined) - value = info.values[value] - - if (typeof node.properties[property] == "number") { - value = Number(value) - } - if (type == "array" || type == "object") { - // @ts-expect-error JSON.parse doesn't care. - value = JSON.parse(value) - } - node.properties[property] = value - if (node.graph) { - node.graph._version++ - } - node.onPropertyChanged?.(property, value) - options.onclose?.() - dialog.close() - this.setDirty(true, true) - } - - return dialog - } - // TODO refactor, theer are different dialog, some uses createDialog, some dont - createDialog(html: string, options: IDialogOptions): IDialog { - const def_options = { checkForInput: false, closeOnLeave: true, closeOnLeave_checkModified: true } - options = Object.assign(def_options, options || {}) - const dialog: IDialog = document.createElement("div") - dialog.className = "graphdialog" - dialog.innerHTML = html - dialog.is_modified = false - - const rect = this.canvas.getBoundingClientRect() - let offsetx = -20 - let offsety = -20 - if (rect) { - offsetx -= rect.left - offsety -= rect.top - } - - if (options.position) { - offsetx += options.position[0] - offsety += options.position[1] - } else if (options.event) { - offsetx += options.event.clientX - offsety += options.event.clientY - } //centered - else { - offsetx += this.canvas.width * 0.5 - offsety += this.canvas.height * 0.5 - } - - dialog.style.left = offsetx + "px" - dialog.style.top = offsety + "px" - - this.canvas.parentNode.appendChild(dialog) - - // acheck for input and use default behaviour: save on enter, close on esc - if (options.checkForInput) { - const aI = dialog.querySelectorAll("input") - const focused = false - aI?.forEach(function (iX) { - iX.addEventListener("keydown", function (e) { - dialog.modified() - if (e.keyCode == 27) { - dialog.close() - } else if (e.keyCode != 13) { - return - } - // set value ? - e.preventDefault() - e.stopPropagation() - }) - if (!focused) iX.focus() - }) - } - - dialog.modified = function () { - dialog.is_modified = true - } - dialog.close = function () { - dialog.parentNode?.removeChild(dialog) - } - - let dialogCloseTimer = null - let prevent_timeout = 0 - dialog.addEventListener("mouseleave", function () { - if (prevent_timeout) - return - if (!dialog.is_modified && LiteGraph.dialog_close_on_mouse_leave) - dialogCloseTimer = setTimeout(dialog.close, LiteGraph.dialog_close_on_mouse_leave_delay) //dialog.close(); - }) - dialog.addEventListener("mouseenter", function () { - if (options.closeOnLeave || LiteGraph.dialog_close_on_mouse_leave) - if (dialogCloseTimer) clearTimeout(dialogCloseTimer) - }) - const selInDia = dialog.querySelectorAll("select") - // if filtering, check focus changed to comboboxes and prevent closing - selInDia?.forEach(function (selIn) { - selIn.addEventListener("click", function () { prevent_timeout++ }) - selIn.addEventListener("blur", function () { prevent_timeout = 0 }) - selIn.addEventListener("change", function () { prevent_timeout = -1 }) - }) - - return dialog - } - createPanel(title, options) { - options = options || {} - - const ref_window = options.window || window - // TODO: any kludge - const root: any = document.createElement("div") - root.className = "litegraph dialog" - root.innerHTML = "
" - root.header = root.querySelector(".dialog-header") - - if (options.width) - root.style.width = options.width + (typeof options.width === "number" ? "px" : "") - if (options.height) - root.style.height = options.height + (typeof options.height === "number" ? "px" : "") - if (options.closable) { - const close = document.createElement("span") - close.innerHTML = "✕" - close.classList.add("close") - close.addEventListener("click", function () { - root.close() - }) - root.header.appendChild(close) - } - root.title_element = root.querySelector(".dialog-title") - root.title_element.innerText = title - root.content = root.querySelector(".dialog-content") - root.alt_content = root.querySelector(".dialog-alt-content") - root.footer = root.querySelector(".dialog-footer") - - root.close = function () { - if (typeof root.onClose == "function") root.onClose() - root.parentNode?.removeChild(root) - /* XXX CHECK THIS */ - this.parentNode?.removeChild(this) - /* XXX this was not working, was fixed with an IF, check this */ - } - - // function to swap panel content - root.toggleAltContent = function (force: unknown) { - let vTo: string - let vAlt: string - if (typeof force != "undefined") { - vTo = force ? "block" : "none" - vAlt = force ? "none" : "block" - } else { - vTo = root.alt_content.style.display != "block" ? "block" : "none" - vAlt = root.alt_content.style.display != "block" ? "none" : "block" - } - root.alt_content.style.display = vTo - root.content.style.display = vAlt - } - - root.toggleFooterVisibility = function (force: unknown) { - let vTo: string - if (typeof force != "undefined") { - vTo = force ? "block" : "none" - } else { - vTo = root.footer.style.display != "block" ? "block" : "none" - } - root.footer.style.display = vTo - } - - root.clear = function () { - this.content.innerHTML = "" - } - - root.addHTML = function (code, classname, on_footer) { - const elem = document.createElement("div") - if (classname) - elem.className = classname - elem.innerHTML = code - if (on_footer) - root.footer.appendChild(elem) - - else - root.content.appendChild(elem) - return elem - } - - root.addButton = function (name, callback, options) { - // TODO: any kludge - const elem: any = document.createElement("button") - elem.innerText = name - elem.options = options - elem.classList.add("btn") - elem.addEventListener("click", callback) - root.footer.appendChild(elem) - return elem - } - - root.addSeparator = function () { - const elem = document.createElement("div") - elem.className = "separator" - root.content.appendChild(elem) - } - - root.addWidget = function (type, name, value, options, callback) { - options = options || {} - let str_value = String(value) - type = type.toLowerCase() - if (type == "number") - str_value = value.toFixed(3) - - // FIXME: any kludge - const elem: any = document.createElement("div") - elem.className = "property" - elem.innerHTML = "" - elem.querySelector(".property_name").innerText = options.label || name - // TODO: any kludge - const value_element: any = elem.querySelector(".property_value") - value_element.innerText = str_value - elem.dataset["property"] = name - elem.dataset["type"] = options.type || type - elem.options = options - elem.value = value - - if (type == "code") - elem.addEventListener("click", function () { root.inner_showCodePad(this.dataset["property"]) }) - else if (type == "boolean") { - elem.classList.add("boolean") - if (value) - elem.classList.add("bool-on") - elem.addEventListener("click", function () { - const propname = this.dataset["property"] - this.value = !this.value - this.classList.toggle("bool-on") - this.querySelector(".property_value").innerText = this.value ? "true" : "false" - innerChange(propname, this.value) - }) - } - else if (type == "string" || type == "number") { - value_element.setAttribute("contenteditable", true) - value_element.addEventListener("keydown", function (e) { - // allow for multiline - if (e.code == "Enter" && (type != "string" || !e.shiftKey)) { - e.preventDefault() - this.blur() - } - }) - value_element.addEventListener("blur", function () { - let v = this.innerText - const propname = this.parentNode.dataset["property"] - const proptype = this.parentNode.dataset["type"] - if (proptype == "number") - v = Number(v) - innerChange(propname, v) - }) - } - else if (type == "enum" || type == "combo") { - const str_value = LGraphCanvas.getPropertyPrintableValue(value, options.values) - value_element.innerText = str_value - - value_element.addEventListener("click", function (event) { - const values = options.values || [] - const propname = this.parentNode.dataset["property"] - const elem_that = this - new LiteGraph.ContextMenu(values, { - event: event, - className: "dark", - callback: inner_clicked - }, - // @ts-expect-error - ref_window) - function inner_clicked(v) { - //node.setProperty(propname,v); - //graphcanvas.dirty_canvas = true; - elem_that.innerText = v - innerChange(propname, v) - return false - } - }) - } - - root.content.appendChild(elem) - - function innerChange(name, value) { - options.callback?.(name, value, options) - callback?.(name, value, options) - } - - return elem - } - - if (root.onOpen && typeof root.onOpen == "function") root.onOpen() - - return root - } - closePanels(): void { - document.querySelector("#node-panel")?.close() - document.querySelector("#option-panel")?.close() - } - showShowNodePanel(node: LGraphNode): void { - this.SELECTED_NODE = node - this.closePanels() - const ref_window = this.getCanvasWindow() - const graphcanvas = this - const panel = this.createPanel(node.title || "", { - closable: true, - window: ref_window, - onOpen: function () { - graphcanvas.NODEPANEL_IS_OPEN = true - }, - onClose: function () { - graphcanvas.NODEPANEL_IS_OPEN = false - graphcanvas.node_panel = null - } - }) - graphcanvas.node_panel = panel - panel.id = "node-panel" - panel.node = node - panel.classList.add("settings") - - function inner_refresh() { - //clear - panel.content.innerHTML = "" - // @ts-expect-error ctor props - panel.addHTML(`${node.type}${node.constructor.desc || ""}`) - - panel.addHTML("

Properties

") - - const fUpdate = function (name, value) { - graphcanvas.graph.beforeChange(node) - switch (name) { - case "Title": - node.title = value - break - case "Mode": { - const kV = Object.values(LiteGraph.NODE_MODES).indexOf(value) - if (kV >= 0 && LiteGraph.NODE_MODES[kV]) { - node.changeMode(kV) - } else { - console.warn("unexpected mode: " + value) - } - break - } - case "Color": - if (LGraphCanvas.node_colors[value]) { - node.color = LGraphCanvas.node_colors[value].color - node.bgcolor = LGraphCanvas.node_colors[value].bgcolor - } else { - console.warn("unexpected color: " + value) - } - break - default: - node.setProperty(name, value) - break - } - graphcanvas.graph.afterChange() - graphcanvas.dirty_canvas = true - } - - panel.addWidget("string", "Title", node.title, {}, fUpdate) - - panel.addWidget("combo", "Mode", LiteGraph.NODE_MODES[node.mode], { values: LiteGraph.NODE_MODES }, fUpdate) - - const nodeCol = node.color !== undefined - ? Object.keys(LGraphCanvas.node_colors).filter(function (nK) { return LGraphCanvas.node_colors[nK].color == node.color }) - : "" - - panel.addWidget("combo", "Color", nodeCol, { values: Object.keys(LGraphCanvas.node_colors) }, fUpdate) - - for (const pName in node.properties) { - const value = node.properties[pName] - const info = node.getPropertyInfo(pName) - - //in case the user wants control over the side panel widget - if (node.onAddPropertyToPanel?.(pName, panel)) - continue - - panel.addWidget(info.widget || info.type, pName, value, info, fUpdate) + // Bounding box of all points (bezier overshoot on long links will be cut) + const pointsX = points.map(x => x[0]) + const pointsY = points.map(x => x[1]) + LGraphCanvas.#link_bounding[0] = Math.min(...pointsX) + LGraphCanvas.#link_bounding[1] = Math.min(...pointsY) + LGraphCanvas.#link_bounding[2] = Math.max(...pointsX) - LGraphCanvas.#link_bounding[0] + LGraphCanvas.#link_bounding[3] = Math.max(...pointsY) - LGraphCanvas.#link_bounding[1] + + // skip links outside of the visible area of the canvas + if (!overlapBounding(LGraphCanvas.#link_bounding, LGraphCanvas.#margin_area)) + continue + + const start_slot = start_node.outputs[outputId] + const end_slot = node.inputs[i] + if (!start_slot || !end_slot) continue + const start_dir = + start_slot.dir || + (start_node.horizontal ? LinkDirection.DOWN : LinkDirection.RIGHT) + const end_dir = + end_slot.dir || + (node.horizontal ? LinkDirection.UP : LinkDirection.LEFT) + + // Has reroutes + if (reroutes.length) { + let startControl: Point + + const l = reroutes.length + for (let j = 0; j < l; j++) { + const reroute = reroutes[j] + + // Only render once + if (!rendered.has(reroute)) { + rendered.add(reroute) + visibleReroutes.push(reroute) + reroute._colour = link.color || + LGraphCanvas.link_type_colors[link.type] || + this.default_link_color + + const prevReroute = this.graph.reroutes.get(reroute.parentId) + const startPos = prevReroute?.pos ?? start_node_slotpos + reroute.calculateAngle(this.last_draw_time, this.graph, startPos) + + this.renderLink( + ctx, + startPos, + reroute.pos, + link, + false, + 0, + null, + start_dir, + end_dir, + { + startControl, + endControl: reroute.controlPoint, + reroute, + }, + ) } - panel.addSeparator() + // Calculate start control for the next iter control point + const nextPos = reroutes[j + 1]?.pos ?? end_node_slotpos + const dist = Math.min(80, distance(reroute.pos, nextPos) * 0.25) + startControl = [dist * reroute.cos, dist * reroute.sin] + } - node.onShowCustomPanelInfo?.(panel) - - panel.footer.innerHTML = "" // clear - panel.addButton("Delete", function () { - if (node.block_delete) - return - node.graph.remove(node) - panel.close() - }).classList.add("delete") + // Render final link segment + this.renderLink( + ctx, + points.at(-2), + points.at(-1), + link, + false, + 0, + null, + start_dir, + end_dir, + { startControl }, + ) + } else { + this.renderLink( + ctx, + start_node_slotpos, + end_node_slotpos, + link, + false, + 0, + null, + start_dir, + end_dir, + ) + } + rendered.add(link) + + // event triggered rendered on top + if (link && link._last_time && now - link._last_time < 1000) { + const f = 2.0 - (now - link._last_time) * 0.002 + const tmp = ctx.globalAlpha + ctx.globalAlpha = tmp * f + this.renderLink( + ctx, + start_node_slotpos, + end_node_slotpos, + link, + true, + f, + "white", + start_dir, + end_dir, + ) + ctx.globalAlpha = tmp + } + } + } + + // Render the reroute circles + for (const reroute of visibleReroutes) { + if ( + this.#snapToGrid && + this.isDragging && + this.selectedItems.has(reroute) + ) + this.drawSnapGuide(ctx, reroute, RenderShape.CIRCLE) + reroute.draw(ctx) + } + ctx.globalAlpha = 1 + } + + /** + * draws a link between two points + * @param ctx Canvas 2D rendering context + * @param {vec2} a start pos + * @param {vec2} b end pos + * @param {Object} link the link object with all the link info + * @param {boolean} skip_border ignore the shadow of the link + * @param {boolean} flow show flow animation (for events) + * @param {string} color the color for the link + * @param {LinkDirection} start_dir the direction enum + * @param {LinkDirection} end_dir the direction enum + * @param {number} num_sublines number of sublines (useful to represent vec3 or rgb) + */ + renderLink( + ctx: CanvasRenderingContext2D, + a: ReadOnlyPoint, + b: ReadOnlyPoint, + link: LLink, + skip_border: boolean, + flow: number, + color: CanvasColour, + start_dir: LinkDirection, + end_dir: LinkDirection, + { + startControl, + endControl, + reroute, + num_sublines = 1, + }: { + /** When defined, render data will be saved to this reroute instead of the {@link link}. */ + reroute?: Reroute + /** Offset of the bezier curve control point from {@link a point a} (output side) */ + startControl?: ReadOnlyPoint + /** Offset of the bezier curve control point from {@link b point b} (input side) */ + endControl?: ReadOnlyPoint + /** Number of sublines (useful to represent vec3 or rgb) @todo If implemented, refactor calculations out of the loop */ + num_sublines?: number + } = {}, + ): void { + if (link) this.visible_links.push(link) + + const linkColour = + link != null && this.highlighted_links[link.id] + ? "#FFF" + : color || + link?.color || + LGraphCanvas.link_type_colors[link.type] || + this.default_link_color + const startDir = start_dir || LinkDirection.RIGHT + const endDir = end_dir || LinkDirection.LEFT + + const dist = this.links_render_mode == LinkRenderType.SPLINE_LINK && (!endControl || !startControl) + ? distance(a, b) + : null + + // TODO: Subline code below was inserted in the wrong place - should be before this statement + if (this.render_connections_border && this.ds.scale > 0.6) { + ctx.lineWidth = this.connections_width + 4 + } + ctx.lineJoin = "round" + num_sublines ||= 1 + if (num_sublines > 1) ctx.lineWidth = 0.5 + + // begin line shape + const path = new Path2D() + + /** The link or reroute we're currently rendering */ + const linkSegment = reroute ?? link + if (linkSegment) linkSegment.path = path + + const innerA = LGraphCanvas.#lTempA + const innerB = LGraphCanvas.#lTempB + + /** Reference to {@link reroute._pos} if present, or {@link link._pos} if present. Caches the centre point of the link. */ + const pos: Point = linkSegment?._pos ?? [0, 0] + + for (let i = 0; i < num_sublines; i += 1) { + const offsety = (i - (num_sublines - 1) * 0.5) * 5 + innerA[0] = a[0] + innerA[1] = a[1] + innerB[0] = b[0] + innerB[1] = b[1] + + if (this.links_render_mode == LinkRenderType.SPLINE_LINK) { + if (endControl) { + innerB[0] = b[0] + endControl[0] + innerB[1] = b[1] + endControl[1] + } else { + this.#addSplineOffset(innerB, endDir, dist) } + if (startControl) { + innerA[0] = a[0] + startControl[0] + innerA[1] = a[1] + startControl[1] + } else { + this.#addSplineOffset(innerA, startDir, dist) + } + path.moveTo(a[0], a[1] + offsety) + path.bezierCurveTo( + innerA[0], + innerA[1] + offsety, + innerB[0], + innerB[1] + offsety, + b[0], + b[1] + offsety, + ) - panel.inner_showCodePad = function (propname) { - panel.classList.remove("settings") - panel.classList.add("centered") - - panel.alt_content.innerHTML = "" - const textarea = panel.alt_content.querySelector("textarea") - const fDoneWith = function () { - panel.toggleAltContent(false) - panel.toggleFooterVisibility(true) - textarea.parentNode.removeChild(textarea) - panel.classList.add("settings") - panel.classList.remove("centered") - inner_refresh() - } - textarea.value = node.properties[propname] - textarea.addEventListener("keydown", function (e) { - if (e.code == "Enter" && e.ctrlKey) { - node.setProperty(propname, textarea.value) - fDoneWith() - } - }) - panel.toggleAltContent(true) - panel.toggleFooterVisibility(false) - textarea.style.height = "calc(100% - 40px)" - - const assign = panel.addButton("Assign", function () { - node.setProperty(propname, textarea.value) - fDoneWith() - }) - panel.alt_content.appendChild(assign) - const button = panel.addButton("Close", fDoneWith) - button.style.float = "right" - panel.alt_content.appendChild(button) + // Calculate centre point + findPointOnCurve(pos, a, b, innerA, innerB, 0.5) + + if (linkSegment && this.linkMarkerShape === LinkMarkerShape.Arrow) { + const justPastCentre = LGraphCanvas.#lTempC + findPointOnCurve(justPastCentre, a, b, innerA, innerB, 0.51) + + linkSegment._centreAngle = Math.atan2( + justPastCentre[1] - pos[1], + justPastCentre[0] - pos[0], + ) + } + } else if (this.links_render_mode == LinkRenderType.LINEAR_LINK) { + const l = 15 + switch (startDir) { + case LinkDirection.LEFT: + innerA[0] += -l + break + case LinkDirection.RIGHT: + innerA[0] += l + break + case LinkDirection.UP: + innerA[1] += -l + break + case LinkDirection.DOWN: + innerA[1] += l + break + } + switch (endDir) { + case LinkDirection.LEFT: + innerB[0] += -l + break + case LinkDirection.RIGHT: + innerB[0] += l + break + case LinkDirection.UP: + innerB[1] += -l + break + case LinkDirection.DOWN: + innerB[1] += l + break + } + path.moveTo(a[0], a[1] + offsety) + path.lineTo(innerA[0], innerA[1] + offsety) + path.lineTo(innerB[0], innerB[1] + offsety) + path.lineTo(b[0], b[1] + offsety) + + // Calculate centre point + pos[0] = (innerA[0] + innerB[0]) * 0.5 + pos[1] = (innerA[1] + innerB[1]) * 0.5 + + if (linkSegment && this.linkMarkerShape === LinkMarkerShape.Arrow) { + linkSegment._centreAngle = Math.atan2( + innerB[1] - innerA[1], + innerB[0] - innerA[0], + ) + } + } else if (this.links_render_mode == LinkRenderType.STRAIGHT_LINK) { + if (startDir == LinkDirection.RIGHT) { + innerA[0] += 10 + } else { + innerA[1] += 10 + } + if (endDir == LinkDirection.LEFT) { + innerB[0] -= 10 + } else { + innerB[1] -= 10 } + const midX = (innerA[0] + innerB[0]) * 0.5 - inner_refresh() + path.moveTo(a[0], a[1]) + path.lineTo(innerA[0], innerA[1]) + path.lineTo(midX, innerA[1]) + path.lineTo(midX, innerB[1]) + path.lineTo(innerB[0], innerB[1]) + path.lineTo(b[0], b[1]) - this.canvas.parentNode.appendChild(panel) - } - checkPanels(): void { - if (!this.canvas) return + // Calculate centre point + pos[0] = midX + pos[1] = (innerA[1] + innerB[1]) * 0.5 - const panels = this.canvas.parentNode.querySelectorAll(".litegraph.dialog") - for (let i = 0; i < panels.length; ++i) { - const panel = panels[i] - // @ts-expect-error Panel - if (!panel.node) continue - // @ts-expect-error Panel - if (!panel.node.graph || panel.graph != this.graph) panel.close() + if (linkSegment && this.linkMarkerShape === LinkMarkerShape.Arrow) { + const diff = innerB[1] - innerA[1] + if (Math.abs(diff) < 4) linkSegment._centreAngle = 0 + else if (diff > 0) linkSegment._centreAngle = Math.PI * 0.5 + else linkSegment._centreAngle = -(Math.PI * 0.5) } + } else { + return + } // unknown } - getCanvasMenuOptions(): IContextMenuValue[] { - let options: IContextMenuValue[] = null - if (this.getMenuOptions) { - options = this.getMenuOptions() - } else { - options = [ - { - content: "Add Node", - has_submenu: true, - // @ts-expect-error Might be broken? Or just param overlap - callback: LGraphCanvas.onMenuAdd - }, - { content: "Add Group", callback: LGraphCanvas.onGroupAdd }, - //{ content: "Arrange", callback: that.graph.arrange }, - //{content:"Collapse All", callback: LGraphCanvas.onMenuCollapseAll } - ] - if (Object.keys(this.selected_nodes).length > 1) { - options.push({ - content: "Align", - has_submenu: true, - callback: LGraphCanvas.onGroupAlign, - }) - } - } - const extra = this.getExtraMenuOptions?.(this, options) - return extra - ? options.concat(extra) - : options + // rendering the outline of the connection can be a little bit slow + if (this.render_connections_border && this.ds.scale > 0.6 && !skip_border) { + ctx.strokeStyle = "rgba(0,0,0,0.5)" + ctx.stroke(path) } - //called by processContextMenu to extract the menu list - getNodeMenuOptions(node: LGraphNode): IContextMenuValue[] { - let options: IContextMenuValue[] = null - if (node.getMenuOptions) { - options = node.getMenuOptions(this) + ctx.lineWidth = this.connections_width + ctx.fillStyle = ctx.strokeStyle = linkColour + ctx.stroke(path) + + // render arrow in the middle + if ( + this.ds.scale >= 0.6 && + this.highquality_render && + linkSegment && + // TODO: Re-assess this usage - likely a workaround that linkSegment truthy check resolves + endDir != LinkDirection.CENTER + ) { + // render arrow + if (this.render_connection_arrows) { + // compute two points in the connection + const posA = this.computeConnectionPoint(a, b, 0.25, startDir, endDir) + const posB = this.computeConnectionPoint(a, b, 0.26, startDir, endDir) + const posC = this.computeConnectionPoint(a, b, 0.75, startDir, endDir) + const posD = this.computeConnectionPoint(a, b, 0.76, startDir, endDir) + + // compute the angle between them so the arrow points in the right direction + let angleA = 0 + let angleB = 0 + if (this.render_curved_connections) { + angleA = -Math.atan2(posB[0] - posA[0], posB[1] - posA[1]) + angleB = -Math.atan2(posD[0] - posC[0], posD[1] - posC[1]) } else { - options = [ - { - content: "Inputs", - has_submenu: true, - disabled: true, - callback: LGraphCanvas.showMenuNodeOptionalInputs - }, - { - content: "Outputs", - has_submenu: true, - disabled: true, - callback: LGraphCanvas.showMenuNodeOptionalOutputs - }, - null, - { - content: "Properties", - has_submenu: true, - callback: LGraphCanvas.onShowMenuNodeProperties - }, - { - content: "Properties Panel", - callback: function (item, options, e, menu, node) { LGraphCanvas.active_canvas.showShowNodePanel(node) } - }, - null, - { - content: "Title", - callback: LGraphCanvas.onShowPropertyEditor - }, - { - content: "Mode", - has_submenu: true, - callback: LGraphCanvas.onMenuNodeMode + angleB = angleA = b[1] > a[1] ? 0 : Math.PI + } + + // render arrow + const transform = ctx.getTransform() + ctx.translate(posA[0], posA[1]) + ctx.rotate(angleA) + ctx.beginPath() + ctx.moveTo(-5, -3) + ctx.lineTo(0, +7) + ctx.lineTo(+5, -3) + ctx.fill() + ctx.setTransform(transform) + + ctx.translate(posC[0], posC[1]) + ctx.rotate(angleB) + ctx.beginPath() + ctx.moveTo(-5, -3) + ctx.lineTo(0, +7) + ctx.lineTo(+5, -3) + ctx.fill() + ctx.setTransform(transform) + } + + // Draw link centre marker + ctx.beginPath() + if (this.linkMarkerShape === LinkMarkerShape.Arrow) { + const transform = ctx.getTransform() + ctx.translate(pos[0], pos[1]) + ctx.rotate(linkSegment._centreAngle) + // The math is off, but it currently looks better in chromium + ctx.moveTo(-3.2, -5) + ctx.lineTo(+7, 0) + ctx.lineTo(-3.2, +5) + ctx.fill() + ctx.setTransform(transform) + } else if ( + this.linkMarkerShape == null || + this.linkMarkerShape === LinkMarkerShape.Circle + ) { + ctx.arc(pos[0], pos[1], 5, 0, Math.PI * 2) + } + ctx.fill() + } + + // render flowing points + if (flow) { + ctx.fillStyle = linkColour + for (let i = 0; i < 5; ++i) { + const f = (LiteGraph.getTime() * 0.001 + i * 0.2) % 1 + const flowPos = this.computeConnectionPoint(a, b, f, startDir, endDir) + ctx.beginPath() + ctx.arc(flowPos[0], flowPos[1], 5, 0, 2 * Math.PI) + ctx.fill() + } + } + } + + /** + * Finds a point along a spline represented by a to b, with spline endpoint directions dictacted by start_dir and end_dir. + * @param a Start point + * @param b End point + * @param t Time: distance between points (e.g 0.25 is 25% along the line) + * @param start_dir Spline start direction + * @param end_dir Spline end direction + * @returns The point at {@link t} distance along the spline a-b. + */ + computeConnectionPoint( + a: ReadOnlyPoint, + b: ReadOnlyPoint, + t: number, + start_dir: LinkDirection, + end_dir: LinkDirection, + ): Point { + start_dir ||= LinkDirection.RIGHT + end_dir ||= LinkDirection.LEFT + + const dist = distance(a, b) + const pa: Point = [a[0], a[1]] + const pb: Point = [b[0], b[1]] + + this.#addSplineOffset(pa, start_dir, dist) + this.#addSplineOffset(pb, end_dir, dist) + + const c1 = (1 - t) * (1 - t) * (1 - t) + const c2 = 3 * ((1 - t) * (1 - t)) * t + const c3 = 3 * (1 - t) * (t * t) + const c4 = t * t * t + + const x = c1 * a[0] + c2 * pa[0] + c3 * pb[0] + c4 * b[0] + const y = c1 * a[1] + c2 * pa[1] + c3 * pb[1] + c4 * b[1] + return [x, y] + } + + /** + * Modifies an existing point, adding a single-axis offset. + * @param point The point to add the offset to + * @param direction The direction to add the offset in + * @param dist Distance to offset + * @param factor Distance is mulitplied by this value. Default: 0.25 + */ + #addSplineOffset( + point: Point, + direction: LinkDirection, + dist: number, + factor = 0.25, + ): void { + switch (direction) { + case LinkDirection.LEFT: + point[0] += dist * -factor + break + case LinkDirection.RIGHT: + point[0] += dist * factor + break + case LinkDirection.UP: + point[1] += dist * -factor + break + case LinkDirection.DOWN: + point[1] += dist * factor + break + } + } + + drawExecutionOrder(ctx: CanvasRenderingContext2D): void { + ctx.shadowColor = "transparent" + ctx.globalAlpha = 0.25 + + ctx.textAlign = "center" + ctx.strokeStyle = "white" + ctx.globalAlpha = 0.75 + + const visible_nodes = this.visible_nodes + for (let i = 0; i < visible_nodes.length; ++i) { + const node = visible_nodes[i] + ctx.fillStyle = "black" + ctx.fillRect( + node.pos[0] - LiteGraph.NODE_TITLE_HEIGHT, + node.pos[1] - LiteGraph.NODE_TITLE_HEIGHT, + LiteGraph.NODE_TITLE_HEIGHT, + LiteGraph.NODE_TITLE_HEIGHT, + ) + if (node.order == 0) { + ctx.strokeRect( + node.pos[0] - LiteGraph.NODE_TITLE_HEIGHT + 0.5, + node.pos[1] - LiteGraph.NODE_TITLE_HEIGHT + 0.5, + LiteGraph.NODE_TITLE_HEIGHT, + LiteGraph.NODE_TITLE_HEIGHT, + ) + } + ctx.fillStyle = "#FFF" + ctx.fillText( + stringOrEmpty(node.order), + node.pos[0] + LiteGraph.NODE_TITLE_HEIGHT * -0.5, + node.pos[1] - 6, + ) + } + ctx.globalAlpha = 1 + } + + /** + * draws the widgets stored inside a node + **/ + drawNodeWidgets( + node: LGraphNode, + posY: number, + ctx: CanvasRenderingContext2D, + active_widget: IWidget, + ) { + if (!node.widgets || !node.widgets.length) return 0 + const width = node.size[0] + const widgets = node.widgets + posY += 2 + const H = LiteGraph.NODE_WIDGET_HEIGHT + const show_text = this.ds.scale > 0.5 + ctx.save() + ctx.globalAlpha = this.editor_alpha + const background_color = LiteGraph.WIDGET_BGCOLOR + const text_color = LiteGraph.WIDGET_TEXT_COLOR + const secondary_text_color = LiteGraph.WIDGET_SECONDARY_TEXT_COLOR + const margin = 15 + + for (let i = 0; i < widgets.length; ++i) { + const w = widgets[i] + if (w.hidden || (w.advanced && !node.showAdvanced)) continue + const y = w.y || posY + const outline_color = w.advanced ? LiteGraph.WIDGET_ADVANCED_OUTLINE_COLOR : LiteGraph.WIDGET_OUTLINE_COLOR + + if (w === this.link_over_widget) { + ctx.fillStyle = this.default_connection_color_byType[this.link_over_widget_type] || + this.default_connection_color.input_on + + // Manually draw a slot next to the widget simulating an input + drawSlot(ctx, {}, [10, y + 10], {}) + } + + w.last_y = y + ctx.strokeStyle = outline_color + ctx.fillStyle = "#222" + ctx.textAlign = "left" + // ctx.lineWidth = 2; + if (w.disabled) ctx.globalAlpha *= 0.5 + const widget_width = w.width || width + + switch (w.type) { + case "button": + ctx.fillStyle = background_color + if (w.clicked) { + ctx.fillStyle = "#AAA" + w.clicked = false + this.dirty_canvas = true + } + ctx.fillRect(margin, y, widget_width - margin * 2, H) + if (show_text && !w.disabled) + ctx.strokeRect(margin, y, widget_width - margin * 2, H) + if (show_text) { + ctx.textAlign = "center" + ctx.fillStyle = text_color + ctx.fillText(w.label || w.name, widget_width * 0.5, y + H * 0.7) + } + break + case "toggle": + ctx.textAlign = "left" + ctx.strokeStyle = outline_color + ctx.fillStyle = background_color + ctx.beginPath() + if (show_text) + ctx.roundRect(margin, y, widget_width - margin * 2, H, [H * 0.5]) + else ctx.rect(margin, y, widget_width - margin * 2, H) + ctx.fill() + if (show_text && !w.disabled) ctx.stroke() + ctx.fillStyle = w.value ? "#89A" : "#333" + ctx.beginPath() + ctx.arc( + widget_width - margin * 2, + y + H * 0.5, + H * 0.36, + 0, + Math.PI * 2, + ) + ctx.fill() + if (show_text) { + ctx.fillStyle = secondary_text_color + const label = w.label || w.name + if (label != null) { + ctx.fillText(label, margin * 2, y + H * 0.7) + } + ctx.fillStyle = w.value ? text_color : secondary_text_color + ctx.textAlign = "right" + ctx.fillText( + w.value ? w.options.on || "true" : w.options.off || "false", + widget_width - 40, + y + H * 0.7, + ) + } + break + case "slider": { + ctx.fillStyle = background_color + ctx.fillRect(margin, y, widget_width - margin * 2, H) + const range = w.options.max - w.options.min + let nvalue = (w.value - w.options.min) / range + if (nvalue < 0.0) nvalue = 0.0 + if (nvalue > 1.0) nvalue = 1.0 + ctx.fillStyle = w.options.hasOwnProperty("slider_color") + ? w.options.slider_color + : active_widget == w + ? "#89A" + : "#678" + ctx.fillRect(margin, y, nvalue * (widget_width - margin * 2), H) + if (show_text && !w.disabled) + ctx.strokeRect(margin, y, widget_width - margin * 2, H) + if (w.marker) { + let marker_nvalue = (w.marker - w.options.min) / range + if (marker_nvalue < 0.0) marker_nvalue = 0.0 + if (marker_nvalue > 1.0) marker_nvalue = 1.0 + ctx.fillStyle = w.options.hasOwnProperty("marker_color") + ? w.options.marker_color + : "#AA9" + ctx.fillRect( + margin + marker_nvalue * (widget_width - margin * 2), + y, + 2, + H, + ) + } + if (show_text) { + ctx.textAlign = "center" + ctx.fillStyle = text_color + ctx.fillText( + w.label || + w.name + + " " + + Number(w.value).toFixed( + w.options.precision != null ? w.options.precision : 3, + ), + widget_width * 0.5, + y + H * 0.7, + ) + } + break + } + case "number": + case "combo": + ctx.textAlign = "left" + ctx.strokeStyle = outline_color + ctx.fillStyle = background_color + ctx.beginPath() + if (show_text) + ctx.roundRect(margin, y, widget_width - margin * 2, H, [H * 0.5]) + else ctx.rect(margin, y, widget_width - margin * 2, H) + ctx.fill() + if (show_text) { + if (!w.disabled) ctx.stroke() + ctx.fillStyle = text_color + if (!w.disabled) { + ctx.beginPath() + ctx.moveTo(margin + 16, y + 5) + ctx.lineTo(margin + 6, y + H * 0.5) + ctx.lineTo(margin + 16, y + H - 5) + ctx.fill() + ctx.beginPath() + ctx.moveTo(widget_width - margin - 16, y + 5) + ctx.lineTo(widget_width - margin - 6, y + H * 0.5) + ctx.lineTo(widget_width - margin - 16, y + H - 5) + ctx.fill() + } + ctx.fillStyle = secondary_text_color + ctx.fillText(w.label || w.name, margin * 2 + 5, y + H * 0.7) + ctx.fillStyle = text_color + ctx.textAlign = "right" + if (w.type == "number") { + ctx.fillText( + Number(w.value).toFixed( + w.options.precision !== undefined + ? w.options.precision + : 3, + ), + widget_width - margin * 2 - 20, + y + H * 0.7, + ) + } else { + let v = typeof w.value === "number" ? String(w.value) : w.value + if (w.options.values) { + let values = w.options.values + if (typeof values === "function") + // @ts-expect-error + values = values() + if (values && !Array.isArray(values)) + v = values[w.value] + } + const labelWidth = ctx.measureText(w.label || w.name).width + margin * 2 + const inputWidth = widget_width - margin * 4 + const availableWidth = inputWidth - labelWidth + const textWidth = ctx.measureText(v).width + if (textWidth > availableWidth) { + const ELLIPSIS = "\u2026" + const ellipsisWidth = ctx.measureText(ELLIPSIS).width + const charWidthAvg = ctx.measureText("a").width + if (availableWidth <= ellipsisWidth) { + v = "\u2024" // One dot leader + } else { + v = `${v}` + const overflowWidth = (textWidth + ellipsisWidth) - availableWidth + // Only first 3 characters need to be measured precisely + if (overflowWidth + charWidthAvg * 3 > availableWidth) { + const preciseRange = availableWidth + charWidthAvg * 3 + const preTruncateCt = Math.floor((preciseRange - ellipsisWidth) / charWidthAvg) + v = v.substr(0, preTruncateCt) } - ] - if (node.resizable !== false) { - options.push({ - content: "Resize", callback: LGraphCanvas.onMenuResizeNode - }) - } - if (node.collapsible) { - options.push({ - content: node.collapsed ? "Expand" : "Collapse", - callback: LGraphCanvas.onMenuNodeCollapse - }) - } - if (node.widgets?.some(w => w.advanced)) { - options.push({ - content: node.showAdvanced ? "Hide Advanced" : "Show Advanced", - callback: LGraphCanvas.onMenuToggleAdvanced - }) + while (ctx.measureText(v).width + ellipsisWidth > availableWidth) { + v = v.substr(0, v.length - 1) + } + v += ELLIPSIS + } } - options.push( - { - content: node.pinned ? "Unpin" : "Pin", - callback: (...args) => { - // @ts-expect-error Not impl. - LGraphCanvas.onMenuNodePin(...args) - for (const i in this.selected_nodes) { - const node = this.selected_nodes[i] - node.pin() - } - this.setDirty(true, true) - } - }, - { - content: "Colors", - has_submenu: true, - callback: LGraphCanvas.onMenuNodeColors - }, - { - content: "Shapes", - has_submenu: true, - callback: LGraphCanvas.onMenuNodeShapes - }, - null + ctx.fillText( + v, + widget_width - margin * 2 - 20, + y + H * 0.7, ) + } } + break + case "string": + case "text": + ctx.textAlign = "left" + ctx.strokeStyle = outline_color + ctx.fillStyle = background_color + ctx.beginPath() - const inputs = node.onGetInputs?.() - if (inputs?.length) - options[0].disabled = false - - const outputs = node.onGetOutputs?.() - if (outputs?.length) - options[1].disabled = false + if (show_text) + ctx.roundRect(margin, y, widget_width - margin * 2, H, [H * 0.5]) + else + ctx.rect(margin, y, widget_width - margin * 2, H) + ctx.fill() + if (show_text) { + if (!w.disabled) ctx.stroke() + ctx.save() + ctx.beginPath() + ctx.rect(margin, y, widget_width - margin * 2, H) + ctx.clip() + + // ctx.stroke(); + ctx.fillStyle = secondary_text_color + const label = w.label || w.name + if (label != null) ctx.fillText(label, margin * 2, y + H * 0.7) + ctx.fillStyle = text_color + ctx.textAlign = "right" + ctx.fillText( + String(w.value).substr(0, 30), + widget_width - margin * 2, + y + H * 0.7, + ) // 30 chars max + ctx.restore() + } + break + // Custom widgets + default: + w.draw?.(ctx, node, widget_width, y, H) + break + } + posY += (w.computeSize ? w.computeSize(widget_width)[1] : H) + 4 + ctx.globalAlpha = this.editor_alpha + } + ctx.restore() + ctx.textAlign = "left" + } + + /** + * draws every group area in the background + **/ + drawGroups(canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D): void { + if (!this.graph) return + + const groups = this.graph._groups + + ctx.save() + ctx.globalAlpha = 0.5 * this.editor_alpha + const drawSnapGuides = this.#snapToGrid && this.isDragging + + for (let i = 0; i < groups.length; ++i) { + const group = groups[i] + + if (!overlapBounding(this.visible_area, group._bounding)) { + continue + } // out of the visible area + + // Draw snap shadow + if (drawSnapGuides && this.selectedItems.has(group)) + this.drawSnapGuide(ctx, group) + + group.draw(this, ctx) + } + + ctx.restore() + } + + adjustNodesSize(): void { + const nodes = this.graph._nodes + for (let i = 0; i < nodes.length; ++i) { + nodes[i].size = nodes[i].computeSize() + } + this.setDirty(true, true) + } + + /** + * resizes the canvas to a given size, if no size is passed, then it tries to fill the parentNode + * @todo Remove or rewrite + **/ + resize(width?: number, height?: number): void { + if (!width && !height) { + const parent = this.canvas.parentElement + width = parent.offsetWidth + height = parent.offsetHeight + } + + if (this.canvas.width == width && this.canvas.height == height) return + + this.canvas.width = width + this.canvas.height = height + this.bgcanvas.width = this.canvas.width + this.bgcanvas.height = this.canvas.height + this.setDirty(true, true) + } + + onNodeSelectionChange(): void {} + + /** + * Determines the furthest nodes in each direction for the currently selected nodes + * @return {{left: LGraphNode, top: LGraphNode, right: LGraphNode, bottom: LGraphNode}} + */ + boundaryNodesForSelection(): NullableProperties { + return LGraphCanvas.getBoundaryNodes(this.selected_nodes) + } + + showLinkMenu(segment: LinkSegment, e: CanvasMouseEvent): boolean { + const { graph } = this + const node_left = graph.getNodeById(segment.origin_id) + const fromType = node_left?.outputs?.[segment.origin_slot]?.type ?? "*" + + const options = ["Add Node", null, "Delete", null] + if (this.reroutesEnabled) options.splice(1, 0, "Add Reroute") + + const title = "data" in segment && segment.data != null + ? segment.data.constructor.name + : null + const menu = new LiteGraph.ContextMenu(options, { + event: e, + title, + callback: inner_clicked.bind(this), + }) + + function inner_clicked(this: LGraphCanvas, v: string, options: unknown, e: MouseEvent) { + switch (v) { + case "Add Node": + LGraphCanvas.onMenuAdd(null, null, e, menu, function (node) { + if (!node.inputs?.length || !node.outputs?.length) return + + // leave the connection type checking inside connectByType + const options = this.reroutesEnabled + ? { afterRerouteId: segment.parentId } + : undefined + if (node_left.connectByType(segment.origin_slot, node, fromType, options)) { + node.pos[0] -= node.size[0] * 0.5 + } + }) + break - const extra = node.getExtraMenuOptions?.(this, options) - if (extra) { - extra.push(null) - options = extra.concat(options) + case "Add Reroute": { + this.adjustMouseEvent(e) + graph.createReroute([e.canvasX, e.canvasY], segment) + this.setDirty(false, true) + break + } + + case "Delete": + graph.removeLink(segment.id) + break + default: + } + } + + return false + } + + createDefaultNodeForSlot(optPass: ICreateNodeOptions): boolean { + const opts = Object.assign({ + nodeFrom: null, + slotFrom: null, + nodeTo: null, + slotTo: null, + position: [0, 0], + nodeType: null, + posAdd: [0, 0], + posSizeFix: [0, 0], + }, optPass || {}) + const { afterRerouteId } = opts + + const isFrom = opts.nodeFrom && opts.slotFrom !== null + const isTo = !isFrom && opts.nodeTo && opts.slotTo !== null + + if (!isFrom && !isTo) { + console.warn("No data passed to createDefaultNodeForSlot " + opts.nodeFrom + " " + opts.slotFrom + " " + opts.nodeTo + " " + opts.slotTo) + return false + } + if (!opts.nodeType) { + console.warn("No type to createDefaultNodeForSlot") + return false + } + + const nodeX = isFrom ? opts.nodeFrom : opts.nodeTo + let slotX = isFrom ? opts.slotFrom : opts.slotTo + + let iSlotConn: number | false = false + switch (typeof slotX) { + case "string": + iSlotConn = isFrom ? nodeX.findOutputSlot(slotX, false) : nodeX.findInputSlot(slotX, false) + slotX = isFrom ? nodeX.outputs[slotX] : nodeX.inputs[slotX] + break + case "object": + // ok slotX + iSlotConn = isFrom ? nodeX.findOutputSlot(slotX.name) : nodeX.findInputSlot(slotX.name) + break + case "number": + iSlotConn = slotX + slotX = isFrom ? nodeX.outputs[slotX] : nodeX.inputs[slotX] + break + case "undefined": + default: + console.warn("Cant get slot information " + slotX) + return false + } + + // check for defaults nodes for this slottype + const fromSlotType = slotX.type == LiteGraph.EVENT ? "_event_" : slotX.type + const slotTypesDefault = isFrom + ? LiteGraph.slot_types_default_out + : LiteGraph.slot_types_default_in + if (slotTypesDefault?.[fromSlotType]) { + // TODO: Remove "any" kludge + let nodeNewType: any = false + if (typeof slotTypesDefault[fromSlotType] == "object") { + for (const typeX in slotTypesDefault[fromSlotType]) { + if ( + opts.nodeType == slotTypesDefault[fromSlotType][typeX] || + opts.nodeType == "AUTO" + ) { + nodeNewType = slotTypesDefault[fromSlotType][typeX] + break + } + } + } else if ( + opts.nodeType == slotTypesDefault[fromSlotType] || + opts.nodeType == "AUTO" + ) { + nodeNewType = slotTypesDefault[fromSlotType] + } + if (nodeNewType) { + // TODO: Remove "any" kludge + let nodeNewOpts: any = false + if (typeof nodeNewType == "object" && nodeNewType.node) { + nodeNewOpts = nodeNewType + nodeNewType = nodeNewType.node + } + + // that.graph.beforeChange(); + const newNode = LiteGraph.createNode(nodeNewType) + if (newNode) { + // if is object pass options + if (nodeNewOpts) { + if (nodeNewOpts.properties) { + for (const i in nodeNewOpts.properties) { + newNode.addProperty(i, nodeNewOpts.properties[i]) + } + } + if (nodeNewOpts.inputs) { + newNode.inputs = [] + for (const i in nodeNewOpts.inputs) { + newNode.addOutput( + nodeNewOpts.inputs[i][0], + nodeNewOpts.inputs[i][1], + ) + } + } + if (nodeNewOpts.outputs) { + newNode.outputs = [] + for (const i in nodeNewOpts.outputs) { + newNode.addOutput( + nodeNewOpts.outputs[i][0], + nodeNewOpts.outputs[i][1], + ) + } + } + if (nodeNewOpts.title) { + newNode.title = nodeNewOpts.title + } + if (nodeNewOpts.json) { + newNode.configure(nodeNewOpts.json) + } + } + + // add the node + this.graph.add(newNode) + newNode.pos = [ + opts.position[0] + opts.posAdd[0] + (opts.posSizeFix[0] ? opts.posSizeFix[0] * newNode.size[0] : 0), + opts.position[1] + opts.posAdd[1] + (opts.posSizeFix[1] ? opts.posSizeFix[1] * newNode.size[1] : 0), + ] + + // connect the two! + if (isFrom) { + opts.nodeFrom.connectByType(iSlotConn, newNode, fromSlotType, { afterRerouteId }) + } else { + opts.nodeTo.connectByTypeOutput(iSlotConn, newNode, fromSlotType, { afterRerouteId }) + } + + // if connecting in between + if (isFrom && isTo) { + // TODO + } + + return true + } + console.log("failed creating " + nodeNewType) + } + } + return false + } + + showConnectionMenu(optPass: Partial): void { + const opts = Object.assign({ + nodeFrom: null, + slotFrom: null, + nodeTo: null, + slotTo: null, + e: null, + allow_searchbox: this.allow_searchbox, + showSearchBox: this.showSearchBox, + }, optPass || {}) + const that = this + const { afterRerouteId } = opts + + const isFrom = opts.nodeFrom && opts.slotFrom + const isTo = !isFrom && opts.nodeTo && opts.slotTo + + if (!isFrom && !isTo) { + console.warn("No data passed to showConnectionMenu") + return + } + + const nodeX = isFrom ? opts.nodeFrom : opts.nodeTo + let slotX = isFrom ? opts.slotFrom : opts.slotTo + + let iSlotConn: number + switch (typeof slotX) { + case "string": + iSlotConn = isFrom + ? nodeX.findOutputSlot(slotX, false) + : nodeX.findInputSlot(slotX, false) + slotX = isFrom ? nodeX.outputs[slotX] : nodeX.inputs[slotX] + break + case "object": + // ok slotX + iSlotConn = isFrom + ? nodeX.findOutputSlot(slotX.name) + : nodeX.findInputSlot(slotX.name) + break + case "number": + iSlotConn = slotX + slotX = isFrom ? nodeX.outputs[slotX] : nodeX.inputs[slotX] + break + default: + console.warn("Cant get slot information " + slotX) + return + } + + const options = ["Add Node", null] + + if (opts.allow_searchbox) { + options.push("Search") + options.push(null) + } + + // get defaults nodes for this slottype + const fromSlotType = slotX.type == LiteGraph.EVENT ? "_event_" : slotX.type + const slotTypesDefault = isFrom + ? LiteGraph.slot_types_default_out + : LiteGraph.slot_types_default_in + if (slotTypesDefault?.[fromSlotType]) { + if (typeof slotTypesDefault[fromSlotType] == "object") { + for (const typeX in slotTypesDefault[fromSlotType]) { + options.push(slotTypesDefault[fromSlotType][typeX]) + } + } else { + options.push(slotTypesDefault[fromSlotType]) + } + } + + // build menu + const menu = new LiteGraph.ContextMenu(options, { + event: opts.e, + title: + (slotX && slotX.name != "" + ? slotX.name + (fromSlotType ? " | " : "") + : "") + (slotX && fromSlotType ? fromSlotType : ""), + callback: inner_clicked, + }) + + // callback + function inner_clicked(v: string, options: unknown, e: MouseEvent) { + // console.log("Process showConnectionMenu selection"); + switch (v) { + case "Add Node": + LGraphCanvas.onMenuAdd(null, null, e, menu, function (node) { + if (isFrom) { + opts.nodeFrom.connectByType(iSlotConn, node, fromSlotType, { afterRerouteId }) + } else { + opts.nodeTo.connectByTypeOutput(iSlotConn, node, fromSlotType, { afterRerouteId }) + } + }) + break + case "Search": + if (isFrom) { + opts.showSearchBox(e, { node_from: opts.nodeFrom, slot_from: slotX, type_filter_in: fromSlotType }) + } else { + opts.showSearchBox(e, { node_to: opts.nodeTo, slot_from: slotX, type_filter_out: fromSlotType }) } + break + default: { + // check for defaults nodes for this slottype - if (node.clonable !== false) { - options.push({ - content: "Clone", - callback: LGraphCanvas.onMenuNodeClone - }) + const nodeCreated = that.createDefaultNodeForSlot(Object.assign(opts, { + position: [opts.e.canvasX, opts.e.canvasY], + nodeType: v, + afterRerouteId, + })) + break + } + } + } + } + + // refactor: there are different dialogs, some uses createDialog some dont + prompt( + title: string, + value: any, + callback: (arg0: any) => void, + event: CanvasMouseEvent, + multiline?: boolean, + ): HTMLDivElement { + const that = this + title = title || "" + + const dialog: IDialog = document.createElement("div") + dialog.is_modified = false + dialog.className = "graphdialog rounded" + dialog.innerHTML = multiline + ? " " + : " " + dialog.close = function () { + that.prompt_box = null + if (dialog.parentNode) { + dialog.parentNode.removeChild(dialog) + } + } + + const graphcanvas = LGraphCanvas.active_canvas + const canvas = graphcanvas.canvas + canvas.parentNode.appendChild(dialog) + + if (this.ds.scale > 1) dialog.style.transform = "scale(" + this.ds.scale + ")" + + let dialogCloseTimer = null + let prevent_timeout = 0 + LiteGraph.pointerListenerAdd(dialog, "leave", function () { + if (prevent_timeout) return + if (LiteGraph.dialog_close_on_mouse_leave) + if (!dialog.is_modified && LiteGraph.dialog_close_on_mouse_leave) + dialogCloseTimer = setTimeout( + dialog.close, + LiteGraph.dialog_close_on_mouse_leave_delay, + ) // dialog.close(); + }) + LiteGraph.pointerListenerAdd(dialog, "enter", function () { + if (LiteGraph.dialog_close_on_mouse_leave && dialogCloseTimer) + clearTimeout(dialogCloseTimer) + }) + const selInDia = dialog.querySelectorAll("select") + if (selInDia) { + // if filtering, check focus changed to comboboxes and prevent closing + for (const selIn of selInDia) { + selIn.addEventListener("click", function () { + prevent_timeout++ + }) + selIn.addEventListener("blur", function () { + prevent_timeout = 0 + }) + selIn.addEventListener("change", function () { + prevent_timeout = -1 + }) + } + } + this.prompt_box?.close() + this.prompt_box = dialog + + const name_element: HTMLSpanElement = dialog.querySelector(".name") + name_element.innerText = title + const value_element: HTMLTextAreaElement | HTMLInputElement = + dialog.querySelector(".value") + value_element.value = value + value_element.select() + + const input = value_element + input.addEventListener("keydown", function (e: KeyboardEvent) { + dialog.is_modified = true + if (e.keyCode == 27) { + // ESC + dialog.close() + } else if ( + e.keyCode == 13 && + (e.target as Element).localName != "textarea" + ) { + if (callback) { + callback(this.value) + } + dialog.close() + } else { + return + } + e.preventDefault() + e.stopPropagation() + }) + + const button = dialog.querySelector("button") + button.addEventListener("click", function () { + callback?.(input.value) + that.setDirty(true) + dialog.close() + }) + + const rect = canvas.getBoundingClientRect() + let offsetx = -20 + let offsety = -20 + if (rect) { + offsetx -= rect.left + offsety -= rect.top + } + + if (event) { + dialog.style.left = event.clientX + offsetx + "px" + dialog.style.top = event.clientY + offsety + "px" + } else { + dialog.style.left = canvas.width * 0.5 + offsetx + "px" + dialog.style.top = canvas.height * 0.5 + offsety + "px" + } + + setTimeout(function () { + input.focus() + const clickTime = Date.now() + function handleOutsideClick(e: MouseEvent) { + if (e.target === canvas && Date.now() - clickTime > 256) { + dialog.close() + canvas.parentNode.removeEventListener("click", handleOutsideClick) + canvas.parentNode.removeEventListener("touchend", handleOutsideClick) + } + } + canvas.parentNode.addEventListener("click", handleOutsideClick) + canvas.parentNode.addEventListener("touchend", handleOutsideClick) + }, 10) + + return dialog + } + + showSearchBox( + event: MouseEvent, + options?: IShowSearchOptions, + ): HTMLDivElement { + // proposed defaults + const def_options: IShowSearchOptions = { + slot_from: null, + node_from: null, + node_to: null, + do_type_filter: LiteGraph.search_filter_enabled, // TODO check for registered_slot_[in/out]_types not empty // this will be checked for functionality enabled : filter on slot type, in and out + + // @ts-expect-error + type_filter_in: false, // these are default: pass to set initially set values + + type_filter_out: false, + show_general_if_none_on_typefilter: true, + show_general_after_typefiltered: true, + hide_on_mouse_leave: LiteGraph.search_hide_on_mouse_leave, + show_all_if_empty: true, + show_all_on_open: LiteGraph.search_show_all_on_open, + } + options = Object.assign(def_options, options || {}) + + // console.log(options); + const that = this + const graphcanvas = LGraphCanvas.active_canvas + const canvas = graphcanvas.canvas + const root_document = canvas.ownerDocument || document + + const dialog = document.createElement("div") + dialog.className = "litegraph litesearchbox graphdialog rounded" + dialog.innerHTML = "Search " + if (options.do_type_filter) { + dialog.innerHTML += "" + dialog.innerHTML += "" + } + dialog.innerHTML += "
" + + if (root_document.fullscreenElement) + root_document.fullscreenElement.appendChild(dialog) + else { + root_document.body.appendChild(dialog) + root_document.body.style.overflow = "hidden" + } + + // dialog element has been appended + let selIn + let selOut + if (options.do_type_filter) { + selIn = dialog.querySelector(".slot_in_type_filter") + selOut = dialog.querySelector(".slot_out_type_filter") + } + + // @ts-expect-error Panel? + dialog.close = function () { + that.search_box = null + this.blur() + canvas.focus() + root_document.body.style.overflow = "" + + setTimeout(function () { + that.canvas.focus() + }, 20) // important, if canvas loses focus keys wont be captured + dialog.parentNode?.removeChild(dialog) + } + + if (this.ds.scale > 1) { + dialog.style.transform = "scale(" + this.ds.scale + ")" + } + + // hide on mouse leave + if (options.hide_on_mouse_leave) { + // FIXME: Remove "any" kludge + let prevent_timeout: any = false + let timeout_close = null + LiteGraph.pointerListenerAdd(dialog, "enter", function () { + if (timeout_close) { + clearTimeout(timeout_close) + timeout_close = null + } + }) + LiteGraph.pointerListenerAdd(dialog, "leave", function () { + if (prevent_timeout) + return + timeout_close = setTimeout(function () { + // @ts-expect-error Panel? + dialog.close() + }, typeof options.hide_on_mouse_leave === "number" ? options.hide_on_mouse_leave : 500) + }) + // if filtering, check focus changed to comboboxes and prevent closing + if (options.do_type_filter) { + selIn.addEventListener("click", function () { + prevent_timeout++ + }) + selIn.addEventListener("blur", function () { + prevent_timeout = 0 + }) + selIn.addEventListener("change", function () { + prevent_timeout = -1 + }) + selOut.addEventListener("click", function () { + prevent_timeout++ + }) + selOut.addEventListener("blur", function () { + prevent_timeout = 0 + }) + selOut.addEventListener("change", function () { + prevent_timeout = -1 + }) + } + } + + // @ts-expect-error Panel? + that.search_box?.close() + that.search_box = dialog + + const helper = dialog.querySelector(".helper") + + let first = null + let timeout = null + let selected = null + + const input = dialog.querySelector("input") + if (input) { + input.addEventListener("blur", function () { + this.focus() + }) + input.addEventListener("keydown", function (e) { + if (e.keyCode == 38) { + // UP + changeSelection(false) + } else if (e.keyCode == 40) { + // DOWN + changeSelection(true) + } else if (e.keyCode == 27) { + // ESC + // @ts-expect-error Panel? + dialog.close() + } else if (e.keyCode == 13) { + if (selected) { + select(unescape(selected.dataset["type"])) + } else if (first) { + select(first) + } else { + // @ts-expect-error Panel? + dialog.close() + } + } else { + if (timeout) { + clearInterval(timeout) + } + timeout = setTimeout(refreshHelper, 10) + return } + e.preventDefault() + e.stopPropagation() + e.stopImmediatePropagation() + return true + }) + } - if (Object.keys(this.selected_nodes).length > 1) { - options.push({ - content: "Align Selected To", - has_submenu: true, - callback: LGraphCanvas.onNodeAlign, - }) - options.push({ - content: "Distribute Nodes", - has_submenu: true, - callback: LGraphCanvas.createDistributeMenu, - }) - } + // if should filter on type, load and fill selected and choose elements if passed + if (options.do_type_filter) { + if (selIn) { + const aSlots = LiteGraph.slot_types_in + const nSlots = aSlots.length // this for object :: Object.keys(aSlots).length; - options.push(null, { - content: "Remove", - disabled: !(node.removable !== false && !node.block_delete), - callback: LGraphCanvas.onMenuNodeRemove + if ( + options.type_filter_in == LiteGraph.EVENT || + options.type_filter_in == LiteGraph.ACTION + ) + options.type_filter_in = "_event_" + /* this will filter on * .. but better do it manually in case + else if(options.type_filter_in === "" || options.type_filter_in === 0) + options.type_filter_in = "*"; */ + for (let iK = 0; iK < nSlots; iK++) { + const opt = document.createElement("option") + opt.value = aSlots[iK] + opt.innerHTML = aSlots[iK] + selIn.appendChild(opt) + if ( + // @ts-expect-error + options.type_filter_in !== false && + (options.type_filter_in + "").toLowerCase() == + (aSlots[iK] + "").toLowerCase() + ) { + // selIn.selectedIndex .. + opt.selected = true + // console.log("comparing IN "+options.type_filter_in+" :: "+aSlots[iK]); + } else { + // console.log("comparing OUT "+options.type_filter_in+" :: "+aSlots[iK]); + } + } + selIn.addEventListener("change", function () { + refreshHelper() }) - - node.graph?.onGetNodeMenuOptions?.(options, node) - - return options - } - getGroupMenuOptions(group: LGraphGroup): IContextMenuValue[] { - console.warn("LGraphCanvas.getGroupMenuOptions is deprecated, use LGraphGroup.getMenuOptions instead") - return group.getMenuOptions() + } + if (selOut) { + const aSlots = LiteGraph.slot_types_out + const nSlots = aSlots.length // this for object :: Object.keys(aSlots).length; + + if ( + options.type_filter_out == LiteGraph.EVENT || + options.type_filter_out == LiteGraph.ACTION + ) + options.type_filter_out = "_event_" + /* this will filter on * .. but better do it manually in case + else if(options.type_filter_out === "" || options.type_filter_out === 0) + options.type_filter_out = "*"; */ + for (let iK = 0; iK < nSlots; iK++) { + const opt = document.createElement("option") + opt.value = aSlots[iK] + opt.innerHTML = aSlots[iK] + selOut.appendChild(opt) + if ( + options.type_filter_out !== false && + (options.type_filter_out + "").toLowerCase() == + (aSlots[iK] + "").toLowerCase() + ) + opt.selected = true + } + selOut.addEventListener("change", function () { + refreshHelper() + }) + } } - processContextMenu(node: LGraphNode, event: CanvasMouseEvent): void { - const that = this - const canvas = LGraphCanvas.active_canvas - const ref_window = canvas.getCanvasWindow() - // TODO: Remove type kludge - let menu_info: (IContextMenuValue | string)[] = null - const options: IContextMenuOptions = { - event: event, - callback: inner_option_clicked, - extra: node - } + // compute best position + const rect = canvas.getBoundingClientRect() - if (node) options.title = node.type + const left = (event ? event.clientX : rect.left + rect.width * 0.5) - 80 + const top = (event ? event.clientY : rect.top + rect.height * 0.5) - 20 + dialog.style.left = left + "px" + dialog.style.top = top + "px" - //check if mouse is in input - let slot: ReturnType = null - if (node) { - slot = node.getSlotInPosition(event.canvasX, event.canvasY) - LGraphCanvas.active_node = node - } + // To avoid out of screen problems + if (event.layerY > rect.height - 200) + // @ts-expect-error + helper.style.maxHeight = rect.height - event.layerY - 20 + "px" - if (slot) { - //on slot - menu_info = [] - if (node.getSlotMenuOptions) { - menu_info = node.getSlotMenuOptions(slot) - } else { - if (slot?.output?.links?.length) - menu_info.push({ content: "Disconnect Links", slot: slot }) - - const _slot = slot.input || slot.output - if (_slot.removable) { - menu_info.push( - _slot.locked - ? "Cannot remove" - : { content: "Remove Slot", slot: slot } - ) - } - if (!_slot.nameLocked) - menu_info.push({ content: "Rename Slot", slot: slot }) + requestAnimationFrame(function () { + input.focus() + }) + if (options.show_all_on_open) refreshHelper() - } - // @ts-expect-error Slot type can be number and has number checks - options.title = (slot.input ? slot.input.type : slot.output.type) || "*" - if (slot.input && slot.input.type == LiteGraph.ACTION) - options.title = "Action" - - if (slot.output && slot.output.type == LiteGraph.EVENT) - options.title = "Event" - } else if (node) { - //on node - menu_info = this.getNodeMenuOptions(node) + function select(name) { + if (name) { + if (that.onSearchBoxSelection) { + that.onSearchBoxSelection(name, event, graphcanvas) } else { - menu_info = this.getCanvasMenuOptions() - - // Check for reroutes - if (this.reroutesEnabled) { - const reroute = this.graph.getRerouteOnPos(event.canvasX, event.canvasY) - if (reroute) { - menu_info.unshift({ - content: "Delete Reroute", - callback: () => this.graph.removeReroute(reroute.id) - }, null) - } + const extra = LiteGraph.searchbox_extras[name.toLowerCase()] + if (extra) name = extra.type + + graphcanvas.graph.beforeChange() + const node = LiteGraph.createNode(name) + if (node) { + node.pos = graphcanvas.convertEventToCanvasOffset(event) + graphcanvas.graph.add(node, false) + } + + if (extra?.data) { + if (extra.data.properties) { + for (const i in extra.data.properties) { + node.addProperty(i, extra.data.properties[i]) + } + } + if (extra.data.inputs) { + node.inputs = [] + for (const i in extra.data.inputs) { + node.addOutput( + extra.data.inputs[i][0], + extra.data.inputs[i][1], + ) + } + } + if (extra.data.outputs) { + node.outputs = [] + for (const i in extra.data.outputs) { + node.addOutput( + extra.data.outputs[i][0], + extra.data.outputs[i][1], + ) + } } + if (extra.data.title) { + node.title = extra.data.title + } + if (extra.data.json) { + node.configure(extra.data.json) + } + } - const group = this.graph.getGroupOnPos( - event.canvasX, - event.canvasY - ) - if (group) { - //on group - menu_info.push(null, { - content: "Edit Group", - has_submenu: true, - submenu: { - title: "Group", - extra: group, - options: group.getMenuOptions() - } - }) + // join node after inserting + if (options.node_from) { + // FIXME: any + let iS: any = false + switch (typeof options.slot_from) { + case "string": + iS = options.node_from.findOutputSlot(options.slot_from) + break + case "object": + iS = options.slot_from.name + ? options.node_from.findOutputSlot(options.slot_from.name) + : -1 + // @ts-expect-error change interface check + if (iS == -1 && typeof options.slot_from.slot_index !== "undefined") iS = options.slot_from.slot_index + break + case "number": + iS = options.slot_from + break + default: + iS = 0 // try with first if no name set + } + if (typeof options.node_from.outputs[iS] !== "undefined") { + if (iS !== false && iS > -1) { + options.node_from.connectByType(iS, node, options.node_from.outputs[iS].type) + } + } else { + // console.warn("cant find slot " + options.slot_from); + } + } + if (options.node_to) { + // FIXME: any + let iS: any = false + switch (typeof options.slot_from) { + case "string": + iS = options.node_to.findInputSlot(options.slot_from) + break + case "object": + iS = options.slot_from.name + ? options.node_to.findInputSlot(options.slot_from.name) + : -1 + // @ts-expect-error change interface check + if (iS == -1 && typeof options.slot_from.slot_index !== "undefined") iS = options.slot_from.slot_index + break + case "number": + iS = options.slot_from + break + default: + iS = 0 // try with first if no name set } + if (typeof options.node_to.inputs[iS] !== "undefined") { + if (iS !== false && iS > -1) { + // try connection + options.node_to.connectByTypeOutput(iS, node, options.node_to.inputs[iS].type) + } + } else { + // console.warn("cant find slot_nodeTO " + options.slot_from); + } + } + + graphcanvas.graph.afterChange() + } + } + + // @ts-expect-error Panel? + dialog.close() + } + + function changeSelection(forward) { + const prev = selected + if (!selected) { + selected = forward + ? helper.childNodes[0] + : helper.childNodes[helper.childNodes.length] + } else { + selected.classList.remove("selected") + selected = forward + ? selected.nextSibling + : selected.previousSibling + selected ||= prev + } + if (!selected) return + + selected.classList.add("selected") + selected.scrollIntoView({ block: "end", behavior: "smooth" }) + } + + function refreshHelper() { + timeout = null + let str = input.value + first = null + helper.innerHTML = "" + if (!str && !options.show_all_if_empty) return + + if (that.onSearchBox) { + const list = that.onSearchBox(helper, str, graphcanvas) + if (list) { + for (let i = 0; i < list.length; ++i) { + addResult(list[i]) + } + } + } else { + let c = 0 + str = str.toLowerCase() + const filter = graphcanvas.filter || graphcanvas.graph.filter + + // FIXME: any + // filter by type preprocess + let sIn: any = false + let sOut: any = false + if (options.do_type_filter && that.search_box) { + sIn = that.search_box.querySelector(".slot_in_type_filter") + sOut = that.search_box.querySelector(".slot_out_type_filter") + } + + // extras + for (const i in LiteGraph.searchbox_extras) { + const extra = LiteGraph.searchbox_extras[i] + if ( + (!options.show_all_if_empty || str) && + extra.desc.toLowerCase().indexOf(str) === -1 + ) + continue + const ctor = LiteGraph.registered_node_types[extra.type] + if (ctor && ctor.filter != filter) continue + if (!inner_test_filter(extra.type)) continue + + addResult(extra.desc, "searchbox_extra") + if (LGraphCanvas.search_limit !== -1 && c++ > LGraphCanvas.search_limit) { + break + } + } + + let filtered = null + if (Array.prototype.filter) { + // filter supported + const keys = Object.keys(LiteGraph.registered_node_types) // types + filtered = keys.filter(inner_test_filter) + } else { + filtered = [] + for (const i in LiteGraph.registered_node_types) { + if (inner_test_filter(i)) filtered.push(i) + } } - //show menu - if (!menu_info) - return + for (let i = 0; i < filtered.length; i++) { + addResult(filtered[i]) + if (LGraphCanvas.search_limit !== -1 && c++ > LGraphCanvas.search_limit) + break + } - // @ts-expect-error Remove param ref_window - unused - new LiteGraph.ContextMenu(menu_info, options, ref_window) + // add general type if filtering + if ( + options.show_general_after_typefiltered && + (sIn.value || sOut.value) + ) { + // FIXME: Undeclared variable again + // @ts-expect-error + filtered_extra = [] + for (const i in LiteGraph.registered_node_types) { + if ( + inner_test_filter(i, { + inTypeOverride: sIn && sIn.value ? "*" : false, + outTypeOverride: sOut && sOut.value ? "*" : false, + }) + ) + // @ts-expect-error + filtered_extra.push(i) + } + // @ts-expect-error + for (let i = 0; i < filtered_extra.length; i++) { + // @ts-expect-error + addResult(filtered_extra[i], "generic_type") + if (LGraphCanvas.search_limit !== -1 && c++ > LGraphCanvas.search_limit) + break + } + } + + // check il filtering gave no results + if ( + (sIn.value || sOut.value) && + helper.childNodes.length == 0 && + options.show_general_if_none_on_typefilter + ) { + // @ts-expect-error + filtered_extra = [] + for (const i in LiteGraph.registered_node_types) { + if (inner_test_filter(i, { skipFilter: true })) + // @ts-expect-error + filtered_extra.push(i) + } + // @ts-expect-error + for (let i = 0; i < filtered_extra.length; i++) { + // @ts-expect-error + addResult(filtered_extra[i], "not_in_filter") + if (LGraphCanvas.search_limit !== -1 && c++ > LGraphCanvas.search_limit) + break + } + } + + function inner_test_filter( + type: string, + optsIn?: + | number + | { + inTypeOverride?: string | boolean + outTypeOverride?: string | boolean + skipFilter?: boolean + }, + ): boolean { + optsIn = optsIn || {} + const optsDef = { + skipFilter: false, + inTypeOverride: false, + outTypeOverride: false, + } + const opts = Object.assign(optsDef, optsIn) + const ctor = LiteGraph.registered_node_types[type] + if (filter && ctor.filter != filter) return false + if ( + (!options.show_all_if_empty || str) && + type.toLowerCase().indexOf(str) === -1 && + (!ctor.title || ctor.title.toLowerCase().indexOf(str) === -1) + ) + return false - function inner_option_clicked(v, options) { - if (!v) return + // filter by slot IN, OUT types + if (options.do_type_filter && !opts.skipFilter) { + const sType = type + + let sV = opts.inTypeOverride !== false + ? opts.inTypeOverride + : sIn.value + // type is stored + if (sIn && sV && LiteGraph.registered_slot_in_types[sV]?.nodes) { + const doesInc = LiteGraph.registered_slot_in_types[sV].nodes.includes(sType) + if (doesInc === false) return false + } + + sV = sOut.value + if (opts.outTypeOverride !== false) sV = opts.outTypeOverride + // type is stored + if (sOut && sV && LiteGraph.registered_slot_out_types[sV]?.nodes) { + const doesInc = LiteGraph.registered_slot_out_types[sV].nodes.includes(sType) + if (doesInc === false) return false + } + } + return true + } + } + + function addResult(type: string, className?: string): void { + const help = document.createElement("div") + first ||= type + + const nodeType = LiteGraph.registered_node_types[type] + if (nodeType?.title) { + help.innerText = nodeType?.title + const typeEl = document.createElement("span") + typeEl.className = "litegraph lite-search-item-type" + typeEl.textContent = type + help.append(typeEl) + } else { + help.innerText = type + } - if (v.content == "Remove Slot") { - const info = v.slot - node.graph.beforeChange() - if (info.input) { - node.removeInput(info.slot) - } else if (info.output) { - node.removeOutput(info.slot) - } - node.graph.afterChange() - return - } else if (v.content == "Disconnect Links") { - const info = v.slot - node.graph.beforeChange() - if (info.output) { - node.disconnectOutput(info.slot) - } else if (info.input) { - node.disconnectInput(info.slot) - } - node.graph.afterChange() - return - } else if (v.content == "Rename Slot") { - const info = v.slot - const slot_info = info.input - ? node.getInputInfo(info.slot) - : node.getOutputInfo(info.slot) - const dialog = that.createDialog( - "Name", - options - ) - const input = dialog.querySelector("input") - if (input && slot_info) { - input.value = slot_info.label || "" - } - const inner = function () { - node.graph.beforeChange() - if (input.value) { - if (slot_info) { - slot_info.label = input.value - } - that.setDirty(true) - } - dialog.close() - node.graph.afterChange() - } - dialog.querySelector("button").addEventListener("click", inner) - input.addEventListener("keydown", function (e) { - dialog.is_modified = true - if (e.keyCode == 27) { - //ESC - dialog.close() - } else if (e.keyCode == 13) { - inner() // save - } else if (e.keyCode != 13 && (e.target as Element).localName != "textarea") { - return - } - e.preventDefault() - e.stopPropagation() - }) - input.focus() - } + help.dataset["type"] = escape(type) + help.className = "litegraph lite-search-item" + if (className) { + help.className += " " + className } + help.addEventListener("click", function () { + select(unescape(this.dataset["type"])) + }) + helper.appendChild(help) + } } - /** - * Starts an animation to fit the view around the specified selection of nodes. - * @param bounds The bounds to animate the view to, defined by a rectangle. - * @param animationParameters Various parameters for the camera movement animation. - */ - animateToBounds( - bounds: ReadOnlyRect, - { - duration = 350, - zoom = 0.75, - easing = EaseFunction.EASE_IN_OUT_QUAD - }: AnimationOptions = {} + return dialog + } + + showEditPropertyValue( + node: LGraphNode, + property: string, + options: IDialogOptions, + ): IDialog { + if (!node || node.properties[property] === undefined) return + + options = options || {} + + const info = node.getPropertyInfo(property) + const type = info.type + + let input_html = "" + + if ( + type == "string" || + type == "number" || + type == "array" || + type == "object" ) { - const easeFunctions = { - linear: (t: number) => t, - easeInQuad: (t: number) => t * t, - easeOutQuad: (t: number) => t * (2 - t), - easeInOutQuad: (t: number) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t) - } - const easeFunction = easeFunctions[easing] ?? easeFunctions.linear - - let animationId = null - const startTimestamp = performance.now() - const startX = this.ds.offset[0] - const startY = this.ds.offset[1] - const startScale = this.ds.scale - const cw = this.canvas.width / window.devicePixelRatio - const ch = this.canvas.height / window.devicePixelRatio - let targetScale = startScale - let targetX = startX - let targetY = startY - - if (zoom > 0) { - const targetScaleX = (zoom * cw) / Math.max(bounds[2], 300) - const targetScaleY = (zoom * ch) / Math.max(bounds[3], 300) - - // Choose the smaller scale to ensure the node fits into the viewport - // Ensure we don't go over the max scale - targetScale = Math.min(targetScaleX, targetScaleY, this.ds.max_scale) + input_html = "" + } else if ((type == "enum" || type == "combo") && info.values) { + input_html = "" + } else if (type == "boolean" || type == "toggle") { + input_html = + "" + } else { + console.warn("unknown type: " + type) + return + } + + const dialog = this.createDialog( + "" + + (info.label || property) + + "" + + input_html + + "", + options, + ) + + let input: HTMLInputElement | HTMLSelectElement + if ((type == "enum" || type == "combo") && info.values) { + input = dialog.querySelector("select") + input.addEventListener("change", function (e) { + dialog.modified() + setValue((e.target as HTMLSelectElement)?.value) + }) + } else if (type == "boolean" || type == "toggle") { + input = dialog.querySelector("input") + input?.addEventListener("click", function () { + dialog.modified() + // @ts-expect-error + setValue(!!input.checked) + }) + } else { + input = dialog.querySelector("input") + if (input) { + input.addEventListener("blur", function () { + this.focus() + }) + + let v = node.properties[property] !== undefined + ? node.properties[property] + : "" + if (type !== "string") { + v = JSON.stringify(v) } - targetX = -bounds[0] - bounds[2] * 0.5 + (cw * 0.5) / targetScale - targetY = -bounds[1] - bounds[3] * 0.5 + (ch * 0.5) / targetScale - const animate = (timestamp: number) => { - const elapsed = timestamp - startTimestamp - const progress = Math.min(elapsed / duration, 1) - const easedProgress = easeFunction(progress) + // @ts-expect-error + input.value = v + input.addEventListener("keydown", function (e) { + if (e.keyCode == 27) { + // ESC + dialog.close() + } else if (e.keyCode == 13) { + // ENTER + inner() // save + } else if (e.keyCode != 13) { + dialog.modified() + return + } + e.preventDefault() + e.stopPropagation() + }) + } + } + input?.focus() + + const button = dialog.querySelector("button") + button.addEventListener("click", inner) + + function inner() { + setValue(input.value) + } + + function setValue(value: string | number) { + if ( + info?.values && + typeof info.values === "object" && + info.values[value] != undefined + ) + value = info.values[value] + + if (typeof node.properties[property] == "number") { + value = Number(value) + } + if (type == "array" || type == "object") { + // @ts-expect-error JSON.parse doesn't care. + value = JSON.parse(value) + } + node.properties[property] = value + if (node.graph) { + node.graph._version++ + } + node.onPropertyChanged?.(property, value) + options.onclose?.() + dialog.close() + this.setDirty(true, true) + } + + return dialog + } + + // TODO refactor, theer are different dialog, some uses createDialog, some dont + createDialog(html: string, options: IDialogOptions): IDialog { + const def_options = { + checkForInput: false, + closeOnLeave: true, + closeOnLeave_checkModified: true, + } + options = Object.assign(def_options, options || {}) + const dialog: IDialog = document.createElement("div") + dialog.className = "graphdialog" + dialog.innerHTML = html + dialog.is_modified = false + + const rect = this.canvas.getBoundingClientRect() + let offsetx = -20 + let offsety = -20 + if (rect) { + offsetx -= rect.left + offsety -= rect.top + } + + if (options.position) { + offsetx += options.position[0] + offsety += options.position[1] + } else if (options.event) { + offsetx += options.event.clientX + offsety += options.event.clientY + } // centered + else { + offsetx += this.canvas.width * 0.5 + offsety += this.canvas.height * 0.5 + } + + dialog.style.left = offsetx + "px" + dialog.style.top = offsety + "px" + + this.canvas.parentNode.appendChild(dialog) + + // acheck for input and use default behaviour: save on enter, close on esc + if (options.checkForInput) { + const aI = dialog.querySelectorAll("input") + const focused = false + aI?.forEach(function (iX) { + iX.addEventListener("keydown", function (e) { + dialog.modified() + if (e.keyCode == 27) { + dialog.close() + } else if (e.keyCode != 13) { + return + } + // set value ? + e.preventDefault() + e.stopPropagation() + }) + if (!focused) iX.focus() + }) + } + + dialog.modified = function () { + dialog.is_modified = true + } + dialog.close = function () { + dialog.parentNode?.removeChild(dialog) + } + + let dialogCloseTimer = null + let prevent_timeout = 0 + dialog.addEventListener("mouseleave", function () { + if (prevent_timeout) return + + if (!dialog.is_modified && LiteGraph.dialog_close_on_mouse_leave) + dialogCloseTimer = setTimeout( + dialog.close, + LiteGraph.dialog_close_on_mouse_leave_delay, + ) // dialog.close(); + }) + dialog.addEventListener("mouseenter", function () { + if (options.closeOnLeave || LiteGraph.dialog_close_on_mouse_leave) + if (dialogCloseTimer) clearTimeout(dialogCloseTimer) + }) + const selInDia = dialog.querySelectorAll("select") + // if filtering, check focus changed to comboboxes and prevent closing + selInDia?.forEach(function (selIn) { + selIn.addEventListener("click", function () { + prevent_timeout++ + }) + selIn.addEventListener("blur", function () { + prevent_timeout = 0 + }) + selIn.addEventListener("change", function () { + prevent_timeout = -1 + }) + }) + + return dialog + } + + createPanel(title, options) { + options = options || {} + + const ref_window = options.window || window + // TODO: any kludge + const root: any = document.createElement("div") + root.className = "litegraph dialog" + root.innerHTML = "
" + root.header = root.querySelector(".dialog-header") + + if (options.width) + root.style.width = options.width + (typeof options.width === "number" ? "px" : "") + if (options.height) + root.style.height = options.height + (typeof options.height === "number" ? "px" : "") + if (options.closable) { + const close = document.createElement("span") + close.innerHTML = "✕" + close.classList.add("close") + close.addEventListener("click", function () { + root.close() + }) + root.header.appendChild(close) + } + root.title_element = root.querySelector(".dialog-title") + root.title_element.innerText = title + root.content = root.querySelector(".dialog-content") + root.alt_content = root.querySelector(".dialog-alt-content") + root.footer = root.querySelector(".dialog-footer") + + root.close = function () { + if (typeof root.onClose == "function") root.onClose() + root.parentNode?.removeChild(root) + /* XXX CHECK THIS */ + this.parentNode?.removeChild(this) + /* XXX this was not working, was fixed with an IF, check this */ + } + + // function to swap panel content + root.toggleAltContent = function (force: unknown) { + let vTo: string + let vAlt: string + if (typeof force != "undefined") { + vTo = force ? "block" : "none" + vAlt = force ? "none" : "block" + } else { + vTo = root.alt_content.style.display != "block" ? "block" : "none" + vAlt = root.alt_content.style.display != "block" ? "none" : "block" + } + root.alt_content.style.display = vTo + root.content.style.display = vAlt + } + + root.toggleFooterVisibility = function (force: unknown) { + let vTo: string + if (typeof force != "undefined") { + vTo = force ? "block" : "none" + } else { + vTo = root.footer.style.display != "block" ? "block" : "none" + } + root.footer.style.display = vTo + } + + root.clear = function () { + this.content.innerHTML = "" + } + + root.addHTML = function (code, classname, on_footer) { + const elem = document.createElement("div") + if (classname) elem.className = classname + elem.innerHTML = code + if (on_footer) root.footer.appendChild(elem) + else root.content.appendChild(elem) + return elem + } + + root.addButton = function (name, callback, options) { + // TODO: any kludge + const elem: any = document.createElement("button") + elem.innerText = name + elem.options = options + elem.classList.add("btn") + elem.addEventListener("click", callback) + root.footer.appendChild(elem) + return elem + } + + root.addSeparator = function () { + const elem = document.createElement("div") + elem.className = "separator" + root.content.appendChild(elem) + } + + root.addWidget = function (type, name, value, options, callback) { + options = options || {} + let str_value = String(value) + type = type.toLowerCase() + if (type == "number") str_value = value.toFixed(3) + + // FIXME: any kludge + const elem: any = document.createElement("div") + elem.className = "property" + elem.innerHTML = "" + elem.querySelector(".property_name").innerText = options.label || name + // TODO: any kludge + const value_element: any = elem.querySelector(".property_value") + value_element.innerText = str_value + elem.dataset["property"] = name + elem.dataset["type"] = options.type || type + elem.options = options + elem.value = value + + if (type == "code") + elem.addEventListener("click", function () { + root.inner_showCodePad(this.dataset["property"]) + }) + else if (type == "boolean") { + elem.classList.add("boolean") + if (value) elem.classList.add("bool-on") + elem.addEventListener("click", function () { + const propname = this.dataset["property"] + this.value = !this.value + this.classList.toggle("bool-on") + this.querySelector(".property_value").innerText = this.value + ? "true" + : "false" + innerChange(propname, this.value) + }) + } else if (type == "string" || type == "number") { + value_element.setAttribute("contenteditable", true) + value_element.addEventListener("keydown", function (e) { + // allow for multiline + if (e.code == "Enter" && (type != "string" || !e.shiftKey)) { + e.preventDefault() + this.blur() + } + }) + value_element.addEventListener("blur", function () { + let v = this.innerText + const propname = this.parentNode.dataset["property"] + const proptype = this.parentNode.dataset["type"] + if (proptype == "number") v = Number(v) + innerChange(propname, v) + }) + } else if (type == "enum" || type == "combo") { + const str_value = LGraphCanvas.getPropertyPrintableValue(value, options.values) + value_element.innerText = str_value + + value_element.addEventListener("click", function (event) { + const values = options.values || [] + const propname = this.parentNode.dataset["property"] + const elem_that = this + new LiteGraph.ContextMenu( + values, + { + event: event, + className: "dark", + callback: inner_clicked, + }, + // @ts-expect-error + ref_window, + ) + function inner_clicked(v) { + // node.setProperty(propname,v); + // graphcanvas.dirty_canvas = true; + elem_that.innerText = v + innerChange(propname, v) + return false + } + }) + } + + root.content.appendChild(elem) + + function innerChange(name, value) { + options.callback?.(name, value, options) + callback?.(name, value, options) + } + + return elem + } + + if (root.onOpen && typeof root.onOpen == "function") root.onOpen() + + return root + } + + closePanels(): void { + document.querySelector("#node-panel")?.close() + document.querySelector("#option-panel")?.close() + } + + showShowNodePanel(node: LGraphNode): void { + this.SELECTED_NODE = node + this.closePanels() + const ref_window = this.getCanvasWindow() + const graphcanvas = this + const panel = this.createPanel(node.title || "", { + closable: true, + window: ref_window, + onOpen: function () { + graphcanvas.NODEPANEL_IS_OPEN = true + }, + onClose: function () { + graphcanvas.NODEPANEL_IS_OPEN = false + graphcanvas.node_panel = null + }, + }) + graphcanvas.node_panel = panel + panel.id = "node-panel" + panel.node = node + panel.classList.add("settings") + + function inner_refresh() { + // clear + panel.content.innerHTML = "" + // @ts-expect-error ctor props + panel.addHTML(`${node.type}${node.constructor.desc || ""}`) + + panel.addHTML("

Properties

") + + const fUpdate = function (name, value) { + graphcanvas.graph.beforeChange(node) + switch (name) { + case "Title": + node.title = value + break + case "Mode": { + const kV = Object.values(LiteGraph.NODE_MODES).indexOf(value) + if (kV >= 0 && LiteGraph.NODE_MODES[kV]) { + node.changeMode(kV) + } else { + console.warn("unexpected mode: " + value) + } + break + } + case "Color": + if (LGraphCanvas.node_colors[value]) { + node.color = LGraphCanvas.node_colors[value].color + node.bgcolor = LGraphCanvas.node_colors[value].bgcolor + } else { + console.warn("unexpected color: " + value) + } + break + default: + node.setProperty(name, value) + break + } + graphcanvas.graph.afterChange() + graphcanvas.dirty_canvas = true + } + + panel.addWidget("string", "Title", node.title, {}, fUpdate) + + panel.addWidget("combo", "Mode", LiteGraph.NODE_MODES[node.mode], { values: LiteGraph.NODE_MODES }, fUpdate) + + const nodeCol = node.color !== undefined + ? Object.keys(LGraphCanvas.node_colors).filter(function (nK) { return LGraphCanvas.node_colors[nK].color == node.color }) + : "" + + panel.addWidget("combo", "Color", nodeCol, { values: Object.keys(LGraphCanvas.node_colors) }, fUpdate) + + for (const pName in node.properties) { + const value = node.properties[pName] + const info = node.getPropertyInfo(pName) + + // in case the user wants control over the side panel widget + if (node.onAddPropertyToPanel?.(pName, panel)) continue + + panel.addWidget(info.widget || info.type, pName, value, info, fUpdate) + } + + panel.addSeparator() + + node.onShowCustomPanelInfo?.(panel) + + panel.footer.innerHTML = "" // clear + panel.addButton("Delete", function () { + if (node.block_delete) + return + node.graph.remove(node) + panel.close() + }).classList.add("delete") + } + + panel.inner_showCodePad = function (propname) { + panel.classList.remove("settings") + panel.classList.add("centered") + + panel.alt_content.innerHTML = "" + const textarea = panel.alt_content.querySelector("textarea") + const fDoneWith = function () { + panel.toggleAltContent(false) + panel.toggleFooterVisibility(true) + textarea.parentNode.removeChild(textarea) + panel.classList.add("settings") + panel.classList.remove("centered") + inner_refresh() + } + textarea.value = node.properties[propname] + textarea.addEventListener("keydown", function (e) { + if (e.code == "Enter" && e.ctrlKey) { + node.setProperty(propname, textarea.value) + fDoneWith() + } + }) + panel.toggleAltContent(true) + panel.toggleFooterVisibility(false) + textarea.style.height = "calc(100% - 40px)" + + const assign = panel.addButton("Assign", function () { + node.setProperty(propname, textarea.value) + fDoneWith() + }) + panel.alt_content.appendChild(assign) + const button = panel.addButton("Close", fDoneWith) + button.style.float = "right" + panel.alt_content.appendChild(button) + } + + inner_refresh() + + this.canvas.parentNode.appendChild(panel) + } + + checkPanels(): void { + if (!this.canvas) return + + const panels = this.canvas.parentNode.querySelectorAll(".litegraph.dialog") + for (let i = 0; i < panels.length; ++i) { + const panel = panels[i] + // @ts-expect-error Panel + if (!panel.node) continue + // @ts-expect-error Panel + if (!panel.node.graph || panel.graph != this.graph) panel.close() + } + } + + getCanvasMenuOptions(): IContextMenuValue[] { + let options: IContextMenuValue[] = null + if (this.getMenuOptions) { + options = this.getMenuOptions() + } else { + options = [ + { + content: "Add Node", + has_submenu: true, + // @ts-expect-error Might be broken? Or just param overlap + callback: LGraphCanvas.onMenuAdd, + }, + { content: "Add Group", callback: LGraphCanvas.onGroupAdd }, + // { content: "Arrange", callback: that.graph.arrange }, + // {content:"Collapse All", callback: LGraphCanvas.onMenuCollapseAll } + ] + if (Object.keys(this.selected_nodes).length > 1) { + options.push({ + content: "Align", + has_submenu: true, + callback: LGraphCanvas.onGroupAlign, + }) + } + } + + const extra = this.getExtraMenuOptions?.(this, options) + return extra + ? options.concat(extra) + : options + } - this.ds.offset[0] = startX + (targetX - startX) * easedProgress - this.ds.offset[1] = startY + (targetY - startY) * easedProgress + // called by processContextMenu to extract the menu list + getNodeMenuOptions(node: LGraphNode): IContextMenuValue[] { + let options: IContextMenuValue[] = null - if (zoom > 0) { - this.ds.scale = startScale + (targetScale - startScale) * easedProgress + if (node.getMenuOptions) { + options = node.getMenuOptions(this) + } else { + options = [ + { + content: "Inputs", + has_submenu: true, + disabled: true, + callback: LGraphCanvas.showMenuNodeOptionalInputs, + }, + { + content: "Outputs", + has_submenu: true, + disabled: true, + callback: LGraphCanvas.showMenuNodeOptionalOutputs, + }, + null, + { + content: "Properties", + has_submenu: true, + callback: LGraphCanvas.onShowMenuNodeProperties, + }, + { + content: "Properties Panel", + callback: function (item, options, e, menu, node) { LGraphCanvas.active_canvas.showShowNodePanel(node) }, + }, + null, + { + content: "Title", + callback: LGraphCanvas.onShowPropertyEditor, + }, + { + content: "Mode", + has_submenu: true, + callback: LGraphCanvas.onMenuNodeMode, + }, + ] + if (node.resizable !== false) { + options.push({ + content: "Resize", + callback: LGraphCanvas.onMenuResizeNode, + }) + } + if (node.collapsible) { + options.push({ + content: node.collapsed ? "Expand" : "Collapse", + callback: LGraphCanvas.onMenuNodeCollapse, + }) + } + if (node.widgets?.some(w => w.advanced)) { + options.push({ + content: node.showAdvanced ? "Hide Advanced" : "Show Advanced", + callback: LGraphCanvas.onMenuToggleAdvanced, + }) + } + options.push( + { + content: node.pinned ? "Unpin" : "Pin", + callback: (...args) => { + // @ts-expect-error Not impl. + LGraphCanvas.onMenuNodePin(...args) + for (const i in this.selected_nodes) { + const node = this.selected_nodes[i] + node.pin() } - this.setDirty(true, true) + }, + }, + { + content: "Colors", + has_submenu: true, + callback: LGraphCanvas.onMenuNodeColors, + }, + { + content: "Shapes", + has_submenu: true, + callback: LGraphCanvas.onMenuNodeShapes, + }, + null, + ) + } + + const inputs = node.onGetInputs?.() + if (inputs?.length) options[0].disabled = false + + const outputs = node.onGetOutputs?.() + if (outputs?.length) options[1].disabled = false + + const extra = node.getExtraMenuOptions?.(this, options) + if (extra) { + extra.push(null) + options = extra.concat(options) + } + + if (node.clonable !== false) { + options.push({ + content: "Clone", + callback: LGraphCanvas.onMenuNodeClone, + }) + } + + if (Object.keys(this.selected_nodes).length > 1) { + options.push({ + content: "Align Selected To", + has_submenu: true, + callback: LGraphCanvas.onNodeAlign, + }) + options.push({ + content: "Distribute Nodes", + has_submenu: true, + callback: LGraphCanvas.createDistributeMenu, + }) + } + + options.push(null, { + content: "Remove", + disabled: !(node.removable !== false && !node.block_delete), + callback: LGraphCanvas.onMenuNodeRemove, + }) + + node.graph?.onGetNodeMenuOptions?.(options, node) + + return options + } + + getGroupMenuOptions(group: LGraphGroup): IContextMenuValue[] { + console.warn("LGraphCanvas.getGroupMenuOptions is deprecated, use LGraphGroup.getMenuOptions instead") + return group.getMenuOptions() + } + + processContextMenu(node: LGraphNode, event: CanvasMouseEvent): void { + const that = this + const canvas = LGraphCanvas.active_canvas + const ref_window = canvas.getCanvasWindow() + + // TODO: Remove type kludge + let menu_info: (IContextMenuValue | string)[] = null + const options: IContextMenuOptions = { + event: event, + callback: inner_option_clicked, + extra: node, + } + + if (node) options.title = node.type + + // check if mouse is in input + let slot: ReturnType = null + if (node) { + slot = node.getSlotInPosition(event.canvasX, event.canvasY) + LGraphCanvas.active_node = node + } + + if (slot) { + // on slot + menu_info = [] + if (node.getSlotMenuOptions) { + menu_info = node.getSlotMenuOptions(slot) + } else { + if (slot?.output?.links?.length) + menu_info.push({ content: "Disconnect Links", slot: slot }) + + const _slot = slot.input || slot.output + if (_slot.removable) { + menu_info.push( + _slot.locked + ? "Cannot remove" + : { content: "Remove Slot", slot: slot }, + ) + } + if (!_slot.nameLocked) + menu_info.push({ content: "Rename Slot", slot: slot }) + } + // @ts-expect-error Slot type can be number and has number checks + options.title = (slot.input ? slot.input.type : slot.output.type) || "*" + if (slot.input && slot.input.type == LiteGraph.ACTION) + options.title = "Action" + + if (slot.output && slot.output.type == LiteGraph.EVENT) + options.title = "Event" + } else if (node) { + // on node + menu_info = this.getNodeMenuOptions(node) + } else { + menu_info = this.getCanvasMenuOptions() + + // Check for reroutes + if (this.reroutesEnabled) { + const reroute = this.graph.getRerouteOnPos(event.canvasX, event.canvasY) + if (reroute) { + menu_info.unshift({ + content: "Delete Reroute", + callback: () => this.graph.removeReroute(reroute.id), + }, null) + } + } + + const group = this.graph.getGroupOnPos( + event.canvasX, + event.canvasY, + ) + if (group) { + // on group + menu_info.push(null, { + content: "Edit Group", + has_submenu: true, + submenu: { + title: "Group", + extra: group, + options: group.getMenuOptions(), + }, + }) + } + } - if (progress < 1) { - animationId = requestAnimationFrame(animate) - } else { - cancelAnimationFrame(animationId) - } + // show menu + if (!menu_info) return + + // @ts-expect-error Remove param ref_window - unused + new LiteGraph.ContextMenu(menu_info, options, ref_window) + + function inner_option_clicked(v, options) { + if (!v) return + + if (v.content == "Remove Slot") { + const info = v.slot + node.graph.beforeChange() + if (info.input) { + node.removeInput(info.slot) + } else if (info.output) { + node.removeOutput(info.slot) } + node.graph.afterChange() + return + } else if (v.content == "Disconnect Links") { + const info = v.slot + node.graph.beforeChange() + if (info.output) { + node.disconnectOutput(info.slot) + } else if (info.input) { + node.disconnectInput(info.slot) + } + node.graph.afterChange() + return + } else if (v.content == "Rename Slot") { + const info = v.slot + const slot_info = info.input + ? node.getInputInfo(info.slot) + : node.getOutputInfo(info.slot) + const dialog = that.createDialog( + "Name", + options, + ) + const input = dialog.querySelector("input") + if (input && slot_info) { + input.value = slot_info.label || "" + } + const inner = function () { + node.graph.beforeChange() + if (input.value) { + if (slot_info) { + slot_info.label = input.value + } + that.setDirty(true) + } + dialog.close() + node.graph.afterChange() + } + dialog.querySelector("button").addEventListener("click", inner) + input.addEventListener("keydown", function (e) { + dialog.is_modified = true + if (e.keyCode == 27) { + // ESC + dialog.close() + } else if (e.keyCode == 13) { + inner() // save + } else if ( + e.keyCode != 13 && + (e.target as Element).localName != "textarea" + ) { + return + } + e.preventDefault() + e.stopPropagation() + }) + input.focus() + } + } + } + + /** + * Starts an animation to fit the view around the specified selection of nodes. + * @param bounds The bounds to animate the view to, defined by a rectangle. + * @param animationParameters Various parameters for the camera movement animation. + */ + animateToBounds( + bounds: ReadOnlyRect, + { + duration = 350, + zoom = 0.75, + easing = EaseFunction.EASE_IN_OUT_QUAD, + }: AnimationOptions = {}, + ) { + const easeFunctions = { + linear: (t: number) => t, + easeInQuad: (t: number) => t * t, + easeOutQuad: (t: number) => t * (2 - t), + easeInOutQuad: (t: number) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t), + } + const easeFunction = easeFunctions[easing] ?? easeFunctions.linear + + let animationId = null + const startTimestamp = performance.now() + const startX = this.ds.offset[0] + const startY = this.ds.offset[1] + const startScale = this.ds.scale + const cw = this.canvas.width / window.devicePixelRatio + const ch = this.canvas.height / window.devicePixelRatio + let targetScale = startScale + let targetX = startX + let targetY = startY + + if (zoom > 0) { + const targetScaleX = (zoom * cw) / Math.max(bounds[2], 300) + const targetScaleY = (zoom * ch) / Math.max(bounds[3], 300) + + // Choose the smaller scale to ensure the node fits into the viewport + // Ensure we don't go over the max scale + targetScale = Math.min(targetScaleX, targetScaleY, this.ds.max_scale) + } + targetX = -bounds[0] - bounds[2] * 0.5 + (cw * 0.5) / targetScale + targetY = -bounds[1] - bounds[3] * 0.5 + (ch * 0.5) / targetScale + + const animate = (timestamp: number) => { + const elapsed = timestamp - startTimestamp + const progress = Math.min(elapsed / duration, 1) + const easedProgress = easeFunction(progress) + + this.ds.offset[0] = startX + (targetX - startX) * easedProgress + this.ds.offset[1] = startY + (targetY - startY) * easedProgress + + if (zoom > 0) { + this.ds.scale = startScale + (targetScale - startScale) * easedProgress + } + + this.setDirty(true, true) + + if (progress < 1) { animationId = requestAnimationFrame(animate) - } - - /** - * Fits the view to the selected nodes with animation. - * If nothing is selected, the view is fitted around all items in the graph. - */ - fitViewToSelectionAnimated(options: AnimationOptions = {}) { - const items: Positionable[] = - this.selectedItems.size ? - Array.from(this.selectedItems) : - this.positionableItems - this.animateToBounds(createBounds(items), options) - } + } else { + cancelAnimationFrame(animationId) + } + } + animationId = requestAnimationFrame(animate) + } + + /** + * Fits the view to the selected nodes with animation. + * If nothing is selected, the view is fitted around all items in the graph. + */ + fitViewToSelectionAnimated(options: AnimationOptions = {}) { + const items: Positionable[] = this.selectedItems.size + ? Array.from(this.selectedItems) + : this.positionableItems + this.animateToBounds(createBounds(items), options) + } } export type AnimationOptions = { - /** Duration of the animation in milliseconds. */ - duration?: number, - /** Relative target zoom level. 1 means the view is fit exactly on the bounding box. */ - zoom?: number, - easing?: EaseFunction + /** Duration of the animation in milliseconds. */ + duration?: number + /** Relative target zoom level. 1 means the view is fit exactly on the bounding box. */ + zoom?: number + easing?: EaseFunction } diff --git a/src/LGraphGroup.ts b/src/LGraphGroup.ts index 3cdadefa..6ad5b2dd 100644 --- a/src/LGraphGroup.ts +++ b/src/LGraphGroup.ts @@ -1,306 +1,328 @@ -import type { IContextMenuValue, IPinnable, Point, Positionable, Size } from "./interfaces" +import type { + IContextMenuValue, + IPinnable, + Point, + Positionable, + Size, +} from "./interfaces" import type { LGraph } from "./LGraph" import type { ISerialisedGroup } from "./types/serialisation" import { LiteGraph } from "./litegraph" import { LGraphCanvas } from "./LGraphCanvas" -import { containsCentre, containsRect, isInRectangle, isPointInRect, createBounds, snapPoint } from "./measure" +import { + containsCentre, + containsRect, + isInRectangle, + isPointInRect, + createBounds, + snapPoint, +} from "./measure" import { LGraphNode } from "./LGraphNode" import { RenderShape, TitleMode } from "./types/globalEnums" export interface IGraphGroupFlags extends Record { - pinned?: true + pinned?: true } export class LGraphGroup implements Positionable, IPinnable { - static minWidth = 140 - static minHeight = 80 - static resizeLength = 10 - static padding = 4 - static defaultColour = '#335' - - id: number - color: string - title: string - font?: string - font_size: number = LiteGraph.DEFAULT_GROUP_FONT || 24 - _bounding: Float32Array = new Float32Array([10, 10, LGraphGroup.minWidth, LGraphGroup.minHeight]) - _pos: Point = this._bounding.subarray(0, 2) - _size: Size = this._bounding.subarray(2, 4) - /** @deprecated See {@link _children} */ - _nodes: LGraphNode[] = [] - _children: Set = new Set() - graph: LGraph | null = null - flags: IGraphGroupFlags = {} - selected?: boolean - - constructor(title?: string, id?: number) { - // TODO: Object instantiation pattern requires too much boilerplate and null checking. ID should be passed in via constructor. - this.id = id ?? -1 - this.title = title || "Group" - this.color = LGraphCanvas.node_colors.pale_blue - ? LGraphCanvas.node_colors.pale_blue.groupcolor - : "#AAA" + static minWidth = 140 + static minHeight = 80 + static resizeLength = 10 + static padding = 4 + static defaultColour = "#335" + + id: number + color: string + title: string + font?: string + font_size: number = LiteGraph.DEFAULT_GROUP_FONT || 24 + _bounding: Float32Array = new Float32Array([ + 10, + 10, + LGraphGroup.minWidth, + LGraphGroup.minHeight, + ]) + _pos: Point = this._bounding.subarray(0, 2) + _size: Size = this._bounding.subarray(2, 4) + /** @deprecated See {@link _children} */ + _nodes: LGraphNode[] = [] + _children: Set = new Set() + graph: LGraph | null = null + flags: IGraphGroupFlags = {} + selected?: boolean + + constructor(title?: string, id?: number) { + // TODO: Object instantiation pattern requires too much boilerplate and null checking. ID should be passed in via constructor. + this.id = id ?? -1 + this.title = title || "Group" + this.color = LGraphCanvas.node_colors.pale_blue + ? LGraphCanvas.node_colors.pale_blue.groupcolor + : "#AAA" + } + + /** Position of the group, as x,y co-ordinates in graph space */ + get pos() { + return this._pos + } + + set pos(v) { + if (!v || v.length < 2) return + + this._pos[0] = v[0] + this._pos[1] = v[1] + } + + /** Size of the group, as width,height in graph units */ + get size() { + return this._size + } + + set size(v) { + if (!v || v.length < 2) return + + this._size[0] = Math.max(LGraphGroup.minWidth, v[0]) + this._size[1] = Math.max(LGraphGroup.minHeight, v[1]) + } + + get boundingRect() { + return this._bounding + } + + get nodes() { + return this._nodes + } + + get titleHeight() { + return this.font_size * 1.4 + } + + get children(): ReadonlySet { + return this._children + } + + get pinned() { + return !!this.flags.pinned + } + + /** + * Prevents the group being accidentally moved or resized by mouse interaction. + * Toggles pinned state if no value is provided. + **/ + pin(value?: boolean): void { + const newState = value === undefined ? !this.pinned : value + + if (newState) this.flags.pinned = true + else delete this.flags.pinned + } + + unpin(): void { + this.pin(false) + } + + configure(o: ISerialisedGroup): void { + this.id = o.id + this.title = o.title + this._bounding.set(o.bounding) + this.color = o.color + this.flags = o.flags || this.flags + if (o.font_size) this.font_size = o.font_size + } + + serialize(): ISerialisedGroup { + const b = this._bounding + return { + id: this.id, + title: this.title, + bounding: [...b], + color: this.color, + font_size: this.font_size, + flags: this.flags, } - - /** Position of the group, as x,y co-ordinates in graph space */ - get pos() { - return this._pos - } - set pos(v) { - if (!v || v.length < 2) return - - this._pos[0] = v[0] - this._pos[1] = v[1] - } - - /** Size of the group, as width,height in graph units */ - get size() { - return this._size - } - set size(v) { - if (!v || v.length < 2) return - - this._size[0] = Math.max(LGraphGroup.minWidth, v[0]) - this._size[1] = Math.max(LGraphGroup.minHeight, v[1]) - } - - get boundingRect() { - return this._bounding - } - - get nodes() { - return this._nodes - } - - get titleHeight() { - return this.font_size * 1.4 - } - - get children(): ReadonlySet { - return this._children + } + + /** + * Draws the group on the canvas + * @param {LGraphCanvas} graphCanvas + * @param {CanvasRenderingContext2D} ctx + */ + draw(graphCanvas: LGraphCanvas, ctx: CanvasRenderingContext2D): void { + const { padding, resizeLength, defaultColour } = LGraphGroup + const font_size = this.font_size || LiteGraph.DEFAULT_GROUP_FONT_SIZE + + const [x, y] = this._pos + const [width, height] = this._size + + // Titlebar + ctx.globalAlpha = 0.25 * graphCanvas.editor_alpha + ctx.fillStyle = this.color || defaultColour + ctx.strokeStyle = this.color || defaultColour + ctx.beginPath() + ctx.rect(x + 0.5, y + 0.5, width, font_size * 1.4) + ctx.fill() + + // Group background, border + ctx.fillStyle = this.color + ctx.strokeStyle = this.color + ctx.beginPath() + ctx.rect(x + 0.5, y + 0.5, width, height) + ctx.fill() + ctx.globalAlpha = graphCanvas.editor_alpha + ctx.stroke() + + // Resize marker + ctx.beginPath() + ctx.moveTo(x + width, y + height) + ctx.lineTo(x + width - resizeLength, y + height) + ctx.lineTo(x + width, y + height - resizeLength) + ctx.fill() + + // Title + ctx.font = font_size + "px Arial" + ctx.textAlign = "left" + ctx.fillText(this.title + (this.pinned ? "📌" : ""), x + padding, y + font_size) + + if (LiteGraph.highlight_selected_group && this.selected) { + graphCanvas.drawSelectionBounding(ctx, this._bounding, { + shape: RenderShape.BOX, + title_height: this.titleHeight, + title_mode: TitleMode.NORMAL_TITLE, + fgcolor: this.color, + padding, + }) } + } - get pinned() { - return !!this.flags.pinned - } + resize(width: number, height: number): boolean { + if (this.pinned) return false - /** - * Prevents the group being accidentally moved or resized by mouse interaction. - * Toggles pinned state if no value is provided. - **/ - pin(value?: boolean): void { - const newState = value === undefined ? !this.pinned : value + this._size[0] = Math.max(LGraphGroup.minWidth, width) + this._size[1] = Math.max(LGraphGroup.minHeight, height) + return true + } - if (newState) this.flags.pinned = true - else delete this.flags.pinned - } + move(deltaX: number, deltaY: number, skipChildren: boolean = false): void { + if (this.pinned) return - unpin(): void { - this.pin(false) - } + this._pos[0] += deltaX + this._pos[1] += deltaY + if (skipChildren === true) return - configure(o: ISerialisedGroup): void { - this.id = o.id - this.title = o.title - this._bounding.set(o.bounding) - this.color = o.color - this.flags = o.flags || this.flags - if (o.font_size) this.font_size = o.font_size + for (const item of this._children) { + item.move(deltaX, deltaY) } - - serialize(): ISerialisedGroup { - const b = this._bounding - return { - id: this.id, - title: this.title, - bounding: [...b], - color: this.color, - font_size: this.font_size, - flags: this.flags, - } + } + + /** @inheritdoc */ + snapToGrid(snapTo: number): boolean { + return this.pinned ? false : snapPoint(this.pos, snapTo) + } + + recomputeInsideNodes(): void { + const { nodes, reroutes, groups } = this.graph + const children = this._children + this._nodes.length = 0 + children.clear() + + // Move nodes we overlap the centre point of + for (const node of nodes) { + if (containsCentre(this._bounding, node.boundingRect)) { + this._nodes.push(node) + children.add(node) + } } - /** - * Draws the group on the canvas - * @param {LGraphCanvas} graphCanvas - * @param {CanvasRenderingContext2D} ctx - */ - draw(graphCanvas: LGraphCanvas, ctx: CanvasRenderingContext2D): void { - const { padding, resizeLength, defaultColour } = LGraphGroup - const font_size = this.font_size || LiteGraph.DEFAULT_GROUP_FONT_SIZE - - const [x, y] = this._pos - const [width, height] = this._size - - // Titlebar - ctx.globalAlpha = 0.25 * graphCanvas.editor_alpha - ctx.fillStyle = this.color || defaultColour - ctx.strokeStyle = this.color || defaultColour - ctx.beginPath() - ctx.rect(x + 0.5, y + 0.5, width, font_size * 1.4) - ctx.fill() - - // Group background, border - ctx.fillStyle = this.color - ctx.strokeStyle = this.color - ctx.beginPath() - ctx.rect(x + 0.5, y + 0.5, width, height) - ctx.fill() - ctx.globalAlpha = graphCanvas.editor_alpha - ctx.stroke() - - // Resize marker - ctx.beginPath() - ctx.moveTo(x + width, y + height) - ctx.lineTo(x + width - resizeLength, y + height) - ctx.lineTo(x + width, y + height - resizeLength) - ctx.fill() - - // Title - ctx.font = font_size + "px Arial" - ctx.textAlign = "left" - ctx.fillText(this.title + (this.pinned ? "📌" : ""), x + padding, y + font_size) - - if (LiteGraph.highlight_selected_group && this.selected) { - graphCanvas.drawSelectionBounding(ctx, this._bounding, { - shape: RenderShape.BOX, - title_height: this.titleHeight, - title_mode: TitleMode.NORMAL_TITLE, - fgcolor: this.color, - padding, - }) - } + // Move reroutes we overlap the centre point of + for (const reroute of reroutes.values()) { + if (isPointInRect(reroute.pos, this._bounding)) + children.add(reroute) } - resize(width: number, height: number): boolean { - if (this.pinned) return false - - this._size[0] = Math.max(LGraphGroup.minWidth, width) - this._size[1] = Math.max(LGraphGroup.minHeight, height) - return true - } - - move(deltaX: number, deltaY: number, skipChildren: boolean = false): void { - if (this.pinned) return - - this._pos[0] += deltaX - this._pos[1] += deltaY - if (skipChildren === true) return - - for (const item of this._children) { - item.move(deltaX, deltaY) - } - } - - /** @inheritdoc */ - snapToGrid(snapTo: number): boolean { - return this.pinned ? false : snapPoint(this.pos, snapTo) - } - - recomputeInsideNodes(): void { - const { nodes, reroutes, groups } = this.graph - const children = this._children - this._nodes.length = 0 - children.clear() - - // Move nodes we overlap the centre point of - for (const node of nodes) { - if (containsCentre(this._bounding, node.boundingRect)) { - this._nodes.push(node) - children.add(node) - } - } - - // Move reroutes we overlap the centre point of - for (const reroute of reroutes.values()) { - if (isPointInRect(reroute.pos, this._bounding)) - children.add(reroute) - } - - // Move groups we wholly contain - for (const group of groups) { - if (containsRect(this._bounding, group._bounding)) - children.add(group) - } - - groups.sort((a, b) => { - if (a === this) { - return children.has(b) ? -1 : 0 - } else if (b === this) { - return children.has(a) ? 1 : 0 - } - }) - } - - /** - * Resizes and moves the group to neatly fit all given {@link objects}. - * @param objects All objects that should be inside the group - * @param padding Value in graph units to add to all sides of the group. Default: 10 - */ - resizeTo(objects: Iterable, padding: number = 10): void { - const boundingBox = createBounds(objects, padding) - if (boundingBox === null) return - - this.pos[0] = boundingBox[0] - this.pos[1] = boundingBox[1] - this.titleHeight - this.size[0] = boundingBox[2] - this.size[1] = boundingBox[3] + this.titleHeight - } - - /** - * Add nodes to the group and adjust the group's position and size accordingly - * @param {LGraphNode[]} nodes - The nodes to add to the group - * @param {number} [padding=10] - The padding around the group - * @returns {void} - */ - addNodes(nodes: LGraphNode[], padding: number = 10): void { - if (!this._nodes && nodes.length === 0) return - this.resizeTo([...this.children, ...this._nodes, ...nodes], padding) - } - - getMenuOptions(): IContextMenuValue[] { - return [ - { - content: this.pinned ? "Unpin" : "Pin", - callback: () => { - if (this.pinned) this.unpin() - else this.pin() - this.setDirtyCanvas(false, true) - }, - }, - null, - { content: "Title", callback: LGraphCanvas.onShowPropertyEditor }, - { - content: "Color", - has_submenu: true, - callback: LGraphCanvas.onMenuNodeColors - }, - { - content: "Font size", - property: "font_size", - type: "Number", - callback: LGraphCanvas.onShowPropertyEditor - }, - null, - { content: "Remove", callback: LGraphCanvas.onMenuNodeRemove } - ] - } - - isPointInTitlebar(x: number, y: number): boolean { - const b = this.boundingRect - return isInRectangle(x, y, b[0], b[1], b[2], this.titleHeight) - } - - isInResize(x: number, y: number): boolean { - const b = this.boundingRect - const right = b[0] + b[2] - const bottom = b[1] + b[3] - - return x < right - && y < bottom - && (x - right) + (y - bottom) > -LGraphGroup.resizeLength + // Move groups we wholly contain + for (const group of groups) { + if (containsRect(this._bounding, group._bounding)) + children.add(group) } - isPointInside = LGraphNode.prototype.isPointInside - setDirtyCanvas = LGraphNode.prototype.setDirtyCanvas + groups.sort((a, b) => { + if (a === this) { + return children.has(b) ? -1 : 0 + } else if (b === this) { + return children.has(a) ? 1 : 0 + } + }) + } + + /** + * Resizes and moves the group to neatly fit all given {@link objects}. + * @param objects All objects that should be inside the group + * @param padding Value in graph units to add to all sides of the group. Default: 10 + */ + resizeTo(objects: Iterable, padding: number = 10): void { + const boundingBox = createBounds(objects, padding) + if (boundingBox === null) return + + this.pos[0] = boundingBox[0] + this.pos[1] = boundingBox[1] - this.titleHeight + this.size[0] = boundingBox[2] + this.size[1] = boundingBox[3] + this.titleHeight + } + + /** + * Add nodes to the group and adjust the group's position and size accordingly + * @param {LGraphNode[]} nodes - The nodes to add to the group + * @param {number} [padding=10] - The padding around the group + * @returns {void} + */ + addNodes(nodes: LGraphNode[], padding: number = 10): void { + if (!this._nodes && nodes.length === 0) return + this.resizeTo([...this.children, ...this._nodes, ...nodes], padding) + } + + getMenuOptions(): IContextMenuValue[] { + return [ + { + content: this.pinned ? "Unpin" : "Pin", + callback: () => { + if (this.pinned) this.unpin() + else this.pin() + this.setDirtyCanvas(false, true) + }, + }, + null, + { content: "Title", callback: LGraphCanvas.onShowPropertyEditor }, + { + content: "Color", + has_submenu: true, + callback: LGraphCanvas.onMenuNodeColors, + }, + { + content: "Font size", + property: "font_size", + type: "Number", + callback: LGraphCanvas.onShowPropertyEditor, + }, + null, + { content: "Remove", callback: LGraphCanvas.onMenuNodeRemove }, + ] + } + + isPointInTitlebar(x: number, y: number): boolean { + const b = this.boundingRect + return isInRectangle(x, y, b[0], b[1], b[2], this.titleHeight) + } + + isInResize(x: number, y: number): boolean { + const b = this.boundingRect + const right = b[0] + b[2] + const bottom = b[1] + b[3] + + return ( + x < right && + y < bottom && + x - right + (y - bottom) > -LGraphGroup.resizeLength + ) + } + + isPointInside = LGraphNode.prototype.isPointInside + setDirtyCanvas = LGraphNode.prototype.setDirtyCanvas } diff --git a/src/LGraphNode.ts b/src/LGraphNode.ts index 7e9521c9..2cb86175 100644 --- a/src/LGraphNode.ts +++ b/src/LGraphNode.ts @@ -1,4 +1,19 @@ -import type { Dictionary, IContextMenuValue, IFoundSlot, INodeFlags, INodeInputSlot, INodeOutputSlot, IOptionalSlotData, IPinnable, ISlotType, Point, Positionable, ReadOnlyRect, Rect, Size } from "./interfaces" +import type { + Dictionary, + IContextMenuValue, + IFoundSlot, + INodeFlags, + INodeInputSlot, + INodeOutputSlot, + IOptionalSlotData, + IPinnable, + ISlotType, + Point, + Positionable, + ReadOnlyRect, + Rect, + Size, +} from "./interfaces" import type { LGraph } from "./LGraph" import type { IWidget, TWidgetValue } from "./types/widgets" import type { ISerialisedNode } from "./types/serialisation" @@ -6,7 +21,12 @@ import type { LGraphCanvas } from "./LGraphCanvas" import type { CanvasMouseEvent } from "./types/events" import type { DragAndScale } from "./DragAndScale" import type { Reroute, RerouteId } from "./Reroute" -import { LGraphEventMode, NodeSlotType, TitleMode, RenderShape } from "./types/globalEnums" +import { + LGraphEventMode, + NodeSlotType, + TitleMode, + RenderShape, +} from "./types/globalEnums" import { BadgePosition, LGraphBadge } from "./LGraphBadge" import { type LGraphNodeConstructor, LiteGraph } from "./litegraph" import { isInRectangle, isInRect, snapPoint } from "./measure" @@ -15,43 +35,43 @@ import { LLink } from "./LLink" export type NodeId = number | string export interface INodePropertyInfo { - name: string - type: string - default_value: unknown + name: string + type: string + default_value: unknown } export type INodeProperties = Dictionary & { - horizontal?: boolean + horizontal?: boolean } interface IMouseOverData { - inputId: number | null - outputId: number | null - overWidget: IWidget | null + inputId: number | null + outputId: number | null + overWidget: IWidget | null } interface ConnectByTypeOptions { - /** @deprecated Events */ - createEventInCase?: boolean - /** Allow our wildcard slot to connect to typed slots on remote node. Default: true */ - wildcardToTyped?: boolean - /** Allow our typed slot to connect to wildcard slots on remote node. Default: true */ - typedToWildcard?: boolean - /** The {@link Reroute.id} that the connection is being dragged from. */ - afterRerouteId?: RerouteId + /** @deprecated Events */ + createEventInCase?: boolean + /** Allow our wildcard slot to connect to typed slots on remote node. Default: true */ + wildcardToTyped?: boolean + /** Allow our typed slot to connect to wildcard slots on remote node. Default: true */ + typedToWildcard?: boolean + /** The {@link Reroute.id} that the connection is being dragged from. */ + afterRerouteId?: RerouteId } /** Internal type used for type safety when implementing generic checks for inputs & outputs */ interface IGenericLinkOrLinks { - links?: INodeOutputSlot["links"] - link?: INodeInputSlot["link"] + links?: INodeOutputSlot["links"] + link?: INodeInputSlot["link"] } interface FindFreeSlotOptions { - /** Slots matching these types will be ignored. Default: [] */ - typesNotAccepted?: ISlotType[] - /** If true, the slot itself is returned instead of the index. Default: false */ - returnObj?: boolean + /** Slots matching these types will be ignored. Default: [] */ + typesNotAccepted?: ISlotType[] + /** If true, the slot itself is returned instead of the index. Default: false */ + returnObj?: boolean } /* @@ -107,7 +127,7 @@ supported callbacks: */ export interface LGraphNode { - constructor: LGraphNodeConstructor + constructor: LGraphNodeConstructor } /** @@ -115,2393 +135,2721 @@ export interface LGraphNode { * @param {String} name a name for the node */ export class LGraphNode implements Positionable, IPinnable { - // Static properties used by dynamic child classes - static title?: string - static MAX_CONSOLE?: number - static type?: string - static category?: string - static supported_extensions?: string[] - static filter?: string - static skip_list?: boolean - - /** Default setting for {@link LGraphNode.connectInputToOutput}. @see {@link INodeFlags.keepAllLinksOnBypass} */ - static keepAllLinksOnBypass: boolean = false - - title: string - graph: LGraph | null = null - id: NodeId - type: string | null = null - inputs: INodeInputSlot[] = [] - outputs: INodeOutputSlot[] = [] - // Not used - connections: unknown[] = [] - properties: INodeProperties = {} - properties_info: INodePropertyInfo[] = [] - flags: INodeFlags = {} - widgets?: IWidget[] - locked?: boolean - - // Execution order, automatically computed during run - order?: number - mode: LGraphEventMode - last_serialization?: ISerialisedNode - serialize_widgets?: boolean - color: string - bgcolor: string - boxcolor: string - exec_version: number - action_call?: string - execute_triggered: number - action_triggered: number - widgets_up?: boolean - widgets_start_y?: number - lostFocusAt?: number - gotFocusAt?: number - badges: (LGraphBadge | (() => LGraphBadge))[] = [] - badgePosition: BadgePosition = BadgePosition.TopLeft - onOutputRemoved?(this: LGraphNode, slot: number): void - onInputRemoved?(this: LGraphNode, slot: number, input: INodeInputSlot): void - _collapsed_width: number - /** Called once at the start of every frame. Caller may change the values in {@link out}, which will be reflected in {@link boundingRect}. */ - onBounding?(this: LGraphNode, out: Rect): void - horizontal?: boolean - console?: string[] - _level: number - _shape?: RenderShape - subgraph?: LGraph - skip_subgraph_button?: boolean - mouseOver?: IMouseOverData - redraw_on_mouse?: boolean - // Appears unused - optional_inputs? - // Appears unused - optional_outputs? - resizable?: boolean - clonable?: boolean - _relative_id?: number - clip_area?: boolean - ignore_remove?: boolean - has_errors?: boolean - removable?: boolean - block_delete?: boolean - selected?: boolean - showAdvanced?: boolean - - /** @inheritdoc {@link renderArea} */ - #renderArea: Float32Array = new Float32Array(4) - /** - * Rect describing the node area, including shadows and any protrusions. - * Determines if the node is visible. Calculated once at the start of every frame. - */ - get renderArea(): ReadOnlyRect { - return this.#renderArea - } - - - /** @inheritdoc {@link boundingRect} */ - #boundingRect: Float32Array = new Float32Array(4) - /** - * Cached node position & area as `x, y, width, height`. Includes changes made by {@link onBounding}, if present. - * - * Determines the node hitbox and other rendering effects. Calculated once at the start of every frame. - */ - get boundingRect(): ReadOnlyRect { - return this.#boundingRect - } - - /** {@link pos} and {@link size} values are backed by this {@link Rect}. */ - _posSize: Float32Array = new Float32Array(4) - _pos: Point = this._posSize.subarray(0, 2) - _size: Size = this._posSize.subarray(2, 4) - - public get pos() { - return this._pos - } - public set pos(value) { - if (!value || value.length < 2) return - - this._pos[0] = value[0] - this._pos[1] = value[1] - } - - public get size() { - return this._size - } - public set size(value) { - if (!value || value.length < 2) return - - this._size[0] = value[0] - this._size[1] = value[1] - } - - get shape(): RenderShape { - return this._shape - } - set shape(v: RenderShape | "default" | "box" | "round" | "circle" | "card") { - switch (v) { - case "default": - delete this._shape - break - case "box": - this._shape = RenderShape.BOX - break - case "round": - this._shape = RenderShape.ROUND - break - case "circle": - this._shape = RenderShape.CIRCLE - break - case "card": - this._shape = RenderShape.CARD - break - default: - this._shape = v - } - } - - public get is_selected(): boolean { - return this.selected - } - public set is_selected(value: boolean) { - this.selected = value - } - - // Used in group node - setInnerNodes?(this: LGraphNode, nodes: LGraphNode[]): void - - onConnectInput?(this: LGraphNode, target_slot: number, type: unknown, output: INodeOutputSlot, node: LGraphNode, slot: number): boolean - onConnectOutput?(this: LGraphNode, slot: number, type: unknown, input: INodeInputSlot, target_node: number | LGraphNode, target_slot: number): boolean - onResize?(this: LGraphNode, size: Size): void - onPropertyChanged?(this: LGraphNode, name: string, value: unknown, prev_value?: unknown): boolean - onConnectionsChange?(this: LGraphNode, type: ISlotType, index: number, isConnected: boolean, link_info: LLink, inputOrOutput: INodeInputSlot | INodeOutputSlot): void - onInputAdded?(this: LGraphNode, input: INodeInputSlot): void - onOutputAdded?(this: LGraphNode, output: INodeOutputSlot): void - onConfigure?(this: LGraphNode, serialisedNode: ISerialisedNode): void - onSerialize?(this: LGraphNode, serialised: ISerialisedNode): any - onExecute?(this: LGraphNode, param?: unknown, options?: { action_call?: any }): void - onAction?(this: LGraphNode, action: string, param: unknown, options: { action_call?: string }): void - onDrawBackground?(this: LGraphNode, ctx: CanvasRenderingContext2D, canvas: LGraphCanvas, canvasElement: HTMLCanvasElement, mousePosition: Point): void - onNodeCreated?(this: LGraphNode): void - /** - * Callback invoked by {@link connect} to override the target slot index. Its return value overrides the target index selection. - * @param target_slot The current input slot index - * @param requested_slot The originally requested slot index - could be negative, or if using (deprecated) name search, a string - * @returns {number | null} If a number is returned, the connection will be made to that input index. - * If an invalid index or non-number (false, null, NaN etc) is returned, the connection will be cancelled. - */ - onBeforeConnectInput?(this: LGraphNode, target_slot: number, requested_slot?: number | string): number | false | null - onShowCustomPanelInfo?(this: LGraphNode, panel: any): void - onAddPropertyToPanel?(this: LGraphNode, pName: string, panel: any): boolean - onWidgetChanged?(this: LGraphNode, name: string, value: unknown, old_value: unknown, w: IWidget): void - onDeselected?(this: LGraphNode): void - onKeyUp?(this: LGraphNode, e: KeyboardEvent): void - onKeyDown?(this: LGraphNode, e: KeyboardEvent): void - onSelected?(this: LGraphNode): void - getExtraMenuOptions?(this: LGraphNode, canvas: LGraphCanvas, options: IContextMenuValue[]): IContextMenuValue[] - getMenuOptions?(this: LGraphNode, canvas: LGraphCanvas): IContextMenuValue[] - onAdded?(this: LGraphNode, graph: LGraph): void - onDrawCollapsed?(this: LGraphNode, ctx: CanvasRenderingContext2D, cavnas: LGraphCanvas): boolean - onDrawForeground?(this: LGraphNode, ctx: CanvasRenderingContext2D, canvas: LGraphCanvas, canvasElement: HTMLCanvasElement): void - onMouseLeave?(this: LGraphNode, e: CanvasMouseEvent): void - getSlotMenuOptions?(this: LGraphNode, slot: IFoundSlot): IContextMenuValue[] - // FIXME: Re-typing - onDropItem?(this: LGraphNode, event: Event): boolean - onDropData?(this: LGraphNode, data: string | ArrayBuffer, filename: any, file: any): void - onDropFile?(this: LGraphNode, file: any): void - onInputClick?(this: LGraphNode, index: number, e: CanvasMouseEvent): void - onInputDblClick?(this: LGraphNode, index: number, e: CanvasMouseEvent): void - onOutputClick?(this: LGraphNode, index: number, e: CanvasMouseEvent): void - onOutputDblClick?(this: LGraphNode, index: number, e: CanvasMouseEvent): void - // TODO: Return type - onGetPropertyInfo?(this: LGraphNode, property: string): any - onNodeOutputAdd?(this: LGraphNode, value): void - onNodeInputAdd?(this: LGraphNode, value): void - onMenuNodeInputs?(this: LGraphNode, entries: IOptionalSlotData[]): IOptionalSlotData[] - onMenuNodeOutputs?(this: LGraphNode, entries: IOptionalSlotData[]): IOptionalSlotData[] - onGetInputs?(this: LGraphNode): INodeInputSlot[] - onGetOutputs?(this: LGraphNode): INodeOutputSlot[] - onMouseUp?(this: LGraphNode, e: CanvasMouseEvent, pos: Point): void - onMouseEnter?(this: LGraphNode, e: CanvasMouseEvent): void - /** Blocks drag if return value is truthy. @param pos Offset from {@link LGraphNode.pos}. */ - onMouseDown?(this: LGraphNode, e: CanvasMouseEvent, pos: Point, canvas: LGraphCanvas): boolean - /** @param pos Offset from {@link LGraphNode.pos}. */ - onDblClick?(this: LGraphNode, e: CanvasMouseEvent, pos: Point, canvas: LGraphCanvas): void - /** @param pos Offset from {@link LGraphNode.pos}. */ - onNodeTitleDblClick?(this: LGraphNode, e: CanvasMouseEvent, pos: Point, canvas: LGraphCanvas): void - onDrawTitle?(this: LGraphNode, ctx: CanvasRenderingContext2D): void - onDrawTitleText?(this: LGraphNode, ctx: CanvasRenderingContext2D, title_height: number, size: Size, scale: number, title_text_font: string, selected: boolean): void - onDrawTitleBox?(this: LGraphNode, ctx: CanvasRenderingContext2D, title_height: number, size: Size, scale: number): void - onDrawTitleBar?(this: LGraphNode, ctx: CanvasRenderingContext2D, title_height: number, size: Size, scale: number, fgcolor: any): void - onRemoved?(this: LGraphNode): void - onMouseMove?(this: LGraphNode, e: MouseEvent, pos: Point, arg2: LGraphCanvas): void - onPropertyChange?(this: LGraphNode): void - updateOutputData?(this: LGraphNode, origin_slot: number): void - isValidWidgetLink?(slot_index: number, node: LGraphNode, overWidget: IWidget): boolean | undefined - - constructor(title: string) { - this.id = LiteGraph.use_uuids ? LiteGraph.uuidv4() : -1 - this.title = title || "Unnamed" - this.size = [LiteGraph.NODE_WIDTH, 60] - this.pos = [10, 10] - } - - /** - * configure a node from an object containing the serialized info - */ - configure(info: ISerialisedNode): void { - if (this.graph) { - this.graph._version++ - } - for (const j in info) { - if (j == "properties") { - //i don't want to clone properties, I want to reuse the old container - for (const k in info.properties) { - this.properties[k] = info.properties[k] - this.onPropertyChanged?.(k, info.properties[k]) - } - continue - } - - if (info[j] == null) { - continue - } else if (typeof info[j] == "object") { - //object - if (this[j]?.configure) { - this[j]?.configure(info[j]) - } else { - this[j] = LiteGraph.cloneObject(info[j], this[j]) - } - } //value - else { - this[j] = info[j] - } - } - - if (!info.title) { - this.title = this.constructor.title - } - - if (this.inputs) { - for (let i = 0; i < this.inputs.length; ++i) { - const input = this.inputs[i] - const link = this.graph ? this.graph._links.get(input.link) : null - this.onConnectionsChange?.(NodeSlotType.INPUT, i, true, link, input) - this.onInputAdded?.(input) - } - } - - if (this.outputs) { - for (let i = 0; i < this.outputs.length; ++i) { - const output = this.outputs[i] - if (!output.links) { - continue - } - for (let j = 0; j < output.links.length; ++j) { - const link = this.graph ? this.graph._links.get(output.links[j]) : null - this.onConnectionsChange?.(NodeSlotType.OUTPUT, i, true, link, output) - } - this.onOutputAdded?.(output) - } - } - - if (this.widgets) { - for (let i = 0; i < this.widgets.length; ++i) { - const w = this.widgets[i] - if (!w) - continue - if (w.options?.property && (this.properties[w.options.property] != undefined)) - w.value = JSON.parse(JSON.stringify(this.properties[w.options.property])) - } - if (info.widgets_values) { - for (let i = 0; i < info.widgets_values.length; ++i) { - if (this.widgets[i]) { - this.widgets[i].value = info.widgets_values[i] - } - } - } - } - - // Sync the state of this.resizable. - if (this.pinned) this.pin(true) - - this.onConfigure?.(info) - } - - /** - * serialize the content - */ - serialize(): ISerialisedNode { - //create serialization object - const o: ISerialisedNode = { - id: this.id, - type: this.type, - pos: [this.pos[0], this.pos[1]], - size: [this.size[0], this.size[1]], - flags: LiteGraph.cloneObject(this.flags), - order: this.order, - mode: this.mode, - showAdvanced: this.showAdvanced - } - - //special case for when there were errors - if (this.constructor === LGraphNode && this.last_serialization) - return this.last_serialization - - if (this.inputs) o.inputs = this.inputs - - if (this.outputs) { - //clear outputs last data (because data in connections is never serialized but stored inside the outputs info) - for (let i = 0; i < this.outputs.length; i++) { - delete this.outputs[i]._data - } - o.outputs = this.outputs - } - - if (this.title && this.title != this.constructor.title) o.title = this.title - - if (this.properties) o.properties = LiteGraph.cloneObject(this.properties) - - if (this.widgets && this.serialize_widgets) { - o.widgets_values = [] - for (let i = 0; i < this.widgets.length; ++i) { - if (this.widgets[i]) - o.widgets_values[i] = this.widgets[i].value - else - o.widgets_values[i] = null - } - } - - if (!o.type) o.type = this.constructor.type - - if (this.color) o.color = this.color - if (this.bgcolor) o.bgcolor = this.bgcolor - if (this.boxcolor) o.boxcolor = this.boxcolor - if (this.shape) o.shape = this.shape - - if (this.onSerialize?.(o)) console.warn("node onSerialize shouldnt return anything, data should be stored in the object pass in the first parameter") - - return o - } - - /* Creates a clone of this node */ - clone(): LGraphNode { - const node = LiteGraph.createNode(this.type) - if (!node) return null - - //we clone it because serialize returns shared containers - const data = LiteGraph.cloneObject(this.serialize()) - - //remove links - if (data.inputs) { - for (let i = 0; i < data.inputs.length; ++i) { - data.inputs[i].link = null - } - } - - if (data.outputs) { - for (let i = 0; i < data.outputs.length; ++i) { - if (data.outputs[i].links) { - data.outputs[i].links.length = 0 - } - } - } - - delete data.id - - if (LiteGraph.use_uuids) data.id = LiteGraph.uuidv4() - - //remove links - node.configure(data) - - return node - } - - /** - * serialize and stringify - */ - toString(): string { - return JSON.stringify(this.serialize()) - } - - /** - * get the title string - */ - getTitle(): string { - return this.title || this.constructor.title - } - - /** - * sets the value of a property - * @param {String} name - * @param {*} value - */ - setProperty(name: string, value: TWidgetValue): void { - this.properties ||= {} - if (value === this.properties[name]) - return - - const prev_value = this.properties[name] - this.properties[name] = value - //abort change - if (this.onPropertyChanged?.(name, value, prev_value) === false) - this.properties[name] = prev_value - - if (this.widgets) //widgets could be linked to properties - for (let i = 0; i < this.widgets.length; ++i) { - const w = this.widgets[i] - if (!w) - continue - if (w.options.property == name) { - w.value = value - break - } - } - } - - /** - * sets the output data - * @param {number} slot - * @param {*} data - */ - setOutputData(slot: number, data: unknown): void { - if (!this.outputs) return - - //this maybe slow and a niche case - //if(slot && slot.constructor === String) - // slot = this.findOutputSlot(slot); - if (slot == -1 || slot >= this.outputs.length) return - - const output_info = this.outputs[slot] - if (!output_info) return - - //store data in the output itself in case we want to debug - output_info._data = data - - //if there are connections, pass the data to the connections - if (this.outputs[slot].links) { - for (let i = 0; i < this.outputs[slot].links.length; i++) { - const link_id = this.outputs[slot].links[i] - const link = this.graph._links.get(link_id) - if (link) - link.data = data - } - } - } - - /** - * sets the output data type, useful when you want to be able to overwrite the data type - * @param {number} slot - * @param {String} datatype - */ - setOutputDataType(slot: number, type: ISlotType): void { - if (!this.outputs) return - if (slot == -1 || slot >= this.outputs.length) return - const output_info = this.outputs[slot] - if (!output_info) return - //store data in the output itself in case we want to debug - output_info.type = type - - //if there are connections, pass the data to the connections - if (this.outputs[slot].links) { - for (let i = 0; i < this.outputs[slot].links.length; i++) { - const link_id = this.outputs[slot].links[i] - this.graph._links.get(link_id).type = type - } - } - } - - /** - * Retrieves the input data (data traveling through the connection) from one slot - * @param {number} slot - * @param {boolean} force_update if set to true it will force the connected node of this slot to output data into this link - * @return {*} data or if it is not connected returns undefined - */ - getInputData(slot: number, force_update?: boolean): unknown { - if (!this.inputs) return - - if (slot >= this.inputs.length || this.inputs[slot].link == null) return - - const link_id = this.inputs[slot].link - const link = this.graph._links.get(link_id) - //bug: weird case but it happens sometimes - if (!link) return null - - if (!force_update) return link.data - - //special case: used to extract data from the incoming connection before the graph has been executed - const node = this.graph.getNodeById(link.origin_id) - if (!node) return link.data - - if (node.updateOutputData) { - node.updateOutputData(link.origin_slot) + // Static properties used by dynamic child classes + static title?: string + static MAX_CONSOLE?: number + static type?: string + static category?: string + static supported_extensions?: string[] + static filter?: string + static skip_list?: boolean + + /** Default setting for {@link LGraphNode.connectInputToOutput}. @see {@link INodeFlags.keepAllLinksOnBypass} */ + static keepAllLinksOnBypass: boolean = false + + title: string + graph: LGraph | null = null + id: NodeId + type: string | null = null + inputs: INodeInputSlot[] = [] + outputs: INodeOutputSlot[] = [] + // Not used + connections: unknown[] = [] + properties: INodeProperties = {} + properties_info: INodePropertyInfo[] = [] + flags: INodeFlags = {} + widgets?: IWidget[] + locked?: boolean + + // Execution order, automatically computed during run + order?: number + mode: LGraphEventMode + last_serialization?: ISerialisedNode + serialize_widgets?: boolean + color: string + bgcolor: string + boxcolor: string + exec_version: number + action_call?: string + execute_triggered: number + action_triggered: number + widgets_up?: boolean + widgets_start_y?: number + lostFocusAt?: number + gotFocusAt?: number + badges: (LGraphBadge | (() => LGraphBadge))[] = [] + badgePosition: BadgePosition = BadgePosition.TopLeft + onOutputRemoved?(this: LGraphNode, slot: number): void + onInputRemoved?(this: LGraphNode, slot: number, input: INodeInputSlot): void + _collapsed_width: number + /** Called once at the start of every frame. Caller may change the values in {@link out}, which will be reflected in {@link boundingRect}. */ + onBounding?(this: LGraphNode, out: Rect): void + horizontal?: boolean + console?: string[] + _level: number + _shape?: RenderShape + subgraph?: LGraph + skip_subgraph_button?: boolean + mouseOver?: IMouseOverData + redraw_on_mouse?: boolean + // Appears unused + optional_inputs? + // Appears unused + optional_outputs? + resizable?: boolean + clonable?: boolean + _relative_id?: number + clip_area?: boolean + ignore_remove?: boolean + has_errors?: boolean + removable?: boolean + block_delete?: boolean + selected?: boolean + showAdvanced?: boolean + + /** @inheritdoc {@link renderArea} */ + #renderArea: Float32Array = new Float32Array(4) + /** + * Rect describing the node area, including shadows and any protrusions. + * Determines if the node is visible. Calculated once at the start of every frame. + */ + get renderArea(): ReadOnlyRect { + return this.#renderArea + } + + /** @inheritdoc {@link boundingRect} */ + #boundingRect: Float32Array = new Float32Array(4) + /** + * Cached node position & area as `x, y, width, height`. Includes changes made by {@link onBounding}, if present. + * + * Determines the node hitbox and other rendering effects. Calculated once at the start of every frame. + */ + get boundingRect(): ReadOnlyRect { + return this.#boundingRect + } + + /** {@link pos} and {@link size} values are backed by this {@link Rect}. */ + _posSize: Float32Array = new Float32Array(4) + _pos: Point = this._posSize.subarray(0, 2) + _size: Size = this._posSize.subarray(2, 4) + + public get pos() { + return this._pos + } + + public set pos(value) { + if (!value || value.length < 2) return + + this._pos[0] = value[0] + this._pos[1] = value[1] + } + + public get size() { + return this._size + } + + public set size(value) { + if (!value || value.length < 2) return + + this._size[0] = value[0] + this._size[1] = value[1] + } + + get shape(): RenderShape { + return this._shape + } + + set shape(v: RenderShape | "default" | "box" | "round" | "circle" | "card") { + switch (v) { + case "default": + delete this._shape + break + case "box": + this._shape = RenderShape.BOX + break + case "round": + this._shape = RenderShape.ROUND + break + case "circle": + this._shape = RenderShape.CIRCLE + break + case "card": + this._shape = RenderShape.CARD + break + default: + this._shape = v + } + } + + public get is_selected(): boolean { + return this.selected + } + + public set is_selected(value: boolean) { + this.selected = value + } + + // Used in group node + setInnerNodes?(this: LGraphNode, nodes: LGraphNode[]): void + + onConnectInput?( + this: LGraphNode, + target_slot: number, + type: unknown, + output: INodeOutputSlot, + node: LGraphNode, + slot: number, + ): boolean + onConnectOutput?( + this: LGraphNode, + slot: number, + type: unknown, + input: INodeInputSlot, + target_node: number | LGraphNode, + target_slot: number, + ): boolean + onResize?(this: LGraphNode, size: Size): void + onPropertyChanged?( + this: LGraphNode, + name: string, + value: unknown, + prev_value?: unknown, + ): boolean + onConnectionsChange?( + this: LGraphNode, + type: ISlotType, + index: number, + isConnected: boolean, + link_info: LLink, + inputOrOutput: INodeInputSlot | INodeOutputSlot, + ): void + onInputAdded?(this: LGraphNode, input: INodeInputSlot): void + onOutputAdded?(this: LGraphNode, output: INodeOutputSlot): void + onConfigure?(this: LGraphNode, serialisedNode: ISerialisedNode): void + onSerialize?(this: LGraphNode, serialised: ISerialisedNode): any + onExecute?( + this: LGraphNode, + param?: unknown, + options?: { action_call?: any }, + ): void + onAction?( + this: LGraphNode, + action: string, + param: unknown, + options: { action_call?: string }, + ): void + onDrawBackground?( + this: LGraphNode, + ctx: CanvasRenderingContext2D, + canvas: LGraphCanvas, + canvasElement: HTMLCanvasElement, + mousePosition: Point, + ): void + onNodeCreated?(this: LGraphNode): void + /** + * Callback invoked by {@link connect} to override the target slot index. + * Its return value overrides the target index selection. + * @param target_slot The current input slot index + * @param requested_slot The originally requested slot index - could be negative, or if using (deprecated) name search, a string + * @returns {number | null} If a number is returned, the connection will be made to that input index. + * If an invalid index or non-number (false, null, NaN etc) is returned, the connection will be cancelled. + */ + onBeforeConnectInput?( + this: LGraphNode, + target_slot: number, + requested_slot?: number | string, + ): number | false | null + onShowCustomPanelInfo?(this: LGraphNode, panel: any): void + onAddPropertyToPanel?(this: LGraphNode, pName: string, panel: any): boolean + onWidgetChanged?( + this: LGraphNode, + name: string, + value: unknown, + old_value: unknown, + w: IWidget, + ): void + onDeselected?(this: LGraphNode): void + onKeyUp?(this: LGraphNode, e: KeyboardEvent): void + onKeyDown?(this: LGraphNode, e: KeyboardEvent): void + onSelected?(this: LGraphNode): void + getExtraMenuOptions?( + this: LGraphNode, + canvas: LGraphCanvas, + options: IContextMenuValue[], + ): IContextMenuValue[] + getMenuOptions?(this: LGraphNode, canvas: LGraphCanvas): IContextMenuValue[] + onAdded?(this: LGraphNode, graph: LGraph): void + onDrawCollapsed?( + this: LGraphNode, + ctx: CanvasRenderingContext2D, + cavnas: LGraphCanvas, + ): boolean + onDrawForeground?( + this: LGraphNode, + ctx: CanvasRenderingContext2D, + canvas: LGraphCanvas, + canvasElement: HTMLCanvasElement, + ): void + onMouseLeave?(this: LGraphNode, e: CanvasMouseEvent): void + getSlotMenuOptions?(this: LGraphNode, slot: IFoundSlot): IContextMenuValue[] + // FIXME: Re-typing + onDropItem?(this: LGraphNode, event: Event): boolean + onDropData?( + this: LGraphNode, + data: string | ArrayBuffer, + filename: any, + file: any, + ): void + onDropFile?(this: LGraphNode, file: any): void + onInputClick?(this: LGraphNode, index: number, e: CanvasMouseEvent): void + onInputDblClick?(this: LGraphNode, index: number, e: CanvasMouseEvent): void + onOutputClick?(this: LGraphNode, index: number, e: CanvasMouseEvent): void + onOutputDblClick?(this: LGraphNode, index: number, e: CanvasMouseEvent): void + // TODO: Return type + onGetPropertyInfo?(this: LGraphNode, property: string): any + onNodeOutputAdd?(this: LGraphNode, value): void + onNodeInputAdd?(this: LGraphNode, value): void + onMenuNodeInputs?( + this: LGraphNode, + entries: IOptionalSlotData[], + ): IOptionalSlotData[] + onMenuNodeOutputs?( + this: LGraphNode, + entries: IOptionalSlotData[], + ): IOptionalSlotData[] + onGetInputs?(this: LGraphNode): INodeInputSlot[] + onGetOutputs?(this: LGraphNode): INodeOutputSlot[] + onMouseUp?(this: LGraphNode, e: CanvasMouseEvent, pos: Point): void + onMouseEnter?(this: LGraphNode, e: CanvasMouseEvent): void + /** Blocks drag if return value is truthy. @param pos Offset from {@link LGraphNode.pos}. */ + onMouseDown?( + this: LGraphNode, + e: CanvasMouseEvent, + pos: Point, + canvas: LGraphCanvas, + ): boolean + /** @param pos Offset from {@link LGraphNode.pos}. */ + onDblClick?( + this: LGraphNode, + e: CanvasMouseEvent, + pos: Point, + canvas: LGraphCanvas, + ): void + /** @param pos Offset from {@link LGraphNode.pos}. */ + onNodeTitleDblClick?( + this: LGraphNode, + e: CanvasMouseEvent, + pos: Point, + canvas: LGraphCanvas, + ): void + onDrawTitle?(this: LGraphNode, ctx: CanvasRenderingContext2D): void + onDrawTitleText?( + this: LGraphNode, + ctx: CanvasRenderingContext2D, + title_height: number, + size: Size, + scale: number, + title_text_font: string, + selected: boolean, + ): void + onDrawTitleBox?( + this: LGraphNode, + ctx: CanvasRenderingContext2D, + title_height: number, + size: Size, + scale: number, + ): void + onDrawTitleBar?( + this: LGraphNode, + ctx: CanvasRenderingContext2D, + title_height: number, + size: Size, + scale: number, + fgcolor: any, + ): void + onRemoved?(this: LGraphNode): void + onMouseMove?( + this: LGraphNode, + e: MouseEvent, + pos: Point, + arg2: LGraphCanvas, + ): void + onPropertyChange?(this: LGraphNode): void + updateOutputData?(this: LGraphNode, origin_slot: number): void + isValidWidgetLink?( + slot_index: number, + node: LGraphNode, + overWidget: IWidget, + ): boolean | undefined + + constructor(title: string) { + this.id = LiteGraph.use_uuids ? LiteGraph.uuidv4() : -1 + this.title = title || "Unnamed" + this.size = [LiteGraph.NODE_WIDTH, 60] + this.pos = [10, 10] + } + + /** + * configure a node from an object containing the serialized info + */ + configure(info: ISerialisedNode): void { + if (this.graph) { + this.graph._version++ + } + for (const j in info) { + if (j == "properties") { + // i don't want to clone properties, I want to reuse the old container + for (const k in info.properties) { + this.properties[k] = info.properties[k] + this.onPropertyChanged?.(k, info.properties[k]) + } + continue + } + + if (info[j] == null) { + continue + } else if (typeof info[j] == "object") { + // object + if (this[j]?.configure) { + this[j]?.configure(info[j]) } else { - node.onExecute?.() + this[j] = LiteGraph.cloneObject(info[j], this[j]) } - - return link.data + } // value + else { + this[j] = info[j] + } } - /** - * Retrieves the input data type (in case this supports multiple input types) - * @param {number} slot - * @return {String} datatype in string format - */ - getInputDataType(slot: number): ISlotType { - if (!this.inputs) return null - - if (slot >= this.inputs.length || this.inputs[slot].link == null) return null - const link_id = this.inputs[slot].link - const link = this.graph._links.get(link_id) - //bug: weird case but it happens sometimes - if (!link) return null - - const node = this.graph.getNodeById(link.origin_id) - if (!node) return link.type - - const output_info = node.outputs[link.origin_slot] - return output_info - ? output_info.type - : null + if (!info.title) { + this.title = this.constructor.title } - /** - * Retrieves the input data from one slot using its name instead of slot number - * @param {String} slot_name - * @param {boolean} force_update if set to true it will force the connected node of this slot to output data into this link - * @return {*} data or if it is not connected returns null - */ - getInputDataByName(slot_name: string, force_update: boolean): unknown { - const slot = this.findInputSlot(slot_name) - return slot == -1 - ? null - : this.getInputData(slot, force_update) - } - - /** - * tells you if there is a connection in one input slot - * @param {number} slot - * @return {boolean} - */ - isInputConnected(slot: number): boolean { - if (!this.inputs) return false - return slot < this.inputs.length && this.inputs[slot].link != null - } - - /** - * tells you info about an input connection (which node, type, etc) - * @param {number} slot - * @return {Object} object or null { link: id, name: string, type: string or 0 } - */ - getInputInfo(slot: number): INodeInputSlot { - return !this.inputs || !(slot < this.inputs.length) - ? null - : this.inputs[slot] - } - - /** - * Returns the link info in the connection of an input slot - * @param {number} slot - * @return {LLink} object or null - */ - getInputLink(slot: number): LLink | null { - if (!this.inputs) return null - if (slot < this.inputs.length) { - const slot_info = this.inputs[slot] - return this.graph._links.get(slot_info.link) - } - return null - } - - /** - * returns the node connected in the input slot - * @param {number} slot - * @return {LGraphNode} node or null - */ - getInputNode(slot: number): LGraphNode { - if (!this.inputs) return null - if (slot >= this.inputs.length) return null - - const input = this.inputs[slot] - if (!input || input.link === null) return null - - const link_info = this.graph._links.get(input.link) - if (!link_info) return null - - return this.graph.getNodeById(link_info.origin_id) + if (this.inputs) { + for (let i = 0; i < this.inputs.length; ++i) { + const input = this.inputs[i] + const link = this.graph ? this.graph._links.get(input.link) : null + this.onConnectionsChange?.(NodeSlotType.INPUT, i, true, link, input) + this.onInputAdded?.(input) + } } - /** - * returns the value of an input with this name, otherwise checks if there is a property with that name - * @param {string} name - * @return {*} value - */ - getInputOrProperty(name: string): unknown { - if (!this.inputs || !this.inputs.length) { - return this.properties ? this.properties[name] : null - } - - for (let i = 0, l = this.inputs.length; i < l; ++i) { - const input_info = this.inputs[i] - if (name == input_info.name && input_info.link != null) { - const link = this.graph._links.get(input_info.link) - if (link) return link.data - } - } - return this.properties[name] - } - - /** - * tells you the last output data that went in that slot - * @param {number} slot - * @return {Object} object or null - */ - getOutputData(slot: number): unknown { - if (!this.outputs) return null - if (slot >= this.outputs.length) return null - - const info = this.outputs[slot] - return info._data - } - - /** - * tells you info about an output connection (which node, type, etc) - * @param {number} slot - * @return {Object} object or null { name: string, type: string, links: [ ids of links in number ] } - */ - getOutputInfo(slot: number): INodeOutputSlot { - return !this.outputs || !(slot < this.outputs.length) - ? null - : this.outputs[slot] - } - - /** - * tells you if there is a connection in one output slot - * @param {number} slot - * @return {boolean} - */ - isOutputConnected(slot: number): boolean { - if (!this.outputs) return false - return slot < this.outputs.length && this.outputs[slot].links?.length > 0 - } - - /** - * tells you if there is any connection in the output slots - * @return {boolean} - */ - isAnyOutputConnected(): boolean { - if (!this.outputs) return false - - for (let i = 0; i < this.outputs.length; ++i) { - if (this.outputs[i].links && this.outputs[i].links.length) { - return true - } + if (this.outputs) { + for (let i = 0; i < this.outputs.length; ++i) { + const output = this.outputs[i] + if (!output.links) { + continue } - return false - } - - /** - * retrieves all the nodes connected to this output slot - * @param {number} slot - * @return {array} - */ - getOutputNodes(slot: number): LGraphNode[] { - if (!this.outputs || this.outputs.length == 0) return null - - if (slot >= this.outputs.length) return null - - const output = this.outputs[slot] - if (!output.links || output.links.length == 0) return null - - const r: LGraphNode[] = [] - for (let i = 0; i < output.links.length; i++) { - const link_id = output.links[i] - const link = this.graph._links.get(link_id) - if (link) { - const target_node = this.graph.getNodeById(link.target_id) - if (target_node) { - r.push(target_node) - } - } + for (let j = 0; j < output.links.length; ++j) { + const link = this.graph + ? this.graph._links.get(output.links[j]) + : null + this.onConnectionsChange?.(NodeSlotType.OUTPUT, i, true, link, output) } - return r + this.onOutputAdded?.(output) + } } - addOnTriggerInput(): number { - const trigS = this.findInputSlot("onTrigger") - if (trigS == -1) { //!trigS || - const input = this.addInput("onTrigger", LiteGraph.EVENT, { optional: true, nameLocked: true }) - return this.findInputSlot("onTrigger") - } - return trigS - } + if (this.widgets) { + for (let i = 0; i < this.widgets.length; ++i) { + const w = this.widgets[i] + if (!w) continue - addOnExecutedOutput(): number { - const trigS = this.findOutputSlot("onExecuted") - if (trigS == -1) { //!trigS || - const output = this.addOutput("onExecuted", LiteGraph.ACTION, { optional: true, nameLocked: true }) - return this.findOutputSlot("onExecuted") + if (w.options?.property && this.properties[w.options.property] != undefined) + w.value = JSON.parse(JSON.stringify(this.properties[w.options.property])) + } + if (info.widgets_values) { + for (let i = 0; i < info.widgets_values.length; ++i) { + if (this.widgets[i]) { + this.widgets[i].value = info.widgets_values[i] + } } - return trigS + } } - onAfterExecuteNode(param: unknown, options?: { action_call?: any }) { - const trigS = this.findOutputSlot("onExecuted") - if (trigS != -1) { + // Sync the state of this.resizable. + if (this.pinned) this.pin(true) - //console.debug(this.id+":"+this.order+" triggering slot onAfterExecute"); - //console.debug(param); - //console.debug(options); - this.triggerSlot(trigS, param, null, options) + this.onConfigure?.(info) + } - } + /** + * serialize the content + */ + serialize(): ISerialisedNode { + // create serialization object + const o: ISerialisedNode = { + id: this.id, + type: this.type, + pos: [this.pos[0], this.pos[1]], + size: [this.size[0], this.size[1]], + flags: LiteGraph.cloneObject(this.flags), + order: this.order, + mode: this.mode, + showAdvanced: this.showAdvanced, } - changeMode(modeTo: number): boolean { - switch (modeTo) { - case LGraphEventMode.ON_EVENT: - // this.addOnExecutedOutput(); - break - - case LGraphEventMode.ON_TRIGGER: - this.addOnTriggerInput() - this.addOnExecutedOutput() - break - - case LGraphEventMode.NEVER: - break + // special case for when there were errors + if (this.constructor === LGraphNode && this.last_serialization) + return this.last_serialization - case LGraphEventMode.ALWAYS: - break + if (this.inputs) o.inputs = this.inputs - // @ts-expect-error Not impl. - case LiteGraph.ON_REQUEST: - break - - default: - return false - break - } - this.mode = modeTo - return true + if (this.outputs) { + // clear outputs last data (because data in connections is never serialized but stored inside the outputs info) + for (let i = 0; i < this.outputs.length; i++) { + delete this.outputs[i]._data + } + o.outputs = this.outputs } - /** - * Triggers the node code execution, place a boolean/counter to mark the node as being executed - * @param {*} param - * @param {*} options - */ - doExecute(param?: unknown, options?: { action_call?: any }): void { - options = options || {} - if (this.onExecute) { - - // enable this to give the event an ID - options.action_call ||= this.id + "_exec_" + Math.floor(Math.random() * 9999) - - this.graph.nodes_executing[this.id] = true //.push(this.id); - this.onExecute(param, options) - this.graph.nodes_executing[this.id] = false //.pop(); - - // save execution/action ref - this.exec_version = this.graph.iteration - if (options?.action_call) { - this.action_call = options.action_call // if (param) - this.graph.nodes_executedAction[this.id] = options.action_call - } - } - this.execute_triggered = 2 // the nFrames it will be used (-- each step), means "how old" is the event - this.onAfterExecuteNode?.(param, options) // callback - } - - /** - * Triggers an action, wrapped by logics to control execution flow - * @param {String} action name - * @param {*} param - */ - actionDo(action: string, param: unknown, options: { action_call?: string }): void { - options = options || {} - if (this.onAction) { - - // enable this to give the event an ID - options.action_call ||= this.id + "_" + (action ? action : "action") + "_" + Math.floor(Math.random() * 9999) - - this.graph.nodes_actioning[this.id] = (action ? action : "actioning") //.push(this.id); - this.onAction(action, param, options) - this.graph.nodes_actioning[this.id] = false //.pop(); - - // save execution/action ref - if (options?.action_call) { - this.action_call = options.action_call // if (param) - this.graph.nodes_executedAction[this.id] = options.action_call - } - } - this.action_triggered = 2 // the nFrames it will be used (-- each step), means "how old" is the event - this.onAfterExecuteNode?.(param, options) - } - - /** - * Triggers an event in this node, this will trigger any output with the same name - * @param {String} event name ( "on_play", ... ) if action is equivalent to false then the event is send to all - * @param {*} param - */ - trigger(action: string, param: unknown, options: { action_call?: any }): void { - if (!this.outputs || !this.outputs.length) { - return - } + if (this.title && this.title != this.constructor.title) o.title = this.title - if (this.graph) - this.graph._last_trigger_time = LiteGraph.getTime() + if (this.properties) o.properties = LiteGraph.cloneObject(this.properties) - for (let i = 0; i < this.outputs.length; ++i) { - const output = this.outputs[i] - if (!output || output.type !== LiteGraph.EVENT || (action && output.name != action)) - continue - this.triggerSlot(i, param, null, options) - } + if (this.widgets && this.serialize_widgets) { + o.widgets_values = [] + for (let i = 0; i < this.widgets.length; ++i) { + if (this.widgets[i]) + o.widgets_values[i] = this.widgets[i].value + else + o.widgets_values[i] = null + } } - /** - * Triggers a slot event in this node: cycle output slots and launch execute/action on connected nodes - * @param {Number} slot the index of the output slot - * @param {*} param - * @param {Number} link_id [optional] in case you want to trigger and specific output link in a slot - */ - triggerSlot(slot: number, param: unknown, link_id: number, options: { action_call?: any }): void { - options = options || {} - if (!this.outputs) return - - if (slot == null) { - console.error("slot must be a number") - return - } - - if (typeof slot !== "number") - console.warn("slot must be a number, use node.trigger('name') if you want to use a string") - - const output = this.outputs[slot] - if (!output) return + if (!o.type) o.type = this.constructor.type - const links = output.links - if (!links || !links.length) return + if (this.color) o.color = this.color + if (this.bgcolor) o.bgcolor = this.bgcolor + if (this.boxcolor) o.boxcolor = this.boxcolor + if (this.shape) o.shape = this.shape - if (this.graph) - this.graph._last_trigger_time = LiteGraph.getTime() + if (this.onSerialize?.(o)) console.warn("node onSerialize shouldnt return anything, data should be stored in the object pass in the first parameter") - //for every link attached here - for (let k = 0; k < links.length; ++k) { - const id = links[k] - //to skip links - if (link_id != null && link_id != id) continue + return o + } - const link_info = this.graph._links.get(id) - //not connected - if (!link_info) continue + /* Creates a clone of this node */ + clone(): LGraphNode { + const node = LiteGraph.createNode(this.type) + if (!node) return null - link_info._last_time = LiteGraph.getTime() - const node = this.graph.getNodeById(link_info.target_id) - //node not found? - if (!node) continue + // we clone it because serialize returns shared containers + const data = LiteGraph.cloneObject(this.serialize()) - if (node.mode === LGraphEventMode.ON_TRIGGER) { - // generate unique trigger ID if not present - if (!options.action_call) options.action_call = this.id + "_trigg_" + Math.floor(Math.random() * 9999) - // -- wrapping node.onExecute(param); -- - node.doExecute?.(param, options) - } - else if (node.onAction) { - // generate unique action ID if not present - if (!options.action_call) options.action_call = this.id + "_act_" + Math.floor(Math.random() * 9999) - //pass the action name - const target_connection = node.inputs[link_info.target_slot] - // wrap node.onAction(target_connection.name, param); - node.actionDo(target_connection.name, param, options) - } - } + // remove links + if (data.inputs) { + for (let i = 0; i < data.inputs.length; ++i) { + data.inputs[i].link = null + } } - /** - * clears the trigger slot animation - * @param {Number} slot the index of the output slot - * @param {Number} link_id [optional] in case you want to trigger and specific output link in a slot - */ - clearTriggeredSlot(slot: number, link_id: number): void { - if (!this.outputs) return - - const output = this.outputs[slot] - if (!output) return - - const links = output.links - if (!links || !links.length) return - - //for every link attached here - for (let k = 0; k < links.length; ++k) { - const id = links[k] - //to skip links - if (link_id != null && link_id != id) continue - - const link_info = this.graph._links.get(id) - //not connected - if (!link_info) continue - - link_info._last_time = 0 - } - } - - /** - * changes node size and triggers callback - * @param {vec2} size - */ - setSize(size: Size): void { - this.size = size - this.onResize?.(this.size) - } - - /** - * add a new property to this node - * @param {string} name - * @param {*} default_value - * @param {string} type string defining the output type ("vec3","number",...) - * @param {Object} extra_info this can be used to have special properties of the property (like values, etc) - */ - addProperty(name: string, - default_value: unknown, - type?: string, - extra_info?: Dictionary): INodePropertyInfo { - const o: INodePropertyInfo = { name: name, type: type, default_value: default_value } - if (extra_info) { - for (const i in extra_info) { - o[i] = extra_info[i] - } - } - this.properties_info ||= [] - this.properties_info.push(o) - this.properties ||= {} - this.properties[name] = default_value - return o - } - - /** - * add a new output slot to use in this node - * @param {string} name - * @param {string} type string defining the output type ("vec3","number",...) - * @param {Object} extra_info this can be used to have special properties of an output (label, special color, position, etc) - */ - addOutput(name?: string, type?: ISlotType, extra_info?: object): INodeOutputSlot { - const output = { name: name, type: type, links: null } - if (extra_info) { - for (const i in extra_info) { - output[i] = extra_info[i] - } - } - - this.outputs ||= [] - this.outputs.push(output) - this.onOutputAdded?.(output) - - if (LiteGraph.auto_load_slot_types) LiteGraph.registerNodeAndSlotType(this, type, true) - - this.setSize(this.computeSize()) - this.setDirtyCanvas(true, true) - return output - } - - /** - * add a new output slot to use in this node - * @param {Array} array of triplets like [[name,type,extra_info],[...]] - */ - addOutputs(array: [string, ISlotType, Record][]): void { - for (let i = 0; i < array.length; ++i) { - const info = array[i] - const o = { name: info[0], type: info[1], links: null } - if (array[2]) { - for (const j in info[2]) { - o[j] = info[2][j] - } - } - - this.outputs ||= [] - this.outputs.push(o) - this.onOutputAdded?.(o) - - if (LiteGraph.auto_load_slot_types) LiteGraph.registerNodeAndSlotType(this, info[1], true) - - } - - this.setSize(this.computeSize()) - this.setDirtyCanvas(true, true) - } - - /** - * remove an existing output slot - * @param {number} slot - */ - removeOutput(slot: number): void { - this.disconnectOutput(slot) - this.outputs.splice(slot, 1) - for (let i = slot; i < this.outputs.length; ++i) { - if (!this.outputs[i] || !this.outputs[i].links) - continue - const links = this.outputs[i].links - for (let j = 0; j < links.length; ++j) { - const link = this.graph._links.get(links[j]) - if (!link) continue - - link.origin_slot -= 1 - } - } - - this.setSize(this.computeSize()) - this.onOutputRemoved?.(slot) - this.setDirtyCanvas(true, true) - } - - /** - * add a new input slot to use in this node - * @param {string} name - * @param {string} type string defining the input type ("vec3","number",...), it its a generic one use 0 - * @param {Object} extra_info this can be used to have special properties of an input (label, color, position, etc) - */ - addInput(name: string, type: ISlotType, extra_info?: object): INodeInputSlot { - type = type || 0 - const input: INodeInputSlot = { name: name, type: type, link: null } - if (extra_info) { - for (const i in extra_info) { - input[i] = extra_info[i] - } + if (data.outputs) { + for (let i = 0; i < data.outputs.length; ++i) { + if (data.outputs[i].links) { + data.outputs[i].links.length = 0 } - - this.inputs ||= [] - this.inputs.push(input) - this.setSize(this.computeSize()) - - this.onInputAdded?.(input) - LiteGraph.registerNodeAndSlotType(this, type) - - this.setDirtyCanvas(true, true) - return input - } - - /** - * add several new input slots in this node - * @param {Array} array of triplets like [[name,type,extra_info],[...]] - */ - addInputs(array: [string, ISlotType, Record][]): void { - for (let i = 0; i < array.length; ++i) { - const info = array[i] - const o: INodeInputSlot = { name: info[0], type: info[1], link: null } - // TODO: Checking the wrong variable here - confirm no downstream consumers, then remove. - if (array[2]) { - for (const j in info[2]) { - o[j] = info[2][j] - } - } - - this.inputs ||= [] - this.inputs.push(o) - this.onInputAdded?.(o) - - LiteGraph.registerNodeAndSlotType(this, info[1]) - } - - this.setSize(this.computeSize()) - this.setDirtyCanvas(true, true) + } } - /** - * remove an existing input slot - * @param {number} slot - */ - removeInput(slot: number): void { - this.disconnectInput(slot) - const slot_info = this.inputs.splice(slot, 1) - for (let i = slot; i < this.inputs.length; ++i) { - if (!this.inputs[i]) continue - - const link = this.graph._links.get(this.inputs[i].link) - if (!link) continue + delete data.id - link.target_slot -= 1 - } - this.setSize(this.computeSize()) - this.onInputRemoved?.(slot, slot_info[0]) - this.setDirtyCanvas(true, true) - } - - /** - * add an special connection to this node (used for special kinds of graphs) - * @param {string} name - * @param {string} type string defining the input type ("vec3","number",...) - * @param {[x,y]} pos position of the connection inside the node - * @param {string} direction if is input or output - */ - addConnection(name: string, type: string, pos: Point, direction: string) { - const o = { - name: name, - type: type, - pos: pos, - direction: direction, - links: null - } - this.connections.push(o) - return o - } - - /** - * computes the minimum size of a node according to its inputs and output slots - * @param out - * @return the total size - */ - computeSize(out?: Size): Size { - const ctorSize = this.constructor.size - if (ctorSize) return [ctorSize[0], ctorSize[1]] - - let rows = Math.max( - this.inputs ? this.inputs.length : 1, - this.outputs ? this.outputs.length : 1 - ) - const size = out || new Float32Array([0, 0]) - rows = Math.max(rows, 1) - const font_size = LiteGraph.NODE_TEXT_SIZE //although it should be graphcanvas.inner_text_font size - - const title_width = compute_text_size(this.title) - let input_width = 0 - let output_width = 0 - - if (this.inputs) { - for (let i = 0, l = this.inputs.length; i < l; ++i) { - const input = this.inputs[i] - const text = input.label || input.name || "" - const text_width = compute_text_size(text) - if (input_width < text_width) - input_width = text_width - } - } + if (LiteGraph.use_uuids) data.id = LiteGraph.uuidv4() - if (this.outputs) { - for (let i = 0, l = this.outputs.length; i < l; ++i) { - const output = this.outputs[i] - const text = output.label || output.name || "" - const text_width = compute_text_size(text) - if (output_width < text_width) - output_width = text_width - } - } + // remove links + node.configure(data) - size[0] = Math.max(input_width + output_width + 10, title_width) - size[0] = Math.max(size[0], LiteGraph.NODE_WIDTH) - if (this.widgets?.length) - size[0] = Math.max(size[0], LiteGraph.NODE_WIDTH * 1.5) + return node + } - size[1] = (this.constructor.slot_start_y || 0) + rows * LiteGraph.NODE_SLOT_HEIGHT + /** + * serialize and stringify + */ + toString(): string { + return JSON.stringify(this.serialize()) + } - let widgets_height = 0 - if (this.widgets?.length) { - for (let i = 0, l = this.widgets.length; i < l; ++i) { - const widget = this.widgets[i] - if (widget.hidden || (widget.advanced && !this.showAdvanced)) continue + /** + * get the title string + */ + getTitle(): string { + return this.title || this.constructor.title + } - widgets_height += widget.computeSize - ? widget.computeSize(size[0])[1] + 4 - : LiteGraph.NODE_WIDGET_HEIGHT + 4 - } - widgets_height += 8 - } + /** + * sets the value of a property + * @param {String} name + * @param {*} value + */ + setProperty(name: string, value: TWidgetValue): void { + this.properties ||= {} + if (value === this.properties[name]) return - //compute height using widgets height - if (this.widgets_up) - size[1] = Math.max(size[1], widgets_height) - else if (this.widgets_start_y != null) - size[1] = Math.max(size[1], widgets_height + this.widgets_start_y) - else - size[1] += widgets_height + const prev_value = this.properties[name] + this.properties[name] = value + // abort change + if (this.onPropertyChanged?.(name, value, prev_value) === false) + this.properties[name] = prev_value - function compute_text_size(text: string) { - return text - ? font_size * text.length * 0.6 - : 0 - } + // widgets could be linked to properties + if (this.widgets) { + for (let i = 0; i < this.widgets.length; ++i) { + const w = this.widgets[i] + if (!w) continue - if (this.constructor.min_height && size[1] < this.constructor.min_height) { - size[1] = this.constructor.min_height + if (w.options.property == name) { + w.value = value + break } - - //margin - size[1] += 6 - - return size + } } + } - inResizeCorner(canvasX: number, canvasY: number): boolean { - const rows = this.outputs ? this.outputs.length : 1 - const outputs_offset = (this.constructor.slot_start_y || 0) + rows * LiteGraph.NODE_SLOT_HEIGHT - return isInRectangle(canvasX, - canvasY, - this.pos[0] + this.size[0] - 15, - this.pos[1] + Math.max(this.size[1] - 15, outputs_offset), - 20, - 20 - ) - } - - /** - * returns all the info available about a property of this node. - * - * @param {String} property name of the property - * @return {Object} the object with all the available info - */ - getPropertyInfo(property: string) { - let info = null - - //there are several ways to define info about a property - //legacy mode - if (this.properties_info) { - for (let i = 0; i < this.properties_info.length; ++i) { - if (this.properties_info[i].name == property) { - info = this.properties_info[i] - break - } - } - } - //litescene mode using the constructor - if (this.constructor["@" + property]) - info = this.constructor["@" + property] - - if (this.constructor.widgets_info?.[property]) - info = this.constructor.widgets_info[property] - - //litescene mode using the constructor - if (!info && this.onGetPropertyInfo) { - info = this.onGetPropertyInfo(property) - } - - info ||= {} - info.type ||= typeof this.properties[property] - if (info.widget == "combo") - info.type = "enum" - - return info - } - - /** - * Defines a widget inside the node, it will be rendered on top of the node, you can control lots of properties - * - * @param {String} type the widget type (could be "number","string","combo" - * @param {String} name the text to show on the widget - * @param {String} value the default value - * @param {Function|String} callback function to call when it changes (optionally, it can be the name of the property to modify) - * @param {Object} options the object that contains special properties of this widget - * @return {Object} the created widget object - */ - addWidget(type: string, name: string, value: any, callback: IWidget["callback"], options?: any): IWidget { - this.widgets ||= [] - - if (!options && callback && typeof callback === "object") { - options = callback - callback = null - } - - //options can be the property name - if (options && typeof options === "string") - options = { property: options } - - //callback can be the property name - if (callback && typeof callback === "string") { - options ||= {} - options.property = callback - callback = null - } - - if (callback && typeof callback !== "function") { - console.warn("addWidget: callback must be a function") - callback = null - } - - const w: IWidget = { - // @ts-expect-error Type check or just assert? - type: type.toLowerCase(), - name: name, - value: value, - callback: callback, - options: options || {} - } + /** + * sets the output data + * @param {number} slot + * @param {*} data + */ + setOutputData(slot: number, data: unknown): void { + if (!this.outputs) return - if (w.options.y !== undefined) { - w.y = w.options.y - } + // this maybe slow and a niche case + // if(slot && slot.constructor === String) + // slot = this.findOutputSlot(slot); + if (slot == -1 || slot >= this.outputs.length) return - if (!callback && !w.options.callback && !w.options.property) { - console.warn("LiteGraph addWidget(...) without a callback or property assigned") - } - if (type == "combo" && !w.options.values) { - throw "LiteGraph addWidget('combo',...) requires to pass values in options: { values:['red','blue'] }" - } - this.widgets.push(w) - this.setSize(this.computeSize()) - return w - } - - addCustomWidget(custom_widget: IWidget): IWidget { - this.widgets ||= [] - this.widgets.push(custom_widget) - return custom_widget - } - - move(deltaX: number, deltaY: number): void { - if (this.pinned) return - - this.pos[0] += deltaX - this.pos[1] += deltaY - } - - /** - * Internal method to measure the node for rendering. Prefer {@link boundingRect} where possible. - * - * Populates {@link out} with the results in graph space. - * Adjusts for title and collapsed status, but does not call {@link onBounding}. - * @param out `x, y, width, height` are written to this array. - * @param pad Expands the area by this amount on each side. Default: 0 - */ - measure(out: Rect, pad = 0): void { - const titleMode = this.constructor.title_mode - const renderTitle = titleMode != TitleMode.TRANSPARENT_TITLE && titleMode != TitleMode.NO_TITLE - const titleHeight = renderTitle ? LiteGraph.NODE_TITLE_HEIGHT : 0 - - out[0] = this.pos[0] - pad - out[1] = this.pos[1] + -titleHeight - pad - if (!this.flags?.collapsed) { - out[2] = this.size[0] + (2 * pad) - out[3] = this.size[1] + titleHeight + (2 * pad) - } else { - out[2] = (this._collapsed_width || LiteGraph.NODE_COLLAPSED_WIDTH) + (2 * pad) - out[3] = LiteGraph.NODE_TITLE_HEIGHT + (2 * pad) - } - } + const output_info = this.outputs[slot] + if (!output_info) return + + // store data in the output itself in case we want to debug + output_info._data = data - /** - * returns the bounding of the object, used for rendering purposes - * @param out {Float32Array[4]?} [optional] a place to store the output, to free garbage - * @param includeExternal {boolean?} [optional] set to true to include the shadow and connection points in the bounding calculation - * @return {Float32Array[4]} the bounding box in format of [topleft_cornerx, topleft_cornery, width, height] - */ - getBounding(out?: Rect, includeExternal?: boolean): Rect { - out ||= new Float32Array(4) - - const rect = includeExternal ? this.renderArea : this.boundingRect - out[0] = rect[0] - out[1] = rect[1] - out[2] = rect[2] - out[3] = rect[3] - - return out - } - - /** - * Calculates the render area of this node, populating both {@link boundingRect} and {@link renderArea}. - * Called automatically at the start of every frame. - */ - updateArea(): void { - const bounds = this.#boundingRect - this.measure(bounds) - this.onBounding?.(bounds) - - const renderArea = this.#renderArea - renderArea.set(bounds) - // 4 offset for collapsed node connection points - renderArea[0] -= 4 - renderArea[1] -= 4 - // Add shadow & left offset - renderArea[2] += 6 + 4 - // Add shadow & top offsets - renderArea[3] += 5 + 4 - } - - /** - * checks if a point is inside the shape of a node - * @param {number} x - * @param {number} y - * @return {boolean} - */ - isPointInside(x: number, y: number): boolean { - return isInRect(x, y, this.boundingRect) - } - - /** - * Checks if the provided point is inside this node's collapse button area. - * @param x X co-ordinate to check - * @param y Y co-ordinate to check - * @returns true if the x,y point is in the collapse button area, otherwise false - */ - isPointInCollapse(x: number, y: number): boolean { - const squareLength = LiteGraph.NODE_TITLE_HEIGHT - return isInRectangle(x, y, this.pos[0], this.pos[1] - squareLength, squareLength, squareLength) - } - - /** - * checks if a point is inside a node slot, and returns info about which slot - * @param x - * @param y - * @returns if found the object contains { input|output: slot object, slot: number, link_pos: [x,y] } - */ - getSlotInPosition(x: number, y: number): IFoundSlot | null { - //search for inputs - const link_pos = new Float32Array(2) - if (this.inputs) { - for (let i = 0, l = this.inputs.length; i < l; ++i) { - const input = this.inputs[i] - this.getConnectionPos(true, i, link_pos) - if (isInRectangle(x, y, link_pos[0] - 10, link_pos[1] - 5, 20, 10)) { - return { input, slot: i, link_pos } - } - } - } - - if (this.outputs) { - for (let i = 0, l = this.outputs.length; i < l; ++i) { - const output = this.outputs[i] - this.getConnectionPos(false, i, link_pos) - if (isInRectangle(x, y, link_pos[0] - 10, link_pos[1] - 5, 20, 10)) { - return { output, slot: i, link_pos } - } + // if there are connections, pass the data to the connections + if (this.outputs[slot].links) { + for (let i = 0; i < this.outputs[slot].links.length; i++) { + const link_id = this.outputs[slot].links[i] + const link = this.graph._links.get(link_id) + if (link) link.data = data + } + } + } + + /** + * sets the output data type, useful when you want to be able to overwrite the data type + * @param {number} slot + * @param {String} datatype + */ + setOutputDataType(slot: number, type: ISlotType): void { + if (!this.outputs) return + if (slot == -1 || slot >= this.outputs.length) return + const output_info = this.outputs[slot] + if (!output_info) return + // store data in the output itself in case we want to debug + output_info.type = type + + // if there are connections, pass the data to the connections + if (this.outputs[slot].links) { + for (let i = 0; i < this.outputs[slot].links.length; i++) { + const link_id = this.outputs[slot].links[i] + this.graph._links.get(link_id).type = type + } + } + } + + /** + * Retrieves the input data (data traveling through the connection) from one slot + * @param {number} slot + * @param {boolean} force_update if set to true it will force the connected node of this slot to output data into this link + * @return {*} data or if it is not connected returns undefined + */ + getInputData(slot: number, force_update?: boolean): unknown { + if (!this.inputs) return + + if (slot >= this.inputs.length || this.inputs[slot].link == null) return + + const link_id = this.inputs[slot].link + const link = this.graph._links.get(link_id) + // bug: weird case but it happens sometimes + if (!link) return null + + if (!force_update) return link.data + + // special case: used to extract data from the incoming connection before the graph has been executed + const node = this.graph.getNodeById(link.origin_id) + if (!node) return link.data + + if (node.updateOutputData) { + node.updateOutputData(link.origin_slot) + } else { + node.onExecute?.() + } + + return link.data + } + + /** + * Retrieves the input data type (in case this supports multiple input types) + * @param {number} slot + * @return {String} datatype in string format + */ + getInputDataType(slot: number): ISlotType { + if (!this.inputs) return null + if (slot >= this.inputs.length || this.inputs[slot].link == null) return null + + const link_id = this.inputs[slot].link + const link = this.graph._links.get(link_id) + // bug: weird case but it happens sometimes + if (!link) return null + + const node = this.graph.getNodeById(link.origin_id) + if (!node) return link.type + + const output_info = node.outputs[link.origin_slot] + return output_info + ? output_info.type + : null + } + + /** + * Retrieves the input data from one slot using its name instead of slot number + * @param {String} slot_name + * @param {boolean} force_update if set to true it will force the connected node of this slot to output data into this link + * @return {*} data or if it is not connected returns null + */ + getInputDataByName(slot_name: string, force_update: boolean): unknown { + const slot = this.findInputSlot(slot_name) + return slot == -1 + ? null + : this.getInputData(slot, force_update) + } + + /** + * tells you if there is a connection in one input slot + * @param {number} slot + * @return {boolean} + */ + isInputConnected(slot: number): boolean { + if (!this.inputs) return false + return slot < this.inputs.length && this.inputs[slot].link != null + } + + /** + * tells you info about an input connection (which node, type, etc) + * @param {number} slot + * @return {Object} object or null { link: id, name: string, type: string or 0 } + */ + getInputInfo(slot: number): INodeInputSlot { + return !this.inputs || !(slot < this.inputs.length) + ? null + : this.inputs[slot] + } + + /** + * Returns the link info in the connection of an input slot + * @param {number} slot + * @return {LLink} object or null + */ + getInputLink(slot: number): LLink | null { + if (!this.inputs) return null + if (slot < this.inputs.length) { + const slot_info = this.inputs[slot] + return this.graph._links.get(slot_info.link) + } + return null + } + + /** + * returns the node connected in the input slot + * @param {number} slot + * @return {LGraphNode} node or null + */ + getInputNode(slot: number): LGraphNode { + if (!this.inputs) return null + if (slot >= this.inputs.length) return null + + const input = this.inputs[slot] + if (!input || input.link === null) return null + + const link_info = this.graph._links.get(input.link) + if (!link_info) return null + + return this.graph.getNodeById(link_info.origin_id) + } + + /** + * returns the value of an input with this name, otherwise checks if there is a property with that name + * @param {string} name + * @return {*} value + */ + getInputOrProperty(name: string): unknown { + if (!this.inputs || !this.inputs.length) { + return this.properties ? this.properties[name] : null + } + + for (let i = 0, l = this.inputs.length; i < l; ++i) { + const input_info = this.inputs[i] + if (name == input_info.name && input_info.link != null) { + const link = this.graph._links.get(input_info.link) + if (link) return link.data + } + } + return this.properties[name] + } + + /** + * tells you the last output data that went in that slot + * @param {number} slot + * @return {Object} object or null + */ + getOutputData(slot: number): unknown { + if (!this.outputs) return null + if (slot >= this.outputs.length) return null + + const info = this.outputs[slot] + return info._data + } + + /** + * tells you info about an output connection (which node, type, etc) + * @param {number} slot + * @return {Object} object or null { name: string, type: string, links: [ ids of links in number ] } + */ + getOutputInfo(slot: number): INodeOutputSlot { + return !this.outputs || !(slot < this.outputs.length) + ? null + : this.outputs[slot] + } + + /** + * tells you if there is a connection in one output slot + * @param {number} slot + * @return {boolean} + */ + isOutputConnected(slot: number): boolean { + if (!this.outputs) return false + return slot < this.outputs.length && this.outputs[slot].links?.length > 0 + } + + /** + * tells you if there is any connection in the output slots + * @return {boolean} + */ + isAnyOutputConnected(): boolean { + if (!this.outputs) return false + + for (let i = 0; i < this.outputs.length; ++i) { + if (this.outputs[i].links && this.outputs[i].links.length) { + return true + } + } + return false + } + + /** + * retrieves all the nodes connected to this output slot + * @param {number} slot + * @return {array} + */ + getOutputNodes(slot: number): LGraphNode[] { + if (!this.outputs || this.outputs.length == 0) return null + + if (slot >= this.outputs.length) return null + + const output = this.outputs[slot] + if (!output.links || output.links.length == 0) return null + + const r: LGraphNode[] = [] + for (let i = 0; i < output.links.length; i++) { + const link_id = output.links[i] + const link = this.graph._links.get(link_id) + if (link) { + const target_node = this.graph.getNodeById(link.target_id) + if (target_node) { + r.push(target_node) + } + } + } + return r + } + + addOnTriggerInput(): number { + const trigS = this.findInputSlot("onTrigger") + // !trigS || + if (trigS == -1) { + const input = this.addInput("onTrigger", LiteGraph.EVENT, { + optional: true, + nameLocked: true, + }) + return this.findInputSlot("onTrigger") + } + return trigS + } + + addOnExecutedOutput(): number { + const trigS = this.findOutputSlot("onExecuted") + // !trigS || + if (trigS == -1) { + const output = this.addOutput("onExecuted", LiteGraph.ACTION, { + optional: true, + nameLocked: true, + }) + return this.findOutputSlot("onExecuted") + } + return trigS + } + + onAfterExecuteNode(param: unknown, options?: { action_call?: any }) { + const trigS = this.findOutputSlot("onExecuted") + if (trigS != -1) { + // console.debug(this.id+":"+this.order+" triggering slot onAfterExecute"); + // console.debug(param); + // console.debug(options); + this.triggerSlot(trigS, param, null, options) + } + } + + changeMode(modeTo: number): boolean { + switch (modeTo) { + case LGraphEventMode.ON_EVENT: + // this.addOnExecutedOutput(); + break + + case LGraphEventMode.ON_TRIGGER: + this.addOnTriggerInput() + this.addOnExecutedOutput() + break + + case LGraphEventMode.NEVER: + break + + case LGraphEventMode.ALWAYS: + break + + // @ts-expect-error Not impl. + case LiteGraph.ON_REQUEST: + break + + default: + return false + break + } + this.mode = modeTo + return true + } + + /** + * Triggers the node code execution, place a boolean/counter to mark the node as being executed + * @param {*} param + * @param {*} options + */ + doExecute(param?: unknown, options?: { action_call?: any }): void { + options = options || {} + if (this.onExecute) { + // enable this to give the event an ID + options.action_call ||= this.id + "_exec_" + Math.floor(Math.random() * 9999) + + this.graph.nodes_executing[this.id] = true // .push(this.id); + this.onExecute(param, options) + this.graph.nodes_executing[this.id] = false // .pop(); + + // save execution/action ref + this.exec_version = this.graph.iteration + if (options?.action_call) { + this.action_call = options.action_call // if (param) + this.graph.nodes_executedAction[this.id] = options.action_call + } + } + this.execute_triggered = 2 // the nFrames it will be used (-- each step), means "how old" is the event + this.onAfterExecuteNode?.(param, options) // callback + } + + /** + * Triggers an action, wrapped by logics to control execution flow + * @param {String} action name + * @param {*} param + */ + actionDo( + action: string, + param: unknown, + options: { action_call?: string }, + ): void { + options = options || {} + if (this.onAction) { + // enable this to give the event an ID + options.action_call ||= this.id + "_" + (action ? action : "action") + "_" + Math.floor(Math.random() * 9999) + + this.graph.nodes_actioning[this.id] = action ? action : "actioning" // .push(this.id); + this.onAction(action, param, options) + this.graph.nodes_actioning[this.id] = false // .pop(); + + // save execution/action ref + if (options?.action_call) { + this.action_call = options.action_call // if (param) + this.graph.nodes_executedAction[this.id] = options.action_call + } + } + this.action_triggered = 2 // the nFrames it will be used (-- each step), means "how old" is the event + this.onAfterExecuteNode?.(param, options) + } + + /** + * Triggers an event in this node, this will trigger any output with the same name + * @param {String} event name ( "on_play", ... ) if action is equivalent to false then the event is send to all + * @param {*} param + */ + trigger( + action: string, + param: unknown, + options: { action_call?: any }, + ): void { + if (!this.outputs || !this.outputs.length) { + return + } + + if (this.graph) this.graph._last_trigger_time = LiteGraph.getTime() + + for (let i = 0; i < this.outputs.length; ++i) { + const output = this.outputs[i] + if ( + !output || + output.type !== LiteGraph.EVENT || + (action && output.name != action) + ) + continue + this.triggerSlot(i, param, null, options) + } + } + + /** + * Triggers a slot event in this node: cycle output slots and launch execute/action on connected nodes + * @param {Number} slot the index of the output slot + * @param {*} param + * @param {Number} link_id [optional] in case you want to trigger and specific output link in a slot + */ + triggerSlot( + slot: number, + param: unknown, + link_id: number, + options: { action_call?: any }, + ): void { + options = options || {} + if (!this.outputs) return + + if (slot == null) { + console.error("slot must be a number") + return + } + + if (typeof slot !== "number") + console.warn("slot must be a number, use node.trigger('name') if you want to use a string") + + const output = this.outputs[slot] + if (!output) return + + const links = output.links + if (!links || !links.length) return + + if (this.graph) this.graph._last_trigger_time = LiteGraph.getTime() + + // for every link attached here + for (let k = 0; k < links.length; ++k) { + const id = links[k] + // to skip links + if (link_id != null && link_id != id) continue + + const link_info = this.graph._links.get(id) + // not connected + if (!link_info) continue + + link_info._last_time = LiteGraph.getTime() + const node = this.graph.getNodeById(link_info.target_id) + // node not found? + if (!node) continue + + if (node.mode === LGraphEventMode.ON_TRIGGER) { + // generate unique trigger ID if not present + if (!options.action_call) + options.action_call = this.id + "_trigg_" + Math.floor(Math.random() * 9999) + // -- wrapping node.onExecute(param); -- + node.doExecute?.(param, options) + } else if (node.onAction) { + // generate unique action ID if not present + if (!options.action_call) + options.action_call = this.id + "_act_" + Math.floor(Math.random() * 9999) + // pass the action name + const target_connection = node.inputs[link_info.target_slot] + // wrap node.onAction(target_connection.name, param); + node.actionDo(target_connection.name, param, options) + } + } + } + + /** + * clears the trigger slot animation + * @param {Number} slot the index of the output slot + * @param {Number} link_id [optional] in case you want to trigger and specific output link in a slot + */ + clearTriggeredSlot(slot: number, link_id: number): void { + if (!this.outputs) return + + const output = this.outputs[slot] + if (!output) return + + const links = output.links + if (!links || !links.length) return + + // for every link attached here + for (let k = 0; k < links.length; ++k) { + const id = links[k] + // to skip links + if (link_id != null && link_id != id) continue + + const link_info = this.graph._links.get(id) + // not connected + if (!link_info) continue + + link_info._last_time = 0 + } + } + + /** + * changes node size and triggers callback + * @param {vec2} size + */ + setSize(size: Size): void { + this.size = size + this.onResize?.(this.size) + } + + /** + * add a new property to this node + * @param {string} name + * @param {*} default_value + * @param {string} type string defining the output type ("vec3","number",...) + * @param {Object} extra_info this can be used to have special properties of the property (like values, etc) + */ + addProperty( + name: string, + default_value: unknown, + type?: string, + extra_info?: Dictionary, + ): INodePropertyInfo { + const o: INodePropertyInfo = { + name: name, + type: type, + default_value: default_value, + } + if (extra_info) { + for (const i in extra_info) { + o[i] = extra_info[i] + } + } + this.properties_info ||= [] + this.properties_info.push(o) + this.properties ||= {} + this.properties[name] = default_value + return o + } + + /** + * add a new output slot to use in this node + * @param {string} name + * @param {string} type string defining the output type ("vec3","number",...) + * @param {Object} extra_info this can be used to have special properties of an output (label, special color, position, etc) + */ + addOutput( + name?: string, + type?: ISlotType, + extra_info?: object, + ): INodeOutputSlot { + const output = { name: name, type: type, links: null } + if (extra_info) { + for (const i in extra_info) { + output[i] = extra_info[i] + } + } + + this.outputs ||= [] + this.outputs.push(output) + this.onOutputAdded?.(output) + + if (LiteGraph.auto_load_slot_types) + LiteGraph.registerNodeAndSlotType(this, type, true) + + this.setSize(this.computeSize()) + this.setDirtyCanvas(true, true) + return output + } + + /** + * add a new output slot to use in this node + * @param {Array} array of triplets like [[name,type,extra_info],[...]] + */ + addOutputs(array: [string, ISlotType, Record][]): void { + for (let i = 0; i < array.length; ++i) { + const info = array[i] + const o = { name: info[0], type: info[1], links: null } + if (array[2]) { + for (const j in info[2]) { + o[j] = info[2][j] + } + } + + this.outputs ||= [] + this.outputs.push(o) + this.onOutputAdded?.(o) + + if (LiteGraph.auto_load_slot_types) + LiteGraph.registerNodeAndSlotType(this, info[1], true) + } + + this.setSize(this.computeSize()) + this.setDirtyCanvas(true, true) + } + + /** + * remove an existing output slot + * @param {number} slot + */ + removeOutput(slot: number): void { + this.disconnectOutput(slot) + this.outputs.splice(slot, 1) + for (let i = slot; i < this.outputs.length; ++i) { + if (!this.outputs[i] || !this.outputs[i].links) continue + + const links = this.outputs[i].links + for (let j = 0; j < links.length; ++j) { + const link = this.graph._links.get(links[j]) + if (!link) continue + + link.origin_slot -= 1 + } + } + + this.setSize(this.computeSize()) + this.onOutputRemoved?.(slot) + this.setDirtyCanvas(true, true) + } + + /** + * add a new input slot to use in this node + * @param {string} name + * @param {string} type string defining the input type ("vec3","number",...), it its a generic one use 0 + * @param {Object} extra_info this can be used to have special properties of an input (label, color, position, etc) + */ + addInput(name: string, type: ISlotType, extra_info?: object): INodeInputSlot { + type = type || 0 + const input: INodeInputSlot = { name: name, type: type, link: null } + if (extra_info) { + for (const i in extra_info) { + input[i] = extra_info[i] + } + } + + this.inputs ||= [] + this.inputs.push(input) + this.setSize(this.computeSize()) + + this.onInputAdded?.(input) + LiteGraph.registerNodeAndSlotType(this, type) + + this.setDirtyCanvas(true, true) + return input + } + + /** + * add several new input slots in this node + * @param {Array} array of triplets like [[name,type,extra_info],[...]] + */ + addInputs(array: [string, ISlotType, Record][]): void { + for (let i = 0; i < array.length; ++i) { + const info = array[i] + const o: INodeInputSlot = { name: info[0], type: info[1], link: null } + // TODO: Checking the wrong variable here - confirm no downstream consumers, then remove. + if (array[2]) { + for (const j in info[2]) { + o[j] = info[2][j] + } + } + + this.inputs ||= [] + this.inputs.push(o) + this.onInputAdded?.(o) + + LiteGraph.registerNodeAndSlotType(this, info[1]) + } + + this.setSize(this.computeSize()) + this.setDirtyCanvas(true, true) + } + + /** + * remove an existing input slot + * @param {number} slot + */ + removeInput(slot: number): void { + this.disconnectInput(slot) + const slot_info = this.inputs.splice(slot, 1) + for (let i = slot; i < this.inputs.length; ++i) { + if (!this.inputs[i]) continue + + const link = this.graph._links.get(this.inputs[i].link) + if (!link) continue + + link.target_slot -= 1 + } + this.setSize(this.computeSize()) + this.onInputRemoved?.(slot, slot_info[0]) + this.setDirtyCanvas(true, true) + } + + /** + * add an special connection to this node (used for special kinds of graphs) + * @param {string} name + * @param {string} type string defining the input type ("vec3","number",...) + * @param {[x,y]} pos position of the connection inside the node + * @param {string} direction if is input or output + */ + addConnection(name: string, type: string, pos: Point, direction: string) { + const o = { + name: name, + type: type, + pos: pos, + direction: direction, + links: null, + } + this.connections.push(o) + return o + } + + /** + * computes the minimum size of a node according to its inputs and output slots + * @param out + * @return the total size + */ + computeSize(out?: Size): Size { + const ctorSize = this.constructor.size + if (ctorSize) return [ctorSize[0], ctorSize[1]] + + let rows = Math.max( + this.inputs ? this.inputs.length : 1, + this.outputs ? this.outputs.length : 1, + ) + const size = out || new Float32Array([0, 0]) + rows = Math.max(rows, 1) + const font_size = LiteGraph.NODE_TEXT_SIZE // although it should be graphcanvas.inner_text_font size + + const title_width = compute_text_size(this.title) + let input_width = 0 + let output_width = 0 + + if (this.inputs) { + for (let i = 0, l = this.inputs.length; i < l; ++i) { + const input = this.inputs[i] + const text = input.label || input.name || "" + const text_width = compute_text_size(text) + if (input_width < text_width) + input_width = text_width + } + } + + if (this.outputs) { + for (let i = 0, l = this.outputs.length; i < l; ++i) { + const output = this.outputs[i] + const text = output.label || output.name || "" + const text_width = compute_text_size(text) + if (output_width < text_width) + output_width = text_width + } + } + + size[0] = Math.max(input_width + output_width + 10, title_width) + size[0] = Math.max(size[0], LiteGraph.NODE_WIDTH) + if (this.widgets?.length) + size[0] = Math.max(size[0], LiteGraph.NODE_WIDTH * 1.5) + + size[1] = (this.constructor.slot_start_y || 0) + rows * LiteGraph.NODE_SLOT_HEIGHT + + let widgets_height = 0 + if (this.widgets?.length) { + for (let i = 0, l = this.widgets.length; i < l; ++i) { + const widget = this.widgets[i] + if (widget.hidden || (widget.advanced && !this.showAdvanced)) continue + + widgets_height += widget.computeSize + ? widget.computeSize(size[0])[1] + 4 + : LiteGraph.NODE_WIDGET_HEIGHT + 4 + } + widgets_height += 8 + } + + // compute height using widgets height + if (this.widgets_up) + size[1] = Math.max(size[1], widgets_height) + else if (this.widgets_start_y != null) + size[1] = Math.max(size[1], widgets_height + this.widgets_start_y) + else + size[1] += widgets_height + + function compute_text_size(text: string) { + return text + ? font_size * text.length * 0.6 + : 0 + } + + if (this.constructor.min_height && size[1] < this.constructor.min_height) { + size[1] = this.constructor.min_height + } + + // margin + size[1] += 6 + + return size + } + + inResizeCorner(canvasX: number, canvasY: number): boolean { + const rows = this.outputs ? this.outputs.length : 1 + const outputs_offset = (this.constructor.slot_start_y || 0) + rows * LiteGraph.NODE_SLOT_HEIGHT + return isInRectangle( + canvasX, + canvasY, + this.pos[0] + this.size[0] - 15, + this.pos[1] + Math.max(this.size[1] - 15, outputs_offset), + 20, + 20, + ) + } + + /** + * returns all the info available about a property of this node. + * + * @param {String} property name of the property + * @return {Object} the object with all the available info + */ + getPropertyInfo(property: string) { + let info = null + + // there are several ways to define info about a property + // legacy mode + if (this.properties_info) { + for (let i = 0; i < this.properties_info.length; ++i) { + if (this.properties_info[i].name == property) { + info = this.properties_info[i] + break + } + } + } + // litescene mode using the constructor + if (this.constructor["@" + property]) + info = this.constructor["@" + property] + + if (this.constructor.widgets_info?.[property]) + info = this.constructor.widgets_info[property] + + // litescene mode using the constructor + if (!info && this.onGetPropertyInfo) { + info = this.onGetPropertyInfo(property) + } + + info ||= {} + info.type ||= typeof this.properties[property] + if (info.widget == "combo") info.type = "enum" + + return info + } + + /** + * Defines a widget inside the node, it will be rendered on top of the node, you can control lots of properties + * + * @param {String} type the widget type (could be "number","string","combo" + * @param {String} name the text to show on the widget + * @param {String} value the default value + * @param {Function|String} callback function to call when it changes (optionally, it can be the name of the property to modify) + * @param {Object} options the object that contains special properties of this widget + * @return {Object} the created widget object + */ + addWidget( + type: string, + name: string, + value: any, + callback: IWidget["callback"], + options?: any, + ): IWidget { + this.widgets ||= [] + + if (!options && callback && typeof callback === "object") { + options = callback + callback = null + } + + // options can be the property name + if (options && typeof options === "string") + options = { property: options } + + // callback can be the property name + if (callback && typeof callback === "string") { + options ||= {} + options.property = callback + callback = null + } + + if (callback && typeof callback !== "function") { + console.warn("addWidget: callback must be a function") + callback = null + } + + const w: IWidget = { + // @ts-expect-error Type check or just assert? + type: type.toLowerCase(), + name: name, + value: value, + callback: callback, + options: options || {}, + } + + if (w.options.y !== undefined) { + w.y = w.options.y + } + + if (!callback && !w.options.callback && !w.options.property) { + console.warn("LiteGraph addWidget(...) without a callback or property assigned") + } + if (type == "combo" && !w.options.values) { + throw "LiteGraph addWidget('combo',...) requires to pass values in options: { values:['red','blue'] }" + } + this.widgets.push(w) + this.setSize(this.computeSize()) + return w + } + + addCustomWidget(custom_widget: IWidget): IWidget { + this.widgets ||= [] + this.widgets.push(custom_widget) + return custom_widget + } + + move(deltaX: number, deltaY: number): void { + if (this.pinned) return + + this.pos[0] += deltaX + this.pos[1] += deltaY + } + + /** + * Internal method to measure the node for rendering. Prefer {@link boundingRect} where possible. + * + * Populates {@link out} with the results in graph space. + * Adjusts for title and collapsed status, but does not call {@link onBounding}. + * @param out `x, y, width, height` are written to this array. + * @param pad Expands the area by this amount on each side. Default: 0 + */ + measure(out: Rect, pad = 0): void { + const titleMode = this.constructor.title_mode + const renderTitle = + titleMode != TitleMode.TRANSPARENT_TITLE && + titleMode != TitleMode.NO_TITLE + const titleHeight = renderTitle ? LiteGraph.NODE_TITLE_HEIGHT : 0 + + out[0] = this.pos[0] - pad + out[1] = this.pos[1] + -titleHeight - pad + if (!this.flags?.collapsed) { + out[2] = this.size[0] + 2 * pad + out[3] = this.size[1] + titleHeight + 2 * pad + } else { + out[2] = (this._collapsed_width || LiteGraph.NODE_COLLAPSED_WIDTH) + 2 * pad + out[3] = LiteGraph.NODE_TITLE_HEIGHT + 2 * pad + } + } + + /** + * returns the bounding of the object, used for rendering purposes + * @param out {Float32Array[4]?} [optional] a place to store the output, to free garbage + * @param includeExternal {boolean?} [optional] set to true to + * include the shadow and connection points in the bounding calculation + * @return {Float32Array[4]} the bounding box in format of [topleft_cornerx, topleft_cornery, width, height] + */ + getBounding(out?: Rect, includeExternal?: boolean): Rect { + out ||= new Float32Array(4) + + const rect = includeExternal ? this.renderArea : this.boundingRect + out[0] = rect[0] + out[1] = rect[1] + out[2] = rect[2] + out[3] = rect[3] + + return out + } + + /** + * Calculates the render area of this node, populating both {@link boundingRect} and {@link renderArea}. + * Called automatically at the start of every frame. + */ + updateArea(): void { + const bounds = this.#boundingRect + this.measure(bounds) + this.onBounding?.(bounds) + + const renderArea = this.#renderArea + renderArea.set(bounds) + // 4 offset for collapsed node connection points + renderArea[0] -= 4 + renderArea[1] -= 4 + // Add shadow & left offset + renderArea[2] += 6 + 4 + // Add shadow & top offsets + renderArea[3] += 5 + 4 + } + + /** + * checks if a point is inside the shape of a node + * @param {number} x + * @param {number} y + * @return {boolean} + */ + isPointInside(x: number, y: number): boolean { + return isInRect(x, y, this.boundingRect) + } + + /** + * Checks if the provided point is inside this node's collapse button area. + * @param x X co-ordinate to check + * @param y Y co-ordinate to check + * @returns true if the x,y point is in the collapse button area, otherwise false + */ + isPointInCollapse(x: number, y: number): boolean { + const squareLength = LiteGraph.NODE_TITLE_HEIGHT + return isInRectangle( + x, + y, + this.pos[0], + this.pos[1] - squareLength, + squareLength, + squareLength, + ) + } + + /** + * checks if a point is inside a node slot, and returns info about which slot + * @param x + * @param y + * @returns if found the object contains { input|output: slot object, slot: number, link_pos: [x,y] } + */ + getSlotInPosition(x: number, y: number): IFoundSlot | null { + // search for inputs + const link_pos = new Float32Array(2) + if (this.inputs) { + for (let i = 0, l = this.inputs.length; i < l; ++i) { + const input = this.inputs[i] + this.getConnectionPos(true, i, link_pos) + if (isInRectangle(x, y, link_pos[0] - 10, link_pos[1] - 5, 20, 10)) { + return { input, slot: i, link_pos } + } + } + } + + if (this.outputs) { + for (let i = 0, l = this.outputs.length; i < l; ++i) { + const output = this.outputs[i] + this.getConnectionPos(false, i, link_pos) + if (isInRectangle(x, y, link_pos[0] - 10, link_pos[1] - 5, 20, 10)) { + return { output, slot: i, link_pos } + } + } + } + + return null + } + + /** + * Gets the widget on this node at the given co-ordinates. + * @param canvasX X co-ordinate in graph space + * @param canvasY Y co-ordinate in graph space + * @returns The widget found, otherwise `null` + */ + getWidgetOnPos( + canvasX: number, + canvasY: number, + includeDisabled = false, + ): IWidget | null { + const { widgets, pos, size } = this + if (!widgets?.length) return null + + const x = canvasX - pos[0] + const y = canvasY - pos[1] + const nodeWidth = size[0] + + for (const widget of widgets) { + if ( + !widget || + (widget.disabled && !includeDisabled) || + widget.hidden || + (widget.advanced && !this.showAdvanced) + ) + continue + + const h = widget.computeSize + ? widget.computeSize(nodeWidth)[1] + : LiteGraph.NODE_WIDGET_HEIGHT + const w = widget.width || nodeWidth + if ( + widget.last_y !== undefined && + isInRectangle(x, y, 6, widget.last_y, w - 12, h) + ) + return widget + } + return null + } + + /** + * Returns the input slot with a given name (used for dynamic slots), -1 if not found + * @param name the name of the slot + * @param returnObj if the obj itself wanted + * @returns the slot (-1 if not found) + */ + findInputSlot(name: string, returnObj?: TReturn): number + findInputSlot(name: string, returnObj?: TReturn): INodeInputSlot + findInputSlot(name: string, returnObj: boolean = false) { + if (!this.inputs) return -1 + + for (let i = 0, l = this.inputs.length; i < l; ++i) { + if (name == this.inputs[i].name) { + return !returnObj ? i : this.inputs[i] + } + } + return -1 + } + + /** + * returns the output slot with a given name (used for dynamic slots), -1 if not found + * @param {string} name the name of the slot + * @param {boolean} returnObj if the obj itself wanted + * @return {number | INodeOutputSlot} the slot (-1 if not found) + */ + findOutputSlot(name: string, returnObj?: TReturn): number + findOutputSlot(name: string, returnObj?: TReturn): INodeOutputSlot + findOutputSlot(name: string, returnObj: boolean = false) { + if (!this.outputs) return -1 + + for (let i = 0, l = this.outputs.length; i < l; ++i) { + if (name == this.outputs[i].name) { + return !returnObj ? i : this.outputs[i] + } + } + return -1 + } + + /** + * Finds the first free input slot. + * @param {object} optsIn + * @return The index of the first matching slot, the slot itself if returnObj is true, or -1 if not found. + */ + findInputSlotFree( + optsIn?: FindFreeSlotOptions & { returnObj?: TReturn }, + ): number + findInputSlotFree( + optsIn?: FindFreeSlotOptions & { returnObj?: TReturn }, + ): INodeInputSlot + findInputSlotFree(optsIn?: FindFreeSlotOptions) { + return this.#findFreeSlot(this.inputs, optsIn) + } + + /** + * Finds the first free output slot. + * @param {object} optsIn + * @return The index of the first matching slot, the slot itself if returnObj is true, or -1 if not found. + */ + findOutputSlotFree( + optsIn?: FindFreeSlotOptions & { returnObj?: TReturn }, + ): number + findOutputSlotFree( + optsIn?: FindFreeSlotOptions & { returnObj?: TReturn }, + ): INodeOutputSlot + findOutputSlotFree(optsIn?: FindFreeSlotOptions) { + return this.#findFreeSlot(this.outputs, optsIn) + } + + /** + * Finds the next free slot + * @param slots The slots to search, i.e. this.inputs or this.outputs + * @param options Options + */ + #findFreeSlot( + slots: TSlot[], + options?: FindFreeSlotOptions, + ): TSlot | number { + const defaults = { + returnObj: false, + typesNotAccepted: [], + } + const opts = Object.assign(defaults, options || {}) + const length = slots?.length + if (!(length > 0)) return -1 + + for (let i = 0; i < length; ++i) { + const slot: TSlot & IGenericLinkOrLinks = slots[i] + if (!slot || slot.link || slot.links?.length) continue + if (opts.typesNotAccepted?.includes?.(slot.type)) continue + return !opts.returnObj ? i : slot + } + return -1 + } + + /** + * findSlotByType for INPUTS + */ + findInputSlotByType( + type: ISlotType, + returnObj?: TReturn, + preferFreeSlot?: boolean, + doNotUseOccupied?: boolean, + ): number + findInputSlotByType( + type: ISlotType, + returnObj?: TReturn, + preferFreeSlot?: boolean, + doNotUseOccupied?: boolean, + ): INodeInputSlot + findInputSlotByType( + type: ISlotType, + returnObj?: boolean, + preferFreeSlot?: boolean, + doNotUseOccupied?: boolean, + ) { + return this.#findSlotByType( + this.inputs, + type, + returnObj, + preferFreeSlot, + doNotUseOccupied, + ) + } + + /** + * findSlotByType for OUTPUTS + */ + findOutputSlotByType( + type: ISlotType, + returnObj?: TReturn, + preferFreeSlot?: boolean, + doNotUseOccupied?: boolean, + ): number + findOutputSlotByType( + type: ISlotType, + returnObj?: TReturn, + preferFreeSlot?: boolean, + doNotUseOccupied?: boolean, + ): INodeOutputSlot + findOutputSlotByType( + type: ISlotType, + returnObj?: boolean, + preferFreeSlot?: boolean, + doNotUseOccupied?: boolean, + ) { + return this.#findSlotByType( + this.outputs, + type, + returnObj, + preferFreeSlot, + doNotUseOccupied, + ) + } + + /** + * returns the output (or input) slot with a given type, -1 if not found + * @param {boolean} input uise inputs instead of outputs + * @param {string} type the type of the slot + * @param {boolean} returnObj if the obj itself wanted + * @param {boolean} preferFreeSlot if we want a free slot (if not found, will return the first of the type anyway) + * @return {number_or_object} the slot (-1 if not found) + */ + findSlotByType( + input: TSlot, + type: ISlotType, + returnObj?: TReturn, + preferFreeSlot?: boolean, + doNotUseOccupied?: boolean, + ): number + findSlotByType( + input: TSlot, + type: ISlotType, + returnObj?: TReturn, + preferFreeSlot?: boolean, + doNotUseOccupied?: boolean, + ): INodeInputSlot + findSlotByType( + input: TSlot, + type: ISlotType, + returnObj?: TReturn, + preferFreeSlot?: boolean, + doNotUseOccupied?: boolean, + ): INodeOutputSlot + findSlotByType( + input: boolean, + type: ISlotType, + returnObj?: boolean, + preferFreeSlot?: boolean, + doNotUseOccupied?: boolean, + ) { + return input + ? this.#findSlotByType( + this.inputs, + type, + returnObj, + preferFreeSlot, + doNotUseOccupied, + ) + : this.#findSlotByType( + this.outputs, + type, + returnObj, + preferFreeSlot, + doNotUseOccupied, + ) + } + + /** + * Finds a matching slot from those provided, returning the slot itself or its index in {@link slots}. + * @param slots Slots to search (this.inputs or this.outputs) + * @param type Type of slot to look for + * @param returnObj If true, returns the slot itself. Otherwise, the index. + * @param preferFreeSlot Prefer a free slot, but if none are found, fall back to an occupied slot. + * @param doNotUseOccupied Do not fall back to occupied slots. + * @see {findSlotByType} + * @see {findOutputSlotByType} + * @see {findInputSlotByType} + * @returns If a match is found, the slot if returnObj is true, otherwise the index. If no matches are found, -1 + */ + #findSlotByType( + slots: TSlot[], + type: ISlotType, + returnObj?: boolean, + preferFreeSlot?: boolean, + doNotUseOccupied?: boolean, + ): TSlot | number { + const length = slots?.length + if (!length) return -1 + + // !! empty string type is considered 0, * !! + if (type == "" || type == "*") type = 0 + const sourceTypes = String(type).toLowerCase() + .split(",") + + // Run the search + let occupiedSlot: number | TSlot | null = null + for (let i = 0; i < length; ++i) { + const slot: TSlot & IGenericLinkOrLinks = slots[i] + const destTypes = slot.type == "0" || slot.type == "*" + ? ["0"] + : String(slot.type).toLowerCase() + .split(",") + + for (const sourceType of sourceTypes) { + // TODO: Remove _event_ entirely. + const source = sourceType == "_event_" ? LiteGraph.EVENT : sourceType + + for (const destType of destTypes) { + const dest = destType == "_event_" ? LiteGraph.EVENT : destType + + if (source == dest || source === "*" || dest === "*") { + if (preferFreeSlot && (slot.links?.length || slot.link != null)) { + // In case we can't find a free slot. + occupiedSlot ??= returnObj ? slot : i + continue } - } - + return returnObj ? slot : i + } + } + } + } + + return doNotUseOccupied ? -1 : occupiedSlot ?? -1 + } + + /** + * Determines the slot index to connect to when attempting to connect by type. + * + * @param findInputs If true, searches for an input. Otherwise, an output. + * @param node The node at the other end of the connection. + * @param slotType The type of slot at the other end of the connection. + * @param options Search restrictions to adhere to. + * @see {connectByType} + * @see {connectByTypeOutput} + */ + findConnectByTypeSlot( + findInputs: boolean, + node: LGraphNode, + slotType: ISlotType, + options?: ConnectByTypeOptions, + ): number | null { + // LEGACY: Old options names + if (options && typeof options === "object") { + if ("firstFreeIfInputGeneralInCase" in options) options.wildcardToTyped = !!options.firstFreeIfInputGeneralInCase + if ("firstFreeIfOutputGeneralInCase" in options) options.wildcardToTyped = !!options.firstFreeIfOutputGeneralInCase + if ("generalTypeInCase" in options) options.typedToWildcard = !!options.generalTypeInCase + } + const optsDef: ConnectByTypeOptions = { + createEventInCase: true, + wildcardToTyped: true, + typedToWildcard: true, + } + const opts = Object.assign(optsDef, options) + + if (node && typeof node === "number") { + node = this.graph.getNodeById(node) + } + const slot = node.findSlotByType(findInputs, slotType, false, true) + if (slot >= 0 && slot !== null) return slot + + // TODO: Remove or reimpl. events. WILL CREATE THE onTrigger IN SLOT + if (opts.createEventInCase && slotType == LiteGraph.EVENT) { + if (findInputs) return -1 + if (LiteGraph.do_add_triggers_slots) return node.addOnExecutedOutput() + } + + // connect to the first general output slot if not found a specific type and + if (opts.typedToWildcard) { + const generalSlot = node.findSlotByType(findInputs, 0, false, true, true) + if (generalSlot >= 0) return generalSlot + } + // connect to the first free input slot if not found a specific type and this output is general + if ( + opts.wildcardToTyped && + (slotType == 0 || slotType == "*" || slotType == "") + ) { + const opt = { typesNotAccepted: [LiteGraph.EVENT] } + const nonEventSlot = findInputs + ? node.findInputSlotFree(opt) + : node.findOutputSlotFree(opt) + if (nonEventSlot >= 0) return nonEventSlot + } + return null + } + + /** + * connect this node output to the input of another node BY TYPE + * @param {number} slot (could be the number of the slot or the string with the name of the slot) + * @param {LGraphNode} target_node the target node + * @param {string} target_slotType the input slot type of the target node + * @return {Object} the link_info is created, otherwise null + */ + connectByType( + slot: number | string, + target_node: LGraphNode, + target_slotType: ISlotType, + optsIn?: ConnectByTypeOptions, + ): LLink | null { + const slotIndex = this.findConnectByTypeSlot( + true, + target_node, + target_slotType, + optsIn, + ) + if (slotIndex !== null) + return this.connect(slot, target_node, slotIndex, optsIn?.afterRerouteId) + + console.debug("[connectByType]: no way to connect type: ", target_slotType, " to node: ", target_node) + return null + } + + /** + * connect this node input to the output of another node BY TYPE + * @method connectByType + * @param {number | string} slot (could be the number of the slot or the string with the name of the slot) + * @param {LGraphNode} source_node the target node + * @param {string} source_slotType the output slot type of the target node + * @return {Object} the link_info is created, otherwise null + */ + connectByTypeOutput( + slot: number | string, + source_node: LGraphNode, + source_slotType: ISlotType, + optsIn?: ConnectByTypeOptions, + ): LLink | null { + // LEGACY: Old options names + if (typeof optsIn === "object") { + if ("firstFreeIfInputGeneralInCase" in optsIn) optsIn.wildcardToTyped = !!optsIn.firstFreeIfInputGeneralInCase + if ("generalTypeInCase" in optsIn) optsIn.typedToWildcard = !!optsIn.generalTypeInCase + } + const slotIndex = this.findConnectByTypeSlot( + false, + source_node, + source_slotType, + optsIn, + ) + if (slotIndex !== null) + return source_node.connect(slotIndex, this, slot, optsIn?.afterRerouteId) + + console.debug("[connectByType]: no way to connect type: ", source_slotType, " to node: ", source_node) + return null + } + + /** + * Connect an output of this node to an input of another node + * @param {number | string} slot (could be the number of the slot or the string with the name of the slot) + * @param {LGraphNode} target_node the target node + * @param {number | string} target_slot the input slot of the target node (could be the number of the slot or the string with the name of the slot, or -1 to connect a trigger) + * @return {Object} the link_info is created, otherwise null + */ + connect( + slot: number | string, + target_node: LGraphNode, + target_slot: ISlotType, + afterRerouteId?: RerouteId, + ): LLink | null { + // Allow legacy API support for searching target_slot by string, without mutating the input variables + let targetIndex: number + + const graph = this.graph + if (!graph) { + // could be connected before adding it to a graph + // due to link ids being associated with graphs + console.log("Connect: Error, node doesn't belong to any graph. Nodes must be added first to a graph before connecting them.") + return null + } + + // seek for the output slot + if (typeof slot === "string") { + slot = this.findOutputSlot(slot) + if (slot == -1) { + if (LiteGraph.debug) console.log("Connect: Error, no slot of name " + slot) return null + } + } else if (!this.outputs || slot >= this.outputs.length) { + if (LiteGraph.debug) console.log("Connect: Error, slot number not found") + return null } - /** - * Gets the widget on this node at the given co-ordinates. - * @param canvasX X co-ordinate in graph space - * @param canvasY Y co-ordinate in graph space - * @returns The widget found, otherwise `null` - */ - getWidgetOnPos(canvasX: number, canvasY: number, includeDisabled = false): IWidget | null { - const { widgets, pos, size } = this - if (!widgets?.length) return null - - const x = canvasX - pos[0] - const y = canvasY - pos[1] - const nodeWidth = size[0] - - for (const widget of widgets) { - if (!widget || (widget.disabled && !includeDisabled) || widget.hidden || (widget.advanced && !this.showAdvanced)) continue - - const h = widget.computeSize - ? widget.computeSize(nodeWidth)[1] - : LiteGraph.NODE_WIDGET_HEIGHT - const w = widget.width || nodeWidth - if ( - widget.last_y !== undefined && - isInRectangle(x, y, 6, widget.last_y, w - 12, h) - ) - return widget - } - return null - } - - /** - * Returns the input slot with a given name (used for dynamic slots), -1 if not found - * @param name the name of the slot - * @param returnObj if the obj itself wanted - * @returns the slot (-1 if not found) - */ - findInputSlot(name: string, returnObj?: TReturn): number - findInputSlot(name: string, returnObj?: TReturn): INodeInputSlot - findInputSlot(name: string, returnObj: boolean = false) { - if (!this.inputs) return -1 - - for (let i = 0, l = this.inputs.length; i < l; ++i) { - if (name == this.inputs[i].name) { - return !returnObj ? i : this.inputs[i] - } - } - return -1 - } - - /** - * returns the output slot with a given name (used for dynamic slots), -1 if not found - * @param {string} name the name of the slot - * @param {boolean} returnObj if the obj itself wanted - * @return {number | INodeOutputSlot} the slot (-1 if not found) - */ - findOutputSlot(name: string, returnObj?: TReturn): number - findOutputSlot(name: string, returnObj?: TReturn): INodeOutputSlot - findOutputSlot(name: string, returnObj: boolean = false) { - if (!this.outputs) return -1 - - for (let i = 0, l = this.outputs.length; i < l; ++i) { - if (name == this.outputs[i].name) { - return !returnObj ? i : this.outputs[i] - } - } - return -1 - } - - /** - * Finds the first free input slot. - * @param {object} optsIn - * @return The index of the first matching slot, the slot itself if returnObj is true, or -1 if not found. - */ - findInputSlotFree(optsIn?: FindFreeSlotOptions & { returnObj?: TReturn }): number - findInputSlotFree(optsIn?: FindFreeSlotOptions & { returnObj?: TReturn }): INodeInputSlot - findInputSlotFree(optsIn?: FindFreeSlotOptions) { - return this.#findFreeSlot(this.inputs, optsIn) - } - - /** - * Finds the first free output slot. - * @param {object} optsIn - * @return The index of the first matching slot, the slot itself if returnObj is true, or -1 if not found. - */ - findOutputSlotFree(optsIn?: FindFreeSlotOptions & { returnObj?: TReturn }): number - findOutputSlotFree(optsIn?: FindFreeSlotOptions & { returnObj?: TReturn }): INodeOutputSlot - findOutputSlotFree(optsIn?: FindFreeSlotOptions) { - return this.#findFreeSlot(this.outputs, optsIn) - } - - /** - * Finds the next free slot - * @param slots The slots to search, i.e. this.inputs or this.outputs - * @param options Options - */ - #findFreeSlot(slots: TSlot[], options?: FindFreeSlotOptions): TSlot | number { - const defaults = { - returnObj: false, - typesNotAccepted: [] - } - const opts = Object.assign(defaults, options || {}) - const length = slots?.length - if (!(length > 0)) return -1 - - for (let i = 0; i < length; ++i) { - const slot: TSlot & IGenericLinkOrLinks = slots[i] - if (!slot || slot.link || slot.links?.length) continue - if (opts.typesNotAccepted?.includes?.(slot.type)) continue - return !opts.returnObj ? i : slot - } - return -1 - } - - /** - * findSlotByType for INPUTS - */ - findInputSlotByType(type: ISlotType, returnObj?: TReturn, preferFreeSlot?: boolean, doNotUseOccupied?: boolean): number - findInputSlotByType(type: ISlotType, returnObj?: TReturn, preferFreeSlot?: boolean, doNotUseOccupied?: boolean): INodeInputSlot - findInputSlotByType(type: ISlotType, returnObj?: boolean, preferFreeSlot?: boolean, doNotUseOccupied?: boolean) { - return this.#findSlotByType(this.inputs, type, returnObj, preferFreeSlot, doNotUseOccupied) - } - - /** - * findSlotByType for OUTPUTS - */ - findOutputSlotByType(type: ISlotType, returnObj?: TReturn, preferFreeSlot?: boolean, doNotUseOccupied?: boolean): number - findOutputSlotByType(type: ISlotType, returnObj?: TReturn, preferFreeSlot?: boolean, doNotUseOccupied?: boolean): INodeOutputSlot - findOutputSlotByType(type: ISlotType, returnObj?: boolean, preferFreeSlot?: boolean, doNotUseOccupied?: boolean) { - return this.#findSlotByType(this.outputs, type, returnObj, preferFreeSlot, doNotUseOccupied) - } - - /** - * returns the output (or input) slot with a given type, -1 if not found - * @param {boolean} input uise inputs instead of outputs - * @param {string} type the type of the slot - * @param {boolean} returnObj if the obj itself wanted - * @param {boolean} preferFreeSlot if we want a free slot (if not found, will return the first of the type anyway) - * @return {number_or_object} the slot (-1 if not found) - */ - findSlotByType(input: TSlot, type: ISlotType, returnObj?: TReturn, preferFreeSlot?: boolean, doNotUseOccupied?: boolean): number - findSlotByType(input: TSlot, type: ISlotType, returnObj?: TReturn, preferFreeSlot?: boolean, doNotUseOccupied?: boolean): INodeInputSlot - findSlotByType(input: TSlot, type: ISlotType, returnObj?: TReturn, preferFreeSlot?: boolean, doNotUseOccupied?: boolean): INodeOutputSlot - findSlotByType(input: boolean, type: ISlotType, returnObj?: boolean, preferFreeSlot?: boolean, doNotUseOccupied?: boolean) { - return input - ? this.#findSlotByType(this.inputs, type, returnObj, preferFreeSlot, doNotUseOccupied) - : this.#findSlotByType(this.outputs, type, returnObj, preferFreeSlot, doNotUseOccupied) - } - - /** - * Finds a matching slot from those provided, returning the slot itself or its index in {@link slots}. - * @param slots Slots to search (this.inputs or this.outputs) - * @param type Type of slot to look for - * @param returnObj If true, returns the slot itself. Otherwise, the index. - * @param preferFreeSlot Prefer a free slot, but if none are found, fall back to an occupied slot. - * @param doNotUseOccupied Do not fall back to occupied slots. - * @see {findSlotByType} - * @see {findOutputSlotByType} - * @see {findInputSlotByType} - * @returns If a match is found, the slot if returnObj is true, otherwise the index. If no matches are found, -1 - */ - #findSlotByType(slots: TSlot[], type: ISlotType, returnObj?: boolean, preferFreeSlot?: boolean, doNotUseOccupied?: boolean): TSlot | number { - const length = slots?.length - if (!length) return -1 - - // !! empty string type is considered 0, * !! - if (type == "" || type == "*") type = 0 - const sourceTypes = String(type).toLowerCase().split(",") - - // Run the search - let occupiedSlot: number | TSlot | null = null - for (let i = 0; i < length; ++i) { - const slot: TSlot & IGenericLinkOrLinks = slots[i] - const destTypes = slot.type == "0" || slot.type == "*" - ? ["0"] - : String(slot.type).toLowerCase().split(",") - - for (const sourceType of sourceTypes) { - // TODO: Remove _event_ entirely. - const source = sourceType == "_event_" ? LiteGraph.EVENT : sourceType - - for (const destType of destTypes) { - const dest = destType == "_event_" ? LiteGraph.EVENT : destType - - if (source == dest || source === "*" || dest === "*") { - if (preferFreeSlot && (slot.links?.length || slot.link != null)) { - // In case we can't find a free slot. - occupiedSlot ??= returnObj ? slot : i - continue - } - return returnObj ? slot : i - } - } - } - } - - return doNotUseOccupied ? -1 : occupiedSlot ?? -1 - } - - /** - * Determines the slot index to connect to when attempting to connect by type. - * - * @param findInputs If true, searches for an input. Otherwise, an output. - * @param node The node at the other end of the connection. - * @param slotType The type of slot at the other end of the connection. - * @param options Search restrictions to adhere to. - * @see {connectByType} - * @see {connectByTypeOutput} - */ - findConnectByTypeSlot( - findInputs: boolean, - node: LGraphNode, - slotType: ISlotType, - options?: ConnectByTypeOptions - ): number | null { - // LEGACY: Old options names - if (options && typeof options === "object") { - if ("firstFreeIfInputGeneralInCase" in options) options.wildcardToTyped = !!options.firstFreeIfInputGeneralInCase - if ("firstFreeIfOutputGeneralInCase" in options) options.wildcardToTyped = !!options.firstFreeIfOutputGeneralInCase - if ("generalTypeInCase" in options) options.typedToWildcard = !!options.generalTypeInCase - } - const optsDef: ConnectByTypeOptions = { - createEventInCase: true, - wildcardToTyped: true, - typedToWildcard: true - } - const opts = Object.assign(optsDef, options) - - if (node && typeof node === "number") { - node = this.graph.getNodeById(node) - } - const slot = node.findSlotByType(findInputs, slotType, false, true) - if (slot >= 0 && slot !== null) return slot - - // TODO: Remove or reimpl. events. WILL CREATE THE onTrigger IN SLOT - if (opts.createEventInCase && slotType == LiteGraph.EVENT) { - if (findInputs) return -1 - if (LiteGraph.do_add_triggers_slots) return node.addOnExecutedOutput() - } - - // connect to the first general output slot if not found a specific type and - if (opts.typedToWildcard) { - const generalSlot = node.findSlotByType(findInputs, 0, false, true, true) - if (generalSlot >= 0) return generalSlot - } - // connect to the first free input slot if not found a specific type and this output is general - if (opts.wildcardToTyped && (slotType == 0 || slotType == "*" || slotType == "")) { - const opt = { typesNotAccepted: [LiteGraph.EVENT] } - const nonEventSlot = findInputs - ? node.findInputSlotFree(opt) - : node.findOutputSlotFree(opt) - if (nonEventSlot >= 0) return nonEventSlot - } - return null + if (target_node && typeof target_node === "number") { + target_node = graph.getNodeById(target_node) } + if (!target_node) throw "target node is null" - /** - * connect this node output to the input of another node BY TYPE - * @param {number} slot (could be the number of the slot or the string with the name of the slot) - * @param {LGraphNode} target_node the target node - * @param {string} target_slotType the input slot type of the target node - * @return {Object} the link_info is created, otherwise null - */ - connectByType(slot: number | string, target_node: LGraphNode, target_slotType: ISlotType, optsIn?: ConnectByTypeOptions): LLink | null { - const slotIndex = this.findConnectByTypeSlot(true, target_node, target_slotType, optsIn) - if (slotIndex !== null) return this.connect(slot, target_node, slotIndex, optsIn?.afterRerouteId) + // avoid loopback + if (target_node == this) return null - console.debug("[connectByType]: no way to connect type: ", target_slotType, " to node: ", target_node) + // you can specify the slot by name + if (typeof target_slot === "string") { + targetIndex = target_node.findInputSlot(target_slot) + if (targetIndex == -1) { + if (LiteGraph.debug) console.log("Connect: Error, no slot of name " + targetIndex) return null - } - - /** - * connect this node input to the output of another node BY TYPE - * @method connectByType - * @param {number | string} slot (could be the number of the slot or the string with the name of the slot) - * @param {LGraphNode} source_node the target node - * @param {string} source_slotType the output slot type of the target node - * @return {Object} the link_info is created, otherwise null - */ - connectByTypeOutput(slot: number | string, source_node: LGraphNode, source_slotType: ISlotType, optsIn?: ConnectByTypeOptions): LLink | null { - // LEGACY: Old options names - if (typeof optsIn === "object") { - if ("firstFreeIfInputGeneralInCase" in optsIn) optsIn.wildcardToTyped = !!optsIn.firstFreeIfInputGeneralInCase - if ("generalTypeInCase" in optsIn) optsIn.typedToWildcard = !!optsIn.generalTypeInCase - } - const slotIndex = this.findConnectByTypeSlot(false, source_node, source_slotType, optsIn) - if (slotIndex !== null) return source_node.connect(slotIndex, this, slot, optsIn?.afterRerouteId) - - console.debug("[connectByType]: no way to connect type: ", source_slotType, " to node: ", source_node) + } + } else if (target_slot === LiteGraph.EVENT) { + // TODO: Events + if (LiteGraph.do_add_triggers_slots) { + target_node.changeMode(LGraphEventMode.ON_TRIGGER) + targetIndex = target_node.findInputSlot("onTrigger") + } else { return null - } - - /** - * Connect an output of this node to an input of another node - * @param {number | string} slot (could be the number of the slot or the string with the name of the slot) - * @param {LGraphNode} target_node the target node - * @param {number | string} target_slot the input slot of the target node (could be the number of the slot or the string with the name of the slot, or -1 to connect a trigger) - * @return {Object} the link_info is created, otherwise null - */ - connect(slot: number | string, target_node: LGraphNode, target_slot: ISlotType, afterRerouteId?: RerouteId): LLink | null { - // Allow legacy API support for searching target_slot by string, without mutating the input variables - let targetIndex: number - - const graph = this.graph - if (!graph) { - //could be connected before adding it to a graph - //due to link ids being associated with graphs - console.log("Connect: Error, node doesn't belong to any graph. Nodes must be added first to a graph before connecting them.") - return null - } - - //seek for the output slot - if (typeof slot === "string") { - slot = this.findOutputSlot(slot) - if (slot == -1) { - if (LiteGraph.debug) console.log("Connect: Error, no slot of name " + slot) - return null - } - } else if (!this.outputs || slot >= this.outputs.length) { - if (LiteGraph.debug) console.log("Connect: Error, slot number not found") - return null - } - - if (target_node && typeof target_node === "number") { - target_node = graph.getNodeById(target_node) - } - if (!target_node) throw "target node is null" - - //avoid loopback - if (target_node == this) return null - - //you can specify the slot by name - if (typeof target_slot === "string") { - targetIndex = target_node.findInputSlot(target_slot) - if (targetIndex == -1) { - if (LiteGraph.debug) console.log("Connect: Error, no slot of name " + targetIndex) - return null - } - } else if (target_slot === LiteGraph.EVENT) { - // TODO: Events - if (LiteGraph.do_add_triggers_slots) { - target_node.changeMode(LGraphEventMode.ON_TRIGGER) - targetIndex = target_node.findInputSlot("onTrigger") - } else { - return null - } - } else if (typeof target_slot === "number") { - targetIndex = target_slot - } else { - targetIndex = 0 - } - - // Allow target node to change slot - if (target_node.onBeforeConnectInput) { - // This way node can choose another slot (or make a new one?) - const requestedIndex: false | number | null = target_node.onBeforeConnectInput(targetIndex, target_slot) - targetIndex = typeof requestedIndex === "number" ? requestedIndex : null - } - - if (targetIndex === null || !target_node.inputs || targetIndex >= target_node.inputs.length) { - if (LiteGraph.debug) console.log("Connect: Error, slot number not found") - return null - } - - let changed = false - - const input = target_node.inputs[targetIndex] - let link_info: LLink = null - const output = this.outputs[slot] - - if (!this.outputs[slot]) return null - - //check targetSlot and check connection types - if (!LiteGraph.isValidConnection(output.type, input.type)) { - this.setDirtyCanvas(false, true) - // @ts-expect-error Unused param - if (changed) graph.connectionChange(this, link_info) - return null - } - - // Allow nodes to block connection - if (target_node.onConnectInput?.(targetIndex, output.type, output, this, slot) === false) - return null - if (this.onConnectOutput?.(slot, input.type, input, target_node, targetIndex) === false) - return null - - //if there is something already plugged there, disconnect - if (target_node.inputs[targetIndex]?.link != null) { - graph.beforeChange() - target_node.disconnectInput(targetIndex, true) - changed = true - } - if (output.links?.length) { - if (output.type === LiteGraph.EVENT && !LiteGraph.allow_multi_output_for_events) { - graph.beforeChange() - // @ts-expect-error Unused param - this.disconnectOutput(slot, false, { doProcessChange: false }) - changed = true - } - } - - // UUID: LinkIds - // const nextId = LiteGraph.use_uuids ? LiteGraph.uuidv4() : ++graph.state.lastLinkId - const nextId = ++graph.state.lastLinkId - - //create link class - link_info = new LLink( - nextId, - input.type || output.type, - this.id, - slot, - target_node.id, - targetIndex, - afterRerouteId - ) - - //add to graph links list - graph._links.set(link_info.id, link_info) - - //connect in output - output.links ??= [] - output.links.push(link_info.id) - //connect in input - target_node.inputs[targetIndex].link = link_info.id - - // Reroutes - LLink.getReroutes(graph, link_info) - .forEach(x => x?.linkIds.add(nextId)) - graph._version++ - - //link_info has been created now, so its updated - this.onConnectionsChange?.( - NodeSlotType.OUTPUT, - slot, - true, - link_info, - output - ) - - target_node.onConnectionsChange?.( + } + } else if (typeof target_slot === "number") { + targetIndex = target_slot + } else { + targetIndex = 0 + } + + // Allow target node to change slot + if (target_node.onBeforeConnectInput) { + // This way node can choose another slot (or make a new one?) + const requestedIndex: false | number | null = + target_node.onBeforeConnectInput(targetIndex, target_slot) + targetIndex = typeof requestedIndex === "number" ? requestedIndex : null + } + + if ( + targetIndex === null || + !target_node.inputs || + targetIndex >= target_node.inputs.length + ) { + if (LiteGraph.debug) console.log("Connect: Error, slot number not found") + return null + } + + let changed = false + + const input = target_node.inputs[targetIndex] + let link_info: LLink = null + const output = this.outputs[slot] + + if (!this.outputs[slot]) return null + + // check targetSlot and check connection types + if (!LiteGraph.isValidConnection(output.type, input.type)) { + this.setDirtyCanvas(false, true) + // @ts-expect-error Unused param + if (changed) graph.connectionChange(this, link_info) + return null + } + + // Allow nodes to block connection + if (target_node.onConnectInput?.(targetIndex, output.type, output, this, slot) === false) + return null + if (this.onConnectOutput?.(slot, input.type, input, target_node, targetIndex) === false) + return null + + // if there is something already plugged there, disconnect + if (target_node.inputs[targetIndex]?.link != null) { + graph.beforeChange() + target_node.disconnectInput(targetIndex, true) + changed = true + } + if (output.links?.length) { + if (output.type === LiteGraph.EVENT && !LiteGraph.allow_multi_output_for_events) { + graph.beforeChange() + // @ts-expect-error Unused param + this.disconnectOutput(slot, false, { doProcessChange: false }) + changed = true + } + } + + // UUID: LinkIds + // const nextId = LiteGraph.use_uuids ? LiteGraph.uuidv4() : ++graph.state.lastLinkId + const nextId = ++graph.state.lastLinkId + + // create link class + link_info = new LLink( + nextId, + input.type || output.type, + this.id, + slot, + target_node.id, + targetIndex, + afterRerouteId, + ) + + // add to graph links list + graph._links.set(link_info.id, link_info) + + // connect in output + output.links ??= [] + output.links.push(link_info.id) + // connect in input + target_node.inputs[targetIndex].link = link_info.id + + // Reroutes + LLink.getReroutes(graph, link_info) + .forEach(x => x?.linkIds.add(nextId)) + graph._version++ + + // link_info has been created now, so its updated + this.onConnectionsChange?.( + NodeSlotType.OUTPUT, + slot, + true, + link_info, + output, + ) + + target_node.onConnectionsChange?.( + NodeSlotType.INPUT, + targetIndex, + true, + link_info, + input, + ) + graph.onNodeConnectionChange?.( + NodeSlotType.INPUT, + target_node, + targetIndex, + this, + slot, + ) + graph.onNodeConnectionChange?.( + NodeSlotType.OUTPUT, + this, + slot, + target_node, + targetIndex, + ) + + this.setDirtyCanvas(false, true) + graph.afterChange() + graph.connectionChange(this) + + return link_info + } + + /** + * disconnect one output to an specific node + * @param {number_or_string} slot (could be the number of the slot or the string with the name of the slot) + * @param {LGraphNode} target_node the target node to which this slot is connected [Optional, + * if not target_node is specified all nodes will be disconnected] + * @return {boolean} if it was disconnected successfully + */ + disconnectOutput(slot: string | number, target_node?: LGraphNode): boolean { + if (typeof slot === "string") { + slot = this.findOutputSlot(slot) + if (slot == -1) { + if (LiteGraph.debug) console.log("Connect: Error, no slot of name " + slot) + return false + } + } else if (!this.outputs || slot >= this.outputs.length) { + if (LiteGraph.debug) console.log("Connect: Error, slot number not found") + return false + } + + // get output slot + const output = this.outputs[slot] + if (!output || !output.links || output.links.length == 0) return false + + // one of the output links in this slot + const graph = this.graph + if (target_node) { + if (typeof target_node === "number") + target_node = graph.getNodeById(target_node) + if (!target_node) throw "Target Node not found" + + for (let i = 0, l = output.links.length; i < l; i++) { + const link_id = output.links[i] + const link_info = graph._links.get(link_id) + + // is the link we are searching for... + if (link_info.target_id == target_node.id) { + output.links.splice(i, 1) // remove here + const input = target_node.inputs[link_info.target_slot] + input.link = null // remove there + + // remove the link from the links pool + graph._links.delete(link_id) + graph._version++ + + // link_info hasn't been modified so its ok + target_node.onConnectionsChange?.( NodeSlotType.INPUT, - targetIndex, - true, + link_info.target_slot, + false, link_info, - input - ) - graph.onNodeConnectionChange?.( - NodeSlotType.INPUT, - target_node, - targetIndex, - this, - slot - ) - graph.onNodeConnectionChange?.( + input, + ) + this.onConnectionsChange?.( NodeSlotType.OUTPUT, - this, slot, - target_node, - targetIndex - ) - - this.setDirtyCanvas(false, true) - graph.afterChange() - graph.connectionChange(this) - - return link_info - } - - /** - * disconnect one output to an specific node - * @param {number_or_string} slot (could be the number of the slot or the string with the name of the slot) - * @param {LGraphNode} target_node the target node to which this slot is connected [Optional, if not target_node is specified all nodes will be disconnected] - * @return {boolean} if it was disconnected successfully - */ - disconnectOutput(slot: string | number, target_node?: LGraphNode): boolean { - if (typeof slot === "string") { - slot = this.findOutputSlot(slot) - if (slot == -1) { - if (LiteGraph.debug) console.log("Connect: Error, no slot of name " + slot) - return false - } - } else if (!this.outputs || slot >= this.outputs.length) { - if (LiteGraph.debug) console.log("Connect: Error, slot number not found") - return false - } - - //get output slot - const output = this.outputs[slot] - if (!output || !output.links || output.links.length == 0) - return false + false, + link_info, + output, + ) + + graph.onNodeConnectionChange?.(NodeSlotType.OUTPUT, this, slot) + graph.onNodeConnectionChange?.(NodeSlotType.INPUT, target_node, link_info.target_slot) + break + } + } + } // all the links in this output slot + else { + for (let i = 0, l = output.links.length; i < l; i++) { + const link_id = output.links[i] + const link_info = graph._links.get(link_id) + // bug: it happens sometimes + if (!link_info) continue + + target_node = graph.getNodeById(link_info.target_id) + graph._version++ - //one of the output links in this slot - const graph = this.graph if (target_node) { - if (typeof target_node === "number") - target_node = graph.getNodeById(target_node) - if (!target_node) - throw "Target Node not found" - - for (let i = 0, l = output.links.length; i < l; i++) { - const link_id = output.links[i] - const link_info = graph._links.get(link_id) - - //is the link we are searching for... - if (link_info.target_id == target_node.id) { - output.links.splice(i, 1) //remove here - const input = target_node.inputs[link_info.target_slot] - input.link = null //remove there - - //remove the link from the links pool - graph._links.delete(link_id) - graph._version++ - - //link_info hasn't been modified so its ok - target_node.onConnectionsChange?.( - NodeSlotType.INPUT, - link_info.target_slot, - false, - link_info, - input - ) - this.onConnectionsChange?.( - NodeSlotType.OUTPUT, - slot, - false, - link_info, - output - ) - - graph.onNodeConnectionChange?.(NodeSlotType.OUTPUT, this, slot) - graph.onNodeConnectionChange?.(NodeSlotType.INPUT, target_node, link_info.target_slot) - break - } - } - } //all the links in this output slot - else { - for (let i = 0, l = output.links.length; i < l; i++) { - const link_id = output.links[i] - const link_info = graph._links.get(link_id) - //bug: it happens sometimes - if (!link_info) continue - - target_node = graph.getNodeById(link_info.target_id) - graph._version++ - - if (target_node) { - const input = target_node.inputs[link_info.target_slot] - //remove other side link - input.link = null - - //link_info hasn't been modified so its ok - target_node.onConnectionsChange?.( - NodeSlotType.INPUT, - link_info.target_slot, - false, - link_info, - input - ) - } - //remove the link from the links pool - graph._links.delete(link_id) - - this.onConnectionsChange?.( - NodeSlotType.OUTPUT, - slot, - false, - link_info, - output - ) - graph.onNodeConnectionChange?.(NodeSlotType.OUTPUT, this, slot) - graph.onNodeConnectionChange?.(NodeSlotType.INPUT, target_node, link_info.target_slot) - } - output.links = null - } - - this.setDirtyCanvas(false, true) - graph.connectionChange(this) - return true - } - - /** - * Disconnect one input - * @param slot Input slot index, or the name of the slot - * @param keepReroutes If `true`, reroutes will not be garbage collected. - * @return true if disconnected successfully or already disconnected, otherwise false - */ - disconnectInput(slot: number | string, keepReroutes?: boolean): boolean { - // Allow search by string - if (typeof slot === "string") { - slot = this.findInputSlot(slot) - if (slot == -1) { - if (LiteGraph.debug) console.log("Connect: Error, no slot of name " + slot) - return false - } - } else if (!this.inputs || slot >= this.inputs.length) { - if (LiteGraph.debug) { - console.log("Connect: Error, slot number not found") - } - return false - } - - const input = this.inputs[slot] - if (!input) return false - - const link_id = this.inputs[slot].link - if (link_id != null) { - this.inputs[slot].link = null - - //remove other side - const link_info = this.graph._links.get(link_id) - if (link_info) { - const target_node = this.graph.getNodeById(link_info.origin_id) - if (!target_node) return false - - const output = target_node.outputs[link_info.origin_slot] - if (!(output?.links?.length > 0)) return false - - //search in the inputs list for this link - let i = 0 - for (const l = output.links.length; i < l; i++) { - if (output.links[i] == link_id) { - output.links.splice(i, 1) - break - } - } - - link_info.disconnect(this.graph, keepReroutes) - if (this.graph) this.graph._version++ - - this.onConnectionsChange?.( - NodeSlotType.INPUT, - slot, - false, - link_info, - input - ) - target_node.onConnectionsChange?.( - NodeSlotType.OUTPUT, - i, - false, - link_info, - output - ) - this.graph?.onNodeConnectionChange?.(NodeSlotType.OUTPUT, target_node, i) - this.graph?.onNodeConnectionChange?.(NodeSlotType.INPUT, this, slot) - } - } - - this.setDirtyCanvas(false, true) - this.graph?.connectionChange(this) - return true - } - - /** - * returns the center of a connection point in canvas coords - * @param {boolean} is_input true if if a input slot, false if it is an output - * @param {number_or_string} slot (could be the number of the slot or the string with the name of the slot) - * @param {vec2} out [optional] a place to store the output, to free garbage - * @return {[x,y]} the position - **/ - getConnectionPos(is_input: boolean, slot_number: number, out?: Point): Point { - out ||= new Float32Array(2) - - const num_slots = is_input - ? this.inputs?.length ?? 0 - : this.outputs?.length ?? 0 - - const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5 - - if (this.flags.collapsed) { - const w = this._collapsed_width || LiteGraph.NODE_COLLAPSED_WIDTH - if (this.horizontal) { - out[0] = this.pos[0] + w * 0.5 - out[1] = is_input - ? this.pos[1] - LiteGraph.NODE_TITLE_HEIGHT - : this.pos[1] - } else { - out[0] = is_input - ? this.pos[0] - : this.pos[0] + w - out[1] = this.pos[1] - LiteGraph.NODE_TITLE_HEIGHT * 0.5 - } - return out - } - - //weird feature that never got finished - if (is_input && slot_number == -1) { - out[0] = this.pos[0] + LiteGraph.NODE_TITLE_HEIGHT * 0.5 - out[1] = this.pos[1] + LiteGraph.NODE_TITLE_HEIGHT * 0.5 - return out - } - - //hard-coded pos - if (is_input && - num_slots > slot_number && - this.inputs[slot_number].pos) { - - out[0] = this.pos[0] + this.inputs[slot_number].pos[0] - out[1] = this.pos[1] + this.inputs[slot_number].pos[1] - return out - } else if (!is_input && - num_slots > slot_number && - this.outputs[slot_number].pos) { - - out[0] = this.pos[0] + this.outputs[slot_number].pos[0] - out[1] = this.pos[1] + this.outputs[slot_number].pos[1] - return out - } - - //horizontal distributed slots - if (this.horizontal) { - out[0] = this.pos[0] + (slot_number + 0.5) * (this.size[0] / num_slots) - out[1] = is_input - ? this.pos[1] - LiteGraph.NODE_TITLE_HEIGHT - : this.pos[1] + this.size[1] - return out - } - - //default vertical slots - out[0] = is_input - ? this.pos[0] + offset - : this.pos[0] + this.size[0] + 1 - offset - out[1] = - this.pos[1] + - (slot_number + 0.7) * LiteGraph.NODE_SLOT_HEIGHT + - (this.constructor.slot_start_y || 0) - return out - } - - /** @inheritdoc */ - snapToGrid(snapTo: number): boolean { - return this.pinned ? false : snapPoint(this.pos, snapTo) - } - - /** @see {@link snapToGrid} */ - alignToGrid(): void { - this.snapToGrid(LiteGraph.CANVAS_GRID_SIZE) - } - - /* Console output */ - trace(msg?: string): void { - this.console ||= [] - this.console.push(msg) - if (this.console.length > LGraphNode.MAX_CONSOLE) - this.console.shift() - - this.graph.onNodeTrace?.(this, msg) - } - - /* Forces to redraw or the main canvas (LGraphNode) or the bg canvas (links) */ - setDirtyCanvas(dirty_foreground: boolean, dirty_background?: boolean): void { - this.graph?.canvasAction(c => c.setDirty(dirty_foreground, dirty_background)) - } - - loadImage(url: string): HTMLImageElement { - interface AsyncImageElement extends HTMLImageElement { ready?: boolean } - - const img: AsyncImageElement = new Image() - img.src = LiteGraph.node_images_path + url - img.ready = false + const input = target_node.inputs[link_info.target_slot] + // remove other side link + input.link = null - const that = this - img.onload = function (this: AsyncImageElement) { - this.ready = true - that.setDirtyCanvas(true) - } - return img - } - - /* Allows to get onMouseMove and onMouseUp events even if the mouse is out of focus */ - captureInput(v: boolean): void { - if (!this.graph || !this.graph.list_of_graphcanvas) - return - - const list = this.graph.list_of_graphcanvas - - for (let i = 0; i < list.length; ++i) { - const c = list[i] - //releasing somebody elses capture?! - if (!v && c.node_capturing_input != this) - continue - - //change - c.node_capturing_input = v ? this : null - } - } - - get collapsed() { - return !!this.flags.collapsed - } - - get collapsible() { - return !this.pinned && (this.constructor.collapsable !== false) - } - - /** - * Toggle node collapse (makes it smaller on the canvas) - **/ - collapse(force?: boolean): void { - if (!this.collapsible && !force) return - this.graph._version++ - this.flags.collapsed = !this.flags.collapsed - this.setDirtyCanvas(true, true) - } - - /** - * Toggles advanced mode of the node, showing advanced widgets - */ - toggleAdvanced() { - if (!this.widgets?.some(w => w.advanced)) return - this.graph._version++ - this.showAdvanced = !this.showAdvanced - const prefSize = this.computeSize() - if (this.size[0] < prefSize[0] || this.size[1] < prefSize[1]) { - this.setSize([Math.max(this.size[0], prefSize[0]), Math.max(this.size[1], prefSize[1])]) + // link_info hasn't been modified so its ok + target_node.onConnectionsChange?.( + NodeSlotType.INPUT, + link_info.target_slot, + false, + link_info, + input, + ) } - this.setDirtyCanvas(true, true) - } - - get pinned() { - return !!this.flags.pinned - } - - /** - * Prevents the node being accidentally moved or resized by mouse interaction. - * Toggles pinned state if no value is provided. - **/ - pin(v?: boolean): void { - this.graph._version++ - this.flags.pinned = v === undefined - ? !this.flags.pinned - : v - this.resizable = !this.pinned - // Delete the flag if unpinned, so that we don't get unnecessary - // flags.pinned = false in serialized object. - if (!this.pinned) - delete this.flags.pinned - } - - unpin(): void { - this.pin(false) - } - - localToScreen(x: number, y: number, dragAndScale: DragAndScale): Point { - return [ - (x + this.pos[0]) * dragAndScale.scale + dragAndScale.offset[0], - (y + this.pos[1]) * dragAndScale.scale + dragAndScale.offset[1] - ] - } + // remove the link from the links pool + graph._links.delete(link_id) - get width() { - return this.collapsed ? this._collapsed_width || LiteGraph.NODE_COLLAPSED_WIDTH : this.size[0] + this.onConnectionsChange?.( + NodeSlotType.OUTPUT, + slot, + false, + link_info, + output, + ) + graph.onNodeConnectionChange?.(NodeSlotType.OUTPUT, this, slot) + graph.onNodeConnectionChange?.(NodeSlotType.INPUT, target_node, link_info.target_slot) + } + output.links = null + } + + this.setDirtyCanvas(false, true) + graph.connectionChange(this) + return true + } + + /** + * Disconnect one input + * @param slot Input slot index, or the name of the slot + * @param keepReroutes If `true`, reroutes will not be garbage collected. + * @return true if disconnected successfully or already disconnected, otherwise false + */ + disconnectInput(slot: number | string, keepReroutes?: boolean): boolean { + // Allow search by string + if (typeof slot === "string") { + slot = this.findInputSlot(slot) + if (slot == -1) { + if (LiteGraph.debug) console.log("Connect: Error, no slot of name " + slot) + return false + } + } else if (!this.inputs || slot >= this.inputs.length) { + if (LiteGraph.debug) { + console.log("Connect: Error, slot number not found") + } + return false } - get height() { - // @ts-expect-error Not impl. - return this.collapsed ? LiteGraph.NODE_COLLAPSED_HEIGHT : this.size[1] - } + const input = this.inputs[slot] + if (!input) return false - drawBadges(ctx: CanvasRenderingContext2D, { gap = 2 } = {}): void { - const badgeInstances = this.badges.map(badge => badge instanceof LGraphBadge ? badge : badge()) - const isLeftAligned = this.badgePosition === BadgePosition.TopLeft + const link_id = this.inputs[slot].link + if (link_id != null) { + this.inputs[slot].link = null - let currentX = isLeftAligned ? 0 : this.width - badgeInstances.reduce((acc, badge) => acc + badge.getWidth(ctx) + gap, 0) - const y = -(LiteGraph.NODE_TITLE_HEIGHT + gap) + // remove other side + const link_info = this.graph._links.get(link_id) + if (link_info) { + const target_node = this.graph.getNodeById(link_info.origin_id) + if (!target_node) return false - for (const badge of badgeInstances) { - badge.draw(ctx, currentX, y - badge.height) - currentX += badge.getWidth(ctx) + gap - } - } + const output = target_node.outputs[link_info.origin_slot] + if (!(output?.links?.length > 0)) return false - /** - * Attempts to gracefully bypass this node in all of its connections by reconnecting all links. - * - * Each input is checked against each output. This is done on a matching index basis, i.e. input 3 -> output 3. - * If there are any input links remaining, and {@link flags}.{@link INodeFlags.keepAllLinksOnBypass keepAllLinksOnBypass} is `true`, - * each input will check for outputs that match, and take the first one that matches - * `true`: Try the index matching first, then every input to every output. - * `false`: Only matches indexes, e.g. input 3 to output 3. - * - * If {@link flags}.{@link INodeFlags.keepAllLinksOnBypass keepAllLinksOnBypass} is `undefined`, it will fall back to - * the static {@link keepAllLinksOnBypass}. - * - * @returns `true` if any new links were established, otherwise `false`. - * @todo Decision: Change API to return array of new links instead? - */ - connectInputToOutput(): boolean { - const { inputs, outputs, graph } = this - if (!inputs || !outputs) return - - const { _links } = graph - let madeAnyConnections = false - - // First pass: only match exactly index-to-index - for (const [index, input] of inputs.entries()) { - const output = outputs[index] - if (!output || !LiteGraph.isValidConnection(input.type, output.type)) continue - - const inLink = _links.get(input.link) - const inNode = graph.getNodeById(inLink?.origin_id) - if (!inNode) continue - - bypassAllLinks(output, inNode, inLink) + // search in the inputs list for this link + let i = 0 + for (const l = output.links.length; i < l; i++) { + if (output.links[i] == link_id) { + output.links.splice(i, 1) + break + } } - // Configured to only use index-to-index matching - if (!(this.flags.keepAllLinksOnBypass ?? LGraphNode.keepAllLinksOnBypass)) return madeAnyConnections - // Second pass: match any remaining links - for (const input of inputs) { - const inLink = _links.get(input.link) - const inNode = graph.getNodeById(inLink?.origin_id) - if (!inNode) continue + link_info.disconnect(this.graph, keepReroutes) + if (this.graph) this.graph._version++ - for (const output of outputs) { - if (!LiteGraph.isValidConnection(input.type, output.type)) continue - - bypassAllLinks(output, inNode, inLink) - break - } - } - return madeAnyConnections - - function bypassAllLinks(output: INodeOutputSlot, inNode: LGraphNode, inLink: LLink) { - const outLinks = output.links - ?.map(x => _links.get(x)) - .filter(x => !!x) - if (!outLinks?.length) return - - for (const outLink of outLinks) { - const outNode = graph.getNodeById(outLink.target_id) - if (!outNode) return - - const result = inNode.connect(inLink.origin_slot, outNode, outLink.target_slot, inLink.parentId) - madeAnyConnections ||= !!result - } - } + this.onConnectionsChange?.( + NodeSlotType.INPUT, + slot, + false, + link_info, + input, + ) + target_node.onConnectionsChange?.( + NodeSlotType.OUTPUT, + i, + false, + link_info, + output, + ) + this.graph?.onNodeConnectionChange?.(NodeSlotType.OUTPUT, target_node, i) + this.graph?.onNodeConnectionChange?.(NodeSlotType.INPUT, this, slot) + } + } + + this.setDirtyCanvas(false, true) + this.graph?.connectionChange(this) + return true + } + + /** + * returns the center of a connection point in canvas coords + * @param {boolean} is_input true if if a input slot, false if it is an output + * @param {number_or_string} slot (could be the number of the slot or the string with the name of the slot) + * @param {vec2} out [optional] a place to store the output, to free garbage + * @return {[x,y]} the position + **/ + getConnectionPos(is_input: boolean, slot_number: number, out?: Point): Point { + out ||= new Float32Array(2) + + const num_slots = is_input + ? this.inputs?.length ?? 0 + : this.outputs?.length ?? 0 + + const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5 + + if (this.flags.collapsed) { + const w = this._collapsed_width || LiteGraph.NODE_COLLAPSED_WIDTH + if (this.horizontal) { + out[0] = this.pos[0] + w * 0.5 + out[1] = is_input + ? this.pos[1] - LiteGraph.NODE_TITLE_HEIGHT + : this.pos[1] + } else { + out[0] = is_input + ? this.pos[0] + : this.pos[0] + w + out[1] = this.pos[1] - LiteGraph.NODE_TITLE_HEIGHT * 0.5 + } + return out + } + + // weird feature that never got finished + if (is_input && slot_number == -1) { + out[0] = this.pos[0] + LiteGraph.NODE_TITLE_HEIGHT * 0.5 + out[1] = this.pos[1] + LiteGraph.NODE_TITLE_HEIGHT * 0.5 + return out + } + + // hard-coded pos + if ( + is_input && + num_slots > slot_number && + this.inputs[slot_number].pos + ) { + out[0] = this.pos[0] + this.inputs[slot_number].pos[0] + out[1] = this.pos[1] + this.inputs[slot_number].pos[1] + return out + } else if ( + !is_input && + num_slots > slot_number && + this.outputs[slot_number].pos + ) { + out[0] = this.pos[0] + this.outputs[slot_number].pos[0] + out[1] = this.pos[1] + this.outputs[slot_number].pos[1] + return out + } + + // horizontal distributed slots + if (this.horizontal) { + out[0] = this.pos[0] + (slot_number + 0.5) * (this.size[0] / num_slots) + out[1] = is_input + ? this.pos[1] - LiteGraph.NODE_TITLE_HEIGHT + : this.pos[1] + this.size[1] + return out + } + + // default vertical slots + out[0] = is_input + ? this.pos[0] + offset + : this.pos[0] + this.size[0] + 1 - offset + out[1] = + this.pos[1] + + (slot_number + 0.7) * LiteGraph.NODE_SLOT_HEIGHT + + (this.constructor.slot_start_y || 0) + return out + } + + /** @inheritdoc */ + snapToGrid(snapTo: number): boolean { + return this.pinned ? false : snapPoint(this.pos, snapTo) + } + + /** @see {@link snapToGrid} */ + alignToGrid(): void { + this.snapToGrid(LiteGraph.CANVAS_GRID_SIZE) + } + + /* Console output */ + trace(msg?: string): void { + this.console ||= [] + this.console.push(msg) + if (this.console.length > LGraphNode.MAX_CONSOLE) + this.console.shift() + + this.graph.onNodeTrace?.(this, msg) + } + + /* Forces to redraw or the main canvas (LGraphNode) or the bg canvas (links) */ + setDirtyCanvas(dirty_foreground: boolean, dirty_background?: boolean): void { + this.graph?.canvasAction(c => c.setDirty(dirty_foreground, dirty_background)) + } + + loadImage(url: string): HTMLImageElement { + interface AsyncImageElement extends HTMLImageElement { ready?: boolean } + + const img: AsyncImageElement = new Image() + img.src = LiteGraph.node_images_path + url + img.ready = false + + const that = this + img.onload = function (this: AsyncImageElement) { + this.ready = true + that.setDirtyCanvas(true) + } + return img + } + + /* Allows to get onMouseMove and onMouseUp events even if the mouse is out of focus */ + captureInput(v: boolean): void { + if (!this.graph || !this.graph.list_of_graphcanvas) return + + const list = this.graph.list_of_graphcanvas + + for (let i = 0; i < list.length; ++i) { + const c = list[i] + // releasing somebody elses capture?! + if (!v && c.node_capturing_input != this) continue + + // change + c.node_capturing_input = v ? this : null + } + } + + get collapsed() { + return !!this.flags.collapsed + } + + get collapsible() { + return !this.pinned && this.constructor.collapsable !== false + } + + /** + * Toggle node collapse (makes it smaller on the canvas) + **/ + collapse(force?: boolean): void { + if (!this.collapsible && !force) return + this.graph._version++ + this.flags.collapsed = !this.flags.collapsed + this.setDirtyCanvas(true, true) + } + + /** + * Toggles advanced mode of the node, showing advanced widgets + */ + toggleAdvanced() { + if (!this.widgets?.some(w => w.advanced)) return + this.graph._version++ + this.showAdvanced = !this.showAdvanced + const prefSize = this.computeSize() + if (this.size[0] < prefSize[0] || this.size[1] < prefSize[1]) { + this.setSize([ + Math.max(this.size[0], prefSize[0]), + Math.max(this.size[1], prefSize[1]), + ]) + } + this.setDirtyCanvas(true, true) + } + + get pinned() { + return !!this.flags.pinned + } + + /** + * Prevents the node being accidentally moved or resized by mouse interaction. + * Toggles pinned state if no value is provided. + **/ + pin(v?: boolean): void { + this.graph._version++ + this.flags.pinned = v === undefined + ? !this.flags.pinned + : v + this.resizable = !this.pinned + // Delete the flag if unpinned, so that we don't get unnecessary + // flags.pinned = false in serialized object. + if (!this.pinned) delete this.flags.pinned + } + + unpin(): void { + this.pin(false) + } + + localToScreen(x: number, y: number, dragAndScale: DragAndScale): Point { + return [ + (x + this.pos[0]) * dragAndScale.scale + dragAndScale.offset[0], + (y + this.pos[1]) * dragAndScale.scale + dragAndScale.offset[1], + ] + } + + get width() { + return this.collapsed + ? this._collapsed_width || LiteGraph.NODE_COLLAPSED_WIDTH + : this.size[0] + } + + get height() { + // @ts-expect-error Not impl. + return this.collapsed ? LiteGraph.NODE_COLLAPSED_HEIGHT : this.size[1] + } + + drawBadges(ctx: CanvasRenderingContext2D, { gap = 2 } = {}): void { + const badgeInstances = this.badges.map(badge => + badge instanceof LGraphBadge ? badge : badge()) + const isLeftAligned = this.badgePosition === BadgePosition.TopLeft + + let currentX = isLeftAligned + ? 0 + : this.width - badgeInstances.reduce((acc, badge) => acc + badge.getWidth(ctx) + gap, 0) + const y = -(LiteGraph.NODE_TITLE_HEIGHT + gap) + + for (const badge of badgeInstances) { + badge.draw(ctx, currentX, y - badge.height) + currentX += badge.getWidth(ctx) + gap + } + } + + /** + * Attempts to gracefully bypass this node in all of its connections by reconnecting all links. + * + * Each input is checked against each output. This is done on a matching index basis, i.e. input 3 -> output 3. + * If there are any input links remaining, + * and {@link flags}.{@link INodeFlags.keepAllLinksOnBypass keepAllLinksOnBypass} is `true`, + * each input will check for outputs that match, and take the first one that matches + * `true`: Try the index matching first, then every input to every output. + * `false`: Only matches indexes, e.g. input 3 to output 3. + * + * If {@link flags}.{@link INodeFlags.keepAllLinksOnBypass keepAllLinksOnBypass} is `undefined`, it will fall back to + * the static {@link keepAllLinksOnBypass}. + * + * @returns `true` if any new links were established, otherwise `false`. + * @todo Decision: Change API to return array of new links instead? + */ + connectInputToOutput(): boolean { + const { inputs, outputs, graph } = this + if (!inputs || !outputs) return + + const { _links } = graph + let madeAnyConnections = false + + // First pass: only match exactly index-to-index + for (const [index, input] of inputs.entries()) { + const output = outputs[index] + if (!output || !LiteGraph.isValidConnection(input.type, output.type)) continue + + const inLink = _links.get(input.link) + const inNode = graph.getNodeById(inLink?.origin_id) + if (!inNode) continue + + bypassAllLinks(output, inNode, inLink) + } + // Configured to only use index-to-index matching + if (!(this.flags.keepAllLinksOnBypass ?? LGraphNode.keepAllLinksOnBypass)) + return madeAnyConnections + + // Second pass: match any remaining links + for (const input of inputs) { + const inLink = _links.get(input.link) + const inNode = graph.getNodeById(inLink?.origin_id) + if (!inNode) continue + + for (const output of outputs) { + if (!LiteGraph.isValidConnection(input.type, output.type)) continue + + bypassAllLinks(output, inNode, inLink) + break + } + } + return madeAnyConnections + + function bypassAllLinks(output: INodeOutputSlot, inNode: LGraphNode, inLink: LLink) { + const outLinks = output.links + ?.map(x => _links.get(x)) + .filter(x => !!x) + if (!outLinks?.length) return + + for (const outLink of outLinks) { + const outNode = graph.getNodeById(outLink.target_id) + if (!outNode) return + + const result = inNode.connect( + inLink.origin_slot, + outNode, + outLink.target_slot, + inLink.parentId, + ) + madeAnyConnections ||= !!result + } } + } } diff --git a/src/LLink.ts b/src/LLink.ts index 56b56941..ae30d525 100644 --- a/src/LLink.ts +++ b/src/LLink.ts @@ -1,152 +1,193 @@ -import type { CanvasColour, LinkNetwork, ISlotType, LinkSegment } from "./interfaces" +import type { + CanvasColour, + LinkNetwork, + ISlotType, + LinkSegment, +} from "./interfaces" import type { NodeId } from "./LGraphNode" import type { Reroute, RerouteId } from "./Reroute" import type { Serialisable, SerialisableLLink } from "./types/serialisation" export type LinkId = number -export type SerialisedLLinkArray = [id: LinkId, origin_id: NodeId, origin_slot: number, target_id: NodeId, target_slot: number, type: ISlotType] +export type SerialisedLLinkArray = [ + id: LinkId, + origin_id: NodeId, + origin_slot: number, + target_id: NodeId, + target_slot: number, + type: ISlotType, +] -//this is the class in charge of storing link information +// this is the class in charge of storing link information export class LLink implements LinkSegment, Serialisable { - /** Link ID */ - id: LinkId - parentId?: RerouteId - type: ISlotType - /** Output node ID */ - origin_id: NodeId - /** Output slot index */ - origin_slot: number - /** Input node ID */ - target_id: NodeId - /** Input slot index */ - target_slot: number - - data?: number | string | boolean | { toToolTip?(): string } - _data?: unknown - /** Centre point of the link, calculated during render only - can be inaccurate */ - _pos: Float32Array - /** @todo Clean up - never implemented in comfy. */ - _last_time?: number - /** The last canvas 2D path that was used to render this link */ - path?: Path2D - /** @inheritdoc */ - _centreAngle?: number - - #color?: CanvasColour - /** Custom colour for this link only */ - public get color(): CanvasColour { return this.#color } - public set color(value: CanvasColour) { - this.#color = value === "" ? null : value + /** Link ID */ + id: LinkId + parentId?: RerouteId + type: ISlotType + /** Output node ID */ + origin_id: NodeId + /** Output slot index */ + origin_slot: number + /** Input node ID */ + target_id: NodeId + /** Input slot index */ + target_slot: number + + data?: number | string | boolean | { toToolTip?(): string } + _data?: unknown + /** Centre point of the link, calculated during render only - can be inaccurate */ + _pos: Float32Array + /** @todo Clean up - never implemented in comfy. */ + _last_time?: number + /** The last canvas 2D path that was used to render this link */ + path?: Path2D + /** @inheritdoc */ + _centreAngle?: number + + #color?: CanvasColour + /** Custom colour for this link only */ + public get color(): CanvasColour { + return this.#color + } + + public set color(value: CanvasColour) { + this.#color = value === "" ? null : value + } + + constructor( + id: LinkId, + type: ISlotType, + origin_id: NodeId, + origin_slot: number, + target_id: NodeId, + target_slot: number, + parentId?: RerouteId, + ) { + this.id = id + this.type = type + this.origin_id = origin_id + this.origin_slot = origin_slot + this.target_id = target_id + this.target_slot = target_slot + this.parentId = parentId + + this._data = null + this._pos = new Float32Array(2) // center + } + + /** @deprecated Use {@link LLink.create} */ + static createFromArray(data: SerialisedLLinkArray): LLink { + return new LLink(data[0], data[5], data[1], data[2], data[3], data[4]) + } + + /** + * LLink static factory: creates a new LLink from the provided data. + * @param data Serialised LLink data to create the link from + * @returns A new LLink + */ + static create(data: SerialisableLLink): LLink { + return new LLink( + data.id, + data.type, + data.origin_id, + data.origin_slot, + data.target_id, + data.target_slot, + data.parentId, + ) + } + + /** + * Gets all reroutes from the output slot to this segment. If this segment is a reroute, it will be the last element. + * @returns An ordered array of all reroutes from the node output to + * this reroute or the reroute before it. Otherwise, an empty array. + */ + static getReroutes( + network: LinkNetwork, + linkSegment: LinkSegment, + ): Reroute[] { + return network.reroutes.get(linkSegment.parentId) + ?.getReroutes() ?? [] + } + + /** + * Finds the reroute in the chain after the provided reroute ID. + * @param network The network this link belongs to + * @param linkSegment The starting point of the search (input side). + * Typically the LLink object itself, but can be any link segment. + * @param rerouteId The matching reroute will have this set as its {@link parentId}. + * @returns The reroute that was found, `undefined` if no reroute was found, or `null` if an infinite loop was detected. + */ + static findNextReroute( + network: LinkNetwork, + linkSegment: LinkSegment, + rerouteId: RerouteId, + ): Reroute | null | undefined { + return network.reroutes.get(linkSegment.parentId) + ?.findNextReroute(rerouteId) + } + + configure(o: LLink | SerialisedLLinkArray) { + if (Array.isArray(o)) { + this.id = o[0] + this.origin_id = o[1] + this.origin_slot = o[2] + this.target_id = o[3] + this.target_slot = o[4] + this.type = o[5] + } else { + this.id = o.id + this.type = o.type + this.origin_id = o.origin_id + this.origin_slot = o.origin_slot + this.target_id = o.target_id + this.target_slot = o.target_slot + this.parentId = o.parentId } - - constructor(id: LinkId, type: ISlotType, origin_id: NodeId, origin_slot: number, target_id: NodeId, target_slot: number, parentId?: RerouteId) { - this.id = id - this.type = type - this.origin_id = origin_id - this.origin_slot = origin_slot - this.target_id = target_id - this.target_slot = target_slot - this.parentId = parentId - - this._data = null - this._pos = new Float32Array(2) //center - } - - /** @deprecated Use {@link LLink.create} */ - static createFromArray(data: SerialisedLLinkArray): LLink { - return new LLink(data[0], data[5], data[1], data[2], data[3], data[4]) - } - - /** - * LLink static factory: creates a new LLink from the provided data. - * @param data Serialised LLink data to create the link from - * @returns A new LLink - */ - static create(data: SerialisableLLink): LLink { - return new LLink(data.id, data.type, data.origin_id, data.origin_slot, data.target_id, data.target_slot, data.parentId) - } - - /** - * Gets all reroutes from the output slot to this segment. If this segment is a reroute, it will be the last element. - * @returns An ordered array of all reroutes from the node output to this reroute or the reroute before it. Otherwise, an empty array. - */ - static getReroutes(network: LinkNetwork, linkSegment: LinkSegment): Reroute[] { - return network.reroutes.get(linkSegment.parentId) - ?.getReroutes() ?? [] - } - - /** - * Finds the reroute in the chain after the provided reroute ID. - * @param network The network this link belongs to - * @param linkSegment The starting point of the search (input side). Typically the LLink object itself, but can be any link segment. - * @param rerouteId The matching reroute will have this set as its {@link parentId}. - * @returns The reroute that was found, `undefined` if no reroute was found, or `null` if an infinite loop was detected. - */ - static findNextReroute(network: LinkNetwork, linkSegment: LinkSegment, rerouteId: RerouteId): Reroute | null | undefined { - return network.reroutes.get(linkSegment.parentId) - ?.findNextReroute(rerouteId) - } - - configure(o: LLink | SerialisedLLinkArray) { - if (Array.isArray(o)) { - this.id = o[0] - this.origin_id = o[1] - this.origin_slot = o[2] - this.target_id = o[3] - this.target_slot = o[4] - this.type = o[5] - } else { - this.id = o.id - this.type = o.type - this.origin_id = o.origin_id - this.origin_slot = o.origin_slot - this.target_id = o.target_id - this.target_slot = o.target_slot - this.parentId = o.parentId - } - } - - /** - * Disconnects a link and removes it from the graph, cleaning up any reroutes that are no longer used - * @param network The container (LGraph) where reroutes should be updated - * @param keepReroutes If `true`, reroutes will not be garbage collected. - */ - disconnect(network: LinkNetwork, keepReroutes?: boolean): void { - const reroutes = LLink.getReroutes(network, this) - - for (const reroute of reroutes) { - reroute.linkIds.delete(this.id) - if (!keepReroutes && !reroute.linkIds.size) network.reroutes.delete(reroute.id) - } - network.links.delete(this.id) - } - - /** - * @deprecated Prefer {@link LLink.asSerialisable} (returns an object, not an array) - * @returns An array representing this LLink - */ - serialize(): SerialisedLLinkArray { - return [ - this.id, - this.origin_id, - this.origin_slot, - this.target_id, - this.target_slot, - this.type - ] + } + + /** + * Disconnects a link and removes it from the graph, cleaning up any reroutes that are no longer used + * @param network The container (LGraph) where reroutes should be updated + * @param keepReroutes If `true`, reroutes will not be garbage collected. + */ + disconnect(network: LinkNetwork, keepReroutes?: boolean): void { + const reroutes = LLink.getReroutes(network, this) + + for (const reroute of reroutes) { + reroute.linkIds.delete(this.id) + if (!keepReroutes && !reroute.linkIds.size) + network.reroutes.delete(reroute.id) } - - asSerialisable(): SerialisableLLink { - const copy: SerialisableLLink = { - id: this.id, - origin_id: this.origin_id, - origin_slot: this.origin_slot, - target_id: this.target_id, - target_slot: this.target_slot, - type: this.type - } - if (this.parentId) copy.parentId = this.parentId - return copy + network.links.delete(this.id) + } + + /** + * @deprecated Prefer {@link LLink.asSerialisable} (returns an object, not an array) + * @returns An array representing this LLink + */ + serialize(): SerialisedLLinkArray { + return [ + this.id, + this.origin_id, + this.origin_slot, + this.target_id, + this.target_slot, + this.type, + ] + } + + asSerialisable(): SerialisableLLink { + const copy: SerialisableLLink = { + id: this.id, + origin_id: this.origin_id, + origin_slot: this.origin_slot, + target_id: this.target_id, + target_slot: this.target_slot, + type: this.type, } + if (this.parentId) copy.parentId = this.parentId + return copy + } } diff --git a/src/LiteGraphGlobal.ts b/src/LiteGraphGlobal.ts index d4bb859a..b779113a 100644 --- a/src/LiteGraphGlobal.ts +++ b/src/LiteGraphGlobal.ts @@ -5,7 +5,14 @@ import { DragAndScale } from "./DragAndScale" import { LGraphCanvas } from "./LGraphCanvas" import { ContextMenu } from "./ContextMenu" import { CurveEditor } from "./CurveEditor" -import { LGraphEventMode, LinkDirection, LinkRenderType, NodeSlotType, RenderShape, TitleMode } from "./types/globalEnums" +import { + LGraphEventMode, + LinkDirection, + LinkRenderType, + NodeSlotType, + RenderShape, + TitleMode, +} from "./types/globalEnums" import { LGraphNode } from "./LGraphNode" import { SlotShape, SlotDirection, SlotType, LabelPosition } from "./draw" import type { Dictionary, ISlotType, Rect } from "./interfaces" @@ -15,934 +22,956 @@ import { distance, isInsideRectangle, overlapBounding } from "./measure" * The Global Scope. It contains all the registered node classes. */ export class LiteGraphGlobal { - // Enums - SlotShape = SlotShape - SlotDirection = SlotDirection - SlotType = SlotType - LabelPosition = LabelPosition - - /** Used in serialised graphs at one point. */ - VERSION = 0.4 as const - - CANVAS_GRID_SIZE = 10 - - NODE_TITLE_HEIGHT = 30 - NODE_TITLE_TEXT_Y = 20 - NODE_SLOT_HEIGHT = 20 - NODE_WIDGET_HEIGHT = 20 - NODE_WIDTH = 140 - NODE_MIN_WIDTH = 50 - NODE_COLLAPSED_RADIUS = 10 - NODE_COLLAPSED_WIDTH = 80 - NODE_TITLE_COLOR = "#999" - NODE_SELECTED_TITLE_COLOR = "#FFF" - NODE_TEXT_SIZE = 14 - NODE_TEXT_COLOR = "#AAA" - NODE_TEXT_HIGHLIGHT_COLOR = "#EEE" - NODE_SUBTEXT_SIZE = 12 - NODE_DEFAULT_COLOR = "#333" - NODE_DEFAULT_BGCOLOR = "#353535" - NODE_DEFAULT_BOXCOLOR = "#666" - NODE_DEFAULT_SHAPE = "box" - NODE_BOX_OUTLINE_COLOR = "#FFF" - DEFAULT_SHADOW_COLOR = "rgba(0,0,0,0.5)" - DEFAULT_GROUP_FONT = 24 - DEFAULT_GROUP_FONT_SIZE?: any - - WIDGET_BGCOLOR = "#222" - WIDGET_OUTLINE_COLOR = "#666" - WIDGET_ADVANCED_OUTLINE_COLOR = "rgba(56, 139, 253, 0.8)" - WIDGET_TEXT_COLOR = "#DDD" - WIDGET_SECONDARY_TEXT_COLOR = "#999" - - LINK_COLOR = "#9A9" - // TODO: This is a workaround until LGraphCanvas.link_type_colors is no longer static. - static DEFAULT_EVENT_LINK_COLOR = "#A86" - EVENT_LINK_COLOR = "#A86" - CONNECTING_LINK_COLOR = "#AFA" - - MAX_NUMBER_OF_NODES = 10000 //avoid infinite loops - DEFAULT_POSITION = [100, 100] //default node position - VALID_SHAPES = ["default", "box", "round", "card"] //,"circle" - - //shapes are used for nodes but also for slots - BOX_SHAPE = RenderShape.BOX - ROUND_SHAPE = RenderShape.ROUND - CIRCLE_SHAPE = RenderShape.CIRCLE - CARD_SHAPE = RenderShape.CARD - ARROW_SHAPE = RenderShape.ARROW - GRID_SHAPE = RenderShape.GRID // intended for slot arrays - - //enums - INPUT = NodeSlotType.INPUT - OUTPUT = NodeSlotType.OUTPUT - - // TODO: -1 can lead to ambiguity in JS; these should be updated to a more explicit constant or Symbol. - EVENT = -1 as const //for outputs - ACTION = -1 as const //for inputs - - NODE_MODES = ["Always", "On Event", "Never", "On Trigger"] // helper, will add "On Request" and more in the future - NODE_MODES_COLORS = ["#666", "#422", "#333", "#224", "#626"] // use with node_box_coloured_by_mode - ALWAYS = LGraphEventMode.ALWAYS - ON_EVENT = LGraphEventMode.ON_EVENT - NEVER = LGraphEventMode.NEVER - ON_TRIGGER = LGraphEventMode.ON_TRIGGER - - UP = LinkDirection.UP - DOWN = LinkDirection.DOWN - LEFT = LinkDirection.LEFT - RIGHT = LinkDirection.RIGHT - CENTER = LinkDirection.CENTER - - LINK_RENDER_MODES = ["Straight", "Linear", "Spline"] // helper - HIDDEN_LINK = LinkRenderType.HIDDEN_LINK - STRAIGHT_LINK = LinkRenderType.STRAIGHT_LINK - LINEAR_LINK = LinkRenderType.LINEAR_LINK - SPLINE_LINK = LinkRenderType.SPLINE_LINK - - NORMAL_TITLE = TitleMode.NORMAL_TITLE - NO_TITLE = TitleMode.NO_TITLE - TRANSPARENT_TITLE = TitleMode.TRANSPARENT_TITLE - AUTOHIDE_TITLE = TitleMode.AUTOHIDE_TITLE - - VERTICAL_LAYOUT = "vertical" // arrange nodes vertically - - proxy = null //used to redirect calls - node_images_path = "" - - debug = false - catch_exceptions = true - throw_errors = true - allow_scripts = false //if set to true some nodes like Formula would be allowed to evaluate code that comes from unsafe sources (like node configuration), which could lead to exploits - registered_node_types: Record = {} //nodetypes by string - node_types_by_file_extension = {} //used for dropping files in the canvas - Nodes: Record = {} //node types by classname - Globals = {} //used to store vars between graphs - - searchbox_extras = {} //used to add extra features to the search box - auto_sort_node_types = false // [true!] If set to true, will automatically sort node types / categories in the context menus - - node_box_coloured_when_on = false // [true!] this make the nodes box (top left circle) coloured when triggered (execute/action), visual feedback - node_box_coloured_by_mode = false // [true!] nodebox based on node mode, visual feedback - - dialog_close_on_mouse_leave = false // [false on mobile] better true if not touch device, TODO add an helper/listener to close if false - dialog_close_on_mouse_leave_delay = 500 - - shift_click_do_break_link_from = false // [false!] prefer false if results too easy to break links - implement with ALT or TODO custom keys - click_do_break_link_to = false // [false!]prefer false, way too easy to break links - ctrl_alt_click_do_break_link = true // [true!] who accidentally ctrl-alt-clicks on an in/output? nobody! that's who! - snaps_for_comfy = true // [true!] snaps links when dragging connections over valid targets - snap_highlights_node = true // [true!] renders a partial border to highlight when a dragged link is snapped to a node - - search_hide_on_mouse_leave = true // [false on mobile] better true if not touch device, TODO add an helper/listener to close if false - search_filter_enabled = false // [true!] enable filtering slots type in the search widget, !requires auto_load_slot_types or manual set registered_slot_[in/out]_types and slot_types_[in/out] - search_show_all_on_open = true // [true!] opens the results list when opening the search widget - - auto_load_slot_types = false // [if want false, use true, run, get vars values to be statically set, than disable] nodes types and nodeclass association with node types need to be calculated, if dont want this, calculate once and set registered_slot_[in/out]_types and slot_types_[in/out] - - // set these values if not using auto_load_slot_types - registered_slot_in_types: Record = {} // slot types for nodeclass - registered_slot_out_types: Record = {} // slot types for nodeclass - slot_types_in: string[] = [] // slot types IN - slot_types_out: string[] = [] // slot types OUT - slot_types_default_in: Record = {} // specify for each IN slot type a(/many) default node(s), use single string, array, or object (with node, title, parameters, ..) like for search - slot_types_default_out: Record = {} // specify for each OUT slot type a(/many) default node(s), use single string, array, or object (with node, title, parameters, ..) like for search - - alt_drag_do_clone_nodes = false // [true!] very handy, ALT click to clone and drag the new node - - do_add_triggers_slots = false // [true!] will create and connect event slots when using action/events connections, !WILL CHANGE node mode when using onTrigger (enable mode colors), onExecuted does not need this - - allow_multi_output_for_events = true // [false!] being events, it is strongly reccomended to use them sequentially, one by one - - middle_click_slot_add_default_node = false //[true!] allows to create and connect a ndoe clicking with the third button (wheel) - - release_link_on_empty_shows_menu = false //[true!] dragging a link to empty space will open a menu, add from list, search or defaults - - pointerevents_method = "pointer" // "mouse"|"pointer" use mouse for retrocompatibility issues? (none found @ now) - - // TODO implement pointercancel, gotpointercapture, lostpointercapture, (pointerover, pointerout if necessary) - ctrl_shift_v_paste_connect_unselected_outputs = true //[true!] allows ctrl + shift + v to paste nodes with the outputs of the unselected nodes connected with the inputs of the newly pasted nodes - - // if true, all newly created nodes/links will use string UUIDs for their id fields instead of integers. - // use this if you must have node IDs that are unique across all graphs and subgraphs. - use_uuids = false - - // Whether to highlight the bounding box of selected groups - highlight_selected_group = true - - // TODO: Remove legacy accessors - LGraph = LGraph - LLink = LLink - LGraphNode = LGraphNode - LGraphGroup = LGraphGroup - DragAndScale = DragAndScale - LGraphCanvas = LGraphCanvas - ContextMenu = ContextMenu - CurveEditor = CurveEditor - - onNodeTypeRegistered?(type: string, base_class: typeof LGraphNode): void - onNodeTypeReplaced?(type: string, base_class: typeof LGraphNode, prev: unknown): void - - // Avoid circular dependency from original single-module - static { - LGraphCanvas.link_type_colors = { - "-1": LiteGraphGlobal.DEFAULT_EVENT_LINK_COLOR, - number: "#AAA", - node: "#DCA" - } + // Enums + SlotShape = SlotShape + SlotDirection = SlotDirection + SlotType = SlotType + LabelPosition = LabelPosition + + /** Used in serialised graphs at one point. */ + VERSION = 0.4 as const + + CANVAS_GRID_SIZE = 10 + + NODE_TITLE_HEIGHT = 30 + NODE_TITLE_TEXT_Y = 20 + NODE_SLOT_HEIGHT = 20 + NODE_WIDGET_HEIGHT = 20 + NODE_WIDTH = 140 + NODE_MIN_WIDTH = 50 + NODE_COLLAPSED_RADIUS = 10 + NODE_COLLAPSED_WIDTH = 80 + NODE_TITLE_COLOR = "#999" + NODE_SELECTED_TITLE_COLOR = "#FFF" + NODE_TEXT_SIZE = 14 + NODE_TEXT_COLOR = "#AAA" + NODE_TEXT_HIGHLIGHT_COLOR = "#EEE" + NODE_SUBTEXT_SIZE = 12 + NODE_DEFAULT_COLOR = "#333" + NODE_DEFAULT_BGCOLOR = "#353535" + NODE_DEFAULT_BOXCOLOR = "#666" + NODE_DEFAULT_SHAPE = "box" + NODE_BOX_OUTLINE_COLOR = "#FFF" + DEFAULT_SHADOW_COLOR = "rgba(0,0,0,0.5)" + DEFAULT_GROUP_FONT = 24 + DEFAULT_GROUP_FONT_SIZE?: any + + WIDGET_BGCOLOR = "#222" + WIDGET_OUTLINE_COLOR = "#666" + WIDGET_ADVANCED_OUTLINE_COLOR = "rgba(56, 139, 253, 0.8)" + WIDGET_TEXT_COLOR = "#DDD" + WIDGET_SECONDARY_TEXT_COLOR = "#999" + + LINK_COLOR = "#9A9" + // TODO: This is a workaround until LGraphCanvas.link_type_colors is no longer static. + static DEFAULT_EVENT_LINK_COLOR = "#A86" + EVENT_LINK_COLOR = "#A86" + CONNECTING_LINK_COLOR = "#AFA" + + MAX_NUMBER_OF_NODES = 10000 // avoid infinite loops + DEFAULT_POSITION = [100, 100] // default node position + VALID_SHAPES = ["default", "box", "round", "card"] // ,"circle" + + // shapes are used for nodes but also for slots + BOX_SHAPE = RenderShape.BOX + ROUND_SHAPE = RenderShape.ROUND + CIRCLE_SHAPE = RenderShape.CIRCLE + CARD_SHAPE = RenderShape.CARD + ARROW_SHAPE = RenderShape.ARROW + GRID_SHAPE = RenderShape.GRID // intended for slot arrays + + // enums + INPUT = NodeSlotType.INPUT + OUTPUT = NodeSlotType.OUTPUT + + // TODO: -1 can lead to ambiguity in JS; these should be updated to a more explicit constant or Symbol. + EVENT = -1 as const // for outputs + ACTION = -1 as const // for inputs + + NODE_MODES = ["Always", "On Event", "Never", "On Trigger"] // helper, will add "On Request" and more in the future + NODE_MODES_COLORS = ["#666", "#422", "#333", "#224", "#626"] // use with node_box_coloured_by_mode + ALWAYS = LGraphEventMode.ALWAYS + ON_EVENT = LGraphEventMode.ON_EVENT + NEVER = LGraphEventMode.NEVER + ON_TRIGGER = LGraphEventMode.ON_TRIGGER + + UP = LinkDirection.UP + DOWN = LinkDirection.DOWN + LEFT = LinkDirection.LEFT + RIGHT = LinkDirection.RIGHT + CENTER = LinkDirection.CENTER + + LINK_RENDER_MODES = ["Straight", "Linear", "Spline"] // helper + HIDDEN_LINK = LinkRenderType.HIDDEN_LINK + STRAIGHT_LINK = LinkRenderType.STRAIGHT_LINK + LINEAR_LINK = LinkRenderType.LINEAR_LINK + SPLINE_LINK = LinkRenderType.SPLINE_LINK + + NORMAL_TITLE = TitleMode.NORMAL_TITLE + NO_TITLE = TitleMode.NO_TITLE + TRANSPARENT_TITLE = TitleMode.TRANSPARENT_TITLE + AUTOHIDE_TITLE = TitleMode.AUTOHIDE_TITLE + + VERTICAL_LAYOUT = "vertical" // arrange nodes vertically + + proxy = null // used to redirect calls + node_images_path = "" + + debug = false + catch_exceptions = true + throw_errors = true + allow_scripts = false // if set to true some nodes like Formula would be allowed to evaluate code that comes from unsafe sources (like node configuration), which could lead to exploits + registered_node_types: Record = {} // nodetypes by string + node_types_by_file_extension = {} // used for dropping files in the canvas + Nodes: Record = {} // node types by classname + Globals = {} // used to store vars between graphs + + searchbox_extras = {} // used to add extra features to the search box + auto_sort_node_types = false // [true!] If set to true, will automatically sort node types / categories in the context menus + + node_box_coloured_when_on = false // [true!] this make the nodes box (top left circle) coloured when triggered (execute/action), visual feedback + node_box_coloured_by_mode = false // [true!] nodebox based on node mode, visual feedback + + dialog_close_on_mouse_leave = false // [false on mobile] better true if not touch device, TODO add an helper/listener to close if false + dialog_close_on_mouse_leave_delay = 500 + + shift_click_do_break_link_from = false // [false!] prefer false if results too easy to break links - implement with ALT or TODO custom keys + click_do_break_link_to = false // [false!]prefer false, way too easy to break links + ctrl_alt_click_do_break_link = true // [true!] who accidentally ctrl-alt-clicks on an in/output? nobody! that's who! + snaps_for_comfy = true // [true!] snaps links when dragging connections over valid targets + snap_highlights_node = true // [true!] renders a partial border to highlight when a dragged link is snapped to a node + + search_hide_on_mouse_leave = true // [false on mobile] better true if not touch device, TODO add an helper/listener to close if false + search_filter_enabled = false // [true!] enable filtering slots type in the search widget, !requires auto_load_slot_types or manual set registered_slot_[in/out]_types and slot_types_[in/out] + search_show_all_on_open = true // [true!] opens the results list when opening the search widget + + auto_load_slot_types = false // [if want false, use true, run, get vars values to be statically set, than disable] nodes types and nodeclass association with node types need to be calculated, if dont want this, calculate once and set registered_slot_[in/out]_types and slot_types_[in/out] + + // set these values if not using auto_load_slot_types + registered_slot_in_types: Record = {} // slot types for nodeclass + registered_slot_out_types: Record = {} // slot types for nodeclass + slot_types_in: string[] = [] // slot types IN + slot_types_out: string[] = [] // slot types OUT + slot_types_default_in: Record = {} // specify for each IN slot type a(/many) default node(s), use single string, array, or object (with node, title, parameters, ..) like for search + slot_types_default_out: Record = {} // specify for each OUT slot type a(/many) default node(s), use single string, array, or object (with node, title, parameters, ..) like for search + + alt_drag_do_clone_nodes = false // [true!] very handy, ALT click to clone and drag the new node + + do_add_triggers_slots = false // [true!] will create and connect event slots when using action/events connections, !WILL CHANGE node mode when using onTrigger (enable mode colors), onExecuted does not need this + + allow_multi_output_for_events = true // [false!] being events, it is strongly reccomended to use them sequentially, one by one + + middle_click_slot_add_default_node = false // [true!] allows to create and connect a ndoe clicking with the third button (wheel) + + release_link_on_empty_shows_menu = false // [true!] dragging a link to empty space will open a menu, add from list, search or defaults + + pointerevents_method = "pointer" // "mouse"|"pointer" use mouse for retrocompatibility issues? (none found @ now) + + // TODO implement pointercancel, gotpointercapture, lostpointercapture, (pointerover, pointerout if necessary) + ctrl_shift_v_paste_connect_unselected_outputs = true // [true!] allows ctrl + shift + v to paste nodes with the outputs of the unselected nodes connected with the inputs of the newly pasted nodes + + // if true, all newly created nodes/links will use string UUIDs for their id fields instead of integers. + // use this if you must have node IDs that are unique across all graphs and subgraphs. + use_uuids = false + + // Whether to highlight the bounding box of selected groups + highlight_selected_group = true + + // TODO: Remove legacy accessors + LGraph = LGraph + LLink = LLink + LGraphNode = LGraphNode + LGraphGroup = LGraphGroup + DragAndScale = DragAndScale + LGraphCanvas = LGraphCanvas + ContextMenu = ContextMenu + CurveEditor = CurveEditor + + onNodeTypeRegistered?(type: string, base_class: typeof LGraphNode): void + onNodeTypeReplaced?(type: string, base_class: typeof LGraphNode, prev: unknown): void + + // Avoid circular dependency from original single-module + static { + LGraphCanvas.link_type_colors = { + "-1": LiteGraphGlobal.DEFAULT_EVENT_LINK_COLOR, + "number": "#AAA", + "node": "#DCA", } - - constructor() { - //timer that works everywhere - if (typeof performance != "undefined") { - this.getTime = performance.now.bind(performance) - } else if (typeof Date != "undefined" && Date.now) { - this.getTime = Date.now.bind(Date) - } else if (typeof process != "undefined") { - this.getTime = function () { - const t = process.hrtime() - return t[0] * 0.001 + t[1] * 1e-6 - } - } else { - this.getTime = function () { - return new Date().getTime() - } - } + } + + constructor() { + // timer that works everywhere + if (typeof performance != "undefined") { + this.getTime = performance.now.bind(performance) + } else if (typeof Date != "undefined" && Date.now) { + this.getTime = Date.now.bind(Date) + } else if (typeof process != "undefined") { + this.getTime = function () { + const t = process.hrtime() + return t[0] * 0.001 + t[1] * 1e-6 + } + } else { + this.getTime = function () { + return new Date().getTime() + } } + } - /** - * Register a node class so it can be listed when the user wants to create a new one - * @param {String} type name of the node and path - * @param {Class} base_class class containing the structure of a node - */ - registerNodeType(type: string, base_class: typeof LGraphNode): void { - if (!base_class.prototype) - throw "Cannot register a simple object, it must be a class with a prototype" - base_class.type = type - - if (this.debug) console.log("Node registered: " + type) - - const classname = base_class.name + /** + * Register a node class so it can be listed when the user wants to create a new one + * @param {String} type name of the node and path + * @param {Class} base_class class containing the structure of a node + */ + registerNodeType(type: string, base_class: typeof LGraphNode): void { + if (!base_class.prototype) + throw "Cannot register a simple object, it must be a class with a prototype" + base_class.type = type - const pos = type.lastIndexOf("/") - base_class.category = type.substring(0, pos) - - base_class.title ||= classname - - //extend class - for (const i in LGraphNode.prototype) { - base_class.prototype[i] ||= LGraphNode.prototype[i] - } - - const prev = this.registered_node_types[type] - if (prev) { - console.log("replacing node type: " + type) - } - if (!Object.prototype.hasOwnProperty.call(base_class.prototype, "shape")) { - Object.defineProperty(base_class.prototype, "shape", { - set(this: LGraphNode, v: RenderShape | "default" | "box" | "round" | "circle" | "card") { - switch (v) { - case "default": - delete this._shape - break - case "box": - this._shape = RenderShape.BOX - break - case "round": - this._shape = RenderShape.ROUND - break - case "circle": - this._shape = RenderShape.CIRCLE - break - case "card": - this._shape = RenderShape.CARD - break - default: - this._shape = v - } - }, - get() { - return this._shape - }, - enumerable: true, - configurable: true - }) - - //used to know which nodes to create when dragging files to the canvas - if (base_class.supported_extensions) { - for (const i in base_class.supported_extensions) { - const ext = base_class.supported_extensions[i] - if (ext && typeof ext === "string") { - this.node_types_by_file_extension[ext.toLowerCase()] = base_class - } - } - } - } + if (this.debug) console.log("Node registered: " + type) - this.registered_node_types[type] = base_class - if (base_class.constructor.name) this.Nodes[classname] = base_class + const classname = base_class.name - this.onNodeTypeRegistered?.(type, base_class) - if (prev) this.onNodeTypeReplaced?.(type, base_class, prev) + const pos = type.lastIndexOf("/") + base_class.category = type.substring(0, pos) - //warnings - if (base_class.prototype.onPropertyChange) - console.warn(`LiteGraph node class ${type} has onPropertyChange method, it must be called onPropertyChanged with d at the end`) + base_class.title ||= classname - // TODO one would want to know input and ouput :: this would allow through registerNodeAndSlotType to get all the slots types - if (this.auto_load_slot_types) new base_class(base_class.title || "tmpnode") + // extend class + for (const i in LGraphNode.prototype) { + base_class.prototype[i] ||= LGraphNode.prototype[i] } - /** - * removes a node type from the system - * @param {String|Object} type name of the node or the node constructor itself - */ - unregisterNodeType(type: string | typeof LGraphNode): void { - const base_class = typeof type === "string" - ? this.registered_node_types[type] - : type - if (!base_class) throw "node type not found: " + type - - delete this.registered_node_types[base_class.type] - - const name = base_class.constructor.name - if (name) delete this.Nodes[name] + const prev = this.registered_node_types[type] + if (prev) { + console.log("replacing node type: " + type) } - - /** - * Save a slot type and his node - * @param {String|Object} type name of the node or the node constructor itself - * @param {String} slot_type name of the slot type (variable type), eg. string, number, array, boolean, .. - */ - registerNodeAndSlotType(type: LGraphNode, slot_type: ISlotType, out?: boolean): void { - out ||= false - // @ts-expect-error Confirm this function no longer supports string types - base_class should always be an instance not a constructor. - const base_class = typeof type === "string" && this.registered_node_types[type] !== "anonymous" - ? this.registered_node_types[type] - : type - - // @ts-expect-error Confirm this function no longer supports string types - base_class should always be an instance not a constructor. - const class_type = base_class.constructor.type - - let allTypes = [] - if (typeof slot_type === "string") { - allTypes = slot_type.split(",") - } else if (slot_type == this.EVENT || slot_type == this.ACTION) { - allTypes = ["_event_"] - } else { - allTypes = ["*"] - } - - for (let i = 0; i < allTypes.length; ++i) { - let slotType = allTypes[i] - if (slotType === "") slotType = "*" - - const registerTo = out - ? "registered_slot_out_types" - : "registered_slot_in_types" - if (this[registerTo][slotType] === undefined) - this[registerTo][slotType] = { nodes: [] } - if (!this[registerTo][slotType].nodes.includes(class_type)) - this[registerTo][slotType].nodes.push(class_type) - - // check if is a new type - const types = out - ? this.slot_types_out - : this.slot_types_in - if (!types.includes(slotType.toLowerCase())) { - types.push(slotType.toLowerCase()) - types.sort() - } - } + if (!Object.prototype.hasOwnProperty.call(base_class.prototype, "shape")) { + Object.defineProperty(base_class.prototype, "shape", { + set(this: LGraphNode, v: RenderShape | "default" | "box" | "round" | "circle" | "card") { + switch (v) { + case "default": + delete this._shape + break + case "box": + this._shape = RenderShape.BOX + break + case "round": + this._shape = RenderShape.ROUND + break + case "circle": + this._shape = RenderShape.CIRCLE + break + case "card": + this._shape = RenderShape.CARD + break + default: + this._shape = v + } + }, + get() { + return this._shape + }, + enumerable: true, + configurable: true, + }) + + // used to know which nodes to create when dragging files to the canvas + if (base_class.supported_extensions) { + for (const i in base_class.supported_extensions) { + const ext = base_class.supported_extensions[i] + if (ext && typeof ext === "string") { + this.node_types_by_file_extension[ext.toLowerCase()] = base_class + } + } + } } - /** - * Create a new nodetype by passing a function, it wraps it with a proper class and generates inputs according to the parameters of the function. - * Useful to wrap simple methods that do not require properties, and that only process some input to generate an output. - * @param {String} name node name with namespace (p.e.: 'math/sum') - * @param {Function} func - * @param {Array} param_types [optional] an array containing the type of every parameter, otherwise parameters will accept any type - * @param {String} return_type [optional] string with the return type, otherwise it will be generic - * @param {Object} properties [optional] properties to be configurable - */ - wrapFunctionAsNode( - name: string, - func: (...args: any) => any, - param_types: string[], - return_type: string, - properties: unknown - ) { - const params = Array(func.length) - let code = "" - const names = this.getParameterNames(func) - for (let i = 0; i < names.length; ++i) { - code += `this.addInput('${names[i]}',${param_types && param_types[i] ? `'${param_types[i]}'` : "0"});\n` - } - code += `this.addOutput('out',${return_type ? `'${return_type}'` : 0});\n` - if (properties) code += `this.properties = ${JSON.stringify(properties)};\n` - - const classobj = Function(code) - // @ts-ignore - classobj.title = name.split("/").pop() - // @ts-ignore - classobj.desc = "Generated from " + func.name - classobj.prototype.onExecute = function onExecute() { - for (let i = 0; i < params.length; ++i) { - params[i] = this.getInputData(i) - } - const r = func.apply(this, params) - this.setOutputData(0, r) - } - // @ts-expect-error Required to make this kludge work - this.registerNodeType(name, classobj) - } - - /** - * Removes all previously registered node's types - */ - clearRegisteredTypes(): void { - this.registered_node_types = {} - this.node_types_by_file_extension = {} - this.Nodes = {} - this.searchbox_extras = {} - } - - /** - * Adds this method to all nodetypes, existing and to be created - * (You can add it to LGraphNode.prototype but then existing node types wont have it) - * @param {Function} func - */ - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type - addNodeMethod(name: string, func: Function): void { - LGraphNode.prototype[name] = func - for (const i in this.registered_node_types) { - const type = this.registered_node_types[i] - //keep old in case of replacing - if (type.prototype[name]) type.prototype["_" + name] = type.prototype[name] - type.prototype[name] = func - } + this.registered_node_types[type] = base_class + if (base_class.constructor.name) this.Nodes[classname] = base_class + + this.onNodeTypeRegistered?.(type, base_class) + if (prev) this.onNodeTypeReplaced?.(type, base_class, prev) + + // warnings + if (base_class.prototype.onPropertyChange) + console.warn(`LiteGraph node class ${type} has onPropertyChange method, it must be called onPropertyChanged with d at the end`) + + // TODO one would want to know input and ouput :: this would allow through registerNodeAndSlotType to get all the slots types + if (this.auto_load_slot_types) new base_class(base_class.title || "tmpnode") + } + + /** + * removes a node type from the system + * @param {String|Object} type name of the node or the node constructor itself + */ + unregisterNodeType(type: string | typeof LGraphNode): void { + const base_class = typeof type === "string" + ? this.registered_node_types[type] + : type + if (!base_class) throw "node type not found: " + type + + delete this.registered_node_types[base_class.type] + + const name = base_class.constructor.name + if (name) delete this.Nodes[name] + } + + /** + * Save a slot type and his node + * @param {String|Object} type name of the node or the node constructor itself + * @param {String} slot_type name of the slot type (variable type), eg. string, number, array, boolean, .. + */ + registerNodeAndSlotType( + type: LGraphNode, + slot_type: ISlotType, + out?: boolean, + ): void { + out ||= false + // @ts-expect-error Confirm this function no longer supports string types - base_class should always be an instance not a constructor. + const base_class = typeof type === "string" && this.registered_node_types[type] !== "anonymous" + ? this.registered_node_types[type] + : type + + // @ts-expect-error Confirm this function no longer supports string types - base_class should always be an instance not a constructor. + const class_type = base_class.constructor.type + + let allTypes = [] + if (typeof slot_type === "string") { + allTypes = slot_type.split(",") + } else if (slot_type == this.EVENT || slot_type == this.ACTION) { + allTypes = ["_event_"] + } else { + allTypes = ["*"] } - /** - * Create a node of a given type with a name. The node is not attached to any graph yet. - * @param {String} type full name of the node class. p.e. "math/sin" - * @param {String} name a name to distinguish from other nodes - * @param {Object} options to set options - */ - createNode(type: string, title?: string, options?: Dictionary): LGraphNode { - const base_class = this.registered_node_types[type] - if (!base_class) { - if (this.debug) console.log(`GraphNode type "${type}" not registered.`) - return null - } + for (let i = 0; i < allTypes.length; ++i) { + let slotType = allTypes[i] + if (slotType === "") slotType = "*" + + const registerTo = out + ? "registered_slot_out_types" + : "registered_slot_in_types" + if (this[registerTo][slotType] === undefined) + this[registerTo][slotType] = { nodes: [] } + if (!this[registerTo][slotType].nodes.includes(class_type)) + this[registerTo][slotType].nodes.push(class_type) + + // check if is a new type + const types = out + ? this.slot_types_out + : this.slot_types_in + if (!types.includes(slotType.toLowerCase())) { + types.push(slotType.toLowerCase()) + types.sort() + } + } + } + + /** + * Create a new nodetype by passing a function, it wraps it with a proper class and + * generates inputs according to the parameters of the function. + * Useful to wrap simple methods that do not require properties, and that only process some input to generate an output. + * @param {String} name node name with namespace (p.e.: 'math/sum') + * @param {Function} func + * @param {Array} param_types [optional] an array containing the type of every parameter, + * otherwise parameters will accept any type + * @param {String} return_type [optional] string with the return type, otherwise it will be generic + * @param {Object} properties [optional] properties to be configurable + */ + wrapFunctionAsNode( + name: string, + func: (...args: any) => any, + param_types: string[], + return_type: string, + properties: unknown, + ) { + const params = Array(func.length) + let code = "" + const names = this.getParameterNames(func) + for (let i = 0; i < names.length; ++i) { + code += `this.addInput('${names[i]}',${param_types && param_types[i] ? `'${param_types[i]}'` : "0"});\n` + } + code += `this.addOutput('out',${return_type ? `'${return_type}'` : 0});\n` + if (properties) code += `this.properties = ${JSON.stringify(properties)};\n` + + const classobj = Function(code) + // @ts-ignore + classobj.title = name.split("/").pop() + // @ts-ignore + classobj.desc = "Generated from " + func.name + classobj.prototype.onExecute = function onExecute() { + for (let i = 0; i < params.length; ++i) { + params[i] = this.getInputData(i) + } + const r = func.apply(this, params) + this.setOutputData(0, r) + } + // @ts-expect-error Required to make this kludge work + this.registerNodeType(name, classobj) + } + + /** + * Removes all previously registered node's types + */ + clearRegisteredTypes(): void { + this.registered_node_types = {} + this.node_types_by_file_extension = {} + this.Nodes = {} + this.searchbox_extras = {} + } + + /** + * Adds this method to all nodetypes, existing and to be created + * (You can add it to LGraphNode.prototype but then existing node types wont have it) + * @param {Function} func + */ + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + addNodeMethod(name: string, func: Function): void { + LGraphNode.prototype[name] = func + for (const i in this.registered_node_types) { + const type = this.registered_node_types[i] + // keep old in case of replacing + if (type.prototype[name]) type.prototype["_" + name] = type.prototype[name] + type.prototype[name] = func + } + } + + /** + * Create a node of a given type with a name. The node is not attached to any graph yet. + * @param {String} type full name of the node class. p.e. "math/sin" + * @param {String} name a name to distinguish from other nodes + * @param {Object} options to set options + */ + createNode( + type: string, + title?: string, + options?: Dictionary, + ): LGraphNode { + const base_class = this.registered_node_types[type] + if (!base_class) { + if (this.debug) console.log(`GraphNode type "${type}" not registered.`) + return null + } - title = title || base_class.title || type + title = title || base_class.title || type - let node = null + let node = null - if (this.catch_exceptions) { - try { - node = new base_class(title) - } catch (err) { - console.error(err) - return null - } - } else { - node = new base_class(title) - } - - node.type = type - - if (!node.title && title) node.title = title - node.properties ||= {} - node.properties_info ||= [] - node.flags ||= {} - //call onresize? - node.size ||= node.computeSize() - node.pos ||= this.DEFAULT_POSITION.concat() - node.mode ||= LGraphEventMode.ALWAYS - - //extra options - if (options) { - for (const i in options) { - node[i] = options[i] - } - } - - // callback - node.onNodeCreated?.() - return node - } - - /** - * Returns a registered node type with a given name - * @param {String} type full name of the node class. p.e. "math/sin" - * @return {Class} the node class - */ - getNodeType(type: string): typeof LGraphNode { - return this.registered_node_types[type] - } - - /** - * Returns a list of node types matching one category - * @param {String} category category name - * @return {Array} array with all the node classes - */ - getNodeTypesInCategory(category: string, filter: any) { - const r = [] - for (const i in this.registered_node_types) { - const type = this.registered_node_types[i] - if (type.filter != filter) continue - - if (category == "") { - if (type.category == null) r.push(type) - } else if (type.category == category) { - r.push(type) - } - } + if (this.catch_exceptions) { + try { + node = new base_class(title) + } catch (err) { + console.error(err) + return null + } + } else { + node = new base_class(title) + } - if (this.auto_sort_node_types) { - r.sort(function (a, b) { - return a.title.localeCompare(b.title) - }) - } + node.type = type + + if (!node.title && title) node.title = title + node.properties ||= {} + node.properties_info ||= [] + node.flags ||= {} + // call onresize? + node.size ||= node.computeSize() + node.pos ||= this.DEFAULT_POSITION.concat() + node.mode ||= LGraphEventMode.ALWAYS + + // extra options + if (options) { + for (const i in options) { + node[i] = options[i] + } + } - return r - } - - /** - * Returns a list with all the node type categories - * @param {String} filter only nodes with ctor.filter equal can be shown - * @return {Array} array with all the names of the categories - */ - getNodeTypesCategories(filter: string): string[] { - const categories = { "": 1 } - for (const i in this.registered_node_types) { - const type = this.registered_node_types[i] - if (type.category && !type.skip_list) { - if (type.filter != filter) - continue - categories[type.category] = 1 - } - } - const result = [] - for (const i in categories) { - result.push(i) - } - return this.auto_sort_node_types ? result.sort() : result + // callback + node.onNodeCreated?.() + return node + } + + /** + * Returns a registered node type with a given name + * @param {String} type full name of the node class. p.e. "math/sin" + * @return {Class} the node class + */ + getNodeType(type: string): typeof LGraphNode { + return this.registered_node_types[type] + } + + /** + * Returns a list of node types matching one category + * @param {String} category category name + * @return {Array} array with all the node classes + */ + getNodeTypesInCategory(category: string, filter: any) { + const r = [] + for (const i in this.registered_node_types) { + const type = this.registered_node_types[i] + if (type.filter != filter) continue + + if (category == "") { + if (type.category == null) r.push(type) + } else if (type.category == category) { + r.push(type) + } } - //debug purposes: reloads all the js scripts that matches a wildcard - reloadNodes(folder_wildcard: string): void { - const tmp = document.getElementsByTagName("script") - //weird, this array changes by its own, so we use a copy - const script_files = [] - for (let i = 0; i < tmp.length; i++) { - script_files.push(tmp[i]) - } + if (this.auto_sort_node_types) { + r.sort(function (a, b) { + return a.title.localeCompare(b.title) + }) + } - const docHeadObj = document.getElementsByTagName("head")[0] - folder_wildcard = document.location.href + folder_wildcard - - for (let i = 0; i < script_files.length; i++) { - const src = script_files[i].src - if (!src || src.substr(0, folder_wildcard.length) != folder_wildcard) - continue - - try { - if (this.debug) console.log("Reloading: " + src) - const dynamicScript = document.createElement("script") - dynamicScript.type = "text/javascript" - dynamicScript.src = src - docHeadObj.appendChild(dynamicScript) - docHeadObj.removeChild(script_files[i]) - } catch (err) { - if (this.throw_errors) throw err - if (this.debug) console.log("Error while reloading " + src) - } - } + return r + } + + /** + * Returns a list with all the node type categories + * @param {String} filter only nodes with ctor.filter equal can be shown + * @return {Array} array with all the names of the categories + */ + getNodeTypesCategories(filter: string): string[] { + const categories = { "": 1 } + for (const i in this.registered_node_types) { + const type = this.registered_node_types[i] + if (type.category && !type.skip_list) { + if (type.filter != filter) continue + + categories[type.category] = 1 + } + } + const result = [] + for (const i in categories) { + result.push(i) + } + return this.auto_sort_node_types ? result.sort() : result + } + + // debug purposes: reloads all the js scripts that matches a wildcard + reloadNodes(folder_wildcard: string): void { + const tmp = document.getElementsByTagName("script") + // weird, this array changes by its own, so we use a copy + const script_files = [] + for (let i = 0; i < tmp.length; i++) { + script_files.push(tmp[i]) + } - if (this.debug) console.log("Nodes reloaded") + const docHeadObj = document.getElementsByTagName("head")[0] + folder_wildcard = document.location.href + folder_wildcard + + for (let i = 0; i < script_files.length; i++) { + const src = script_files[i].src + if (!src || src.substr(0, folder_wildcard.length) != folder_wildcard) + continue + + try { + if (this.debug) console.log("Reloading: " + src) + const dynamicScript = document.createElement("script") + dynamicScript.type = "text/javascript" + dynamicScript.src = src + docHeadObj.appendChild(dynamicScript) + docHeadObj.removeChild(script_files[i]) + } catch (err) { + if (this.throw_errors) throw err + if (this.debug) console.log("Error while reloading " + src) + } } - //separated just to improve if it doesn't work - cloneObject(obj: T, target?: T): T { - if (obj == null) return null + if (this.debug) console.log("Nodes reloaded") + } - const r = JSON.parse(JSON.stringify(obj)) - if (!target) return r + // separated just to improve if it doesn't work + cloneObject(obj: T, target?: T): T { + if (obj == null) return null - for (const i in r) { - target[i] = r[i] - } - return target - } - - /* - * https://gist.github.com/jed/982883?permalink_comment_id=852670#gistcomment-852670 - */ - uuidv4(): string { - // @ts-ignore - return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, a => (a ^ Math.random() * 16 >> a / 4).toString(16)) - } - - /** - * Returns if the types of two slots are compatible (taking into account wildcards, etc) - * @param {String} type_a output - * @param {String} type_b input - * @return {Boolean} true if they can be connected - */ - isValidConnection(type_a: ISlotType, type_b: ISlotType): boolean { - if (type_a == "" || type_a === "*") type_a = 0 - if (type_b == "" || type_b === "*") type_b = 0 - // If generic in/output, matching types (valid for triggers), or event/action types - if (!type_a || !type_b || type_a == type_b || (type_a == this.EVENT && type_b == this.ACTION)) - return true - - // Enforce string type to handle toLowerCase call (-1 number not ok) - type_a = String(type_a) - type_b = String(type_b) - type_a = type_a.toLowerCase() - type_b = type_b.toLowerCase() - - // For nodes supporting multiple connection types - if (type_a.indexOf(",") == -1 && type_b.indexOf(",") == -1) - return type_a == type_b - - // Check all permutations to see if one is valid - const supported_types_a = type_a.split(",") - const supported_types_b = type_b.split(",") - for (let i = 0; i < supported_types_a.length; ++i) { - for (let j = 0; j < supported_types_b.length; ++j) { - if (this.isValidConnection(supported_types_a[i], supported_types_b[j])) - return true - } - } + const r = JSON.parse(JSON.stringify(obj)) + if (!target) return r - return false - } - - /** - * Register a string in the search box so when the user types it it will recommend this node - * @param {String} node_type the node recommended - * @param {String} description text to show next to it - * @param {Object} data it could contain info of how the node should be configured - * @return {Boolean} true if they can be connected - */ - registerSearchboxExtra(node_type: any, description: string, data: any): void { - this.searchbox_extras[description.toLowerCase()] = { - type: node_type, - desc: description, - data: data - } + for (const i in r) { + target[i] = r[i] } - - /** - * Wrapper to load files (from url using fetch or from file using FileReader) - * @param {String|File|Blob} url the url of the file (or the file itself) - * @param {String} type an string to know how to fetch it: "text","arraybuffer","json","blob" - * @param {Function} on_complete callback(data) - * @param {Function} on_error in case of an error - * @return {FileReader|Promise} returns the object used to - */ - fetchFile(url: string | URL | Request | Blob, type: string, on_complete: (data: string | ArrayBuffer) => void, on_error: (error: unknown) => void): void | Promise { - if (!url) return null - - type = type || "text" - if (typeof url === "string") { - if (url.substr(0, 4) == "http" && this.proxy) - url = this.proxy + url.substr(url.indexOf(":") + 3) - - return fetch(url) - .then(function (response) { - if (!response.ok) - throw new Error("File not found") //it will be catch below - if (type == "arraybuffer") - return response.arrayBuffer() - else if (type == "text" || type == "string") - return response.text() - else if (type == "json") - return response.json() - else if (type == "blob") - return response.blob() - }) - .then(function (data: string | ArrayBuffer): void { - on_complete?.(data) - }) - .catch(function (error) { - console.error("error fetching file:", url) - on_error?.(error) - }) - } else if (url instanceof File || url instanceof Blob) { - const reader = new FileReader() - reader.onload = function (e) { - let v = e.target.result - if (type == "json") - // @ts-ignore - v = JSON.parse(v) - on_complete?.(v) - } - if (type == "arraybuffer") - return reader.readAsArrayBuffer(url) - else if (type == "text" || type == "json") - return reader.readAsText(url) - else if (type == "blob") - return reader.readAsBinaryString(url) - } - return null + return target + } + + /* + * https://gist.github.com/jed/982883?permalink_comment_id=852670#gistcomment-852670 + */ + uuidv4(): string { + // @ts-ignore + return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, a => + (a ^ ((Math.random() * 16) >> (a / 4))).toString(16)) + } + + /** + * Returns if the types of two slots are compatible (taking into account wildcards, etc) + * @param {String} type_a output + * @param {String} type_b input + * @return {Boolean} true if they can be connected + */ + isValidConnection(type_a: ISlotType, type_b: ISlotType): boolean { + if (type_a == "" || type_a === "*") type_a = 0 + if (type_b == "" || type_b === "*") type_b = 0 + // If generic in/output, matching types (valid for triggers), or event/action types + if ( + !type_a || + !type_b || + type_a == type_b || + (type_a == this.EVENT && type_b == this.ACTION) + ) + return true + + // Enforce string type to handle toLowerCase call (-1 number not ok) + type_a = String(type_a) + type_b = String(type_b) + type_a = type_a.toLowerCase() + type_b = type_b.toLowerCase() + + // For nodes supporting multiple connection types + if (type_a.indexOf(",") == -1 && type_b.indexOf(",") == -1) + return type_a == type_b + + // Check all permutations to see if one is valid + const supported_types_a = type_a.split(",") + const supported_types_b = type_b.split(",") + for (let i = 0; i < supported_types_a.length; ++i) { + for (let j = 0; j < supported_types_b.length; ++j) { + if (this.isValidConnection(supported_types_a[i], supported_types_b[j])) + return true + } } - //used to create nodes from wrapping functions - getParameterNames(func: (...args: any) => any): string[] { - return (func + "") - .replace(/[/][/].*$/gm, "") // strip single-line comments - .replace(/\s+/g, "") // strip white space - .replace(/[/][*][^/*]*[*][/]/g, "") // strip multi-line comments /**/ - .split("){", 1)[0] - .replace(/^[^(]*[(]/, "") // extract the parameters - .replace(/=[^,]+/g, "") // strip any ES6 defaults - .split(",") - .filter(Boolean) // split & filter [""] - } - - /* helper for interaction: pointer, touch, mouse Listeners - used by LGraphCanvas DragAndScale ContextMenu*/ - pointerListenerAdd(oDOM: Node, sEvIn: string, fCall: (e: Event) => boolean | void, capture = false): void { - if (!oDOM || !oDOM.addEventListener || !sEvIn || typeof fCall !== "function") return - - let sMethod = this.pointerevents_method - let sEvent = sEvIn - - // UNDER CONSTRUCTION - // convert pointerevents to touch event when not available - if (sMethod == "pointer" && !window.PointerEvent) { - console.warn("sMethod=='pointer' && !window.PointerEvent") - console.log("Converting pointer[" + sEvent + "] : down move up cancel enter TO touchstart touchmove touchend, etc ..") - switch (sEvent) { - case "down": { - sMethod = "touch" - sEvent = "start" - break - } - case "move": { - sMethod = "touch" - //sEvent = "move"; - break - } - case "up": { - sMethod = "touch" - sEvent = "end" - break - } - case "cancel": { - sMethod = "touch" - //sEvent = "cancel"; - break - } - case "enter": { - console.log("debug: Should I send a move event?") // ??? - break - } - // case "over": case "out": not used at now - default: { - console.warn("PointerEvent not available in this browser ? The event " + sEvent + " would not be called") - } - } - } - - switch (sEvent) { - // @ts-expect-error - //both pointer and move events - case "down": case "up": case "move": case "over": case "out": case "enter": - { - oDOM.addEventListener(sMethod + sEvent, fCall, capture) - } - // @ts-expect-error - // only pointerevents - case "leave": case "cancel": case "gotpointercapture": case "lostpointercapture": - { - if (sMethod != "mouse") { - return oDOM.addEventListener(sMethod + sEvent, fCall, capture) - } - } - // not "pointer" || "mouse" - default: - return oDOM.addEventListener(sEvent, fCall, capture) - } + return false + } + + /** + * Register a string in the search box so when the user types it it will recommend this node + * @param {String} node_type the node recommended + * @param {String} description text to show next to it + * @param {Object} data it could contain info of how the node should be configured + * @return {Boolean} true if they can be connected + */ + registerSearchboxExtra(node_type: any, description: string, data: any): void { + this.searchbox_extras[description.toLowerCase()] = { + type: node_type, + desc: description, + data: data, } - pointerListenerRemove(oDOM: Node, sEvent: string, fCall: (e: Event) => boolean | void, capture = false): void { - if (!oDOM || !oDOM.removeEventListener || !sEvent || typeof fCall !== "function") return - - switch (sEvent) { - // @ts-expect-error - //both pointer and move events - case "down": case "up": case "move": case "over": case "out": case "enter": - { - if (this.pointerevents_method == "pointer" || this.pointerevents_method == "mouse") { - oDOM.removeEventListener(this.pointerevents_method + sEvent, fCall, capture) - } - } - // @ts-expect-error - // only pointerevents - case "leave": case "cancel": case "gotpointercapture": case "lostpointercapture": - { - if (this.pointerevents_method == "pointer") { - return oDOM.removeEventListener(this.pointerevents_method + sEvent, fCall, capture) - } - } - // not "pointer" || "mouse" - default: - return oDOM.removeEventListener(sEvent, fCall, capture) - } + } + + /** + * Wrapper to load files (from url using fetch or from file using FileReader) + * @param {String|File|Blob} url the url of the file (or the file itself) + * @param {String} type an string to know how to fetch it: "text","arraybuffer","json","blob" + * @param {Function} on_complete callback(data) + * @param {Function} on_error in case of an error + * @return {FileReader|Promise} returns the object used to + */ + fetchFile( + url: string | URL | Request | Blob, + type: string, + on_complete: (data: string | ArrayBuffer) => void, + on_error: (error: unknown) => void, + ): void | Promise { + if (!url) return null + + type = type || "text" + if (typeof url === "string") { + if (url.substr(0, 4) == "http" && this.proxy) + url = this.proxy + url.substr(url.indexOf(":") + 3) + + return fetch(url) + .then(function (response) { + if (!response.ok) + throw new Error("File not found") // it will be catch below + if (type == "arraybuffer") + return response.arrayBuffer() + else if (type == "text" || type == "string") + return response.text() + else if (type == "json") + return response.json() + else if (type == "blob") + return response.blob() + }) + .then(function (data: string | ArrayBuffer): void { + on_complete?.(data) + }) + .catch(function (error) { + console.error("error fetching file:", url) + on_error?.(error) + }) + } else if (url instanceof File || url instanceof Blob) { + const reader = new FileReader() + reader.onload = function (e) { + let v = e.target.result + if (type == "json") + // @ts-ignore + v = JSON.parse(v) + on_complete?.(v) + } + if (type == "arraybuffer") + return reader.readAsArrayBuffer(url) + else if (type == "text" || type == "json") + return reader.readAsText(url) + else if (type == "blob") + return reader.readAsBinaryString(url) } - - getTime: () => number - - compareObjects(a: object, b: object): boolean { - for (const i in a) { - if (a[i] != b[i]) return false - } - return true + return null + } + + // used to create nodes from wrapping functions + getParameterNames(func: (...args: any) => any): string[] { + return (func + "") + .replace(/[/][/].*$/gm, "") // strip single-line comments + .replace(/\s+/g, "") // strip white space + .replace(/[/][*][^/*]*[*][/]/g, "") // strip multi-line comments /**/ + .split("){", 1)[0] + .replace(/^[^(]*[(]/, "") // extract the parameters + .replace(/=[^,]+/g, "") // strip any ES6 defaults + .split(",") + .filter(Boolean) // split & filter [""] + } + + /* helper for interaction: pointer, touch, mouse Listeners + used by LGraphCanvas DragAndScale ContextMenu */ + pointerListenerAdd(oDOM: Node, sEvIn: string, fCall: (e: Event) => boolean | void, capture = false): void { + if (!oDOM || !oDOM.addEventListener || !sEvIn || typeof fCall !== "function") return + + let sMethod = this.pointerevents_method + let sEvent = sEvIn + + // UNDER CONSTRUCTION + // convert pointerevents to touch event when not available + if (sMethod == "pointer" && !window.PointerEvent) { + console.warn("sMethod=='pointer' && !window.PointerEvent") + console.log("Converting pointer[" + sEvent + "] : down move up cancel enter TO touchstart touchmove touchend, etc ..") + switch (sEvent) { + case "down": { + sMethod = "touch" + sEvent = "start" + break + } + case "move": { + sMethod = "touch" + // sEvent = "move"; + break + } + case "up": { + sMethod = "touch" + sEvent = "end" + break + } + case "cancel": { + sMethod = "touch" + // sEvent = "cancel"; + break + } + case "enter": { + console.log("debug: Should I send a move event?") // ??? + break + } + // case "over": case "out": not used at now + default: { + console.warn("PointerEvent not available in this browser ? The event " + sEvent + " would not be called") + } + } } - distance = distance - - colorToString(c: [number, number, number, number]): string { - return ( - "rgba(" + - Math.round(c[0] * 255).toFixed() + - "," + - Math.round(c[1] * 255).toFixed() + - "," + - Math.round(c[2] * 255).toFixed() + - "," + - (c.length == 4 ? c[3].toFixed(2) : "1.0") + - ")" - ) + switch (sEvent) { + // @ts-expect-error + // both pointer and move events + case "down": case "up": case "move": case "over": case "out": case "enter": + { + oDOM.addEventListener(sMethod + sEvent, fCall, capture) } + // @ts-expect-error + // only pointerevents + case "leave": case "cancel": case "gotpointercapture": case "lostpointercapture": + { + if (sMethod != "mouse") { + return oDOM.addEventListener(sMethod + sEvent, fCall, capture) + } + } + // not "pointer" || "mouse" + default: + return oDOM.addEventListener(sEvent, fCall, capture) + } + } + + pointerListenerRemove(oDOM: Node, sEvent: string, fCall: (e: Event) => boolean | void, capture = false): void { + if (!oDOM || !oDOM.removeEventListener || !sEvent || typeof fCall !== "function") return + + switch (sEvent) { + // @ts-expect-error + // both pointer and move events + case "down": case "up": case "move": case "over": case "out": case "enter": + { + if (this.pointerevents_method == "pointer" || this.pointerevents_method == "mouse") { + oDOM.removeEventListener(this.pointerevents_method + sEvent, fCall, capture) + } + } + // @ts-expect-error + // only pointerevents + case "leave": case "cancel": case "gotpointercapture": case "lostpointercapture": + { + if (this.pointerevents_method == "pointer") { + return oDOM.removeEventListener(this.pointerevents_method + sEvent, fCall, capture) + } + } + // not "pointer" || "mouse" + default: + return oDOM.removeEventListener(sEvent, fCall, capture) + } + } - isInsideRectangle = isInsideRectangle - - //[minx,miny,maxx,maxy] - growBounding(bounding: Rect, x: number, y: number): void { - if (x < bounding[0]) { - bounding[0] = x - } else if (x > bounding[2]) { - bounding[2] = x - } + getTime: () => number - if (y < bounding[1]) { - bounding[1] = y - } else if (y > bounding[3]) { - bounding[3] = y - } + compareObjects(a: object, b: object): boolean { + for (const i in a) { + if (a[i] != b[i]) return false + } + return true + } + + distance = distance + + colorToString(c: [number, number, number, number]): string { + return ( + "rgba(" + + Math.round(c[0] * 255).toFixed() + + "," + + Math.round(c[1] * 255).toFixed() + + "," + + Math.round(c[2] * 255).toFixed() + + "," + + (c.length == 4 ? c[3].toFixed(2) : "1.0") + + ")" + ) + } + + isInsideRectangle = isInsideRectangle + + // [minx,miny,maxx,maxy] + growBounding(bounding: Rect, x: number, y: number): void { + if (x < bounding[0]) { + bounding[0] = x + } else if (x > bounding[2]) { + bounding[2] = x } - overlapBounding = overlapBounding - - //point inside bounding box - isInsideBounding(p: number[], bb: number[][]): boolean { - if ( - p[0] < bb[0][0] || - p[1] < bb[0][1] || - p[0] > bb[1][0] || - p[1] > bb[1][1] - ) { - return false - } - return true - } - - //Convert a hex value to its decimal value - the inputted hex must be in the - // format of a hex triplet - the kind we use for HTML colours. The function - // will return an array with three values. - hex2num(hex: string): number[] { - if (hex.charAt(0) == "#") { - hex = hex.slice(1) - } //Remove the '#' char - if there is one. - hex = hex.toUpperCase() - const hex_alphabets = "0123456789ABCDEF" - const value = new Array(3) - let k = 0 - let int1, int2 - for (let i = 0; i < 6; i += 2) { - int1 = hex_alphabets.indexOf(hex.charAt(i)) - int2 = hex_alphabets.indexOf(hex.charAt(i + 1)) - value[k] = int1 * 16 + int2 - k++ - } - return value + if (y < bounding[1]) { + bounding[1] = y + } else if (y > bounding[3]) { + bounding[3] = y } + } - //Give a array with three values as the argument and the function will return - // the corresponding hex triplet. - num2hex(triplet: number[]): string { - const hex_alphabets = "0123456789ABCDEF" - let hex = "#" - let int1, int2 - for (let i = 0; i < 3; i++) { - int1 = triplet[i] / 16 - int2 = triplet[i] % 16 + overlapBounding = overlapBounding - hex += hex_alphabets.charAt(int1) + hex_alphabets.charAt(int2) - } - return hex + // point inside bounding box + isInsideBounding(p: number[], bb: number[][]): boolean { + if ( + p[0] < bb[0][0] || + p[1] < bb[0][1] || + p[0] > bb[1][0] || + p[1] > bb[1][1] + ) { + return false + } + return true + } + + // Convert a hex value to its decimal value - the inputted hex must be in the + // format of a hex triplet - the kind we use for HTML colours. The function + // will return an array with three values. + hex2num(hex: string): number[] { + if (hex.charAt(0) == "#") { + hex = hex.slice(1) + } // Remove the '#' char - if there is one. + hex = hex.toUpperCase() + const hex_alphabets = "0123456789ABCDEF" + const value = new Array(3) + let k = 0 + let int1, int2 + for (let i = 0; i < 6; i += 2) { + int1 = hex_alphabets.indexOf(hex.charAt(i)) + int2 = hex_alphabets.indexOf(hex.charAt(i + 1)) + value[k] = int1 * 16 + int2 + k++ } + return value + } + + // Give a array with three values as the argument and the function will return + // the corresponding hex triplet. + num2hex(triplet: number[]): string { + const hex_alphabets = "0123456789ABCDEF" + let hex = "#" + let int1, int2 + for (let i = 0; i < 3; i++) { + int1 = triplet[i] / 16 + int2 = triplet[i] % 16 + + hex += hex_alphabets.charAt(int1) + hex_alphabets.charAt(int2) + } + return hex + } - closeAllContextMenus(ref_window: Window): void { - ref_window = ref_window || window + closeAllContextMenus(ref_window: Window): void { + ref_window = ref_window || window - const elements = ref_window.document.querySelectorAll(".litecontextmenu") - if (!elements.length) return + const elements = ref_window.document.querySelectorAll(".litecontextmenu") + if (!elements.length) return - const result = [] - for (let i = 0; i < elements.length; i++) { - result.push(elements[i]) - } + const result = [] + for (let i = 0; i < elements.length; i++) { + result.push(elements[i]) + } - for (let i = 0; i < result.length; i++) { - if (result[i].close) { - result[i].close() - } else if (result[i].parentNode) { - result[i].parentNode.removeChild(result[i]) - } - } + for (let i = 0; i < result.length; i++) { + if (result[i].close) { + result[i].close() + } else if (result[i].parentNode) { + result[i].parentNode.removeChild(result[i]) + } } + } - extendClass(target: any, origin: any): void { - for (const i in origin) { - //copy class properties - if (target.hasOwnProperty(i)) continue - target[i] = origin[i] + extendClass(target: any, origin: any): void { + for (const i in origin) { + // copy class properties + if (target.hasOwnProperty(i)) continue + target[i] = origin[i] + } + + if (origin.prototype) { + // copy prototype properties + for (const i in origin.prototype) { + // only enumerable + if (!origin.prototype.hasOwnProperty(i)) continue + + // avoid overwriting existing ones + if (target.prototype.hasOwnProperty(i)) continue + + // copy getters + if (origin.prototype.__lookupGetter__(i)) { + target.prototype.__defineGetter__( + i, + origin.prototype.__lookupGetter__(i), + ) + } else { + target.prototype[i] = origin.prototype[i] } - if (origin.prototype) { - //copy prototype properties - for (const i in origin.prototype) { - //only enumerable - if (!origin.prototype.hasOwnProperty(i)) continue - - //avoid overwriting existing ones - if (target.prototype.hasOwnProperty(i)) continue - - //copy getters - if (origin.prototype.__lookupGetter__(i)) { - target.prototype.__defineGetter__( - i, - origin.prototype.__lookupGetter__(i) - ) - } else { - target.prototype[i] = origin.prototype[i] - } - - //and setters - if (origin.prototype.__lookupSetter__(i)) { - target.prototype.__defineSetter__( - i, - origin.prototype.__lookupSetter__(i) - ) - } - } + // and setters + if (origin.prototype.__lookupSetter__(i)) { + target.prototype.__defineSetter__( + i, + origin.prototype.__lookupSetter__(i), + ) } + } } + } } diff --git a/src/MapProxyHandler.ts b/src/MapProxyHandler.ts index 68c59bb8..cc71c38f 100644 --- a/src/MapProxyHandler.ts +++ b/src/MapProxyHandler.ts @@ -1,55 +1,61 @@ -/** Temporary workaround until downstream consumers migrate to Map. A brittle wrapper with many flaws, but should be fine for simple maps using int indexes. */ +/** + * Temporary workaround until downstream consumers migrate to Map. + * A brittle wrapper with many flaws, but should be fine for simple maps using int indexes. + */ export class MapProxyHandler implements ProxyHandler> { - getOwnPropertyDescriptor(target: Map, p: string | symbol): PropertyDescriptor | undefined { - const value = this.get(target, p) - if (value) return { - configurable: true, - enumerable: true, - value - } - } - - has(target: Map, p: string | symbol): boolean { - if (typeof p === "symbol") return false - - const int = parseInt(p, 10) - return target.has(!isNaN(int) ? int : p) - } - - ownKeys(target: Map): ArrayLike { - return [...target.keys()].map(x => String(x)) - } - - get(target: Map, p: string | symbol): any { - // Workaround does not support link IDs of "values", "entries", "constructor", etc. - if (p in target) return Reflect.get(target, p, target) - if (typeof p === "symbol") return - - const int = parseInt(p, 10) - return target.get(!isNaN(int) ? int : p) - } - - set(target: Map, p: string | symbol, newValue: any): boolean { - if (typeof p === "symbol") return false - - const int = parseInt(p, 10) - target.set(!isNaN(int) ? int : p, newValue) - return true - } - - deleteProperty(target: Map, p: string | symbol): boolean { - return target.delete(p as number | string) - } - - static bindAllMethods(map: Map): void { - map.clear = map.clear.bind(map) - map.delete = map.delete.bind(map) - map.forEach = map.forEach.bind(map) - map.get = map.get.bind(map) - map.has = map.has.bind(map) - map.set = map.set.bind(map) - map.entries = map.entries.bind(map) - map.keys = map.keys.bind(map) - map.values = map.values.bind(map) + getOwnPropertyDescriptor( + target: Map, + p: string | symbol, + ): PropertyDescriptor | undefined { + const value = this.get(target, p) + if (value) return { + configurable: true, + enumerable: true, + value, } + } + + has(target: Map, p: string | symbol): boolean { + if (typeof p === "symbol") return false + + const int = parseInt(p, 10) + return target.has(!isNaN(int) ? int : p) + } + + ownKeys(target: Map): ArrayLike { + return [...target.keys()].map(x => String(x)) + } + + get(target: Map, p: string | symbol): any { + // Workaround does not support link IDs of "values", "entries", "constructor", etc. + if (p in target) return Reflect.get(target, p, target) + if (typeof p === "symbol") return + + const int = parseInt(p, 10) + return target.get(!isNaN(int) ? int : p) + } + + set(target: Map, p: string | symbol, newValue: any): boolean { + if (typeof p === "symbol") return false + + const int = parseInt(p, 10) + target.set(!isNaN(int) ? int : p, newValue) + return true + } + + deleteProperty(target: Map, p: string | symbol): boolean { + return target.delete(p as number | string) + } + + static bindAllMethods(map: Map): void { + map.clear = map.clear.bind(map) + map.delete = map.delete.bind(map) + map.forEach = map.forEach.bind(map) + map.get = map.get.bind(map) + map.has = map.has.bind(map) + map.set = map.set.bind(map) + map.entries = map.entries.bind(map) + map.keys = map.keys.bind(map) + map.values = map.values.bind(map) + } } diff --git a/src/Reroute.ts b/src/Reroute.ts index 72bc2004..94f58ee0 100644 --- a/src/Reroute.ts +++ b/src/Reroute.ts @@ -1,4 +1,11 @@ -import type { CanvasColour, LinkSegment, LinkNetwork, Point, Positionable, ReadOnlyRect } from "./interfaces" +import type { + CanvasColour, + LinkSegment, + LinkNetwork, + Point, + Positionable, + ReadOnlyRect, +} from "./interfaces" import { LLink, type LinkId } from "./LLink" import type { SerialisableReroute, Serialisable } from "./types/serialisation" import { distance } from "./measure" @@ -8,285 +15,302 @@ export type RerouteId = number /** * Represents an additional point on the graph that a link path will travel through. Used for visual organisation only. - * + * * Requires no disposal or clean up. - * Stores only primitive values (IDs) to reference other items in its network, and a `WeakRef` to a {@link LinkNetwork} to resolve them. + * Stores only primitive values (IDs) to reference other items in its network, + * and a `WeakRef` to a {@link LinkNetwork} to resolve them. */ export class Reroute implements Positionable, LinkSegment, Serialisable { - static radius: number = 10 - - #malloc = new Float32Array(8) - - /** The network this reroute belongs to. Contains all valid links and reroutes. */ - #network: WeakRef - - #parentId?: RerouteId - /** @inheritdoc */ - public get parentId(): RerouteId { - return this.#parentId - } - /** Ignores attempts to create an infinite loop. @inheritdoc */ - public set parentId(value: RerouteId) { - if (value === this.id) return - if (this.getReroutes() === null) return - this.#parentId = value + static radius: number = 10 + + #malloc = new Float32Array(8) + + /** The network this reroute belongs to. Contains all valid links and reroutes. */ + #network: WeakRef + + #parentId?: RerouteId + /** @inheritdoc */ + public get parentId(): RerouteId { + return this.#parentId + } + + /** Ignores attempts to create an infinite loop. @inheritdoc */ + public set parentId(value: RerouteId) { + if (value === this.id) return + if (this.getReroutes() === null) return + this.#parentId = value + } + + #pos = this.#malloc.subarray(0, 2) + /** @inheritdoc */ + get pos(): Point { + return this.#pos + } + + set pos(value: Point) { + if (!(value?.length >= 2)) + throw new TypeError("Reroute.pos is an x,y point, and expects an indexable with at least two values.") + this.#pos[0] = value[0] + this.#pos[1] = value[1] + } + + /** @inheritdoc */ + get boundingRect(): ReadOnlyRect { + const { radius } = Reroute + const [x, y] = this.#pos + return [x - radius, y - radius, 2 * radius, 2 * radius] + } + + /** @inheritdoc */ + selected?: boolean + + /** The ID ({@link LLink.id}) of every link using this reroute */ + linkIds: Set + + /** The averaged angle of every link through this reroute. */ + otherAngle: number = 0 + + /** Cached cos */ + cos: number = 0 + sin: number = 0 + + /** Bezier curve control point for the "target" (input) side of the link */ + controlPoint: Point = this.#malloc.subarray(4, 6) + + /** @inheritdoc */ + path?: Path2D + /** @inheritdoc */ + _centreAngle?: number + /** @inheritdoc */ + _pos: Float32Array = this.#malloc.subarray(6, 8) + + /** Colour of the first link that rendered this reroute */ + _colour?: CanvasColour + + /** + * Used to ensure reroute angles are only executed once per frame. + * @todo Calculate on change instead. + */ + #lastRenderTime: number = -Infinity + #buffer: Point = this.#malloc.subarray(2, 4) + + /** @inheritdoc */ + get origin_id(): NodeId | undefined { + // if (!this.linkIds.size) return this.#network.deref()?.reroutes.get(this.parentId) + return this.#network.deref() + ?.links.get(this.linkIds.values().next().value) + ?.origin_id + } + + /** @inheritdoc */ + get origin_slot(): number | undefined { + return this.#network.deref() + ?.links.get(this.linkIds.values().next().value) + ?.origin_slot + } + + /** + * Initialises a new link reroute object. + * @param id Unique identifier for this reroute + * @param network The network of links this reroute belongs to. Internally converted to a WeakRef. + * @param pos Position in graph coordinates + * @param linkIds Link IDs ({@link LLink.id}) of all links that use this reroute + */ + constructor( + public readonly id: RerouteId, + network: LinkNetwork, + pos?: Point, + parentId?: RerouteId, + linkIds?: Iterable, + ) { + this.#network = new WeakRef(network) + this.update(parentId, pos, linkIds) + this.linkIds ??= new Set() + } + + /** + * Applies a new parentId to the reroute, and optinoally a new position and linkId. + * Primarily used for deserialisation. + * @param parentId The ID of the reroute prior to this reroute, or + * `undefined` if it is the first reroute connected to a nodes output + * @param pos The position of this reroute + * @param linkIds All link IDs that pass through this reroute + */ + update( + parentId: RerouteId | undefined, + pos?: Point, + linkIds?: Iterable, + ): void { + this.parentId = parentId + if (pos) this.pos = pos + if (linkIds) this.linkIds = new Set(linkIds) + } + + /** + * Validates the linkIds this reroute has. Removes broken links. + * @param links Collection of valid links + * @returns true if any links remain after validation + */ + validateLinks(links: Map): boolean { + const { linkIds } = this + for (const linkId of linkIds) { + if (!links.get(linkId)) linkIds.delete(linkId) } - - #pos = this.#malloc.subarray(0, 2) - /** @inheritdoc */ - get pos(): Point { - return this.#pos - } - set pos(value: Point) { - if (!(value?.length >= 2)) throw new TypeError("Reroute.pos is an x,y point, and expects an indexable with at least two values.") - this.#pos[0] = value[0] - this.#pos[1] = value[1] - } - - /** @inheritdoc */ - get boundingRect(): ReadOnlyRect { - const { radius } = Reroute - const [x, y] = this.#pos - return [x - radius, y - radius, 2 * radius, 2 * radius] + return linkIds.size > 0 + } + + /** + * Retrieves an ordered array of all reroutes from the node output. + * @param visited Internal. A set of reroutes that this function + * has already visited whilst recursing up the chain. + * @returns An ordered array of all reroutes from the node output to this reroute, inclusive. + * `null` if an infinite loop is detected. + * `undefined` if the reroute chain or {@link LinkNetwork} are invalid. + */ + getReroutes(visited = new Set()): Reroute[] | null | undefined { + // No parentId - last in the chain + if (this.#parentId === undefined) return [this] + // Invalid chain - looped + if (visited.has(this)) return null + visited.add(this) + + const parent = this.#network.deref()?.reroutes.get(this.#parentId) + // Invalid parent (or network) - drop silently to recover + if (!parent) { + this.#parentId = undefined + return [this] } - /** @inheritdoc */ - selected?: boolean - - /** The ID ({@link LLink.id}) of every link using this reroute */ - linkIds: Set - - /** The averaged angle of every link through this reroute. */ - otherAngle: number = 0 - - /** Cached cos */ - cos: number = 0 - sin: number = 0 - - /** Bezier curve control point for the "target" (input) side of the link */ - controlPoint: Point = this.#malloc.subarray(4, 6) - - /** @inheritdoc */ - path?: Path2D - /** @inheritdoc */ - _centreAngle?: number - /** @inheritdoc */ - _pos: Float32Array = this.#malloc.subarray(6, 8) - - /** Colour of the first link that rendered this reroute */ - _colour?: CanvasColour - - /** - * Used to ensure reroute angles are only executed once per frame. - * @todo Calculate on change instead. - */ - #lastRenderTime: number = -Infinity - #buffer: Point = this.#malloc.subarray(2, 4) - - /** @inheritdoc */ - get origin_id(): NodeId | undefined { - // if (!this.linkIds.size) return this.#network.deref()?.reroutes.get(this.parentId) - return this.#network.deref() - ?.links.get(this.linkIds.values().next().value) - ?.origin_id + const reroutes = parent.getReroutes(visited) + reroutes?.push(this) + return reroutes + } + + /** + * Internal. Called by {@link LLink.findNextReroute}. Not intended for use by itself. + * @param withParentId The rerouteId to look for + * @param visited A set of reroutes that have already been visited + * @returns The reroute that was found, `undefined` if no reroute was found, or `null` if an infinite loop was detected. + */ + findNextReroute( + withParentId: RerouteId, + visited = new Set(), + ): Reroute | null | undefined { + if (this.#parentId === withParentId) return this + if (visited.has(this)) return null + visited.add(this) + + return this.#network + .deref() + ?.reroutes.get(this.#parentId) + ?.findNextReroute(withParentId, visited) + } + + /** @inheritdoc */ + move(deltaX: number, deltaY: number) { + this.#pos[0] += deltaX + this.#pos[1] += deltaY + } + + /** @inheritdoc */ + snapToGrid(snapTo: number): boolean { + if (!snapTo) return false + + const { pos } = this + pos[0] = snapTo * Math.round(pos[0] / snapTo) + pos[1] = snapTo * Math.round(pos[1] / snapTo) + return true + } + + calculateAngle(lastRenderTime: number, network: LinkNetwork, linkStart: Point): void { + // Ensure we run once per render + if (!(lastRenderTime > this.#lastRenderTime)) return + this.#lastRenderTime = lastRenderTime + + const { links } = network + const { linkIds, id } = this + const angles: number[] = [] + let sum = 0 + for (const linkId of linkIds) { + const link = links.get(linkId) + // Remove the linkId or just ignore? + if (!link) continue + + const pos = LLink.findNextReroute(network, link, id)?.pos ?? + network.getNodeById(link.target_id) + ?.getConnectionPos(true, link.target_slot, this.#buffer) + if (!pos) continue + + // TODO: Store points/angles, check if changed, skip calcs. + const angle = Math.atan2(pos[1] - this.#pos[1], pos[0] - this.#pos[0]) + angles.push(angle) + sum += angle } - - /** @inheritdoc */ - get origin_slot(): number | undefined { - return this.#network.deref() - ?.links.get(this.linkIds.values().next().value) - ?.origin_slot - } - - /** - * Initialises a new link reroute object. - * @param id Unique identifier for this reroute - * @param network The network of links this reroute belongs to. Internally converted to a WeakRef. - * @param pos Position in graph coordinates - * @param linkIds Link IDs ({@link LLink.id}) of all links that use this reroute - */ - constructor( - public readonly id: RerouteId, - network: LinkNetwork, - pos?: Point, - parentId?: RerouteId, - linkIds?: Iterable - ) { - this.#network = new WeakRef(network) - this.update(parentId, pos, linkIds) - this.linkIds ??= new Set() - } - - /** - * Applies a new parentId to the reroute, and optinoally a new position and linkId. - * Primarily used for deserialisation. - * @param parentId The ID of the reroute prior to this reroute, or `undefined` if it is the first reroute connected to a nodes output - * @param pos The position of this reroute - * @param linkIds All link IDs that pass through this reroute - */ - update(parentId: RerouteId | undefined, pos?: Point, linkIds?: Iterable): void { - this.parentId = parentId - if (pos) this.pos = pos - if (linkIds) this.linkIds = new Set(linkIds) - } - - /** - * Validates the linkIds this reroute has. Removes broken links. - * @param links Collection of valid links - * @returns true if any links remain after validation - */ - validateLinks(links: Map): boolean { - const { linkIds } = this - for (const linkId of linkIds) { - if (!links.get(linkId)) linkIds.delete(linkId) - } - return linkIds.size > 0 + if (!angles.length) return + + sum /= angles.length + + const originToReroute = Math.atan2( + this.#pos[1] - linkStart[1], + this.#pos[0] - linkStart[0], + ) + let diff = (originToReroute - sum) * 0.5 + if (Math.abs(diff) > Math.PI * 0.5) diff += Math.PI + const dist = Math.min(80, distance(linkStart, this.#pos) * 0.25) + + // Store results + const originDiff = originToReroute - diff + const cos = Math.cos(originDiff) + const sin = Math.sin(originDiff) + + this.otherAngle = originDiff + this.cos = cos + this.sin = sin + this.controlPoint[0] = dist * -cos + this.controlPoint[1] = dist * -sin + return + } + + /** + * Renders the reroute on the canvas. + * @param ctx Canvas context to draw on + * @param colour Reroute colour (typically link colour) + * + * @remarks Leaves {@link ctx}.fillStyle, strokeStyle, and lineWidth dirty (perf.). + */ + draw(ctx: CanvasRenderingContext2D): void { + const { pos } = this + ctx.fillStyle = this._colour + ctx.beginPath() + ctx.arc(pos[0], pos[1], Reroute.radius, 0, 2 * Math.PI) + ctx.fill() + + ctx.lineWidth = 1 + ctx.strokeStyle = "rgb(0,0,0,0.5)" + ctx.stroke() + + ctx.fillStyle = "#ffffff55" + ctx.strokeStyle = "rgb(0,0,0,0.3)" + ctx.beginPath() + ctx.arc(pos[0], pos[1], 8, 0, 2 * Math.PI) + ctx.fill() + ctx.stroke() + + if (this.selected) { + ctx.strokeStyle = "#fff" + ctx.beginPath() + ctx.arc(pos[0], pos[1], 12, 0, 2 * Math.PI) + ctx.stroke() } - - /** - * Retrieves an ordered array of all reroutes from the node output. - * @param visited Internal. A set of reroutes that this function has already visited whilst recursing up the chain. - * @returns An ordered array of all reroutes from the node output to this reroute, inclusive. - * `null` if an infinite loop is detected. - * `undefined` if the reroute chain or {@link LinkNetwork} are invalid. - */ - getReroutes(visited = new Set()): Reroute[] | null | undefined { - // No parentId - last in the chain - if (this.#parentId === undefined) return [this] - // Invalid chain - looped - if (visited.has(this)) return null - visited.add(this) - - const parent = this.#network.deref()?.reroutes.get(this.#parentId) - // Invalid parent (or network) - drop silently to recover - if (!parent) { - this.#parentId = undefined - return [this] - } - - const reroutes = parent.getReroutes(visited) - reroutes?.push(this) - return reroutes - } - - /** - * Internal. Called by {@link LLink.findNextReroute}. Not intended for use by itself. - * @param withParentId The rerouteId to look for - * @param visited A set of reroutes that have already been visited - * @returns The reroute that was found, `undefined` if no reroute was found, or `null` if an infinite loop was detected. - */ - findNextReroute(withParentId: RerouteId, visited = new Set()): Reroute | null | undefined { - if (this.#parentId === withParentId) return this - if (visited.has(this)) return null - visited.add(this) - - return this.#network.deref() - ?.reroutes.get(this.#parentId) - ?.findNextReroute(withParentId, visited) - } - - /** @inheritdoc */ - move(deltaX: number, deltaY: number) { - this.#pos[0] += deltaX - this.#pos[1] += deltaY - } - - /** @inheritdoc */ - snapToGrid(snapTo: number): boolean { - if (!snapTo) return false - - const { pos } = this - pos[0] = snapTo * Math.round(pos[0] / snapTo) - pos[1] = snapTo * Math.round(pos[1] / snapTo) - return true - } - - calculateAngle(lastRenderTime: number, network: LinkNetwork, linkStart: Point): void { - // Ensure we run once per render - if (!(lastRenderTime > this.#lastRenderTime)) return - this.#lastRenderTime = lastRenderTime - - const { links } = network - const { linkIds, id } = this - const angles: number[] = [] - let sum = 0 - for (const linkId of linkIds) { - const link = links.get(linkId) - // Remove the linkId or just ignore? - if (!link) continue - - const pos = LLink.findNextReroute(network, link, id)?.pos ?? - network.getNodeById(link.target_id) - ?.getConnectionPos(true, link.target_slot, this.#buffer) - if (!pos) continue - - // TODO: Store points/angles, check if changed, skip calcs. - const angle = Math.atan2(pos[1] - this.#pos[1], pos[0] - this.#pos[0]) - angles.push(angle) - sum += angle - } - if (!angles.length) return - - sum /= angles.length - - const originToReroute = Math.atan2(this.#pos[1] - linkStart[1], this.#pos[0] - linkStart[0]) - let diff = (originToReroute - sum) * 0.5 - if (Math.abs(diff) > Math.PI * 0.5) diff += Math.PI - const dist = Math.min(80, distance(linkStart, this.#pos) * 0.25) - - // Store results - const originDiff = originToReroute - diff - const cos = Math.cos(originDiff) - const sin = Math.sin(originDiff) - - this.otherAngle = originDiff - this.cos = cos - this.sin = sin - this.controlPoint[0] = dist * -cos - this.controlPoint[1] = dist * -sin - return - } - - /** - * Renders the reroute on the canvas. - * @param ctx Canvas context to draw on - * @param colour Reroute colour (typically link colour) - * - * @remarks Leaves {@link ctx}.fillStyle, strokeStyle, and lineWidth dirty (perf.). - */ - draw(ctx: CanvasRenderingContext2D): void { - const { pos } = this - ctx.fillStyle = this._colour - ctx.beginPath() - ctx.arc(pos[0], pos[1], Reroute.radius, 0, 2 * Math.PI) - ctx.fill() - - ctx.lineWidth = 1 - ctx.strokeStyle = "rgb(0,0,0,0.5)" - ctx.stroke() - - ctx.fillStyle = "#ffffff55" - ctx.strokeStyle = "rgb(0,0,0,0.3)" - ctx.beginPath() - ctx.arc(pos[0], pos[1], 8, 0, 2 * Math.PI) - ctx.fill() - ctx.stroke() - - if (this.selected) { - ctx.strokeStyle = "#fff" - ctx.beginPath() - ctx.arc(pos[0], pos[1], 12, 0, 2 * Math.PI) - ctx.stroke() - } - } - - /** @inheritdoc */ - asSerialisable(): SerialisableReroute { - return { - id: this.id, - parentId: this.parentId, - pos: [this.pos[0], this.pos[1]], - linkIds: [...this.linkIds] - } + } + + /** @inheritdoc */ + asSerialisable(): SerialisableReroute { + return { + id: this.id, + parentId: this.parentId, + pos: [this.pos[0], this.pos[1]], + linkIds: [...this.linkIds], } + } } diff --git a/src/draw.ts b/src/draw.ts index 8a6e74ed..54ccf326 100644 --- a/src/draw.ts +++ b/src/draw.ts @@ -1,4 +1,4 @@ -import type { Vector2 } from "./litegraph"; +import type { Vector2 } from "./litegraph" import type { INodeSlot } from "./interfaces" import { LinkDirection, RenderShape } from "./types/globalEnums" @@ -42,44 +42,44 @@ export function drawSlot( do_stroke = false, highlight = false, }: { - label_color?: string; - label_position?: LabelPosition; - horizontal?: boolean; - low_quality?: boolean; - render_text?: boolean; - do_stroke?: boolean; - highlight?: boolean; - } = {} + label_color?: string + label_position?: LabelPosition + horizontal?: boolean + low_quality?: boolean + render_text?: boolean + do_stroke?: boolean + highlight?: boolean + } = {}, ) { // Save the current fillStyle and strokeStyle - const originalFillStyle = ctx.fillStyle; - const originalStrokeStyle = ctx.strokeStyle; - const originalLineWidth = ctx.lineWidth; + const originalFillStyle = ctx.fillStyle + const originalStrokeStyle = ctx.strokeStyle + const originalLineWidth = ctx.lineWidth - const slot_type = slot.type as SlotType; + const slot_type = slot.type as SlotType const slot_shape = ( slot_type === SlotType.Array ? SlotShape.Grid : slot.shape - ) as SlotShape; + ) as SlotShape - ctx.beginPath(); - let doStroke = do_stroke; - let doFill = true; + ctx.beginPath() + let doStroke = do_stroke + let doFill = true if (slot_type === SlotType.Event || slot_shape === SlotShape.Box) { if (horizontal) { - ctx.rect(pos[0] - 5 + 0.5, pos[1] - 8 + 0.5, 10, 14); + ctx.rect(pos[0] - 5 + 0.5, pos[1] - 8 + 0.5, 10, 14) } else { - ctx.rect(pos[0] - 6 + 0.5, pos[1] - 5 + 0.5, 14, 10); + ctx.rect(pos[0] - 6 + 0.5, pos[1] - 5 + 0.5, 14, 10) } } else if (slot_shape === SlotShape.Arrow) { - ctx.moveTo(pos[0] + 8, pos[1] + 0.5); - ctx.lineTo(pos[0] - 4, pos[1] + 6 + 0.5); - ctx.lineTo(pos[0] - 4, pos[1] - 6 + 0.5); - ctx.closePath(); + ctx.moveTo(pos[0] + 8, pos[1] + 0.5) + ctx.lineTo(pos[0] - 4, pos[1] + 6 + 0.5) + ctx.lineTo(pos[0] - 4, pos[1] - 6 + 0.5) + ctx.closePath() } else if (slot_shape === SlotShape.Grid) { - const gridSize = 3; - const cellSize = 2; - const spacing = 3; + const gridSize = 3 + const cellSize = 2 + const spacing = 3 for (let x = 0; x < gridSize; x++) { for (let y = 0; y < gridSize; y++) { @@ -87,59 +87,59 @@ export function drawSlot( pos[0] - 4 + x * spacing, pos[1] - 4 + y * spacing, cellSize, - cellSize - ); + cellSize, + ) } } - doStroke = false; + doStroke = false } else { // Default rendering for circle, hollow circle. if (low_quality) { - ctx.rect(pos[0] - 4, pos[1] - 4, 8, 8); + ctx.rect(pos[0] - 4, pos[1] - 4, 8, 8) } else { - let radius: number; + let radius: number if (slot_shape === SlotShape.HollowCircle) { - doFill = false; - doStroke = true; - ctx.lineWidth = 3; - ctx.strokeStyle = ctx.fillStyle; - radius = highlight ? 4 : 3; + doFill = false + doStroke = true + ctx.lineWidth = 3 + ctx.strokeStyle = ctx.fillStyle + radius = highlight ? 4 : 3 } else { // Normal circle - radius = highlight ? 5 : 4; + radius = highlight ? 5 : 4 } - ctx.arc(pos[0], pos[1], radius, 0, Math.PI * 2); + ctx.arc(pos[0], pos[1], radius, 0, Math.PI * 2) } } - if (doFill) ctx.fill(); - if (!low_quality && doStroke) ctx.stroke(); + if (doFill) ctx.fill() + if (!low_quality && doStroke) ctx.stroke() // render slot label if (render_text) { - const text = slot.label != null ? slot.label : slot.name; + const text = slot.label != null ? slot.label : slot.name if (text) { // TODO: Finish impl. Highlight text on mouseover unless we're connecting links. - ctx.fillStyle = label_color; + ctx.fillStyle = label_color if (label_position === LabelPosition.Right) { if (horizontal || slot.dir == LinkDirection.UP) { - ctx.fillText(text, pos[0], pos[1] - 10); + ctx.fillText(text, pos[0], pos[1] - 10) } else { - ctx.fillText(text, pos[0] + 10, pos[1] + 5); + ctx.fillText(text, pos[0] + 10, pos[1] + 5) } } else { if (horizontal || slot.dir == LinkDirection.DOWN) { - ctx.fillText(text, pos[0], pos[1] - 8); + ctx.fillText(text, pos[0], pos[1] - 8) } else { - ctx.fillText(text, pos[0] - 10, pos[1] + 5); + ctx.fillText(text, pos[0] - 10, pos[1] + 5) } } } } // Restore the original fillStyle and strokeStyle - ctx.fillStyle = originalFillStyle; - ctx.strokeStyle = originalStrokeStyle; - ctx.lineWidth = originalLineWidth; + ctx.fillStyle = originalFillStyle + ctx.strokeStyle = originalStrokeStyle + ctx.lineWidth = originalLineWidth } diff --git a/src/interfaces.ts b/src/interfaces.ts index 6a27f6d8..d4a7e8be 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -8,15 +8,15 @@ export type Dictionary = { [key: string]: T } /** Allows all properties to be null. The same as `Partial`, but adds null instead of undefined. */ export type NullableProperties = { - [P in keyof T]: T[P] | null + [P in keyof T]: T[P] | null } export type CanvasColour = string | CanvasGradient | CanvasPattern /** An object containing a set of child objects */ export interface Parent { - /** All objects owned by the parent object. */ - readonly children?: ReadonlySet + /** All objects owned by the parent object. */ + readonly children?: ReadonlySet } /** @@ -25,42 +25,42 @@ export interface Parent { * May contain other {@link Positionable} objects. */ export interface Positionable extends Parent { - id: NodeId | RerouteId | number - /** Position in graph coordinates. Default: 0,0 */ - pos: Point - /** true if this object is part of the selection, otherwise false. */ - selected?: boolean - - /** See {@link IPinnable.pinned} */ - readonly pinned?: boolean - - /** - * Adds a delta to the current position. - * @param deltaX X value to add to current position - * @param deltaY Y value to add to current position - * @param skipChildren If true, any child objects like group contents will not be moved - */ - move(deltaX: number, deltaY: number, skipChildren?: boolean): void - - /** - * Snaps this item to a grid. - * - * Position values are rounded to the nearest multiple of {@link snapTo}. - * @param snapTo The size of the grid to align to - * @returns `true` if it moved, or `false` if the snap was rejected (e.g. `pinned`) - */ - snapToGrid(snapTo: number): boolean - - /** - * Cached position & size as `x, y, width, height`. - * @readonly See {@link move} - */ - readonly boundingRect: ReadOnlyRect - - /** Called whenever the item is selected */ - onSelected?(): void - /** Called whenever the item is deselected */ - onDeselected?(): void + id: NodeId | RerouteId | number + /** Position in graph coordinates. Default: 0,0 */ + pos: Point + /** true if this object is part of the selection, otherwise false. */ + selected?: boolean + + /** See {@link IPinnable.pinned} */ + readonly pinned?: boolean + + /** + * Adds a delta to the current position. + * @param deltaX X value to add to current position + * @param deltaY Y value to add to current position + * @param skipChildren If true, any child objects like group contents will not be moved + */ + move(deltaX: number, deltaY: number, skipChildren?: boolean): void + + /** + * Snaps this item to a grid. + * + * Position values are rounded to the nearest multiple of {@link snapTo}. + * @param snapTo The size of the grid to align to + * @returns `true` if it moved, or `false` if the snap was rejected (e.g. `pinned`) + */ + snapToGrid(snapTo: number): boolean + + /** + * Cached position & size as `x, y, width, height`. + * @readonly See {@link move} + */ + readonly boundingRect: ReadOnlyRect + + /** Called whenever the item is selected */ + onSelected?(): void + /** Called whenever the item is deselected */ + onDeselected?(): void } /** @@ -69,52 +69,55 @@ export interface Positionable extends Parent { * Prevents the object being accidentally moved or resized by mouse interaction. */ export interface IPinnable { - pinned: boolean - pin(value?: boolean): void - unpin(): void + pinned: boolean + pin(value?: boolean): void + unpin(): void } /** * Contains a list of links, reroutes, and nodes. */ export interface LinkNetwork { - links: Map - reroutes: Map - getNodeById(id: NodeId): LGraphNode | null + links: Map + reroutes: Map + getNodeById(id: NodeId): LGraphNode | null } /** Contains a cached 2D canvas path and a centre point, with an optional forward angle. */ export interface LinkSegment { - /** Link / reroute ID */ - readonly id: LinkId | RerouteId - /** The {@link id} of the reroute that this segment starts from (output side), otherwise `undefined`. */ - readonly parentId?: RerouteId - - /** The last canvas 2D path that was used to render this segment */ - path?: Path2D - /** Centre point of the {@link path}. Calculated during render only - can be inaccurate */ - readonly _pos: Float32Array - /** Y-forward along the {@link path} from its centre point, in radians. `undefined` if using circles for link centres. Calculated during render only - can be inaccurate. */ - _centreAngle?: number - - /** Output node ID */ - readonly origin_id: NodeId - /** Output slot index */ - readonly origin_slot: number + /** Link / reroute ID */ + readonly id: LinkId | RerouteId + /** The {@link id} of the reroute that this segment starts from (output side), otherwise `undefined`. */ + readonly parentId?: RerouteId + + /** The last canvas 2D path that was used to render this segment */ + path?: Path2D + /** Centre point of the {@link path}. Calculated during render only - can be inaccurate */ + readonly _pos: Float32Array + /** Y-forward along the {@link path} from its centre point, in radians. + * `undefined` if using circles for link centres. + * Calculated during render only - can be inaccurate. + */ + _centreAngle?: number + + /** Output node ID */ + readonly origin_id: NodeId + /** Output slot index */ + readonly origin_slot: number } export interface IInputOrOutput { - // If an input, this will be defined - input?: INodeInputSlot - // If an output, this will be defined - output?: INodeOutputSlot + // If an input, this will be defined + input?: INodeInputSlot + // If an output, this will be defined + output?: INodeOutputSlot } export interface IFoundSlot extends IInputOrOutput { - // Slot index - slot: number - // Centre point of the rendered slot connection - link_pos: Point + // Slot index + slot: number + // Centre point of the rendered slot connection + link_pos: Point } /** A point represented as `[x, y]` co-ordinates */ @@ -133,13 +136,31 @@ export type Rect = ArRect | Float32Array | Float64Array export type Rect32 = Float32Array /** A point represented as `[x, y]` co-ordinates that will not be modified */ -export type ReadOnlyPoint = readonly [x: number, y: number] | ReadOnlyTypedArray | ReadOnlyTypedArray +export type ReadOnlyPoint = + | readonly [x: number, y: number] + | ReadOnlyTypedArray + | ReadOnlyTypedArray + /** A rectangle starting at top-left coordinates `[x, y, width, height]` that will not be modified */ -export type ReadOnlyRect = readonly [x: number, y: number, width: number, height: number] | ReadOnlyTypedArray | ReadOnlyTypedArray +export type ReadOnlyRect = + | readonly [x: number, y: number, width: number, height: number] + | ReadOnlyTypedArray + | ReadOnlyTypedArray + +type TypedArrays = + | Int8Array + | Uint8Array + | Uint8ClampedArray + | Int16Array + | Uint16Array + | Int32Array + | Uint32Array + | Float32Array + | Float64Array -type TypedArrays = Int8Array | Uint8Array | Uint8ClampedArray | Int16Array | Uint16Array | Int32Array | Uint32Array | Float32Array | Float64Array type TypedBigIntArrays = BigInt64Array | BigUint64Array -type ReadOnlyTypedArray = Omit +type ReadOnlyTypedArray = + Omit /** Union of property names that are of type Match */ export type KeysOfType = { [P in keyof T]: T[P] extends Match ? P : never }[keyof T] @@ -151,99 +172,105 @@ export type PickByType = { [P in keyof T]: Extract } export type MethodNames = KeysOfType any) | undefined> export interface IBoundaryNodes { - top: LGraphNode - right: LGraphNode - bottom: LGraphNode - left: LGraphNode + top: LGraphNode + right: LGraphNode + bottom: LGraphNode + left: LGraphNode } export type Direction = "top" | "bottom" | "left" | "right" export interface IOptionalSlotData { - content: string - value: TSlot - className?: string + content: string + value: TSlot + className?: string } export type ISlotType = number | string export interface INodeSlot { - name: string - type: ISlotType - dir?: LinkDirection - removable?: boolean - shape?: RenderShape - not_subgraph_input?: boolean - color_off?: CanvasColour - color_on?: CanvasColour - label?: string - locked?: boolean - nameLocked?: boolean - pos?: Point - widget?: unknown + name: string + type: ISlotType + dir?: LinkDirection + removable?: boolean + shape?: RenderShape + not_subgraph_input?: boolean + color_off?: CanvasColour + color_on?: CanvasColour + label?: string + locked?: boolean + nameLocked?: boolean + pos?: Point + widget?: unknown } export interface INodeFlags { - skip_repeated_outputs?: boolean - allow_interaction?: boolean - pinned?: boolean - collapsed?: boolean - /** Configuration setting for {@link LGraphNode.connectInputToOutput} */ - keepAllLinksOnBypass?: boolean + skip_repeated_outputs?: boolean + allow_interaction?: boolean + pinned?: boolean + collapsed?: boolean + /** Configuration setting for {@link LGraphNode.connectInputToOutput} */ + keepAllLinksOnBypass?: boolean } export interface INodeInputSlot extends INodeSlot { - link: LinkId | null - not_subgraph_input?: boolean + link: LinkId | null + not_subgraph_input?: boolean } export interface INodeOutputSlot extends INodeSlot { - links: LinkId[] | null - _data?: unknown - slot_index?: number - not_subgraph_output?: boolean + links: LinkId[] | null + _data?: unknown + slot_index?: number + not_subgraph_output?: boolean } /** Links */ export interface ConnectingLink extends IInputOrOutput { - node: LGraphNode - slot: number - pos: Point - direction?: LinkDirection - afterRerouteId?: RerouteId + node: LGraphNode + slot: number + pos: Point + direction?: LinkDirection + afterRerouteId?: RerouteId } interface IContextMenuBase { - title?: string - className?: string - callback?(value?: unknown, options?: unknown, event?: MouseEvent, previous_menu?: ContextMenu, node?: LGraphNode): void | boolean + title?: string + className?: string + callback?( + value?: unknown, + options?: unknown, + event?: MouseEvent, + previous_menu?: ContextMenu, + node?: LGraphNode, + ): void | boolean } /** ContextMenu */ export interface IContextMenuOptions extends IContextMenuBase { - ignore_item_callbacks?: boolean - parentMenu?: ContextMenu - event?: MouseEvent - extra?: unknown - scroll_speed?: number - left?: number - top?: number - scale?: number - node?: LGraphNode - autoopen?: boolean + ignore_item_callbacks?: boolean + parentMenu?: ContextMenu + event?: MouseEvent + extra?: unknown + scroll_speed?: number + left?: number + top?: number + scale?: number + node?: LGraphNode + autoopen?: boolean } export interface IContextMenuValue extends IContextMenuBase { - value?: string - content: string - has_submenu?: boolean - disabled?: boolean - submenu?: IContextMenuSubmenu - property?: string - type?: string - slot?: IFoundSlot + value?: string + content: string + has_submenu?: boolean + disabled?: boolean + submenu?: IContextMenuSubmenu + property?: string + type?: string + slot?: IFoundSlot } export interface IContextMenuSubmenu extends IContextMenuOptions { - options: ConstructorParameters[0] + options: ConstructorParameters[0] } diff --git a/src/litegraph.ts b/src/litegraph.ts index 624fbb46..a2176448 100644 --- a/src/litegraph.ts +++ b/src/litegraph.ts @@ -1,5 +1,25 @@ import type { Point, ConnectingLink } from "./interfaces" -import type { INodeSlot, INodeInputSlot, INodeOutputSlot, CanvasColour, Direction, IBoundaryNodes, IContextMenuOptions, IContextMenuValue, IFoundSlot, IInputOrOutput, INodeFlags, IOptionalSlotData, ISlotType, KeysOfType, MethodNames, PickByType, Rect, Rect32, Size } from "./interfaces" +import type { + INodeSlot, + INodeInputSlot, + INodeOutputSlot, + CanvasColour, + Direction, + IBoundaryNodes, + IContextMenuOptions, + IContextMenuValue, + IFoundSlot, + IInputOrOutput, + INodeFlags, + IOptionalSlotData, + ISlotType, + KeysOfType, + MethodNames, + PickByType, + Rect, + Rect32, + Size, +} from "./interfaces" import type { SlotShape, LabelPosition, SlotDirection, SlotType } from "./draw" import type { IWidget } from "./types/widgets" import type { RenderShape, TitleMode } from "./types/globalEnums" @@ -18,19 +38,53 @@ import { CurveEditor } from "./CurveEditor" import { LGraphBadge, BadgePosition } from "./LGraphBadge" export const LiteGraph = new LiteGraphGlobal() -export { LGraph, LGraphCanvas, LGraphCanvasState, DragAndScale, LGraphNode, LGraphGroup, LLink, ContextMenu, CurveEditor } -export { INodeSlot, INodeInputSlot, INodeOutputSlot, ConnectingLink, CanvasColour, Direction, IBoundaryNodes, IContextMenuOptions, IContextMenuValue, IFoundSlot, IInputOrOutput, INodeFlags, IOptionalSlotData, ISlotType, KeysOfType, MethodNames, PickByType, Rect, Rect32, Size } +export { + LGraph, + LGraphCanvas, + LGraphCanvasState, + DragAndScale, + LGraphNode, + LGraphGroup, + LLink, + ContextMenu, + CurveEditor, +} +export { + INodeSlot, + INodeInputSlot, + INodeOutputSlot, + ConnectingLink, + CanvasColour, + Direction, + IBoundaryNodes, + IContextMenuOptions, + IContextMenuValue, + IFoundSlot, + IInputOrOutput, + INodeFlags, + IOptionalSlotData, + ISlotType, + KeysOfType, + MethodNames, + PickByType, + Rect, + Rect32, + Size, +} export { IWidget } export { LGraphBadge, BadgePosition } export { SlotShape, LabelPosition, SlotDirection, SlotType } export { EaseFunction, LinkMarkerShape } from "./types/globalEnums" -export type { SerialisableGraph, SerialisableLLink } from "./types/serialisation" +export type { + SerialisableGraph, + SerialisableLLink, +} from "./types/serialisation" export { CanvasPointer } from "./CanvasPointer" export { createBounds } from "./measure" export function clamp(v: number, a: number, b: number): number { - return a > v ? a : b < v ? b : v -}; + return a > v ? a : b < v ? b : v +} // Load legacy polyfills loadPolyfills() @@ -44,68 +98,72 @@ export type Vector2 = Point export type Vector4 = [number, number, number, number] export interface IContextMenuItem { - content: string - callback?: ContextMenuEventListener - /** Used as innerHTML for extra child element */ - title?: string - disabled?: boolean - has_submenu?: boolean - submenu?: { - options: IContextMenuItem[] - } & IContextMenuOptions - className?: string + content: string + callback?: ContextMenuEventListener + /** Used as innerHTML for extra child element */ + title?: string + disabled?: boolean + has_submenu?: boolean + submenu?: { + options: IContextMenuItem[] + } & IContextMenuOptions + className?: string } export type ContextMenuEventListener = ( - value: IContextMenuItem, - options: IContextMenuOptions, - event: MouseEvent, - parentMenu: ContextMenu | undefined, - node: LGraphNode + value: IContextMenuItem, + options: IContextMenuOptions, + event: MouseEvent, + parentMenu: ContextMenu | undefined, + node: LGraphNode, ) => boolean | void export interface LinkReleaseContext { - node_to?: LGraphNode - node_from?: LGraphNode - slot_from: INodeSlot - type_filter_in?: string - type_filter_out?: string + node_to?: LGraphNode + node_from?: LGraphNode + slot_from: INodeSlot + type_filter_in?: string + type_filter_out?: string } export interface LinkReleaseContextExtended { - links: ConnectingLink[] + links: ConnectingLink[] } /** @deprecated Confirm no downstream consumers, then remove. */ -export type LiteGraphCanvasEventType = "empty-release" | "empty-double-click" | "group-double-click" +export type LiteGraphCanvasEventType = + | "empty-release" + | "empty-double-click" + | "group-double-click" -export interface LiteGraphCanvasEvent extends CustomEvent { } +export interface LiteGraphCanvasEvent extends CustomEvent {} -export interface LiteGraphCanvasGroupEvent extends CustomEvent<{ +export interface LiteGraphCanvasGroupEvent + extends CustomEvent<{ subType: "group-double-click" originalEvent: MouseEvent group: LGraphGroup -}> { } + }> {} /** https://github.com/jagenjo/litegraph.js/blob/master/guides/README.md#lgraphnode */ export interface LGraphNodeConstructor { - title?: string - type?: string - size?: Size - min_height?: number - slot_start_y?: number - widgets_info?: any - collapsable?: boolean - color?: string - bgcolor?: string - shape?: RenderShape - title_mode?: TitleMode - title_color?: string - title_text_color?: string - keepAllLinksOnBypass: boolean - nodeData: any - new(): T + title?: string + type?: string + size?: Size + min_height?: number + slot_start_y?: number + widgets_info?: any + collapsable?: boolean + color?: string + bgcolor?: string + shape?: RenderShape + title_mode?: TitleMode + title_color?: string + title_text_color?: string + keepAllLinksOnBypass: boolean + nodeData: any + new (): T } // End backwards compat diff --git a/src/measure.ts b/src/measure.ts index 66c2f943..6eae1935 100644 --- a/src/measure.ts +++ b/src/measure.ts @@ -1,4 +1,10 @@ -import type { Point, Positionable, ReadOnlyPoint, ReadOnlyRect, Rect } from "./interfaces" +import type { + Point, + Positionable, + ReadOnlyPoint, + ReadOnlyRect, + Rect, +} from "./interfaces" import { LinkDirection } from "./types/globalEnums" /** @@ -8,9 +14,9 @@ import { LinkDirection } from "./types/globalEnums" * @returns Distance between point {@link a} & {@link b} */ export function distance(a: ReadOnlyPoint, b: ReadOnlyPoint): number { - return Math.sqrt( - (b[0] - a[0]) * (b[0] - a[0]) + (b[1] - a[1]) * (b[1] - a[1]) - ) + return Math.sqrt( + (b[0] - a[0]) * (b[0] - a[0]) + (b[1] - a[1]) * (b[1] - a[1]), + ) } /** @@ -23,12 +29,12 @@ export function distance(a: ReadOnlyPoint, b: ReadOnlyPoint): number { * @returns Distance2 (squared) between point [{@link x1}, {@link y1}] & [{@link x2}, {@link y2}] */ export function dist2(x1: number, y1: number, x2: number, y2: number): number { - return ((x2 - x1) * (x2 - x1)) + ((y2 - y1) * (y2 - y1)) + return ((x2 - x1) * (x2 - x1)) + ((y2 - y1) * (y2 - y1)) } /** * Determines whether a point is inside a rectangle. - * + * * Otherwise identical to {@link isInsideRectangle}, it also returns `true` if `x` equals `left` or `y` equals `top`. * @param x Point x * @param y Point y @@ -38,11 +44,18 @@ export function dist2(x1: number, y1: number, x2: number, y2: number): number { * @param height Rect height * @returns `true` if the point is inside the rect, otherwise `false` */ -export function isInRectangle(x: number, y: number, left: number, top: number, width: number, height: number): boolean { - return x >= left - && x < left + width - && y >= top - && y < top + height +export function isInRectangle( + x: number, + y: number, + left: number, + top: number, + width: number, + height: number, +): boolean { + return x >= left && + x < left + width && + y >= top && + y < top + height } /** @@ -52,10 +65,10 @@ export function isInRectangle(x: number, y: number, left: number, top: number, w * @returns `true` if the point is inside the rect, otherwise `false` */ export function isPointInRect(point: ReadOnlyPoint, rect: ReadOnlyRect): boolean { - return point[0] >= rect[0] - && point[0] < rect[0] + rect[2] - && point[1] >= rect[1] - && point[1] < rect[1] + rect[3] + return point[0] >= rect[0] && + point[0] < rect[0] + rect[2] && + point[1] >= rect[1] && + point[1] < rect[1] + rect[3] } /** @@ -66,20 +79,20 @@ export function isPointInRect(point: ReadOnlyPoint, rect: ReadOnlyRect): boolean * @returns `true` if the point is inside the rect, otherwise `false` */ export function isInRect(x: number, y: number, rect: ReadOnlyRect): boolean { - return x >= rect[0] - && x < rect[0] + rect[2] - && y >= rect[1] - && y < rect[1] + rect[3] + return x >= rect[0] && + x < rect[0] + rect[2] && + y >= rect[1] && + y < rect[1] + rect[3] } /** * Determines whether a point (`x, y`) is inside a rectangle. - * + * * This is the original litegraph implementation. It returns `false` if `x` is equal to `left`, or `y` is equal to `top`. * @deprecated * Use {@link isInRectangle} to match inclusive of top left. * This function returns a false negative when an integer point (e.g. pixel) is on the leftmost or uppermost edge of a rectangle. - * + * * @param x Point x * @param y Point y * @param left Rect x @@ -88,11 +101,18 @@ export function isInRect(x: number, y: number, rect: ReadOnlyRect): boolean { * @param height Rect height * @returns `true` if the point is inside the rect, otherwise `false` */ -export function isInsideRectangle(x: number, y: number, left: number, top: number, width: number, height: number): boolean { - return left < x - && left + width > x - && top < y - && top + height > y +export function isInsideRectangle( + x: number, + y: number, + left: number, + top: number, + width: number, + height: number, +): boolean { + return left < x && + left + width > x && + top < y && + top + height > y } /** @@ -103,8 +123,8 @@ export function isInsideRectangle(x: number, y: number, left: number, top: numbe * @returns `true` if the point is roughly inside the octagon centred on 0,0 with specified radius */ export function isSortaInsideOctagon(x: number, y: number, radius: number): boolean { - const sum = Math.min(radius, Math.abs(x)) + Math.min(radius, Math.abs(y)) - return sum < radius * 0.75 + const sum = Math.min(radius, Math.abs(x)) + Math.min(radius, Math.abs(y)) + return sum < radius * 0.75 } /** @@ -114,17 +134,17 @@ export function isSortaInsideOctagon(x: number, y: number, radius: number): bool * @returns `true` if rectangles overlap, otherwise `false` */ export function overlapBounding(a: ReadOnlyRect, b: ReadOnlyRect): boolean { - const aRight = a[0] + a[2] - const aBottom = a[1] + a[3] - const bRight = b[0] + b[2] - const bBottom = b[1] + b[3] + const aRight = a[0] + a[2] + const aBottom = a[1] + a[3] + const bRight = b[0] + b[2] + const bBottom = b[1] + b[3] - return a[0] > bRight - || a[1] > bBottom - || aRight < b[0] - || aBottom < b[1] - ? false - : true + return a[0] > bRight || + a[1] > bBottom || + aRight < b[0] || + aBottom < b[1] + ? false + : true } /** @@ -134,9 +154,9 @@ export function overlapBounding(a: ReadOnlyRect, b: ReadOnlyRect): boolean { * @returns `true` if {@link a} contains most of {@link b}, otherwise `false` */ export function containsCentre(a: ReadOnlyRect, b: ReadOnlyRect): boolean { - const centreX = b[0] + (b[2] * 0.5) - const centreY = b[1] + (b[3] * 0.5) - return isInRect(centreX, centreY, a) + const centreX = b[0] + (b[2] * 0.5) + const centreY = b[1] + (b[3] * 0.5) + return isInRect(centreX, centreY, a) } /** @@ -146,21 +166,21 @@ export function containsCentre(a: ReadOnlyRect, b: ReadOnlyRect): boolean { * @returns `true` if {@link a} wholly contains {@link b}, otherwise `false` */ export function containsRect(a: ReadOnlyRect, b: ReadOnlyRect): boolean { - const aRight = a[0] + a[2] - const aBottom = a[1] + a[3] - const bRight = b[0] + b[2] - const bBottom = b[1] + b[3] + const aRight = a[0] + a[2] + const aBottom = a[1] + a[3] + const bRight = b[0] + b[2] + const bBottom = b[1] + b[3] - const identical = a[0] === b[0] - && a[1] === b[1] - && aRight === bRight - && aBottom === bBottom + const identical = a[0] === b[0] && + a[1] === b[1] && + aRight === bRight && + aBottom === bBottom - return !identical - && a[0] <= b[0] - && a[1] <= b[1] - && aRight >= bRight - && aBottom >= bBottom + return !identical && + a[0] <= b[0] && + a[1] <= b[1] && + aRight >= bRight && + aBottom >= bBottom } /** @@ -169,99 +189,117 @@ export function containsRect(a: ReadOnlyRect, b: ReadOnlyRect): boolean { * @param direction Direction to add the offset to * @param out The {@link Point} to add the offset to */ -export function addDirectionalOffset(amount: number, direction: LinkDirection, out: Point): void { - switch (direction) { - case LinkDirection.LEFT: - out[0] -= amount - return - case LinkDirection.RIGHT: - out[0] += amount - return - case LinkDirection.UP: - out[1] -= amount - return - case LinkDirection.DOWN: - out[1] += amount - return - // LinkDirection.CENTER: Nothing to do. - } +export function addDirectionalOffset( + amount: number, + direction: LinkDirection, + out: Point, +): void { + switch (direction) { + case LinkDirection.LEFT: + out[0] -= amount + return + case LinkDirection.RIGHT: + out[0] += amount + return + case LinkDirection.UP: + out[1] -= amount + return + case LinkDirection.DOWN: + out[1] += amount + return + // LinkDirection.CENTER: Nothing to do. + } } /** * Rotates an offset in 90° increments. * - * Swaps/flips axis values of a 2D vector offset - effectively rotating {@link offset} by 90° + * Swaps/flips axis values of a 2D vector offset - effectively rotating + * {@link offset} by 90° * @param offset The zero-based offset to rotate * @param from Direction to rotate from * @param to Direction to rotate to */ -export function rotateLink(offset: Point, from: LinkDirection, to: LinkDirection): void { - let x: number - let y: number +export function rotateLink( + offset: Point, + from: LinkDirection, + to: LinkDirection, +): void { + let x: number + let y: number - // Normalise to left - switch (from) { - case to: - case LinkDirection.CENTER: - case LinkDirection.NONE: - // Nothing to do - return + // Normalise to left + switch (from) { + case to: + case LinkDirection.CENTER: + case LinkDirection.NONE: + // Nothing to do + return - case LinkDirection.LEFT: - x = offset[0] - y = offset[1] - break - case LinkDirection.RIGHT: - x = -offset[0] - y = -offset[1] - break - case LinkDirection.UP: - x = -offset[1] - y = offset[0] - break - case LinkDirection.DOWN: - x = offset[1] - y = -offset[0] - break - } + case LinkDirection.LEFT: + x = offset[0] + y = offset[1] + break + case LinkDirection.RIGHT: + x = -offset[0] + y = -offset[1] + break + case LinkDirection.UP: + x = -offset[1] + y = offset[0] + break + case LinkDirection.DOWN: + x = offset[1] + y = -offset[0] + break + } - // Apply new direction - switch (to) { - case LinkDirection.CENTER: - case LinkDirection.NONE: - // Nothing to do - return + // Apply new direction + switch (to) { + case LinkDirection.CENTER: + case LinkDirection.NONE: + // Nothing to do + return - case LinkDirection.LEFT: - offset[0] = x - offset[1] = y - break - case LinkDirection.RIGHT: - offset[0] = -x - offset[1] = -y - break - case LinkDirection.UP: - offset[0] = y - offset[1] = -x - break - case LinkDirection.DOWN: - offset[0] = -y - offset[1] = x - break - } + case LinkDirection.LEFT: + offset[0] = x + offset[1] = y + break + case LinkDirection.RIGHT: + offset[0] = -x + offset[1] = -y + break + case LinkDirection.UP: + offset[0] = y + offset[1] = -x + break + case LinkDirection.DOWN: + offset[0] = -y + offset[1] = x + break + } } /** * Check if a point is to to the left or right of a line. - * Project a line from lineStart -> lineEnd. Determine if point is to the left or right of that projection. + * Project a line from lineStart -> lineEnd. Determine if point is to the left + * or right of that projection. * {@link https://www.geeksforgeeks.org/orientation-3-ordered-points/} * @param lineStart The start point of the line * @param lineEnd The end point of the line * @param point The point to check - * @returns 0 if all three points are in a straight line, a negative value if point is to the left of the projected line, or positive if the point is to the right + * @returns 0 if all three points are in a straight line, a negative value if + * point is to the left of the projected line, or positive if the point is to + * the right */ -export function getOrientation(lineStart: ReadOnlyPoint, lineEnd: ReadOnlyPoint, x: number, y: number): number { - return ((lineEnd[1] - lineStart[1]) * (x - lineEnd[0])) - ((lineEnd[0] - lineStart[0]) * (y - lineEnd[1])) +export function getOrientation( + lineStart: ReadOnlyPoint, + lineEnd: ReadOnlyPoint, + x: number, + y: number, +): number { + return ((lineEnd[1] - lineStart[1]) * (x - lineEnd[0])) - + ((lineEnd[0] - lineStart[0]) * (y - lineEnd[1])) } /** @@ -274,42 +312,45 @@ export function getOrientation(lineStart: ReadOnlyPoint, lineEnd: ReadOnlyPoint, * @param t Time: factor of distance to travel along the curve (e.g 0.25 is 25% along the curve) */ export function findPointOnCurve( - out: Point, - a: ReadOnlyPoint, - b: ReadOnlyPoint, - controlA: ReadOnlyPoint, - controlB: ReadOnlyPoint, - t: number = 0.5, + out: Point, + a: ReadOnlyPoint, + b: ReadOnlyPoint, + controlA: ReadOnlyPoint, + controlB: ReadOnlyPoint, + t: number = 0.5, ): void { - const iT = 1 - t + const iT = 1 - t - const c1 = iT * iT * iT - const c2 = 3 * (iT * iT) * t - const c3 = 3 * iT * (t * t) - const c4 = t * t * t + const c1 = iT * iT * iT + const c2 = 3 * (iT * iT) * t + const c3 = 3 * iT * (t * t) + const c4 = t * t * t - out[0] = (c1 * a[0]) + (c2 * controlA[0]) + (c3 * controlB[0]) + (c4 * b[0]) - out[1] = (c1 * a[1]) + (c2 * controlA[1]) + (c3 * controlB[1]) + (c4 * b[1]) + out[0] = (c1 * a[0]) + (c2 * controlA[0]) + (c3 * controlB[0]) + (c4 * b[0]) + out[1] = (c1 * a[1]) + (c2 * controlA[1]) + (c3 * controlB[1]) + (c4 * b[1]) } -export function createBounds(objects: Iterable, padding: number = 10): ReadOnlyRect | null { - const bounds = new Float32Array([Infinity, Infinity, -Infinity, -Infinity]) +export function createBounds( + objects: Iterable, + padding: number = 10, +): ReadOnlyRect | null { + const bounds = new Float32Array([Infinity, Infinity, -Infinity, -Infinity]) - for (const obj of objects) { - const rect = obj.boundingRect - bounds[0] = Math.min(bounds[0], rect[0]) - bounds[1] = Math.min(bounds[1], rect[1]) - bounds[2] = Math.max(bounds[2], rect[0] + rect[2]) - bounds[3] = Math.max(bounds[3], rect[1] + rect[3]) - } - if (!bounds.every(x => isFinite(x))) return null + for (const obj of objects) { + const rect = obj.boundingRect + bounds[0] = Math.min(bounds[0], rect[0]) + bounds[1] = Math.min(bounds[1], rect[1]) + bounds[2] = Math.max(bounds[2], rect[0] + rect[2]) + bounds[3] = Math.max(bounds[3], rect[1] + rect[3]) + } + if (!bounds.every(x => isFinite(x))) return null - return [ - bounds[0] - padding, - bounds[1] - padding, - bounds[2] - bounds[0] + (2 * padding), - bounds[3] - bounds[1] + (2 * padding) - ] + return [ + bounds[0] - padding, + bounds[1] - padding, + bounds[2] - bounds[0] + (2 * padding), + bounds[3] - bounds[1] + (2 * padding), + ] } /** @@ -320,9 +361,9 @@ export function createBounds(objects: Iterable, padding: number = * @remarks `NaN` propagates through this function and does not affect return value. */ export function snapPoint(pos: Point | Rect, snapTo: number): boolean { - if (!snapTo) return false + if (!snapTo) return false - pos[0] = snapTo * Math.round(pos[0] / snapTo) - pos[1] = snapTo * Math.round(pos[1] / snapTo) - return true + pos[0] = snapTo * Math.round(pos[0] / snapTo) + pos[1] = snapTo * Math.round(pos[1] / snapTo) + return true } diff --git a/src/polyfills.ts b/src/polyfills.ts index 758e3d1a..f73985ae 100644 --- a/src/polyfills.ts +++ b/src/polyfills.ts @@ -1,85 +1,85 @@ - -//API ************************************************* -//like rect but rounded corners +// API ************************************************* +// like rect but rounded corners export function loadPolyfills() { -if (typeof (window) != "undefined" && window.CanvasRenderingContext2D && !window.CanvasRenderingContext2D.prototype.roundRect) { + if ( + typeof window != "undefined" && + window.CanvasRenderingContext2D && + !window.CanvasRenderingContext2D.prototype.roundRect + ) { // @ts-expect-error Slightly broken polyfill - radius_low not impl. anywhere window.CanvasRenderingContext2D.prototype.roundRect = function ( - x, - y, - w, - h, - radius, - radius_low + x, + y, + w, + h, + radius, + radius_low, ) { - let top_left_radius = 0; - let top_right_radius = 0; - let bottom_left_radius = 0; - let bottom_right_radius = 0; + let top_left_radius = 0 + let top_right_radius = 0 + let bottom_left_radius = 0 + let bottom_right_radius = 0 - if (radius === 0) { - this.rect(x, y, w, h); - return; - } + if (radius === 0) { + this.rect(x, y, w, h) + return + } - if (radius_low === undefined) - radius_low = radius; + if (radius_low === undefined) radius_low = radius - //make it compatible with official one - if (radius != null && radius.constructor === Array) { - if (radius.length == 1) - top_left_radius = top_right_radius = bottom_left_radius = bottom_right_radius = radius[0]; - else if (radius.length == 2) { - top_left_radius = bottom_right_radius = radius[0]; - top_right_radius = bottom_left_radius = radius[1]; - } - else if (radius.length == 4) { - top_left_radius = radius[0]; - top_right_radius = radius[1]; - bottom_left_radius = radius[2]; - bottom_right_radius = radius[3]; - } - else - return; - } - else //old using numbers - { - top_left_radius = radius || 0; - top_right_radius = radius || 0; - bottom_left_radius = radius_low || 0; - bottom_right_radius = radius_low || 0; + // make it compatible with official one + if (radius != null && radius.constructor === Array) { + if (radius.length == 1) + top_left_radius = top_right_radius = bottom_left_radius = bottom_right_radius = radius[0] + else if (radius.length == 2) { + top_left_radius = bottom_right_radius = radius[0] + top_right_radius = bottom_left_radius = radius[1] + } else if (radius.length == 4) { + top_left_radius = radius[0] + top_right_radius = radius[1] + bottom_left_radius = radius[2] + bottom_right_radius = radius[3] + } else { + return } + } else { + // old using numbers + top_left_radius = radius || 0 + top_right_radius = radius || 0 + bottom_left_radius = radius_low || 0 + bottom_right_radius = radius_low || 0 + } - //top right - this.moveTo(x + top_left_radius, y); - this.lineTo(x + w - top_right_radius, y); - this.quadraticCurveTo(x + w, y, x + w, y + top_right_radius); + // top right + this.moveTo(x + top_left_radius, y) + this.lineTo(x + w - top_right_radius, y) + this.quadraticCurveTo(x + w, y, x + w, y + top_right_radius) - //bottom right - this.lineTo(x + w, y + h - bottom_right_radius); - this.quadraticCurveTo( - x + w, - y + h, - x + w - bottom_right_radius, - y + h - ); + // bottom right + this.lineTo(x + w, y + h - bottom_right_radius) + this.quadraticCurveTo( + x + w, + y + h, + x + w - bottom_right_radius, + y + h, + ) - //bottom left - this.lineTo(x + bottom_right_radius, y + h); - this.quadraticCurveTo(x, y + h, x, y + h - bottom_left_radius); + // bottom left + this.lineTo(x + bottom_right_radius, y + h) + this.quadraticCurveTo(x, y + h, x, y + h - bottom_left_radius) - //top left - this.lineTo(x, y + bottom_left_radius); - this.quadraticCurveTo(x, y, x + top_left_radius, y); - }; -}//if + // top left + this.lineTo(x, y + bottom_left_radius) + this.quadraticCurveTo(x, y, x + top_left_radius, y) + } + } // if -if (typeof window != "undefined" && !window["requestAnimationFrame"]) { + if (typeof window != "undefined" && !window["requestAnimationFrame"]) { window.requestAnimationFrame = - // @ts-expect-error Legacy code - window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame || - function (callback) { - window.setTimeout(callback, 1000 / 60); - }; + // @ts-expect-error Legacy code + window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame || + function (callback) { + window.setTimeout(callback, 1000 / 60) + } + } } -} \ No newline at end of file diff --git a/src/strings.ts b/src/strings.ts index 31699266..f966ab6e 100644 --- a/src/strings.ts +++ b/src/strings.ts @@ -4,7 +4,7 @@ * @returns String(value) or null */ export function stringOrNull(value: unknown): string | null { - return value == null ? null : String(value) + return value == null ? null : String(value) } /** @@ -13,5 +13,5 @@ export function stringOrNull(value: unknown): string | null { * @returns String(value) or "" */ export function stringOrEmpty(value: unknown): string { - return value == null ? "" : String(value) + return value == null ? "" : String(value) } diff --git a/src/types/events.ts b/src/types/events.ts index f35eab38..df7de853 100644 --- a/src/types/events.ts +++ b/src/types/events.ts @@ -9,77 +9,84 @@ import type { LGraphGroup } from "../LGraphGroup" /** For Canvas*Event - adds graph space co-ordinates (property names are shipped) */ export interface ICanvasPosition { - /** X co-ordinate of the event, in graph space (NOT canvas space) */ - canvasX: number - /** Y co-ordinate of the event, in graph space (NOT canvas space) */ - canvasY: number + /** X co-ordinate of the event, in graph space (NOT canvas space) */ + canvasX: number + /** Y co-ordinate of the event, in graph space (NOT canvas space) */ + canvasY: number } /** For Canvas*Event */ export interface IDeltaPosition { - deltaX: number - deltaY: number + deltaX: number + deltaY: number } interface LegacyMouseEvent { - /** @deprecated Part of DragAndScale mouse API - incomplete / not maintained */ - dragging?: boolean - click_time?: number + /** @deprecated Part of DragAndScale mouse API - incomplete / not maintained */ + dragging?: boolean + click_time?: number } /** PointerEvent with canvasX/Y and deltaX/Y properties */ -export interface CanvasPointerEvent extends PointerEvent, CanvasMouseEvent { } +export interface CanvasPointerEvent extends PointerEvent, CanvasMouseEvent {} /** MouseEvent with canvasX/Y and deltaX/Y properties */ -export interface CanvasMouseEvent extends MouseEvent, Readonly, Readonly, LegacyMouseEvent { } +export interface CanvasMouseEvent extends + MouseEvent, + Readonly, + Readonly, + LegacyMouseEvent {} /** DragEvent with canvasX/Y and deltaX/Y properties */ -export interface CanvasDragEvent extends DragEvent, ICanvasPosition, IDeltaPosition { } +export interface CanvasDragEvent extends + DragEvent, + ICanvasPosition, + IDeltaPosition {} export type CanvasEventDetail = - GenericEventDetail - | DragggingCanvasEventDetail - | ReadOnlyEventDetail - | GroupDoubleClickEventDetail - | EmptyDoubleClickEventDetail - | ConnectingWidgetLinkEventDetail - | EmptyReleaseEventDetail + | GenericEventDetail + | DragggingCanvasEventDetail + | ReadOnlyEventDetail + | GroupDoubleClickEventDetail + | EmptyDoubleClickEventDetail + | ConnectingWidgetLinkEventDetail + | EmptyReleaseEventDetail export interface GenericEventDetail { - subType: "before-change" | "after-change" + subType: "before-change" | "after-change" } export interface OriginalEvent { - originalEvent: CanvasPointerEvent, + originalEvent: CanvasPointerEvent } export interface EmptyReleaseEventDetail extends OriginalEvent { - subType: "empty-release", - linkReleaseContext: LinkReleaseContextExtended, + subType: "empty-release" + linkReleaseContext: LinkReleaseContextExtended } export interface ConnectingWidgetLinkEventDetail { - subType: "connectingWidgetLink" - link: ConnectingLink - node: LGraphNode - widget: IWidget + subType: "connectingWidgetLink" + link: ConnectingLink + node: LGraphNode + widget: IWidget } export interface EmptyDoubleClickEventDetail extends OriginalEvent { - subType: "empty-double-click" + subType: "empty-double-click" } export interface GroupDoubleClickEventDetail extends OriginalEvent { - subType: "group-double-click" - group: LGraphGroup + subType: "group-double-click" + group: LGraphGroup } export interface DragggingCanvasEventDetail { - subType: "dragging-canvas" - draggingCanvas: boolean + subType: "dragging-canvas" + draggingCanvas: boolean } export interface ReadOnlyEventDetail { - subType: "read-only" - readOnly: boolean + subType: "read-only" + readOnly: boolean } diff --git a/src/types/globalEnums.ts b/src/types/globalEnums.ts index f38d19fe..e61bd737 100644 --- a/src/types/globalEnums.ts +++ b/src/types/globalEnums.ts @@ -1,92 +1,92 @@ /** Node slot type - input or output */ export enum NodeSlotType { - INPUT = 1, - OUTPUT = 2, + INPUT = 1, + OUTPUT = 2, } /** Shape that an object will render as - used by nodes and slots */ export enum RenderShape { - /** Rectangle with square corners */ - BOX = 1, - /** Rounded rectangle */ - ROUND = 2, - /** Circle is circle */ - CIRCLE = 3, - /** Two rounded corners: top left & bottom right */ - CARD = 4, - /** Slot shape: Arrow */ - ARROW = 5, - /** Slot shape: Grid */ - GRID = 6, - /** Slot shape: Hollow circle */ - HollowCircle = 7, + /** Rectangle with square corners */ + BOX = 1, + /** Rounded rectangle */ + ROUND = 2, + /** Circle is circle */ + CIRCLE = 3, + /** Two rounded corners: top left & bottom right */ + CARD = 4, + /** Slot shape: Arrow */ + ARROW = 5, + /** Slot shape: Grid */ + GRID = 6, + /** Slot shape: Hollow circle */ + HollowCircle = 7, } /** Bit flags used to indicate what the pointer is currently hovering over. */ export enum CanvasItem { - /** No items / none */ - Nothing = 0, - /** At least one node */ - Node = 1 << 0, - /** At least one group */ - Group = 1 << 1, - /** A reroute (not its path) */ - Reroute = 1 << 2, - /** The path of a link */ - Link = 1 << 3, - /** A resize in the bottom-right corner */ - ResizeSe = 1 << 4, + /** No items / none */ + Nothing = 0, + /** At least one node */ + Node = 1 << 0, + /** At least one group */ + Group = 1 << 1, + /** A reroute (not its path) */ + Reroute = 1 << 2, + /** The path of a link */ + Link = 1 << 3, + /** A resize in the bottom-right corner */ + ResizeSe = 1 << 4, } /** The direction that a link point will flow towards - e.g. horizontal outputs are right by default */ export enum LinkDirection { - NONE = 0, - UP = 1, - DOWN = 2, - LEFT = 3, - RIGHT = 4, - CENTER = 5, + NONE = 0, + UP = 1, + DOWN = 2, + LEFT = 3, + RIGHT = 4, + CENTER = 5, } /** The path calculation that links follow */ export enum LinkRenderType { - HIDDEN_LINK = -1, - /** Juts out from the input & output a little @see LinkDirection, then a straight line between them */ - STRAIGHT_LINK = 0, - /** 90° angles, clean and box-like */ - LINEAR_LINK = 1, - /** Smooth curved links - default */ - SPLINE_LINK = 2, + HIDDEN_LINK = -1, + /** Juts out from the input & output a little @see LinkDirection, then a straight line between them */ + STRAIGHT_LINK = 0, + /** 90° angles, clean and box-like */ + LINEAR_LINK = 1, + /** Smooth curved links - default */ + SPLINE_LINK = 2, } /** The marker in the middle of a link */ export enum LinkMarkerShape { - /** Do not display markers */ - None = 0, - /** Circles (default) */ - Circle = 1, - /** Directional arrows */ - Arrow = 2, + /** Do not display markers */ + None = 0, + /** Circles (default) */ + Circle = 1, + /** Directional arrows */ + Arrow = 2, } export enum TitleMode { - NORMAL_TITLE = 0, - NO_TITLE = 1, - TRANSPARENT_TITLE = 2, - AUTOHIDE_TITLE = 3, + NORMAL_TITLE = 0, + NO_TITLE = 1, + TRANSPARENT_TITLE = 2, + AUTOHIDE_TITLE = 3, } export enum LGraphEventMode { - ALWAYS = 0, - ON_EVENT = 1, - NEVER = 2, - ON_TRIGGER = 3, - BYPASS = 4, + ALWAYS = 0, + ON_EVENT = 1, + NEVER = 2, + ON_TRIGGER = 3, + BYPASS = 4, } export enum EaseFunction { - LINEAR = "linear", - EASE_IN_QUAD = "easeInQuad", - EASE_OUT_QUAD = "easeOutQuad", - EASE_IN_OUT_QUAD = "easeInOutQuad", + LINEAR = "linear", + EASE_IN_QUAD = "easeInQuad", + EASE_OUT_QUAD = "easeOutQuad", + EASE_IN_OUT_QUAD = "easeInOutQuad", } diff --git a/src/types/serialisation.ts b/src/types/serialisation.ts index 3ae64f08..99682d73 100644 --- a/src/types/serialisation.ts +++ b/src/types/serialisation.ts @@ -1,4 +1,12 @@ -import type { ISlotType, Dictionary, INodeFlags, INodeInputSlot, INodeOutputSlot, Point, Size } from "../interfaces" +import type { + ISlotType, + Dictionary, + INodeFlags, + INodeInputSlot, + INodeOutputSlot, + Point, + Size, +} from "../interfaces" import type { LGraph, LGraphState } from "../LGraph" import type { IGraphGroupFlags, LGraphGroup } from "../LGraphGroup" import type { LGraphNode, NodeId } from "../LGraphNode" @@ -12,45 +20,45 @@ import type { RenderShape } from "./globalEnums" * An object that implements custom pre-serialization logic via {@link Serialisable.asSerialisable}. */ export interface Serialisable { - /** - * Prepares this object for serialization. - * Creates a partial shallow copy of itself, with only the properties that should be serialised. - * @returns An object that can immediately be serialized to JSON. - */ - asSerialisable(): SerialisableObject + /** + * Prepares this object for serialization. + * Creates a partial shallow copy of itself, with only the properties that should be serialised. + * @returns An object that can immediately be serialized to JSON. + */ + asSerialisable(): SerialisableObject } export interface SerialisableGraph { - /** Schema version. @remarks Version bump should add to const union, which is used to narrow type during deserialise. */ - version: 0 | 1 - config: LGraph["config"] - state: LGraphState - groups?: ISerialisedGroup[] - nodes?: ISerialisedNode[] - links?: SerialisableLLink[] - reroutes?: SerialisableReroute[] - extra?: Record + /** Schema version. @remarks Version bump should add to const union, which is used to narrow type during deserialise. */ + version: 0 | 1 + config: LGraph["config"] + state: LGraphState + groups?: ISerialisedGroup[] + nodes?: ISerialisedNode[] + links?: SerialisableLLink[] + reroutes?: SerialisableReroute[] + extra?: Record } /** Serialised LGraphNode */ export interface ISerialisedNode { - title?: string - id: NodeId - type?: string - pos?: Point - size?: Size - flags?: INodeFlags - order?: number - mode?: number - outputs?: INodeOutputSlot[] - inputs?: INodeInputSlot[] - properties?: Dictionary - shape?: RenderShape - boxcolor?: string - color?: string - bgcolor?: string - showAdvanced?: boolean - widgets_values?: TWidgetValue[] + title?: string + id: NodeId + type?: string + pos?: Point + size?: Size + flags?: INodeFlags + order?: number + mode?: number + outputs?: INodeOutputSlot[] + inputs?: INodeInputSlot[] + properties?: Dictionary + shape?: RenderShape + boxcolor?: string + color?: string + bgcolor?: string + showAdvanced?: boolean + widgets_values?: TWidgetValue[] } /** @@ -58,66 +66,72 @@ export interface ISerialisedNode { * Maintained for backwards compat */ export type ISerialisedGraph< - TNode = ReturnType, - TLink = ReturnType, - TGroup = ReturnType + TNode = ReturnType, + TLink = ReturnType, + TGroup = ReturnType, > = { - last_node_id: NodeId - last_link_id: number - nodes: TNode[] - links: TLink[] - groups: TGroup[] - config: LGraph["config"] - version: typeof LiteGraph.VERSION - extra?: Record + last_node_id: NodeId + last_link_id: number + nodes: TNode[] + links: TLink[] + groups: TGroup[] + config: LGraph["config"] + version: typeof LiteGraph.VERSION + extra?: Record } /** Serialised LGraphGroup */ export interface ISerialisedGroup { - id: number - title: string - bounding: number[] - color: string - font_size: number - flags?: IGraphGroupFlags + id: number + title: string + bounding: number[] + color: string + font_size: number + flags?: IGraphGroupFlags } -export type TClipboardLink = [targetRelativeIndex: number, originSlot: number, nodeRelativeIndex: number, targetSlot: number, targetNodeId: NodeId] +export type TClipboardLink = [ + targetRelativeIndex: number, + originSlot: number, + nodeRelativeIndex: number, + targetSlot: number, + targetNodeId: NodeId, +] /** Items copied from the canvas */ export interface ClipboardItems { - nodes?: ISerialisedNode[] - groups?: ISerialisedGroup[] - reroutes?: SerialisableReroute[] - links?: SerialisableLLink[] + nodes?: ISerialisedNode[] + groups?: ISerialisedGroup[] + reroutes?: SerialisableReroute[] + links?: SerialisableLLink[] } /** */ export interface IClipboardContents { - nodes?: ISerialisedNode[] - links?: TClipboardLink[] + nodes?: ISerialisedNode[] + links?: TClipboardLink[] } export interface SerialisableReroute { - id: RerouteId - parentId?: RerouteId - pos: Point - linkIds: LinkId[] + id: RerouteId + parentId?: RerouteId + pos: Point + linkIds: LinkId[] } export interface SerialisableLLink { - /** Link ID */ - id: LinkId - /** Output node ID */ - origin_id: NodeId - /** Output slot index */ - origin_slot: number - /** Input node ID */ - target_id: NodeId - /** Input slot index */ - target_slot: number - /** Data type of the link */ - type: ISlotType - /** ID of the last reroute (from input to output) that this link passes through, otherwise `undefined` */ - parentId?: RerouteId + /** Link ID */ + id: LinkId + /** Output node ID */ + origin_id: NodeId + /** Output slot index */ + origin_slot: number + /** Input node ID */ + target_id: NodeId + /** Input slot index */ + target_slot: number + /** Data type of the link */ + type: ISlotType + /** ID of the last reroute (from input to output) that this link passes through, otherwise `undefined` */ + parentId?: RerouteId } diff --git a/src/types/widgets.ts b/src/types/widgets.ts index 03c3c3c1..c42394a9 100644 --- a/src/types/widgets.ts +++ b/src/types/widgets.ts @@ -3,26 +3,26 @@ import type { LGraphCanvas, LGraphNode } from "../litegraph" import type { CanvasMouseEvent } from "./events" export interface IWidgetOptions extends Record { - on?: string - off?: string - max?: number - min?: number - slider_color?: CanvasColour - marker_color?: CanvasColour - precision?: number - read_only?: boolean - step?: number - y?: number - multiline?: boolean - // TODO: Confirm this - property?: string - - hasOwnProperty?(arg0: string): any - // values?(widget?: IWidget, node?: LGraphNode): any - values?: TValue[] - callback?: IWidget["callback"] - - onHide?(widget: IWidget): void + on?: string + off?: string + max?: number + min?: number + slider_color?: CanvasColour + marker_color?: CanvasColour + precision?: number + read_only?: boolean + step?: number + y?: number + multiline?: boolean + // TODO: Confirm this + property?: string + + hasOwnProperty?(arg0: string): any + // values?(widget?: IWidget, node?: LGraphNode): any + values?: TValue[] + callback?: IWidget["callback"] + + onHide?(widget: IWidget): void } /** @@ -34,51 +34,60 @@ export interface IWidgetOptions extends Record + type?: "combo" + value: string | number + options: IWidgetOptions } export type IStringWidgetType = IStringWidget["type"] | IMultilineStringWidget["type"] /** A widget with a string value */ export interface IStringWidget extends IBaseWidget { - type?: "string" | "text" | "button" - value: string + type?: "string" | "text" | "button" + value: string } /** A widget with a string value and a multiline text input */ -export interface IMultilineStringWidget extends IBaseWidget { - type?: "multiline" - value: string +export interface IMultilineStringWidget extends + IBaseWidget { - /** HTML textarea element */ - element?: TElement + type?: "multiline" + value: string + + /** HTML textarea element */ + element?: TElement } /** A custom widget - accepts any value and has no built-in special handling */ -export interface ICustomWidget extends IBaseWidget { - type?: "custom" - value: string | object +export interface ICustomWidget extends + IBaseWidget { - element?: TElement -} + type?: "custom" + value: string | object + element?: TElement +} /** * Valid widget types. TS cannot provide easily extensible type safety for this at present. @@ -93,35 +102,47 @@ export type TWidgetValue = IWidget["value"] * @see IWidget */ export interface IBaseWidget { - linkedWidgets?: IWidget[] - - options: IWidgetOptions - marker?: number - label?: string - clicked?: boolean - name?: string - /** Widget type (see {@link TWidgetType}) */ - type?: TWidgetType - value?: TWidgetValue - y?: number - last_y?: number - width?: number - disabled?: boolean - - hidden?: boolean - advanced?: boolean - - tooltip?: string - - /** HTML widget element */ - element?: TElement - - // TODO: Confirm this format - callback?(value: any, canvas?: LGraphCanvas, node?: LGraphNode, pos?: Point, e?: CanvasMouseEvent): void - onRemove?(): void - beforeQueued?(): void - - mouse?(event: CanvasMouseEvent, arg1: number[], node: LGraphNode): boolean - draw?(ctx: CanvasRenderingContext2D, node: LGraphNode, widget_width: number, y: number, H: number): void - computeSize?(width: number): Size + linkedWidgets?: IWidget[] + + options: IWidgetOptions + marker?: number + label?: string + clicked?: boolean + name?: string + /** Widget type (see {@link TWidgetType}) */ + type?: TWidgetType + value?: TWidgetValue + y?: number + last_y?: number + width?: number + disabled?: boolean + + hidden?: boolean + advanced?: boolean + + tooltip?: string + + /** HTML widget element */ + element?: TElement + + // TODO: Confirm this format + callback?( + value: any, + canvas?: LGraphCanvas, + node?: LGraphNode, + pos?: Point, + e?: CanvasMouseEvent, + ): void + onRemove?(): void + beforeQueued?(): void + + mouse?(event: CanvasMouseEvent, arg1: number[], node: LGraphNode): boolean + draw?( + ctx: CanvasRenderingContext2D, + node: LGraphNode, + widget_width: number, + y: number, + H: number, + ): void + computeSize?(width: number): Size } diff --git a/src/utils/arrange.ts b/src/utils/arrange.ts index c42cb7bf..1088ae42 100644 --- a/src/utils/arrange.ts +++ b/src/utils/arrange.ts @@ -4,34 +4,35 @@ import type { LGraphNode } from "../LGraphNode" /** * Finds the nodes that are farthest in all four directions, representing the boundary of the nodes. * @param nodes The nodes to check the edges of - * @returns An object listing the furthest node (edge) in all four directions. `null` if no nodes were supplied or the first node was falsy. + * @returns An object listing the furthest node (edge) in all four directions. + * `null` if no nodes were supplied or the first node was falsy. */ export function getBoundaryNodes(nodes: LGraphNode[]): IBoundaryNodes | null { - const valid = nodes?.find(x => x) - if (!valid) return null + const valid = nodes?.find(x => x) + if (!valid) return null - let top = valid - let right = valid - let bottom = valid - let left = valid + let top = valid + let right = valid + let bottom = valid + let left = valid - for (const node of nodes) { - if (!node) continue - const [x, y] = node.pos - const [width, height] = node.size + for (const node of nodes) { + if (!node) continue + const [x, y] = node.pos + const [width, height] = node.size - if (y < top.pos[1]) top = node - if (x + width > right.pos[0] + right.size[0]) right = node - if (y + height > bottom.pos[1] + bottom.size[1]) bottom = node - if (x < left.pos[0]) left = node - } + if (y < top.pos[1]) top = node + if (x + width > right.pos[0] + right.size[0]) right = node + if (y + height > bottom.pos[1] + bottom.size[1]) bottom = node + if (x < left.pos[0]) left = node + } - return { - top, - right, - bottom, - left - } + return { + top, + right, + bottom, + left, + } } /** @@ -40,30 +41,30 @@ export function getBoundaryNodes(nodes: LGraphNode[]): IBoundaryNodes | null { * @param horizontal If true, distributes along the horizontal plane. Otherwise, the vertical plane. */ export function distributeNodes(nodes: LGraphNode[], horizontal?: boolean): void { - const nodeCount = nodes?.length - if (!(nodeCount > 1)) return + const nodeCount = nodes?.length + if (!(nodeCount > 1)) return - const index = horizontal ? 0 : 1 + const index = horizontal ? 0 : 1 - let total = 0 - let highest = -Infinity + let total = 0 + let highest = -Infinity - for (const node of nodes) { - total += node.size[index] + for (const node of nodes) { + total += node.size[index] - const high = node.pos[index] + node.size[index] - if (high > highest) highest = high - } - const sorted = [...nodes].sort((a, b) => a.pos[index] - b.pos[index]) - const lowest = sorted[0].pos[index] + const high = node.pos[index] + node.size[index] + if (high > highest) highest = high + } + const sorted = [...nodes].sort((a, b) => a.pos[index] - b.pos[index]) + const lowest = sorted[0].pos[index] - const gap = ((highest - lowest) - total) / (nodeCount - 1) - let startAt = lowest - for (let i = 0; i < nodeCount; i++) { - const node = sorted[i] - node.pos[index] = startAt + (gap * i) - startAt += node.size[index] - } + const gap = (highest - lowest - total) / (nodeCount - 1) + let startAt = lowest + for (let i = 0; i < nodeCount; i++) { + const node = sorted[i] + node.pos[index] = startAt + gap * i + startAt += node.size[index] + } } /** @@ -72,34 +73,33 @@ export function distributeNodes(nodes: LGraphNode[], horizontal?: boolean): void * @param direction The edge to align nodes on * @param align_to The node to align all other nodes to. If undefined, the farthest node will be used. */ -export function alignNodes(nodes: LGraphNode[], direction: Direction, align_to?: LGraphNode): void { - if (!nodes) return +export function alignNodes( + nodes: LGraphNode[], + direction: Direction, + align_to?: LGraphNode, +): void { + if (!nodes) return - const boundary = align_to === undefined - ? getBoundaryNodes(nodes) - : { - top: align_to, - right: align_to, - bottom: align_to, - left: align_to - } + const boundary = align_to === undefined + ? getBoundaryNodes(nodes) + : { top: align_to, right: align_to, bottom: align_to, left: align_to } - if (boundary === null) return + if (boundary === null) return - for (const node of nodes) { - switch (direction) { - case "right": - node.pos[0] = boundary.right.pos[0] + boundary.right.size[0] - node.size[0] - break - case "left": - node.pos[0] = boundary.left.pos[0] - break - case "top": - node.pos[1] = boundary.top.pos[1] - break - case "bottom": - node.pos[1] = boundary.bottom.pos[1] + boundary.bottom.size[1] - node.size[1] - break - } + for (const node of nodes) { + switch (direction) { + case "right": + node.pos[0] = boundary.right.pos[0] + boundary.right.size[0] - node.size[0] + break + case "left": + node.pos[0] = boundary.left.pos[0] + break + case "top": + node.pos[1] = boundary.top.pos[1] + break + case "bottom": + node.pos[1] = boundary.bottom.pos[1] + boundary.bottom.size[1] - node.size[1] + break } + } } diff --git a/src/utils/collections.ts b/src/utils/collections.ts index 8a25ff0f..eef5946a 100644 --- a/src/utils/collections.ts +++ b/src/utils/collections.ts @@ -3,21 +3,21 @@ import { LGraphNode } from "@/LGraphNode" /** * Creates a flat set of all positionable items by recursively iterating through all child items. - * + * * Does not include or recurse into pinned items. * @param items The original set of items to iterate through * @returns All unpinned items in the original set, and recursively, their children */ export function getAllNestedItems(items: ReadonlySet): Set { - const allItems = new Set() - items?.forEach(x => addRecursively(x, allItems)) - return allItems + const allItems = new Set() + items?.forEach(x => addRecursively(x, allItems)) + return allItems - function addRecursively(item: Positionable, flatSet: Set): void { - if (flatSet.has(item) || item.pinned) return - flatSet.add(item) - item.children?.forEach(x => addRecursively(x, flatSet)) - } + function addRecursively(item: Positionable, flatSet: Set): void { + if (flatSet.has(item) || item.pinned) return + flatSet.add(item) + item.children?.forEach(x => addRecursively(x, flatSet)) + } } /** @@ -26,7 +26,7 @@ export function getAllNestedItems(items: ReadonlySet): Set): LGraphNode | undefined { - for (const item of items) { - if (item instanceof LGraphNode) return item - } + for (const item of items) { + if (item instanceof LGraphNode) return item + } } diff --git a/vite.config.mts b/vite.config.mts index 062df1e6..34399ddf 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -1,18 +1,18 @@ /// -import { defineConfig } from 'vite' -import path from 'path' -import dts from 'vite-plugin-dts' +import { defineConfig } from "vite" +import path from "path" +import dts from "vite-plugin-dts" export default defineConfig({ build: { lib: { - entry: path.resolve(__dirname, 'src/litegraph'), - name: 'litegraph.js', - fileName: (format) => `litegraph.${format}.js`, - formats: ['es', 'umd'] + entry: path.resolve(__dirname, "src/litegraph"), + name: "litegraph.js", + fileName: format => `litegraph.${format}.js`, + formats: ["es", "umd"], }, sourcemap: true, - target: ['es2022'], + target: ["es2022"], }, esbuild: { minifyIdentifiers: false, @@ -20,18 +20,18 @@ export default defineConfig({ }, plugins: [ dts({ - entryRoot: 'src', + entryRoot: "src", insertTypesEntry: true, - include: ['src/**/*.ts'], - outDir: 'dist', - aliasesExclude: ['@'], + include: ["src/**/*.ts"], + outDir: "dist", + aliasesExclude: ["@"], }), ], resolve: { - alias: { '@': '/src' }, + alias: { "@": "/src" }, }, test: { - alias: { '@/': path.resolve(__dirname, './src/') }, - environment: 'jsdom', + alias: { "@/": path.resolve(__dirname, "./src/") }, + environment: "jsdom", }, })