From 6a62854e7dc1ef3836ffd69135a40c9e62e5d0fc Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 28 Aug 2024 21:32:10 +1000 Subject: [PATCH] feat(ui): migrate add node popover to cmdk Put this together as a way to figure out the library before moving on to the full app cmdk. Works great. --- invokeai/frontend/web/package.json | 2 +- invokeai/frontend/web/pnpm-lock.yaml | 329 +++++++++++++- .../features/nodes/components/NodeEditor.tsx | 4 +- .../flow/AddNodeCmdk/AddNodeCmdk.tsx | 420 ++++++++++++++++++ .../flow/AddNodePopover/AddNodePopover.tsx | 267 ----------- .../features/nodes/components/flow/Flow.tsx | 4 +- .../flow/panels/TopPanel/AddNodeButton.tsx | 5 +- .../src/features/nodes/hooks/useConnection.ts | 4 +- .../src/features/nodes/store/nodesSlice.ts | 11 +- 9 files changed, 755 insertions(+), 291 deletions(-) create mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/AddNodeCmdk/AddNodeCmdk.tsx delete mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/AddNodePopover/AddNodePopover.tsx diff --git a/invokeai/frontend/web/package.json b/invokeai/frontend/web/package.json index 4fdb81ea67..4e98e28770 100644 --- a/invokeai/frontend/web/package.json +++ b/invokeai/frontend/web/package.json @@ -64,6 +64,7 @@ "@roarr/browser-log-writer": "^1.3.0", "async-mutex": "^0.5.0", "chakra-react-select": "^4.9.1", + "cmdk": "^1.0.0", "compare-versions": "^6.1.1", "dateformat": "^5.0.3", "fracturedjsonjs": "^4.0.2", @@ -92,7 +93,6 @@ "react-icons": "^5.2.1", "react-redux": "9.1.2", "react-resizable-panels": "^2.0.23", - "react-select": "5.8.0", "react-use": "^17.5.1", "react-virtuoso": "^4.9.0", "reactflow": "^11.11.4", diff --git a/invokeai/frontend/web/pnpm-lock.yaml b/invokeai/frontend/web/pnpm-lock.yaml index a487b8d6f3..2dad5456f4 100644 --- a/invokeai/frontend/web/pnpm-lock.yaml +++ b/invokeai/frontend/web/pnpm-lock.yaml @@ -41,6 +41,9 @@ dependencies: chakra-react-select: specifier: ^4.9.1 version: 4.9.1(@chakra-ui/form-control@2.2.0)(@chakra-ui/icon@3.2.0)(@chakra-ui/layout@2.3.1)(@chakra-ui/media-query@3.3.0)(@chakra-ui/menu@2.2.1)(@chakra-ui/spinner@2.1.0)(@chakra-ui/system@2.6.2)(@emotion/react@11.13.3)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1) + cmdk: + specifier: ^1.0.0 + version: 1.0.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1) compare-versions: specifier: ^6.1.1 version: 6.1.1 @@ -125,9 +128,6 @@ dependencies: react-resizable-panels: specifier: ^2.0.23 version: 2.0.23(react-dom@18.3.1)(react@18.3.1) - react-select: - specifier: 5.8.0 - version: 5.8.0(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1) react-use: specifier: ^17.5.1 version: 17.5.1(react-dom@18.3.1)(react@18.3.1) @@ -2053,7 +2053,7 @@ packages: dependencies: '@chakra-ui/dom-utils': 2.1.0 react: 18.3.1 - react-focus-lock: 2.12.1(@types/react@18.3.3)(react@18.3.1) + react-focus-lock: 2.13.0(@types/react@18.3.3)(react@18.3.1) transitivePeerDependencies: - '@types/react' dev: false @@ -3784,6 +3784,288 @@ packages: resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} dev: false + /@radix-ui/primitive@1.0.1: + resolution: {integrity: sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==} + dependencies: + '@babel/runtime': 7.25.4 + dev: false + + /@radix-ui/react-compose-refs@1.0.1(@types/react@18.3.3)(react@18.3.1): + resolution: {integrity: sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.25.4 + '@types/react': 18.3.3 + react: 18.3.1 + dev: false + + /@radix-ui/react-context@1.0.1(@types/react@18.3.3)(react@18.3.1): + resolution: {integrity: sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.25.4 + '@types/react': 18.3.3 + react: 18.3.1 + dev: false + + /@radix-ui/react-dialog@1.0.5(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-GjWJX/AUpB703eEBanuBnIWdIXg6NvJFCXcNlSZk4xdszCdhrJgBoUd1cGk67vFO+WdA2pfI/plOpqz/5GUP6Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.25.4 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-context': 1.0.1(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.0.5(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-focus-guards': 1.0.1(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-id': 1.0.1(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-portal': 1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-presence': 1.0.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-slot': 1.0.2(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.3.3)(react@18.3.1) + '@types/react': 18.3.3 + '@types/react-dom': 18.3.0 + aria-hidden: 1.2.4 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.5.5(@types/react@18.3.3)(react@18.3.1) + dev: false + + /@radix-ui/react-dismissable-layer@1.0.5(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-aJeDjQhywg9LBu2t/At58hCvr7pEm0o2Ke1x33B+MhjNmmZ17sy4KImo0KPLgsnc/zN7GPdce8Cnn0SWvwZO7g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.25.4 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-use-escape-keydown': 1.0.3(@types/react@18.3.3)(react@18.3.1) + '@types/react': 18.3.3 + '@types/react-dom': 18.3.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: false + + /@radix-ui/react-focus-guards@1.0.1(@types/react@18.3.3)(react@18.3.1): + resolution: {integrity: sha512-Rect2dWbQ8waGzhMavsIbmSVCgYxkXLxxR3ZvCX79JOglzdEy4JXMb98lq4hPxUbLr77nP0UOGf4rcMU+s1pUA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.25.4 + '@types/react': 18.3.3 + react: 18.3.1 + dev: false + + /@radix-ui/react-focus-scope@1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-sL04Mgvf+FmyvZeYfNu1EPAaaxD+aw7cYeIB9L9Fvq8+urhltTRaEo5ysKOpHuKPclsZcSUMKlN05x4u+CINpA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.25.4 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.3.3)(react@18.3.1) + '@types/react': 18.3.3 + '@types/react-dom': 18.3.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: false + + /@radix-ui/react-id@1.0.1(@types/react@18.3.3)(react@18.3.1): + resolution: {integrity: sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.25.4 + '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.3.3)(react@18.3.1) + '@types/react': 18.3.3 + react: 18.3.1 + dev: false + + /@radix-ui/react-portal@1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-Qki+C/EuGUVCQTOTD5vzJzJuMUlewbzuKyUy+/iHM2uwGiru9gZeBJtHAPKAEkB5KWGi9mP/CHKcY0wt1aW45Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.25.4 + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1) + '@types/react': 18.3.3 + '@types/react-dom': 18.3.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: false + + /@radix-ui/react-presence@1.0.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-UXLW4UAbIY5ZjcvzjfRFo5gxva8QirC9hF7wRE4U5gz+TP0DbRk+//qyuAQ1McDxBt1xNMBTaciFGvEmJvAZCg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.25.4 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.3.3)(react@18.3.1) + '@types/react': 18.3.3 + '@types/react-dom': 18.3.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: false + + /@radix-ui/react-primitive@1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.25.4 + '@radix-ui/react-slot': 1.0.2(@types/react@18.3.3)(react@18.3.1) + '@types/react': 18.3.3 + '@types/react-dom': 18.3.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: false + + /@radix-ui/react-slot@1.0.2(@types/react@18.3.3)(react@18.3.1): + resolution: {integrity: sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.25.4 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.3)(react@18.3.1) + '@types/react': 18.3.3 + react: 18.3.1 + dev: false + + /@radix-ui/react-use-callback-ref@1.0.1(@types/react@18.3.3)(react@18.3.1): + resolution: {integrity: sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.25.4 + '@types/react': 18.3.3 + react: 18.3.1 + dev: false + + /@radix-ui/react-use-controllable-state@1.0.1(@types/react@18.3.3)(react@18.3.1): + resolution: {integrity: sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.25.4 + '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.3.3)(react@18.3.1) + '@types/react': 18.3.3 + react: 18.3.1 + dev: false + + /@radix-ui/react-use-escape-keydown@1.0.3(@types/react@18.3.3)(react@18.3.1): + resolution: {integrity: sha512-vyL82j40hcFicA+M4Ex7hVkB9vHgSse1ZWomAqV2Je3RleKGO5iM8KMOEtfoSB0PnIelMd2lATjTGMYqN5ylTg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.25.4 + '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.3.3)(react@18.3.1) + '@types/react': 18.3.3 + react: 18.3.1 + dev: false + + /@radix-ui/react-use-layout-effect@1.0.1(@types/react@18.3.3)(react@18.3.1): + resolution: {integrity: sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.25.4 + '@types/react': 18.3.3 + react: 18.3.1 + dev: false + /@reactflow/background@11.3.14(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1): resolution: {integrity: sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==} peerDependencies: @@ -5210,7 +5492,6 @@ packages: resolution: {integrity: sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==} dependencies: '@types/react': 18.3.3 - dev: true /@types/react-transition-group@4.4.10: resolution: {integrity: sha512-hT/+s0VQs2ojCX823m60m5f0sL5idt9SO6Tj6Dg+rdphGPIeJbJ6CxvBYkgkGKrYeDjvIpKTR38UzmtHJOGW3Q==} @@ -6268,6 +6549,21 @@ packages: requiresBuild: true dev: true + /cmdk@1.0.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-gDzVf0a09TvoJ5jnuPvygTB77+XdOSwEmJ88L6XPFPlv7T3RxbP9jgenfylrAMD0+Le1aO0nVjQUzl2g+vjz5Q==} + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + dependencies: + '@radix-ui/react-dialog': 1.0.5(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + dev: false + /color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} dependencies: @@ -9712,8 +10008,8 @@ packages: resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==} dev: false - /react-focus-lock@2.12.1(@types/react@18.3.3)(react@18.3.1): - resolution: {integrity: sha512-lfp8Dve4yJagkHiFrC1bGtib3mF2ktqwPJw4/WGcgPW+pJ/AVQA5X2vI7xgp13FcxFEpYBBHpXai/N2DBNC0Jw==} + /react-focus-lock@2.13.0(@types/react@18.3.3)(react@18.3.1): + resolution: {integrity: sha512-w7aIcTwZwNzUp2fYQDMICy+khFwVmKmOrLF8kNsPS+dz4Oi/oxoVJ2wCMVvX6rWGriM/+mYaTyp1MRmkcs2amw==} peerDependencies: '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0 @@ -9867,6 +10163,25 @@ packages: use-sidecar: 1.1.2(@types/react@18.3.3)(react@18.3.1) dev: false + /react-remove-scroll@2.5.5(@types/react@18.3.3)(react@18.3.1): + resolution: {integrity: sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.3.3 + react: 18.3.1 + react-remove-scroll-bar: 2.3.6(@types/react@18.3.3)(react@18.3.1) + react-style-singleton: 2.2.1(@types/react@18.3.3)(react@18.3.1) + tslib: 2.7.0 + use-callback-ref: 1.3.2(@types/react@18.3.3)(react@18.3.1) + use-sidecar: 1.1.2(@types/react@18.3.3)(react@18.3.1) + dev: false + /react-resizable-panels@2.0.23(react-dom@18.3.1)(react@18.3.1): resolution: {integrity: sha512-8ZKTwTU11t/FYwiwhMdtZYYyFxic5U5ysRu2YwfkAgDbUJXFvnWSJqhnzkSlW+mnDoNAzDCrJhdOSXBPA76wug==} peerDependencies: diff --git a/invokeai/frontend/web/src/features/nodes/components/NodeEditor.tsx b/invokeai/frontend/web/src/features/nodes/components/NodeEditor.tsx index 27e2006b08..18ac2abdc4 100644 --- a/invokeai/frontend/web/src/features/nodes/components/NodeEditor.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/NodeEditor.tsx @@ -2,6 +2,7 @@ import 'reactflow/dist/style.css'; import { Flex } from '@invoke-ai/ui-library'; import { IAINoContentFallback } from 'common/components/IAIImageFallback'; +import { AddNodeCmdk } from 'features/nodes/components/flow/AddNodeCmdk/AddNodeCmdk'; import TopPanel from 'features/nodes/components/flow/panels/TopPanel/TopPanel'; import { LoadWorkflowFromGraphModal } from 'features/workflowLibrary/components/LoadWorkflowFromGraphModal/LoadWorkflowFromGraphModal'; import { SaveWorkflowAsDialog } from 'features/workflowLibrary/components/SaveWorkflowAsDialog/SaveWorkflowAsDialog'; @@ -10,7 +11,6 @@ import { useTranslation } from 'react-i18next'; import { MdDeviceHub } from 'react-icons/md'; import { useGetOpenAPISchemaQuery } from 'services/api/endpoints/appInfo'; -import AddNodePopover from './flow/AddNodePopover/AddNodePopover'; import { Flow } from './flow/Flow'; import BottomLeftPanel from './flow/panels/BottomLeftPanel/BottomLeftPanel'; import MinimapPanel from './flow/panels/MinimapPanel/MinimapPanel'; @@ -31,7 +31,7 @@ const NodeEditor = () => { {data && ( <> - + diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/AddNodeCmdk/AddNodeCmdk.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/AddNodeCmdk/AddNodeCmdk.tsx new file mode 100644 index 0000000000..207b7cf0aa --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/flow/AddNodeCmdk/AddNodeCmdk.tsx @@ -0,0 +1,420 @@ +import type { SystemStyleObject } from '@invoke-ai/ui-library'; +import { + Box, + Flex, + Icon, + Input, + Modal, + ModalBody, + ModalContent, + ModalOverlay, + Spacer, + Text, +} from '@invoke-ai/ui-library'; +import { useStore } from '@nanostores/react'; +import { useAppStore } from 'app/store/storeHooks'; +import { CommandEmpty, CommandItem, CommandList, CommandRoot } from 'cmdk'; +import { IAINoContentFallback } from 'common/components/IAIImageFallback'; +import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent'; +import { useBuildNode } from 'features/nodes/hooks/useBuildNode'; +import { + $addNodeCmdk, + $cursorPos, + $edgePendingUpdate, + $pendingConnection, + $templates, + edgesChanged, + nodesChanged, + useAddNodeCmdk, +} from 'features/nodes/store/nodesSlice'; +import { selectNodesSlice } from 'features/nodes/store/selectors'; +import { findUnoccupiedPosition } from 'features/nodes/store/util/findUnoccupiedPosition'; +import { getFirstValidConnection } from 'features/nodes/store/util/getFirstValidConnection'; +import { connectionToEdge } from 'features/nodes/store/util/reactFlowUtil'; +import { validateConnectionTypes } from 'features/nodes/store/util/validateConnectionTypes'; +import { isInvocationNode } from 'features/nodes/types/invocation'; +import { toast } from 'features/toast/toast'; +import { memoize } from 'lodash-es'; +import { computed } from 'nanostores'; +import type { ChangeEvent } from 'react'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useHotkeys } from 'react-hotkeys-hook'; +import { useTranslation } from 'react-i18next'; +import { PiFlaskBold, PiHammerBold } from 'react-icons/pi'; +import type { EdgeChange, NodeChange } from 'reactflow'; +import type { S } from 'services/api/types'; + +const useThrottle = (value: T, limit: number) => { + const [throttledValue, setThrottledValue] = useState(value); + const lastRan = useRef(Date.now()); + + useEffect(() => { + const handler = setTimeout( + function () { + if (Date.now() - lastRan.current >= limit) { + setThrottledValue(value); + lastRan.current = Date.now(); + } + }, + limit - (Date.now() - lastRan.current) + ); + + return () => { + clearTimeout(handler); + }; + }, [value, limit]); + + return throttledValue; +}; + +const useAddNode = () => { + const { t } = useTranslation(); + const store = useAppStore(); + const buildInvocation = useBuildNode(); + const templates = useStore($templates); + const pendingConnection = useStore($pendingConnection); + + const addNode = useCallback( + (nodeType: string): void => { + const node = buildInvocation(nodeType); + if (!node) { + const errorMessage = t('nodes.unknownNode', { + nodeType: nodeType, + }); + toast({ + status: 'error', + title: errorMessage, + }); + return; + } + + // Find a cozy spot for the node + const cursorPos = $cursorPos.get(); + const { nodes, edges } = selectNodesSlice(store.getState()); + node.position = findUnoccupiedPosition(nodes, cursorPos?.x ?? node.position.x, cursorPos?.y ?? node.position.y); + node.selected = true; + + // Deselect all other nodes and edges + const nodeChanges: NodeChange[] = [{ type: 'add', item: node }]; + const edgeChanges: EdgeChange[] = []; + nodes.forEach(({ id, selected }) => { + if (selected) { + nodeChanges.push({ type: 'select', id, selected: false }); + } + }); + edges.forEach(({ id, selected }) => { + if (selected) { + edgeChanges.push({ type: 'select', id, selected: false }); + } + }); + + // Onwards! + if (nodeChanges.length > 0) { + store.dispatch(nodesChanged(nodeChanges)); + } + if (edgeChanges.length > 0) { + store.dispatch(edgesChanged(edgeChanges)); + } + + // Auto-connect an edge if we just added a node and have a pending connection + if (pendingConnection && isInvocationNode(node)) { + const edgePendingUpdate = $edgePendingUpdate.get(); + const { handleType } = pendingConnection; + + const source = handleType === 'source' ? pendingConnection.nodeId : node.id; + const sourceHandle = handleType === 'source' ? pendingConnection.handleId : null; + const target = handleType === 'target' ? pendingConnection.nodeId : node.id; + const targetHandle = handleType === 'target' ? pendingConnection.handleId : null; + + const { nodes, edges } = selectNodesSlice(store.getState()); + const connection = getFirstValidConnection( + source, + sourceHandle, + target, + targetHandle, + nodes, + edges, + templates, + edgePendingUpdate + ); + if (connection) { + const newEdge = connectionToEdge(connection); + store.dispatch(edgesChanged([{ type: 'add', item: newEdge }])); + } + } + }, + [buildInvocation, pendingConnection, store, t, templates] + ); + + return addNode; +}; + +const cmdkRootSx: SystemStyleObject = { + '[cmdk-root]': { + w: 'full', + h: 'full', + }, + '[cmdk-list]': { + w: 'full', + h: 'full', + }, +}; + +export const AddNodeCmdk = memo(() => { + const { t } = useTranslation(); + const addNodeCmdk = useAddNodeCmdk(); + const addNodeCmdkIsOpen = useStore(addNodeCmdk.$boolean); + const inputRef = useRef(null); + const [searchTerm, setSearchTerm] = useState(''); + const addNode = useAddNode(); + const throttledSearchTerm = useThrottle(searchTerm, 100); + + useHotkeys(['shift+a', 'space'], addNodeCmdk.setTrue, { preventDefault: true }); + + const onChange = useCallback((e: ChangeEvent) => { + setSearchTerm(e.target.value); + }, []); + + const onSelect = useCallback( + (value: string) => { + addNode(value); + $addNodeCmdk.set(false); + setSearchTerm(''); + }, + [addNode] + ); + + const onClose = useCallback(() => { + addNodeCmdk.setFalse(); + setSearchTerm(''); + $pendingConnection.set(null); + }, [addNodeCmdk]); + + return ( + + + + + + + + + + + + + + + + + + + + + + + ); +}); + +AddNodeCmdk.displayName = 'AddNodeCmdk'; + +const cmdkItemSx: SystemStyleObject = { + '&[data-selected="true"]': { + bg: 'base.700', + }, +}; + +type NodeCommandItemData = { + value: string; + label: string; + description: string; + classification: S['Classification']; + nodePack: string; +}; + +const $templatesArray = computed($templates, (templates) => Object.values(templates)); + +const createRegex = memoize( + (inputValue: string) => + new RegExp( + inputValue + .trim() + .replace(/[-[\]{}()*+!<=:?./\\^$|#,]/g, '') + .split(' ') + .join('.*'), + 'gi' + ) +); + +// Filterable items are a subset of Invocation template - we also want to filter for notes or current image node, +// so we are using a less specific type instead of `InvocationTemplate` +type FilterableItem = { + type: string; + title: string; + description: string; + tags: string[]; + classification: S['Classification']; + nodePack: string; +}; + +const filter = memoize( + (item: FilterableItem, searchTerm: string) => { + const regex = createRegex(searchTerm); + + if (!searchTerm) { + return true; + } + + if (item.title.includes(searchTerm) || regex.test(item.title)) { + return true; + } + + if (item.type.includes(searchTerm) || regex.test(item.type)) { + return true; + } + + if (item.description.includes(searchTerm) || regex.test(item.description)) { + return true; + } + + if (item.nodePack.includes(searchTerm) || regex.test(item.nodePack)) { + return true; + } + + if (item.classification.includes(searchTerm) || regex.test(item.classification)) { + return true; + } + + for (const tag of item.tags) { + if (tag.includes(searchTerm) || regex.test(tag)) { + return true; + } + } + + return false; + }, + (item: FilterableItem, searchTerm: string) => `${item.type}-${searchTerm}` +); + +const NodeCommandList = memo(({ searchTerm, onSelect }: { searchTerm: string; onSelect: (value: string) => void }) => { + const { t } = useTranslation(); + const templatesArray = useStore($templatesArray); + const pendingConnection = useStore($pendingConnection); + const currentImageFilterItem = useMemo( + () => ({ + type: 'current_image', + title: t('nodes.currentImage'), + description: t('nodes.currentImageDescription'), + tags: ['progress', 'image', 'current'], + classification: 'stable', + nodePack: 'invokeai', + }), + [t] + ); + const notesFilterItem = useMemo( + () => ({ + type: 'notes', + title: t('nodes.notes'), + description: t('nodes.notesDescription'), + tags: ['notes'], + classification: 'stable', + nodePack: 'invokeai', + }), + [t] + ); + + const items = useMemo(() => { + // If we have a connection in progress, we need to filter the node choices + const _items: NodeCommandItemData[] = []; + + if (!pendingConnection) { + for (const template of templatesArray) { + if (filter(template, searchTerm)) { + _items.push({ + label: template.title, + value: template.type, + description: template.description, + classification: template.classification, + nodePack: template.nodePack, + }); + } + } + + for (const item of [currentImageFilterItem, notesFilterItem]) { + if (filter(item, searchTerm)) { + _items.push({ + label: item.title, + value: item.type, + description: item.description, + classification: item.classification, + nodePack: item.nodePack, + }); + } + } + } else { + for (const template of templatesArray) { + if (filter(template, searchTerm)) { + const candidateFields = pendingConnection.handleType === 'source' ? template.inputs : template.outputs; + + for (const field of Object.values(candidateFields)) { + const sourceType = + pendingConnection.handleType === 'source' ? field.type : pendingConnection.fieldTemplate.type; + const targetType = + pendingConnection.handleType === 'target' ? field.type : pendingConnection.fieldTemplate.type; + + if (validateConnectionTypes(sourceType, targetType)) { + _items.push({ + label: template.title, + value: template.type, + description: template.description, + classification: template.classification, + nodePack: template.nodePack, + }); + break; + } + } + } + } + } + + return _items; + }, [pendingConnection, currentImageFilterItem, searchTerm, notesFilterItem, templatesArray]); + + return ( + <> + {items.map((item) => ( + + + + {item.classification === 'beta' && } + {item.classification === 'prototype' && } + {item.label} + + + {item.nodePack} + + + {item.description && {item.description}} + + + ))} + + ); +}); + +NodeCommandList.displayName = 'CommandListItems'; diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/AddNodePopover/AddNodePopover.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/AddNodePopover/AddNodePopover.tsx deleted file mode 100644 index cb6516efd9..0000000000 --- a/invokeai/frontend/web/src/features/nodes/components/flow/AddNodePopover/AddNodePopover.tsx +++ /dev/null @@ -1,267 +0,0 @@ -import 'reactflow/dist/style.css'; - -import type { ComboboxOnChange, ComboboxOption } from '@invoke-ai/ui-library'; -import { Combobox, Flex, Popover, PopoverAnchor, PopoverBody, PopoverContent } from '@invoke-ai/ui-library'; -import { useStore } from '@nanostores/react'; -import { useAppDispatch, useAppStore } from 'app/store/storeHooks'; -import type { SelectInstance } from 'chakra-react-select'; -import { INTERACTION_SCOPES } from 'common/hooks/interactionScopes'; -import { useBuildNode } from 'features/nodes/hooks/useBuildNode'; -import { - $cursorPos, - $edgePendingUpdate, - $isAddNodePopoverOpen, - $pendingConnection, - $templates, - closeAddNodePopover, - edgesChanged, - nodesChanged, - openAddNodePopover, -} from 'features/nodes/store/nodesSlice'; -import { selectNodesSlice } from 'features/nodes/store/selectors'; -import { findUnoccupiedPosition } from 'features/nodes/store/util/findUnoccupiedPosition'; -import { getFirstValidConnection } from 'features/nodes/store/util/getFirstValidConnection'; -import { connectionToEdge } from 'features/nodes/store/util/reactFlowUtil'; -import { validateConnectionTypes } from 'features/nodes/store/util/validateConnectionTypes'; -import type { AnyNode } from 'features/nodes/types/invocation'; -import { isInvocationNode } from 'features/nodes/types/invocation'; -import { toast } from 'features/toast/toast'; -import { filter, map, memoize, some } from 'lodash-es'; -import { memo, useCallback, useMemo, useRef } from 'react'; -import { flushSync } from 'react-dom'; -import { useHotkeys } from 'react-hotkeys-hook'; -import type { HotkeyCallback } from 'react-hotkeys-hook/dist/types'; -import { useTranslation } from 'react-i18next'; -import type { FilterOptionOption } from 'react-select/dist/declarations/src/filters'; -import type { EdgeChange, NodeChange } from 'reactflow'; - -const createRegex = memoize( - (inputValue: string) => - new RegExp( - inputValue - .trim() - .replace(/[-[\]{}()*+!<=:?./\\^$|#,]/g, '') - .split(' ') - .join('.*'), - 'gi' - ) -); - -const filterOption = memoize((option: FilterOptionOption, inputValue: string) => { - if (!inputValue) { - return true; - } - const regex = createRegex(inputValue); - return ( - regex.test(option.label) || - regex.test(option.data.description ?? '') || - (option.data.tags ?? []).some((tag) => regex.test(tag)) - ); -}); - -const AddNodePopover = () => { - const dispatch = useAppDispatch(); - const buildInvocation = useBuildNode(); - const { t } = useTranslation(); - const selectRef = useRef | null>(null); - const inputRef = useRef(null); - const templates = useStore($templates); - const pendingConnection = useStore($pendingConnection); - const isOpen = useStore($isAddNodePopoverOpen); - const store = useAppStore(); - const isWorkflowsActive = useStore(INTERACTION_SCOPES.workflows.$isActive); - - const filteredTemplates = useMemo(() => { - // If we have a connection in progress, we need to filter the node choices - const templatesArray = map(templates); - if (!pendingConnection) { - return templatesArray; - } - - return filter(templates, (template) => { - const candidateFields = pendingConnection.handleType === 'source' ? template.inputs : template.outputs; - return some(candidateFields, (field) => { - const sourceType = - pendingConnection.handleType === 'source' ? field.type : pendingConnection.fieldTemplate.type; - const targetType = - pendingConnection.handleType === 'target' ? field.type : pendingConnection.fieldTemplate.type; - return validateConnectionTypes(sourceType, targetType); - }); - }); - }, [templates, pendingConnection]); - - const options = useMemo(() => { - const _options: ComboboxOption[] = map(filteredTemplates, (template) => { - return { - label: template.title, - value: template.type, - description: template.description, - tags: template.tags, - }; - }); - - //We only want these nodes if we're not filtered - if (!pendingConnection) { - _options.push({ - label: t('nodes.currentImage'), - value: 'current_image', - description: t('nodes.currentImageDescription'), - tags: ['progress'], - }); - - _options.push({ - label: t('nodes.notes'), - value: 'notes', - description: t('nodes.notesDescription'), - tags: ['notes'], - }); - } - - _options.sort((a, b) => a.label.localeCompare(b.label)); - - return _options; - }, [filteredTemplates, pendingConnection, t]); - - const addNode = useCallback( - (nodeType: string): AnyNode | null => { - const node = buildInvocation(nodeType); - if (!node) { - const errorMessage = t('nodes.unknownNode', { - nodeType: nodeType, - }); - toast({ - status: 'error', - title: errorMessage, - }); - return null; - } - - // Find a cozy spot for the node - const cursorPos = $cursorPos.get(); - const { nodes, edges } = selectNodesSlice(store.getState()); - node.position = findUnoccupiedPosition(nodes, cursorPos?.x ?? node.position.x, cursorPos?.y ?? node.position.y); - node.selected = true; - - // Deselect all other nodes and edges - const nodeChanges: NodeChange[] = [{ type: 'add', item: node }]; - const edgeChanges: EdgeChange[] = []; - nodes.forEach(({ id, selected }) => { - if (selected) { - nodeChanges.push({ type: 'select', id, selected: false }); - } - }); - edges.forEach(({ id, selected }) => { - if (selected) { - edgeChanges.push({ type: 'select', id, selected: false }); - } - }); - - // Onwards! - if (nodeChanges.length > 0) { - dispatch(nodesChanged(nodeChanges)); - } - if (edgeChanges.length > 0) { - dispatch(edgesChanged(edgeChanges)); - } - return node; - }, - [buildInvocation, store, dispatch, t] - ); - - const onChange = useCallback( - (v) => { - if (!v) { - return; - } - const node = addNode(v.value); - - // Auto-connect an edge if we just added a node and have a pending connection - if (pendingConnection && isInvocationNode(node)) { - const edgePendingUpdate = $edgePendingUpdate.get(); - const { handleType } = pendingConnection; - - const source = handleType === 'source' ? pendingConnection.nodeId : node.id; - const sourceHandle = handleType === 'source' ? pendingConnection.handleId : null; - const target = handleType === 'target' ? pendingConnection.nodeId : node.id; - const targetHandle = handleType === 'target' ? pendingConnection.handleId : null; - - const { nodes, edges } = selectNodesSlice(store.getState()); - const connection = getFirstValidConnection( - source, - sourceHandle, - target, - targetHandle, - nodes, - edges, - templates, - edgePendingUpdate - ); - if (connection) { - const newEdge = connectionToEdge(connection); - dispatch(edgesChanged([{ type: 'add', item: newEdge }])); - } - } - - closeAddNodePopover(); - }, - [addNode, dispatch, pendingConnection, store, templates] - ); - - const handleHotkeyOpen: HotkeyCallback = useCallback((e) => { - if (!$isAddNodePopoverOpen.get()) { - e.preventDefault(); - openAddNodePopover(); - flushSync(() => { - selectRef.current?.inputRef?.focus(); - }); - } - }, []); - - useHotkeys(['shift+a', 'space'], handleHotkeyOpen, { enabled: isWorkflowsActive }, [isWorkflowsActive]); - - const noOptionsMessage = useCallback(() => t('nodes.noMatchingNodes'), [t]); - - return ( - - - - - - - - - - - ); -}; - -export default memo(AddNodePopover); diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx index f85131a133..ad631d3125 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx @@ -8,10 +8,10 @@ import { useSyncExecutionState } from 'features/nodes/hooks/useExecutionState'; import { useIsValidConnection } from 'features/nodes/hooks/useIsValidConnection'; import { useWorkflowWatcher } from 'features/nodes/hooks/useWorkflowWatcher'; import { + $addNodeCmdk, $cursorPos, $didUpdateEdge, $edgePendingUpdate, - $isAddNodePopoverOpen, $lastEdgeUpdateMouseEvent, $pendingConnection, $viewport, @@ -281,7 +281,7 @@ export const Flow = memo(() => { const onEscapeHotkey = useCallback(() => { if (!$edgePendingUpdate.get()) { $pendingConnection.set(null); - $isAddNodePopoverOpen.set(false); + $addNodeCmdk.set(false); cancelConnection(); } }, [cancelConnection]); diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/AddNodeButton.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/AddNodeButton.tsx index c7eb9bdbb0..72beff084c 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/AddNodeButton.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/AddNodeButton.tsx @@ -1,10 +1,11 @@ import { IconButton } from '@invoke-ai/ui-library'; -import { openAddNodePopover } from 'features/nodes/store/nodesSlice'; +import { useAddNodeCmdk } from 'features/nodes/store/nodesSlice'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiPlusBold } from 'react-icons/pi'; const AddNodeButton = () => { + const addNodeCmdk = useAddNodeCmdk(); const { t } = useTranslation(); return ( @@ -12,7 +13,7 @@ const AddNodeButton = () => { tooltip={t('nodes.addNodeToolTip')} aria-label={t('nodes.addNode')} icon={} - onClick={openAddNodePopover} + onClick={addNodeCmdk.setTrue} pointerEvents="auto" /> ); diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useConnection.ts b/invokeai/frontend/web/src/features/nodes/hooks/useConnection.ts index f2cc8690bb..c41ef9f689 100644 --- a/invokeai/frontend/web/src/features/nodes/hooks/useConnection.ts +++ b/invokeai/frontend/web/src/features/nodes/hooks/useConnection.ts @@ -2,9 +2,9 @@ import { useStore } from '@nanostores/react'; import { useAppStore } from 'app/store/storeHooks'; import { $mouseOverNode } from 'features/nodes/hooks/useMouseOverNode'; import { + $addNodeCmdk, $didUpdateEdge, $edgePendingUpdate, - $isAddNodePopoverOpen, $pendingConnection, $templates, edgesChanged, @@ -107,7 +107,7 @@ export const useConnection = () => { $pendingConnection.set(null); } else { // The mouse is not over a node - we should open the add node popover - $isAddNodePopoverOpen.set(true); + $addNodeCmdk.set(true); } }, [store, templates, updateNodeInternals]); diff --git a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts index d0b144dfa2..bdcc7ae815 100644 --- a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts +++ b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts @@ -1,6 +1,7 @@ import type { PayloadAction, UnknownAction } from '@reduxjs/toolkit'; import { createSlice, isAnyOf } from '@reduxjs/toolkit'; import type { PersistConfig } from 'app/store/store'; +import { buildUseBoolean } from 'common/hooks/useBoolean'; import { workflowLoaded } from 'features/nodes/store/actions'; import { SHARED_NODE_PROPERTIES } from 'features/nodes/types/constants'; import type { @@ -443,14 +444,8 @@ export const $didUpdateEdge = atom(false); export const $lastEdgeUpdateMouseEvent = atom(null); export const $viewport = atom({ x: 0, y: 0, zoom: 1 }); -export const $isAddNodePopoverOpen = atom(false); -export const closeAddNodePopover = () => { - $isAddNodePopoverOpen.set(false); - $pendingConnection.set(null); -}; -export const openAddNodePopover = () => { - $isAddNodePopoverOpen.set(true); -}; +export const $addNodeCmdk = atom(false); +export const useAddNodeCmdk = buildUseBoolean($addNodeCmdk); /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ const migrateNodesState = (state: any): any => {