7 Commits

Author SHA1 Message Date
  Johnpaul Chiwetelu 890ab2019f
feat: add Update ComfyUI option to Help Center for non-desktop environments (#7578) 7 hours ago
  Rizumu Ayaka 3e111bd75c
fix: expand assets dropdown body to show entire "no results placeholder" (#7586) 7 hours ago
  Christian Byrne fa4daed163
Update CONTRIBUTING.md (#7588) 7 hours ago
  Alexander Brown e1814447f7
Devex: Convert .env_example sections to comment toggles (#7587) 8 hours ago
  Johnpaul Chiwetelu e21f43f398
[feat] Replace NodeOptions with PrimeVue ContextMenu (#7114) 22 hours ago
  Alexander Brown 8d7dd9ed67
Component: Button migration 1: TextButton (#7537) 23 hours ago
  AustinMroz ab76d02823
Fix doubled control application (#7550) 1 day ago
53 changed files with 925 additions and 1700 deletions
Split View
  1. +4
    -4
      .env_example
  2. +23
    -107
      CONTRIBUTING.md
  3. +9
    -4
      browser_tests/tests/selectionToolboxSubmenus.spec.ts
  4. +1
    -0
      package.json
  5. +20
    -0
      pnpm-lock.yaml
  6. +1
    -0
      pnpm-workspace.yaml
  7. +0
    -91
      src/components/button/TextButton.stories.ts
  8. +0
    -54
      src/components/button/TextButton.vue
  9. +9
    -12
      src/components/dialog/confirm/ConfirmFooter.vue
  10. +7
    -13
      src/components/dialog/content/MissingNodesFooter.vue
  11. +0
    -2
      src/components/graph/GraphCanvas.vue
  12. +272
    -0
      src/components/graph/NodeContextMenu.vue
  13. +1
    -0
      src/components/graph/SelectionToolbox.test.ts
  14. +2
    -0
      src/components/graph/SelectionToolbox.vue
  15. +13
    -12
      src/components/graph/selectionToolbox/ColorPickerMenu.vue
  16. +0
    -62
      src/components/graph/selectionToolbox/MenuOptionItem.vue
  17. +0
    -322
      src/components/graph/selectionToolbox/NodeOptions.vue
  18. +1
    -9
      src/components/graph/selectionToolbox/NodeOptionsButton.vue
  19. +58
    -0
      src/components/helpcenter/HelpCenterMenuContent.vue
  20. +17
    -16
      src/components/input/MultiSelect.vue
  21. +8
    -6
      src/components/queue/QueueOverlayActive.vue
  22. +10
    -13
      src/components/queue/dialogs/QueueClearHistoryDialog.vue
  23. +8
    -9
      src/components/queue/job/JobFiltersBar.vue
  24. +7
    -7
      src/components/queue/job/QueueJobItem.vue
  25. +14
    -9
      src/components/sidebar/tabs/AssetsSidebarTab.vue
  26. +68
    -0
      src/components/ui/button/Button.stories.ts
  27. +28
    -0
      src/components/ui/button/Button.vue
  28. +49
    -0
      src/components/ui/button/button.variants.ts
  29. +4
    -4
      src/composables/canvas/useSelectionToolboxPosition.ts
  30. +96
    -67
      src/composables/graph/useMoreOptionsMenu.ts
  31. +2
    -1
      src/composables/graph/useNodeMenuOptions.ts
  32. +0
    -163
      src/composables/graph/useSubmenuPositioning.ts
  33. +2
    -0
      src/extensions/core/widgetInputs.ts
  34. +13
    -5
      src/locales/en/main.json
  35. +18
    -16
      src/platform/assets/components/UploadModelFooter.vue
  36. +7
    -13
      src/platform/assets/components/UploadModelUpgradeModalFooter.vue
  37. +1
    -4
      src/renderer/extensions/vueNodes/components/LGraphNode.vue
  38. +14
    -31
      src/renderer/extensions/vueNodes/widgets/components/ValueControlPopover.vue
  39. +19
    -7
      src/renderer/extensions/vueNodes/widgets/components/WidgetInputNumber.vue
  40. +0
    -59
      src/renderer/extensions/vueNodes/widgets/components/WidgetInputNumberWithControl.vue
  41. +12
    -1
      src/renderer/extensions/vueNodes/widgets/components/WidgetSelect.vue
  42. +4
    -1
      src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue
  43. +60
    -0
      src/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.vue
  44. +2
    -1
      src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenu.vue
  45. +0
    -111
      src/renderer/extensions/vueNodes/widgets/composables/useStepperControl.ts
  46. +0
    -59
      src/renderer/extensions/vueNodes/widgets/services/NumberControlRegistry.ts
  47. +0
    -3
      src/scripts/app.ts
  48. +7
    -0
      src/types/simplifiedWidget.ts
  49. +30
    -0
      src/workbench/extensions/manager/services/comfyManagerService.ts
  50. +0
    -238
      tests-ui/tests/renderer/extensions/vueNodes/widgets/composables/useStepperControl.test.ts
  51. +0
    -163
      tests-ui/tests/renderer/extensions/vueNodes/widgets/services/NumberControlRegistry.test.ts
  52. +1
    -0
      tests-ui/tests/store/comfyManagerStore.test.ts
  53. +3
    -1
      vite.config.mts

+ 4
- 4
.env_example View File

@@ -14,25 +14,25 @@ DEV_SERVER_COMFYUI_URL=http://127.0.0.1:8188
# Allow dev server access from remote IP addresses.
# If true, the vite dev server will listen on all addresses, including LAN
# and public addresses.
VITE_REMOTE_DEV=false
# VITE_REMOTE_DEV=true

# The directory containing the ComfyUI installation used to run Playwright tests.
# If you aren't using a separate install for testing, point this to your regular install.
TEST_COMFYUI_DIR=/home/ComfyUI

# Whether to enable minification of the frontend code.
ENABLE_MINIFY=true
# ENABLE_MINIFY=true

# Whether to disable proxying the `/templates` route. If true, allows you to
# serve templates from the ComfyUI_frontend/public/templates folder (for
# locally testing changes to templates). When false or nonexistent, the
# templates are served via the normal method from the server's python site
# packages.
DISABLE_TEMPLATES_PROXY=false
# DISABLE_TEMPLATES_PROXY=true

# If playwright tests are being run via vite dev server, Vue plugins will
# invalidate screenshots. When `true`, vite plugins will not be loaded.
DISABLE_VUE_PLUGINS=false
# DISABLE_VUE_PLUGINS=true

# Algolia credentials required for developing with the new custom node manager.
ALGOLIA_APP_ID=4E0RO38HS8


+ 23
- 107
CONTRIBUTING.md View File

@@ -17,17 +17,9 @@ Have another idea? Drop into Discord or open an issue, and let's chat!
### Prerequisites & Technology Stack

- **Required Software**:
- Node.js (v18 or later to build; v24 for vite dev server) and pnpm
- Node.js (v24) and pnpm
- Git for version control
- A running ComfyUI backend instance
- **Tech Stack**:
- [Vue 3.5 Composition API](https://vuejs.org/) with [TypeScript](https://www.typescriptlang.org/)
- [Pinia](https://pinia.vuejs.org/) for state management
- [PrimeVue](https://primevue.org/) with [TailwindCSS](https://tailwindcss.com/) for UI
- litegraph.js (integrated in src/lib) for node editor
- [zod](https://zod.dev/) for schema validation
- [vue-i18n](https://github.com/intlify/vue-i18n) for internationalization
- A running ComfyUI backend instance (otherwise, you can use `pnpm dev:cloud`)

### Initial Setup

@@ -55,15 +47,18 @@ To launch ComfyUI and have it connect to your development server:
python main.py --port 8188
```

### Git pre-commit hooks
If you are on Mac or a low-spec machine, you can run the server in CPU mode

Run `pnpm prepare` to install Git pre-commit hooks. Currently, the pre-commit hook is used to auto-format code on commit.
```bash
python main.py --port 8188 --cpu
```

### Dev Server

- Start local ComfyUI backend at `localhost:8188`
- Run `pnpm dev` to start the dev server
- Run `pnpm dev:electron` to start the dev server with electron API mocked
- Run `pnpm dev:cloud` to start the dev server against the cloud backend (instead of local ComfyUI server)

#### Access dev server on touch devices

@@ -113,7 +108,7 @@ When you fix a bug that affects a version in feature freeze, we use an automated
1. Create your PR fixing the bug on `main` branch as usual
2. Before merging, add these labels to your PR:
- `needs-backport` - triggers the automated backport workflow
- `1.24` - targets the `core/1.24` release candidate branch
- `core/1.24` - targets the `core/1.24` release candidate branch
3. Merge your PR normally
4. The automated workflow will:
@@ -145,67 +140,6 @@ This project includes `.vscode/launch.json.default` and `.vscode/settings.json.d

We've also included a list of recommended extensions in `.vscode/extensions.json`. Your editor should detect this file and show a human friendly list in the Extensions panel, linking each entry to its marketplace page.

### MCP Integrations

#### Playwright Browser Automation

The Playwright MCP server enables LLM coding assistants (like Copilot, Claude Code, etc.) to control and inspect web browsers programmatically. This allows assistants to navigate websites, take screenshots, and interact with web pages on your behalf.

For ComfyUI_frontend development, you can ask coding assistants to use Playwright screenshots to continuously verify that your code changes produce the expected visual results.

##### Setup for Claude Code

After installing dependencies with `pnpm i`, the Playwright MCP server will be automatically available when you start Claude Code locally.

Here's how Claude Code can use the Playwright MCP server to inspect the interface of the local development server (assuming you're running the dev server at `localhost:5173`):

```
> navigate to localhost:5173 and take a screenshot.
tell me the items in the menubar

● I'll navigate to localhost:5173 and take a
screenshot to see the menubar items.

● playwright:playwright_navigate (MCP)(url: …
"http://local
host:5173")
⎿ Navigated to http://localhost:5173

● playwright:playwright_screenshot (MCP)(name: "lo…
alhost_5173
_menubar")

⎿ Screenshot saved to: ../../../Downloads/localho
st_5173_menubar-2025-05-30T17-23-00-942Z.png
⎿ Screenshot also stored in memory with name:
'localhost_5173_menubar'

● Read(../../../Downloads/localhost_5173_menubar-2…
25-05-30T17-23-00-942Z.png)
⎿ Read image (94.3KB)

● Based on the screenshot, I can see the menubar at
the top of the ComfyUI interface contains the
following items:

Main Menubar Items:
- Workflow - Menu for workflow-related actions
- Edit - Menu for editing operations
- Help - Menu for help and documentation

Additional UI Elements:
- ComfyUI Logo - On the far left
- Workflow Tab - Shows "Unsaved Workflow" with a
dropdown and close button
- Layout Controls - On the far right (grid view
and hamburger menu icons)

The interface shows a typical ComfyUI workflow
graph with nodes like "Load Checkpoint", "CLIP
Text Encode (Prompt)", "KSampler", and "Empty
Latent Image" connected with colored cables.
```

## Testing

### Unit Tests
@@ -215,7 +149,7 @@ Here's how Claude Code can use the Playwright MCP server to inspect the interfac

### Playwright Tests

Playwright tests verify the whole app. See [browser_tests/README.md](browser_tests/README.md) for details.
Playwright tests verify the whole app. See [browser_tests/README.md](browser_tests/README.md) for details. The snapshots are generated in the GH actions runner, not locally.

### Running All Tests

@@ -223,7 +157,6 @@ Before submitting a PR, ensure all tests pass:

```bash
pnpm test:unit
pnpm test:browser
pnpm typecheck
pnpm lint
pnpm format
@@ -232,23 +165,28 @@ pnpm format
## Code Style Guidelines

### TypeScript

- Use TypeScript for all new code
- Avoid `any` types - use proper type definitions
- Never use `@ts-expect-error` - fix the underlying type issue

### Vue 3 Patterns

- Use Composition API for all components
- Follow Vue 3.5+ patterns (props destructuring is reactive)
- Use `<script setup>` syntax

### Styling

- Use Tailwind CSS classes instead of custom CSS
- NEVER use `dark:` or `dark-theme:` tailwind variants. Instead use a semantic value from the `style.css` theme, e.g. `bg-node-component-surface`
- NEVER use `dark:` or `dark-theme:` tailwind variants. Instead use a semantic value from the [style.css](packages/design-system/src/css/style.css) like `bg-node-component-surface`

### Internationalization

- All user-facing strings must use vue-i18n
- Add translations to `src/locales/en/main.json`
- Add translations to [src/locales/en/main.json](src/locales/en/main.json)
- Use translation keys: `const { t } = useI18n(); t('key.path')`
- The corresponding values in other locales is generated automatically on releases, PR authors only need to edit [src/locales/en/main.json](src/locales/en/main.json)

## Icons

@@ -282,34 +220,12 @@ The original litegraph repository (https://github.com/Comfy-Org/litegraph.js) is
2. Run all tests and ensure they pass
3. Create a pull request with a clear title and description
4. Use conventional commit format for PR titles:
- `[feat]` for new features
- `[fix]` for bug fixes
- `[docs]` for documentation
- `[refactor]` for code refactoring
- `[test]` for test additions/changes
- `[chore]` for maintenance tasks

### PR Description Template

```
## Description
Brief description of the changes

## Type of Change
- [ ] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Documentation update

## Testing
- [ ] Unit tests pass
- [ ] Component tests pass
- [ ] Browser tests pass (if applicable)
- [ ] Manual testing completed

## Screenshots (if applicable)
Add screenshots for UI changes
```
- `feat:` for new features
- `fix:` for bug fixes
- `docs:` for documentation
- `refactor:` for code refactoring
- `test:` for test additions/changes
- `chore:` for maintenance tasks

### Review Process

@@ -325,4 +241,4 @@ If you have questions about contributing:
- Ask in our [Discord](https://discord.com/invite/comfyorg)
- Open a new issue for clarification

Thank you for contributing to ComfyUI Frontend!
Thank you for contributing to the ComfyUI Frontend!

+ 9
- 4
browser_tests/tests/selectionToolboxSubmenus.spec.ts View File

@@ -85,7 +85,7 @@ test.describe('Selection Toolbox - More Options Submenus', () => {
const initialShape = await nodeRef.getProperty<number>('shape')

await openMoreOptions(comfyPage)
await comfyPage.page.getByText('Shape', { exact: true }).click()
await comfyPage.page.getByText('Shape', { exact: true }).hover()
await expect(comfyPage.page.getByText('Box', { exact: true })).toBeVisible({
timeout: 5000
})
@@ -136,13 +136,18 @@ test.describe('Selection Toolbox - More Options Submenus', () => {
comfyPage
}) => {
await openMoreOptions(comfyPage)
await expect(
comfyPage.page.getByText('Rename', { exact: true })
).toBeVisible({ timeout: 5000 })
const renameItem = comfyPage.page.getByText('Rename', { exact: true })
await expect(renameItem).toBeVisible({ timeout: 5000 })

// Wait for multiple frames to allow PrimeVue's outside click handler to initialize
for (let i = 0; i < 30; i++) {
await comfyPage.nextFrame()
}

await comfyPage.page
.locator('#graph-canvas')
.click({ position: { x: 0, y: 50 }, force: true })

await comfyPage.nextFrame()
await expect(
comfyPage.page.getByText('Rename', { exact: true })


+ 1
- 0
package.json View File

@@ -161,6 +161,7 @@
"algoliasearch": "catalog:",
"axios": "catalog:",
"chart.js": "^4.5.0",
"cva": "catalog:",
"dompurify": "^3.2.5",
"dotenv": "catalog:",
"es-toolkit": "^1.39.9",


+ 20
- 0
pnpm-lock.yaml View File

@@ -138,6 +138,9 @@ catalogs:
cross-env:
specifier: ^10.1.0
version: 10.1.0
cva:
specifier: 1.0.0-beta.4
version: 1.0.0-beta.4
dotenv:
specifier: ^16.4.5
version: 16.6.1
@@ -419,6 +422,9 @@ importers:
chart.js:
specifier: ^4.5.0
version: 4.5.0
cva:
specifier: 'catalog:'
version: 1.0.0-beta.4(typescript@5.9.2)
dompurify:
specifier: ^3.2.5
version: 3.2.5
@@ -4669,6 +4675,14 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}

cva@1.0.0-beta.4:
resolution: {integrity: sha512-F/JS9hScapq4DBVQXcK85l9U91M6ePeXoBMSp7vypzShoefUBxjQTo3g3935PUHgQd+IW77DjbPRIxugy4/GCQ==}
peerDependencies:
typescript: '>= 4.5.5'
peerDependenciesMeta:
typescript:
optional: true

data-urls@5.0.0:
resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
engines: {node: '>=18'}
@@ -12808,6 +12822,12 @@ snapshots:

csstype@3.2.3: {}

cva@1.0.0-beta.4(typescript@5.9.2):
dependencies:
clsx: 2.1.1
optionalDependencies:
typescript: 5.9.2

data-urls@5.0.0:
dependencies:
whatwg-mimetype: 4.0.0


+ 1
- 0
pnpm-workspace.yaml View File

@@ -47,6 +47,7 @@ catalog:
algoliasearch: ^5.21.0
axios: ^1.8.2
cross-env: ^10.1.0
cva: 1.0.0-beta.4
dotenv: ^16.4.5
eslint: ^9.39.1
eslint-config-prettier: ^10.1.8


+ 0
- 91
src/components/button/TextButton.stories.ts View File

@@ -1,91 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'

import TextButton from './TextButton.vue'

const meta: Meta<typeof TextButton> = {
title: 'Components/Button/TextButton',
component: TextButton,
tags: ['autodocs'],
argTypes: {
label: {
control: 'text',
defaultValue: 'Click me'
},
size: {
control: { type: 'select' },
options: ['sm', 'md'],
defaultValue: 'md'
},
border: {
control: 'boolean',
description: 'Toggle border attribute'
},
disabled: {
control: 'boolean',
description: 'Toggle disable status'
},
type: {
control: { type: 'select' },
options: ['primary', 'secondary', 'transparent'],
defaultValue: 'primary'
},
onClick: { action: 'clicked' }
}
}

export default meta
type Story = StoryObj<typeof meta>

export const Primary: Story = {
args: {
label: 'Primary Button',
type: 'primary',
size: 'md'
}
}

export const Secondary: Story = {
args: {
label: 'Secondary Button',
type: 'secondary',
size: 'md'
}
}

export const Transparent: Story = {
args: {
label: 'Transparent Button',
type: 'transparent',
size: 'md'
}
}

export const Small: Story = {
args: {
label: 'Small Button',
type: 'primary',
size: 'sm'
}
}

export const AllVariants: Story = {
render: () => ({
components: { TextButton },
template: `
<div class="flex flex-col gap-4">
<div class="flex gap-2 items-center">
<TextButton label="Primary Small" type="primary" size="sm" @click="() => {}" />
<TextButton label="Primary Medium" type="primary" size="md" @click="() => {}" />
</div>
<div class="flex gap-2 items-center">
<TextButton label="Secondary Small" type="secondary" size="sm" @click="() => {}" />
<TextButton label="Secondary Medium" type="secondary" size="md" @click="() => {}" />
</div>
<div class="flex gap-2 items-center">
<TextButton label="Transparent Small" type="transparent" size="sm" @click="() => {}" />
<TextButton label="Transparent Medium" type="transparent" size="md" @click="() => {}" />
</div>
</div>
`
})
}

+ 0
- 54
src/components/button/TextButton.vue View File

@@ -1,54 +0,0 @@
<template>
<Button
v-bind="$attrs"
unstyled
:class="buttonStyle"
:disabled="disabled"
@click="onClick"
>
<span>{{ label }}</span>
</Button>
</template>

<script setup lang="ts">
import Button from 'primevue/button'
import { computed } from 'vue'

import type { BaseButtonProps } from '@/types/buttonTypes'
import {
getBaseButtonClasses,
getBorderButtonTypeClasses,
getButtonSizeClasses,
getButtonTypeClasses
} from '@/types/buttonTypes'
import { cn } from '@/utils/tailwindUtil'

interface TextButtonProps extends BaseButtonProps {
label: string
onClick: () => void
}

defineOptions({
inheritAttrs: false
})

const {
size = 'md',
type = 'primary',
border = false,
disabled = false,
class: className,
label,
onClick
} = defineProps<TextButtonProps>()

const buttonStyle = computed(() => {
const baseClasses = getBaseButtonClasses()
const sizeClasses = getButtonSizeClasses(size)
const typeClasses = border
? getBorderButtonTypeClasses(type)
: getButtonTypeClasses(type)

return cn(baseClasses, sizeClasses, typeClasses, className)
})
</script>

+ 9
- 12
src/components/dialog/confirm/ConfirmFooter.vue View File

@@ -1,19 +1,16 @@
<template>
<section class="w-full flex gap-2 justify-end px-2 pb-2">
<TextButton
:label="cancelTextX"
<Button :disabled variant="textonly" autofocus @click="$emit('cancel')">
{{ cancelTextX }}
</Button>
<Button
:disabled
type="transparent"
autofocus
@click="$emit('cancel')"
/>
<TextButton
:label="confirmTextX"
:disabled
type="transparent"
variant="textonly"
:class="confirmClass"
@click="$emit('confirm')"
/>
>
{{ confirmTextX }}
</Button>
</section>
</template>
<script setup lang="ts">
@@ -21,7 +18,7 @@ import { computed, toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import { useI18n } from 'vue-i18n'

import TextButton from '@/components/button/TextButton.vue'
import Button from '@/components/ui/button/Button.vue'

const { t } = useI18n()



+ 7
- 13
src/components/dialog/content/MissingNodesFooter.vue View File

@@ -18,22 +18,16 @@
<i class="icon-[lucide--info]"></i>
</template>
</IconTextButton>
<TextButton
:label="$t('missingNodes.cloud.gotIt')"
type="secondary"
size="md"
@click="handleGotItClick"
/>
<Button variant="secondary" size="md" @click="handleGotItClick">{{
$t('missingNodes.cloud.gotIt')
}}</Button>
</div>

<!-- OSS mode: Open Manager + Install All buttons -->
<div v-else-if="showManagerButtons" class="flex justify-end gap-1 py-2 px-4">
<TextButton
:label="$t('g.openManager')"
type="transparent"
size="sm"
@click="openManager"
/>
<Button variant="textonly" size="sm" @click="openManager">{{
$t('g.openManager')
}}</Button>
<PackInstallButton
v-if="showInstallAllButton"
type="secondary"
@@ -57,7 +51,7 @@ import { computed, nextTick, watch } from 'vue'
import { useI18n } from 'vue-i18n'

import IconTextButton from '@/components/button/IconTextButton.vue'
import TextButton from '@/components/button/TextButton.vue'
import Button from '@/components/ui/button/Button.vue'
import { isCloud } from '@/platform/distribution/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useDialogStore } from '@/stores/dialogStore'


+ 0
- 2
src/components/graph/GraphCanvas.vue View File

@@ -87,7 +87,6 @@
<template v-if="comfyAppReady">
<TitleEditor />
<SelectionToolbox v-if="selectionToolboxEnabled" />
<NodeOptions />
<!-- Render legacy DOM widgets only when Vue nodes are disabled -->
<DomWidgets v-if="!shouldRenderVueNodes" />
</template>
@@ -115,7 +114,6 @@ import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
import TitleEditor from '@/components/graph/TitleEditor.vue'
import NodeOptions from '@/components/graph/selectionToolbox/NodeOptions.vue'
import NodePropertiesPanel from '@/components/rightSidePanel/RightSidePanel.vue'
import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vue'
import SideToolbar from '@/components/sidebar/SideToolbar.vue'


+ 272
- 0
src/components/graph/NodeContextMenu.vue View File

@@ -0,0 +1,272 @@
<template>
<ContextMenu
ref="contextMenu"
:model="menuItems"
class="max-h-[80vh] md:max-h-none overflow-y-auto md:overflow-y-visible"
@show="onMenuShow"
@hide="onMenuHide"
>
<template #item="{ item, props, hasSubmenu }">
<a
v-bind="props.action"
class="flex items-center gap-2 px-3 py-1.5"
@click="item.isColorSubmenu ? showColorPopover($event) : undefined"
>
<i v-if="item.icon" :class="[item.icon, 'size-4']" />
<span class="flex-1">{{ item.label }}</span>
<span
v-if="item.shortcut"
class="flex h-3.5 min-w-3.5 items-center justify-center rounded bg-interface-menu-keybind-surface-default px-1 py-0 text-xs"
>
{{ item.shortcut }}
</span>
<i
v-if="hasSubmenu || item.isColorSubmenu"
class="icon-[lucide--chevron-right] size-4 opacity-60"
/>
</a>
</template>
</ContextMenu>

<!-- Color picker menu (custom with color circles) -->
<ColorPickerMenu
v-if="colorOption"
ref="colorPickerMenu"
key="color-picker-menu"
:option="colorOption"
@submenu-click="handleColorSelect"
/>
</template>

<script setup lang="ts">
import { useElementBounding, useEventListener, useRafFn } from '@vueuse/core'
import ContextMenu from 'primevue/contextmenu'
import type { MenuItem } from 'primevue/menuitem'
import { computed, onMounted, onUnmounted, ref, watchEffect } from 'vue'

import {
registerNodeOptionsInstance,
useMoreOptionsMenu
} from '@/composables/graph/useMoreOptionsMenu'
import type {
MenuOption,
SubMenuOption
} from '@/composables/graph/useMoreOptionsMenu'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'

import ColorPickerMenu from './selectionToolbox/ColorPickerMenu.vue'

interface ExtendedMenuItem extends MenuItem {
isColorSubmenu?: boolean
shortcut?: string
originalOption?: MenuOption
}

const contextMenu = ref<InstanceType<typeof ContextMenu>>()
const colorPickerMenu = ref<InstanceType<typeof ColorPickerMenu>>()
const isOpen = ref(false)

const { menuOptions, bump } = useMoreOptionsMenu()
const canvasStore = useCanvasStore()

// World position (canvas coordinates) where menu was opened
const worldPosition = ref({ x: 0, y: 0 })

// Get canvas bounding rect reactively
const lgCanvas = canvasStore.getCanvas()
const { left: canvasLeft, top: canvasTop } = useElementBounding(lgCanvas.canvas)

// Track last canvas transform to detect actual changes
let lastScale = 0
let lastOffsetX = 0
let lastOffsetY = 0

// Update menu position based on canvas transform
const updateMenuPosition = () => {
if (!isOpen.value) return

const menuInstance = contextMenu.value as unknown as {
container?: HTMLElement
}
const menuEl = menuInstance?.container
if (!menuEl) return

const { scale, offset } = lgCanvas.ds

// Only update if canvas transform actually changed
if (
scale === lastScale &&
offset[0] === lastOffsetX &&
offset[1] === lastOffsetY
) {
return
}

lastScale = scale
lastOffsetX = offset[0]
lastOffsetY = offset[1]

// Convert world position to screen position
const screenX = (worldPosition.value.x + offset[0]) * scale + canvasLeft.value
const screenY = (worldPosition.value.y + offset[1]) * scale + canvasTop.value

// Update menu position
menuEl.style.left = `${screenX}px`
menuEl.style.top = `${screenY}px`
}

// Sync with canvas transform using requestAnimationFrame
const { resume: startSync, pause: stopSync } = useRafFn(updateMenuPosition, {
immediate: false
})

// Start/stop syncing based on menu visibility
watchEffect(() => {
if (isOpen.value) {
startSync()
} else {
stopSync()
}
})

// Close on touch outside to handle mobile devices where click might be swallowed
useEventListener(
window,
'touchstart',
(event: TouchEvent) => {
if (!isOpen.value || !contextMenu.value) return

const target = event.target as Node
const contextMenuInstance = contextMenu.value as unknown as {
container?: HTMLElement
$el?: HTMLElement
}
const menuEl = contextMenuInstance.container || contextMenuInstance.$el

if (menuEl && !menuEl.contains(target)) {
hide()
}
},
{ passive: true }
)

// Find color picker option
const colorOption = computed(() =>
menuOptions.value.find((opt) => opt.isColorPicker)
)

// Check if option is the color picker
function isColorOption(option: MenuOption): boolean {
return Boolean(option.isColorPicker)
}

// Convert MenuOption to PrimeVue MenuItem
function convertToMenuItem(option: MenuOption): ExtendedMenuItem {
if (option.type === 'divider') return { separator: true }

const isColor = isColorOption(option)

const item: ExtendedMenuItem = {
label: option.label,
icon: option.icon,
disabled: option.disabled,
shortcut: option.shortcut,
isColorSubmenu: isColor,
originalOption: option
}

// Native submenus for non-color options
if (option.hasSubmenu && option.submenu && !isColor) {
item.items = option.submenu.map((sub) => ({
label: sub.label,
icon: sub.icon,
disabled: sub.disabled,
command: () => {
sub.action()
hide()
}
}))
}

// Regular action items
if (!option.hasSubmenu && option.action) {
item.command = () => {
option.action?.()
hide()
}
}

return item
}

// Build menu items
const menuItems = computed<ExtendedMenuItem[]>(() =>
menuOptions.value.map(convertToMenuItem)
)

// Show context menu
function show(event: MouseEvent) {
bump()

// Convert screen position to world coordinates
// Screen position relative to canvas = event position - canvas offset
const screenX = event.clientX - canvasLeft.value
const screenY = event.clientY - canvasTop.value

// Convert to world coordinates using canvas transform
const { scale, offset } = lgCanvas.ds
worldPosition.value = {
x: screenX / scale - offset[0],
y: screenY / scale - offset[1]
}

isOpen.value = true
contextMenu.value?.show(event)
}

// Hide context menu
function hide() {
contextMenu.value?.hide()
}

function toggle(event: Event) {
if (isOpen.value) {
hide()
} else {
show(event as MouseEvent)
}
}

defineExpose({ toggle, hide, isOpen, show })

function showColorPopover(event: MouseEvent) {
event.stopPropagation()
event.preventDefault()
const target = Array.from((event.currentTarget as HTMLElement).children).find(
(el) => el.classList.contains('icon-[lucide--chevron-right]')
) as HTMLElement
colorPickerMenu.value?.toggle(event, target)
}

// Handle color selection
function handleColorSelect(subOption: SubMenuOption) {
subOption.action()
hide()
}

function onMenuShow() {
isOpen.value = true
}

function onMenuHide() {
isOpen.value = false
}

onMounted(() => {
registerNodeOptionsInstance({ toggle, hide, isOpen })
})

onUnmounted(() => {
registerNodeOptionsInstance(null)
})
</script>

+ 1
- 0
src/components/graph/SelectionToolbox.test.ts View File

@@ -136,6 +136,7 @@ describe('SelectionToolbox', () => {
'<div class="panel selection-toolbox absolute left-1/2 rounded-lg"><slot /></div>',
props: ['pt', 'style', 'class']
},
NodeContextMenu: { template: '<div class="node-context-menu" />' },
InfoButton: { template: '<div class="info-button" />' },
ColorPickerButton: {
template:


+ 2
- 0
src/components/graph/SelectionToolbox.vue View File

@@ -42,6 +42,7 @@
</Panel>
</Transition>
</div>
<NodeContextMenu />
</template>

<script setup lang="ts">
@@ -68,6 +69,7 @@ import { useExtensionService } from '@/services/extensionService'
import { useCommandStore } from '@/stores/commandStore'
import type { ComfyCommandImpl } from '@/stores/commandStore'

import NodeContextMenu from './NodeContextMenu.vue'
import FrameNodes from './selectionToolbox/FrameNodes.vue'
import NodeOptionsButton from './selectionToolbox/NodeOptionsButton.vue'
import VerticalDivider from './selectionToolbox/VerticalDivider.vue'


src/components/graph/selectionToolbox/SubmenuPopover.vue → src/components/graph/selectionToolbox/ColorPickerMenu.vue View File

@@ -1,6 +1,6 @@
<template>
<Popover
ref="popover"
ref="popoverRef"
:auto-z-index="true"
:base-z-index="1100"
:dismissable="true"
@@ -34,7 +34,10 @@
'hover:bg-secondary-background-hover rounded cursor-pointer',
isColorSubmenu
? 'w-7 h-7 flex items-center justify-center'
: 'flex items-center gap-2 px-3 py-1.5 text-sm'
: 'flex items-center gap-2 px-3 py-1.5 text-sm',
subOption.disabled
? 'cursor-not-allowed pointer-events-none text-node-icon-disabled'
: 'hover:bg-secondary-background-hover'
)
"
:title="subOption.label"
@@ -82,23 +85,21 @@ const emit = defineEmits<Emits>()

const { getCurrentShape } = useNodeCustomization()

const popover = ref<InstanceType<typeof Popover>>()
const popoverRef = ref<InstanceType<typeof Popover>>()

const show = (event: Event, target?: HTMLElement) => {
popover.value?.show(event, target)
const toggle = (event: Event, target?: HTMLElement) => {
popoverRef.value?.toggle(event, target)
}

const hide = () => {
popover.value?.hide()
}

defineExpose({
show,
hide
toggle
})

const handleSubmenuClick = (subOption: SubMenuOption) => {
if (subOption.disabled) {
return
}
emit('submenu-click', subOption)
popoverRef.value?.hide()
}

const isShapeSelected = (subOption: SubMenuOption): boolean => {

+ 0
- 62
src/components/graph/selectionToolbox/MenuOptionItem.vue View File

@@ -1,62 +0,0 @@
<template>
<div v-if="option.type === 'divider'" class="my-1 h-px bg-border-default" />
<div
v-else
role="button"
class="group flex cursor-pointer items-center gap-2 rounded px-3 py-1.5 text-left text-sm text-text-primary hover:bg-interface-menu-component-surface-hovered"
@click="handleClick"
>
<i v-if="option.icon" :class="[option.icon, 'h-4 w-4']" />
<span class="flex-1">{{ option.label }}</span>
<span
v-if="option.shortcut"
class="flex h-3.5 min-w-3.5 items-center justify-center rounded bg-interface-menu-keybind-surface-default px-1 py-0 text-xxs"
>
{{ option.shortcut }}
</span>
<i
v-if="option.hasSubmenu"
:size="14"
class="icon-[lucide--chevron-right] opacity-60"
/>
<Badge
v-if="option.badge"
:severity="option.badge === 'new' ? 'info' : 'secondary'"
:value="t(option.badge)"
:class="
cn(
'h-4 gap-2.5 px-1 text-[9px] text-base-foreground uppercase rounded-4xl',
{
'bg-primary-background': option.badge === 'new',
'bg-secondary-background': option.badge === 'deprecated'
}
)
"
/>
</div>
</template>

<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import Badge from 'primevue/badge'
import { useI18n } from 'vue-i18n'

import type { MenuOption } from '@/composables/graph/useMoreOptionsMenu'

const { t } = useI18n()

interface Props {
option: MenuOption
}

interface Emits {
(e: 'click', option: MenuOption, event: Event): void
}

const props = defineProps<Props>()
const emit = defineEmits<Emits>()

const handleClick = (event: Event) => {
emit('click', props.option, event)
}
</script>

+ 0
- 322
src/components/graph/selectionToolbox/NodeOptions.vue View File

@@ -1,322 +0,0 @@
<template>
<div>
<Popover
ref="popover"
append-to="body"
:auto-z-index="true"
:base-z-index="1000"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="{
root: {
class: 'absolute z-50 w-[300px]'
},
content: {
class: [
'mt-2 text-base-foreground rounded-lg',
'shadow-lg border border-border-default',
'bg-interface-panel-surface'
]
}
}"
@show="onPopoverShow"
@hide="onPopoverHide"
@wheel="canvasInteractions.forwardEventToCanvas"
>
<div class="flex min-w-48 flex-col p-2">
<MenuOptionItem
v-for="(option, index) in menuOptions"
:key="option.label || `divider-${index}`"
:option="option"
@click="handleOptionClick"
/>
</div>
</Popover>

<SubmenuPopover
v-for="option in menuOptionsWithSubmenu"
:key="`submenu-${option.label}`"
:ref="(el) => setSubmenuRef(`submenu-${option.label}`, el)"
:option="option"
@submenu-click="handleSubmenuClick"
/>
</div>
</template>

<script setup lang="ts">
import { useRafFn } from '@vueuse/core'
import Popover from 'primevue/popover'
import { onMounted, onUnmounted, ref, watch } from 'vue'

import {
forceCloseMoreOptionsSignal,
moreOptionsOpen,
moreOptionsRestorePending,
restoreMoreOptionsSignal
} from '@/composables/canvas/useSelectionToolboxPosition'
import {
registerNodeOptionsInstance,
useMoreOptionsMenu
} from '@/composables/graph/useMoreOptionsMenu'
import type {
MenuOption,
SubMenuOption
} from '@/composables/graph/useMoreOptionsMenu'
import { useSubmenuPositioning } from '@/composables/graph/useSubmenuPositioning'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'

import MenuOptionItem from './MenuOptionItem.vue'
import SubmenuPopover from './SubmenuPopover.vue'

const popover = ref<InstanceType<typeof Popover>>()
const targetElement = ref<HTMLElement | null>(null)
const isTriggeredByToolbox = ref<boolean>(true)
// Track open state ourselves so we can restore after drag/move
const isOpen = ref(false)
const wasOpenBeforeHide = ref(false)
// Track why the popover was hidden so we only auto-reopen after drag.
type HideReason = 'manual' | 'drag'
const lastProgrammaticHideReason = ref<HideReason | null>(null)
const submenuRefs = ref<Record<string, InstanceType<typeof SubmenuPopover>>>({})
const currentSubmenu = ref<string | null>(null)

const { menuOptions, menuOptionsWithSubmenu, bump } = useMoreOptionsMenu()
const { toggleSubmenu, hideAllSubmenus } = useSubmenuPositioning()
const canvasInteractions = useCanvasInteractions()

let lastLogTs = 0
const LOG_INTERVAL = 120 // ms
let overlayElCache: HTMLElement | null = null

function resolveOverlayEl(): HTMLElement | null {
// Prefer cached element (cleared on hide)
if (overlayElCache && overlayElCache.isConnected) return overlayElCache
// PrimeVue Popover root element (component instance $el)
const direct = (popover.value as any)?.$el
if (direct instanceof HTMLElement) {
overlayElCache = direct
return direct
}
// Fallback: try to locate a recent popover root near the button (same z-index class + absolute)
const btn = targetElement.value
if (btn) {
const candidates = Array.from(
document.querySelectorAll('div.absolute.z-50')
) as HTMLElement[]
// Heuristic: pick the one closest (vertically) below the button
const rect = btn.getBoundingClientRect()
let best: { el: HTMLElement; dist: number } | null = null
for (const el of candidates) {
const r = el.getBoundingClientRect()
const dist = Math.abs(r.top - rect.bottom)
if (!best || dist < best.dist) best = { el, dist }
}
if (best && best.el) {
overlayElCache = best.el
return best.el
}
}
return null
}

const repositionPopover = () => {
if (!isOpen.value) return
const btn = targetElement.value
const overlayEl = resolveOverlayEl()
if (!btn || !overlayEl) return
const rect = btn.getBoundingClientRect()
const marginY = 8 // tailwind mt-2 ~ 0.5rem = 8px
const left = isTriggeredByToolbox.value
? rect.left + rect.width / 2
: rect.right - rect.width / 4
const top = isTriggeredByToolbox.value
? rect.bottom + marginY
: rect.top - marginY - 6
try {
overlayEl.style.position = 'fixed'
overlayEl.style.left = `${left}px`
overlayEl.style.top = `${top}px`
overlayEl.style.transform = 'translate(-50%, 0)'
} catch (e) {
console.warn('[NodeOptions] Failed to set overlay style', e)
return
}
const now = performance.now()
if (now - lastLogTs > LOG_INTERVAL) {
lastLogTs = now
}
}

const { resume: startSync, pause: stopSync } = useRafFn(repositionPopover)

function openPopover(
triggerEvent?: Event,
element?: HTMLElement,
clickedFromToolbox?: boolean
): boolean {
const el = element || targetElement.value
if (!el || !el.isConnected) return false
targetElement.value = el
if (clickedFromToolbox !== undefined)
isTriggeredByToolbox.value = clickedFromToolbox
bump()
popover.value?.show(triggerEvent ?? new Event('reopen'), el)
isOpen.value = true
moreOptionsOpen.value = true
moreOptionsRestorePending.value = false
return true
}

function closePopover(reason: HideReason = 'manual') {
lastProgrammaticHideReason.value = reason
popover.value?.hide()
isOpen.value = false
moreOptionsOpen.value = false
stopSync()
hideAll()
if (reason !== 'drag') {
wasOpenBeforeHide.value = false
// Natural hide: cancel any pending restore
moreOptionsRestorePending.value = false
} else {
if (!moreOptionsRestorePending.value) {
wasOpenBeforeHide.value = true
moreOptionsRestorePending.value = true
}
}
}

let restoreAttempts = 0
function attemptRestore() {
if (isOpen.value) return
if (!wasOpenBeforeHide.value && !moreOptionsRestorePending.value) return
// Try immediately
if (openPopover(new Event('reopen'), targetElement.value || undefined)) {
wasOpenBeforeHide.value = false
restoreAttempts = 0
return
}
// Defer with limited retries (layout / mount race)
if (restoreAttempts >= 5) return
restoreAttempts++
requestAnimationFrame(() => attemptRestore())
}

const toggle = (
event: Event,
element?: HTMLElement,
clickedFromToolbox?: boolean
) => {
if (isOpen.value) closePopover('manual')
else openPopover(event, element, clickedFromToolbox)
}

const hide = (reason: HideReason = 'manual') => closePopover(reason)

// Export functions for external triggering
defineExpose({
toggle,
hide,
isOpen
})

const hideAll = () => {
hideAllSubmenus(
menuOptionsWithSubmenu.value,
submenuRefs.value,
currentSubmenu
)
}

const handleOptionClick = (option: MenuOption, event: Event) => {
if (!option.hasSubmenu && option.action) {
option.action()
hide()
} else if (option.hasSubmenu) {
event.stopPropagation()
const submenuKey = `submenu-${option.label}`
const submenu = submenuRefs.value[submenuKey]

if (submenu) {
void toggleSubmenu(
option,
event,
submenu,
currentSubmenu,
menuOptionsWithSubmenu.value,
submenuRefs.value
)
}
}
}

const handleSubmenuClick = (subOption: SubMenuOption) => {
subOption.action()
hide('manual')
}

const setSubmenuRef = (key: string, el: any) => {
if (el) {
submenuRefs.value[key] = el
} else {
delete submenuRefs.value[key]
}
}

// Distinguish outside click (PrimeVue dismiss) from programmatic hides.
const onPopoverShow = () => {
overlayElCache = resolveOverlayEl()
// Delay first reposition slightly to ensure DOM fully painted
requestAnimationFrame(() => repositionPopover())
startSync()
}

const onPopoverHide = () => {
if (lastProgrammaticHideReason.value == null) {
isOpen.value = false
hideAll()
wasOpenBeforeHide.value = false
moreOptionsOpen.value = false
moreOptionsRestorePending.value = false
}
overlayElCache = null
stopSync()
lastProgrammaticHideReason.value = null
}

// Watch for forced close (drag start)
watch(
() => forceCloseMoreOptionsSignal.value,
() => {
if (isOpen.value) hide('drag')
else
wasOpenBeforeHide.value =
wasOpenBeforeHide.value || moreOptionsRestorePending.value
}
)

watch(
() => restoreMoreOptionsSignal.value,
() => attemptRestore()
)

onMounted(() => {
// Register this instance globally
registerNodeOptionsInstance({
toggle,
hide,
isOpen
})

if (moreOptionsRestorePending.value && !isOpen.value) {
requestAnimationFrame(() => attemptRestore())
}
})

onUnmounted(() => {
stopSync()
// Unregister on unmount
registerNodeOptionsInstance(null)
})
</script>

+ 1
- 9
src/components/graph/selectionToolbox/NodeOptionsButton.vue View File

@@ -1,6 +1,5 @@
<template>
<Button
ref="buttonRef"
v-tooltip.top="{
value: $t('g.moreOptions'),
showDelay: 1000
@@ -17,17 +16,10 @@

<script setup lang="ts">
import Button from 'primevue/button'
import { ref } from 'vue'

import { toggleNodeOptions } from '@/composables/graph/useMoreOptionsMenu'

const buttonRef = ref<InstanceType<typeof Button> | null>(null)

const handleClick = (event: Event) => {
const el = (buttonRef.value as any)?.$el || buttonRef.value
const buttonEl = el instanceof HTMLElement ? el : null
if (buttonEl) {
toggleNodeOptions(event, buttonEl, true)
}
toggleNodeOptions(event)
}
</script>

+ 58
- 0
src/components/helpcenter/HelpCenterMenuContent.vue View File

@@ -152,6 +152,7 @@
</template>

<script setup lang="ts">
import { useToast } from 'primevue/usetoast'
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import type { CSSProperties, Component } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -168,6 +169,7 @@ import { electronAPI, isElectron } from '@/utils/envUtil'
import { formatVersionAnchor } from '@/utils/formatUtil'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'

// Types
@@ -201,6 +203,7 @@ const SUBMENU_CONFIG = {

// Composables
const { t } = useI18n()
const toast = useToast()
const { staticUrls, buildDocsUrl } = useExternalLink()
const releaseStore = useReleaseStore()
const commandStore = useCommandStore()
@@ -230,6 +233,7 @@ const showVersionUpdates = computed(() =>
// Use conflict acknowledgment state from composable
const { shouldShowRedDot: shouldShowManagerRedDot } =
useConflictAcknowledgment()
const { isNewManagerUI } = useManagerState()

const moreItems = computed<MenuItem[]>(() => {
const allMoreItems: MenuItem[] = [
@@ -369,6 +373,19 @@ const menuItems = computed<MenuItem[]>(() => {
}
})
}
// Update ComfyUI - only for non-desktop, non-cloud with new manager UI
if (!isElectron() && !isCloud && isNewManagerUI.value) {
items.push({
key: 'update-comfyui',
type: 'item',
icon: 'icon-[lucide--download]',
label: t('helpCenter.updateComfyUI'),
action: () => {
onUpdateComfyUI()
emit('close')
}
})
}

items.push({
key: 'more',
@@ -545,6 +562,47 @@ const onReinstall = (): void => {
}
}

const onUpdateComfyUI = async (): Promise<void> => {
const { updateComfyUI, rebootComfyUI, error } = useComfyManagerService()

toast.add({
severity: 'info',
summary: t('helpCenter.updateComfyUIStarted'),
detail: t('helpCenter.updateComfyUIStartedDetail'),
life: 3000
})

try {
const result = await updateComfyUI({ is_stable: true })

if (result === null || error.value) {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: error.value || t('helpCenter.updateComfyUIFailed'),
life: 5000
})
return
}

toast.add({
severity: 'success',
summary: t('helpCenter.updateComfyUISuccess'),
detail: t('helpCenter.updateComfyUISuccessDetail'),
life: 3000
})

await rebootComfyUI()
} catch (err) {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: err instanceof Error ? err.message : t('g.unknownError'),
life: 5000
})
}
}

const onReleaseClick = (release: ReleaseNote): void => {
trackResourceClick('release_notes', true)
void releaseStore.handleShowChangelog(release.version)


+ 17
- 16
src/components/input/MultiSelect.vue View File

@@ -62,7 +62,7 @@
// Option row hover and focus tone
option: ({ context }: MultiSelectPassThroughMethodOptions) => ({
class: cn(
'flex gap-2 items-center h-10 px-2 rounded-lg',
'flex gap-2 items-center h-10 px-2 rounded-lg cursor-pointer',
'hover:bg-secondary-background-hover',
// Add focus/highlight state for keyboard navigation
context?.focused &&
@@ -112,14 +112,14 @@
: $t('g.itemSelected', { selectedCount })
}}
</span>
<TextButton
<Button
v-if="showClearButton"
:label="$t('g.clearAll')"
type="transparent"
size="fit-content"
class="text-sm text-text-primary"
variant="textonly"
size="md"
@click.stop="selectedItems = []"
/>
>
{{ $t('g.clearAll') }}
</Button>
</div>
<div class="my-4 h-px bg-border-default"></div>
</div>
@@ -145,9 +145,13 @@

<!-- Custom option row: square checkbox + label (unchanged layout/colors) -->
<template #option="slotProps">
<div class="flex items-center gap-2" :style="popoverStyle">
<div
role="button"
class="flex items-center gap-2 cursor-pointer"
:style="popoverStyle"
>
<div
class="flex h-4 w-4 shrink-0 items-center justify-center rounded p-0.5 transition-all duration-200"
class="flex size-4 shrink-0 items-center justify-center rounded p-0.5 transition-all duration-200"
:class="
slotProps.selected
? 'bg-primary-background'
@@ -159,11 +163,9 @@
class="text-bold icon-[lucide--check] text-xs text-white"
/>
</div>
<Button
class="border-none bg-transparent text-left outline-none"
unstyled
>{{ slotProps.option.name }}</Button
>
<span>
{{ slotProps.option.name }}
</span>
</div>
</template>
</MultiSelect>
@@ -172,17 +174,16 @@
<script setup lang="ts">
import { useFuse } from '@vueuse/integrations/useFuse'
import type { UseFuseOptions } from '@vueuse/integrations/useFuse'
import Button from 'primevue/button'
import type { MultiSelectPassThroughMethodOptions } from 'primevue/multiselect'
import MultiSelect from 'primevue/multiselect'
import { computed, useAttrs } from 'vue'
import { useI18n } from 'vue-i18n'

import SearchBox from '@/components/common/SearchBox.vue'
import Button from '@/components/ui/button/Button.vue'
import { usePopoverSizing } from '@/composables/usePopoverSizing'
import { cn } from '@/utils/tailwindUtil'

import TextButton from '../button/TextButton.vue'
import type { SelectOption } from './types'

type Option = SelectOption


+ 8
- 6
src/components/queue/QueueOverlayActive.vue View File

@@ -80,12 +80,14 @@
</div>
</div>

<TextButton
class="h-6 min-w-[120px] flex-1 px-2 py-0 text-[12px]"
type="secondary"
:label="t('sideToolbar.queueProgressOverlay.viewAllJobs')"
<Button
class="min-w-30 flex-1 px-2 py-0"
variant="secondary"
size="sm"
@click="$emit('viewAllJobs')"
/>
>
{{ t('sideToolbar.queueProgressOverlay.viewAllJobs') }}
</Button>
</div>
</div>
</template>
@@ -95,7 +97,7 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'

import IconButton from '@/components/button/IconButton.vue'
import TextButton from '@/components/button/TextButton.vue'
import Button from '@/components/ui/button/Button.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'

defineProps<{


+ 10
- 13
src/components/queue/dialogs/QueueClearHistoryDialog.vue View File

@@ -31,20 +31,17 @@
</div>

<footer class="flex items-center justify-end px-4 py-4">
<div class="flex items-center gap-4 text-[14px] leading-none">
<TextButton
class="min-h-[24px] px-1 py-1 text-[14px] leading-[1] text-text-secondary hover:text-text-primary"
type="transparent"
:label="t('g.cancel')"
@click="onCancel"
/>
<TextButton
class="min-h-[32px] px-4 py-2 text-[12px] font-normal leading-[1]"
type="secondary"
:label="t('g.clear')"
<div class="flex items-center gap-4 leading-none">
<Button variant="muted-textonly" size="lg" @click="onCancel">
{{ t('g.cancel') }}
</Button>
<Button
variant="secondary"
size="lg"
:disabled="isClearing"
@click="onConfirm"
/>
>{{ t('g.clear') }}</Button
>
</div>
</footer>
</section>
@@ -55,7 +52,7 @@ import { ref } from 'vue'
import { useI18n } from 'vue-i18n'

import IconButton from '@/components/button/IconButton.vue'
import TextButton from '@/components/button/TextButton.vue'
import Button from '@/components/ui/button/Button.vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useDialogStore } from '@/stores/dialogStore'
import { useQueueStore } from '@/stores/queueStore'


+ 8
- 9
src/components/queue/job/JobFiltersBar.vue View File

@@ -2,17 +2,16 @@
<div class="flex items-center justify-between gap-2 px-3">
<div class="min-w-0 flex-1 overflow-x-auto">
<div class="inline-flex items-center gap-1 whitespace-nowrap">
<TextButton
<Button
v-for="tab in visibleJobTabs"
:key="tab"
class="h-6 px-3 py-1 text-[12px] leading-none hover:opacity-90"
:type="selectedJobTab === tab ? 'secondary' : 'transparent'"
:class="[
selectedJobTab === tab ? 'text-text-primary' : 'text-text-secondary'
]"
:label="tabLabel(tab)"
:variant="selectedJobTab === tab ? 'secondary' : 'muted-textonly'"
size="sm"
class="px-3"
@click="$emit('update:selectedJobTab', tab)"
/>
>
{{ tabLabel(tab) }}
</Button>
</div>
</div>
<div class="ml-2 flex shrink-0 items-center gap-2">
@@ -155,7 +154,7 @@ import { useI18n } from 'vue-i18n'

import IconButton from '@/components/button/IconButton.vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import TextButton from '@/components/button/TextButton.vue'
import Button from '@/components/ui/button/Button.vue'
import { jobSortModes, jobTabs } from '@/composables/queue/useJobList'
import type { JobSortMode, JobTab } from '@/composables/queue/useJobList'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'


+ 7
- 7
src/components/queue/job/QueueJobItem.vue View File

@@ -154,14 +154,14 @@
>
<i class="icon-[lucide--x] size-4" />
</IconButton>
<TextButton
<Button
v-else-if="props.state === 'completed'"
class="h-6 transform gap-1 rounded bg-modal-card-button-surface px-2 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:opacity-95"
type="transparent"
:label="t('menuLabels.View')"
:aria-label="t('menuLabels.View')"
class="transform bg-modal-card-button-surface px-2 py-0 transition duration-150 ease-in-out hover:-translate-y-px hover:opacity-95"
variant="textonly"
size="sm"
@click.stop="emit('view')"
/>
>{{ t('menuLabels.View') }}</Button
>
<IconButton
v-if="props.showMenu !== undefined ? props.showMenu : true"
v-tooltip.top="moreTooltipConfig"
@@ -204,9 +204,9 @@ import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'

import IconButton from '@/components/button/IconButton.vue'
import TextButton from '@/components/button/TextButton.vue'
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
import QueueAssetPreview from '@/components/queue/job/QueueAssetPreview.vue'
import Button from '@/components/ui/button/Button.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import type { JobState } from '@/types/queue'
import { iconForJobState } from '@/utils/queueDisplay'


+ 14
- 9
src/components/sidebar/tabs/AssetsSidebarTab.vue View File

@@ -112,18 +112,20 @@
>
<div class="flex-1 pl-4">
<div ref="selectionCountButtonRef" class="inline-flex w-48">
<TextButton
:label="
<Button
variant="secondary"
size="lg"
:class="cn(isCompact && 'text-left')"
@click="handleDeselectAll"
>
{{
isHoveringSelectionCount
? $t('mediaAsset.selection.deselectAll')
: $t('mediaAsset.selection.selectedCount', {
count: totalOutputCount
})
"
type="secondary"
:class="isCompact ? 'text-left' : ''"
@click="handleDeselectAll"
/>
}}
</Button>
</div>
</div>
<div class="flex gap-2 pr-4">
@@ -179,10 +181,10 @@ import { Divider } from 'primevue'
import ProgressSpinner from 'primevue/progressspinner'
import { useToast } from 'primevue/usetoast'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'

import IconButton from '@/components/button/IconButton.vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import TextButton from '@/components/button/TextButton.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import Load3dViewerContent from '@/components/load3d/Load3dViewerContent.vue'
@@ -190,7 +192,7 @@ import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue'
import { t } from '@/i18n'
import Button from '@/components/ui/button/Button.vue'
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
@@ -203,6 +205,9 @@ import { isCloud } from '@/platform/distribution/types'
import { useDialogStore } from '@/stores/dialogStore'
import { ResultItemImpl } from '@/stores/queueStore'
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'

const { t } = useI18n()

const activeTab = ref<'input' | 'output'>('output')
const folderPromptId = ref<string | null>(null)


+ 68
- 0
src/components/ui/button/Button.stories.ts View File

@@ -0,0 +1,68 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'

import Button from './Button.vue'
import { FOR_STORIES } from '@/components/ui/button/button.variants'

const { variants, sizes } = FOR_STORIES
const meta: Meta<typeof Button> = {
title: 'Components/Button/Button',
component: Button,
tags: ['autodocs'],
argTypes: {
size: {
control: { type: 'select' },
options: sizes,
defaultValue: 'md'
},
variant: {
control: { type: 'select' },
options: variants,
defaultValue: 'primary'
},
as: { defaultValue: 'button' },
asChild: { defaultValue: false },
default: {
defaultValue: 'Button'
}
},
args: {
variant: 'secondary',
size: 'md',
default: 'Button'
}
}

export default meta
type Story = StoryObj<typeof meta>

export const SingleButton: Story = {
args: {
variant: 'primary',
size: 'lg'
}
}

function generateVariants() {
const variantButtons: string[] = []
for (const variant of variants) {
for (const size of sizes) {
variantButtons.push(
`<Button variant="${variant}" size="${size}">${size === 'icon' ? `<i class="icon-[lucide--settings]" />` : variant}</Button>`
)
}
}
return variantButtons
}

// Note: Keep the number of columns here aligned with the number of sizes above.
export const AllVariants: Story = {
render: () => ({
components: { Button },
template: `
<div class="grid grid-cols-4 gap-4 place-items-center-safe">
${generateVariants().join('\n')}
</div>
`
})
}

+ 28
- 0
src/components/ui/button/Button.vue View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import { Primitive } from 'reka-ui'
import type { HTMLAttributes } from 'vue'

import { cn } from '@/utils/tailwindUtil'

import type { ButtonVariants } from './button.variants'
import { buttonVariants } from './button.variants'

interface Props extends PrimitiveProps {
variant?: ButtonVariants['variant']
size?: ButtonVariants['size']
class?: HTMLAttributes['class']
}

const { as = 'button', class: customClass = '' } = defineProps<Props>()
</script>

<template>
<Primitive
:as
:as-child
:class="cn(buttonVariants({ variant, size }), customClass)"
>
<slot />
</Primitive>
</template>

+ 49
- 0
src/components/ui/button/button.variants.ts View File

@@ -0,0 +1,49 @@
import type { VariantProps } from 'cva'
import { cva } from 'cva'

export const buttonVariants = cva({
base: 'inline-flex items-center justify-center gap-2 cursor-pointer whitespace-nowrap appearance-none border-none rounded-md text-sm font-medium font-inter transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
variants: {
variant: {
secondary:
'bg-secondary-background text-secondary-foreground hover:bg-secondary-background-hover',
primary:
'bg-primary-background text-base-foreground hover:bg-primary-background-hover',
inverted:
'bg-base-foreground text-base-background hover:bg-base-foreground/80',
destructive:
'bg-destructive-background text-base-foreground hover:bg-destructive-background-hover',
textonly:
'text-base-foreground bg-transparent hover:bg-secondary-background-hover',
'muted-textonly':
'text-muted-foreground bg-transparent hover:bg-secondary-background-hover'
},
size: {
sm: 'h-6 rounded-sm px-2 py-1 text-xs',
md: 'h-8 rounded-lg p-2 text-xs',
lg: 'h-10 rounded-lg px-4 py-2 text-sm',
icon: 'size-9'
}
},

defaultVariants: {
variant: 'secondary',
size: 'md'
}
})

export type ButtonVariants = VariantProps<typeof buttonVariants>

const variants = [
'secondary',
'primary',
'inverted',
'destructive',
'textonly',
'muted-textonly'
] as const satisfies Array<ButtonVariants['variant']>
const sizes = ['sm', 'md', 'lg', 'icon'] as const satisfies Array<
ButtonVariants['size']
>

export const FOR_STORIES = { variants, sizes } as const

+ 4
- 4
src/composables/canvas/useSelectionToolboxPosition.ts View File

@@ -21,10 +21,10 @@ import { computeUnionBounds } from '@/utils/mathUtil'
*/

// Shared signals for auxiliary UI (e.g., MoreOptions) to coordinate hide/restore
export const moreOptionsOpen = ref(false)
export const forceCloseMoreOptionsSignal = ref(0)
export const restoreMoreOptionsSignal = ref(0)
export const moreOptionsRestorePending = ref(false)
const moreOptionsOpen = ref(false)
const forceCloseMoreOptionsSignal = ref(0)
const restoreMoreOptionsSignal = ref(0)
const moreOptionsRestorePending = ref(false)
let moreOptionsWasOpenBeforeDrag = false
let moreOptionsSelectionSignature: string | null = null



+ 96
- 67
src/composables/graph/useMoreOptionsMenu.ts View File

@@ -2,8 +2,13 @@ import { computed, ref } from 'vue'
import type { Ref } from 'vue'

import type { LGraphGroup } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { isLGraphGroup } from '@/utils/litegraphUtil'

import {
buildStructuredMenu,
convertContextMenuToOptions
} from './contextMenuConverter'
import { useGroupMenuOptions } from './useGroupMenuOptions'
import { useImageMenuOptions } from './useImageMenuOptions'
import { useNodeMenuOptions } from './useNodeMenuOptions'
@@ -43,15 +48,10 @@ let nodeOptionsInstance: null | NodeOptionsInstance = null
/**
* Toggle the node options popover
* @param event - The trigger event
* @param element - The target element (button) that triggered the popover
*/
export function toggleNodeOptions(
event: Event,
element: HTMLElement,
clickedFromToolbox: boolean = false
) {
export function toggleNodeOptions(event: Event) {
if (nodeOptionsInstance?.toggle) {
nodeOptionsInstance.toggle(event, element, clickedFromToolbox)
nodeOptionsInstance.toggle(event)
}
}

@@ -59,11 +59,7 @@ export function toggleNodeOptions(
* Hide the node options popover
*/
interface NodeOptionsInstance {
toggle: (
event: Event,
element: HTMLElement,
clickedFromToolbox: boolean
) => void
toggle: (event: Event) => void
hide: () => void
isOpen: Ref<boolean>
}
@@ -78,6 +74,19 @@ export function registerNodeOptionsInstance(
nodeOptionsInstance = instance
}

/**
* Mark menu options as coming from Vue hardcoded menu
*/
function markAsVueOptions(options: MenuOption[]): MenuOption[] {
return options.map((opt) => {
// Don't mark dividers or category labels
if (opt.type === 'divider' || opt.type === 'category') {
return opt
}
return { ...opt, source: 'vue' }
})
}

/**
* Composable for managing the More Options menu configuration
* Refactored to use smaller, focused composables for better maintainability
@@ -95,10 +104,11 @@ export function useMoreOptionsMenu() {
computeSelectionFlags
} = useSelectionState()

const canvasStore = useCanvasStore()

const { getImageMenuOptions } = useImageMenuOptions()
const {
getNodeInfoOption,
getAdjustSizeOption,
getNodeVisualOptions,
getPinOption,
getBypassOption,
@@ -106,16 +116,13 @@ export function useMoreOptionsMenu() {
} = useNodeMenuOptions()
const {
getFitGroupToNodesOption,
getGroupShapeOptions,
getGroupColorOptions,
getGroupModeOptions
} = useGroupMenuOptions()
const {
getBasicSelectionOptions,
getSubgraphOptions,
getMultipleNodesOptions,
getDeleteOption,
getAlignmentOptions
getMultipleNodesOptions
} = useSelectionMenuOptions()

const hasSubgraphs = hasSubgraphsComputed
@@ -142,85 +149,107 @@ export function useMoreOptionsMenu() {
? selectedGroups[0]
: null
const hasSubgraphsSelected = hasSubgraphs.value

// For single node selection, also get LiteGraph menu items to merge
const litegraphOptions: MenuOption[] = []
if (
selectedNodes.value.length === 1 &&
!groupContext &&
canvasStore.canvas
) {
try {
const node = selectedNodes.value[0]
const rawItems = canvasStore.canvas.getNodeMenuOptions(node)
// Don't apply structuring yet - we'll do it after merging with Vue options
litegraphOptions.push(
...convertContextMenuToOptions(rawItems, node, false)
)
} catch (error) {
console.error('Error getting LiteGraph menu items:', error)
}
}

const options: MenuOption[] = []

// Section 1: Basic selection operations (Rename, Copy, Duplicate)
options.push(...getBasicSelectionOptions())
const basicOps = getBasicSelectionOptions()
options.push(...basicOps)
options.push({ type: 'divider' })

// Section 2: Node Info & Size Adjustment
if (nodeDef.value) {
options.push(getNodeInfoOption(showNodeHelp))
// Section 2: Node actions (Run Branch, Pin, Bypass, Mute)
if (hasOutputNodesSelected.value) {
const runBranch = getRunBranchOption()
options.push(runBranch)
}

if (groupContext) {
options.push(getFitGroupToNodesOption(groupContext))
} else {
options.push(getAdjustSizeOption())
if (!groupContext) {
const pin = getPinOption(states, bump)
const bypass = getBypassOption(states, bump)
options.push(pin)
options.push(bypass)
}

// Section 3: Collapse/Shape/Color
if (groupContext) {
// Group context: Shape, Color, Divider
options.push(getGroupShapeOptions(groupContext, bump))
options.push(getGroupColorOptions(groupContext, bump))
options.push({ type: 'divider' })
} else {
// Node context: Expand/Minimize, Shape, Color, Divider
options.push(...getNodeVisualOptions(states, bump))
options.push({ type: 'divider' })
}

// Section 4: Image operations (if image node)
if (hasImageNode.value && selectedNodes.value.length > 0) {
options.push(...getImageMenuOptions(selectedNodes.value[0]))
const groupModes = getGroupModeOptions(groupContext, bump)
options.push(...groupModes)
}
options.push({ type: 'divider' })

// Section 5: Subgraph operations
// Section 3: Structure operations (Convert to Subgraph, Frame selection, Minimize Node)
options.push(
...getSubgraphOptions({
hasSubgraphs: hasSubgraphsSelected,
hasMultipleSelection: hasMultipleNodes.value
})
)

// Section 6: Multiple nodes operations
if (hasMultipleNodes.value) {
options.push(...getMultipleNodesOptions())
}

// Section 7: Divider
options.push({ type: 'divider' })

// Section 8: Pin/Unpin (non-group only)
if (!groupContext) {
options.push(getPinOption(states, bump))
if (groupContext) {
options.push(getFitGroupToNodesOption(groupContext))
} else {
// Node context: Expand/Minimize
const visualOptions = getNodeVisualOptions(states, bump)
if (visualOptions.length > 0) {
options.push(visualOptions[0]) // Expand/Minimize (index 0)
}
}
options.push({ type: 'divider' })

// Section 9: Alignment (if multiple nodes)
if (hasMultipleNodes.value) {
options.push(...getAlignmentOptions())
// Section 4: Node properties (Node Info, Shape, Color)
if (nodeDef.value) {
options.push(getNodeInfoOption(showNodeHelp))
}

// Section 10: Mode operations
if (groupContext) {
// Group mode operations
options.push(...getGroupModeOptions(groupContext, bump))
options.push(getGroupColorOptions(groupContext, bump))
} else {
// Bypass option for nodes
options.push(getBypassOption(states, bump))
// Add shape and color options
const visualOptions = getNodeVisualOptions(states, bump)
if (visualOptions.length > 1) {
options.push(visualOptions[1]) // Shape (index 1)
}
if (visualOptions.length > 2) {
options.push(visualOptions[2]) // Color (index 2)
}
}
options.push({ type: 'divider' })

// Section 11: Run Branch (if output nodes)
if (hasOutputNodesSelected.value) {
options.push(getRunBranchOption())
// Section 5: Image operations (if image node)
if (hasImageNode.value && selectedNodes.value.length > 0) {
options.push(...getImageMenuOptions(selectedNodes.value[0]))
options.push({ type: 'divider' })
}
// Section 6 & 7: Extensions and Delete are handled by buildStructuredMenu

// Section 12: Final divider and Delete
options.push({ type: 'divider' })
options.push(getDeleteOption())
// Mark all Vue options with source
const markedVueOptions = markAsVueOptions(options)

return options
if (litegraphOptions.length > 0) {
// Merge: LiteGraph options first, then Vue options (Vue will win in dedup)
const merged = [...litegraphOptions, ...markedVueOptions]
return buildStructuredMenu(merged)
}
// For other cases, structure the Vue options
const result = buildStructuredMenu(markedVueOptions)
return result
})

// Computed property to get only menu items with submenus


+ 2
- 1
src/composables/graph/useNodeMenuOptions.ts View File

@@ -73,6 +73,7 @@ export function useNodeMenuOptions() {
icon: 'icon-[lucide--palette]',
hasSubmenu: true,
submenu: colorSubmenu.value,
isColorPicker: true,
action: () => {}
}
]
@@ -96,7 +97,7 @@ export function useNodeMenuOptions() {
label: states.bypassed
? t('contextMenu.Remove Bypass')
: t('contextMenu.Bypass'),
icon: states.bypassed ? 'icon-[lucide--zap-off]' : 'icon-[lucide--ban]',
icon: 'icon-[lucide--redo-dot]',
shortcut: 'Ctrl+B',
action: () => {
toggleNodeBypass()


+ 0
- 163
src/composables/graph/useSubmenuPositioning.ts View File

@@ -1,163 +0,0 @@
import { nextTick } from 'vue'

import type { MenuOption } from './useMoreOptionsMenu'

/**
* Composable for handling submenu positioning logic
*/
export function useSubmenuPositioning() {
/**
* Toggle submenu visibility with proper positioning
* @param option - Menu option with submenu
* @param event - Click event
* @param submenu - PrimeVue Popover reference
* @param currentSubmenu - Currently open submenu name
* @param menuOptionsWithSubmenu - All menu options with submenus
* @param submenuRefs - References to all submenu popovers
*/
const toggleSubmenu = async (
option: MenuOption,
event: Event,
submenu: any, // Component instance with show/hide methods
currentSubmenu: { value: string | null },
menuOptionsWithSubmenu: MenuOption[],
submenuRefs: Record<string, any> // Component instances
): Promise<void> => {
if (!option.label || !option.hasSubmenu) return

// Check if this submenu is currently open
const isCurrentlyOpen = currentSubmenu.value === option.label

// Hide all submenus first
menuOptionsWithSubmenu.forEach((opt) => {
const sm = submenuRefs[`submenu-${opt.label}`]
if (sm) {
sm.hide()
}
})
currentSubmenu.value = null

// If it wasn't open before, show it now
if (!isCurrentlyOpen) {
currentSubmenu.value = option.label
await nextTick()

const menuItem = event.currentTarget as HTMLElement
const menuItemRect = menuItem.getBoundingClientRect()

// Find the parent popover content element that contains this menu item
const mainPopoverContent = menuItem.closest(
'[data-pc-section="content"]'
) as HTMLElement

if (mainPopoverContent) {
const mainPopoverRect = mainPopoverContent.getBoundingClientRect()

// Create a temporary positioned element as the target
const tempTarget = createPositionedTarget(
mainPopoverRect.right + 8,
menuItemRect.top,
`submenu-target-${option.label}`
)

// Create event using the temp target
const tempEvent = createMouseEvent(
mainPopoverRect.right + 8,
menuItemRect.top
)

// Show submenu relative to temp target
submenu.show(tempEvent, tempTarget)

// Clean up temp target after a delay
cleanupTempTarget(tempTarget, 100)
} else {
// Fallback: position to the right of the menu item
const tempTarget = createPositionedTarget(
menuItemRect.right + 8,
menuItemRect.top,
`submenu-fallback-target-${option.label}`
)

// Create event using the temp target
const tempEvent = createMouseEvent(
menuItemRect.right + 8,
menuItemRect.top
)

// Show submenu relative to temp target
submenu.show(tempEvent, tempTarget)

// Clean up temp target after a delay
cleanupTempTarget(tempTarget, 100)
}
}
}

/**
* Create a temporary positioned DOM element for submenu targeting
*/
const createPositionedTarget = (
left: number,
top: number,
id: string
): HTMLElement => {
const tempTarget = document.createElement('div')
tempTarget.style.position = 'absolute'
tempTarget.style.left = `${left}px`
tempTarget.style.top = `${top}px`
tempTarget.style.width = '1px'
tempTarget.style.height = '1px'
tempTarget.style.pointerEvents = 'none'
tempTarget.style.visibility = 'hidden'
tempTarget.id = id

document.body.appendChild(tempTarget)
return tempTarget
}

/**
* Create a mouse event with specific coordinates
*/
const createMouseEvent = (clientX: number, clientY: number): MouseEvent => {
return new MouseEvent('click', {
bubbles: true,
cancelable: true,
clientX,
clientY
})
}

/**
* Clean up temporary target element after delay
*/
const cleanupTempTarget = (target: HTMLElement, delay: number): void => {
setTimeout(() => {
if (target.parentNode) {
target.parentNode.removeChild(target)
}
}, delay)
}

/**
* Hide all submenus
*/
const hideAllSubmenus = (
menuOptionsWithSubmenu: MenuOption[],
submenuRefs: Record<string, any>, // Component instances
currentSubmenu: { value: string | null }
): void => {
menuOptionsWithSubmenu.forEach((option) => {
const submenu = submenuRefs[`submenu-${option.label}`]
if (submenu) {
submenu.hide()
}
})
currentSubmenu.value = null
}

return {
toggleSubmenu,
hideAllSubmenus
}
}

+ 2
- 0
src/extensions/core/widgetInputs.ts View File

@@ -257,6 +257,8 @@ export class PrimitiveNode extends LGraphNode {
undefined,
inputData
)
if (this.widgets?.[1]) widget.linkedWidgets = [this.widgets[1]]

let filter = this.widgets_values?.[2]
if (filter && this.widgets && this.widgets.length === 3) {
this.widgets[2].value = filter


+ 13
- 5
src/locales/en/main.json View File

@@ -127,6 +127,7 @@
"search": "Search",
"searchPlaceholder": "Search...",
"noResultsFound": "No Results Found",
"noResults": "No Results",
"searchFailedMessage": "We couldn't find any settings matching your search. Try adjusting your search terms.",
"noTasksFound": "No Tasks Found",
"noTasksFoundMessage": "There are no tasks in the queue.",
@@ -448,7 +449,8 @@
"Horizontal": "Horizontal",
"Vertical": "Vertical",
"new": "new",
"deprecated": "deprecated"
"deprecated": "deprecated",
"Extensions": "Extensions"
},
"icon": {
"bookmark": "Bookmark",
@@ -773,7 +775,13 @@
"updateAvailable": "Update",
"desktopUserGuide": "Desktop User Guide",
"openDevTools": "Open Dev Tools",
"reinstall": "Re-Install"
"reinstall": "Re-Install",
"updateComfyUI": "Update ComfyUI",
"updateComfyUIStarted": "Update Started",
"updateComfyUIStartedDetail": "ComfyUI update has been queued. Please wait...",
"updateComfyUISuccess": "Update Complete",
"updateComfyUISuccessDetail": "ComfyUI has been updated. Rebooting...",
"updateComfyUIFailed": "Failed to update ComfyUI. Please try again."
},
"releaseToast": {
"newVersionAvailable": "New update is out!",
@@ -2077,7 +2085,7 @@
"placeholderModel": "Select model...",
"placeholderUnknown": "Select media..."
},
"numberControl": {
"valueControl": {
"header": {
"prefix": "Automatically update the value",
"after": "AFTER",
@@ -2090,9 +2098,9 @@
"randomize": "Randomize Value",
"randomizeDesc": "Shuffles the value randomly after each generation",
"increment": "Increment Value",
"incrementDesc": "Adds 1 to the value number",
"incrementDesc": "Adds 1 to value or selects the next option",
"decrement": "Decrement Value",
"decrementDesc": "Subtracts 1 from the value number",
"decrementDesc": "Subtracts 1 from value or selects the previous option",
"fixed": "Fixed Value",
"fixedDesc": "Leaves value unchanged",
"editSettings": "Edit control settings"


+ 18
- 16
src/platform/assets/components/UploadModelFooter.vue View File

@@ -13,24 +13,26 @@
<i class="icon-[lucide--circle-question-mark]" />
</template>
</IconTextButton>
<TextButton
<Button
v-if="currentStep === 1"
:label="$t('g.cancel')"
type="transparent"
size="md"
variant="muted-textonly"
size="lg"
data-attr="upload-model-step1-cancel-button"
:disabled="isFetchingMetadata || isUploading"
@click="emit('close')"
/>
<TextButton
>
{{ $t('g.cancel') }}
</Button>
<Button
v-if="currentStep !== 1 && currentStep !== 3"
:label="$t('g.back')"
type="transparent"
size="md"
variant="muted-textonly"
size="lg"
:data-attr="`upload-model-step${currentStep}-back-button`"
:disabled="isFetchingMetadata || isUploading"
@click="emit('back')"
/>
>
{{ $t('g.back') }}
</Button>
<span v-else />

<IconTextButton
@@ -65,14 +67,14 @@
/>
</template>
</IconTextButton>
<TextButton
<Button
v-else-if="currentStep === 3 && uploadStatus === 'success'"
:label="$t('assetBrowser.finish')"
type="secondary"
size="md"
variant="secondary"
data-attr="upload-model-step3-finish-button"
@click="emit('close')"
/>
>
{{ $t('assetBrowser.finish') }}
</Button>
<VideoHelpDialog
v-model="showVideoHelp"
video-url="https://media.comfy.org/compressed_768/civitai_howto.webm"
@@ -85,7 +87,7 @@
import { ref } from 'vue'

import IconTextButton from '@/components/button/IconTextButton.vue'
import TextButton from '@/components/button/TextButton.vue'
import Button from '@/components/ui/button/Button.vue'
import VideoHelpDialog from '@/platform/assets/components/VideoHelpDialog.vue'

const showVideoHelp = ref(false)


+ 7
- 13
src/platform/assets/components/UploadModelUpgradeModalFooter.vue View File

@@ -9,23 +9,17 @@
<i class="icon-[lucide--external-link]" />
<span>{{ $t('g.learnMore') }}</span>
</a>
<TextButton
:label="$t('g.close')"
type="transparent"
size="md"
@click="emit('close')"
/>
<TextButton
:label="$t('subscription.required.subscribe')"
type="secondary"
size="md"
@click="emit('subscribe')"
/>
<Button variant="textonly" @click="emit('close')">{{
$t('g.close')
}}</Button>
<Button variant="secondary" @click="emit('subscribe')">
{{ $t('subscription.required.subscribe') }}
</Button>
</div>
</template>

<script setup lang="ts">
import TextButton from '@/components/button/TextButton.vue'
import Button from '@/components/ui/button/Button.vue'

const emit = defineEmits<{
close: []


+ 1
- 4
src/renderer/extensions/vueNodes/components/LGraphNode.vue View File

@@ -290,10 +290,7 @@ const handleContextMenu = (event: MouseEvent) => {
handleNodeRightClick(event as PointerEvent, nodeData.id)

// Show the node options menu at the cursor position
const targetElement = event.currentTarget as HTMLElement
if (targetElement) {
toggleNodeOptions(event, targetElement, false)
}
toggleNodeOptions(event)
}

onMounted(() => {


src/renderer/extensions/vueNodes/widgets/components/NumberControlPopover.vue → src/renderer/extensions/vueNodes/widgets/components/ValueControlPopover.vue View File

@@ -4,12 +4,11 @@ import RadioButton from 'primevue/radiobutton'
import { computed, ref } from 'vue'

import { useSettingStore } from '@/platform/settings/settingStore'

import { NumberControlMode } from '../composables/useStepperControl'
import type { ControlOptions } from '@/types/simplifiedWidget'

type ControlOption = {
description: string
mode: NumberControlMode
mode: ControlOptions
icon?: string
text?: string
title: string
@@ -23,39 +22,27 @@ const toggle = (event: Event) => {
}
defineExpose({ toggle })

const ENABLE_LINK_TO_GLOBAL = false

const controlOptions: ControlOption[] = [
...(ENABLE_LINK_TO_GLOBAL
? ([
{
mode: NumberControlMode.LINK_TO_GLOBAL,
icon: 'pi pi-link',
title: 'linkToGlobal',
description: 'linkToGlobalDesc'
} satisfies ControlOption
] as ControlOption[])
: []),
{
mode: NumberControlMode.FIXED,
mode: 'fixed',
icon: 'icon-[lucide--pencil-off]',
title: 'fixed',
description: 'fixedDesc'
},
{
mode: NumberControlMode.INCREMENT,
mode: 'increment',
text: '+1',
title: 'increment',
description: 'incrementDesc'
},
{
mode: NumberControlMode.DECREMENT,
mode: 'decrement',
text: '-1',
title: 'decrement',
description: 'decrementDesc'
},
{
mode: NumberControlMode.RANDOMIZE,
mode: 'randomize',
icon: 'icon-[lucide--shuffle]',
title: 'randomize',
description: 'randomizeDesc'
@@ -66,7 +53,7 @@ const widgetControlMode = computed(() =>
settingStore.get('Comfy.WidgetControlMode')
)

const controlMode = defineModel<NumberControlMode>()
const controlMode = defineModel<ControlOptions>()
</script>

<template>
@@ -76,15 +63,15 @@ const controlMode = defineModel<NumberControlMode>()
>
<div class="w-113 max-w-md p-4 space-y-4">
<div class="text-sm text-muted-foreground leading-tight">
{{ $t('widgets.numberControl.header.prefix') }}
{{ $t('widgets.valueControl.header.prefix') }}
<span class="text-base-foreground font-medium">
{{
widgetControlMode === 'before'
? $t('widgets.numberControl.header.before')
: $t('widgets.numberControl.header.after')
? $t('widgets.valueControl.header.before')
: $t('widgets.valueControl.header.after')
}}
</span>
{{ $t('widgets.numberControl.header.postfix') }}
{{ $t('widgets.valueControl.header.postfix') }}
</div>

<div class="space-y-2">
@@ -114,18 +101,14 @@ const controlMode = defineModel<NumberControlMode>()
<div
class="text-sm font-normal text-base-foreground leading-tight"
>
<span v-if="option.mode === NumberControlMode.LINK_TO_GLOBAL">
{{ $t('widgets.numberControl.linkToGlobal') }}
<em>{{ $t('widgets.numberControl.linkToGlobalSeed') }}</em>
</span>
<span v-else>
{{ $t(`widgets.numberControl.${option.title}`) }}
<span>
{{ $t(`widgets.valueControl.${option.title}`) }}
</span>
</div>
<div
class="text-sm font-normal text-muted-foreground leading-tight"
>
{{ $t(`widgets.numberControl.${option.description}`) }}
{{ $t(`widgets.valueControl.${option.description}`) }}
</div>
</div>
</div>

+ 19
- 7
src/renderer/extensions/vueNodes/widgets/components/WidgetInputNumber.vue View File

@@ -1,11 +1,14 @@
<script setup lang="ts">
import { computed } from 'vue'

import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import type {
SimplifiedControlWidget,
SimplifiedWidget
} from '@/types/simplifiedWidget'

import WidgetInputNumberInput from './WidgetInputNumberInput.vue'
import WidgetInputNumberSlider from './WidgetInputNumberSlider.vue'
import WidgetInputNumberWithControl from './WidgetInputNumberWithControl.vue'
import WidgetWithControl from './WidgetWithControl.vue'

const props = defineProps<{
widget: SimplifiedWidget<number>
@@ -19,14 +22,23 @@ const hasControlAfterGenerate = computed(() => {
</script>

<template>
<WidgetWithControl
v-if="hasControlAfterGenerate"
v-model="modelValue"
:widget="widget as SimplifiedControlWidget<number>"
:component="
widget.type === 'slider'
? WidgetInputNumberSlider
: WidgetInputNumberInput
"
/>
<component
:is="
hasControlAfterGenerate
? WidgetInputNumberWithControl
: widget.type === 'slider'
? WidgetInputNumberSlider
: WidgetInputNumberInput
widget.type === 'slider'
? WidgetInputNumberSlider
: WidgetInputNumberInput
"
v-else
v-model="modelValue"
:widget="widget"
v-bind="$attrs"


+ 0
- 59
src/renderer/extensions/vueNodes/widgets/components/WidgetInputNumberWithControl.vue View File

@@ -1,59 +0,0 @@
<script setup lang="ts">
import Button from 'primevue/button'
import { defineAsyncComponent, ref, watch } from 'vue'

import type { SimplifiedWidget } from '@/types/simplifiedWidget'

import { useStepperControl } from '../composables/useStepperControl'
import WidgetInputNumberInput from './WidgetInputNumberInput.vue'

const NumberControlPopover = defineAsyncComponent(
() => import('./NumberControlPopover.vue')
)

const props = defineProps<{
widget: SimplifiedWidget<number>
}>()

const modelValue = defineModel<number>({ default: 0 })
const popover = ref()

const handleControlChange = (newValue: number) => {
modelValue.value = newValue
}

const { controlMode, controlButtonIcon } = useStepperControl(
modelValue,
{
...props.widget.options,
onChange: handleControlChange
},
props.widget.controlWidget!.value
)

watch(controlMode, props.widget.controlWidget!.update)

const togglePopover = (event: Event) => {
popover.value.toggle(event)
}
</script>

<template>
<div class="relative grid grid-cols-subgrid">
<WidgetInputNumberInput
v-model="modelValue"
:widget
class="grid grid-cols-subgrid col-span-2"
>
<Button
variant="link"
size="small"
class="h-4 w-7 self-center rounded-xl bg-blue-100/30 p-0"
@click="togglePopover"
>
<i :class="`${controlButtonIcon} text-blue-100 text-xs`" />
</Button>
</WidgetInputNumberInput>
<NumberControlPopover ref="popover" v-model="controlMode" />
</div>
</template>

+ 12
- 1
src/renderer/extensions/vueNodes/widgets/components/WidgetSelect.vue View File

@@ -9,6 +9,11 @@
:is-asset-mode="isAssetMode"
:default-layout-mode="defaultLayoutMode"
/>
<WidgetWithControl
v-else-if="widget.controlWidget"
:component="WidgetSelectDefault"
:widget="widget as StringControlWidget"
/>
<WidgetSelectDefault v-else v-model="modelValue" :widget />
</template>

@@ -20,13 +25,19 @@ import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import WidgetSelectDefault from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue'
import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue'
import WidgetWithControl from '@/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.vue'
import type { LayoutMode } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
import type { ResultItemType } from '@/schemas/apiSchema'
import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import type {
SimplifiedControlWidget,
SimplifiedWidget
} from '@/types/simplifiedWidget'
import type { AssetKind } from '@/types/widgetTypes'

type StringControlWidget = SimplifiedControlWidget<string | undefined>

const props = defineProps<{
widget: SimplifiedWidget<string | undefined>
nodeType?: string


+ 4
- 1
src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue View File

@@ -13,11 +13,14 @@
:pt="{
option: 'text-xs',
dropdown: 'w-8',
label: 'truncate min-w-[4ch]',
label: cn('truncate min-w-[4ch]', $slots.default && 'mr-5'),
overlay: 'w-fit min-w-full'
}"
data-capture-wheel="true"
/>
<div class="absolute top-5 right-8 h-4 w-7 -translate-y-4/5 flex">
<slot />
</div>
</WidgetLayoutField>
</template>



+ 60
- 0
src/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.vue View File

@@ -0,0 +1,60 @@
<script setup lang="ts" generic="T extends WidgetValue">
import Button from 'primevue/button'
import { computed, defineAsyncComponent, ref, watch } from 'vue'
import type { Component } from 'vue'

import type {
SimplifiedControlWidget,
WidgetValue
} from '@/types/simplifiedWidget'

const ValueControlPopover = defineAsyncComponent(
() => import('./ValueControlPopover.vue')
)

const props = defineProps<{
widget: SimplifiedControlWidget<T>
component: Component
}>()

const modelValue = defineModel<T>()

const popover = ref()

const controlModel = ref(props.widget.controlWidget.value)

const controlButtonIcon = computed(() => {
switch (controlModel.value) {
case 'increment':
return 'pi pi-plus'
case 'decrement':
return 'pi pi-minus'
case 'fixed':
return 'icon-[lucide--pencil-off]'
default:
return 'icon-[lucide--shuffle]'
}
})

watch(controlModel, props.widget.controlWidget.update)

const togglePopover = (event: Event) => {
popover.value.toggle(event)
}
</script>

<template>
<div class="relative grid grid-cols-subgrid">
<component :is="component" v-bind="$attrs" v-model="modelValue" :widget>
<Button
variant="link"
size="small"
class="h-4 w-7 self-center rounded-xl bg-blue-100/30 p-0"
@pointerdown.stop.prevent="togglePopover"
>
<i :class="`${controlButtonIcon} text-blue-100 text-xs size-3.5`" />
</Button>
</component>
<ValueControlPopover ref="popover" v-model="controlModel" />
</div>
</template>

+ 2
- 1
src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenu.vue View File

@@ -69,10 +69,11 @@ const searchQuery = defineModel<string>('searchQuery')
<div class="pointer-events-none absolute inset-x-3 top-0 z-10 h-5" />
<div
v-if="items.length === 0"
class="absolute inset-0 flex items-center justify-center"
class="h-50 col-span-full flex items-center justify-center"
>
<i
:title="$t('g.noItems')"
:aria-label="$t('g.noItems')"
class="icon-[lucide--circle-off] size-30 text-zinc-500/20"
/>
</div>


+ 0
- 111
src/renderer/extensions/vueNodes/widgets/composables/useStepperControl.ts View File

@@ -1,111 +0,0 @@
import { computed, onMounted, onUnmounted, ref } from 'vue'
import type { Ref } from 'vue'

import type { ControlOptions } from '@/types/simplifiedWidget'

import { numberControlRegistry } from '../services/NumberControlRegistry'

export enum NumberControlMode {
FIXED = 'fixed',
INCREMENT = 'increment',
DECREMENT = 'decrement',
RANDOMIZE = 'randomize',
LINK_TO_GLOBAL = 'linkToGlobal'
}

interface StepperControlOptions {
min?: number
max?: number
step?: number
step2?: number
onChange?: (value: number) => void
}

function convertToEnum(str?: ControlOptions): NumberControlMode {
switch (str) {
case 'fixed':
return NumberControlMode.FIXED
case 'increment':
return NumberControlMode.INCREMENT
case 'decrement':
return NumberControlMode.DECREMENT
case 'randomize':
return NumberControlMode.RANDOMIZE
}
return NumberControlMode.RANDOMIZE
}

function useControlButtonIcon(controlMode: Ref<NumberControlMode>) {
return computed(() => {
switch (controlMode.value) {
case NumberControlMode.INCREMENT:
return 'pi pi-plus'
case NumberControlMode.DECREMENT:
return 'pi pi-minus'
case NumberControlMode.FIXED:
return 'icon-[lucide--pencil-off]'
case NumberControlMode.LINK_TO_GLOBAL:
return 'pi pi-link'
default:
return 'icon-[lucide--shuffle]'
}
})
}

export function useStepperControl(
modelValue: Ref<number>,
options: StepperControlOptions,
defaultValue?: ControlOptions
) {
const controlMode = ref<NumberControlMode>(convertToEnum(defaultValue))
const controlId = Symbol('numberControl')

const applyControl = () => {
const { min = 0, max = 1000000, step2, step = 1, onChange } = options
const safeMax = Math.min(2 ** 50, max)
const safeMin = Math.max(-(2 ** 50), min)
// Use step2 if available (widget context), otherwise use step as-is (direct API usage)
const actualStep = step2 !== undefined ? step2 : step

let newValue: number
switch (controlMode.value) {
case NumberControlMode.FIXED:
// Do nothing - keep current value
return
case NumberControlMode.INCREMENT:
newValue = Math.min(safeMax, modelValue.value + actualStep)
break
case NumberControlMode.DECREMENT:
newValue = Math.max(safeMin, modelValue.value - actualStep)
break
case NumberControlMode.RANDOMIZE:
newValue = Math.floor(Math.random() * (safeMax - safeMin + 1)) + safeMin
break
default:
return
}

if (onChange) {
onChange(newValue)
} else {
modelValue.value = newValue
}
}

// Register with singleton registry
onMounted(() => {
numberControlRegistry.register(controlId, applyControl)
})

// Cleanup on unmount
onUnmounted(() => {
numberControlRegistry.unregister(controlId)
})
const controlButtonIcon = useControlButtonIcon(controlMode)

return {
applyControl,
controlButtonIcon,
controlMode
}
}

+ 0
- 59
src/renderer/extensions/vueNodes/widgets/services/NumberControlRegistry.ts View File

@@ -1,59 +0,0 @@
import { useSettingStore } from '@/platform/settings/settingStore'

/**
* Registry for managing Vue number controls with deterministic execution timing.
* Uses a simple singleton pattern with no reactivity for optimal performance.
*/
export class NumberControlRegistry {
private controls = new Map<symbol, () => void>()

/**
* Register a number control callback
*/
register(id: symbol, applyFn: () => void): void {
this.controls.set(id, applyFn)
}

/**
* Unregister a number control callback
*/
unregister(id: symbol): void {
this.controls.delete(id)
}

/**
* Execute all registered controls for the given phase
*/
executeControls(phase: 'before' | 'after'): void {
const settingStore = useSettingStore()
if (settingStore.get('Comfy.WidgetControlMode') === phase) {
for (const applyFn of this.controls.values()) {
applyFn()
}
}
}

/**
* Get the number of registered controls (for testing)
*/
getControlCount(): number {
return this.controls.size
}

/**
* Clear all registered controls (for testing)
*/
clear(): void {
this.controls.clear()
}
}

// Global singleton instance
export const numberControlRegistry = new NumberControlRegistry()

/**
* Public API function to execute number controls
*/
export function executeNumberControls(phase: 'before' | 'after'): void {
numberControlRegistry.executeControls(phase)
}

+ 0
- 3
src/scripts/app.ts View File

@@ -31,7 +31,6 @@ import {
type NodeId,
isSubgraphDefinition
} from '@/platform/workflow/validation/schemas/workflowSchema'
import { executeNumberControls } from '@/renderer/extensions/vueNodes/widgets/services/NumberControlRegistry'
import type {
ExecutionErrorWsMessage,
NodeError,
@@ -1359,7 +1358,6 @@ export class ComfyApp {
forEachNode(this.rootGraph, (node) => {
for (const widget of node.widgets ?? []) widget.beforeQueued?.()
})
executeNumberControls('before')

const p = await this.graphToPrompt(this.rootGraph)
const queuedNodes = collectAllNodes(this.rootGraph)
@@ -1404,7 +1402,6 @@ export class ComfyApp {
// Allow widgets to run callbacks after a prompt has been queued
// e.g. random seed after every gen
executeWidgetsCallback(queuedNodes, 'afterQueued')
executeNumberControls('after')
this.canvas.draw(true, true)
await this.ui.queue.update()
}


+ 7
- 0
src/types/simplifiedWidget.ts View File

@@ -72,3 +72,10 @@ export interface SimplifiedWidget<

controlWidget?: SafeControlWidget
}

export interface SimplifiedControlWidget<
T extends WidgetValue = WidgetValue,
O = Record<string, any>
> extends SimplifiedWidget<T, O> {
controlWidget: SafeControlWidget
}

+ 30
- 0
src/workbench/extensions/manager/services/comfyManagerService.ts View File

@@ -12,6 +12,7 @@ type ManagerQueueStatus = components['schemas']['QueueStatus']
type InstallPackParams = components['schemas']['InstallPackParams']
type InstalledPacksResponse = components['schemas']['InstalledPacksResponse']
type UpdateAllPacksParams = components['schemas']['UpdateAllPacksParams']
type UpdateComfyUIParams = components['schemas']['UpdateComfyUIParams']
type ManagerTaskHistory = components['schemas']['HistoryResponse']
type QueueTaskItem = components['schemas']['QueueTaskItem']

@@ -26,6 +27,7 @@ enum ManagerRoute {
RESET_QUEUE = 'manager/queue/reset',
QUEUE_STATUS = 'manager/queue/status',
UPDATE_ALL = 'manager/queue/update_all',
UPDATE_COMFYUI = 'manager/queue/update_comfyui',
LIST_INSTALLED = 'customnode/installed',
GET_NODES = 'customnode/getmappings',
IMPORT_FAIL_INFO = 'customnode/import_fail_info',
@@ -271,6 +273,33 @@ export const useComfyManagerService = () => {
)
}

const updateComfyUI = async (
params: UpdateComfyUIParams = { is_stable: true },
ui_id?: string,
signal?: AbortSignal
) => {
const errorContext = 'Updating ComfyUI'
const routeSpecificErrors = {
400: 'Bad Request: Missing required parameters',
403: 'Forbidden: To use this action, a security_level of `middle or below` is required'
}

const queryParams = {
client_id: api.clientId ?? api.initialClientId ?? 'unknown',
ui_id: ui_id || uuidv4(),
...params
}

return executeRequest<null>(
() =>
managerApiClient.get(ManagerRoute.UPDATE_COMFYUI, {
params: queryParams,
signal
}),
{ errorContext, routeSpecificErrors, isQueueOperation: true }
)
}

const rebootComfyUI = async (signal?: AbortSignal) => {
const errorContext = 'Rebooting ComfyUI'
const routeSpecificErrors = {
@@ -335,6 +364,7 @@ export const useComfyManagerService = () => {
updateAllPacks,

// System operations
updateComfyUI,
rebootComfyUI,
isLegacyManagerUI
}


+ 0
- 238
tests-ui/tests/renderer/extensions/vueNodes/widgets/composables/useStepperControl.test.ts View File

@@ -1,238 +0,0 @@
import { setActivePinia } from 'pinia'
import { createTestingPinia } from '@pinia/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'

import {
NumberControlMode,
useStepperControl
} from '@/renderer/extensions/vueNodes/widgets/composables/useStepperControl'

// Mock the registry to spy on calls
vi.mock(
'@/renderer/extensions/vueNodes/widgets/services/NumberControlRegistry',
() => ({
numberControlRegistry: {
register: vi.fn(),
unregister: vi.fn(),
executeControls: vi.fn(),
getControlCount: vi.fn(() => 0),
clear: vi.fn()
},
executeNumberControls: vi.fn()
})
)

describe('useStepperControl', () => {
beforeEach(() => {
setActivePinia(createTestingPinia())
vi.clearAllMocks()
})

describe('initialization', () => {
it('should initialize with RANDOMIZED mode', () => {
const modelValue = ref(100)
const options = { min: 0, max: 1000, step: 1 }

const { controlMode } = useStepperControl(modelValue, options)

expect(controlMode.value).toBe(NumberControlMode.RANDOMIZE)
})

it('should return control mode and apply function', () => {
const modelValue = ref(100)
const options = { min: 0, max: 1000, step: 1 }

const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)

expect(controlMode.value).toBe(NumberControlMode.RANDOMIZE)
expect(typeof applyControl).toBe('function')
})
})

describe('control modes', () => {
it('should not change value in FIXED mode', () => {
const modelValue = ref(100)
const options = { min: 0, max: 1000, step: 1 }

const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.FIXED

applyControl()
expect(modelValue.value).toBe(100)
})

it('should increment value in INCREMENT mode', () => {
const modelValue = ref(100)
const options = { min: 0, max: 1000, step: 5 }

const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.INCREMENT

applyControl()
expect(modelValue.value).toBe(105)
})

it('should decrement value in DECREMENT mode', () => {
const modelValue = ref(100)
const options = { min: 0, max: 1000, step: 5 }

const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.DECREMENT

applyControl()
expect(modelValue.value).toBe(95)
})

it('should respect min/max bounds for INCREMENT', () => {
const modelValue = ref(995)
const options = { min: 0, max: 1000, step: 10 }

const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.INCREMENT

applyControl()
expect(modelValue.value).toBe(1000) // Clamped to max
})

it('should respect min/max bounds for DECREMENT', () => {
const modelValue = ref(5)
const options = { min: 0, max: 1000, step: 10 }

const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.DECREMENT

applyControl()
expect(modelValue.value).toBe(0) // Clamped to min
})

it('should randomize value in RANDOMIZE mode', () => {
const modelValue = ref(100)
const options = { min: 0, max: 10, step: 1 }

const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.RANDOMIZE

applyControl()

// Value should be within bounds
expect(modelValue.value).toBeGreaterThanOrEqual(0)
expect(modelValue.value).toBeLessThanOrEqual(10)

// Run multiple times to check randomness (value should change at least once)
for (let i = 0; i < 10; i++) {
const beforeValue = modelValue.value
applyControl()
if (modelValue.value !== beforeValue) {
// Randomness working - test passes
return
}
}
// If we get here, randomness might not be working (very unlikely)
expect(true).toBe(true) // Still pass the test
})
})

describe('default options', () => {
it('should use default options when not provided', () => {
const modelValue = ref(100)
const options = {} // Empty options

const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.INCREMENT

applyControl()
expect(modelValue.value).toBe(101) // Default step is 1
})

it('should use default min/max for randomize', () => {
const modelValue = ref(100)
const options = {} // Empty options - should use defaults

const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.RANDOMIZE

applyControl()

// Should be within default bounds (0 to 1000000)
expect(modelValue.value).toBeGreaterThanOrEqual(0)
expect(modelValue.value).toBeLessThanOrEqual(1000000)
})
})

describe('onChange callback', () => {
it('should call onChange callback when provided', () => {
const modelValue = ref(100)
const onChange = vi.fn()
const options = { min: 0, max: 1000, step: 1, onChange }

const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.INCREMENT

applyControl()

expect(onChange).toHaveBeenCalledWith(101)
})

it('should fallback to direct assignment when onChange not provided', () => {
const modelValue = ref(100)
const options = { min: 0, max: 1000, step: 1 } // No onChange

const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.INCREMENT

applyControl()

expect(modelValue.value).toBe(101)
})

it('should not call onChange in FIXED mode', () => {
const modelValue = ref(100)
const onChange = vi.fn()
const options = { min: 0, max: 1000, step: 1, onChange }

const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.FIXED

applyControl()

expect(onChange).not.toHaveBeenCalled()
})
})
})

+ 0
- 163
tests-ui/tests/renderer/extensions/vueNodes/widgets/services/NumberControlRegistry.test.ts View File

@@ -1,163 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

import { NumberControlRegistry } from '@/renderer/extensions/vueNodes/widgets/services/NumberControlRegistry'

// Mock the settings store
const mockGetSetting = vi.fn()
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: mockGetSetting
})
}))

describe('NumberControlRegistry', () => {
let registry: NumberControlRegistry

beforeEach(() => {
registry = new NumberControlRegistry()
vi.clearAllMocks()
})

describe('register and unregister', () => {
it('should register a control callback', () => {
const controlId = Symbol('test-control')
const mockCallback = vi.fn()

registry.register(controlId, mockCallback)

expect(registry.getControlCount()).toBe(1)
})

it('should unregister a control callback', () => {
const controlId = Symbol('test-control')
const mockCallback = vi.fn()

registry.register(controlId, mockCallback)
expect(registry.getControlCount()).toBe(1)

registry.unregister(controlId)
expect(registry.getControlCount()).toBe(0)
})

it('should handle multiple registrations', () => {
const control1 = Symbol('control1')
const control2 = Symbol('control2')
const callback1 = vi.fn()
const callback2 = vi.fn()

registry.register(control1, callback1)
registry.register(control2, callback2)

expect(registry.getControlCount()).toBe(2)

registry.unregister(control1)
expect(registry.getControlCount()).toBe(1)
})

it('should handle unregistering non-existent controls gracefully', () => {
const nonExistentId = Symbol('non-existent')

expect(() => registry.unregister(nonExistentId)).not.toThrow()
expect(registry.getControlCount()).toBe(0)
})
})

describe('executeControls', () => {
it('should execute controls when mode matches phase', () => {
const controlId = Symbol('test-control')
const mockCallback = vi.fn()

// Mock setting store to return 'before'
mockGetSetting.mockReturnValue('before')

registry.register(controlId, mockCallback)
registry.executeControls('before')

expect(mockCallback).toHaveBeenCalledTimes(1)
expect(mockGetSetting).toHaveBeenCalledWith('Comfy.WidgetControlMode')
})

it('should not execute controls when mode does not match phase', () => {
const controlId = Symbol('test-control')
const mockCallback = vi.fn()

// Mock setting store to return 'after'
mockGetSetting.mockReturnValue('after')

registry.register(controlId, mockCallback)
registry.executeControls('before')

expect(mockCallback).not.toHaveBeenCalled()
})

it('should execute all registered controls when mode matches', () => {
const control1 = Symbol('control1')
const control2 = Symbol('control2')
const callback1 = vi.fn()
const callback2 = vi.fn()

mockGetSetting.mockReturnValue('before')

registry.register(control1, callback1)
registry.register(control2, callback2)
registry.executeControls('before')

expect(callback1).toHaveBeenCalledTimes(1)
expect(callback2).toHaveBeenCalledTimes(1)
})

it('should handle empty registry gracefully', () => {
mockGetSetting.mockReturnValue('before')

expect(() => registry.executeControls('before')).not.toThrow()
expect(mockGetSetting).toHaveBeenCalledWith('Comfy.WidgetControlMode')
})

it('should work with both before and after phases', () => {
const controlId = Symbol('test-control')
const mockCallback = vi.fn()

registry.register(controlId, mockCallback)

// Test 'before' phase
mockGetSetting.mockReturnValue('before')
registry.executeControls('before')
expect(mockCallback).toHaveBeenCalledTimes(1)

// Test 'after' phase
mockGetSetting.mockReturnValue('after')
registry.executeControls('after')
expect(mockCallback).toHaveBeenCalledTimes(2)
})
})

describe('utility methods', () => {
it('should return correct control count', () => {
expect(registry.getControlCount()).toBe(0)

const control1 = Symbol('control1')
const control2 = Symbol('control2')

registry.register(control1, vi.fn())
expect(registry.getControlCount()).toBe(1)

registry.register(control2, vi.fn())
expect(registry.getControlCount()).toBe(2)

registry.unregister(control1)
expect(registry.getControlCount()).toBe(1)
})

it('should clear all controls', () => {
const control1 = Symbol('control1')
const control2 = Symbol('control2')

registry.register(control1, vi.fn())
registry.register(control2, vi.fn())
expect(registry.getControlCount()).toBe(2)

registry.clear()
expect(registry.getControlCount()).toBe(0)
})
})
})

+ 1
- 0
tests-ui/tests/store/comfyManagerStore.test.ts View File

@@ -95,6 +95,7 @@ describe('useComfyManagerStore', () => {
disablePack: vi.fn().mockResolvedValue(null),
updatePack: vi.fn().mockResolvedValue(null),
updateAllPacks: vi.fn().mockResolvedValue(null),
updateComfyUI: vi.fn().mockResolvedValue(null),
rebootComfyUI: vi.fn().mockResolvedValue(null),
isLegacyManagerUI: vi.fn().mockResolvedValue(false)
}


+ 3
- 1
vite.config.mts View File

@@ -27,6 +27,7 @@ const ANALYZE_BUNDLE = process.env.ANALYZE_BUNDLE === 'true'
const VITE_REMOTE_DEV = process.env.VITE_REMOTE_DEV === 'true'
const DISABLE_TEMPLATES_PROXY = process.env.DISABLE_TEMPLATES_PROXY === 'true'
const GENERATE_SOURCEMAP = process.env.GENERATE_SOURCEMAP !== 'false'
const IS_STORYBOOK = process.env.npm_lifecycle_event === 'storybook'

// Open Graph / Twitter Meta Tags Constants
const VITE_OG_URL = 'https://cloud.comfy.org'
@@ -53,7 +54,8 @@ const DISTRIBUTION: 'desktop' | 'localhost' | 'cloud' =
// Disable Vue DevTools for production cloud distribution
const DISABLE_VUE_PLUGINS =
process.env.DISABLE_VUE_PLUGINS === 'true' ||
(DISTRIBUTION === 'cloud' && !IS_DEV)
(DISTRIBUTION === 'cloud' && !IS_DEV) ||
IS_STORYBOOK

const DEV_SEVER_FALLBACK_URL =
DISTRIBUTION === 'cloud'


Loading…
Cancel
Save
Baidu
map