├── .gitattributes ├── .github └── workflows │ └── publish_action.yml ├── .gitignore ├── LICENSE ├── README.md ├── __init__.py ├── image.png ├── images ├── 0.png ├── 2.png ├── 4.png ├── 9.png ├── batch.png ├── collapse.png ├── controllertop.png ├── drag.png ├── dropdown.png ├── example.png ├── gears.png ├── groups.png ├── ksampler.png ├── menu.png ├── minimised.png ├── minmax.png ├── nodeblock-context.png ├── nodeblock.png ├── nodeblock_icons.png ├── other_icons.png ├── pin.png ├── resize.png ├── settings.png ├── sidebar.png ├── slider-edit.png ├── sliderbasic.gif ├── top.png └── widgets.png ├── js ├── _notes.md ├── advanced-options-on.png ├── advanced-options.png ├── collapse.png ├── combo.js ├── constants.js ├── context_menu.js ├── controller.css ├── controller.js ├── controller_node.js ├── controller_panel.js ├── debug.js ├── expand.png ├── groups.js ├── highlighter.js ├── image_comparer_control_widget.js ├── image_manager.js ├── image_popup.js ├── input_slider.js ├── node_inclusion.js ├── nodeblock.js ├── options.js ├── panel_entry.js ├── power_lora_loader_widget.js ├── prompt_id_manager.js ├── resize_manager.js ├── settings.js ├── slider.css ├── snap_manager.js ├── toggle.js ├── update_controller.js ├── utilities.js ├── widget_change_manager.js └── workspace.js └── pyproject.toml /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/workflows/publish_action.yml: -------------------------------------------------------------------------------- 1 | name: Publish to Comfy registry 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - "pyproject.toml" 9 | 10 | jobs: 11 | publish-node: 12 | name: Publish Custom Node to registry 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Check out code 16 | uses: actions/checkout@v4 17 | - name: Publish Custom Node 18 | uses: Comfy-Org/publish-node-action@main 19 | with: 20 | personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }} ## Add your own personal access token to your Github Repository secrets and reference it here. 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | js/tracking_hacks.js 162 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Chris 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Controller 2 | 3 | The Controller is a new way to interact with a Comfy workflow, 4 | in which just the nodes that you select are mirrored in the Controller, 5 | without you needing to make any changes to the workflow itself. 6 | 7 | ![image](images/example.png) 8 | 9 | So you can lay your workflow out however makes most sense to you, 10 | and then bring all the nodes you want to see together in one place. 11 | When you want to edit the workflow, it's still there, untouched. 12 | 13 | The controller gets saved with the workflow, so once you've set it up, it's always there for you. 14 | And if you share the workflow with someone else, they get your controller as well... 15 | 16 | ## Latest update - v1.7, 14th Feb 2025 17 | 18 | - Added workspaces (WIP) 19 | - Image viewers now work with batches 20 | - Better support for some rgthree COMBO features 21 | - Option to hide file types such as .safetensors in loader nodes (main settings) 22 | - Option to specify that a node shouldn't show images in controller (right-click nodeblock title) 23 | 24 | ## Getting started 25 | 26 | The Controller is toggled using a button in the top menu. When the sliders icon is blue, the Controller is active. 27 | 28 | ![top](images/top.png) 29 | 30 | There will normally be one controller window created for you. If not, or to add more, 31 | right click on the background and select "New Controller" from the menu (if it isn't there, check the Controller is active!). 32 | 33 | At first, the Controller will be empty. To add nodes to it, right-click on the node and in the Controller Panel menu select "Include this node". 34 | You can also select multiple nodes and add them all at once. 35 | 36 | ![menu](images/menu.png) 37 | 38 | When you include a node, it appears on the Controller panel, and it also gains a coloured dot in the top-right hand corner, 39 | so when you look at the workflow you can easily see which nodes are included. 40 | 41 | The node, with all standard widgets (we'll work to include the most popular custom widgets in future releases!) will now appear in the controller 42 | as a panel with the same title and colour as the node, underneath which are all the widgets. 43 | 44 | ![widgets](images/widgets.png) 45 | 46 | If you now edit the values in those widgets, the changes will be reflected in the workflow (and vica versa). 47 | Combo boxes, toggle switches, buttons, and text fields all work just as you would expect. 48 | 49 | ### Sliders for numeric values 50 | 51 | Numeric fields (like width in the image) a represented using an editable slider. 52 | Click on the slider and drag left or right, and the value will change between the minimum and maximum values. 53 | You can also just hover the mouse over the slider and move the value up or down with your mouse scrollwheel. 54 | To enter a precise value, double click and the slider turns into a text entry box where you can type the value you want. 55 | 56 | If the minimum and maximum values, or the step size, aren't convenient, shift-click on the slider to change them. 57 | Note that changes made here will be reflected in the actual widget as well, however, if you set a value outside 58 | of the original limits the workflow may fail to validate on the server. 59 | 60 | ![edit](images/slider-edit.png) 61 | 62 | You can change the way the scrollwheel interacts with the sliders, and the keys required to edit the limits, in the main settings. 63 | 64 | ## Groups and Tabs 65 | 66 | A new controller will show all of the selected nodes - that's what is meant by the 'All' in the top left hand corner. 67 | 68 | ![controllertop](images/controllertop.png) 69 | 70 | If you click on the 'All' you will get a drop down menu of all groups in the workflow 71 | which contain nodes that have been added to the controller. If there are added nodes that 72 | are not in any group, you will also see the pseudo-group 'Ungrouped'. 73 | Select from this menu to choose the group to display. 74 | 75 | Alternatively, you can add additional tabs, by clicking on the '+' and selecting from the same menu, 76 | and then switch between them by clicking on the tabs (once you have multiple tabs, clicking on them 77 | doesn't bring up the menu any more). If you want to get rid of a tab, switch to it and then click on the dustbin icon. 78 | 79 | ## The other icons 80 | 81 | ### On the controller 82 | 83 | ![other icons](images/other_icons.png) 84 | 85 | The play button indicates that all of the nodes in this group are currently active (not bypassed or muted). 86 | You can use it to bypass or mute a group. (Mute is refered to as 'never' in some places). 87 | 88 | Clicking the play button will bypass or unbypass the group, ctrl-clicking will mute or unmute. 89 | 90 | |Icon|Meaning|Click will...|Ctrl-click will...| 91 | |-|-|-|-| 92 | |![0](images/0.png)|Group nodes active|Bypass group|Mute group| 93 | |![2](images/2.png)|Group nodes muted|Activate group|Activate group| 94 | |![4](images/4.png)|Group nodes bypassed|Activate group|Mute group| 95 | |![29](images/9.png)|Mixed state in group|Activate group|Activate group| 96 | 97 | The little lightning bolt icon is used to show/hide any nodes that were added as advanced controls. 98 | If there are no advanced control, this icon won't be shown. 99 | 100 | ### On each nodeblock 101 | 102 | ![other icons](images/nodeblock_icons.png) 103 | 104 | Each node can be independantly bypassed or muted using the arrow on the nodeblock. 105 | 106 | Nodes which include an image also have a pin in the top right corner: ![pin](images/pin.png) 107 | When this is active (blue, the default) the image will be shown at the full width of the controller. 108 | If you unpin the image, you will find a handle at the bottom right of the image that can be used to resize it (vertically). 109 | 110 | Right-clicking the title of a nodeblock brings up options to control which widgets should be shown, and which images (if any) should be shown, in this nodeblock. 111 | 112 | ## Other things to explore 113 | 114 | ### Workspaces 115 | 116 | WIP - you can save and then reload the setup of your controllers from the dropdown triangle ![screenshot](images/dropdown.png). This may be useful if you use the same workflow in different ways. There are plans to make this feature more useful! 117 | 118 | ### Batches 119 | 120 | ![image](images/batch.png) 121 | 122 | If you have a batch of images, you can click through them on the preview using the control in the top right corner. 123 | 124 | ### Mask Editor 125 | 126 | ctrl-click or right-click on an image in the controller to bring up a context menu, from which you can launch the mask editor. 127 | 128 | ### Selectively hide widgets 129 | 130 | ctrl-click or right-click on the title of a nodeblock and you can hide/show specific widgets. Note that hide/show is specific to the controller, 131 | so you can have the same node showing different widgets on different controllers if you want! 132 | 133 | ![context](images/nodeblock-context.png) 134 | 135 | ### Hover and zoom 136 | 137 | If the magnifying glass in the top control is active - ![image](images/top.png) - then when you move you mouse over a node in 138 | the controller it will be highlighted in the workflow, and when you move your mouse over a group tab, the group will 139 | be highlighted. 140 | 141 | Control-click a tab and the workflow will zoom to show the group. 142 | 143 | ### Multiple controllers 144 | 145 | Remember, you can add more controllers any time by right clicking on the canvas. You can also close controllers with the 'X', 146 | or minimise them with the '_'. 147 | 148 | ### Rearrange the nodes 149 | 150 | You can rearrange the node panels in the controller by clicking on the node panel title bar and dragging it up or down. 151 | 152 | ### Collapse nodes 153 | 154 | You can save space by collapsing the node panels, just like you can collapse nodes in the workflow. 155 | Click on the title bar without dragging to collapse, click it again to expand. 156 | 157 | ### Resize text and images 158 | 159 | Multiline text fields and (unpinned) image displays can be rescaled up or down using the resize handle in the bottom right hand corner of the widget. 160 | As you drag the height is shown as an overlay, in case you want to make things the same size. 161 | 162 | ### The workflow is still there 163 | 164 | Any time you want to, you can go back to the workflow and work with it directly. 165 | The Controller is just a way of viewing it (and changing widgets values). 166 | 167 | Some changes that you make will not be immediately reflected in the Controller (for instance, if you change the colour of a node), 168 | but you just need to click the refresh button (top right of the Controller) to bring it up to date. 169 | 170 | ### Settings 171 | 172 | In the main settings menu are a few things you can tweak such as: 173 | 174 | - hiding control_after_generate 175 | - keyboard shortcut to show or hide the Controller 176 | - settings to control how you can interact with the sliders 177 | - option to hide filename extensions 178 | 179 | There's also a debug setting that I might ask you to use if you report a problem! 180 | 181 | ### Resize controller 182 | 183 | There is a little drag box at the bottom right of the controller that you can use to resize it. 184 | You can also move it around by dragging the header. 185 | 186 | ### Snapping 187 | 188 | Controllers will snap to each other and move around together. To break them apart, move the controller on the right or bottom. 189 | 190 | ## Supporting Custom Nodes 191 | 192 | Custom nodes which do not add specialised widgets will generally work. Some custom nodes with custom widgets are also supported: 193 | 194 | - [trung0246](https://github.com/Trung0246/ComfyUI-0246) switch 195 | 196 | ## Known Limitations 197 | 198 | ### Custom widgets 199 | 200 | At present only standard Comfy widgets and some custom ones are supported. We'll be working to bring more of the more popular custom node widgets to the controller 201 | in future releases. 202 | 203 | 204 | # The road ahead... 205 | 206 | For more details of what's under consideration, take a look at the [issues list](https://github.com/chrisgoringe/cg-controller/issues), 207 | and feel free to add your ideas there, or 208 | jump into the discussion [here](https://github.com/chrisgoringe/cg-controller/discussions). 209 | 210 | # Bug reports 211 | 212 | [Bug reports are very welcome](https://github.com/chrisgoringe/cg-controller/issues). 213 | 214 | It's really helpful if you include as much in the way of specific detail as possible, possibly including screenshots or copies of the workflow. 215 | You can also press f12 and look in the javascript console to see if there are any errors with 'cg-controller' in them. 216 | 217 | A screenshot of your Settings/About is a big help too! 218 | 219 | ![settings](images/settings.png) 220 | 221 | # Credits 222 | 223 | Controller has been developed by [Chris Goringe](https://github.com/chrisgoringe). 224 | 225 | [JorgeR81](https://github.com/JorgeR81) has been making invaluable suggestions, testing, and UI mockups since the first alpha release, and more recently [LukeG89](https://github.com/LukeG89) has become a regular and valuable contributor to the discussions and testing. -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | VERSION = "1.7" 2 | WEB_DIRECTORY = "./js" 3 | 4 | NODE_CLASS_MAPPINGS= {} 5 | 6 | __all__ = ["NODE_CLASS_MAPPINGS", "WEB_DIRECTORY"] -------------------------------------------------------------------------------- /image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisgoringe/cg-controller/bbc23b22cf2a9cd5ffdf039b9e5bbb8ea440a838/image.png -------------------------------------------------------------------------------- /images/0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisgoringe/cg-controller/bbc23b22cf2a9cd5ffdf039b9e5bbb8ea440a838/images/0.png -------------------------------------------------------------------------------- /images/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisgoringe/cg-controller/bbc23b22cf2a9cd5ffdf039b9e5bbb8ea440a838/images/2.png -------------------------------------------------------------------------------- /images/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisgoringe/cg-controller/bbc23b22cf2a9cd5ffdf039b9e5bbb8ea440a838/images/4.png -------------------------------------------------------------------------------- /images/9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisgoringe/cg-controller/bbc23b22cf2a9cd5ffdf039b9e5bbb8ea440a838/images/9.png -------------------------------------------------------------------------------- /images/batch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisgoringe/cg-controller/bbc23b22cf2a9cd5ffdf039b9e5bbb8ea440a838/images/batch.png -------------------------------------------------------------------------------- /images/collapse.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisgoringe/cg-controller/bbc23b22cf2a9cd5ffdf039b9e5bbb8ea440a838/images/collapse.png -------------------------------------------------------------------------------- /images/controllertop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisgoringe/cg-controller/bbc23b22cf2a9cd5ffdf039b9e5bbb8ea440a838/images/controllertop.png -------------------------------------------------------------------------------- /images/drag.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisgoringe/cg-controller/bbc23b22cf2a9cd5ffdf039b9e5bbb8ea440a838/images/drag.png -------------------------------------------------------------------------------- /images/dropdown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisgoringe/cg-controller/bbc23b22cf2a9cd5ffdf039b9e5bbb8ea440a838/images/dropdown.png -------------------------------------------------------------------------------- /images/example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisgoringe/cg-controller/bbc23b22cf2a9cd5ffdf039b9e5bbb8ea440a838/images/example.png -------------------------------------------------------------------------------- /images/gears.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisgoringe/cg-controller/bbc23b22cf2a9cd5ffdf039b9e5bbb8ea440a838/images/gears.png -------------------------------------------------------------------------------- /images/groups.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisgoringe/cg-controller/bbc23b22cf2a9cd5ffdf039b9e5bbb8ea440a838/images/groups.png -------------------------------------------------------------------------------- /images/ksampler.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisgoringe/cg-controller/bbc23b22cf2a9cd5ffdf039b9e5bbb8ea440a838/images/ksampler.png -------------------------------------------------------------------------------- /images/menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisgoringe/cg-controller/bbc23b22cf2a9cd5ffdf039b9e5bbb8ea440a838/images/menu.png -------------------------------------------------------------------------------- /images/minimised.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisgoringe/cg-controller/bbc23b22cf2a9cd5ffdf039b9e5bbb8ea440a838/images/minimised.png -------------------------------------------------------------------------------- /images/minmax.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisgoringe/cg-controller/bbc23b22cf2a9cd5ffdf039b9e5bbb8ea440a838/images/minmax.png -------------------------------------------------------------------------------- /images/nodeblock-context.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisgoringe/cg-controller/bbc23b22cf2a9cd5ffdf039b9e5bbb8ea440a838/images/nodeblock-context.png -------------------------------------------------------------------------------- /images/nodeblock.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisgoringe/cg-controller/bbc23b22cf2a9cd5ffdf039b9e5bbb8ea440a838/images/nodeblock.png -------------------------------------------------------------------------------- /images/nodeblock_icons.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisgoringe/cg-controller/bbc23b22cf2a9cd5ffdf039b9e5bbb8ea440a838/images/nodeblock_icons.png -------------------------------------------------------------------------------- /images/other_icons.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisgoringe/cg-controller/bbc23b22cf2a9cd5ffdf039b9e5bbb8ea440a838/images/other_icons.png -------------------------------------------------------------------------------- /images/pin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisgoringe/cg-controller/bbc23b22cf2a9cd5ffdf039b9e5bbb8ea440a838/images/pin.png -------------------------------------------------------------------------------- /images/resize.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisgoringe/cg-controller/bbc23b22cf2a9cd5ffdf039b9e5bbb8ea440a838/images/resize.png -------------------------------------------------------------------------------- /images/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisgoringe/cg-controller/bbc23b22cf2a9cd5ffdf039b9e5bbb8ea440a838/images/settings.png -------------------------------------------------------------------------------- /images/sidebar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisgoringe/cg-controller/bbc23b22cf2a9cd5ffdf039b9e5bbb8ea440a838/images/sidebar.png -------------------------------------------------------------------------------- /images/slider-edit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisgoringe/cg-controller/bbc23b22cf2a9cd5ffdf039b9e5bbb8ea440a838/images/slider-edit.png -------------------------------------------------------------------------------- /images/sliderbasic.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisgoringe/cg-controller/bbc23b22cf2a9cd5ffdf039b9e5bbb8ea440a838/images/sliderbasic.gif -------------------------------------------------------------------------------- /images/top.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisgoringe/cg-controller/bbc23b22cf2a9cd5ffdf039b9e5bbb8ea440a838/images/top.png -------------------------------------------------------------------------------- /images/widgets.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisgoringe/cg-controller/bbc23b22cf2a9cd5ffdf039b9e5bbb8ea440a838/images/widgets.png -------------------------------------------------------------------------------- /js/_notes.md: -------------------------------------------------------------------------------- 1 | # Capturing changes 2 | 3 | |Change|code|detection|response| 4 | |-|-|-|-| 5 | |Node created|controller.js|nodeCreated|UpdateController| 6 | |Node destroyed|controller.js|node_Created adds to onRemove|UpdateController| 7 | |Input change|controller.js|beforeRegisterNodeDef|ControllerPanel.node_change| 8 | |Mode change|controller.js|beforeRegisterNodeDef|ControllerPanel.node_change| 9 | |Imgs changed|controller.js|nodeCreated adds to onDrawForeground|ImageManager.node_img_change| 10 | |Definitions refreshed|controller.js|refreshComboInNodes|UpdateController| 11 | |Dialog boxes|controller.js|setup, MutationObserver|UpdateController| 12 | |Focus mode|controller.js|setup, MutationObserver|ControllerPanel.focus_mode_changed| 13 | |read_only|controller.js|setup, hijack of read_only|UpdateController| 14 | |load|controller.js|afterConfigureGraph|ControllerPanel.new_workflow| 15 | |node inclusion|node_inclusion.js|cp_callback_submenu|UpdateController| 16 | |title, color, group membership|controller.js|UpdateController.on_change| -------------------------------------------------------------------------------- /js/advanced-options-on.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisgoringe/cg-controller/bbc23b22cf2a9cd5ffdf039b9e5bbb8ea440a838/js/advanced-options-on.png -------------------------------------------------------------------------------- /js/advanced-options.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisgoringe/cg-controller/bbc23b22cf2a9cd5ffdf039b9e5bbb8ea440a838/js/advanced-options.png -------------------------------------------------------------------------------- /js/collapse.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisgoringe/cg-controller/bbc23b22cf2a9cd5ffdf039b9e5bbb8ea440a838/js/collapse.png -------------------------------------------------------------------------------- /js/combo.js: -------------------------------------------------------------------------------- 1 | import { extension_hiding } from "./utilities.js" 2 | import { open_context_menu, close_context_menu } from "./context_menu.js" 3 | import { WidgetChangeManager } from "./widget_change_manager.js" 4 | 5 | export class ExtendedCombo extends HTMLSpanElement { 6 | constructor(choices, target_widget, node) { 7 | super() 8 | this.classList.add('input') 9 | this.classList.add('clickabletext') 10 | this.innerText = extension_hiding(target_widget.value) 11 | this.draggable = false 12 | this.addEventListener('click', (e)=>{ 13 | e.stopPropagation() 14 | open_context_menu(e, "", choices, { 15 | className: "dark", 16 | callback: (v)=>{ 17 | close_context_menu() 18 | target_widget.value=v; 19 | this.innerText = extension_hiding(v) 20 | target_widget.callback(v, app.canvas, node) 21 | WidgetChangeManager.notify(target_widget) 22 | }, 23 | }, node) 24 | }) 25 | } 26 | } 27 | 28 | customElements.define('extended-combo-widget', ExtendedCombo, {extends: 'span'}) -------------------------------------------------------------------------------- /js/constants.js: -------------------------------------------------------------------------------- 1 | 2 | export const VERSION = "1.6.1" 3 | 4 | //export const MAXIMUM_UPSTREAM = 4 5 | 6 | export class SettingIds { 7 | static KEYBOARD_TOGGLE = "Controller.Display.keyboard" 8 | static CONTROL_AFTER_GENERATE = "Controller.Display.control_after_generate" 9 | static SCROLL_MOVES_SLIDERS = "Controller.Sliders.scroll_moves_slider" 10 | static SCROLL_REVERSED = "Controller.Sliders.scroll_reversed_for_slider" 11 | static EDIT_SLIDERS = "Controller.Sliders.edit_slider" 12 | static DEBUG_LEVEL = "Controller.Debug.level" 13 | static FONT_SIZE = "Controller.Display.font_size" 14 | static TOOLTIPS = "Controller.Display.tooltips" 15 | static MINIMUM_TAB_WIDTH = "Controller.Display.minimum_tab_width" 16 | static DEFAULT_APPLY_TO_SIMILAR = "Controller.Sliders.default_apply_to_similar" 17 | static SHOW_SCROLLBARS = "Controller.Display.show_scrollbars" 18 | static SHOW_IN_FOCUS_MODE = "Controller.Display.show_in_focus_mode" 19 | static HIDE_EXTENSIONS = "Controller.Display.hide_extensions" 20 | } 21 | 22 | export class SettingNames { 23 | static KEYBOARD_TOGGLE = "Toggle controller visibility:" 24 | static FONT_SIZE = "Base font size:" 25 | static CONTROL_AFTER_GENERATE = "Show 'control before/after generate'" 26 | static TOOLTIPS = "Show tooltips" 27 | static MINIMUM_TAB_WIDTH = "Minimum tab width" 28 | static DEFAULT_APPLY_TO_SIMILAR = "Default apply to similar" 29 | static SHOW_IN_FOCUS_MODE = "Show controllers in focus mode" 30 | static SCROLL_MOVES_SLIDERS = "Scrollwheel changes sliders" 31 | static SCROLL_REVERSED = "Scrollwheel reversed for sliders" 32 | static SHOW_SCROLLBARS = "Controller scrollbars" 33 | static EDIT_SLIDERS = "Edit slider limits" 34 | static DEBUG_LEVEL = "Debug level" 35 | static HIDE_EXTENSIONS = "Hide extensions" 36 | } 37 | 38 | export class Generic { 39 | static NEVER = "Never" 40 | static ALWAYS = "Always" 41 | static SHIFT = "shift key" 42 | static CTRL = "ctrl key" 43 | static OFF = "Off" 44 | static THIN = "Thin" 45 | static NORMAL = "Normal" 46 | static D0 = "Minimal" 47 | static D1 = "Normal" 48 | static D2 = "Extra" 49 | static D3 = "Verbose" 50 | static SHOW = "Show" 51 | static HIDE = "Hide" 52 | static SHOW_ALL = "[Show all widgets]" 53 | static HIDE_ALL = "[Hide all widgets]" 54 | } 55 | 56 | export class Tooltips { 57 | static FONT_SIZE = "All font sizes will be scaled relative to this value" 58 | static CONTROL_AFTER_GENERATE = "Allow the control_after_generate widget to be shown" 59 | static TOOLTIPS = "Refresh controller after changing" 60 | static MINIMUM_TAB_WIDTH = "Minimum width of a tab before switching to stacked layout" 61 | static DEFAULT_APPLY_TO_SIMILAR = "Default setting of 'apply to similar' checkbox" 62 | static SCROLL_REVERSED = "Scroll up to reduce value" 63 | static SHOW_SCROLLBARS = "If off, can still scroll with scrollwheel" 64 | static DEBUG_LEVEL = "Press f12 for js console" 65 | static HIDE_EXTENSIONS = "Hide filename extensions" 66 | } 67 | 68 | export class InclusionOptions { 69 | static EXCLUDE = "Don't include this node" 70 | static INCLUDE = "Include this node" 71 | static ADVANCED = "Include this node as advanced control" 72 | static FAVORITE = "Include this node as favorite" 73 | static EXCLUDES = InclusionOptions.EXCLUDE.replace('this node', 'these nodes') 74 | static INCLUDES = InclusionOptions.INCLUDE.replace('this node', 'these nodes') 75 | static ADVANCEDS = InclusionOptions.ADVANCED.replace('this node', 'these nodes') 76 | static FAVORITES = InclusionOptions.FAVORITE.replace('this node', 'these nodes') 77 | } 78 | 79 | export class Timings { // ms 80 | static GENERIC_SHORT_DELAY = 20 81 | static GENERIC_LONGER_DELAY = 1000 82 | static GENERIC_MUCH_LONGER_DELAY = 5000 83 | static PERIODIC_CHECK = 1000 // on_change 'tick' 84 | static DRAG_PAUSE_OVER_BACKGROUND = 500 85 | static SLIDER_ACTIVE_DELAY = 300 86 | static UPDATE_EXCEPTION_WAITTIME = 10000 87 | static PAUSE_STACK_WAIT = 101 88 | static ACTIVE_ELEMENT_DELAY = 234 89 | static ON_CHANGE_GAP = 200 // must be less than PERIODIC_CHECK. How long to wait for gap in on_change calls 90 | static ALLOW_LAYOUT = 1000 91 | } 92 | 93 | export class Colors { 94 | static DARK_BACKGROUND = '#222222' 95 | static MENU_HIGHLIGHT = '#C08080' 96 | static FAVORITES_FG = '#CC4444' 97 | static FAVORITES_GROUP = '#223322' 98 | static FOREGROUND = '#FFFFFF' 99 | static OPTIONS = ['#FFFFFF', '#000000'] 100 | static UNSELECTED_DARKEN = 0.4 101 | static HEADER_DARKEN = 0.666 102 | } 103 | 104 | export class Pixels { 105 | static BORDER_WIDTH = 4 106 | static FOOTER = 4 107 | } 108 | 109 | export class Texts { 110 | static ALL_GROUPS = "All" 111 | static UNGROUPED = "Ungrouped" 112 | static FAVORITES = "❤" 113 | static CONTEXT_MENU = "Controller Panel" 114 | static MODE_TOOLTIP = { 115 | 0 : "Click to bypass
ctrl‑click to mute", 116 | 2 : "Group muted.
Click to activate", 117 | 4 : "Group bypassed.
Click to activate", 118 | 9 : "Some nodes muted or bypassed.
Click to activate" 119 | } 120 | static REMOVE = "Remove from controllers" 121 | static EDIT_WV = "Edit widget visibility" 122 | static IMAGE_WIDGET_NAME = "image viewer" 123 | static UNCONNECTED = "Unconnected Input" 124 | static EDIT_IMAGE_SETTING = "Images" 125 | static ACCEPT_UPSTREAM = "Show upstream images" 126 | static REJECT_UPSTREAM = "Only show my images" 127 | static NO_IMAGES = "Don't show images" 128 | static STACK_ALWAYS = "Only show active tab" 129 | static STACK_IF_NEEDED = "Show all tabs if space allows" 130 | } 131 | 132 | export const DisplayNames = { 133 | "❤" : "❤ Favorites" 134 | } 135 | 136 | export const BASE_PATH = "extensions/cg-controller" 137 | 138 | -------------------------------------------------------------------------------- /js/context_menu.js: -------------------------------------------------------------------------------- 1 | import { app } from "../../scripts/app.js" 2 | import { Timings } from "./constants.js" 3 | 4 | var context_menu 5 | var closable = false 6 | 7 | function autoclose(e) { 8 | if (context_menu?.root?.contains(e.target)) return 9 | close_context_menu() 10 | } 11 | 12 | export function close_context_menu() { 13 | context_menu?.close() 14 | if (closable) { 15 | Array.from(document.getElementsByClassName('litecontextmenu')).forEach((e)=>e.remove()) 16 | } 17 | closable = false 18 | context_menu = null 19 | } 20 | 21 | export function register_closable() { 22 | closable = true 23 | } 24 | 25 | function _open_context_menu(e, title, values, opts) { 26 | close_context_menu() 27 | 28 | const options = { 29 | "title":title, 30 | "event":e, 31 | } 32 | if (opts) Object.assign(options, opts) 33 | context_menu = LiteGraph.ContextMenu(values, options, app.canvas.getCanvasWindow()) 34 | } 35 | export function open_context_menu(e, title, values, opts, node) { 36 | if (node) app.canvas.current_node = node 37 | setTimeout(_open_context_menu, Timings.GENERIC_SHORT_DELAY, e, title, values, opts) 38 | } 39 | 40 | window.addEventListener('click',autoclose) 41 | -------------------------------------------------------------------------------- /js/controller.css: -------------------------------------------------------------------------------- 1 | /* Menu */ 2 | 3 | .controller_menu_buttons { 4 | background-color: var(--comfy-input-bg); 5 | border-radius: 6px; 6 | } 7 | .controller_menu_button { 8 | cursor: pointer; 9 | font-size: 12pt; 10 | background-color: var(--comfy-input-bg); 11 | padding: 6px; 12 | border-radius: 6px; 13 | } 14 | 15 | .controller_menu_button.litup { 16 | color: var(--p-button-text-primary-color); 17 | } 18 | 19 | .hide .hideable { 20 | display: none; 21 | } 22 | 23 | .controller { 24 | --font-size: 12px; 25 | --element_width: 100%; 26 | 27 | --main-fore-color: #ffffff; 28 | --mid-fore-color: #c5c5c5; 29 | --muted-fore-color: #999999; 30 | 31 | --deep-back-color: #000000; 32 | --main-back-color: #1c1c1cfa; 33 | --second-back-color: #222222; 34 | --third-back-color: #353535; 35 | 36 | --overlay-background: #ffffff99; 37 | --overlay-foreground: #353535; 38 | 39 | --mute-button-color:#ff6e6e; 40 | --bypass-button-color: #d179ff; 41 | --mixed-button-color:#99aa99; 42 | 43 | --mute-overlay-color: #ffffff30; 44 | --bypass-overlay-color: #d179ff30; 45 | 46 | --toggle-on: #8899aa; 47 | --toggle-off: #333333; 48 | --toggle-intermediate: #556677; 49 | 50 | --main-border-color: rgb(53 53 53); 51 | 52 | --progress-color: green; 53 | --alien-progress-color: rgb(0, 122, 128); 54 | --active-color: green; 55 | 56 | --border_width: 4px; 57 | 58 | font-size: var(--font-size); 59 | height:100%; 60 | min-height: 42px; 61 | background: var(--main-back-color); 62 | padding: 0 4px 0 4px; 63 | position: absolute; 64 | min-width: 130px; 65 | overflow: clip scroll; 66 | border-width: var(--border_width); 67 | border-style: solid; 68 | border-color: var(--main-border-color); 69 | border-radius: 4px; 70 | z-index:999; 71 | scrollbar-width: auto; 72 | scrollbar-color: var(--third-back-color) var(--main-back-color); 73 | pointer-events: all; 74 | user-select: none; 75 | } 76 | 77 | .controller.small_scrollbars { 78 | scrollbar-width: thin; 79 | } 80 | 81 | .controller.hide_scrollbars { 82 | scrollbar-width: none; 83 | } 84 | 85 | .controller.collapsed { 86 | overflow: clip; 87 | min-width: 0px; 88 | padding: 0px; 89 | } 90 | 91 | .gutter_overlay { 92 | position: absolute; 93 | background-color: transparent; 94 | width: 100%; 95 | height: 100%; 96 | pointer-events: none; 97 | z-index:999; 98 | } 99 | 100 | .gutter_overlap { 101 | position:absolute; 102 | pointer-events: none; 103 | background-color: #999999; 104 | border-radius: 2px; 105 | } 106 | 107 | 108 | /* Header */ 109 | 110 | .header { 111 | position: sticky; 112 | top: 0px; 113 | z-index: 40000; 114 | background: var(--second-back-color); 115 | display: flex; 116 | width: var(--element_width); 117 | justify-content: space-between; 118 | margin: 0px 0px 4px 0px; 119 | align-items: center; 120 | flex-wrap: wrap; 121 | z-index:1; 122 | } 123 | 124 | .subheader { 125 | width: 100%; 126 | display: flex; 127 | border-bottom: 1px solid var(--third-back-color); 128 | padding: 0px 4px 0px 4px; 129 | /*cursor: grab;*/ 130 | padding: 4px 0px 0px 0px; 131 | border-bottom: 1px solid var(--main-back-color); 132 | margin-bottom: 2px; 133 | justify-content: space-between; 134 | } 135 | 136 | .subheader1 { 137 | display:flex; 138 | } 139 | 140 | .subheader2 { 141 | padding-top:2px; 142 | margin-bottom:0px; 143 | } 144 | 145 | .subheader .left { 146 | 147 | } 148 | 149 | .subheader .right { 150 | 151 | } 152 | 153 | .subheader1 .right { 154 | min-width: max-content; 155 | } 156 | 157 | .controller.grabbed .subheader1 { 158 | cursor:grabbing; 159 | } 160 | 161 | .last { order: 100 } 162 | 163 | .tabs.group { 164 | display: flex; 165 | justify-content: flex-start; 166 | flex-direction: row; 167 | align-items: center; 168 | /* allow the group to shrink */ 169 | min-width: 0px; 170 | flex-shrink: 1; 171 | flex-grow: 1; 172 | } 173 | .tab { 174 | --base-color: black; 175 | border-radius: 6px; 176 | padding: 2px 5px; 177 | border: 1px solid var(--deep-back-color); 178 | /*background-color: var(--base-color); 179 | color: var(--muted-fore-color); set programatically*/ 180 | cursor: pointer; 181 | text-align: center; 182 | margin: 0px 1px 4px 1px; 183 | max-width: fit-content; 184 | /* min-width, flex-basis, flex-sshrink, flex-grow all set programatically */ 185 | text-overflow: ellipsis; 186 | overflow: hidden; 187 | } 188 | .tab.selected { 189 | 190 | } 191 | 192 | .tab.stack { 193 | box-shadow: 1px 1px var(--muted-fore-color); 194 | } 195 | 196 | .controller.grabbed .tab { 197 | cursor:grabbing; 198 | } 199 | 200 | .collapsed .tab { 201 | display: none; 202 | margin-bottom: 0px; 203 | margin-left:4px; 204 | } 205 | .collapsed .tab.selected { 206 | display: block; 207 | } 208 | 209 | .header_buttons { 210 | border-bottom: 1px solid var(--deep-back-color); 211 | flex-shrink: 0; 212 | } 213 | 214 | 215 | 216 | .header_button { 217 | cursor: pointer; 218 | font-size: 12pt; 219 | color: var(--mid-fore-color); 220 | margin: 8px 4px 0px 4px; 221 | top:-4px; 222 | position: relative; 223 | } 224 | 225 | .header_button.collapse_button { 226 | top: 4px; 227 | } 228 | 229 | .collapse_button { 230 | margin-top:0; 231 | padding-top: 8px; 232 | } 233 | 234 | .header_button.mode { 235 | 236 | } 237 | .header_button.mode:before { 238 | content: "\ea14"; 239 | } 240 | .header_button.mode_2:before { 241 | content: "\e90c"; 242 | color: var(--mute-button-color); 243 | } 244 | .header_button.mode_4:before { 245 | content: "\ea10"; 246 | color: var(--bypass-button-color) 247 | } 248 | .header_button.mode_9:before { 249 | content: "\e959"; 250 | color: var(--mixed-button-color); 251 | } 252 | 253 | 254 | .clicked { 255 | color: var(--p-button-text-primary-color); 256 | } 257 | 258 | .being_dragged { 259 | opacity: 0.5; 260 | cursor: grabbing; 261 | } 262 | 263 | .extra_controls { 264 | height: 15px; 265 | } 266 | 267 | .collapsed .main { display:none; } 268 | .collapsed .footer { display: none; } 269 | 270 | 271 | .empty_message { 272 | display: block; 273 | font-size: 70%; 274 | text-align: center; 275 | width: var(--element_width); 276 | margin-right:5px; 277 | } 278 | 279 | /* Node block */ 280 | 281 | .nodeblock { 282 | margin: 0px 0px 5px 0px; 283 | position: relative; 284 | width: var(--element_width); 285 | min-height: 40px; 286 | display:block; 287 | padding-bottom: 3px; 288 | border: 1px solid transparent; 289 | } 290 | 291 | .nodeblock.mode_2 { 292 | opacity: 0.5; 293 | } 294 | .nodeblock.mode_4 { 295 | opacity: 0.5; 296 | } 297 | 298 | .nodeblock.active { 299 | border-color: var(--active-color); 300 | } 301 | 302 | .nodeblock.minimised { 303 | min-height: unset; 304 | padding-bottom: 0px; 305 | overflow: hidden; 306 | } 307 | 308 | .nodeblock_titlebar { 309 | font-size: 80%; 310 | display: block; 311 | padding: 2px 3px 2px 2px; 312 | color: var(--mid-fore-color); 313 | display: flex; 314 | justify-content: space-between; 315 | align-items: center; 316 | } 317 | 318 | .nodeblock_titlebar_right .pi { 319 | font-size:1.25em; 320 | vertical-align: middle; 321 | } 322 | 323 | .nodeblock_titlebar_left .pi { 324 | font-size:1.05em; 325 | top: 0.05em; 326 | position: relative; 327 | } 328 | 329 | .minimised .entry { display: none; } 330 | 331 | .minimisedot { 332 | padding: 0px 0px 0px 0px; 333 | cursor: url(collapse.png) 8 8, zoom-out; 334 | position: relative; 335 | font-size: 125%; 336 | top: 1px; 337 | } 338 | 339 | .nodeblock.minimised .minimisedot { 340 | cursor: url(expand.png) 8 8, zoom-out; 341 | } 342 | 343 | .titlebar_nocolor { 344 | border-bottom: 1px solid var(--second-back-color); 345 | padding-bottom: 1px; 346 | background-color: var(--third-back-color) 347 | } 348 | 349 | .nodeblock.minimised .nodeblock_titlebar { 350 | border-bottom: 0px; 351 | padding-bottom: 0px; 352 | } 353 | 354 | .nodeblock_draghandle { 355 | flex-grow: 1; 356 | flex-shrink: 1; 357 | max-width: calc(100% - 30px); 358 | } 359 | 360 | .nodeblock_title { 361 | text-align: left; 362 | pointer-events: none; 363 | padding-left: 4px; 364 | position: relative; 365 | bottom: 1px; 366 | overflow: clip; 367 | text-wrap-mode: nowrap; 368 | text-overflow: ellipsis; 369 | display: block; 370 | } 371 | .nodeblock_title::after { 372 | content: " "; 373 | font-size: 1.25em; 374 | } 375 | 376 | .nodeblock_image_panel { 377 | width: 100%; 378 | resize: vertical; 379 | overflow: hidden; 380 | display: flex; 381 | justify-content: space-evenly; 382 | flex-direction: column; 383 | align-items: center; 384 | margin-top: 4px; 385 | } 386 | 387 | .nodeblock_image_empty { 388 | width: 100%; 389 | height: 10px; 390 | border: none; 391 | display:none; 392 | } 393 | 394 | 395 | .nodeblock_image_grid { 396 | display: grid; 397 | justify-content: center; 398 | align-items: center; 399 | justify-items: center; 400 | align-content: center; 401 | height: 100%; 402 | width: auto; 403 | max-width: 100%; 404 | } 405 | 406 | .nodeblock_image_grid_image { 407 | max-width: 100%; 408 | max-height: 100%; 409 | padding: 1px; 410 | } 411 | 412 | .nodeblock_image_overlay { 413 | position: absolute; 414 | pointer-events: none; 415 | height: auto; 416 | } 417 | 418 | 419 | .overlay.overlay_show_grid { 420 | padding: 2px 4px; 421 | bottom: unset; 422 | top: 2.5em; 423 | } 424 | 425 | .nodeblock.minimised .nodeblock_image_panel { 426 | display:none; 427 | height:0px !important; 428 | } 429 | 430 | 431 | 432 | .nodeblock .mode_button:before { 433 | content: "\ea14"; 434 | } 435 | 436 | .nodeblock .mode_button_2:before { 437 | content: "\e90c"; 438 | color: var(--mute-button-color); 439 | } 440 | 441 | .nodeblock .mode_button_4:before { 442 | content: "\ea10"; 443 | color: var(--bypass-button-color) 444 | } 445 | 446 | /* Entry (widget) */ 447 | 448 | .entry { 449 | position: relative; 450 | padding:4px 3px 0px 3px; 451 | } 452 | 453 | .two_line_entry { 454 | display:flex; 455 | flex-direction:column; 456 | } 457 | 458 | .two_line_entry .line { 459 | display:flex; 460 | justify-content: space-between; 461 | border-bottom: 1px solid var(--third-back-color); 462 | } 463 | 464 | .line .toggle { 465 | width: unset; 466 | padding-left:4px; 467 | } 468 | 469 | .line .label { 470 | font-size: 70%; 471 | flex-grow: 1; 472 | background-color: var(--second-back-color); 473 | height: 20px; 474 | padding: 4px; 475 | text-overflow: ellipsis; 476 | overflow: hidden; 477 | text-wrap-mode: nowrap; 478 | } 479 | 480 | .entry_label { 481 | position: absolute; 482 | height: calc(var(--font-size) - 2px); 483 | top: 9px; 484 | padding-right: 6px; 485 | background: var(--second-back-color); 486 | color: var(--muted-fore-color); 487 | text-align: left; 488 | left: 6px; 489 | font-size: 70%; 490 | pointer-events: none; 491 | width: calc(var(--element_width) - 40px); 492 | overflow: clip; 493 | text-overflow: ellipsis; 494 | text-wrap-mode: nowrap; 495 | } 496 | 497 | .entry_label.text { 498 | background: #00000000; 499 | } 500 | 501 | .combo_label_wrapper { 502 | position: relative; 503 | padding: 0; 504 | margin: 0; 505 | display: flex 506 | ; 507 | width: 100%; 508 | height: 100%; 509 | } 510 | 511 | .entry_label.combo { 512 | background-color: var(--second-back-color); 513 | padding: 4px 0px 4px 4px; 514 | position: unset; 515 | width: unset; 516 | height: 20px; 517 | flex-shrink:0; 518 | min-width: 0; 519 | } 520 | 521 | .entry_label.value { 522 | right: 20px; 523 | height: calc(var(--font-size) - 2px); 524 | top: 9px; 525 | color: var(--main-fore-color); 526 | width: calc(100% - 24px); 527 | } 528 | 529 | .input { 530 | width: 100%; 531 | left: 25%; 532 | font-size: 75%; 533 | background-color: var(--second-back-color); 534 | color: var(--main-fore-color); 535 | border: none; 536 | height: 20px; 537 | text-align: right; 538 | } 539 | .input.clickabletext { 540 | /*display:block;*/ 541 | text-align: right; 542 | padding: 4px; 543 | font-size: 70%; 544 | overflow: clip; 545 | text-wrap-mode: nowrap; 546 | text-overflow: ellipsis; 547 | width: unset; 548 | flex-shrink: 1; 549 | flex-grow: 1; 550 | min-width: 0; 551 | } 552 | select.input { 553 | height: calc(var(--font-size) + 4px) 554 | } 555 | 556 | button.input { 557 | text-align: center; 558 | } 559 | 560 | textarea.input { 561 | height: 72px; 562 | resize: vertical; 563 | font-size: 85%; 564 | text-align: left; 565 | margin-bottom: -4px; 566 | padding: 3px 4px 3px 4px; 567 | } 568 | 569 | .input option { 570 | text-align:left; 571 | } 572 | 573 | .toggle { 574 | width: 100%; 575 | display: flex; 576 | justify-content: space-between; 577 | align-items: center; 578 | background-color:var(--second-back-color) 579 | } 580 | 581 | .toggle_label { 582 | font-size: 70%; 583 | padding-left: 4px; 584 | } 585 | 586 | .toggle_value { 587 | display: flex; 588 | justify-content: flex-end; 589 | align-items: center; 590 | } 591 | .toggle_text { 592 | font-size: 70%; 593 | } 594 | 595 | .toggle_graphic { 596 | font-size: 80%; 597 | cursor: pointer; 598 | padding: 1px 4px 2px 4px; 599 | } 600 | 601 | .toggle_graphic.true { 602 | color: var(--toggle-on); 603 | } 604 | 605 | .toggle_graphic.false { 606 | color: var(--toggle-off); 607 | } 608 | .toggle_graphic.intermediate { 609 | color: var(--toggle-intermediate); 610 | } 611 | 612 | .muted .toggle_value .toggle_text { 613 | color: var(--muted-fore-color); 614 | } 615 | 616 | .muted .toggle_label { 617 | color: var(--muted-fore-color); 618 | } 619 | 620 | .muted .line { 621 | color: var(--muted-fore-color); 622 | } 623 | 624 | .muted .line .fancy_slider .fs_graphic .fs_graphic_fill { 625 | opacity: 50%; 626 | } 627 | 628 | .image_comparer_widget { 629 | display:flex; 630 | justify-content: center; 631 | } 632 | 633 | .image_comparer_option { 634 | padding: 0px 8px; 635 | color: var(--muted-fore-color) 636 | } 637 | 638 | .image_comparer_option.selected { 639 | color: var(--main-fore-color) 640 | } 641 | 642 | /* Footer */ 643 | 644 | .footer { 645 | width: 100%; 646 | height: 20px; 647 | display: block; 648 | position: absolute; 649 | } 650 | 651 | /* group add */ 652 | 653 | .group_add_select { 654 | position:absolute; 655 | z-index: 1001; 656 | background-color: var(--comfy-input-bg); 657 | border: thin solid var(--border-color); 658 | border-radius: 6px; 659 | padding: 4px; 660 | } 661 | 662 | .group_add_option { 663 | padding: 2px; 664 | cursor: pointer; 665 | color: var(--fg-color); 666 | border-bottom: 1px solid var(--border-color); 667 | border-left: 2px solid var(--comfy-input-bg); 668 | } 669 | 670 | .group_add_option:last-child { 671 | border-bottom: none; 672 | } 673 | 674 | .group_add_option:hover { 675 | border-left-color: var(--comfy-input-fg); 676 | } 677 | 678 | 679 | /* Global */ 680 | 681 | .hidden { 682 | display:none !important; 683 | } 684 | 685 | .blank { 686 | color: transparent; 687 | } 688 | 689 | i { 690 | cursor: pointer; 691 | } 692 | 693 | .overlay { 694 | position: absolute; 695 | text-align: right; 696 | bottom:10px; 697 | background-color: var(--overlay-background); 698 | color: var(--overlay-foreground); 699 | right: 10px; 700 | padding: 3px; 701 | border-radius: 4px; 702 | font-size: 80%; 703 | } 704 | 705 | .overlay_paging { 706 | display:flex; 707 | padding-bottom: 1px; 708 | bottom: unset; 709 | top: 2.5em; 710 | right: 2em; 711 | } 712 | 713 | .overlay_paging_icon { 714 | font-family: 'primeicons'; 715 | min-width: 1em; 716 | } 717 | .overlay_paging_text { 718 | text-align: center; 719 | min-width: 2.5em; 720 | padding: 0px 4px; 721 | position: relative; 722 | top: -1px; 723 | } 724 | .overlay_paging_icon:before { 725 | 726 | } 727 | .overlay_paging_icon.prev:before { 728 | content: "\e928"; 729 | } 730 | .overlay_paging_icon.next:before { 731 | content: "\e92a"; 732 | } 733 | 734 | .tooltip { 735 | position: relative; 736 | display: inline-block; 737 | z-index:1000; 738 | } 739 | 740 | /* Tooltip text */ 741 | .tooltip .tooltiptext { 742 | visibility:hidden; 743 | background-color: #ffffff66; 744 | color: #353535; 745 | font-size: 70%; 746 | text-align: center; 747 | padding: 5px; 748 | border-radius: 6px; 749 | position: absolute; 750 | right: 0px; 751 | top: 20px; 752 | } 753 | 754 | .tooltip.right .tooltiptext { 755 | right: unset; 756 | left: 0px; 757 | } 758 | 759 | .controller.collapsed .tooltiptext { 760 | top:0px; 761 | } 762 | 763 | .progress_bar { 764 | position: absolute; 765 | background-color: var(--progress-color); 766 | left: 0px; 767 | /* top, width, height set programatically */ 768 | } 769 | 770 | /* Show the tooltip text when you mouse over the tooltip container */ 771 | .tooltip:hover .tooltiptext { 772 | visibility: visible; 773 | } 774 | 775 | .read_only .mode_button { 776 | opacity: 0.5; 777 | cursor: unset; 778 | } 779 | 780 | .read_only .header_button { 781 | opacity: 0.5; 782 | cursor: unset; 783 | } 784 | 785 | .image_popup { 786 | position: absolute; 787 | width: 100%; 788 | height: 100%; 789 | background-color: black; 790 | border: none; 791 | display: flex; 792 | justify-content: center; 793 | align-items: center; 794 | z-index: 2000; 795 | } 796 | 797 | .image_popup_frame { 798 | background-color: #333333; 799 | /*border: thin solid #333333;*/ 800 | display: flex; 801 | width: 100%; 802 | height: 100%; 803 | justify-content: center; 804 | } 805 | 806 | .image_popup_image { 807 | border: 12px solid black; 808 | max-width: 100%; 809 | max-height: 100%; 810 | background-color: white; 811 | padding: 16px; 812 | } -------------------------------------------------------------------------------- /js/controller.js: -------------------------------------------------------------------------------- 1 | import { app } from "../../scripts/app.js" 2 | import { api } from "../../scripts/api.js" 3 | import { ControllerPanel } from "./controller_panel.js" 4 | import { create, mouse_change, send_graph_changed } from "./utilities.js" 5 | import { OPTIONS } from "./options.js" 6 | import { add_control_panel_options, NodeInclusionManager, } from "./node_inclusion.js" 7 | import { OnChangeController, UpdateController } from "./update_controller.js" 8 | import { Debug } from "./debug.js" 9 | import { BASE_PATH, SettingIds, Timings } from "./constants.js" 10 | import { ImageManager } from "./image_manager.js" 11 | import { global_settings } from "./settings.js" 12 | import { FancySlider } from "./input_slider.js" 13 | import { WindowResizeManager } from "./snap_manager.js" 14 | import { Highlighter } from "./highlighter.js" 15 | import { GroupManager } from "./groups.js" 16 | import { NodeBlock } from "./nodeblock.js" 17 | import { ImagePopup } from "./image_popup.js" 18 | import { pim } from "./prompt_id_manager.js" 19 | 20 | const MINIMUM_UE = 500006 21 | async function check_ue() { 22 | try { 23 | let ue = await import("../cg-use-everywhere/ue_debug.js") 24 | if (!ue.version || ue.version < MINIMUM_UE) { 25 | alert("A warning from Comfy Controller\n\n" + 26 | "The version of Use Everywhere nodes that you have installed is not compatible with Comfy Controller or newer version of the Comfy UI.\n\n"+ 27 | "You should update to the latest version of Use Everywhere (which is much improved anyway!) to avoid problems.\n\n" + 28 | "You can do this through the manager, or manually by going into the custom_nodes/cg-use-everywhere directory and typing 'git pull'.") 29 | } 30 | } catch (e) { 31 | Debug.extended("Use Everywhere nodes not installed") 32 | } 33 | } 34 | 35 | function stop_event(e) { 36 | e.preventDefault() 37 | e.stopImmediatePropagation() 38 | return false 39 | } 40 | 41 | function on_setup() { 42 | UpdateController.setup(ControllerPanel.redraw, ControllerPanel.can_refresh, ControllerPanel.node_change) 43 | NodeInclusionManager.node_change_callback = UpdateController.make_request 44 | GroupManager.change_callback = ControllerPanel.on_group_details_change 45 | ImageManager.node_image_change = ControllerPanel.node_image_change 46 | 47 | api.addEventListener('graphCleared', ControllerPanel.on_graphCleared) 48 | 49 | api.addEventListener('executed', ImageManager.on_executed) 50 | api.addEventListener('executed', pim.on_executed) 51 | api.addEventListener('execution_start', ImageManager.on_execution_start) 52 | api.addEventListener('executing', ImageManager.on_executing) 53 | api.addEventListener('b_preview', ImageManager.on_b_preview) 54 | 55 | api.addEventListener('progress', ControllerPanel.on_progress) 56 | api.addEventListener('executing', ControllerPanel.on_executing) 57 | 58 | api.addEventListener('executing', OnChangeController.on_executing) 59 | 60 | window.addEventListener("resize", WindowResizeManager.onWindowResize) 61 | window.addEventListener('mousedown', (e)=>{ 62 | mouse_change(true) 63 | if (e.button==2) e.target.handle_right_click?.(e) 64 | }) 65 | window.addEventListener('click', (e)=>{ 66 | mouse_change(true) 67 | if (e.ctrlKey) e.target.handle_right_click?.(e) 68 | }) 69 | window.addEventListener('mouseup', (e)=>{ 70 | mouse_change(false) 71 | ControllerPanel.handle_mouse_up(e) 72 | FancySlider.handle_mouse_up(e) 73 | }) 74 | window.addEventListener('click', (e)=>{ 75 | ImagePopup.handle_click(e) 76 | }) 77 | window.addEventListener('mousemove', (e)=>{ 78 | ControllerPanel.handle_mouse_move(e) 79 | FancySlider.handle_mouse_move(e) 80 | NodeBlock.handle_mouse_move(e) 81 | }) 82 | window.addEventListener('contextmenu', (e)=>{ 83 | if (e.target.handle_right_click) return stop_event(e); 84 | }) 85 | window.addEventListener('keypress', (e) => { 86 | if (e.target.tagName=="CANVAS" || e.target.tagName=="BODY") { 87 | const keysetting = app.ui.settings.getSettingValue(SettingIds.KEYBOARD_TOGGLE) 88 | if (keysetting==e.key) { 89 | ControllerPanel.toggle() 90 | e.preventDefault() 91 | e.stopImmediatePropagation() 92 | return false 93 | } 94 | } 95 | }) 96 | 97 | 98 | const original_getCanvasMenuOptions = LGraphCanvas.prototype.getCanvasMenuOptions; 99 | LGraphCanvas.prototype.getCanvasMenuOptions = function () { 100 | // get the basic options 101 | const options = original_getCanvasMenuOptions.apply(this, arguments); 102 | if (!global_settings.hidden) { 103 | options.push(null); // inserts a divider 104 | options.push({ 105 | content: "New Controller", 106 | callback: async (_, e) => { 107 | ControllerPanel.create_new(e?.event) 108 | } 109 | }) 110 | } 111 | return options; 112 | } 113 | ControllerPanel.create_menu_icon() 114 | } 115 | 116 | app.registerExtension({ 117 | name: "cg.controller", 118 | settings: OPTIONS, 119 | 120 | async beforeConfigureGraph() { 121 | UpdateController.configuring(true) 122 | }, 123 | 124 | /* Called when the graph has been configured (page load, workflow load) */ 125 | async afterConfigureGraph() { 126 | UpdateController.configuring(false) 127 | try { 128 | ControllerPanel.on_new_workflow() 129 | ImageManager.analyse_graph() 130 | ImageManager.send_all() 131 | send_graph_changed(true) 132 | } catch (e) { 133 | console.error(e) 134 | } 135 | }, 136 | 137 | /* Called at the end of the application startup */ 138 | async setup() { 139 | // Add the css call to the document 140 | create('link', null, document.getElementsByTagName('HEAD')[0], 141 | {'rel':'stylesheet', 'type':'text/css', 'href': new URL("./controller.css", import.meta.url).href } ) 142 | create('link', null, document.getElementsByTagName('HEAD')[0], 143 | {'rel':'stylesheet', 'type':'text/css', 'href': new URL("./slider.css", import.meta.url).href} ) 144 | 145 | // Allow our elements to do any setup they want 146 | try { 147 | on_setup() 148 | } catch (e) { Debug.error("on setup", e) } 149 | 150 | try { 151 | const onAfterChange = app.graph.onAfterChange 152 | app.graph.onAfterChange = function () { 153 | try { 154 | onAfterChange?.apply(this,arguments) 155 | OnChangeController.on_change('graph.onAfterChange') 156 | } catch (e) { 157 | Debug.error("onAfterChange", e) 158 | } 159 | } 160 | } catch (e) { 161 | Debug.error("ADDING onAfterChange", e) 162 | } 163 | 164 | const draw = app.canvas.onDrawForeground; 165 | app.canvas.onDrawForeground = function(ctx, visible) { 166 | draw?.apply(this,arguments); 167 | try { 168 | Highlighter.on_draw(ctx); 169 | } catch (e) { 170 | Debug.error('onDrawForeground', e) 171 | } 172 | } 173 | 174 | /* look for dialog boxes appearing or disappearing */ 175 | new MutationObserver((mutations)=>{ 176 | var need_update = "" 177 | mutations.forEach((mutation)=>{ 178 | mutation.addedNodes.forEach((n)=>{ 179 | if (n.classList?.contains?.('p-dialog-mask')) need_update = "dialog added" 180 | }) 181 | mutation.removedNodes.forEach((n)=>{ 182 | if (n.classList?.contains?.('p-dialog-mask')) need_update = "dialog removed" 183 | }) 184 | }) 185 | if (need_update != "") UpdateController.make_request(`mutation: ${need_update}`) 186 | }).observe(document.body, {"childList":true}) 187 | 188 | /* look for focus mode start or stop */ 189 | new MutationObserver((mutations)=>{ 190 | var focus_change = false 191 | mutations.forEach((mutation)=>{ 192 | mutation.addedNodes.forEach((n)=>{ 193 | if (n.$pc?.name == "Splitter") focus_change = true 194 | }) 195 | mutation.removedNodes.forEach((n)=>{ 196 | if (n.$pc?.name == "Splitter") focus_change = true 197 | }) 198 | }) 199 | if (focus_change) ControllerPanel.focus_mode_changed() 200 | }).observe(document.getElementsByClassName('graph-canvas-container')[0], {"childList":true}) 201 | 202 | const queuePrompt = api.queuePrompt 203 | api.queuePrompt = async function() { 204 | const r = await queuePrompt.apply(api, arguments) 205 | pim.add(r.prompt_id) 206 | return r 207 | } 208 | 209 | check_ue() 210 | }, 211 | 212 | async refreshComboInNodes() { 213 | UpdateController.make_request('refreshComboInNodes') 214 | UpdateController.make_request('refreshComboInNodes delayed',1000) 215 | }, 216 | 217 | async init() { 218 | const getExtraMenuOptions = LGraphNode.prototype.getExtraMenuOptions 219 | LGraphNode.prototype.getExtraMenuOptions = function(_, options) { 220 | getExtraMenuOptions?.apply(this, arguments); 221 | add_control_panel_options(options) 222 | } 223 | 224 | const onDrawTitle = LGraphNode.prototype.onDrawTitle 225 | LGraphNode.prototype.onDrawTitle = function (ctx) { 226 | onDrawTitle?.apply(this,arguments) 227 | NodeInclusionManager.visual(ctx, this) 228 | } 229 | }, 230 | 231 | async beforeRegisterNodeDef(nodeType, nodeData, app) { 232 | nodeType.prototype.__controller_tooltips = {} 233 | Object.values(nodeData.input).forEach((list)=>{ 234 | Object.keys(list).filter((k)=>(list[k].length>1 && list[k][1].tooltip)).forEach((k)=>{ 235 | nodeType.prototype.__controller_tooltips[k] = list[k][1].tooltip 236 | }) 237 | }) 238 | 239 | const getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions; 240 | nodeType.prototype.getExtraMenuOptions = function(_, options) { 241 | getExtraMenuOptions?.apply(this, arguments); 242 | add_control_panel_options(options) 243 | } 244 | 245 | // request an update if an input is added or removed 246 | const onInputRemoved = nodeType.prototype.onInputRemoved 247 | nodeType.prototype.onInputRemoved = function () { 248 | onInputRemoved?.apply(this,arguments) 249 | ControllerPanel.node_change(this.id, "onInputRemoved") 250 | } 251 | const onInputAdded = nodeType.prototype.onInputAdded 252 | nodeType.prototype.onInputAdded = function () { 253 | onInputAdded?.apply(this,arguments) 254 | ControllerPanel.node_change(this.id, "onInputAdded") 255 | app.graph.afterChange() 256 | } 257 | const onOutputRemoved = nodeType.prototype.onOutputRemoved 258 | nodeType.prototype.onOutputRemoved = function () { 259 | onOutputRemoved?.apply(this,arguments) 260 | ControllerPanel.node_change(this.id, "onOutputRemoved") 261 | } 262 | const onOutputAdded = nodeType.prototype.onOutputAdded 263 | nodeType.prototype.onOutputAdded = function () { 264 | onOutputAdded?.apply(this,arguments) 265 | ControllerPanel.node_change(this.id, "onOutputAdded") 266 | } 267 | 268 | const onModeChange = nodeType.prototype.onModeChange 269 | nodeType.prototype.onModeChange = function () { 270 | onModeChange?.apply(this,arguments) 271 | ControllerPanel.node_change(this.id, "onModeChange") 272 | } 273 | }, 274 | 275 | async nodeCreated(node) { 276 | 277 | const onRemoved = node.onRemoved 278 | node.onRemoved = function() { 279 | onRemoved?.apply(this, arguments) 280 | UpdateController.make_request_unless_configuring("node_removed", Timings.GENERIC_SHORT_DELAY) 281 | } 282 | 283 | const onDrawForeground = node.onDrawForeground 284 | node.onDrawForeground = function() { 285 | onDrawForeground?.apply(this,arguments) 286 | if (node.imgs) ImageManager.node_reported_images(node.id, node.imgs) 287 | } 288 | 289 | UpdateController.make_request_unless_configuring("node_created", Timings.GENERIC_SHORT_DELAY) 290 | }, 291 | 292 | }) 293 | -------------------------------------------------------------------------------- /js/controller_node.js: -------------------------------------------------------------------------------- 1 | import { app } from "../../scripts/app.js" 2 | /* 3 | A custom node which doesn't do anything. Only still here for backward compatibility with workflows saved before v0.6 4 | */ 5 | 6 | export class CGControllerNode extends LiteGraph.LGraphNode { 7 | instance = undefined 8 | constructor() { 9 | super("CGControllerNode") 10 | CGControllerNode.instance = this 11 | } 12 | 13 | static remove() { 14 | if (CGControllerNode.instance) { 15 | app.graph.remove(CGControllerNode.instance) 16 | CGControllerNode.instance = undefined 17 | } 18 | } 19 | 20 | } -------------------------------------------------------------------------------- /js/debug.js: -------------------------------------------------------------------------------- 1 | import { app } from "../../scripts/app.js" 2 | import { SettingIds, VERSION } from "./constants.js" 3 | 4 | export class _Debug { 5 | static instance 6 | 7 | constructor(title = "Controller") { 8 | this.prefix = title 9 | this.last_message = null 10 | _Debug.instance = this 11 | } 12 | 13 | _log(message, level, repeatok) { 14 | if ((message == this.last_message && !repeatok) || 15 | level > app.ui.settings.getSettingValue(SettingIds.DEBUG_LEVEL)) return 16 | this.last_message = message 17 | console.log(`${VERSION} ${(this.prefix instanceof Function) ? this.prefix() : this.prefix}: (${level}) ${message}`) 18 | } 19 | error(message, e) { 20 | _Debug.instance._log(message, 0, true); 21 | if (e) console.error(e) 22 | } 23 | essential(message, repeatok) { _Debug.instance._log(message, 0, repeatok) } 24 | important(message, repeatok) { _Debug.instance._log(message, 1, repeatok) } 25 | extended(message, repeatok) { _Debug.instance._log(message, 2, repeatok) } 26 | trivia(message, repeatok) { _Debug.instance._log(message, 3, repeatok) } 27 | } 28 | 29 | export const Debug = new _Debug() -------------------------------------------------------------------------------- /js/expand.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisgoringe/cg-controller/bbc23b22cf2a9cd5ffdf039b9e5bbb8ea440a838/js/expand.png -------------------------------------------------------------------------------- /js/groups.js: -------------------------------------------------------------------------------- 1 | import { app } from "../../scripts/app.js" 2 | import { NodeInclusionManager } from "./node_inclusion.js" 3 | import { Colors, Texts, DisplayNames } from "./constants.js" 4 | import { mode_change, pickContrastingColor, darken } from "./utilities.js" 5 | import { Debug } from "./debug.js" 6 | 7 | function recompute_safely(group) { 8 | const _g = [...group._nodes] 9 | const _c = new Set(group._children) 10 | group.recomputeInsideNodes() 11 | group._controller_nodes = [...group._nodes] 12 | group._controller_children = new Set(group._children) 13 | group._nodes = _g 14 | group._children = _c 15 | return group._controller_nodes 16 | } 17 | 18 | function recompute_all_safely() { 19 | app.graph._groups.forEach((g)=>{recompute_safely(g)}) 20 | } 21 | 22 | export function family_names(group_name) { 23 | recompute_all_safely() 24 | /* Given the name of a group, return a list of the names of all parents and children of all groups with this name */ 25 | const names = new Set([group_name,]) 26 | app.graph._groups.filter((g)=>(g.title==group_name)).forEach((g)=>{ 27 | Array.from(g._controller_children).filter((c)=>(c instanceof LGraphGroup)).forEach((c)=>{names.add(c.title)}) 28 | app.graph._groups.filter((p)=>(p._controller_children.has(g))).forEach((p)=>{names.add(p.title)}) 29 | }) 30 | return names 31 | } 32 | 33 | export class GroupManager { 34 | static _instance = null 35 | static change_callback 36 | static group_properties = {} 37 | 38 | constructor() { 39 | this.groups = {} // maps group name to Set of node ids 40 | const ungrouped = new Set() 41 | const favorites = new Set() 42 | app.graph._nodes.forEach((node)=>{ 43 | if (NodeInclusionManager.node_includable(node)) ungrouped.add(node.id) 44 | if (NodeInclusionManager.favorite(node)) favorites.add(node.id) 45 | }) 46 | if (favorites.size>0) this.groups[Texts.FAVORITES] = favorites 47 | this.colors = { } 48 | this.colors[Texts.FAVORITES] = Colors.FAVORITES_GROUP // maps group name to color 49 | app.graph._groups.forEach((group) => { 50 | if (!group.graph) { 51 | group.graph = app.graph 52 | } 53 | recompute_safely(group).forEach((node) => { 54 | if (NodeInclusionManager.node_includable(node)) { 55 | if (!this.groups[group.title]) { 56 | this.groups[group.title] = new Set() 57 | this.colors[group.title] = group.color 58 | } 59 | this.groups[group.title].add(node.id) 60 | ungrouped.delete(node.id) 61 | } 62 | }) 63 | }) 64 | if (ungrouped.size>0) this.groups[Texts.UNGROUPED] = ungrouped 65 | 66 | this.jsoned = JSON.stringify(this,(_key, value) => (value instanceof Set ? [...value] : value)) 67 | } 68 | 69 | static check_for_changes() { 70 | const group_titles = new Set() 71 | app.graph._groups.forEach((group) => { 72 | const id = parseInt(group.id) 73 | group_titles.add(group.title) 74 | if (GroupManager.group_properties[id]) { 75 | if (GroupManager.group_properties[id].color != group.color) { 76 | GroupManager.change_callback?.(GroupManager.group_properties[id].title, {"color":group.color}) 77 | GroupManager.group_properties[id].color = group.color 78 | } 79 | if (GroupManager.group_properties[id].title != group.title) { 80 | GroupManager.change_callback?.(GroupManager.group_properties[id].title, {"title":group.title}) 81 | GroupManager.group_properties[id].title = group.title 82 | } 83 | } else { 84 | GroupManager.group_properties[id] = {"color":group.color, "title":group.title} 85 | } 86 | }) 87 | Object.keys(GroupManager.group_properties).filter((id)=>(!group_titles.has(GroupManager.group_properties[id].title))).forEach((id)=>{ 88 | GroupManager.change_callback?.(GroupManager.group_properties[id].title, {"removed":true}) 89 | delete GroupManager.group_properties[id] 90 | }) 91 | 92 | const gm2 = new GroupManager() 93 | if (gm2.jsoned==GroupManager.instance.jsoned) return false 94 | GroupManager._instance = gm2 95 | return true 96 | } 97 | 98 | static displayName(group_name) { 99 | return DisplayNames[group_name] ?? group_name 100 | } 101 | 102 | static list_group_names() { 103 | const names = Object.keys(GroupManager.instance.groups) 104 | names.sort() 105 | names.unshift(Texts.ALL_GROUPS) 106 | return names 107 | } 108 | 109 | static group_bgcolor(group_name, selected) { 110 | var c = GroupManager.instance.colors[group_name] ?? Colors.DARK_BACKGROUND 111 | return selected ? c : darken(c, Colors.UNSELECTED_DARKEN) 112 | } 113 | 114 | static group_fgcolor(group_name, selected) { 115 | var c = (group_name==Texts.FAVORITES) ? Colors.FAVORITES_FG : 116 | pickContrastingColor(GroupManager.group_bgcolor(group_name, selected),Colors.OPTIONS) 117 | return selected ? c : darken(c, Colors.UNSELECTED_DARKEN) 118 | } 119 | 120 | static normal_group(group_name) { 121 | return !(group_name==Texts.ALL_GROUPS || group_name==Texts.UNGROUPED || group_name==Texts.FAVORITES) 122 | } 123 | 124 | static group_node_mode(group_name) { 125 | const modes = {0:0,2:0,4:0} 126 | app.graph._groups.forEach((group) => { 127 | if (group.title == group_name) { 128 | recompute_safely(group).forEach((node) => { 129 | modes[node.mode] += 1 130 | }) 131 | } 132 | }) 133 | if (modes[2]==0 && modes[4]==0) return 0 134 | if (modes[0]==0 && modes[4]==0) return 2 135 | if (modes[0]==0 && modes[2]==0) return 4 136 | return 9 137 | } 138 | 139 | static change_group_mode(group_name, current_mode, e) { 140 | const value = mode_change(current_mode,e) 141 | app.graph._groups.forEach((group) => { 142 | if (group.title == group_name) { 143 | recompute_safely(group).forEach((node) => { 144 | node.mode = value 145 | }) 146 | } 147 | }) 148 | } 149 | 150 | static is_node_in(group_name, node_id) { 151 | if (group_name==Texts.ALL_GROUPS) return true 152 | return (GroupManager.instance.groups?.[group_name] && GroupManager.instance.groups[group_name].has(parseInt(node_id))) 153 | } 154 | 155 | static any_groups() { return (Object.keys(GroupManager.instance.groups).length > 0) } 156 | } 157 | 158 | Object.defineProperty(GroupManager, "instance", { 159 | get : () => { 160 | if (!GroupManager._instance) GroupManager._instance = new GroupManager() 161 | return GroupManager._instance 162 | } 163 | }) -------------------------------------------------------------------------------- /js/highlighter.js: -------------------------------------------------------------------------------- 1 | import { focus_mode } from "./utilities.js"; 2 | import { global_settings } from "./settings.js"; 3 | import { Debug } from "./debug.js"; 4 | import { app } from "../../scripts/app.js" 5 | 6 | export class Highlighter { 7 | static highlight_node = null 8 | static highlight_group = null 9 | 10 | static area = [0,0,0,0] 11 | 12 | static node(n) { 13 | Highlighter.highlight_node = n 14 | app.canvas.setDirty(true, true) 15 | } 16 | 17 | static group(g) { 18 | Highlighter.highlight_group = g 19 | app.canvas.setDirty(true, true) 20 | } 21 | 22 | static highlight_area() { 23 | const ctx = app.canvas.ctx 24 | ctx.save(); 25 | try { 26 | ctx.strokeStyle = "white" 27 | ctx.lineWidth = 1 28 | ctx.shadowColor = "white" 29 | ctx.shadowBlur = 4 30 | ctx.fillStyle = "#ffd70040" 31 | 32 | ctx.beginPath() 33 | ctx.roundRect(Highlighter.area[0], Highlighter.area[1], Highlighter.area[2], Highlighter.area[3], 6) 34 | ctx.stroke() 35 | ctx.fill() 36 | } finally { 37 | ctx.restore() 38 | } 39 | } 40 | static on_draw() { 41 | if (focus_mode()=="normal" && global_settings.highlight) { 42 | if (Highlighter.highlight_node) { 43 | Highlighter.highlight_node.measure(Highlighter.area); 44 | this.highlight_area() 45 | } 46 | if (Highlighter.highlight_group) { 47 | app.graph._groups.filter((group)=>(group.title == Highlighter.highlight_group)).forEach((group) => { 48 | Highlighter.area[0] = group._pos[0] 49 | Highlighter.area[1] = group._pos[1] 50 | Highlighter.area[2] = group._size[0] 51 | Highlighter.area[3] = group._size[1] 52 | this.highlight_area() 53 | }); 54 | } 55 | } 56 | } 57 | } -------------------------------------------------------------------------------- /js/image_comparer_control_widget.js: -------------------------------------------------------------------------------- 1 | import { ImageManager } from "./image_manager.js" 2 | import { create, classSet } from "./utilities.js" 3 | 4 | export class ImageComparerControlWidget extends HTMLSpanElement { 5 | constructor(parent_controller, node, target_widget) { 6 | super() 7 | this.classList.add('image_comparer_widget') 8 | Object.keys(target_widget.hitAreas).forEach((key)=>{ 9 | const clickable = create('span', 'image_comparer_option', this, {"innerText":key}) 10 | classSet(clickable, 'selected', target_widget.hitAreas[key].data.selected) 11 | clickable.addEventListener('click', (e)=>{ 12 | e.stopPropagation() 13 | target_widget.hitAreas[key].onDown.bind(target_widget)(e,null,node,target_widget.hitAreas[key]) 14 | const imgs = [] 15 | target_widget.selected.forEach((v)=>{imgs.push(v.img)}) 16 | node.imgs = imgs 17 | ImageManager.node_reported_images(node.id, imgs) 18 | }) 19 | }) 20 | } 21 | 22 | } 23 | 24 | customElements.define('pll-iccw', ImageComparerControlWidget, {extends: 'span'}) -------------------------------------------------------------------------------- /js/image_manager.js: -------------------------------------------------------------------------------- 1 | import { app } from "../../scripts/app.js"; 2 | import { api } from "../../scripts/api.js"; 3 | import { Debug } from "./debug.js"; 4 | import { Timings } from "./constants.js"; 5 | import { pim } from "./prompt_id_manager.js"; 6 | 7 | const KNOWN_NOT_IMAGE_TYPES = [ 8 | "ImageFromBatch", "ImageListToImageBatch" 9 | ] 10 | 11 | const KNOWN_IMAGE_TYPES = [ 12 | "SaveImage", "LoadImage", "PreviewImage" 13 | ] 14 | 15 | export function is_image_upload_node(node) { 16 | return ( node?.pasteFile != undefined ) 17 | } 18 | 19 | export function isImageNode(node) { 20 | if (KNOWN_NOT_IMAGE_TYPES.includes(node.type)) return false 21 | if (KNOWN_IMAGE_TYPES.includes(node.type)) return true 22 | return (node.title && node.title.toLowerCase().includes("image")) 23 | } 24 | 25 | export function image_is_blob(url) { 26 | return url.startsWith('blob') 27 | } 28 | 29 | export function get_image_url(v) { 30 | if (v.src) return v.src 31 | return api.apiURL( `/view?filename=${encodeURIComponent(v.filename ?? v)}&type=${v.type ?? "input"}&subfolder=${v.subfolder ?? ""}`) 32 | } 33 | 34 | function differs(one, from) { 35 | if (one.length!=from.length) return true 36 | for (var i=0; i{} 48 | 49 | static reset() { 50 | this.node_listener_map = {} 51 | this.executing_node = null 52 | this.last_preview_node = null 53 | } 54 | 55 | static clear_listeners(node_id) { 56 | Object.values(this.node_listener_map).forEach((s)=>{s.delete(node_id)}) 57 | } 58 | 59 | static add_listener(node_id, listens_to_node_id) { 60 | if (!this.node_listener_map[listens_to_node_id]) this.node_listener_map[listens_to_node_id] = new Set() 61 | this.node_listener_map[listens_to_node_id].add(node_id) 62 | } 63 | 64 | static get_urls(node_id) { 65 | if (this.node_urls_map[node_id] && this.node_urls_map[node_id].length>0) return this.node_urls_map[node_id] 66 | return null 67 | } 68 | 69 | static get_listeners(node_id) { 70 | return Array.from(this.node_listener_map[node_id] ?? []) 71 | } 72 | 73 | static _set_urls(node_id, urls, caused_by_node_id) { 74 | if (!this.node_urls_map[node_id]) this.node_urls_map[node_id] = [] 75 | const is_change = differs(this.node_urls_map[node_id], urls) 76 | if (is_change) { 77 | this.node_urls_map[node_id] = Array.from(urls) 78 | Debug.trivia(`Sending image update to node ${node_id} caused by ${caused_by_node_id}`) 79 | this.node_image_change(node_id, caused_by_node_id) 80 | } 81 | return is_change 82 | } 83 | 84 | static _consider_urls(node_id, urls, caused_by_node_id) { 85 | if ( ImageManager.executing_node || // when running, we take any image 86 | !this.get_urls(node_id) || // if we don't have an image, we take any image 87 | image_is_blob(this.get_urls(node_id)[0]) || // if we have a blob, we take any image 88 | !image_is_blob(urls[0]) // if this isn't a blob, take it 89 | ) { 90 | this._set_urls(node_id, urls, caused_by_node_id) 91 | } else { return false } 92 | } 93 | 94 | static _images_received(node_id, urls, detail) { 95 | if (this._set_urls(node_id, urls, node_id)) { 96 | if (detail) Debug.trivia(`${detail}: Image urls received for ${node_id} with length ${urls.length}`) 97 | if (urls.length && this.node_listener_map[node_id]) { 98 | Array.from(this.node_listener_map[node_id]).forEach((listener)=>{this._consider_urls(listener, urls, node_id)}) 99 | } 100 | } 101 | } 102 | 103 | static node_reported_images(node_id, imgs) { 104 | if (ImageManager.executing_node || ImageManager.just_finished) return 105 | const urls = [] 106 | imgs.filter((i)=>(i.src && ! i.src.endsWith('undefined'))).forEach((i)=>{urls.push(i.src)}) 107 | this._set_urls(node_id, urls, node_id) 108 | } 109 | 110 | static on_executing(e) { 111 | // TODO if (!pim.ours(e)) return 112 | ImageManager.executing_node = e.detail 113 | if (!ImageManager.executing_node) { 114 | ImageManager.just_finished = true 115 | Array.from(app.graph._nodes).filter((node)=>(node.imgs && node.imgs.length>0)).forEach((node)=>{ 116 | ImageManager.node_reported_images(node.id, node.imgs) 117 | }) 118 | setTimeout(()=>{ImageManager.just_finished = false}, Timings.GENERIC_LONGER_DELAY) 119 | } 120 | 121 | } 122 | 123 | static on_b_preview(e) { 124 | // TODO if (!pim.ours(e)) return 125 | const blob_url = window.URL.createObjectURL(e.detail) 126 | ImageManager.last_preview_node = ImageManager.executing_node 127 | const i = new Image() 128 | i.onload = ()=>{ 129 | ImageManager.last_preview_image = {width: i.width, height: i.height} 130 | } 131 | i.src = blob_url 132 | ImageManager._images_received( ImageManager.executing_node, [blob_url,], "b_preview" ) 133 | } 134 | 135 | static on_executed(e) { 136 | // TODO if (!pim.ours(e)) return 137 | const srcs = [] 138 | e.detail?.output?.images?.forEach((v)=>{ srcs.push(get_image_url(v)) }) 139 | if (srcs.length) { 140 | ImageManager._images_received( e.detail.node, srcs, 'on_executed' ) 141 | ImageManager._images_received( ImageManager.last_preview_node, srcs, 'on_executed' ) 142 | ImageManager.last_preview_node = null 143 | } 144 | 145 | } 146 | 147 | static on_execution_start(e) { 148 | ImageManager.analyse_graph() 149 | this.node_urls_map = {} 150 | } 151 | 152 | static analyse_graph() { 153 | this.reset() 154 | Array.from(app.graph._nodes).filter((node)=>(isImageNode(node))).forEach((node)=>{ 155 | add_upstream(node, node, new Set()) 156 | }) 157 | } 158 | 159 | static send_all() { 160 | Object.keys(this.node_listener_map).forEach((origin)=>{ 161 | Array.from(this.node_listener_map[origin]).forEach((destination)=>{ 162 | this.node_image_change(destination, origin) 163 | }) 164 | this.node_image_change(origin, origin) 165 | }) 166 | } 167 | 168 | } 169 | 170 | function get_upstream_ids(node) { 171 | const upstream_ids = new Set() 172 | node.inputs?.filter((i)=>(i.type=="IMAGE" || i.type=="LATENT")).forEach((i)=>{ 173 | const lk = i.link 174 | if (lk && app.graph.links[lk]?.origin_id) upstream_ids.add(app.graph.links[lk]?.origin_id) 175 | }) 176 | if (app.graph.extra?.ue_links) { 177 | app.graph.extra?.ue_links?.forEach((ue_link)=>{ 178 | if (ue_link.downstream==node.id && (ue_link.type=="IMAGE" || ue_link.type=="LATENT")) upstream_ids.add(ue_link.upstream) 179 | }) 180 | } 181 | return Array.from(upstream_ids) 182 | } 183 | 184 | function add_upstream(root_node, nd, seen) { 185 | if (seen.has(nd.id)) return 186 | seen.add(nd.id) 187 | if (root_node!=nd) ImageManager.add_listener(root_node.id, nd.id) 188 | get_upstream_ids(nd).forEach((nd2_id)=>{add_upstream(root_node, app.graph._nodes_by_id[nd2_id], seen)}) 189 | } 190 | 191 | 192 | -------------------------------------------------------------------------------- /js/image_popup.js: -------------------------------------------------------------------------------- 1 | import { app } from "../../scripts/app.js"; 2 | import { Timings } from "./constants.js" 3 | import { find_controller_parent, create, kill_event } from "./utilities.js" 4 | import { Debug } from "./debug.js"; 5 | 6 | export class ImagePopup extends HTMLSpanElement { 7 | constructor() { 8 | super() 9 | this.frame = create('span', 'image_popup_frame', this) 10 | this.img = create('img', 'image_popup_image', this.frame) 11 | ImagePopup._instance = this 12 | this.classList.add('image_popup') 13 | this.classList.add('hidden') 14 | find_controller_parent().appendChild(this) 15 | } 16 | 17 | static just_shown = false 18 | 19 | static handle_click(e) { 20 | if (ImagePopup.just_shown) return 21 | ImagePopup.instance.classList.add("hidden") 22 | } 23 | 24 | static show(url) { 25 | ImagePopup.just_shown = true 26 | ImagePopup.instance.img.src = url 27 | ImagePopup.instance.classList.remove("hidden") 28 | setTimeout(()=>{ImagePopup.just_shown=false},Timings.GENERIC_SHORT_DELAY) 29 | } 30 | } 31 | 32 | Object.defineProperty(ImagePopup, "instance", { 33 | get : ()=>{ 34 | if (!ImagePopup._instance) new ImagePopup() 35 | return ImagePopup._instance 36 | } 37 | }) 38 | 39 | customElements.define('cp-image_popup', ImagePopup, {extends: 'span'}) -------------------------------------------------------------------------------- /js/input_slider.js: -------------------------------------------------------------------------------- 1 | import { app } from "../../scripts/app.js"; 2 | import { create, step_size, check_float, defineProperty } from "./utilities.js" 3 | import { clamp, classSet } from "./utilities.js" 4 | import { Debug } from "./debug.js" 5 | import { getSettingValue } from "./settings.js"; 6 | import { SettingIds } from "./constants.js"; 7 | import { WidgetChangeManager } from "./widget_change_manager.js"; 8 | 9 | function copy_to_widget(node, widget, options) { 10 | const opt = { 11 | "min":options.min, "max":options.max, 12 | "round":options.round, "precision":options.precision, "step":options.step, 13 | "org_min":options.org_min, "org_max":options.org_max 14 | } 15 | Object.assign(widget.options, opt) 16 | widget.options.step *= 10 // clicking the arrows moves a widget by 0.1 * step ???? 17 | node.properties.controller_widgets[widget.name] = opt 18 | WidgetChangeManager.set_widget_value(widget, widget.value, widget.o) 19 | } 20 | 21 | class SliderOptions { 22 | static KEYS = [ "min", "max", "step", "precision", "round", "org_min", "org_max" ] 23 | constructor(widget_options, saved_values, is_integer) { 24 | const options = {} 25 | Object.assign(options, widget_options) 26 | if (saved_values) Object.assign(options, saved_values) 27 | if (is_integer && options.max > Number.MAX_SAFE_INTEGER) options.max = Number.MAX_SAFE_INTEGER 28 | this.min = options.min 29 | this.max = options.max 30 | this.precision = options.precision 31 | this._step = null 32 | this.org_min = options.org_min ?? options.min 33 | this.org_max = options.org_max ?? options.max 34 | defineProperty(this, "step", { 35 | get : () => { return this._step }, 36 | set : (v) => { 37 | this._step = v 38 | this.round = v 39 | if (this.precision) { 40 | while (Math.pow(0.1,this.precision) > (this._step+1e-8)) this.precision += 1 41 | } 42 | } 43 | }) 44 | this.step = step_size(options) 45 | 46 | } 47 | } 48 | 49 | class SliderOptionEditor extends HTMLSpanElement { 50 | static instance 51 | 52 | constructor(slider_options, heading_text, widget, node) { 53 | super() 54 | if (SliderOptionEditor.instance) SliderOptionEditor.instance.remove() 55 | SliderOptionEditor.instance = this 56 | this.classList.add('option_setting') 57 | this.style.backgroundColor = node.color 58 | 59 | this.slider_options = slider_options 60 | this.widget = widget 61 | this.node = node 62 | 63 | this.other_like_node = app.graph._nodes.filter((other_node) => (other_node.id != node.id && node.type==other_node.type)) 64 | this.is_integer = (widget.options.round == 1 && widget.options.precision == 0) 65 | 66 | this.heading = create('span', 'option_setting_panel', this) 67 | this.title = create('span', 'option_setting_title', this.heading, {"innerText":heading_text}) 68 | 69 | this.value_panel = create('span', 'option_setting_panel', this) 70 | this.min_edit = create('input', 'option_setting_input min', this.value_panel, {"value":slider_options.min}) 71 | create('span', 'option_setting_dash', this.value_panel, {"innerHTML":"-"}) 72 | this.max_edit = create('input', 'option_setting_input max', this.value_panel, {"value":slider_options.max}) 73 | 74 | this.step_panel = create('span', 'option_setting_panel', this) 75 | create('span', 'option_setting_label', this.step_panel, {"innerHTML":"Step:"}) 76 | this.step_edit = create('input', 'option_setting_input step', this.step_panel, {"value":slider_options.step}) 77 | 78 | this.min_edit.addEventListener('keyup', (e) => {this._keyup(e)}) 79 | this.max_edit.addEventListener('keyup', (e) => {this._keyup(e)}) 80 | this.step_edit.addEventListener('keyup', (e) => {this._keyup(e)}) 81 | 82 | if (this.other_like_node.length>0) { 83 | const n = this.other_like_node.length 84 | this.apply_also_panel = create('span', 'option_setting_panel', this) 85 | create('span', 'option_setting_label', this.apply_also_panel, {"innerHTML":`Apply to ${n} similar node${n>1?"s":"" }`}) 86 | this.apply_also_checkbox = create('input', 'option_setting_also_checkbox', this.apply_also_panel, {'type':'checkbox', "checked":getSettingValue(SettingIds.DEFAULT_APPLY_TO_SIMILAR)}) 87 | } 88 | 89 | this.buttons = create('span', 'option_setting_buttons', this) 90 | this.button_cancel = create('button', 'option_setting_button', this.buttons, {"innerText":"Cancel"}) 91 | this.button_save = create('button', 'option_setting_button', this.buttons, {"innerText":"Save"}) 92 | 93 | this.button_save.addEventListener('click', (e) => { this.save_and_close() }) 94 | this.button_cancel.addEventListener('click', () => {this.close()}) 95 | 96 | /* Highlight in red for bad values */ 97 | this.min_edit.addEventListener('input', (e)=>{this.check_for_bad_values()}) 98 | this.max_edit.addEventListener('input', (e)=>{this.check_for_bad_values()}) 99 | this.step_edit.addEventListener('input', (e)=>{this.check_for_bad_values()}) 100 | this.check_for_bad_values() 101 | } 102 | 103 | _keyup(e) { 104 | if (e.key == "Enter") this.maybe_save() 105 | } 106 | 107 | save_and_close() { 108 | this.slider_options.min = parseFloat(this.min_edit.value) 109 | this.slider_options.max = parseFloat(this.max_edit.value) 110 | this.slider_options.step = parseFloat(this.step_edit.value) // also updates precision 111 | 112 | copy_to_widget(this.node, this.widget, this.slider_options) 113 | 114 | if (this.apply_also_checkbox?.checked) { 115 | this.other_like_node.forEach((node) => { 116 | node.widgets?.forEach((widget) => { 117 | if (widget.name == this.widget.name) { 118 | copy_to_widget(node, widget, this.slider_options) 119 | } 120 | }) 121 | }) 122 | } 123 | 124 | this.close() 125 | } 126 | 127 | maybe_save() { 128 | if (!this.button_save.disabled) { 129 | this.save_and_close() 130 | } 131 | } 132 | 133 | close() { 134 | this.remove() 135 | SliderOptionEditor.instance = null 136 | } 137 | 138 | check_for_bad_values() { 139 | var min_bad = (!check_float(this.min_edit.value)) 140 | var max_bad = (!check_float(this.max_edit.value)) 141 | const both_bad = (parseFloat(this.min_edit.value) >= parseFloat(this.max_edit.value)) 142 | var step_bad = (!check_float(this.step_edit.value)) 143 | if (this.is_integer) { 144 | min_bad = min_bad || (parseFloat(this.min_edit.value) != parseInt(this.min_edit.value)) 145 | max_bad = max_bad || (parseFloat(this.max_edit.value) != parseInt(this.max_edit.value)) 146 | step_bad = step_bad || (parseFloat(this.step_edit.value) != parseInt(this.step_edit.value)) 147 | } 148 | 149 | min_bad = min_bad || both_bad 150 | max_bad = max_bad || both_bad 151 | 152 | const min_danger = (!min_bad && this.min_edit.value < this.slider_options.org_min) 153 | const max_danger = (!max_bad && this.max_edit.value > this.slider_options.org_max) 154 | 155 | classSet(this.min_edit, 'bad_value', min_bad) 156 | classSet(this.max_edit, 'bad_value', max_bad) 157 | classSet(this.min_edit, 'danger_value', min_danger) 158 | classSet(this.max_edit, 'danger_value', max_danger) 159 | classSet(this.step_edit, 'bad_value', (step_bad)) 160 | 161 | this.button_save.disabled = (min_bad || max_bad || step_bad || 162 | (this.min_edit.value == this.slider_options.min && this.max_edit.value == this.slider_options.max && 163 | this.step_edit.value == this.slider_options.step)) 164 | } 165 | } 166 | 167 | export class FancySlider extends HTMLSpanElement { 168 | 169 | static in_textedit = null 170 | static mouse_down_on = null 171 | static currently_active = null 172 | 173 | constructor(parent_controller, node, widget, properties, label) { 174 | super() 175 | this.classList.add("fancy_slider") 176 | this.parent_controller = parent_controller 177 | this.node = node 178 | this.widget = widget 179 | 180 | this.is_integer = (this.widget.options.precision == 0) 181 | this.options = new SliderOptions(widget.options, node.properties.controller_widgets[widget.name], this.is_integer) 182 | 183 | copy_to_widget(this.node, this.widget, this.options) 184 | this.value = widget.value 185 | this.last_good = this.value 186 | 187 | this.graphic = create('span', 'fs_graphic', this) 188 | this.graphic_fill = create('span', 'fs_graphic_fill', this.graphic) 189 | this.graphic_text = create('span', 'fs_graphic_text', this.graphic) 190 | this.text_edit = create('input','fs_text_edit', this) 191 | this.label = create('span', 'fs_label', this, {"innerText":label ?? widget.label ?? widget.name}) 192 | 193 | this.displaying = "graphic" 194 | 195 | this.addEventListener('mousedown', (e) => this._mousedown(e)) 196 | this.addEventListener('wheel', (e) => this._wheel(e)) 197 | this.addEventListener('mouseout', (e) => this._mouseout(e)) 198 | 199 | this.addEventListener('change', (e) => this._change(e)) 200 | this.addEventListener('focusin', (e) => this._focus(e)) 201 | this.addEventListener('focusout', (e) => this._focusout(e)) 202 | 203 | this._dragging = false 204 | defineProperty(this, "dragging", { 205 | get : () => { return this._dragging}, 206 | set : (v) => { 207 | this._dragging = v 208 | if (v) FancySlider.currently_active = this 209 | else FancySlider.currently_active = null 210 | } 211 | }) 212 | 213 | this._wheeling = false 214 | defineProperty(this, "wheeling", { 215 | get : () => { return this._wheeling}, 216 | set : (v) => { 217 | this._wheeling = v 218 | if (v) FancySlider.currently_active = this 219 | else { 220 | FancySlider.currently_active = null 221 | this.redraw() 222 | } 223 | } 224 | }) 225 | this.redraw() 226 | setTimeout(()=>{this.redraw()}, 100) 227 | } 228 | 229 | enddragging(e) { 230 | this.mouse_down_on_me_at = null; 231 | FancySlider.mouse_down_on = null; 232 | this.dragging = false 233 | this.classList.remove('can_drag') 234 | if (e && e.target==this) { 235 | e.preventDefault() 236 | e.stopPropagation() 237 | } 238 | } 239 | 240 | switch_to_textedit() { 241 | if (FancySlider.in_textedit) FancySlider.in_textedit.switch_to_graphicaledit() 242 | FancySlider.in_textedit = this 243 | this.displaying = "text" 244 | this.enddragging() 245 | FancySlider.currently_active = this 246 | this.redraw() 247 | setTimeout(()=>{this.text_edit.focus()},100) 248 | } 249 | 250 | switch_to_graphicaledit() { 251 | if (FancySlider.in_textedit == this) FancySlider.in_textedit = null 252 | this.displaying = "graphic" 253 | this.redraw() 254 | } 255 | 256 | edit_min_max(e) { 257 | const soe = new SliderOptionEditor(this.options, `${this.node.title}.${this.widget.name} range`, this.widget, this.node) 258 | document.body.appendChild( soe ) 259 | soe.style.left = `${clamp(e.x-10, 10)}px` 260 | soe.style.top = `${clamp(e.y-10, 20, window.innerHeight - soe.getBoundingClientRect().height - 20)}px` 261 | } 262 | 263 | _wheel(e) { 264 | if (this.displaying = "graphic") { 265 | const shift_setting = getSettingValue(SettingIds.SCROLL_MOVES_SLIDERS) 266 | if ( shift_setting=="yes" || (shift_setting=="shift" && e.shiftKey) || (shift_setting=="ctrl" && e.ctrlKey) ) { 267 | this.wheeling = true 268 | const reverse = getSettingValue(SettingIds.SCROLL_REVERSED) ? -1 : 1 269 | const new_value = this.value + reverse * this.options.step * (e.wheelDelta>0 ? 1 : -1) 270 | WidgetChangeManager.set_widget_value(this.widget, new_value) 271 | e.preventDefault() 272 | e.stopPropagation() 273 | } 274 | } 275 | } 276 | 277 | _mouseout(e) { 278 | if (this.wheeling) this.wheeling = false 279 | } 280 | 281 | _focus(e) { 282 | this.text_edit.select() 283 | } 284 | 285 | _focusout(e) { 286 | if (this.displaying = "text") this.switch_to_graphicaledit() 287 | } 288 | 289 | _change(e) { 290 | e.stopPropagation() 291 | this.switch_to_graphicaledit() 292 | FancySlider.currently_active = null 293 | } 294 | 295 | _mousedown(e) { 296 | const shift_setting = getSettingValue(SettingIds.EDIT_SLIDERS) 297 | if ((e.shiftKey && shift_setting=='shift') || (e.ctrlKey && shift_setting=='ctrl')){ 298 | this.edit_min_max(e) 299 | e.preventDefault() 300 | e.stopPropagation() 301 | return 302 | } 303 | if (this.displaying=="graphic") { 304 | e.preventDefault() 305 | if (e.detail==2) { 306 | this.switch_to_textedit() 307 | } else { 308 | this.classList.add('can_drag') 309 | this.mouse_down_on_me_at = e.x; 310 | FancySlider.mouse_down_on = this 311 | e.stopPropagation() 312 | } 313 | } 314 | } 315 | 316 | _mousemove(e) { 317 | if (!this.dragging && (Math.abs(e.x-this.mouse_down_on_me_at)>6 || Math.abs(e.movementX)>4)) { 318 | this.dragging = true 319 | } 320 | if (this.dragging) { 321 | const box = this.getBoundingClientRect() 322 | const f = clamp(( e.x - box.x ) / box.width, 0, 1) 323 | var new_value = this.options.min + f * (this.options.max - this.options.min) 324 | if (this.is_integer) new_value = parseInt(new_value) 325 | 326 | WidgetChangeManager.set_widget_value(this.widget,new_value) 327 | 328 | e.preventDefault() 329 | e.stopPropagation() 330 | } 331 | } 332 | 333 | set_widget_value(v) { 334 | this.widget.value = v 335 | if (this.widget.original_callback) this.widget.original_callback(this.widget.value) 336 | WidgetChangeManager.notify(this.widget) 337 | } 338 | 339 | format_for_display(v) { 340 | if (this.options.precision != null) { 341 | return v.toFixed(this.options.precision) 342 | } else { 343 | return v 344 | } 345 | } 346 | 347 | wcm_manager_callback() { 348 | this.options = new SliderOptions(this.widget.options) 349 | } 350 | 351 | redraw() { 352 | var new_value = parseFloat(this.value) 353 | if (isNaN(new_value)) new_value = this.last_good 354 | this.value = new_value 355 | this.last_good = this.value 356 | 357 | classSet(this.graphic, "hidden",this.displaying!="graphic") 358 | classSet(this.text_edit,"hidden",this.displaying=="graphic") 359 | 360 | const f = (this.value - this.options.min) / (this.options.max - this.options.min) 361 | this.graphic_fill.style.width = `${100*f}%` 362 | this.graphic_text.innerHTML = this.format_for_display(this.value) 363 | this.text_edit.value = this.format_for_display(this.value) 364 | 365 | } 366 | 367 | static handle_mouse_move(e) { 368 | if (FancySlider.mouse_down_on) FancySlider.mouse_down_on._mousemove.bind(FancySlider.mouse_down_on)(e) 369 | } 370 | 371 | static handle_mouse_up(e) { 372 | if (FancySlider.mouse_down_on) FancySlider.mouse_down_on.enddragging(e) 373 | } 374 | 375 | } 376 | 377 | 378 | 379 | customElements.define('cp-fslider', FancySlider, {extends: 'span'}) 380 | customElements.define('cp-fslideroptioneditor', SliderOptionEditor, {extends: 'span'}) -------------------------------------------------------------------------------- /js/node_inclusion.js: -------------------------------------------------------------------------------- 1 | import { get_node } from "./utilities.js"; 2 | import { app } from "../../scripts/app.js" 3 | import { InclusionOptions, Texts, Colors } from "./constants.js"; 4 | 5 | export class NodeInclusionManager { 6 | static node_change_callback = null 7 | 8 | static node_includable(node_or_node_id) { 9 | const nd = get_node(node_or_node_id) 10 | return (nd && nd.properties["controller"] && nd.properties["controller"]!=InclusionOptions.EXCLUDE) 11 | } 12 | 13 | static include_node(node_or_node_id) { 14 | const nd = get_node(node_or_node_id) 15 | return (nd && nd.properties["controller"] && nd.properties["controller"]!=InclusionOptions.EXCLUDE) 16 | } 17 | 18 | static advanced_only(node_or_node_id) { 19 | const nd = get_node(node_or_node_id) 20 | return (nd && nd.properties["controller"] && nd.properties["controller"]==InclusionOptions.ADVANCED) 21 | } 22 | 23 | static favorite(node_or_node_id) { 24 | const nd = get_node(node_or_node_id) 25 | return (nd && nd.properties["controller"] && nd.properties["controller"]==InclusionOptions.FAVORITE) 26 | } 27 | 28 | static visual(ctx, node) { 29 | const r = NodeInclusionManager.favorite(node) ? 4 : 3 30 | const title_mid = 15 31 | const width = node.collapsed ? node._collapsed_width : node.size[0] 32 | const x = 3+width-title_mid 33 | const y = -title_mid 34 | if (NodeInclusionManager.node_includable(node)) { 35 | ctx.save(); 36 | 37 | ctx.fillStyle = "#C08080"; 38 | ctx.lineWidth = 3; 39 | ctx.strokeStyle = "#C08080"; 40 | 41 | if (NodeInclusionManager.favorite(node)) { 42 | ctx.beginPath(); 43 | ctx.arc(x-(r/2), y-(r/2), (r/2), -Math.PI, 0, false); 44 | ctx.arc(x+(r/2), y-(r/2), (r/2), -Math.PI, 0, false); 45 | ctx.lineTo(x, y+r) 46 | ctx.lineTo(x-r, y-(r/2)) 47 | ctx.fill() 48 | } else { 49 | ctx.beginPath(); 50 | ctx.arc(x, y, r, 0, 2*Math.PI, false); 51 | if (!NodeInclusionManager.advanced_only(node)) { 52 | ctx.fill() 53 | } else { 54 | ctx.stroke() 55 | } 56 | } 57 | 58 | ctx.restore(); 59 | } 60 | } 61 | } 62 | 63 | function selected_nodes() { 64 | return app.graph._nodes.filter((node)=>node.is_selected) 65 | } 66 | 67 | function cp_callback_submenu(value, options, e, menu, node) { 68 | const current = node.properties["controller"] ?? InclusionOptions.EXCLUDE; 69 | const selection = selected_nodes() 70 | const choices = (selection.length==1) ? 71 | [InclusionOptions.EXCLUDE, InclusionOptions.INCLUDE, InclusionOptions.ADVANCED, InclusionOptions.FAVORITE] : 72 | [InclusionOptions.EXCLUDES, InclusionOptions.INCLUDES, InclusionOptions.ADVANCEDS, InclusionOptions.FAVORITES] 73 | const submenu = new LiteGraph.ContextMenu( 74 | choices, 75 | { event: e, callback: function (v) { 76 | selection.forEach((nd)=>{nd.properties["controller"] = v.replace('these nodes', 'this node')}) 77 | NodeInclusionManager.node_change_callback?.('submenu', 100); 78 | app.canvas.setDirty(true, true) 79 | }, 80 | parentMenu: menu, node:node} 81 | ) 82 | Array.from(submenu.root.children).forEach(child => { 83 | if (child.innerText == current) child.style.borderLeft = `2px solid ${Colors.MENU_HIGHLIGHT}`; 84 | }); 85 | } 86 | 87 | export function add_control_panel_options(options) { 88 | if (options[options.length-1] != null) options.push(null); 89 | options.push( 90 | { 91 | content: Texts.CONTEXT_MENU, 92 | has_submenu: true, 93 | callback: cp_callback_submenu, 94 | } 95 | ) 96 | } 97 | 98 | -------------------------------------------------------------------------------- /js/nodeblock.js: -------------------------------------------------------------------------------- 1 | import { app, ComfyApp } from "../../scripts/app.js"; 2 | import { ComfyWidgets } from "../../scripts/widgets.js"; 3 | 4 | import { create, darken, classSet, mode_change, tooltip_if_overflowing, kill_event } from "./utilities.js"; 5 | import { Entry } from "./panel_entry.js" 6 | import { make_resizable } from "./resize_manager.js"; 7 | import { get_image_url, image_is_blob, ImageManager, is_image_upload_node, isImageNode } from "./image_manager.js"; 8 | import { OnChangeController, UpdateController } from "./update_controller.js"; 9 | import { Debug } from "./debug.js"; 10 | import { Highlighter } from "./highlighter.js"; 11 | import { close_context_menu, open_context_menu } from "./context_menu.js"; 12 | import { Generic, /*MAXIMUM_UPSTREAM, */Texts, Timings } from "./constants.js"; 13 | import { InclusionOptions } from "./constants.js"; 14 | import { NodeInclusionManager } from "./node_inclusion.js"; 15 | import { ImagePopup } from "./image_popup.js"; 16 | 17 | function is_single_image(data) { return (data && data.items && data.items.length==1 && data.items[0].type.includes("image")) } 18 | 19 | export class NodeBlock extends HTMLSpanElement { 20 | /* 21 | NodeBlock represents a single node - zero or more Entry children, and zero or one images. 22 | If neither Entry nor images, it is not 'valid' (ie should not be included) 23 | */ 24 | static count = 0 25 | 26 | _remove() { 27 | Debug.trivia(`removing nodeblock for ${this.node?.id} from controller ${this.parent_controller?.index}`) 28 | if (!this.has_been_removed) { 29 | NodeBlock.count -= 1 30 | this.has_been_removed = true 31 | } else { 32 | Debug.trivia('alreadyremoved nodeblock?') 33 | } 34 | this.remove() 35 | this.parent_controller = null 36 | this._remove_entries() 37 | 38 | if (this.resize_observer) { 39 | this.resize_observer.disconnect() 40 | delete this.resize_observer 41 | } 42 | 43 | if (this.node==Highlighter.highlight_node) Highlighter.highlight_node = null 44 | //Debug.trivia(`NodeBlock._remove count = ${NodeBlock.count}`) 45 | } 46 | 47 | _remove_entries() { 48 | Array.from(this.main.children).forEach((c)=>{c._remove?.()}) 49 | } 50 | 51 | constructor(parent_controller, node) { 52 | super() 53 | NodeBlock.count += 1 54 | this.parent_controller = parent_controller 55 | this.node = node 56 | this.mode = this.node.mode 57 | this.imageIndex = null 58 | Debug.trivia(`creating nodeblock for ${this.node?.id} on controller ${this.parent_controller?.index}`) 59 | 60 | if (!this.node.properties.controller_details) { 61 | this.node.properties.controller_details = {} 62 | this.node.properties.controller_widgets = {} 63 | } 64 | this.classList.add("nodeblock") 65 | this.classList.add(`mode_${this.mode}`) 66 | 67 | this.main = create("span","nb_main",this) 68 | this.build_nodeblock() 69 | this.add_block_handlers() 70 | 71 | this.progress = create('span','progress_bar') 72 | } 73 | 74 | can_reuse() { 75 | if (this.bypassed != (this.node.mode!=0)) return false 76 | return true 77 | } 78 | 79 | add_block_handlers() { 80 | this.addEventListener('dragover', function (e) { NodeBlock.drag_over_me(e) } ) 81 | this.addEventListener('drop', function (e) { NodeBlock.drop_on_me(e) } ) 82 | this.addEventListener('dragend', function (e) { NodeBlock.drag_end(e) } ) 83 | this.addEventListener('dragenter', function (e) { e.preventDefault() } ) 84 | 85 | this.addEventListener('mouseenter', (e) => {Highlighter.node(this.node)}) 86 | this.addEventListener('mouseleave', (e) => {Highlighter.node(null)}) 87 | } 88 | 89 | add_handle_drag_handlers(draghandle) { 90 | draghandle.draggable = "true" 91 | draghandle.addEventListener('dragstart', (e) => { this.drag_me(e) } ) 92 | //draghandle.addEventListener('mousedown', (e)=>{ }) 93 | draghandle.addEventListener('mouseup', (e)=>{ 94 | if (!NodeBlock.dragged && e.button == 0 && !e.ctrlKey) this.toggle_minimise() 95 | }) 96 | } 97 | 98 | static dragged = null 99 | static last_dragged = null 100 | 101 | drag_me(e) { 102 | if (app.canvas.read_only) return 103 | NodeBlock.dragged = this 104 | NodeBlock.last_dragged = this 105 | NodeBlock.dragged.classList.add("being_dragged") 106 | e.dataTransfer.setDragImage(this, e.layerX, e.layerY); 107 | } 108 | 109 | static drag_over_me(e, nodeblock_over, force_before) { 110 | nodeblock_over = nodeblock_over ?? e.currentTarget 111 | if (NodeBlock.dragged) { 112 | // e.dataTransfer.effectAllowed = "all"; 113 | e.dataTransfer.dropEffect = "move" 114 | e.preventDefault(); 115 | } 116 | if (NodeBlock.dragged && nodeblock_over!=NodeBlock.dragged && nodeblock_over.parent_controller==NodeBlock.dragged.parent_controller) { 117 | if (nodeblock_over != NodeBlock.last_swap) { 118 | if (nodeblock_over.drag_id=='header') { 119 | NodeBlock.dragged.parentElement.insertBefore(NodeBlock.dragged, NodeBlock.dragged.parentElement.firstChild) 120 | } else if (nodeblock_over.drag_id=='footer') { 121 | NodeBlock.dragged.parentElement.appendChild(NodeBlock.dragged) 122 | } else { 123 | if (nodeblock_over.previousSibling == NodeBlock.dragged && !force_before) { 124 | nodeblock_over.parentElement.insertBefore(nodeblock_over, NodeBlock.dragged) 125 | } else { 126 | nodeblock_over.parentElement.insertBefore(NodeBlock.dragged, nodeblock_over) 127 | } 128 | } 129 | NodeBlock.last_swap = nodeblock_over 130 | } 131 | } 132 | 133 | if (e.dataTransfer.types.includes('Files')) { 134 | if (is_image_upload_node(nodeblock_over?.node) && is_single_image(e.dataTransfer)) { 135 | e.dataTransfer.dropEffect = "move" 136 | e.stopPropagation() 137 | } else { 138 | e.dataTransfer.dropEffect = "none" 139 | } 140 | e.preventDefault(); 141 | } 142 | } 143 | 144 | static async drop_on_me(e) { 145 | if (NodeBlock.dragged) { 146 | e.preventDefault(); 147 | } else if (e.dataTransfer.types.includes('Files')) { 148 | if (is_image_upload_node(e.currentTarget?.node) && is_single_image(e.dataTransfer)) { 149 | const node = e.currentTarget.node 150 | e.preventDefault(); 151 | e.stopImmediatePropagation() 152 | 153 | /* 154 | When an IMAGEUPLOAD gets created, it adds an input node to the body. 155 | That'll be the last element added, so give it the files, 156 | tell it that it has changed and wait for it to do the upload, 157 | then remove it from the node and the document. 158 | */ 159 | ComfyWidgets.IMAGEUPLOAD(node, "image", node, app) 160 | document.body.lastChild.files = e.dataTransfer.files 161 | await document.body.lastChild.onchange() 162 | node.widgets.pop() 163 | document.body.lastChild.remove() 164 | 165 | node.setSizeForImage() 166 | UpdateController.make_request('image_upload', 100) 167 | } 168 | } 169 | } 170 | 171 | static drag_end(e) { 172 | if (NodeBlock.dragged) NodeBlock.dragged.classList.remove("being_dragged") 173 | NodeBlock.dragged = null 174 | NodeBlock.last_swap = null 175 | } 176 | 177 | image_progress_update(value, max, me) { 178 | if (value) { 179 | this.title_bar.appendChild(this.progress) 180 | const w = this.getBoundingClientRect().width * value / max 181 | const h = this.minimised ? 2 : 3 182 | const top = this.title_bar.getBoundingClientRect().height - h 183 | this.progress.style.width = `${w}px` 184 | this.progress.style.top = `${top}px` 185 | this.progress.style.height = `${h}px` 186 | this.progress.style.backgroundColor = me ? "var(--progress-color)" : "var(--alien-progress-color)" 187 | } else { this.progress.remove() } 188 | } 189 | 190 | get_minimised() { 191 | return this.parent_controller.settings.minimised_blocks.includes(this.node.id) 192 | } 193 | 194 | reject_image(is_from_upstream) { 195 | return ( (this.get_rejects_images()) || (this.get_rejects_upstream() && is_from_upstream) ) 196 | } 197 | 198 | get_rejects_upstream() { 199 | return this.parent_controller.settings.blocks_rejecting_upstream.includes(this.node.id) 200 | } 201 | 202 | set_rejects_upstream(v) { 203 | this.set(this.parent_controller.settings.blocks_rejecting_upstream, v) 204 | } 205 | 206 | get_rejects_images() { 207 | return this.parent_controller.settings.blocks_rejecting_images.includes(this.node.id) 208 | } 209 | 210 | set_rejects_images(v) { 211 | this.set(this.parent_controller.settings.blocks_rejecting_images, v) 212 | } 213 | 214 | toggle_minimise() { 215 | this.set_minimised(!this.get_minimised()) 216 | } 217 | 218 | set(lst, v) { 219 | if (v && lst.includes(this.node.id)) return 220 | if (!v && !lst.includes(this.node.id)) return 221 | 222 | if (v) { lst.push(this.node.id) } 223 | else { 224 | const index = lst.findIndex((e)=>(e==this.node.id)) 225 | lst.splice(index, 1) 226 | } 227 | } 228 | 229 | set_minimised(v) { 230 | if (v == this.get_minimised()) return 231 | this.set(this.parent_controller.settings.minimised_blocks, v) 232 | this.minimised = v 233 | classSet(this, 'minimised', this.minimised) 234 | if (this.minimised && this.contains(document.activeElement)) { 235 | document.activeElement.blur() 236 | } 237 | } 238 | 239 | set_all_widget_visibility(v) { 240 | this.parent_controller.settings.hidden_widgets = [] 241 | if (!v) { 242 | Array.from(this.main.children).filter((child)=>(child.display_name)).forEach((child)=>{ 243 | this.parent_controller.settings.hidden_widgets.push(`${this.node.id}:${child.display_name}`) 244 | }) 245 | } 246 | } 247 | 248 | set_widget_visibility(display_name, v) { 249 | const wid = `${this.node.id}:${display_name}` 250 | if (v) { 251 | const index = this.parent_controller.settings.hidden_widgets.findIndex((e)=>(e==wid)) 252 | if (index>=0) this.parent_controller.settings.hidden_widgets.splice(index, 1) 253 | } else { 254 | this.parent_controller.settings.hidden_widgets.push(wid) 255 | } 256 | } 257 | 258 | apply_widget_visibility() { 259 | Array.from(this.main.children).filter((child)=>(child.display_name)).forEach((child)=>{ 260 | const wid = `${this.node.id}:${child.display_name}` 261 | if (this.parent_controller.settings.hidden_widgets.find((e)=>(e==wid))) child.classList.add("hidden") 262 | }) 263 | } 264 | 265 | show_nodeblock_context_menu(e) { 266 | const ewv_submenu = (value, options, e, menu, node) => { 267 | const choices = [] 268 | const re = /(.*) '(.*)'/ 269 | var showing = 0 270 | var hidden = 0 271 | Array.from(this.main.children).forEach((child)=>{ 272 | if (child.display_name && (child.display_name!=Texts.IMAGE_WIDGET_NAME || !this.image_panel.classList.contains('nodeblock_image_empty'))) { 273 | choices.push(`${child.classList.contains('hidden') ? Generic.SHOW : Generic.HIDE} '${child.display_name}'`) 274 | if (child.classList.contains('hidden')) hidden += 1 275 | else showing += 1 276 | } 277 | }) 278 | if (showing>1) choices.push(Generic.HIDE_ALL) 279 | if (hidden>1) choices.push(Generic.SHOW_ALL) 280 | 281 | const submenu = new LiteGraph.ContextMenu( 282 | choices, 283 | { event: e, callback: (v) => { 284 | if (v==Generic.HIDE_ALL || v==Generic.SHOW_ALL) { 285 | this.set_all_widget_visibility(v==Generic.SHOW_ALL) 286 | } else { 287 | const match = v.match(re) 288 | this.set_widget_visibility(match[2], (match[1]==Generic.SHOW)) 289 | } 290 | UpdateController.make_request('wve', null, null, this.parent_controller) 291 | close_context_menu() 292 | }, 293 | parentMenu: menu, node:node} 294 | ) 295 | } 296 | 297 | const eis_submenu = (value, options, e, menu, node) => { 298 | new LiteGraph.ContextMenu( 299 | [Texts.ACCEPT_UPSTREAM, Texts.REJECT_UPSTREAM, Texts.NO_IMAGES], 300 | { 301 | event: e, 302 | callback: (v) => { 303 | this.set_rejects_upstream((v==Texts.REJECT_UPSTREAM)) 304 | this.set_rejects_images((v==Texts.NO_IMAGES)) 305 | UpdateController.make_request('eis', null, null, this.parent_controller) 306 | close_context_menu() 307 | }, 308 | parentMenu: menu, node:node 309 | } 310 | ) 311 | } 312 | 313 | open_context_menu(e, null, [ 314 | { 315 | "title" : Texts.REMOVE, 316 | "callback" : ()=>{ 317 | this.node.properties["controller"] = InclusionOptions.EXCLUDE 318 | NodeInclusionManager.node_change_callback?.('context_menu_remove', Timings.GENERIC_SHORT_DELAY); 319 | } 320 | }, 321 | { 322 | "title" : Texts.EDIT_IMAGE_SETTING, 323 | has_submenu: true, 324 | callback: eis_submenu, 325 | }, 326 | { 327 | "title" : Texts.EDIT_WV, 328 | has_submenu: true, 329 | callback: ewv_submenu, 330 | } 331 | ]) 332 | } 333 | 334 | nodeblock_context_menu(e) { 335 | e.stopImmediatePropagation() 336 | e.preventDefault() 337 | this.show_nodeblock_context_menu(e) 338 | } 339 | 340 | image_context_menu(e) { 341 | open_context_menu(e, "Image", [ 342 | { 343 | "title":"Open in Mask Editor", 344 | "callback":()=>{ 345 | ComfyApp.copyToClipspace(this.node) 346 | ComfyApp.clipspace_return_node = this.node 347 | ComfyApp.open_maskeditor() 348 | } 349 | }, 350 | { 351 | "title":"Open in new tab", 352 | "callback":()=>{ 353 | window.open(this.urls[this.imageIndex]) 354 | } 355 | }, 356 | { 357 | "title":"Show in popup", 358 | callback:()=>{ 359 | ImagePopup.show(this.urls[this.imageIndex]) 360 | } 361 | }, 362 | { 363 | "title":"Save image", 364 | "callback":()=>{ 365 | const a = create('a', 'hidden', this, {href:this.urls[this.imageIndex], download:"image.png"}) 366 | a.click() 367 | a.remove() 368 | } 369 | }, 370 | ]) 371 | } 372 | 373 | build_nodeblock() { 374 | const new_main = create("span", 'nb_main') 375 | 376 | this.title_bar = create("span", 'nodeblock_titlebar', new_main) 377 | 378 | this.title_bar_left = create("span", 'nodeblock_titlebar_left', this.title_bar) 379 | this.draghandle = create("span", 'nodeblock_draghandle', this.title_bar, { }) 380 | this.title_bar_right = create("span", 'nodeblock_titlebar_right', this.title_bar) 381 | 382 | this.add_handle_drag_handlers(this.draghandle) 383 | this.draghandle.handle_right_click = (e)=>{ 384 | this.nodeblock_context_menu(e) 385 | } 386 | 387 | this.minimised = this.get_minimised() 388 | 389 | this.mode_button = create('i', `pi mode_button mode_button_${this.mode}`, this.title_bar_left) 390 | this.mode_button.addEventListener('click', (e)=>{ 391 | if (app.canvas.read_only) return 392 | kill_event(e) 393 | this.node.mode = mode_change(this.node.mode,e) 394 | app.canvas.setDirty(true,true) 395 | OnChangeController.on_change('node mode button') 396 | }) 397 | 398 | this.title_text = create("span", 'nodeblock_title', this.draghandle, {"innerText":this.node.title, 'draggable':false}) 399 | tooltip_if_overflowing(this.title_text, this.title_bar) 400 | 401 | this.image_pin = create('i', 'pi pi-thumbtack blank', this.title_bar_right) 402 | this.image_pin.addEventListener('click', (e) => { 403 | if (app.canvas.read_only) return 404 | this.node.properties.controller_widgets[this.image_panel_id].pinned = !this.node.properties.controller_widgets[this.image_panel_id].pinned 405 | this.update_pin(true) 406 | }) 407 | 408 | this.style.backgroundColor = this.node.bgcolor ?? LiteGraph.NODE_DEFAULT_BGCOLOR 409 | if (this.node.bgcolor) { 410 | this.title_bar.style.backgroundColor = darken(this.node.bgcolor) 411 | } else { 412 | this.title_bar.classList.add("titlebar_nocolor") 413 | } 414 | 415 | classSet(this, 'minimised', this.minimised) 416 | 417 | if (this.image_panel) this.image_panel.remove() 418 | this.image_panel = create("div", "nodeblock_image_panel nodeblock_image_empty", new_main, {"display_name":Texts.IMAGE_WIDGET_NAME}) 419 | 420 | this.widget_count = 0 421 | this.entry_controlling_image = null 422 | this.node.widgets?.forEach(w => { 423 | if (!this.node.properties.controller_widgets[w.name]) this.node.properties.controller_widgets[w.name] = {} 424 | const properties = this.node.properties.controller_widgets[w.name] 425 | try { 426 | const e = new Entry(this.parent_controller, this, this.node, w, properties) 427 | if (e.valid()) { 428 | if (e.combo_for_image) this.entry_controlling_image = e 429 | new_main.appendChild(e) 430 | //this[w.name] = e // removed because it breaks when widgets are named like 'style'. Why was it here? Dates from change 12149b1 431 | this.widget_count += 1 432 | } else { 433 | e._remove() 434 | } 435 | } catch (e) { 436 | Debug.error(`adding widget on node ${this.node.id}`, e) 437 | } 438 | }) 439 | 440 | this.image_panel_id = `__image_panel.${this.parent_controller.settings.index}` 441 | 442 | if (!this.node.properties.controller_widgets[this.image_panel_id]) { 443 | this.node.properties.controller_widgets[this.image_panel_id] = {} 444 | if (this.node.properties.controller_widgets['__image_panel']) { // backward compatibility - get the size from the per-node version 445 | Object.assign(this.node.properties.controller_widgets[this.image_panel_id], this.node.properties.controller_widgets['__image_panel']) 446 | delete this.node.properties.controller_widgets['__image_panel'] 447 | } 448 | } 449 | if (this.node.properties.controller_widgets[this.image_panel_id].pinned == undefined) this.node.properties.controller_widgets[this.image_panel_id].pinned = true 450 | this.update_pin() 451 | 452 | this.image_paging = create('span', 'overlay overlay_paging', this.image_panel) 453 | 454 | this.image_prev = create('span', 'overlay_paging_icon prev', this.image_paging) 455 | this.image_xofy = create('span', 'overlay_paging_text', this.image_paging) 456 | this.image_next = create('span', 'overlay_paging_icon next', this.image_paging) 457 | this.image_prev.addEventListener('click', ()=>{this.previousImage()}) 458 | this.image_next.addEventListener('click', ()=>{this.nextImage()}) 459 | 460 | this.image_show_grid = create('span', 'overlay overlay_show_grid', this.image_panel, {"innerText":"x"}) 461 | this.image_show_grid.addEventListener('click', ()=>{this.showImageGrid()}) 462 | 463 | this.image_grid = create('span', 'nodeblock_image_grid', this.image_panel) 464 | this.image_grid.addEventListener('mousemove', NodeBlock.handle_mouse_move) 465 | 466 | 467 | make_resizable( this.image_panel, this.node.id, this.image_panel_id, this.node.properties.controller_widgets[this.image_panel_id] ) 468 | this.resize_observer = new ResizeObserver( ()=>{this.rescale_image()} ).observe(this.image_panel) 469 | if (app.canvas.read_only) this.image_panel.style.resize = "none" 470 | 471 | this._remove_entries() 472 | this.replaceChild(new_main, this.main) 473 | this.main = new_main 474 | this.apply_widget_visibility() 475 | 476 | if (ImageManager.get_urls(this.node.id)) { 477 | this.show_images(ImageManager.get_urls(this.node.id), this.node.id) 478 | } else if (this.node.imgs && this.node.imgs.length>0) { 479 | const urls = [] 480 | this.node.imgs.forEach((i)=>{urls.push(i.src)}) 481 | this.show_images(urls, this.node.id) 482 | } 483 | 484 | this.valid_nodeblock = true 485 | //if (!(isImageNode(this.node) || this.widget_count || (this.node.imgs && this.node.imgs.length>0))) this.set_minimised(true) 486 | } 487 | 488 | update_pin(from_click) { 489 | classSet(this.image_pin, 'clicked', this.node.properties.controller_widgets[this.image_panel_id].pinned) 490 | this.image_panel.style.resize = this.node.properties.controller_widgets[this.image_panel_id].pinned ? "none" : "vertical" 491 | this.rescale_image(from_click) 492 | } 493 | 494 | rescale_image(just_clicked_pin) { 495 | if (this.rescaling || !this.parent_controller || this.parent_controller.settings.collapsed || !this.urls || !this.urls.length) return 496 | 497 | const children_in_grid = Array.from(this.image_grid.children).filter((c)=>(!c.exclude_from_grid)) 498 | if (!children_in_grid.length) return 499 | 500 | const first_image = children_in_grid[0] 501 | const is_blob = image_is_blob(this.urls[0]) 502 | const pinned = this.node.properties.controller_widgets[this.image_panel_id].pinned 503 | const panel_box = this.image_panel.getBoundingClientRect() 504 | const grid_box = this.image_grid.getBoundingClientRect() 505 | 506 | if (!panel_box.width) return 507 | 508 | try { 509 | this.rescaling = true 510 | //const available_width = box.width - 8 511 | this.node.properties.controller_widgets[this.image_panel_id].height = panel_box.height 512 | 513 | this.images_per_row = this.pick_images_per_row(this.image_grid.firstChild, children_in_grid.length) 514 | this.image_rows = Math.ceil(children_in_grid.length / this.images_per_row) 515 | 516 | children_in_grid.forEach((img, i)=>{ 517 | img.style.gridArea = `${Math.floor(i/this.images_per_row) + 1} / ${i%this.images_per_row + 1} / auto / auto`; 518 | //img.style.width = `${this.ratio * img.naturalWidth}px` 519 | }) 520 | 521 | this.node.properties.controller_widgets[this.image_panel_id].height = panel_box.height 522 | 523 | var img_width = first_image.naturalWidth 524 | var img_height = first_image.naturalHeight 525 | if (!img_width || !img_height) { 526 | if (is_blob) { 527 | img_width = ImageManager.last_preview_image?.width 528 | img_height = ImageManager.last_preview_image?.height 529 | } 530 | if (!img_width || !img_height) return 531 | Debug.trivia(`Got size from blob: ${img_width} ${img_height}`) 532 | } else { 533 | Debug.trivia(`Got size from image: ${img_width} ${img_height}`) 534 | } 535 | 536 | const total_images_height = img_height * this.image_rows 537 | const total_images_width = img_width * this.images_per_row 538 | 539 | const image_aspect_ratio = img_height / img_width 540 | const grid_aspect_ratio = total_images_height / total_images_width 541 | 542 | const panel_settings = {'maxHeight': 'unset'} 543 | const image_settings = {} 544 | const grid_settings = {} 545 | 546 | var grid_height = panel_box.height 547 | var grid_width = panel_box.width 548 | 549 | if (pinned) { 550 | panel_settings['maxHeight'] = `${panel_box.width * grid_aspect_ratio}px` 551 | panel_settings['height'] = `${panel_box.width * grid_aspect_ratio}px` 552 | grid_height = grid_width * grid_aspect_ratio 553 | } else { 554 | panel_settings['maxHeight'] = `${panel_box.width * image_aspect_ratio * this.urls.length}px` 555 | if (just_clicked_pin) { 556 | const overflow = panel_box.bottom - this.parent_controller.getBoundingClientRect().bottom + 8 557 | if (overflow>0) { 558 | grid_height -= overflow 559 | } 560 | } 561 | grid_width = Math.min(grid_height / grid_aspect_ratio, grid_width) 562 | } 563 | 564 | grid_settings['height'] = `${grid_height}px` 565 | grid_settings['width'] = `${grid_width}px` 566 | 567 | image_settings['width'] = `${grid_width}px` 568 | 569 | Object.assign(this.image_panel.style, panel_settings) 570 | Object.assign(this.image_grid.style, grid_settings) 571 | children_in_grid.forEach((img)=>{Object.assign(img.style, image_settings)}) 572 | if (this.image_compare_overlay) Object.assign(this.image_compare_overlay.style, image_settings) 573 | 574 | } finally { this.rescaling = false } 575 | } 576 | 577 | select_image(nm) { 578 | this.show_images([get_image_url(nm),]) 579 | } 580 | 581 | static comparing = null 582 | static handle_mouse_move(e) { 583 | if (e.target.doing_compare) { 584 | NodeBlock.comparing = e.target.doing_compare 585 | const fraction = e.offsetX / NodeBlock.comparing.image_grid.getBoundingClientRect().width 586 | NodeBlock.comparing.show_part_of_overlay(fraction) 587 | Debug.extended(`mouse over compare image ${fraction}`) 588 | e.stopPropagation() 589 | } else if (NodeBlock.comparing) { 590 | NodeBlock.comparing.show_part_of_overlay(0) 591 | NodeBlock.comparing = null 592 | } 593 | } 594 | 595 | show_part_of_overlay(fraction) { 596 | if (this.image_compare_overlay) { 597 | const box = this.image_grid.firstChild.getBoundingClientRect() 598 | const w = fraction * box.width 599 | const h = box.height 600 | const delta = 0 601 | this.image_compare_overlay.style.clip = `rect(${delta}px, ${w}px, ${h-delta}px, 0px)` 602 | } 603 | } 604 | 605 | show_images(urls, node_id) { 606 | if (this.reject_image(node_id!=this.node.id)) return 607 | 608 | Debug.trivia(`called show_images for ${this.node.id} with ${urls?.length} images from ${node_id}`) 609 | 610 | if (this.entry_controlling_image) setTimeout(()=>{ 611 | this.entry_controlling_image.update_combo_selection() 612 | }, Timings.GENERIC_SHORT_DELAY) 613 | 614 | this.urls = urls 615 | const nothing = !(urls && urls.length>0) 616 | const is_blob = (!nothing && image_is_blob(urls[0])) 617 | if (is_blob && this.node.id==18) { 618 | let a; 619 | } 620 | const doing_compare = (this.node.type=="Image Comparer (rgthree)" && urls && urls.length==2) 621 | 622 | if (nothing || !(this.node.imageIndex < urls.length && this.node.imageIndex>=0)) this.node.imageIndex = null 623 | this.imageIndex = this.node.imageIndex 624 | 625 | classSet(this.image_panel, 'nodeblock_image_empty', nothing) 626 | classSet(this.image_grid, 'hidden', nothing) 627 | classSet(this.image_pin, 'blank', nothing) 628 | classSet(this.image_paging, 'hidden', doing_compare || this.imageIndex===null || this.urls.length<2) 629 | classSet(this.image_show_grid, 'hidden', doing_compare || this.imageIndex===null || this.urls.length<2) 630 | 631 | 632 | if (nothing) { 633 | this.image_grid.innerHTML = '' 634 | } else if (doing_compare) { 635 | this.image_grid.innerHTML = '' 636 | create('img', 'nodeblock_image_grid_image', this.image_grid, {src:urls[0], "doing_compare":this}) 637 | this.image_compare_overlay = create('img', 'nodeblock_image_overlay nodeblock_image_grid_image', this.image_grid, {src:urls[1], "exclude_from_grid":true}) 638 | setTimeout(this.show_part_of_overlay.bind(this), Timings.GENERIC_SHORT_DELAY, 0.0) 639 | } else if (this.imageIndex !== null) { 640 | if (this.image_grid.children.length != 1) { 641 | this.image_grid.innerHTML = '' 642 | create('img', 'nodeblock_image_grid_image', this.image_grid, {src:this.urls[this.imageIndex]}) 643 | } else { 644 | this.image_grid.firstChild.src = this.urls[this.imageIndex] 645 | } 646 | this.image_xofy.innerText = `${this.imageIndex+1} of ${this.urls.length}` 647 | } else { 648 | if (this.image_grid.children.length != this.urls.length) { 649 | this.image_grid.innerHTML = '' 650 | this.urls.forEach((url, i)=>{ 651 | const img = create('img', 'nodeblock_image_grid_image', this.image_grid, {src:url}) 652 | img.addEventListener('click', ()=>{this.node.imageIndex = i; this.show_images(this.urls)}) 653 | }) 654 | } else { 655 | this.urls.forEach((url, i)=>{ this.image_grid.children[i].src = url }) 656 | } 657 | } 658 | 659 | if (!nothing) { 660 | this.image_grid.firstChild.addEventListener('load', (e) => {this.rescale_image()}) 661 | Array.from(this.image_grid.children).forEach((img, i)=>{ 662 | img.addEventListener('click', (e)=>{ 663 | if (e.shiftKey) { ImagePopup.show(this.urls[i]) } 664 | }) 665 | img.handle_right_click = (e) => { 666 | if (this.node.imageIndex===null) this.node.imageIndex = i; 667 | this.image_context_menu(e) 668 | } 669 | }) 670 | } 671 | 672 | this.rescale_image() 673 | } 674 | 675 | pick_images_per_row(an_image, count) { 676 | if (count==1) return 1 677 | const pinned = this.node.properties.controller_widgets[this.image_panel_id].pinned 678 | const w = an_image?.naturalWidth 679 | const h = an_image?.naturalHeight 680 | const box = this.image_panel.getBoundingClientRect() 681 | 682 | if (w && h && box.width && box.height) { 683 | var best = 0 684 | var best_pick 685 | for (var per_row=1; per_row<=count; per_row++) { 686 | const rows = Math.ceil(count / per_row) 687 | const width = per_row * w 688 | const height = rows * h 689 | var ratio 690 | if (pinned) { 691 | ratio = Math.min(width / height, height / width) 692 | } else { 693 | ratio = Math.min(box.width / width, box.height / height) 694 | Debug.trivia(`ratio ${ratio} for ${per_row} per row, ${rows} rows`) 695 | } 696 | if (ratio > best) { 697 | best = ratio 698 | best_pick = per_row 699 | Debug.trivia(`best yet ${best} for ${best_pick} per row`) 700 | } 701 | } 702 | return best_pick 703 | } 704 | return Math.ceil(Math.sqrt(count)) 705 | 706 | } 707 | 708 | previousImage() { 709 | this.node.imageIndex -= 1 710 | if (this.node.imageIndex < 0) this.node.imageIndex = this.urls.length - 1 711 | app.canvas.setDirty(true,true) 712 | this.show_images(this.urls) 713 | } 714 | 715 | nextImage() { 716 | this.node.imageIndex += 1 717 | if (this.node.imageIndex >= this.urls.length) this.node.imageIndex = 0 718 | app.canvas.setDirty(true,true) 719 | this.show_images(this.urls) 720 | } 721 | 722 | showImageGrid() { 723 | this.node.imageIndex = null 724 | app.canvas.setDirty(true,true) 725 | this.show_images(this.urls) 726 | } 727 | 728 | } 729 | 730 | 731 | 732 | customElements.define('cp-span', NodeBlock, {extends: 'span'}) -------------------------------------------------------------------------------- /js/options.js: -------------------------------------------------------------------------------- 1 | import { SettingIds, SettingNames, Tooltips, Generic } from "./constants.js"; 2 | 3 | export const OPTIONS = [ 4 | { 5 | id: SettingIds.MINIMUM_TAB_WIDTH, 6 | name: SettingNames.MINIMUM_TAB_WIDTH, 7 | tooltip: Tooltips.MINIMUM_TAB_WIDTH, 8 | type: "slider", 9 | attrs: { 10 | min: 20, 11 | max: 150 12 | }, 13 | defaultValue: 50 14 | }, 15 | { 16 | id: SettingIds.HIDE_EXTENSIONS, 17 | name: SettingNames.HIDE_EXTENSIONS, 18 | tooltip: Tooltips.HIDE_EXTENSIONS, 19 | type: "boolean", 20 | defaultValue: false 21 | }, 22 | { 23 | id: SettingIds.KEYBOARD_TOGGLE, 24 | name: SettingNames.KEYBOARD_TOGGLE, 25 | type: "combo", 26 | options: [ {value:0, text:"Off"}, {value:"c", text:"c"}, {value:"C", text:"shift-C"}, 27 | {value:"o", text:"o"}, {value:"O", text:"shift-O"}], 28 | defaultValue: "C", 29 | }, 30 | { 31 | id: SettingIds.SHOW_SCROLLBARS, 32 | name: SettingNames.SHOW_SCROLLBARS, 33 | tooltip: Tooltips.SHOW_SCROLLBARS, 34 | type: "combo", 35 | options: [ {value:"no", text:Generic.OFF}, 36 | {value:"thin", text:Generic.THIN}, 37 | {value:"full", text:Generic.NORMAL}, 38 | ], 39 | defaultValue: "thin", 40 | }, 41 | { 42 | id: SettingIds.FONT_SIZE, 43 | name: SettingNames.FONT_SIZE, 44 | tooltip: Tooltips.FONT_SIZE, 45 | type: "slider", 46 | attrs: { 47 | min: 10, 48 | max: 16 49 | }, 50 | defaultValue: 12 51 | }, 52 | { 53 | id: SettingIds.CONTROL_AFTER_GENERATE, 54 | name: SettingNames.CONTROL_AFTER_GENERATE, 55 | tooltip: Tooltips.CONTROL_AFTER_GENERATE, 56 | type: "boolean", 57 | defaultValue: true 58 | }, 59 | { 60 | id: SettingIds.TOOLTIPS, 61 | name: SettingNames.TOOLTIPS, 62 | tooltip: Tooltips.TOOLTIPS, 63 | type: "boolean", 64 | defaultValue: true 65 | }, 66 | 67 | { 68 | id: SettingIds.SHOW_IN_FOCUS_MODE, 69 | name: SettingNames.SHOW_IN_FOCUS_MODE, 70 | type: "boolean", 71 | defaultValue: false 72 | }, 73 | { 74 | id: SettingIds.SCROLL_MOVES_SLIDERS, 75 | name: SettingNames.SCROLL_MOVES_SLIDERS, 76 | type: "combo", 77 | options: [ {value:"no", text:Generic.NEVER}, 78 | {value:"yes", text:Generic.ALWAYS}, 79 | {value:"shift", text:Generic.SHIFT}, 80 | {value:"ctrl", text:Generic.CTRL}, 81 | ], 82 | defaultValue: "yes", 83 | }, 84 | { 85 | id: SettingIds.SCROLL_REVERSED, 86 | name: SettingNames.SCROLL_REVERSED, 87 | tooltip: Tooltips.SCROLL_REVERSED, 88 | type: "boolean", 89 | defaultValue: false 90 | }, 91 | 92 | { 93 | id: SettingIds.EDIT_SLIDERS, 94 | name: SettingNames.EDIT_SLIDERS, 95 | type: "combo", 96 | options: [ 97 | {value:"shift", text:"shift-click"}, 98 | {value:"ctrl", text:"ctrl-click"}, 99 | ], 100 | defaultValue: "yes", 101 | }, 102 | { 103 | id: SettingIds.DEFAULT_APPLY_TO_SIMILAR, 104 | name: SettingNames.DEFAULT_APPLY_TO_SIMILAR, 105 | tooltip: Tooltips.DEFAULT_APPLY_TO_SIMILAR, 106 | type: "boolean", 107 | defaultValue: true 108 | }, 109 | { 110 | id: SettingIds.DEBUG_LEVEL, 111 | name: SettingNames.DEBUG_LEVEL, 112 | tooltip: Tooltips.DEBUG_LEVEL, 113 | type: "combo", 114 | options: [ {value:0, text:Generic.D0}, 115 | {value:1, text:Generic.D1}, 116 | {value:2, text:Generic.D2}, 117 | {value:3, text:Generic.D3} ], 118 | defaultValue: "1" 119 | } 120 | ].reverse() -------------------------------------------------------------------------------- /js/panel_entry.js: -------------------------------------------------------------------------------- 1 | import { app } from "../../scripts/app.js"; 2 | import { create, tooltip_if_overflowing, extension_hiding } from "./utilities.js"; 3 | import { FancySlider } from "./input_slider.js"; 4 | import { rounding } from "./utilities.js"; 5 | import { make_resizable } from "./resize_manager.js"; 6 | import { OnChangeController, UpdateController } from "./update_controller.js"; 7 | import { Debug } from "./debug.js"; 8 | import { SettingIds } from "./constants.js"; 9 | import { Toggle } from "./toggle.js"; 10 | import { WidgetChangeManager } from "./widget_change_manager.js"; 11 | import { Texts } from "./constants.js"; 12 | import { PLL_Widget } from "./power_lora_loader_widget.js"; 13 | import { ImageComparerControlWidget } from "./image_comparer_control_widget.js"; 14 | import { ExtendedCombo } from "./combo.js"; 15 | 16 | function typecheck_number(v) { 17 | const vv = parseFloat(v) 18 | if (isNaN(vv)) return null 19 | return vv 20 | } 21 | 22 | function typecheck_other(v) { return v } 23 | 24 | export class Entry extends HTMLDivElement { 25 | /* 26 | Entry represents a single widget within a NodeBlock 27 | */ 28 | static count = 0 29 | 30 | _remove() { 31 | this.remove() 32 | Object.keys(this).forEach((k)=>{this[k]=null}) 33 | Entry.count -= 1 34 | //Debug.trivia(`Entry._remove count = ${Entry.count}`) 35 | } 36 | 37 | constructor(parent_controller, parent_nodeblock, node, target_widget, properties) { 38 | super() 39 | Entry.count += 1 40 | if (target_widget.disabled) return 41 | if (target_widget.name=='control_after_generate' && !app.ui.settings.getSettingValue(SettingIds.CONTROL_AFTER_GENERATE)) return 42 | 43 | const widget_label = (target_widget.label && target_widget.label!="") ? target_widget.label : target_widget.name 44 | this.display_name = widget_label 45 | 46 | this.classList.add('entry') 47 | this.parent_controller = parent_controller 48 | this.parent_nodeblock = parent_nodeblock 49 | this.target_widget = target_widget 50 | this.input_element = null 51 | this.properties = properties 52 | 53 | target_widget.type = target_widget.type ?? target_widget.constructor.name 54 | 55 | var implementation_type = target_widget.type 56 | if (node.type=="ImpactSwitch" && target_widget.type=="number") implementation_type = "switch-combo" 57 | if (node.type=="Fast Groups Muter (rgthree)") implementation_type = "toggle" 58 | if (node.type=="Fast Groups Bypasser (rgthree)") implementation_type = "toggle" 59 | 60 | switch (implementation_type) { 61 | case 'text': 62 | this.entry_label = create('span','entry_label text', this, {'innerText':widget_label, 'draggable':false} ) 63 | this.input_element = create('input', 'input', this) 64 | break 65 | case 'customtext': 66 | this.input_element = create("textarea", 'input', this, {"title":widget_label, "placeholder":widget_label}) 67 | make_resizable( this.input_element, node.id, target_widget.name, properties ) 68 | break 69 | case 'number': 70 | this.input_element = new FancySlider(parent_controller, node, target_widget, properties) 71 | this.is_integer = this.input_element.is_integer 72 | this.input_element.addEventListener('keydown', this.keydown_callback.bind(this)) 73 | this.appendChild(this.input_element) 74 | break 75 | case 'switch-combo': 76 | this.choices_value_to_name = {} 77 | for (var idx=0; idx { 82 | this.input_element.add(new Option(this.choices_value_to_name[value], value )) 83 | }) 84 | this.input_element.value = target_widget.value 85 | this.input_element.redraw = () => { this.entry_value.innerText = this.choices_value_to_name[this.input_element.value] } 86 | this.input_element.addEventListener("change", this.input_element.redraw.bind(this)) 87 | this.input_element.redraw() 88 | break 89 | case 'combo': 90 | this.choices = (target_widget.options.values instanceof Function) ? target_widget.options.values() : target_widget.options.values 91 | this.input_element = new ExtendedCombo(this.choices, target_widget, node) 92 | this.entry_label = create('span','entry_label text combo', this, {'innerText':widget_label, 'draggable':false} ) 93 | this.combo_label_wrapper = create('span', 'combo_label_wrapper', this) 94 | this.combo_label_wrapper.appendChild(this.entry_label) 95 | this.combo_label_wrapper.appendChild(this.input_element) 96 | break 97 | case 'RgthreeBetterButtonWidget': 98 | case 'button': 99 | this.input_element = create("button", 'input', this, {"innerText":widget_label, "doesntBlockRefresh":true}) 100 | break 101 | case 'toggle': 102 | this.input_element = new Toggle(target_widget.value, widget_label, target_widget.options?.on, target_widget.options?.off) 103 | this.appendChild(this.input_element) 104 | break 105 | case 'PowerLoraLoaderHeaderWidget': 106 | var state = null 107 | node.widgets.filter((w)=>(w.type=="PowerLoraLoaderWidget")).forEach((w)=>{ 108 | if (w.value.on) { 109 | if (state=='mixed' || state=='off') state = 'mixed' 110 | else state = 'on' 111 | } else { 112 | if (state=='mixed' || state=='on') state = 'mixed' 113 | else state = 'off' 114 | } 115 | }) 116 | this.input_element = new Toggle(state, "Toggle All", "On", "Off", "Mixed") 117 | this.input_element.addEventListener('click', (e)=>{ 118 | target_widget.hitAreas.toggle.onDown.bind(target_widget)(e, null, node) 119 | app.graph.setDirtyCanvas(true,true); 120 | OnChangeController.on_change('Power lora toggle all') 121 | }) 122 | this.appendChild(this.input_element) 123 | break 124 | case 'PowerLoraLoaderWidget': 125 | this.input_element = new PLL_Widget(this.parent_controller, node, target_widget) 126 | this.appendChild(this.input_element) 127 | break 128 | case 'RgthreeImageComparerWidget': 129 | this.input_element = new ImageComparerControlWidget(this.parent_controller, node, target_widget) 130 | this.appendChild(this.input_element) 131 | break 132 | case 'converted-widget': 133 | case 'converted-widget:seed': 134 | case 'RgthreeDividerWidget': 135 | case 'string': 136 | Debug.trivia(`Not adding known widget type ${implementation_type}`) 137 | return 138 | default: 139 | Debug.extended(`Not adding unknown widget type ${implementation_type}`) 140 | return 141 | } 142 | 143 | if (!this.input_element) return 144 | 145 | this.combo_for_image = (this.target_widget.name=='image' && this.target_widget._real_value && this.target_widget.type=="combo") 146 | 147 | switch (implementation_type) { 148 | case 'RgthreeBetterButtonWidget': 149 | case 'button': 150 | this.input_element.addEventListener('click', this.button_click_callback.bind(this)) 151 | break 152 | case 'PowerLoraLoaderWidget': 153 | case 'PowerLoraLoaderHeaderWidget': 154 | break 155 | default: 156 | this.input_element.addEventListener('input', this.input_callback.bind(this)) 157 | } 158 | 159 | this.typecheck = (target_widget.type=='number') ? typecheck_number : typecheck_other 160 | if (node.__controller_tooltips && node.__controller_tooltips[target_widget.name]) this.tooltip = node.__controller_tooltips[target_widget.name] 161 | 162 | if (target_widget.element) { 163 | target_widget.element.addEventListener('input', (e)=>{WidgetChangeManager.notify(target_widget)}) 164 | } else { 165 | if (!target_widget.original_callback) target_widget.original_callback = target_widget.callback 166 | const callback = target_widget.original_callback 167 | target_widget.callback = function() { 168 | callback?.apply(this, arguments) // (target_widget.value, app.canvas, this.pa) 169 | WidgetChangeManager.notify(target_widget) 170 | } 171 | } 172 | 173 | WidgetChangeManager.add_listener(target_widget, this) 174 | this.render() 175 | } 176 | 177 | update_combo_selection() { 178 | if (this.input_element) { 179 | this.input_element.value = this.target_widget.value 180 | if (this.entry_value) this.entry_value.innerText = this.target_widget.value 181 | } else { 182 | Debug.important("update_combo with no input_element") 183 | } 184 | } 185 | 186 | wcm_manager_callback() { 187 | if (!this.parent_controller?.contains(this)) return false; 188 | this.input_element.value = this.target_widget.value; 189 | if (this.input_element.wcm_manager_callback) this.input_element.wcm_manager_callback() 190 | if (this.input_element.redraw) this.input_element.redraw(true) 191 | if (this.combo_for_image) { 192 | try { 193 | this.parent_nodeblock.select_image(this.target_widget.value) 194 | } catch (e) { 195 | Debug.error('wcm_manager_callback',e) 196 | } 197 | } 198 | return true; 199 | } 200 | 201 | valid() { return (this.input_element != null) } 202 | 203 | input_callback(e) { 204 | Debug.trivia("input_callback") 205 | UpdateController.push_pause() 206 | try { 207 | const v = this.typecheck(e.target.value) 208 | if (v != null && this.target_widget.value != v) { 209 | this.target_widget.value = v 210 | this.target_widget.callback?.(v, app.canvas, this.parent_nodeblock.node, [e.x,e.y], e) 211 | WidgetChangeManager.notify(this.target_widget) 212 | } 213 | } finally { UpdateController.pop_pause() } 214 | } 215 | 216 | keydown_callback(e) { 217 | Debug.trivia("keydown_callback") 218 | if (e.key=="Enter") document.activeElement.blur(); 219 | } 220 | 221 | button_click_callback(e) { 222 | Debug.trivia("button_click_callback") 223 | UpdateController.push_pause() 224 | try { 225 | if (this.target_widget.mouseUpCallback) { 226 | this.target_widget.mouseUpCallback(e); // RgthreeBetterButtonWidget uses mouseUpCallback 227 | } else { 228 | this.target_widget.callback() 229 | } 230 | app.graph.setDirtyCanvas(true,true); 231 | OnChangeController.on_change("button clicked") 232 | } finally { UpdateController.pop_pause() } 233 | } 234 | 235 | render() { 236 | if (document.activeElement == this.input_element) return 237 | tooltip_if_overflowing(this.entry_label, this) 238 | tooltip_if_overflowing(this.entry_value, this) 239 | if (this.input_element.value == this.target_widget.value) return 240 | this.input_element.value = rounding(this.target_widget.value, this.target_widget.options) 241 | } 242 | } 243 | 244 | customElements.define('cp-input', Entry, {extends: 'div'}) -------------------------------------------------------------------------------- /js/power_lora_loader_widget.js: -------------------------------------------------------------------------------- 1 | import { create } from "./utilities.js" 2 | import { Toggle } from "./toggle.js" 3 | import { OnChangeController } from "./update_controller.js" 4 | import { FancySlider } from "./input_slider.js" 5 | import { app } from "../../scripts/app.js"; 6 | import { extension_hiding } from "./utilities.js"; 7 | import { close_context_menu, register_closable } from "./context_menu.js"; 8 | 9 | class PseudoWidget { 10 | constructor(target_widget, pil, slider_name, val_name = "strength") { 11 | this.target_widget = target_widget 12 | this.slider_name = slider_name 13 | this.pil = pil 14 | 15 | Object.defineProperty(this, 'value', { 16 | 'get': () => { return this.target_widget.value[val_name] }, 17 | 'set': (v) => { 18 | this.target_widget.value[val_name] = v; 19 | if (this.pil[this.slider_name]) { 20 | this.pil[this.slider_name].value = v 21 | this.pil[this.slider_name].redraw() 22 | } 23 | 24 | } 25 | }) 26 | 27 | this.options = {"min":parseFloat(target_widget.loraInfo?.strengthMin ?? -4), "max":parseFloat(target_widget.loraInfo?.strengthMax ?? 4), "precision":2} 28 | this.label = "strength" 29 | } 30 | 31 | original_callback(v) { 32 | this.value = v 33 | } 34 | } 35 | 36 | export class PLL_Widget extends HTMLSpanElement { 37 | constructor(parent_controller, node, target_widget) { 38 | super() 39 | this.twoStrengths = !!(target_widget.value.strengthTwo) 40 | this.parent_controller = parent_controller 41 | this.node = node 42 | this.target_widget = target_widget 43 | this.classList.add('two_line_entry') 44 | this.top_line = create('span','line', this) 45 | 46 | this.label = create('span', 'label', this.top_line, {"innerText":extension_hiding(this.target_widget._value.lora)}) 47 | this.label.addEventListener('click', (e)=>{ 48 | e.stopPropagation() 49 | close_context_menu() 50 | register_closable() 51 | this.target_widget.hitAreas.lora.onDown.bind(this.target_widget)(e,null,node) 52 | }) 53 | this.on_off = new Toggle(target_widget._value.on, "", "Active", "Muted") 54 | this.on_off.addEventListener('input',(e)=>{ 55 | target_widget.hitAreas.toggle.onDown.apply(target_widget, e) 56 | OnChangeController.on_change('pll_widget toggle') 57 | app.canvas.setDirty(true,true) 58 | }) 59 | this.top_line.appendChild(this.on_off) 60 | this.on_off.render() 61 | 62 | this.second_line = create('span','line', this) 63 | 64 | 65 | this.options = {} 66 | this.ps = new PseudoWidget(this.target_widget, this, "slider") 67 | this.slider = new FancySlider(parent_controller, node, this.ps, null, this.twoStrengths ? "Model": null) 68 | this.slider._change = (e)=>{ 69 | e.stopPropagation() 70 | const v = parseFloat(e.target.value) 71 | if (!isNaN(v)) this.ps.value = v 72 | this.slider.switch_to_graphicaledit() 73 | FancySlider.currently_active = null 74 | } 75 | this.second_line.appendChild(this.slider) 76 | 77 | if (target_widget.value.strengthTwo) { 78 | this.third_line = create('span','line', this) 79 | this.ps2 = new PseudoWidget(this.target_widget, this, "slider2", "strengthTwo") 80 | this.slider2 = new FancySlider(parent_controller, node, this.ps2, null, "Clip") 81 | this.slider2._change = (e)=>{ 82 | e.stopPropagation() 83 | const v = parseFloat(e.target.value) 84 | if (!isNaN(v)) this.ps2.value = v 85 | this.slider2.switch_to_graphicaledit() 86 | FancySlider.currently_active = null 87 | } 88 | this.third_line.appendChild(this.slider2) 89 | } 90 | 91 | let a; 92 | } 93 | 94 | } 95 | 96 | customElements.define('pll-widget', PLL_Widget, {extends: 'span'}) -------------------------------------------------------------------------------- /js/prompt_id_manager.js: -------------------------------------------------------------------------------- 1 | import { Timings } from "./constants.js" 2 | 3 | export class PromptIdManager { 4 | constructor() { 5 | this.prompt_ids = new Set() 6 | } 7 | 8 | add(pid) {/*this.prompt_ids.add(pid)*/} 9 | 10 | on_executed(e) { 11 | //setTimeout(PromptIdManager._execution_end, Timings.GENERIC_SHORT_DELAY, e) 12 | } 13 | _on_executed(e) { 14 | this.prompt_ids.delete(e.prompt_id) 15 | } 16 | 17 | ours(e) { 18 | return true; 19 | if (this.prompt_ids.has(e.prompt_id) || this.prompt_ids.has(e.detail?.prompt_id)) { 20 | return true 21 | } else { 22 | let a; 23 | return false 24 | } 25 | } 26 | } 27 | 28 | export const pim = new PromptIdManager() -------------------------------------------------------------------------------- /js/resize_manager.js: -------------------------------------------------------------------------------- 1 | import { Debug } from "./debug.js" 2 | export function make_resizable( element, node_id, widget_name, properties ) { 3 | element.resizable = { 4 | "node_id" : node_id, 5 | "widget_name" : widget_name, 6 | "properties" : properties 7 | } 8 | element.resize_id = `${node_id}.${widget_name}.${Math.floor(Math.random() * 1000000)}` 9 | if (properties.height) element.style.height = `${properties.height}px` 10 | } 11 | 12 | class PersistSize { 13 | static sizes = {} 14 | } 15 | 16 | class ResizeManager { 17 | static all_rms = [] 18 | constructor(change_callback) { 19 | this.change_callback = change_callback 20 | this.resize_observer = new ResizeObserver( (x) => { 21 | x.forEach((resize) => { 22 | if (resize.borderBoxSize[0].blockSize==0 ) return 23 | const sz = resize.borderBoxSize[0].blockSize 24 | var delta = sz - PersistSize.sizes[resize.target.resize_id] 25 | PersistSize.sizes[resize.target.resize_id] = sz 26 | if (isNaN(delta)) delta = 0 27 | this.change_callback(resize.target, delta) 28 | resize.target.resizable.properties.height = resize.target.getBoundingClientRect().height 29 | }) 30 | } ) 31 | ResizeManager.all_rms.push(this) 32 | } 33 | 34 | recursive_observe(element) { 35 | if (element.resizable) this.resize_observer.observe(element) 36 | element.childNodes?.forEach((child) => { this.recursive_observe(child) }) 37 | } 38 | } 39 | 40 | export function observe_resizables( root, change_callback ) { 41 | const rm = new ResizeManager(change_callback) 42 | rm.recursive_observe(root) 43 | } 44 | 45 | export function clear_resize_managers() { 46 | ResizeManager.all_rms.forEach((rm)=>rm.resize_observer.disconnect()) 47 | ResizeManager.all_rms = [] 48 | } -------------------------------------------------------------------------------- /js/settings.js: -------------------------------------------------------------------------------- 1 | import { app } from "../../scripts/app.js" 2 | import { Debug } from "./debug.js" 3 | import { Texts } from "./constants.js" 4 | import { defineProperty, send_graph_changed } from "./utilities.js" 5 | 6 | const DEFAULTS = { 7 | "node_order" : [], 8 | "advanced" : false, 9 | "groups" : [], 10 | "group_choice" : Texts.ALL_GROUPS, 11 | "position" : {"x" : 0, "y" : 0, "w" : 250, "h" : 180}, 12 | //"userposition" : {"x" : 0, "y" : 0, "w" : 250, "h" : 180}, 13 | "collapsed" : false, 14 | "fullheight" : false, 15 | "fullwidth" : false, 16 | "hidden_widgets" : [], 17 | "minimised_blocks" : [], 18 | "blocks_rejecting_upstream" : [], 19 | "blocks_rejecting_images" : [], 20 | "stack_tabs" : false, 21 | } 22 | export const KEYS = Object.keys(DEFAULTS) 23 | 24 | const GLOBAL = { 25 | "hidden" : true, 26 | "highlight" : true, 27 | "version" : 2, 28 | "default_order" : [] 29 | } 30 | const GLOBAL_KEYS = Object.keys(GLOBAL) 31 | 32 | class _Settings { 33 | constructor(index) { 34 | this.index = index 35 | if (!app.graph.extra.controller_panel.controllers[index]) { 36 | app.graph.extra.controller_panel.controllers[index] = {} 37 | Object.assign(app.graph.extra.controller_panel.controllers[index], DEFAULTS) 38 | } 39 | this.settings = app.graph.extra.controller_panel.controllers[index] 40 | if (this.settings.node_order.length==0) this.settings.node_order = Array.from(global_settings.default_order) 41 | KEYS.forEach((k) => { 42 | defineProperty(this, k, { 43 | get : () => { return this.settings[k] }, 44 | set : (v) => { this.settings[k] = v; send_graph_changed(); } 45 | }) 46 | }) 47 | } 48 | set_position(x,y,w,h) { 49 | if (isNaN(x)) { 50 | x = 0 51 | } 52 | this.position = { 53 | "x" : (x!=null) ? x : this.position.x, 54 | "y" : (y!=null) ? y : this.position.y, 55 | "w" : (w!=null) ? w : this.position.w, 56 | "h" : (h!=null) ? h : this.position.h, 57 | } 58 | } 59 | delta_position(x,y,w,h) { 60 | this.set_position( 61 | x ? x + this.position.x : null, 62 | y ? y + this.position.y : null, 63 | w ? w + this.position.w : null, 64 | h ? h + this.position.h : null, 65 | ) 66 | } 67 | } 68 | 69 | export function get_settings(index) { 70 | return new _Settings(index) 71 | } 72 | 73 | export function delete_settings(index) { 74 | delete app.graph.extra.controller_panel.controllers[index] 75 | } 76 | 77 | export function clear_settings() { 78 | app.graph.extra.controller_panel.controllers = {} 79 | } 80 | 81 | export function get_all_setting_indices() { 82 | const all_setting_indices = [] 83 | Object.keys(app.graph.extra.controller_panel.controllers).forEach((i)=>{ 84 | if (app.graph.extra.controller_panel.controllers[i]) all_setting_indices.push(i) 85 | }) 86 | return all_setting_indices 87 | } 88 | 89 | export function new_controller_setting_index() { 90 | var i = 0 91 | if (app.graph.extra.controller_panel == undefined) initialise_settings() 92 | while (app.graph.extra.controller_panel.controllers[i]) i += 1 93 | return i 94 | } 95 | 96 | export function valid_settings() { 97 | if (app.graph.extra.controller_panel == undefined || app.graph.extra.controller_panel.hidden == undefined) { 98 | initialise_settings() 99 | return false 100 | } 101 | return true 102 | } 103 | 104 | export function initialise_settings() { 105 | /* If there is no controller_panel */ 106 | if (app.graph.extra.controller_panel == undefined) { 107 | app.graph.extra.controller_panel = { "controllers":{} } 108 | } 109 | 110 | /* If there is one of the old style */ 111 | if (app.graph.extra.controller_panel.controllers == undefined) { 112 | app.graph.extra.controller_panel = { "controllers":{} } 113 | } 114 | 115 | /* Fix any missing GLOBALS */ 116 | GLOBAL_KEYS.forEach((k)=>{ 117 | if (app.graph.extra.controller_panel[k] == undefined) app.graph.extra.controller_panel[k] = GLOBAL[k] 118 | }) 119 | 120 | /* Fix any missing DEFAULTS */ 121 | Object.keys(app.graph.extra.controller_panel.controllers).forEach((k) => { 122 | const controller_settings = app.graph.extra.controller_panel.controllers[k] 123 | if (controller_settings) { 124 | if (controller_settings.controllers) controller_settings.controllers = null 125 | KEYS.forEach((key)=>{ 126 | if (!controller_settings[key]) controller_settings[key] = DEFAULTS[key] 127 | }) 128 | } 129 | }) 130 | } 131 | 132 | class GlobalSettings { 133 | constructor() { 134 | GLOBAL_KEYS.forEach((k) => { 135 | defineProperty(this, k, { 136 | get : () => { 137 | const v = app?.graph?.extra?.controller_panel?.[k] 138 | return v ?? GLOBAL[k] 139 | }, 140 | set : (v) => { 141 | try { 142 | app.graph.extra.controller_panel[k] = v 143 | } catch (e) { Debug.error("global set", e)} 144 | } 145 | }) 146 | }) 147 | } 148 | } 149 | 150 | export const global_settings = new GlobalSettings() 151 | 152 | export function getSettingValue(comfy_key, _default) { 153 | return app.ui.settings.getSettingValue(comfy_key) 154 | } 155 | 156 | export function add_missing_nodes(order) { 157 | app.graph._nodes.forEach((n)=>{ 158 | if (!order.includes(n.id)) order.push(n.id) 159 | if (!global_settings.default_order.includes(n.id)) global_settings.default_order.push(n.id) 160 | }) 161 | } 162 | 163 | export function update_node_order(order, moved_node, now_after, now_before) { 164 | _update_node_order(order, moved_node, now_after, now_before) 165 | _update_node_order(global_settings.default_order, moved_node, now_after, now_before) 166 | } 167 | 168 | function _update_node_order(order, moved_node, now_after, now_before) { 169 | const initial = order.indexOf(moved_node) 170 | const place_after = order.indexOf(now_after) 171 | const place_before = (order.indexOf(now_before) >= 0) ? order.indexOf(now_before) : order.length 172 | 173 | if (place_before < place_after) { 174 | _update_node_order(order, now_before, now_after) 175 | _update_node_order(order, moved_node, now_after, now_before) 176 | return 177 | } 178 | 179 | if (initial < place_after) { 180 | order.splice(initial, 1) 181 | order.splice(place_after, 0, moved_node) 182 | } else if (initial > place_before) { 183 | order.splice(initial, 1) 184 | order.splice(place_before, 0, moved_node) 185 | } 186 | 187 | } -------------------------------------------------------------------------------- /js/slider.css: -------------------------------------------------------------------------------- 1 | 2 | .fancy_slider { 3 | display:block; 4 | --full-height: 20px; 5 | --full-width: 100%; 6 | height: var(--full-height); 7 | width: var(--full-width); 8 | border:0; 9 | padding:0; 10 | margin:0; 11 | position: relative; 12 | } 13 | 14 | .fs_label { 15 | position: absolute; 16 | height: calc(var(--full-height) -4); 17 | top: 3px; 18 | width: var(--full-width); 19 | background: rgba(255, 255, 255, 0); 20 | text-align: left; 21 | padding-left: 4px; 22 | pointer-events: none; 23 | font-size: 70%; 24 | color: #c5c5c5; 25 | } 26 | 27 | .fs_graphic { 28 | position: absolute; 29 | left: 0; 30 | top: 0; 31 | height: var(--full-height); 32 | width: var(--full-width); 33 | background: rgb(34 34 34); 34 | pointer-events: none; 35 | } 36 | 37 | .fs_graphic_fill { 38 | position: absolute; 39 | left: 0; 40 | top: 0; 41 | height: var(--full-height); 42 | /*width set dynamically*/ 43 | background: #657886; 44 | pointer-events: none; 45 | } 46 | 47 | .fs_graphic_text { 48 | position: absolute; 49 | height: calc(var(--full-height) -4); 50 | top: 3px; 51 | width: var(--full-width); 52 | background: rgba(255, 255, 255, 0); 53 | text-align: right; 54 | padding-right: 4px; 55 | font-size: 75%; 56 | pointer-events: none; 57 | } 58 | 59 | .fs_text_edit { 60 | position: absolute; 61 | height: var(--full-height); 62 | width: var(--full-width); 63 | background: black; 64 | text-align: right; 65 | } 66 | 67 | .can_drag { 68 | cursor:ew-resize; 69 | } 70 | 71 | .option_setting { 72 | display: flex; 73 | flex-direction: column; 74 | position: absolute; 75 | background: black; 76 | border: thin solid white; 77 | border-radius: 8px; 78 | font-size: 75%; 79 | padding: 6px; 80 | z-index: 1000; 81 | } 82 | 83 | .option_setting_title { 84 | text-align: center; 85 | width: 100%; 86 | } 87 | 88 | .option_setting_panel { 89 | width: 100%; 90 | display: flex; 91 | align-items: center; 92 | padding: 4px 0px 4px 0px; 93 | justify-content: center; 94 | } 95 | 96 | .option_setting_input { 97 | width: 50px; 98 | font-size: 90%; 99 | background-color: #00ff0080; 100 | } 101 | .option_setting_input.min { 102 | text-align:right; 103 | } 104 | .option_setting_input.max { 105 | text-align:left; 106 | } 107 | 108 | 109 | .option_setting_dash { 110 | padding:0px 4px 0px 4px; 111 | } 112 | 113 | .option_setting_buttons { 114 | padding-top: 8px; 115 | display: flex; 116 | justify-content: space-evenly; 117 | } 118 | 119 | .option_setting_button { 120 | font-size: 90%; 121 | width: 50px; 122 | background-color: #0072ff; 123 | border-style: none; 124 | } 125 | 126 | .bad_value { 127 | background: #ff000080; 128 | } 129 | 130 | .danger_value { 131 | background: #ff800080; 132 | } 133 | -------------------------------------------------------------------------------- /js/snap_manager.js: -------------------------------------------------------------------------------- 1 | import { clamp, mouse_is_down } from "./utilities.js" 2 | import { Debug } from "./debug.js" 3 | import { Pixels, Timings } from "./constants.js" 4 | 5 | const THRESHOLD = 16 6 | const OVERLAP = Pixels.BORDER_WIDTH 7 | const UNDERLAP = Math.min(3, OVERLAP) // pixel grabbable at the bottom 8 | 9 | class ChildType { 10 | anything() { 11 | return (Object.values(this).filter((v)=>(v)).length>0) 12 | } 13 | child_in_x() { this.move_with = true; this.joined_x = true; this.indirect_joined_x = true } 14 | child_in_y() { this.move_with = true; this.joined_y = true; this.indirect_joined_y = true } 15 | share_top() { this.shared_t = true; } 16 | share_bottom() { this.shared_b = true; } 17 | share_left() { this.shared_l = true; } 18 | share_right() { this.shared_r = true; } 19 | 20 | describe() { 21 | var desc = "" 22 | Object.keys(this).forEach((k)=>{if (this[k]) desc += `${k} `}) 23 | return desc 24 | } 25 | 26 | static none() { return new ChildType() } 27 | } 28 | 29 | function get_child_type(p1, p2) { 30 | const r = new ChildType() 31 | if (p1 && p2) { 32 | const r2 = p2.x + p2.w - OVERLAP 33 | const b2 = p2.y + p2.h - OVERLAP 34 | const r1 = p1.x + p1.w - OVERLAP 35 | const b1 = p1.y + p1.h - OVERLAP 36 | if ((Math.abs(p1.x - r2)0) return 87 | Object.values(SnapManager.panels).filter((p)=>(p.settings.fullwidth)).forEach((panel)=>{ 88 | const w = panel.parentElement.getBoundingClientRect().width + 2 * OVERLAP 89 | const me = panel.settings 90 | me.set_position( null, null, (me.fullwidth) ? w : null, null ) 91 | update_panel_position(panel) 92 | }) 93 | 94 | const parent_height = get_parent_height() 95 | 96 | Array.from(WindowResizeManager.vertical_snapped).forEach((i)=>{ 97 | const panel = SnapManager.panels[i] 98 | if (!panel) return 99 | const frac = WindowResizeManager.vertical_fraction[i] 100 | const new_height = parent_height * frac + 2*OVERLAP 101 | const new_y = (panel.settings.position.y == -OVERLAP) ? -OVERLAP : parent_height - new_height 102 | 103 | Debug.trivia(`Panel ${{i}} vertical rescale from ${panel.settings.position.h} to ${new_height}`) 104 | 105 | panel.settings.set_position(null, new_y, null, new_height ) 106 | update_panel_position(panel) 107 | }) 108 | } 109 | 110 | static update_vertical_spans() { 111 | if (WindowResizeManager.owr_stack>0) return 112 | Debug.trivia("get_vertical_spans") 113 | const parent_height = get_parent_height() 114 | WindowResizeManager.vertical_snapped = new Set() 115 | 116 | Object.keys(SnapManager.panels).forEach((i)=>{ 117 | const panel = SnapManager.panels[i] 118 | WindowResizeManager.vertical_fraction[i] = (panel.getBoundingClientRect().height - 2*OVERLAP) / parent_height 119 | if (panel.settings.fullheight) WindowResizeManager.vertical_snapped.add(i) 120 | if (Math.abs(panel.settings.position.y + panel.settings.position.h - parent_height - UNDERLAP) < THRESHOLD) { 121 | // we are touching the bottom. Are we child of anyone touching the top? 122 | Object.keys(SnapManager.panels).filter((j)=>(SnapManager.child_types[j][i]?.joined_y)).forEach((j)=>{ 123 | if (SnapManager.panels[j].settings.position.y == -OVERLAP) { 124 | WindowResizeManager.vertical_snapped.add(i) 125 | WindowResizeManager.vertical_snapped.add(j) 126 | panel.settings.set_position(null, null, null, parent_height + OVERLAP - panel.settings.position.y - UNDERLAP) 127 | update_panel_position(panel) 128 | } 129 | }) 130 | } 131 | }) 132 | 133 | } 134 | } 135 | 136 | export class SnapManager { 137 | static panels = {} 138 | static last_dim = {} 139 | static child_types = {} 140 | static gutter_overlay = null 141 | 142 | static register(panel) { 143 | SnapManager.panels[panel.index] = panel 144 | SnapManager.last_dim[panel.index] = {...panel.settings.position} 145 | SnapManager.update_child_list('register') 146 | } 147 | static remove(panel) { 148 | delete SnapManager.panels[panel.index] 149 | SnapManager.update_child_list('remove') 150 | } 151 | 152 | static update_child_list(note) { 153 | Debug.trivia(`update_child_list ${note?note:''}`) 154 | SnapManager.child_types = {} 155 | Object.keys(SnapManager.panels).forEach((i)=>{ 156 | SnapManager.child_types[i] = {} 157 | Object.keys(SnapManager.panels).filter((j)=>(i!=j)).forEach((j)=>{ 158 | if (SnapManager.panels[i].settings.collapsed || SnapManager.panels[j].settings.collapsed) { 159 | SnapManager.child_types[i][j] = ChildType.none() 160 | } else { 161 | SnapManager.child_types[i][j] = get_child_type(SnapManager.panels[j].settings.position, SnapManager.panels[i].settings.position) 162 | } 163 | }) 164 | }) 165 | 166 | SnapManager.apply_transitively('move_with') 167 | SnapManager.apply_transitively('shared_t') 168 | SnapManager.apply_transitively('shared_b') 169 | SnapManager.apply_transitively('shared_l') 170 | SnapManager.apply_transitively('shared_r') 171 | 172 | /* if we share a top, and you are joined in y to someone, so am I */ 173 | Object.keys(SnapManager.panels).forEach((i)=>{ 174 | const me = SnapManager.panels[i].settings.position 175 | Object.keys(SnapManager.panels).filter((j)=>(i!=j && SnapManager.child_types[i][j].shared_t)).forEach((j)=>{ 176 | Object.keys(SnapManager.panels).filter((k)=>(i!=k && j!=k && SnapManager.child_types[k][i].joined_y)).forEach((k)=>{ 177 | Debug.trivia(`${j} joined in y to ${k} via ${i}`) 178 | SnapManager.child_types[k][j].child_in_y() 179 | }) 180 | }) 181 | }) 182 | 183 | Object.keys(SnapManager.panels).forEach((i)=>{ 184 | const me = SnapManager.panels[i].settings.position 185 | Object.keys(SnapManager.panels).filter((j)=>(i!=j && SnapManager.child_types[i][j].shared_l)).forEach((j)=>{ 186 | Object.keys(SnapManager.panels).filter((k)=>(i!=k && j!=k && SnapManager.child_types[k][i].joined_x)).forEach((k)=>{ 187 | Debug.trivia(`${j} joined in x to ${k} via ${i}`) 188 | SnapManager.child_types[k][j].child_in_x() 189 | }) 190 | }) 191 | }) 192 | 193 | SnapManager.apply_transitively('indirect_joined_x') 194 | SnapManager.apply_transitively('indirect_joined_y') 195 | 196 | Object.keys(SnapManager.panels).forEach((i)=>{ 197 | Object.keys(SnapManager.panels).filter((j)=>(i!=j)).forEach((j)=>{ 198 | Debug.trivia(`SnapManager.child_types[${i}][${j}] = ${SnapManager.child_types[i][j].describe()}`) 199 | }) 200 | }) 201 | 202 | 203 | /* set the z-indices */ 204 | Object.keys(SnapManager.panels).forEach((i)=>{ 205 | var count = 0 206 | const my_box = SnapManager.panels[i].getBoundingClientRect() 207 | Object.keys(SnapManager.panels).filter((j)=>(i!=j)).forEach((j)=>{ 208 | const your_box = SnapManager.panels[j].getBoundingClientRect() 209 | if (your_box.x > my_box.x) count += 1 210 | if (your_box.y > my_box.y) count += 1 211 | }) 212 | SnapManager.panels[i].style.zIndex = 900 + count 213 | }) 214 | } 215 | 216 | static apply_transitively(property) { 217 | var need_to_recurse = true 218 | while (need_to_recurse) { 219 | need_to_recurse = false 220 | Object.keys(SnapManager.panels).forEach((i)=>{ 221 | Object.keys(SnapManager.panels).filter((j)=>(i!=j)).forEach((j)=>{ 222 | Object.keys(SnapManager.panels).filter((k)=>(i!=k && j!=k)).forEach((k)=>{ 223 | if (SnapManager.child_types[j][i][property] && SnapManager.child_types[k][j][property] && !SnapManager.child_types[k][i][property]) { 224 | Debug.trivia(`Transitive: ${k} now has '${property}' with ${i} via ${j}`) 225 | SnapManager.child_types[k][i][property] = true 226 | need_to_recurse = true 227 | } 228 | }) 229 | }) 230 | }) 231 | } 232 | } 233 | 234 | /* 235 | Called by a panel when it has been moved by the user. 236 | */ 237 | static apply_snapping(panel) { 238 | if (mouse_is_down) { 239 | SnapManager.move_my_children(panel) 240 | } else { 241 | SnapManager.update_child_list(`panel ${panel.index}`) 242 | SnapManager.tidy_edges() 243 | WindowResizeManager.update_vertical_spans() 244 | } 245 | } 246 | 247 | static tidy_edges() { 248 | // start in the upper left 249 | const order = Object.values(SnapManager.panels).sort((a,b)=>(parseInt(b.style.zIndex) - parseInt(a.style.zIndex))) 250 | const reversed = Object.keys(SnapManager.panels).sort((a,b)=>(parseInt(SnapManager.panels[a].style.zIndex) - parseInt(SnapManager.panels[b].style.zIndex))) 251 | order.forEach((p)=>{ 252 | SnapManager.tidy_up(p, reversed) 253 | }) 254 | } 255 | 256 | static tidy_up(panel, apply_order) { 257 | 258 | Debug.trivia(`Tidy up ${panel.index}`) 259 | const me = panel.settings 260 | const i = panel.index 261 | 262 | apply_order.filter( (k)=>(k!=i && SnapManager.child_types[k][i]?.anything()) ).forEach((k)=>{ 263 | const you = SnapManager.panels[k].settings 264 | const child_type = SnapManager.child_types[k][i] 265 | 266 | Debug.trivia(`${i} tidy_up ${i} is a child of ${k}, child_type '${child_type.describe()}'`) 267 | 268 | if (child_type.move_with) { 269 | if (child_type.shared_t) me.set_position( null, you.position.y, null, null ) 270 | if (child_type.shared_b) me.set_position( null, null, null, you.position.y + you.position.h - me.position.y ) 271 | if (child_type.shared_l) me.set_position( you.position.x, null, null, null ) 272 | if (child_type.shared_r) me.set_position( null, null, you.position.x + you.position.w - me.position.x, null ) 273 | } 274 | 275 | if (child_type.joined_x) { 276 | const your_r = you.position.x + you.position.w - OVERLAP 277 | me.set_position( your_r, null, null, null ) 278 | } 279 | 280 | if (child_type.joined_y) { 281 | const your_b = you.position.y + you.position.h - OVERLAP 282 | const my_h = WindowResizeManager.vertical_snapped.has(i) ? (get_parent_height() - your_b + OVERLAP) : null 283 | me.set_position( null, your_b, null, my_h ) 284 | } 285 | 286 | 287 | }) 288 | 289 | me.set_position( clamp(me.position.x,-OVERLAP), clamp(me.position.y,-OVERLAP), null, null ) 290 | if (me.position.x < THRESHOLD) { 291 | me.set_position( -OVERLAP, null, null, null ) 292 | if (me.position.x + me.position.w + THRESHOLD > panel.parentElement.getBoundingClientRect().width) { 293 | me.fullwidth = true 294 | me.set_position( null, null, panel.parentElement.getBoundingClientRect().width + 2 * OVERLAP, null ) 295 | } else { me.fullwidth = false } 296 | } else { me.fullwidth = false } 297 | 298 | if (me.position.y < THRESHOLD) { 299 | me.set_position( null, -OVERLAP, null, null ) 300 | if (me.position.y + me.position.h + THRESHOLD > panel.parentElement.getBoundingClientRect().height) { 301 | me.fullheight = true 302 | me.set_position( null, null, null, panel.parentElement.getBoundingClientRect().height + 2 * OVERLAP - UNDERLAP ) 303 | } else { me.fullheight = false } 304 | } else { me.fullheight = false } 305 | 306 | update_panel_position(panel, false) 307 | } 308 | 309 | static move_my_children(panel) { 310 | /* 311 | Called when mouse is down; apply any existing snappings to other panels 312 | */ 313 | 314 | const me = panel.settings 315 | const dx = me.position.x - SnapManager.last_dim[panel.index].x 316 | const dw = me.position.w - SnapManager.last_dim[panel.index].w 317 | const dy = me.position.y - SnapManager.last_dim[panel.index].y 318 | const dh = me.position.h - SnapManager.last_dim[panel.index].h 319 | 320 | if (!(dx||dw||dy||dh)) return 321 | 322 | Object.keys(SnapManager.child_types[panel.index]).forEach((i)=>{ 323 | const effects_to_apply = SnapManager.child_types[panel.index][i] 324 | const ysnap = WindowResizeManager.vertical_snapped.has(i) 325 | 326 | if (effects_to_apply.anything()) { 327 | Debug.trivia(`moved ${panel.index}, child ${i}, ${effects_to_apply.describe()}, ${dx}, ${dy}, ${dw}, ${dh}`) 328 | 329 | if (dw || dh) { 330 | if ( effects_to_apply.indirect_joined_x && dx==0 ) SnapManager.delta( i, dw, null, null, null ) 331 | if ( effects_to_apply.indirect_joined_y && dy==0 ) SnapManager.delta( i, null, dh, null, ysnap?-dh:null ) 332 | if ( effects_to_apply.shared_r && dx==0 ) SnapManager.delta( i, null, null, dw, null ) 333 | if ( effects_to_apply.shared_b && dy==0 ) SnapManager.delta( i, null, null, null, dh ) 334 | if ( effects_to_apply.shared_l && dx!=0 ) SnapManager.delta( i, -dw, null, dw, null ) 335 | if ( effects_to_apply.shared_t && dy!=0 ) SnapManager.delta( i, null, -dh, null, dh ) 336 | } else { 337 | if ( effects_to_apply.move_with ) SnapManager.delta( i, dx, dy, null, null ) 338 | } 339 | 340 | update_panel_position( SnapManager.panels[i], true ) 341 | 342 | } 343 | }) 344 | update_panel_position( panel, true ) 345 | } 346 | 347 | static delta(i, x, y, w, h) { 348 | if (x || y || w || h) SnapManager.panels[i].settings.delta_position(x,y,w,h) 349 | } 350 | } -------------------------------------------------------------------------------- /js/toggle.js: -------------------------------------------------------------------------------- 1 | import { Timings } from "./constants.js"; 2 | import { classSet, create } from "./utilities.js"; 3 | 4 | export class Toggle extends HTMLSpanElement { 5 | constructor(state, label, label_true, label_false, label_intermediate) { 6 | super() 7 | this.value = state 8 | this.label_true = label_true ?? "true" 9 | this.label_false = label_false ?? "false" 10 | this.label_intermediate = label_intermediate 11 | this.threestate = !!(label_intermediate) 12 | 13 | this.classList.add('toggle') 14 | if (!this.threestate) { 15 | this.addEventListener('click', (e) => { 16 | this.value = !this.value 17 | const e2 = new Event('input') 18 | this.dispatchEvent(e2) 19 | this.render() 20 | }) 21 | } 22 | this.addEventListener('mousedown', (e) => { 23 | e.preventDefault() 24 | }) 25 | 26 | this.label = create('span', 'toggle_label', this, {"innerHTML":label}) 27 | 28 | this.value_span = create('span', 'toggle_value', this) 29 | this.text_value = create('span', 'toggle_text', this.value_span) 30 | this.graphical_value = create('span', 'toggle_graphic', this.value_span, {"innerHTML":"⬤"}) 31 | 32 | this.render() 33 | } 34 | 35 | render() { 36 | if (this.threestate) { 37 | this.text_value.innerText = this.value=="on" ? this.label_true : (this.value=="off" ? this.label_false : this.label_intermediate) 38 | classSet(this, 'muted', this.value=="off") 39 | classSet(this.graphical_value, "true", this.value=="on") 40 | classSet(this.graphical_value, "false", this.value=="off") 41 | classSet(this.graphical_value, "intermediate", this.value=="mixed") 42 | } else { 43 | this.text_value.innerText = this.value ? this.label_true : this.label_false 44 | classSet(this, 'muted', !this.value) 45 | classSet(this.parentElement, 'muted', !this.value) 46 | classSet(this.parentElement?.parentElement, 'muted', !this.value) 47 | classSet(this.graphical_value, "true", this.value) 48 | classSet(this.graphical_value, "false", !this.value) 49 | } 50 | } 51 | } 52 | 53 | customElements.define('cp-toggle', Toggle, {extends: 'span'}) -------------------------------------------------------------------------------- /js/update_controller.js: -------------------------------------------------------------------------------- 1 | import { app } from "../../scripts/app.js" 2 | import { Timings } from "./constants.js" 3 | import { _Debug } from "./debug.js" 4 | import { GroupManager } from "./groups.js" 5 | import { send_graph_changed } from "./utilities.js" 6 | import { pim } from "./prompt_id_manager.js" 7 | 8 | const Debug = new _Debug(()=>(new Date().toISOString())) 9 | 10 | function message(wait_time) { 11 | if (wait_time==0) return "" 12 | if (wait_time==-2) return "Graph configuring" 13 | if (wait_time<0) return "Controller refused with no retry" 14 | return `Controller requested retry after ${wait_time}ms` 15 | } 16 | 17 | export class UpdateController { 18 | static callback = ()=>{} 19 | static permission = ()=>{return false} 20 | static single_node = (node_id, info)=>{} 21 | static pause_stack = 0 22 | static _configuring = false 23 | 24 | static setup(callback, permission, single_node) { 25 | UpdateController.callback = callback 26 | UpdateController.permission = permission 27 | UpdateController.single_node = single_node 28 | } 29 | 30 | static push_pause() { UpdateController.pause_stack += 1 } 31 | static pop_pause() { UpdateController.pause_stack -= 1 } 32 | 33 | static configuring(v) { 34 | Debug.trivia(`_configuring set to ${v}`) 35 | UpdateController._configuring = v 36 | } 37 | 38 | static make_single_request(label, controller) { 39 | UpdateController.make_request(label, null, null, controller) 40 | } 41 | static make_request_unless_configuring(label, after_ms, noretry, controller) { 42 | if (UpdateController._configuring) { 43 | Debug.extended(`make_request_unless_configuring ${label} ignored because still configuring`) 44 | } else { 45 | UpdateController.make_request(label, after_ms, noretry, controller) 46 | } 47 | } 48 | static make_request(label, after_ms, noretry, controller) { 49 | label = label ?? "" 50 | const cont_name = controller ? `for controller ${controller.settings.index}` : `all controllers` 51 | if (after_ms) { 52 | 53 | setTimeout(UpdateController.make_request, after_ms, label, null, noretry, controller) 54 | 55 | } else { 56 | var wait_time = 0 57 | if (wait_time==0 && UpdateController.pause_stack>0) { 58 | Debug.extended("Delayed by pause_stack") 59 | wait_time = Timings.PAUSE_STACK_WAIT 60 | } 61 | if (wait_time==0 && UpdateController._configuring) wait_time = -2 62 | if (wait_time==0) wait_time = UpdateController.permission(controller) 63 | Debug.extended(`Update ${cont_name} requested because '${label}'. ${message(wait_time)}`) 64 | 65 | if (wait_time == 0) { 66 | Debug.extended(`Update ${cont_name} request '${label}' sent`) 67 | UpdateController.callback(controller) 68 | send_graph_changed() 69 | return 70 | } else { 71 | var reason_not_to_try_again = null 72 | if (wait_time < 0) reason_not_to_try_again = "delay was negative" 73 | if (noretry) reason_not_to_try_again = "noretry was set" 74 | if (UpdateController.requesting) reason_not_to_try_again = "a retry is already pending" 75 | 76 | if (reason_not_to_try_again) { 77 | Debug.extended(`Update ${cont_name} request '${label}' cancelled because ${reason_not_to_try_again}`) 78 | } else { 79 | Debug.extended(`Update ${cont_name} request '${label}' rescheduled for ${wait_time}ms`) 80 | UpdateController.requesting = true 81 | setTimeout( UpdateController.deferred_request, wait_time, label, controller) 82 | } 83 | } 84 | 85 | } 86 | } 87 | 88 | static deferred_request(label, controller) { 89 | UpdateController.requesting = false 90 | UpdateController.make_request(label, null, null, controller) 91 | } 92 | } 93 | 94 | function hash_node(node) { 95 | /* 96 | hash all the things we want to check for changes. 97 | */ 98 | if (!node) return "nonode" 99 | var hash = `${node.bgcolor} ${node.title} ${node.mode} ${node.imageIndex}` 100 | node.inputs?.forEach( (i)=>{hash += `${i.label ?? i.name} `}) 101 | node.outputs?.forEach( (o)=>{hash += `${o.name} `}) 102 | node.widgets?.filter((w)=>(w.element?.value)).forEach((w)=>{hash += `${w.element.value} `}) 103 | node.widgets?.filter((w)=>(w.value)).forEach( (w)=>{hash += JSON.stringify(w.value)}) 104 | node.widget_values?.forEach( (w)=>{hash += `${w} `}) 105 | return hash 106 | } 107 | 108 | function node_changed(node) { 109 | if (!node) return false 110 | const new_hash = hash_node(node) 111 | if (new_hash == node._controller_hash) return false 112 | node._controller_hash = new_hash 113 | return true 114 | } 115 | 116 | export class OnChangeController { 117 | static nodes_requested = new Set() 118 | static all_nodes = false 119 | constructor() { 120 | setTimeout(OnChangeController.start, Timings.GENERIC_LONGER_DELAY) 121 | } 122 | static start() { 123 | setInterval(OnChangeController.on_change, Timings.PERIODIC_CHECK, "tick") 124 | } 125 | static gap_request_stack = 0 126 | static on_change(details, node_id) { 127 | OnChangeController.gap_request_stack += 1 128 | if (node_id) OnChangeController.nodes_requested.add(node_id) 129 | else OnChangeController.all_nodes = true 130 | setTimeout(OnChangeController._on_change, Timings.ON_CHANGE_GAP, details) 131 | } 132 | 133 | static _on_change(details) { 134 | const log = (details=="tick") ? Debug.trivia : Debug.extended 135 | OnChangeController.gap_request_stack -= 1 136 | if (OnChangeController.gap_request_stack == 0) { 137 | if (GroupManager.check_for_changes()) { 138 | UpdateController.make_request(`on_change (${details}), change in groups`) 139 | } else { 140 | var nodes_to_check = [] 141 | if (OnChangeController.all_nodes) { 142 | nodes_to_check = Array.from(app.graph._nodes) 143 | } else { 144 | Array.from(OnChangeController.nodes_requested).forEach((nid)=>{ nodes_to_check.push(app.graph._nodes_by_id[nid])}) 145 | } 146 | const changed_nodes = nodes_to_check.filter((node)=>(node_changed(node))) 147 | if (changed_nodes.length > 1) { 148 | UpdateController.make_request(`on_change (${details}), ${changed_nodes.length} nodes changed`) 149 | } else if (changed_nodes.length == 1) { 150 | UpdateController.single_node(changed_nodes[0].id, `on_change (${details}), node ${changed_nodes[0].id} changed`) 151 | } else if (app.canvas.read_only != app.canvas._controller_read_only) { 152 | UpdateController.make_request(`on_change (${details}), read_only changed to ${app.canvas.read_only}`) 153 | app.canvas._controller_read_only = app.canvas.read_only 154 | } else { 155 | log(`on_change (${details}), no changes`, true) 156 | } 157 | OnChangeController.all_nodes = false 158 | OnChangeController.nodes_to_check = new Set() 159 | } 160 | } else { 161 | log(`on_change (${details}), too soon`, true) 162 | } 163 | } 164 | static on_executing(e) { 165 | //if (!pim.ours(e)) return 166 | if (OnChangeController.executing_node && OnChangeController.executing_node!=e.detail) { 167 | OnChangeController.on_change(`on_executing ${e.detail}`) 168 | } 169 | OnChangeController.executing_node = e.detail 170 | } 171 | /*static _on_executing(nid) { 172 | if (node_changed(app.graph._nodes_by_id[nid])) { 173 | Debug.extended(`Node (${nid} on_executing changed)`) 174 | UpdateController.single_node(nid, `on_executing, node ${nid} changed`) 175 | } else { 176 | Debug.extended(`Node (${nid} on_executing unchanged)`) 177 | } 178 | }*/ 179 | } 180 | 181 | const occ = new OnChangeController() -------------------------------------------------------------------------------- /js/utilities.js: -------------------------------------------------------------------------------- 1 | import { app } from "../../scripts/app.js" 2 | import { api } from "../../scripts/api.js" 3 | import { Colors, SettingIds, Timings } from "./constants.js"; 4 | import { getSettingValue } from "./settings.js"; 5 | import { Debug } from "./debug.js"; 6 | 7 | export var mouse_is_down 8 | export function mouse_change(v) { mouse_is_down = v } 9 | 10 | var started = false 11 | var sgc_stack = 0 12 | 13 | export function extension_hiding(v) { 14 | if (!getSettingValue(SettingIds.HIDE_EXTENSIONS)) return v 15 | return v.replace(/\.[^\. ]+$/, "") 16 | } 17 | 18 | function _send_graph_changed() { 19 | sgc_stack -= 1 20 | if (sgc_stack == 0) { 21 | Debug.extended("Sending graphChanged") 22 | api.dispatchEvent( 23 | new CustomEvent('graphChanged', { }) 24 | ) 25 | } else { 26 | Debug.trivia("Not sending graphChanged yet") 27 | } 28 | } 29 | 30 | export function send_graph_changed(turn_on) { 31 | started = started || turn_on 32 | if (started) { 33 | sgc_stack += 1 34 | setTimeout(_send_graph_changed, Timings.GENERIC_LONGER_DELAY) 35 | } 36 | } 37 | 38 | 39 | export function create( tag, clss, parent, properties ) { 40 | const nd = document.createElement(tag); 41 | if (clss) clss.split(" ").forEach((s) => nd.classList.add(s)) 42 | if (parent) parent.appendChild(nd); 43 | if (properties) Object.assign(nd, properties); 44 | return nd; 45 | } 46 | 47 | export function create_deep( tag_clss_properties_list, parent ) { 48 | tag_clss_properties_list.forEach((tcp) => { 49 | parent = create(tcp.tag, tcp.clss, parent, tcp.properties) 50 | }) 51 | return parent 52 | } 53 | 54 | 55 | export function step_size(options) { 56 | if (options.round) return options.round 57 | if (options.precision) return Math.pow(0.1, options.precision) 58 | return 1 59 | } 60 | 61 | var floatRegex = /^-?\d+(?:[.,]\d*?)?$/ 62 | 63 | export function check_float(v) { 64 | if (!floatRegex.test(`${v}`)) return false; 65 | var vv = parseFloat(v) 66 | return (!isNaN(vv)) 67 | } 68 | 69 | export function rounding(v, options) { 70 | if (!floatRegex.test(`${v}`)) return v; 71 | var vv = parseFloat(v) 72 | if (isNaN(vv)) return v 73 | if (options?.round) { 74 | vv = Math.round((vv + Number.EPSILON) / options.round) * options.round 75 | } 76 | if (options?.precision) { 77 | vv = vv.toFixed(options.precision) 78 | } 79 | return parseFloat(vv) 80 | } 81 | 82 | export function integer_rounding(v, options) { 83 | const s = options.step / 10 84 | let sh = options.min % s 85 | sh = (isNaN(sh)) ? 0 : sh 86 | return Math.round((v - sh) / s) * s + sh 87 | } 88 | 89 | export function get_node(node_or_node_id) { 90 | if (node_or_node_id.id) return node_or_node_id 91 | return app.graph._nodes_by_id[node_or_node_id] 92 | } 93 | 94 | export function darken(hex, lum) { 95 | lum = lum ?? Colors.HEADER_DARKEN 96 | hex = hex.replace("#", ''); 97 | const rgb = (hex.length == 3) ? 98 | [ parseInt(hex.substr(0,1), 16)*17*lum, parseInt(hex.substr(1,1), 16)*17*lum, parseInt(hex.substr(2,1), 16)*17*lum ] : 99 | [ parseInt(hex.substr(0,2), 16)*lum, parseInt(hex.substr(2,2), 16)*lum, parseInt(hex.substr(4,2), 16)*lum ] 100 | 101 | var result = "#" 102 | rgb.forEach((v) => { 103 | const hex = Math.round(v).toString(16) 104 | if (hex.length==1) result += "0" 105 | result += hex 106 | }) 107 | 108 | return result; 109 | } 110 | 111 | export function clamp(v,min,max) { 112 | if (max!=null) return Math.max(min, Math.min(v,max)) 113 | return Math.max(min,v) 114 | } 115 | 116 | export function classSet(element, name, add) { 117 | if (!element) return 118 | if (add) { 119 | element.classList.add(name) 120 | } else { 121 | element.classList.remove(name) 122 | } 123 | } 124 | 125 | export function add_tooltip(element, text, extra_classes) { 126 | if (app.canvas.read_only) return 127 | if (getSettingValue(SettingIds.TOOLTIPS)) { 128 | element.classList.add('tooltip') 129 | if (extra_classes) extra_classes.split(" ").forEach((s) => element.classList.add(s)) 130 | create('span', 'tooltiptext', element, {"innerHTML":text.replaceAll(' ',' ')}) 131 | } 132 | } 133 | 134 | export function defineProperty(instance, property, desc) { 135 | const existingDesc = Object.getOwnPropertyDescriptor(instance, property); 136 | if (existingDesc?.configurable === false) { 137 | throw new Error(`Error: Cannot define un-configurable property "${property}"`); 138 | } 139 | if (existingDesc?.get && desc.get) { 140 | const descGet = desc.get; 141 | desc.get = () => { 142 | existingDesc.get.apply(instance, []); 143 | return descGet.apply(instance, []); 144 | }; 145 | } 146 | if (existingDesc?.set && desc.set) { 147 | const descSet = desc.set; 148 | desc.set = (v) => { 149 | existingDesc.set.apply(instance, [v]); 150 | return descSet.apply(instance, [v]); 151 | }; 152 | } 153 | desc.enumerable = desc.enumerable ?? existingDesc?.enumerable ?? true; 154 | desc.configurable = desc.configurable ?? existingDesc?.configurable ?? true; 155 | if (!desc.get && !desc.set) { 156 | desc.writable = desc.writable ?? existingDesc?.writable ?? true; 157 | } 158 | return Object.defineProperty(instance, property, desc); 159 | } 160 | 161 | export function mode_change(mode, e) { 162 | return (mode==0) ? (e.ctrlKey ? 2 : 4) : ((e.ctrlKey && mode==4) ? 2 : 0) 163 | } 164 | 165 | export function focus_mode() { 166 | if (document.getElementsByClassName('graph-canvas-panel')[0]) return "normal" 167 | if (document.getElementsByClassName('graph-canvas-container')[0]) return "focus" 168 | return null 169 | } 170 | 171 | export function find_controller_parent() { 172 | const show_in_focus = getSettingValue(SettingIds.SHOW_IN_FOCUS_MODE) 173 | return document.getElementsByClassName('graph-canvas-panel')[0] ?? 174 | (show_in_focus ? (document.getElementsByClassName('graph-canvas-container')[0] ?? null) : null) 175 | } 176 | 177 | export function createBounds(objects, padding = 10) { 178 | const bounds = new Float32Array([Infinity, Infinity, -Infinity, -Infinity]); 179 | for (const obj of objects) { 180 | const rect = obj.boundingRect; 181 | bounds[0] = Math.min(bounds[0], rect[0]); 182 | bounds[1] = Math.min(bounds[1], rect[1]); 183 | bounds[2] = Math.max(bounds[2], rect[0] + rect[2]); 184 | bounds[3] = Math.max(bounds[3], rect[1] + rect[3]); 185 | } 186 | if (!bounds.every((x2) => isFinite(x2))) return null; 187 | return [ 188 | bounds[0] - padding, 189 | bounds[1] - padding, 190 | bounds[2] - bounds[0] + 2 * padding, 191 | bounds[3] - bounds[1] + 2 * padding 192 | ]; 193 | } 194 | 195 | /* 196 | After a short delay (for layout), add or remove a title to the specified element, based on whether it's content is overflowing. 197 | if overflowing, add title (which acts as a tooltip in most browsers) otherwise remove title. 198 | 199 | Second parameter is the element that the title gets applied to (default is the same as the first) 200 | 201 | If the element applied to has a .tooltip property this is used when not overflowing 202 | 203 | Used for elements with ellipsis text-overflow 204 | */ 205 | export function tooltip_if_overflowing(element, applyto) { 206 | if (element) setTimeout(_tooltip_if_overflowing, Timings.GENERIC_LONGER_DELAY, element, applyto ?? element) 207 | } 208 | 209 | function _tooltip_if_overflowing(element, applyto) { 210 | if (element.clientWidth < element.scrollWidth) { 211 | applyto.title = element.innerText ?? innerHTML 212 | } else if (applyto.tooltip) { 213 | applyto.title = applyto.tooltip 214 | } else { 215 | if (applyto.title) delete applyto.title 216 | } 217 | } 218 | 219 | /* choose the first color that has contrast > threshold, or the highest contrast */ 220 | export function pickContrastingColor(fixed, options, threshold=3.0) { 221 | var best_score = 0 222 | var best_choice = null 223 | options.forEach((o)=>{ 224 | if (best_score>=0) { 225 | const score = calculateContrastRatioAntecedent(fixed, o) 226 | Debug.trivia(`contrast for ${fixed} and ${o} score ${score}`) 227 | if (score > threshold) { 228 | best_choice = o 229 | best_score = -1 230 | } else if (score > best_score) { 231 | best_score = score 232 | best_choice = o 233 | } 234 | } 235 | }) 236 | Debug.trivia(`contrast for ${fixed} chose ${best_choice} score ${best_score}`) 237 | return best_choice 238 | } 239 | //////////////////////////////////////////////////////////////////////// 240 | // calculateContrastRatioAntecedent 241 | //////////////////////////////////////////////////////////////////////// 242 | // 243 | // This function the main funciton that gets called with two hex 244 | // values. It returns the antecedent as a float. Each hex value 245 | // is first turned into a color object with a luminocity (lum) 246 | // property via the `prepColor()` function before being used in 247 | // the core calculation which produces the final value to return 248 | // 249 | //////////////////////////////////////////////////////////////////////// 250 | 251 | function calculateContrastRatioAntecedent (hex1, hex2) { 252 | const color1 = prepColor(hex1) 253 | const color2 = prepColor(hex2) 254 | 255 | const antecedent = 256 | (Math.max(color1.lum, color2.lum) + 0.05) / 257 | (Math.min(color1.lum, color2.lum) + 0.05) 258 | 259 | return antecedent 260 | } 261 | 262 | //////////////////////////////////////////////////////////////////////// 263 | //prepColor 264 | //////////////////////////////////////////////////////////////////////// 265 | // 266 | // This funciton contains all the math for the conversion from hex 267 | // to a color object with the luminocity value. It starts by pulling 268 | // the individual pairs of red, green, and blue hex values out of 269 | // the input string and then runs them through the stack of calcluations 270 | // before combining them at the end to produce the value 271 | // 272 | //////////////////////////////////////////////////////////////////////// 273 | 274 | function prepColor(hex) { 275 | 276 | if (hex.length==4) hex = `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}` 277 | var color = { 278 | hex: hex, 279 | hex_r: hex.substr(1, 2), 280 | hex_g: hex.substr(3, 2), 281 | hex_b: hex.substr(5, 2), 282 | } 283 | 284 | color.rgb_r = parseInt(color.hex_r, 16) 285 | color.rgb_g = parseInt(color.hex_g, 16) 286 | color.rgb_b = parseInt(color.hex_b, 16) 287 | 288 | color.tmp_r = color.rgb_r / 255 289 | color.tmp_g = color.rgb_g / 255 290 | color.tmp_b = color.rgb_b / 255 291 | 292 | color.srgb_r = 293 | color.tmp_r <= 0.03928 294 | ? color.tmp_r / 12.92 295 | : Math.pow((color.tmp_r + 0.055) / 1.055, 2.4) 296 | 297 | color.srgb_g = 298 | color.tmp_g <= 0.03928 299 | ? color.tmp_g / 12.92 300 | : Math.pow((color.tmp_g + 0.055) / 1.055, 2.4) 301 | 302 | color.srgb_b = 303 | color.tmp_b <= 0.03928 304 | ? color.tmp_b / 12.92 305 | : Math.pow((color.tmp_b + 0.055) / 1.055, 2.4) 306 | 307 | color.lum_r = 0.2126 * color.srgb_r 308 | color.lum_g = 0.7152 * color.srgb_g 309 | color.lum_b = 0.0722 * color.srgb_b 310 | 311 | color.lum = color.lum_r + color.lum_g + color.lum_b 312 | 313 | return color 314 | } 315 | 316 | export function kill_event(e) { 317 | if (e) { 318 | e.preventDefault() 319 | e.stopPropagation() 320 | e.stopImmediatePropagation() 321 | } 322 | } -------------------------------------------------------------------------------- /js/widget_change_manager.js: -------------------------------------------------------------------------------- 1 | import { app } from "../../scripts/app.js"; 2 | import { clamp } from "./utilities.js"; 3 | 4 | export class WidgetChangeManager { 5 | static widget_listener_map = {} 6 | static next_id = 1 7 | static add_listener(widget, listener) { 8 | if (!widget.wcm_id) { 9 | widget.wcm_id = WidgetChangeManager.next_id 10 | WidgetChangeManager.next_id += 1 11 | } 12 | if (!WidgetChangeManager.widget_listener_map[widget.wcm_id]) { 13 | WidgetChangeManager.widget_listener_map[widget.wcm_id] = [] 14 | } 15 | WidgetChangeManager.widget_listener_map[widget.wcm_id].push(listener) 16 | } 17 | static notify(widget) { 18 | if (widget.wcm_id && WidgetChangeManager.widget_listener_map[widget.wcm_id]) { 19 | WidgetChangeManager.widget_listener_map[widget.wcm_id] = WidgetChangeManager.widget_listener_map[widget.wcm_id].filter((l)=>l.wcm_manager_callback()) 20 | } 21 | app.graph.setDirtyCanvas(true,true) 22 | } 23 | static set_widget_value(widget, v) { 24 | widget.value = v 25 | if (widget.original_callback) widget.original_callback(widget.value) 26 | if (widget.options.min != null) widget.value = clamp(widget.value, widget.options.min, widget.options.max) 27 | WidgetChangeManager.notify(widget) 28 | } 29 | } 30 | 31 | export function clear_widget_change_managers() { 32 | WidgetChangeManager.widget_listener_map = {} 33 | } 34 | -------------------------------------------------------------------------------- /js/workspace.js: -------------------------------------------------------------------------------- 1 | import { Debug } from "./debug.js"; 2 | import { get_parent_height, get_parent_width } from "./snap_manager.js"; 3 | 4 | function to_json() { 5 | return JSON.stringify( app.graph.extra.controller_panel ) 6 | } 7 | 8 | function from_json(j) { 9 | app.graph.extra.controller_panel = JSON.parse(j) 10 | } 11 | 12 | export function download_workspace_as_json(panel_instances, filename) { 13 | const json = to_json(panel_instances) 14 | var element = document.createElement('a'); 15 | element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(json)); 16 | element.setAttribute('download', filename); 17 | element.style.display = 'none'; 18 | document.body.appendChild(element); 19 | element.click(); 20 | document.body.removeChild(element); 21 | } 22 | 23 | export async function load_workspace(callback, error_callback) { 24 | try { 25 | const fileHandles = await window.showOpenFilePicker({"types":[{"accept":{"application/json":[".json"]}}]}) 26 | const file = await fileHandles[0].getFile() 27 | const reader = new FileReader(); 28 | reader.onload = (e) => { 29 | from_json(e.target.result) 30 | callback() 31 | }; 32 | reader.readAsText(file); 33 | } catch (e) { 34 | error_callback?.(e) 35 | } 36 | } 37 | 38 | export function set_settings_for_instance(settings, instance, w_was, h_was) { 39 | Object.keys(instance).filter((key)=>(key!="index")).forEach((key)=>settings.settings[key] = instance[key]) 40 | const h = get_parent_height() 41 | const w = get_parent_width() 42 | settings.set_position( 43 | Math.round(settings.position.x * w / w_was), 44 | Math.round(settings.position.y * h / h_was), 45 | Math.round(settings.position.w * w / w_was), 46 | Math.round(settings.position.h * h / h_was), 47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "cg-controller" 3 | description = "A control panel that shows all widgets from selected nodes. A whole new way to interact with Comfy!" 4 | version = "1.7" 5 | license = {file = "LICENSE"} 6 | 7 | [project.urls] 8 | Repository = "https://github.com/chrisgoringe/cg-controller" 9 | # Used by Comfy Registry https://comfyregistry.org 10 | 11 | [tool.comfy] 12 | PublisherId = "chrisgoringe" 13 | DisplayName = "cg-controller" 14 | Icon = "" 15 | --------------------------------------------------------------------------------