Compare commits

...

29 Commits

Author SHA1 Message Date
Comfy Org PR Bot
71d6c9ff94 [backport cloud/1.32] mark vue nodes menu toggle with beta tag (#7050)
Backport of #7047 to `cloud/1.32`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7050-backport-cloud-1-32-mark-vue-nodes-menu-toggle-with-beta-tag-2bb6d73d365081658342f18759c69a32)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-11-29 19:14:13 -07:00
Christian Byrne
b3d6a49328 [backport cloud/1.32] fix: Vue Node <-> Litegraph node height offset normalization (#6977)
## Summary
Backport of #6966 onto cloud/1.32.

- cherry-picked 29dbfa3f
- resolved snapshot conflicts by taking the updated expectations from
the upstream commit

## Testing
- pnpm typecheck

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6977-backport-cloud-1-32-fix-Vue-Node-Litegraph-node-height-offset-normalization-2b86d73d36508119a3e8db7b27107215)
by [Unito](https://www.unito.io)

Co-authored-by: github-actions <github-actions@github.com>
2025-11-26 22:38:18 -07:00
Christian Byrne
0ea066599d [backport cloud/1.32] fix: remove LOD from vue nodes (#6982)
## Summary
Backport of #6950 onto cloud/1.32.

- cherry-picked 4b87b1fdc
- resolved same PNG + WidgetMarkdown conflicts by keeping upstream LOD
removal and local padding

## Testing
- pnpm typecheck

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6982-backport-cloud-1-32-fix-remove-LOD-from-vue-nodes-2b86d73d365081ee9346d685bd65a642)
by [Unito](https://www.unito.io)

Co-authored-by: Simula_r <18093452+simula-r@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-11-26 21:40:48 -07:00
Christian Byrne
20b29f0301 [backport cloud/1.32] fix: don't use registry when only checking for presence of missing nodes (#6971)
## Summary
Backport of #6965 onto cloud/1.32.

- cherry-picked 83f04490b
- resolved merge conflict in
src/components/actionbar/ComfyRunButton/ComfyQueueButton.vue to keep
cloud queue controls while wiring in graphHasMissingNodes

## Testing
- pnpm typecheck

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6971-backport-cloud-1-32-fix-don-t-use-registry-when-only-checking-for-presence-of-missing--2b86d73d365081029489c10d57f960f6)
by [Unito](https://www.unito.io)
2025-11-26 17:57:51 -07:00
Comfy Org PR Bot
ac4c553a46 [backport cloud/1.32] feat: open template via URL in linear mode (#6967)
Backport of #6945 to `cloud/1.32`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6967-backport-cloud-1-32-feat-open-template-via-URL-in-linear-mode-2b76d73d3650810aaf26efea68272ed0)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-11-26 17:32:27 -07:00
Comfy Org PR Bot
6516e0bdbf [backport cloud/1.32] allow telemetry events to be disabled by name in feature flags (#6964)
Backport of #6946 to `cloud/1.32`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6964-backport-cloud-1-32-allow-telemetry-events-to-be-disabled-by-name-in-feature-flags-2b76d73d36508116b888ecbf19a8a52c)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-11-26 16:54:58 -07:00
Comfy Org PR Bot
fbf5e6db21 [backport cloud/1.32] feat: mobile breakpoint for vue nodes banner (#6948)
Backport of #6942 to `cloud/1.32`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6948-backport-cloud-1-32-feat-mobile-breakpoint-for-vue-nodes-banner-2b76d73d365081b18d3cc78c7301f613)
by [Unito](https://www.unito.io)

Co-authored-by: Simula_r <18093452+simula-r@users.noreply.github.com>
2025-11-26 12:26:21 -07:00
Comfy Org PR Bot
93b71b2b64 [backport cloud/1.32] fix: duplicate "refresh node definitions" in menu entries (#6937)
Backport of #6876 to `cloud/1.32`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6937-backport-cloud-1-32-fix-duplicate-refresh-node-definitions-in-menu-entries-2b66d73d365081cca7c8c56048edd011)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-11-25 16:58:40 -07:00
Christian Byrne
a10f9fa30b [backport] Share button and Assets Panel in Linear Mode (#6794) (#6935)
## Summary
Backport of #6794 to cloud/1.32

- Re-enables share button in Linear Mode (exports workflow)
- Displays Assets Panel in left sidebar instead of Queue Panel
- Adds margin to main content area

## Conflict Resolution
Resolved conflict in `src/views/LinearView.vue`:
- Replaced `useQueueSidebarTab` → `useAssetsSidebarTab` (the intended
change from the original PR)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6935-backport-Share-button-and-Assets-Panel-in-Linear-Mode-6794-2b66d73d36508116a759e9a5c585315a)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2025-11-25 15:36:59 -07:00
Comfy Org PR Bot
427bc312e4 [backport cloud/1.32] Fix linear mode with vue (#6929)
Backport of #6769 to `cloud/1.32`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6929-backport-cloud-1-32-Fix-linear-mode-with-vue-2b66d73d36508189ac11ff8e85ac5a80)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2025-11-25 14:13:15 -07:00
Comfy Org PR Bot
b63df5efaf [backport cloud/1.32] Add linear mode (#6927)
Backport of #6670 to `cloud/1.32`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6927-backport-cloud-1-32-Add-linear-mode-2b66d73d36508131a34adcf0b9515e08)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2025-11-25 13:52:42 -07:00
Comfy Org PR Bot
f599b89252 [backport cloud/1.32] Fix: Selected assets count not updating in Imported tab (#6873)
Backport of #6842 to `cloud/1.32`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6873-backport-cloud-1-32-Fix-Selected-assets-count-not-updating-in-Imported-tab-2b46d73d365081a0992bed6de971d81e)
by [Unito](https://www.unito.io)

Co-authored-by: Jin Yi <jin12cc@gmail.com>
2025-11-23 14:01:48 -07:00
Comfy Org PR Bot
19b673cbf5 [backport cloud/1.32] Feat: Load Image (from Outputs) support in Vue Nodes (#6872)
Backport of #6836 to `cloud/1.32`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6872-backport-cloud-1-32-Feat-Load-Image-from-Outputs-support-in-Vue-Nodes-2b46d73d365081e3a39bc0ba9e19accf)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2025-11-23 14:01:43 -07:00
Comfy Org PR Bot
50c353ebb6 [backport cloud/1.32] Fix: Opening mask editor on context menu (#6870)
Backport of #6825 to `cloud/1.32`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6870-backport-cloud-1-32-Fix-Opening-mask-editor-on-context-menu-2b46d73d365081ed811bd3b390c11e8c)
by [Unito](https://www.unito.io)

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2025-11-23 14:01:32 -07:00
Comfy Org PR Bot
75e448085b [backport cloud/1.32] Style: Fix the filter/search/sort controls on the Template Select Modal (#6868)
Backport of #6835 to `cloud/1.32`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6868-backport-cloud-1-32-Style-Fix-the-filter-search-sort-controls-on-the-Template-Select-M-2b46d73d3650811584b1e0c9ea2cf044)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2025-11-23 13:58:11 -07:00
Comfy Org PR Bot
3d2be8f259 [backport cloud/1.32] Fix: TextArea context menu (#6866)
Backport of #6834 to `cloud/1.32`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6866-backport-cloud-1-32-Fix-TextArea-context-menu-2b46d73d3650811c97ccf3cb3bd42a5a)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2025-11-23 13:56:48 -07:00
Comfy Org PR Bot
15ab78370e [backport cloud/1.32] Cleanup: Vue <--> Litegraph scaling logic. (#6864)
Backport of #6745 to `cloud/1.32`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6864-backport-cloud-1-32-Cleanup-Vue-Litegraph-scaling-logic-2b46d73d365081f5beadfe94ca109668)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2025-11-23 13:40:45 -07:00
Comfy Org PR Bot
00e27700e4 [backport cloud/1.32] Feat: Alt+Drag to clone - Vue Nodes (#6861)
Backport of #6789 to `cloud/1.32`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6861-backport-cloud-1-32-Feat-Alt-Drag-to-clone-Vue-Nodes-2b46d73d3650819e83e7f55bb16fdf9d)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: github-actions <github-actions@github.com>
2025-11-23 13:23:59 -07:00
Christian Byrne
5d279b680e [backport cloud/1.32] Fix: node preview background color (#6859)
## Summary
Backports the node preview background color fix from main to cloud/1.32.

Changes node previews to use background color from color palette instead
of the menu color.

Original PR: #6768
Cherry-picked from: 836cd7f9ba

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6859-Backport-Fix-node-preview-background-color-2b46d73d365081d0b188eea8f1dadcec)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2025-11-23 13:23:39 -07:00
Comfy Org PR Bot
4d7369b97d [backport cloud/1.32] Feat: Show Progress Text on Vue Nodes, Markdown for Preview as Text (#6858)
Backport of #6805 to `cloud/1.32`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6858-backport-cloud-1-32-Feat-Show-Progress-Text-on-Vue-Nodes-Markdown-for-Preview-as-Text-2b46d73d36508139bdd1e2eaf77bf8a9)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2025-11-23 12:20:26 -07:00
Comfy Org PR Bot
66509b81c4 [backport cloud/1.32] Use shared button components in queue overlay (#6855)
Backport of #6793 to `cloud/1.32`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6855-backport-cloud-1-32-Use-shared-button-components-in-queue-overlay-2b46d73d365081d69a6edd5e08e19edd)
by [Unito](https://www.unito.io)

Co-authored-by: Benjamin Lu <benceruleanlu@proton.me>
2025-11-23 12:19:56 -07:00
Comfy Org PR Bot
e4ef961876 [backport cloud/1.32] fix: disabling queue button (#6853)
Backport of #6797 to `cloud/1.32`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6853-backport-cloud-1-32-fix-disabling-queue-button-2b46d73d36508115b672f4be8d608e96)
by [Unito](https://www.unito.io)

Co-authored-by: Jin Yi <jin12cc@gmail.com>
2025-11-23 12:02:26 -07:00
Comfy Org PR Bot
7e7d0e84e5 [backport cloud/1.32] hotfix: Stop clicks on the textarea from propagating to the node itself (#6852)
Backport of #6788 to `cloud/1.32`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6852-backport-cloud-1-32-hotfix-Stop-clicks-on-the-textarea-from-propagating-to-the-node-it-2b46d73d3650812fb44ccaf707ca38e3)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2025-11-23 12:02:19 -07:00
Comfy Org PR Bot
cca12d8dc5 [backport cloud/1.32] feat(api-nodes-pricing): add Nano-Banana-2 prices (#6850)
Backport of #6781 to `cloud/1.32`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6850-backport-cloud-1-32-feat-api-nodes-pricing-add-Nano-Banana-2-prices-2b46d73d365081e08333c9e4534f2fbd)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Piskun <13381981+bigcat88@users.noreply.github.com>
2025-11-23 11:45:37 -07:00
Comfy Org PR Bot
ccccd0ff0d [backport cloud/1.32] feat: LOD setting for LG and Vue (#6849)
Backport of #6755 to `cloud/1.32`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6849-backport-cloud-1-32-feat-LOD-setting-for-LG-and-Vue-2b46d73d36508186b306ef89d87bef29)
by [Unito](https://www.unito.io)

Co-authored-by: Simula_r <18093452+simula-r@users.noreply.github.com>
2025-11-23 11:44:52 -07:00
Comfy Org PR Bot
5af9b842ac [backport cloud/1.32] [bugfix] Fix double-click required after pasting URL in upload model dialog (#6815)
Backport of #6801 to `cloud/1.32`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6815-backport-cloud-1-32-bugfix-Fix-double-click-required-after-pasting-URL-in-upload-mode-2b26d73d3650811fa5c7ffab9d25df4f)
by [Unito](https://www.unito.io)

Co-authored-by: Luke Mino-Altherr <luke@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-21 15:11:48 -07:00
Comfy Org PR Bot
fe9c960d24 [backport cloud/1.32] refactor: change model button terminology from Upload to Import (#6803)
Backport of #6800 to `cloud/1.32`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6803-backport-cloud-1-32-refactor-change-model-button-terminology-from-Upload-to-Import-2b26d73d365081269ab5d6ef04456a40)
by [Unito](https://www.unito.io)

Co-authored-by: Luke Mino-Altherr <luke@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-20 22:34:52 -08:00
Comfy Org PR Bot
b2f2144d51 [backport cloud/1.32] [feat] Add Civitai model upload wizard (#6772)
Backport of #6694 to `cloud/1.32`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6772-backport-cloud-1-32-feat-Add-Civitai-model-upload-wizard-2b16d73d3650814dbeccc12c11427050)
by [Unito](https://www.unito.io)

Co-authored-by: Luke Mino-Altherr <luke@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-20 16:51:19 -07:00
Comfy Org PR Bot
c4f7686575 [backport cloud/1.32] [bugfix] Fix execute button incorrectly disabled on empty workflows (#6776)
Backport of #6774 to `cloud/1.32`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6776-backport-cloud-1-32-bugfix-Fix-execute-button-incorrectly-disabled-on-empty-workflows-2b16d73d36508115936aed57d1474bf5)
by [Unito](https://www.unito.io)

Co-authored-by: Jin Yi <jin12cc@gmail.com>
2025-11-19 22:17:46 -08:00
138 changed files with 2723 additions and 2441 deletions

View File

@@ -564,7 +564,7 @@ export class ComfyPage {
async dragAndDrop(source: Position, target: Position) {
await this.page.mouse.move(source.x, source.y)
await this.page.mouse.down()
await this.page.mouse.move(target.x, target.y)
await this.page.mouse.move(target.x, target.y, { steps: 100 })
await this.page.mouse.up()
await this.nextFrame()
}

View File

@@ -65,7 +65,9 @@ export class VueNodeHelpers {
* Select a specific Vue node by ID
*/
async selectNode(nodeId: string): Promise<void> {
await this.page.locator(`[data-node-id="${nodeId}"]`).click()
await this.page
.locator(`[data-node-id="${nodeId}"] .lg-node-header`)
.click()
}
/**
@@ -77,11 +79,13 @@ export class VueNodeHelpers {
// Select first node normally
await this.selectNode(nodeIds[0])
// Add additional nodes with Ctrl+click
// Add additional nodes with Ctrl+click on header
for (let i = 1; i < nodeIds.length; i++) {
await this.page.locator(`[data-node-id="${nodeIds[i]}"]`).click({
modifiers: ['Control']
})
await this.page
.locator(`[data-node-id="${nodeIds[i]}"] .lg-node-header`)
.click({
modifiers: ['Control']
})
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

View File

@@ -6,6 +6,7 @@ import {
test.describe('Vue Nodes Zoom', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setSetting('LiteGraph.Canvas.MinFontSizeForLOD', 8)
await comfyPage.vueNodes.waitForNodes()
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 122 KiB

View File

@@ -1,48 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Vue Nodes - LOD', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setup()
await comfyPage.loadWorkflow('default')
})
test('should toggle LOD based on zoom threshold', async ({ comfyPage }) => {
await comfyPage.vueNodes.waitForNodes()
const initialNodeCount = await comfyPage.vueNodes.getNodeCount()
expect(initialNodeCount).toBeGreaterThan(0)
await expect(comfyPage.canvas).toHaveScreenshot('vue-nodes-default.png')
const vueNodesContainer = comfyPage.vueNodes.nodes
const textboxesInNodes = vueNodesContainer.getByRole('textbox')
const comboboxesInNodes = vueNodesContainer.getByRole('combobox')
await expect(textboxesInNodes.first()).toBeVisible()
await expect(comboboxesInNodes.first()).toBeVisible()
await comfyPage.zoom(120, 10)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('vue-nodes-lod-active.png')
await expect(textboxesInNodes.first()).toBeHidden()
await expect(comboboxesInNodes.first()).toBeHidden()
await comfyPage.zoom(-120, 10)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-nodes-lod-inactive.png'
)
await expect(textboxesInNodes.first()).toBeVisible()
await expect(comboboxesInNodes.first()).toBeVisible()
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 81 KiB

View File

@@ -1329,57 +1329,6 @@ audio.comfy-audio.empty-audio-widget {
will-change: transform;
}
/* START LOD specific styles */
/* LOD styles - Custom CSS avoids 100+ Tailwind selectors that would slow style recalculation when .isLOD toggles */
.isLOD .lg-node {
box-shadow: none;
filter: none;
backdrop-filter: none;
text-shadow: none;
mask-image: none;
clip-path: none;
background-image: none;
text-rendering: optimizeSpeed;
border-radius: 0;
contain: layout style;
transition: none;
}
.isLOD .lg-node-header {
border-radius: 0;
pointer-events: none;
}
.isLOD .lg-node-widgets {
pointer-events: none;
}
.lod-toggle {
visibility: visible;
}
.isLOD .lod-toggle {
visibility: hidden;
}
.lod-fallback {
display: none;
}
.isLOD .lod-fallback {
display: block;
}
.isLOD .image-preview img {
image-rendering: pixelated;
}
.isLOD .slot-dot {
border-radius: 0;
}
/* END LOD specific styles */
/* ===================== Mask Editor Styles ===================== */
/* To be migrated to Tailwind later */
#maskEditor_brush {

View File

@@ -10,7 +10,6 @@
severity="primary"
size="small"
:model="queueModeMenuItems"
:disabled="hasMissingNodes"
data-testid="queue-button"
@click="queuePrompt"
>
@@ -79,13 +78,15 @@ import { useI18n } from 'vue-i18n'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import {
useQueuePendingTaskCountStore,
useQueueSettingsStore
} from '@/stores/queueStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { graphHasMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
import BatchCountEdit from '../BatchCountEdit.vue'
@@ -93,7 +94,10 @@ const workspaceStore = useWorkspaceStore()
const queueCountStore = storeToRefs(useQueuePendingTaskCountStore())
const { mode: queueMode, batchCount } = storeToRefs(useQueueSettingsStore())
const { hasMissingNodes } = useMissingNodes()
const nodeDefStore = useNodeDefStore()
const hasMissingNodes = computed(() =>
graphHasMissingNodes(app.graph, nodeDefStore.nodeDefsByName)
)
const { t } = useI18n()
const queueModeMenuItemLookup = computed(() => {

View File

@@ -64,11 +64,13 @@ import {
ComfyWorkflow,
useWorkflowStore
} from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
import { useCommandStore } from '@/stores/commandStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { appendJsonExt } from '@/utils/formatUtil'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { graphHasMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
interface Props {
item: MenuItem
@@ -79,7 +81,10 @@ const props = withDefaults(defineProps<Props>(), {
isActive: false
})
const { hasMissingNodes } = useMissingNodes()
const nodeDefStore = useNodeDefStore()
const hasMissingNodes = computed(() =>
graphHasMissingNodes(app.graph, nodeDefStore.nodeDefsByName)
)
const { t } = useI18n()
const menu = ref<InstanceType<typeof Menu> & MenuState>()

View File

@@ -24,7 +24,7 @@ import {
import { cn } from '@/utils/tailwindUtil'
interface IconButtonProps extends BaseButtonProps {
onClick: (event: Event) => void
onClick?: (event: MouseEvent) => void
}
defineOptions({

View File

@@ -47,7 +47,7 @@ const {
} = defineProps<IconTextButtonProps>()
const buttonStyle = computed(() => {
const baseClasses = `${getBaseButtonClasses()} justify-start! gap-2`
const baseClasses = `${getBaseButtonClasses()} justify-start gap-2`
const sizeClasses = getButtonSizeClasses(size)
const typeClasses = border
? getBorderButtonTypeClasses(type)

View File

@@ -92,7 +92,7 @@
class="w-62.5"
>
<template #icon>
<i class="icon-[lucide--arrow-up-down]" />
<i class="icon-[lucide--arrow-up-down] text-muted-foreground" />
</template>
</SingleSelect>
</div>

View File

@@ -17,7 +17,7 @@
root: ({ props }: MultiSelectPassThroughMethodOptions) => ({
class: cn(
'h-10 relative inline-flex cursor-pointer select-none',
'rounded-lg bg-base-background text-base-foreground',
'rounded-lg bg-secondary-background text-base-foreground',
'transition-all duration-200 ease-in-out',
'border-[2.5px] border-solid',
selectedCount > 0
@@ -127,7 +127,7 @@
<!-- Trigger value (keep text scale identical) -->
<template #value>
<span class="text-sm text-muted-foreground">
<span class="text-sm">
{{ label }}
</span>
<span
@@ -140,7 +140,7 @@
<!-- Chevron size identical to current -->
<template #dropdownicon>
<i class="icon-[lucide--chevron-down] text-lg text-neutral-400" />
<i class="icon-[lucide--chevron-down] text-muted-foreground" />
</template>
<!-- Custom option row: square checkbox + label (unchanged layout/colors) -->

View File

@@ -1,6 +1,6 @@
<template>
<div :class="wrapperStyle" @click="focusInput">
<i class="icon-[lucide--search] text-muted" />
<i class="icon-[lucide--search] text-muted-foreground" />
<InputText
ref="input"
v-model="internalSearchQuery"
@@ -73,7 +73,7 @@ onMounted(() => autofocus && focusInput())
const wrapperStyle = computed(() => {
const baseClasses =
'relative flex w-full items-center gap-2 bg-base-background cursor-text'
'relative flex w-full items-center gap-2 bg-secondary-background cursor-text'
if (showBorder) {
return cn(

View File

@@ -20,7 +20,7 @@
'h-10 relative inline-flex cursor-pointer select-none items-center',
// trigger surface
'rounded-lg',
'bg-base-background text-base-foreground',
'bg-secondary-background text-base-foreground',
'border-[2.5px] border-solid border-transparent',
'transition-all duration-200 ease-in-out',
'focus-within:border-node-component-border',
@@ -84,7 +84,7 @@
>
<!-- Trigger value -->
<template #value="slotProps">
<div class="flex items-center gap-2 text-sm text-neutral-500">
<div class="flex items-center gap-2 text-sm">
<slot name="icon" />
<span
v-if="slotProps.value !== null && slotProps.value !== undefined"
@@ -100,7 +100,7 @@
<!-- Trigger caret -->
<template #dropdownicon>
<i class="icon-[lucide--chevron-down] text-base text-neutral-500" />
<i class="icon-[lucide--chevron-down] text-muted-foreground" />
</template>
<!-- Option row -->

View File

@@ -3,7 +3,7 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
-->
<template>
<LGraphNodePreview v-if="shouldRenderVueNodes" :node-def="nodeDef" />
<div v-else class="_sb_node_preview">
<div v-else class="_sb_node_preview bg-component-node-background">
<div class="_sb_table">
<div
class="node_header mr-4 text-ellipsis"
@@ -200,7 +200,6 @@ const truncateDefaultValue = (value: any, charLimit: number = 32): string => {
}
._sb_node_preview {
background-color: var(--comfy-menu-bg);
font-family: 'Open Sans', sans-serif;
color: var(--descrip-text);
border: 1px solid var(--descrip-text);

View File

@@ -1,8 +1,10 @@
<template>
<button
type="button"
class="group flex w-full items-center justify-between gap-3 rounded-lg border-0 bg-secondary-background p-1 text-left transition-colors duration-200 ease-in-out hover:cursor-pointer hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
<IconButton
type="secondary"
size="fit-content"
class="group w-full justify-between gap-3 rounded-lg p-1 text-left font-normal hover:cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
:aria-label="props.ariaLabel"
@click="emit('click', $event)"
>
<span class="inline-flex items-center gap-2">
<span v-if="props.mode === 'allFailed'" class="inline-flex items-center">
@@ -76,10 +78,11 @@
>
<i class="icon-[lucide--chevron-down] block size-4 leading-none" />
</span>
</button>
</IconButton>
</template>
<script setup lang="ts">
import IconButton from '@/components/button/IconButton.vue'
import type {
CompletionSummary,
CompletionSummaryMode
@@ -96,4 +99,8 @@ type Props = {
const props = withDefaults(defineProps<Props>(), {
thumbnailUrls: () => []
})
const emit = defineEmits<{
(e: 'click', event: MouseEvent): void
}>()
</script>

View File

@@ -42,17 +42,19 @@
t('sideToolbar.queueProgressOverlay.running')
}}</span>
</span>
<button
<IconButton
v-if="runningCount > 0"
v-tooltip.top="cancelJobTooltip"
class="inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-secondary-background p-0 transition-colors hover:bg-destructive-background"
type="secondary"
size="sm"
class="size-6 bg-secondary-background hover:bg-destructive-background"
:aria-label="t('sideToolbar.queueProgressOverlay.interruptAll')"
@click="$emit('interruptAll')"
>
<i
class="icon-[lucide--x] block size-4 leading-none text-text-primary"
/>
</button>
</IconButton>
</div>
<div class="flex items-center gap-2">
@@ -62,26 +64,28 @@
t('sideToolbar.queueProgressOverlay.queuedSuffix')
}}</span>
</span>
<button
<IconButton
v-if="queuedCount > 0"
v-tooltip.top="clearQueueTooltip"
class="inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-secondary-background p-0 transition-colors hover:bg-destructive-background"
type="secondary"
size="sm"
class="size-6 bg-secondary-background hover:bg-destructive-background"
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
@click="$emit('clearQueued')"
>
<i
class="icon-[lucide--list-x] block size-4 leading-none text-text-primary"
/>
</button>
</IconButton>
</div>
</div>
<button
class="inline-flex h-6 min-w-[120px] flex-1 cursor-pointer items-center justify-center rounded border-0 bg-secondary-background px-2 py-0 text-[12px] text-text-primary hover:bg-secondary-background-hover hover:opacity-90"
<TextButton
class="h-6 min-w-[120px] flex-1 px-2 py-0 text-[12px]"
type="secondary"
:label="t('sideToolbar.queueProgressOverlay.viewAllJobs')"
@click="$emit('viewAllJobs')"
>
{{ t('sideToolbar.queueProgressOverlay.viewAllJobs') }}
</button>
/>
</div>
</div>
</template>
@@ -90,6 +94,8 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import TextButton from '@/components/button/TextButton.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
defineProps<{

View File

@@ -8,17 +8,20 @@
/>
<div class="flex items-center justify-between px-3">
<button
class="inline-flex grow cursor-pointer items-center justify-center gap-1 rounded border-0 bg-secondary-background p-2 text-center font-inter text-[12px] leading-none text-text-primary hover:bg-secondary-background-hover hover:opacity-90"
<IconTextButton
class="grow gap-1 p-2 text-center font-inter text-[12px] leading-none hover:opacity-90 justify-center"
type="secondary"
:label="t('sideToolbar.queueProgressOverlay.showAssets')"
:aria-label="t('sideToolbar.queueProgressOverlay.showAssets')"
@click="$emit('showAssets')"
>
<div
class="pointer-events-none block size-4 shrink-0 leading-none icon-[comfy--image-ai-edit]"
aria-hidden="true"
/>
<span>{{ t('sideToolbar.queueProgressOverlay.showAssets') }}</span>
</button>
<template #icon>
<div
class="pointer-events-none block size-4 shrink-0 leading-none icon-[comfy--image-ai-edit]"
aria-hidden="true"
/>
</template>
</IconTextButton>
<div class="ml-4 inline-flex items-center">
<div
class="inline-flex h-6 items-center text-[12px] leading-none text-text-primary opacity-90"
@@ -28,16 +31,18 @@
t('sideToolbar.queueProgressOverlay.queuedSuffix')
}}</span>
</div>
<button
<IconButton
v-if="queuedCount > 0"
class="group ml-2 inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-secondary-background p-0 transition-colors hover:bg-destructive-background"
class="group ml-2 size-6 bg-secondary-background hover:bg-destructive-background"
type="secondary"
size="sm"
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
@click="$emit('clearQueued')"
>
<i
class="pointer-events-none icon-[lucide--list-x] block size-4 leading-none text-text-primary transition-colors group-hover:text-base-background"
/>
</button>
</IconButton>
</div>
</div>
@@ -75,6 +80,8 @@
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import type {
JobGroup,
JobListItem,

View File

@@ -18,16 +18,18 @@
</span>
</div>
<div class="flex items-center gap-1">
<button
<IconButton
v-tooltip.top="moreTooltipConfig"
class="inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-transparent p-0 hover:bg-secondary-background hover:opacity-100"
type="transparent"
size="sm"
class="size-6 bg-transparent hover:bg-secondary-background hover:opacity-100"
:aria-label="t('sideToolbar.queueProgressOverlay.moreOptions')"
@click="onMoreClick"
>
<i
class="icon-[lucide--more-horizontal] block size-4 leading-none text-text-secondary"
/>
</button>
</IconButton>
<Popover
ref="morePopoverRef"
:dismissable="true"
@@ -45,18 +47,19 @@
<div
class="flex flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3 font-inter"
>
<button
class="inline-flex w-full cursor-pointer items-center justify-start gap-2 rounded-lg border-0 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
<IconTextButton
class="w-full justify-start gap-2 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
type="transparent"
:label="t('sideToolbar.queueProgressOverlay.clearHistory')"
:aria-label="t('sideToolbar.queueProgressOverlay.clearHistory')"
@click="onClearHistoryFromMenu"
>
<i
class="icon-[lucide--file-x-2] block size-4 leading-none text-text-secondary"
/>
<span>{{
t('sideToolbar.queueProgressOverlay.clearHistory')
}}</span>
</button>
<template #icon>
<i
class="icon-[lucide--file-x-2] block size-4 leading-none text-text-secondary"
/>
</template>
</IconTextButton>
</div>
</Popover>
</div>
@@ -69,6 +72,8 @@ import type { PopoverMethods } from 'primevue/popover'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
defineProps<{

View File

@@ -8,13 +8,15 @@
<p class="m-0 text-[14px] font-normal leading-none">
{{ t('sideToolbar.queueProgressOverlay.clearHistoryDialogTitle') }}
</p>
<button
class="inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-transparent p-0 text-text-secondary transition hover:bg-secondary-background hover:opacity-100"
<IconButton
type="transparent"
size="sm"
class="size-6 bg-transparent text-text-secondary hover:bg-secondary-background hover:opacity-100"
:aria-label="t('g.close')"
@click="onCancel"
>
<i class="icon-[lucide--x] block size-4 leading-none" />
</button>
</IconButton>
</header>
<div class="flex flex-col gap-4 px-4 py-4 text-[14px] text-text-secondary">
@@ -30,21 +32,19 @@
<footer class="flex items-center justify-end px-4 py-4">
<div class="flex items-center gap-4 text-[14px] leading-none">
<button
class="inline-flex min-h-[24px] cursor-pointer items-center rounded-md border-0 bg-transparent px-1 py-1 text-[14px] leading-[1] text-text-secondary transition hover:text-text-primary"
:aria-label="t('g.cancel')"
<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"
>
{{ t('g.cancel') }}
</button>
<button
class="inline-flex min-h-[32px] items-center rounded-lg border-0 bg-secondary-background px-4 py-2 text-[12px] font-normal leading-[1] text-text-primary transition hover:bg-secondary-background-hover hover:text-text-primary disabled:cursor-not-allowed disabled:opacity-60"
:aria-label="t('g.clear')"
/>
<TextButton
class="min-h-[32px] px-4 py-2 text-[12px] font-normal leading-[1]"
type="secondary"
:label="t('g.clear')"
:disabled="isClearing"
@click="onConfirm"
>
{{ t('g.clear') }}
</button>
/>
</div>
</footer>
</section>
@@ -54,6 +54,8 @@
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import TextButton from '@/components/button/TextButton.vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useDialogStore } from '@/stores/dialogStore'
import { useQueueStore } from '@/stores/queueStore'

View File

@@ -20,21 +20,24 @@
<div v-if="entry.kind === 'divider'" class="px-2 py-1">
<div class="h-px bg-interface-stroke" />
</div>
<button
<IconTextButton
v-else
class="inline-flex w-full cursor-pointer items-center justify-start gap-2 rounded-lg border-0 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary transition-colors duration-150 hover:bg-interface-panel-hover-surface"
class="w-full justify-start gap-2 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-interface-panel-hover-surface"
type="transparent"
:label="entry.label"
:aria-label="entry.label"
@click="onEntry(entry)"
>
<i
v-if="entry.icon"
:class="[
entry.icon,
'block size-4 shrink-0 leading-none text-text-secondary'
]"
/>
<span>{{ entry.label }}</span>
</button>
<template #icon>
<i
v-if="entry.icon"
:class="[
entry.icon,
'block size-4 shrink-0 leading-none text-text-secondary'
]"
/>
</template>
</IconTextButton>
</template>
</div>
</Popover>
@@ -44,6 +47,7 @@
import Popover from 'primevue/popover'
import { ref } from 'vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import type { MenuEntry } from '@/composables/queue/useJobMenu'
defineProps<{ entries: MenuEntry[] }>()

View File

@@ -20,17 +20,18 @@
class="flex min-w-0 items-center text-[0.75rem] leading-normal font-normal text-text-secondary"
>
<span class="block min-w-0 truncate">{{ row.value }}</span>
<button
<IconButton
v-if="row.canCopy"
type="button"
class="ml-2 inline-flex size-6 items-center justify-center rounded border-0 bg-transparent p-0 hover:opacity-90"
type="transparent"
size="sm"
class="ml-2 size-6 bg-transparent hover:opacity-90"
:aria-label="copyAriaLabel"
@click.stop="copyJobId"
>
<i
class="icon-[lucide--copy] block size-4 leading-none text-text-secondary"
/>
</button>
</IconButton>
</div>
</template>
</div>
@@ -60,25 +61,31 @@
{{ t('queue.jobDetails.errorMessage') }}
</div>
<div class="flex items-center justify-between gap-4">
<button
type="button"
class="inline-flex h-6 items-center justify-center gap-2 rounded border-none bg-transparent px-0 text-[0.75rem] leading-none text-text-secondary hover:opacity-90"
<IconTextButton
class="h-6 justify-start gap-2 bg-transparent px-0 text-[0.75rem] leading-none text-text-secondary hover:opacity-90"
type="transparent"
:label="copyAriaLabel"
:aria-label="copyAriaLabel"
icon-position="right"
@click.stop="copyErrorMessage"
>
<span>{{ copyAriaLabel }}</span>
<i class="icon-[lucide--copy] block size-3.5 leading-none" />
</button>
<button
type="button"
class="inline-flex h-6 items-center justify-center gap-2 rounded border-none bg-transparent px-0 text-[0.75rem] leading-none text-text-secondary hover:opacity-90"
<template #icon>
<i class="icon-[lucide--copy] block size-3.5 leading-none" />
</template>
</IconTextButton>
<IconTextButton
class="h-6 justify-start gap-2 bg-transparent px-0 text-[0.75rem] leading-none text-text-secondary hover:opacity-90"
type="transparent"
:label="t('queue.jobDetails.report')"
icon-position="right"
@click.stop="reportJobError"
>
<span>{{ t('queue.jobDetails.report') }}</span>
<i
class="icon-[lucide--message-circle-warning] block size-3.5 leading-none"
/>
</button>
<template #icon>
<i
class="icon-[lucide--message-circle-warning] block size-3.5 leading-none"
/>
</template>
</IconTextButton>
</div>
<div
class="col-span-2 mt-2 rounded bg-interface-panel-hover-surface px-4 py-2 text-[0.75rem] leading-normal text-text-secondary"
@@ -94,6 +101,8 @@
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'

View File

@@ -2,26 +2,26 @@
<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">
<button
<TextButton
v-for="tab in visibleJobTabs"
:key="tab"
class="h-6 cursor-pointer rounded border-0 px-3 py-1 text-[12px] leading-none hover:opacity-90"
class="h-6 px-3 py-1 text-[12px] leading-none hover:opacity-90"
:type="selectedJobTab === tab ? 'secondary' : 'transparent'"
:class="[
selectedJobTab === tab
? 'bg-secondary-background text-text-primary'
: 'bg-transparent text-text-secondary'
selectedJobTab === tab ? 'text-text-primary' : 'text-text-secondary'
]"
:label="tabLabel(tab)"
@click="$emit('update:selectedJobTab', tab)"
>
{{ tabLabel(tab) }}
</button>
/>
</div>
</div>
<div class="ml-2 flex shrink-0 items-center gap-2">
<button
<IconButton
v-if="showWorkflowFilter"
v-tooltip.top="filterTooltipConfig"
class="relative inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-secondary-background p-0 hover:bg-secondary-background-hover hover:opacity-90"
type="secondary"
size="sm"
class="relative size-6 bg-secondary-background hover:bg-secondary-background-hover hover:opacity-90"
:aria-label="t('sideToolbar.queueProgressOverlay.filterJobs')"
@click="onFilterClick"
>
@@ -32,7 +32,7 @@
v-if="selectedWorkflowFilter !== 'all'"
class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground"
/>
</button>
</IconButton>
<Popover
v-if="showWorkflowFilter"
ref="filterPopoverRef"
@@ -51,46 +51,48 @@
<div
class="flex min-w-[12rem] flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3"
>
<button
class="inline-flex w-full cursor-pointer items-center justify-start gap-1 rounded-lg border-0 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
<IconTextButton
class="w-full justify-between gap-1 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
type="transparent"
icon-position="right"
:label="t('sideToolbar.queueProgressOverlay.filterAllWorkflows')"
:aria-label="
t('sideToolbar.queueProgressOverlay.filterAllWorkflows')
"
@click="selectWorkflowFilter('all')"
>
<span>{{
t('sideToolbar.queueProgressOverlay.filterAllWorkflows')
}}</span>
<span class="ml-auto inline-flex items-center">
<template #icon>
<i
v-if="selectedWorkflowFilter === 'all'"
class="icon-[lucide--check] block size-4 leading-none text-text-secondary"
/>
</span>
</button>
</template>
</IconTextButton>
<div class="mx-2 mt-1 h-px" />
<button
class="inline-flex w-full cursor-pointer items-center justify-start gap-1 rounded-lg border-0 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
<IconTextButton
class="w-full justify-between gap-1 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
type="transparent"
icon-position="right"
:label="t('sideToolbar.queueProgressOverlay.filterCurrentWorkflow')"
:aria-label="
t('sideToolbar.queueProgressOverlay.filterCurrentWorkflow')
"
@click="selectWorkflowFilter('current')"
>
<span>{{
t('sideToolbar.queueProgressOverlay.filterCurrentWorkflow')
}}</span>
<span class="ml-auto inline-flex items-center">
<template #icon>
<i
v-if="selectedWorkflowFilter === 'current'"
class="icon-[lucide--check] block size-4 leading-none text-text-secondary"
/>
</span>
</button>
</template>
</IconTextButton>
</div>
</Popover>
<button
<IconButton
v-tooltip.top="sortTooltipConfig"
class="relative inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-secondary-background p-0 hover:bg-secondary-background-hover hover:opacity-90"
type="secondary"
size="sm"
class="relative size-6 bg-secondary-background hover:bg-secondary-background-hover hover:opacity-90"
:aria-label="t('sideToolbar.queueProgressOverlay.sortJobs')"
@click="onSortClick"
>
@@ -101,7 +103,7 @@
v-if="selectedSortMode !== 'mostRecent'"
class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground"
/>
</button>
</IconButton>
<Popover
ref="sortPopoverRef"
:dismissable="true"
@@ -120,19 +122,21 @@
class="flex min-w-[12rem] flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3"
>
<template v-for="(mode, index) in jobSortModes" :key="mode">
<button
class="inline-flex w-full cursor-pointer items-center justify-start gap-1 rounded-lg border-0 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
<IconTextButton
class="w-full justify-between gap-1 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
type="transparent"
icon-position="right"
:label="sortLabel(mode)"
:aria-label="sortLabel(mode)"
@click="selectSortMode(mode)"
>
<span>{{ sortLabel(mode) }}</span>
<span class="ml-auto inline-flex items-center">
<template #icon>
<i
v-if="selectedSortMode === mode"
class="icon-[lucide--check] block size-4 leading-none text-text-secondary"
/>
</span>
</button>
</template>
</IconTextButton>
<div
v-if="index < jobSortModes.length - 1"
class="mx-2 mt-1 h-px"
@@ -149,6 +153,9 @@ import Popover from 'primevue/popover'
import { computed, ref } 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 { jobSortModes, jobTabs } from '@/composables/queue/useJobList'
import type { JobSortMode, JobTab } from '@/composables/queue/useJobList'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'

View File

@@ -108,45 +108,47 @@
key="actions"
class="inline-flex items-center gap-2 pr-1"
>
<button
<IconButton
v-if="props.state === 'failed' && computedShowClear"
v-tooltip.top="deleteTooltipConfig"
type="button"
class="inline-flex h-6 transform cursor-pointer items-center gap-1 rounded border-0 bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background hover:opacity-95"
type="transparent"
size="sm"
class="h-6 transform gap-1 rounded bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background hover:opacity-95"
:aria-label="t('g.delete')"
@click.stop="emit('delete')"
>
<i class="icon-[lucide--trash-2] size-4" />
</button>
<button
</IconButton>
<IconButton
v-else-if="props.state !== 'completed' && computedShowClear"
v-tooltip.top="cancelTooltipConfig"
type="button"
class="inline-flex h-6 transform cursor-pointer items-center gap-1 rounded border-0 bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background hover:opacity-95"
type="transparent"
size="sm"
class="h-6 transform gap-1 rounded bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background hover:opacity-95"
:aria-label="t('g.cancel')"
@click.stop="emit('cancel')"
>
<i class="icon-[lucide--x] size-4" />
</button>
<button
</IconButton>
<TextButton
v-else-if="props.state === 'completed'"
type="button"
class="inline-flex h-6 transform cursor-pointer items-center gap-1 rounded border-0 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"
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')"
@click.stop="emit('view')"
>
<span>{{ t('menuLabels.View') }}</span>
</button>
<button
/>
<IconButton
v-if="props.showMenu !== undefined ? props.showMenu : true"
v-tooltip.top="moreTooltipConfig"
type="button"
class="inline-flex h-6 transform cursor-pointer items-center gap-1 rounded border-0 bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:opacity-95"
type="transparent"
size="sm"
class="h-6 transform gap-1 rounded bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:opacity-95"
:aria-label="t('g.more')"
@click.stop="emit('menu', $event)"
>
<i class="icon-[lucide--more-horizontal] size-4" />
</button>
</IconButton>
</div>
<div v-else key="secondary" class="pr-2">
<slot name="secondary">{{ props.rightText }}</slot>
@@ -161,6 +163,8 @@
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 { buildTooltipConfig } from '@/composables/useTooltipConfig'

View File

@@ -73,6 +73,7 @@
@click.stop="handleNodes2ToggleClick"
>
<span class="p-menubar-item-label text-nowrap">{{ item.label }}</span>
<Tag severity="info" class="ml-2 text-xs">{{ $t('g.beta') }}</Tag>
<ToggleSwitch
v-model="nodes2Enabled"
class="ml-4"
@@ -101,6 +102,7 @@
<script setup lang="ts">
import type { MenuItem } from 'primevue/menuitem'
import Tag from 'primevue/tag'
import TieredMenu from 'primevue/tieredmenu'
import type { TieredMenuMethods, TieredMenuState } from 'primevue/tieredmenu'
import ToggleSwitch from 'primevue/toggleswitch'

View File

@@ -208,7 +208,7 @@ const shouldShowDeleteButton = computed(() => {
const getOutputCount = (item: AssetItem): number => {
const count = item.user_metadata?.outputCount
return typeof count === 'number' && count > 0 ? count : 0
return typeof count === 'number' && count > 0 ? count : 1
}
const shouldShowOutputCount = (item: AssetItem): boolean => {

View File

@@ -3,9 +3,12 @@
v-if="showVueNodesBanner"
class="pointer-events-auto relative w-full h-10 bg-gradient-to-r from-blue-600 to-blue-700 flex items-center justify-center px-4"
>
<div class="flex items-center">
<div class="flex items-center text-sm">
<i class="icon-[lucide--rocket]"></i>
<span class="pl-2 text-sm">{{ $t('vueNodesBanner.message') }}</span>
<span class="pl-2">{{ $t('vueNodesBanner.title') }}</span>
<span class="pl-1.5 hidden md:inline">{{
$t('vueNodesBanner.desc')
}}</span>
<Button
class="cursor-pointer bg-transparent rounded h-7 px-3 border border-white text-white ml-4 text-xs"
@click="handleTryItOut"

View File

@@ -13,6 +13,7 @@ import type {
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import type { NodeId } from '@/renderer/core/layout/types'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { isDOMWidget } from '@/scripts/domWidget'
import { useNodeDefStore } from '@/stores/nodeDefStore'
@@ -46,7 +47,7 @@ export interface SafeWidgetData {
}
export interface VueNodeData {
id: string
id: NodeId
title: string
type: string
mode: number
@@ -78,10 +79,64 @@ export interface GraphNodeManager {
cleanup(): void
}
export function safeWidgetMapper(
node: LGraphNode,
slotMetadata: Map<string, WidgetSlotMetadata>
): (widget: IBaseWidget) => SafeWidgetData {
const nodeDefStore = useNodeDefStore()
return function (widget) {
try {
// TODO: Use widget.getReactiveData() once TypeScript types are updated
let value = widget.value
// For combo widgets, if value is undefined, use the first option as default
if (
value === undefined &&
widget.type === 'combo' &&
widget.options?.values &&
Array.isArray(widget.options.values) &&
widget.options.values.length > 0
) {
value = widget.options.values[0]
}
const spec = nodeDefStore.getInputSpecForWidget(node, widget.name)
const slotInfo = slotMetadata.get(widget.name)
return {
name: widget.name,
type: widget.type,
value: value,
label: widget.label,
options: widget.options ? { ...widget.options } : undefined,
callback: widget.callback,
spec,
slotMetadata: slotInfo,
isDOMWidget: isDOMWidget(widget)
}
} catch (error) {
return {
name: widget.name || 'unknown',
type: widget.type || 'text',
value: undefined
}
}
}
}
export function isValidWidgetValue(value: unknown): value is WidgetValue {
return (
value === null ||
value === undefined ||
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean' ||
typeof value === 'object'
)
}
export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
// Get layout mutations composable
const { createNode, deleteNode, setSource } = useLayoutMutations()
const nodeDefStore = useNodeDefStore()
// Safe reactive data extracted from LiteGraph nodes
const vueNodeData = reactive(new Map<string, VueNodeData>())
@@ -147,45 +202,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
linked: input.link != null
})
})
return (
node.widgets?.map((widget) => {
try {
// TODO: Use widget.getReactiveData() once TypeScript types are updated
let value = widget.value
// For combo widgets, if value is undefined, use the first option as default
if (
value === undefined &&
widget.type === 'combo' &&
widget.options?.values &&
Array.isArray(widget.options.values) &&
widget.options.values.length > 0
) {
value = widget.options.values[0]
}
const spec = nodeDefStore.getInputSpecForWidget(node, widget.name)
const slotInfo = slotMetadata.get(widget.name)
return {
name: widget.name,
type: widget.type,
value: value,
label: widget.label,
options: widget.options ? { ...widget.options } : undefined,
callback: widget.callback,
spec,
slotMetadata: slotInfo,
isDOMWidget: isDOMWidget(widget)
}
} catch (error) {
return {
name: widget.name || 'unknown',
type: widget.type || 'text',
value: undefined
}
}
}) ?? []
)
return node.widgets?.map(safeWidgetMapper(node, slotMetadata)) ?? []
})
const nodeType =

View File

@@ -10,6 +10,7 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useLayoutSync } from '@/renderer/core/layout/sync/useLayoutSync'
import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
import { ensureCorrectLayoutScale } from '@/renderer/extensions/vueNodes/layout/ensureCorrectLayoutScale'
import { app as comfyApp } from '@/scripts/app'
import { useToastStore } from '@/platform/updates/common/toastStore'
@@ -18,9 +19,7 @@ function useVueNodeLifecycleIndividual() {
const canvasStore = useCanvasStore()
const layoutMutations = useLayoutMutations()
const { shouldRenderVueNodes } = useVueFeatureFlags()
const nodeManager = shallowRef<GraphNodeManager | null>(null)
const { startSync } = useLayoutSync()
const isVueNodeToastDismissed = useVueNodesMigrationDismissed()
@@ -40,7 +39,10 @@ function useVueNodeLifecycleIndividual() {
const nodes = activeGraph._nodes.map((node: LGraphNode) => ({
id: node.id.toString(),
pos: [node.pos[0], node.pos[1]] as [number, number],
size: [node.size[0], node.size[1]] as [number, number]
size: [node.size[0], removeNodeTitleHeight(node.size[1])] as [
number,
number
]
}))
layoutStore.initializeFromLiteGraph(nodes)

View File

@@ -0,0 +1,48 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { useDialogStore } from '@/stores/dialogStore'
import TopBarHeader from '@/components/maskeditor/dialog/TopBarHeader.vue'
import MaskEditorContent from '@/components/maskeditor/MaskEditorContent.vue'
export function useMaskEditor() {
const openMaskEditor = (node: LGraphNode) => {
if (!node) {
console.error('[MaskEditor] No node provided')
return
}
if (!node.imgs?.length && node.previewMediaType !== 'image') {
console.error('[MaskEditor] Node has no images')
return
}
useDialogStore().showDialog({
key: 'global-mask-editor',
headerComponent: TopBarHeader,
component: MaskEditorContent,
props: {
node
},
dialogComponentProps: {
style: 'width: 90vw; height: 90vh;',
modal: true,
maximizable: true,
closable: true,
pt: {
root: {
class: 'mask-editor-dialog flex flex-col'
},
content: {
class: 'flex flex-col min-h-0 flex-1 !p-0'
},
header: {
class: '!p-2'
}
}
}
})
}
return {
openMaskEditor
}
}

View File

@@ -1545,7 +1545,26 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
}
},
GeminiImageNode: {
displayPrice: '$0.03 per 1K tokens'
displayPrice: '~$0.039/Image (1K)'
},
GeminiImage2Node: {
displayPrice: (node: LGraphNode): string => {
const resolutionWidget = node.widgets?.find(
(w) => w.name === 'resolution'
) as IComboWidget
if (!resolutionWidget) return 'Token-based'
const resolution = String(resolutionWidget.value)
if (resolution.includes('1K')) {
return '~$0.134/Image'
} else if (resolution.includes('2K')) {
return '~$0.134/Image'
} else if (resolution.includes('4K')) {
return '~$0.24/Image'
}
return 'Token-based'
}
},
// OpenAI nodes
OpenAIChatNode: {
@@ -1829,6 +1848,7 @@ export const useNodePricing = () => {
TripoTextureNode: ['texture_quality'],
// Google/Gemini nodes
GeminiNode: ['model'],
GeminiImage2Node: ['resolution'],
// OpenAI nodes
OpenAIChatNode: ['model'],
// ByteDance

View File

@@ -1219,6 +1219,12 @@ export function useCoreCommands(): ComfyCommand[] {
await settingStore.set('Comfy.Assets.UseAssetAPI', !current)
await useWorkflowService().reloadCurrentWorkflow() // ensure changes take effect immediately
}
},
{
id: 'Comfy.ToggleLinear',
icon: 'pi pi-database',
label: 'toggle linear mode',
function: () => (canvasStore.linearMode = !canvasStore.linearMode)
}
]

View File

@@ -14,7 +14,6 @@ export const CORE_MENU_COMMANDS = [
[['Edit'], ['Comfy.Undo', 'Comfy.Redo']],
[['Edit'], ['Comfy.ClearWorkflow']],
[['Edit'], ['Comfy.OpenClipspace']],
[['Edit'], ['Comfy.RefreshNodeDefinitions']],
[
['Edit'],
[

View File

@@ -5,10 +5,9 @@ import { app } from '@/scripts/app'
import { ComfyApp } from '@/scripts/app'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import { useDialogStore } from '@/stores/dialogStore'
import MaskEditorContent from '@/components/maskeditor/MaskEditorContent.vue'
import TopBarHeader from '@/components/maskeditor/dialog/TopBarHeader.vue'
import { MaskEditorDialogOld } from './maskEditorOld'
import { ClipspaceDialog } from './clipspace'
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
function openMaskEditor(node: LGraphNode): void {
if (!node) {
@@ -26,32 +25,7 @@ function openMaskEditor(node: LGraphNode): void {
)
if (useNewEditor) {
// Use new refactored editor
useDialogStore().showDialog({
key: 'global-mask-editor',
headerComponent: TopBarHeader,
component: MaskEditorContent,
props: {
node
},
dialogComponentProps: {
style: 'width: 90vw; height: 90vh;',
modal: true,
maximizable: true,
closable: true,
pt: {
root: {
class: 'mask-editor-dialog flex flex-col'
},
content: {
class: 'flex flex-col min-h-0 flex-1 !p-0'
},
header: {
class: '!p-2'
}
}
}
})
useMaskEditor().openMaskEditor(node)
} else {
// Use old editor
ComfyApp.copyToClipspace(node)

View File

@@ -17,10 +17,10 @@ useExtensionService().registerExtension({
nodeType.prototype.onNodeCreated = function () {
onNodeCreated ? onNodeCreated.apply(this, []) : undefined
const showValueWidget = ComfyWidgets['STRING'](
const showValueWidget = ComfyWidgets['MARKDOWN'](
this,
'preview',
['STRING', { multiline: true }],
['MARKDOWN', {}],
app
).widget as DOMWidget<HTMLTextAreaElement, string>

View File

@@ -79,7 +79,7 @@ export type {
LGraphTriggerParam
} from './types/graphTriggers'
export type rendererType = 'LG' | 'Vue'
export type RendererType = 'LG' | 'Vue'
export interface LGraphState {
lastGroupId: number
@@ -106,7 +106,7 @@ export interface LGraphExtra extends Dictionary<unknown> {
reroutes?: SerialisableReroute[]
linkExtensions?: { id: number; parentId: number | undefined }[]
ds?: DragAndScaleState
workflowRendererVersion?: rendererType
workflowRendererVersion?: RendererType
}
export interface BaseLGraph {

View File

@@ -7,6 +7,7 @@ import { getSlotPosition } from '@/renderer/core/canvas/litegraph/slotCalculatio
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
import { CanvasPointer } from './CanvasPointer'
import type { ContextMenu } from './ContextMenu'
@@ -1771,18 +1772,19 @@ export class LGraphCanvas
}
static onMenuNodeClone(
// @ts-expect-error - unused parameter
value: IContextMenuValue,
// @ts-expect-error - unused parameter
options: IContextMenuOptions,
// @ts-expect-error - unused parameter
e: MouseEvent,
// @ts-expect-error - unused parameter
menu: ContextMenu,
_value: IContextMenuValue,
_options: IContextMenuOptions,
_e: MouseEvent,
_menu: ContextMenu,
node: LGraphNode
): void {
const canvas = LGraphCanvas.active_canvas
const nodes = canvas.selectedItems.size ? canvas.selectedItems : [node]
const nodes = canvas.selectedItems.size ? [...canvas.selectedItems] : [node]
if (nodes.length) LGraphCanvas.cloneNodes(nodes)
}
static cloneNodes(nodes: Positionable[]) {
const canvas = LGraphCanvas.active_canvas
// Find top-left-most boundary
let offsetX = Infinity
@@ -1792,11 +1794,11 @@ export class LGraphCanvas
throw new TypeError(
'Invalid node encountered on clone. `pos` was null.'
)
if (item.pos[0] < offsetX) offsetX = item.pos[0]
if (item.pos[1] < offsetY) offsetY = item.pos[1]
offsetX = Math.min(offsetX, item.pos[0])
offsetY = Math.min(offsetY, item.pos[1])
}
canvas._deserializeItems(canvas._serializeItems(nodes), {
return canvas._deserializeItems(canvas._serializeItems(nodes), {
position: [offsetX + 5, offsetY + 5]
})
}
@@ -4042,16 +4044,25 @@ export class LGraphCanvas
// TODO: Report failures, i.e. `failedNodes`
const newPositions = created.map((node) => ({
nodeId: String(node.id),
bounds: {
x: node.pos[0],
y: node.pos[1],
width: node.size?.[0] ?? 100,
height: node.size?.[1] ?? 200
}
}))
const newPositions = created
.filter((item): item is LGraphNode => item instanceof LGraphNode)
.map((node) => {
const fullHeight = node.size?.[1] ?? 200
const layoutHeight = LiteGraph.vueNodesMode
? removeNodeTitleHeight(fullHeight)
: fullHeight
return {
nodeId: String(node.id),
bounds: {
x: node.pos[0],
y: node.pos[1],
width: node.size?.[0] ?? 100,
height: layoutHeight
}
}
})
if (newPositions.length) layoutStore.setSource(LayoutSource.Canvas)
layoutStore.batchUpdateNodeBounds(newPositions)
this.selectItems(created)

View File

@@ -1,5 +1,6 @@
{
"g": {
"beta": "Beta",
"user": "User",
"currentUser": "Current user",
"empty": "Empty",
@@ -2075,7 +2076,36 @@
"failedToCreateNode": "Failed to create node. Please try again or check console for details.",
"noModelsInFolder": "No {type} available in this folder",
"searchAssetsPlaceholder": "Type to search...",
"uploadModel": "Upload model",
"uploadModel": "Import model",
"uploadModelFromCivitai": "Import a model from Civitai",
"uploadModelFailedToRetrieveMetadata": "Failed to retrieve metadata. Please check the link and try again.",
"onlyCivitaiUrlsSupported": "Only Civitai URLs are supported",
"uploadModelDescription1": "Paste a Civitai model download link to add it to your library.",
"uploadModelDescription2": "Only links from https://civitai.com are supported at the moment",
"uploadModelDescription3": "Max file size: 1 GB",
"civitaiLinkLabel": "Civitai model download link",
"civitaiLinkPlaceholder": "Paste link here",
"civitaiLinkExample": "Example: https://civitai.com/api/download/models/833921?type=Model&format=SafeTensor",
"confirmModelDetails": "Confirm Model Details",
"fileName": "File Name",
"fileSize": "File Size",
"modelName": "Model Name",
"modelNamePlaceholder": "Enter a name for this model",
"tags": "Tags",
"tagsPlaceholder": "e.g., models, checkpoint",
"tagsHelp": "Separate tags with commas",
"upload": "Import",
"uploadingModel": "Importing model...",
"uploadSuccess": "Model imported successfully!",
"uploadFailed": "Import failed",
"modelAssociatedWithLink": "The model associated with the link you provided:",
"modelTypeSelectorLabel": "What type of model is this?",
"modelTypeSelectorPlaceholder": "Select model type",
"selectModelType": "Select model type",
"notSureLeaveAsIs": "Not sure? Just leave this as is",
"modelUploaded": "Model imported! 🎉",
"findInLibrary": "Find it in the {type} section of the models library.",
"finish": "Finish",
"allModels": "All Models",
"allCategory": "All {category}",
"unknown": "Unknown",
@@ -2087,6 +2117,13 @@
"sortZA": "Z-A",
"sortRecent": "Recent",
"sortPopular": "Popular",
"errorFileTooLarge": "File exceeds the maximum allowed size limit",
"errorFormatNotAllowed": "Only SafeTensor format is allowed",
"errorUnsafePickleScan": "CivitAI detected potentially unsafe code in this file",
"errorUnsafeVirusScan": "CivitAI detected malware or suspicious content in this file",
"errorModelTypeNotSupported": "This model type is not supported",
"errorUnknown": "An unexpected error occurred",
"errorUploadFailed": "Failed to import asset. Please try again.",
"ariaLabel": {
"assetCard": "{name} - {type} asset",
"loadingAsset": "Loading asset"
@@ -2151,7 +2188,8 @@
}
},
"vueNodesBanner": {
"message": "Introducing Nodes 2.0 More flexible workflows, powerful new widgets, built for extensibility",
"title": "Introducing Nodes 2.0",
"desc": " More flexible workflows, powerful new widgets, built for extensibility",
"tryItOut": "Try it out"
},
"vueNodesMigration": {
@@ -2161,6 +2199,10 @@
"vueNodesMigrationMainMenu": {
"message": "Switch back to Nodes 2.0 anytime from the main menu."
},
"linearMode": {
"share": "Share",
"openWorkflow": "Open Workflow"
},
"missingNodes": {
"cloud": {
"title": "These nodes aren't available on Comfy Cloud yet",
@@ -2176,4 +2218,4 @@
"replacementInstruction": "Install these nodes to run this workflow, or replace them with installed alternatives. Missing nodes are highlighted in red on the canvas."
}
}
}
}

View File

@@ -38,7 +38,7 @@
:on-click="handleUploadClick"
>
<template #icon>
<i class="icon-[lucide--upload]" />
<i class="icon-[lucide--package-plus]" />
</template>
</IconTextButton>
</div>
@@ -73,11 +73,14 @@ import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import AssetFilterBar from '@/platform/assets/components/AssetFilterBar.vue'
import AssetGrid from '@/platform/assets/components/AssetGrid.vue'
import UploadModelDialog from '@/platform/assets/components/UploadModelDialog.vue'
import UploadModelDialogHeader from '@/platform/assets/components/UploadModelDialogHeader.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
import { formatCategoryLabel } from '@/platform/assets/utils/categoryLabel'
import { useDialogStore } from '@/stores/dialogStore'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import { OnCloseKey } from '@/types/widgetTypes'
@@ -92,6 +95,7 @@ const props = defineProps<{
}>()
const { t } = useI18n()
const dialogStore = useDialogStore()
const emit = defineEmits<{
'asset-select': [asset: AssetDisplayItem]
@@ -189,6 +193,15 @@ const { flags } = useFeatureFlags()
const isUploadButtonEnabled = computed(() => flags.modelUploadButtonEnabled)
function handleUploadClick() {
// Will be implemented in the future commit
dialogStore.showDialog({
key: 'upload-model',
headerComponent: UploadModelDialogHeader,
component: UploadModelDialog,
props: {
onUploadSuccess: async () => {
await execute()
}
}
})
}
</script>

View File

@@ -0,0 +1,58 @@
<template>
<div class="flex flex-col gap-4">
<!-- Model Info Section -->
<div class="flex flex-col gap-2">
<p class="text-sm text-muted m-0">
{{ $t('assetBrowser.modelAssociatedWithLink') }}
</p>
<p class="text-sm mt-0">
{{ metadata?.name || metadata?.filename }}
</p>
</div>
<!-- Model Type Selection -->
<div class="flex flex-col gap-2">
<label class="text-sm text-muted">
{{ $t('assetBrowser.modelTypeSelectorLabel') }}
</label>
<SingleSelect
v-model="selectedModelType"
:label="
isLoading
? $t('g.loading')
: $t('assetBrowser.modelTypeSelectorPlaceholder')
"
:options="modelTypes"
:disabled="isLoading"
/>
<div class="flex items-center gap-2 text-sm text-muted">
<i class="icon-[lucide--info]" />
<span>{{ $t('assetBrowser.notSureLeaveAsIs') }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import SingleSelect from '@/components/input/SingleSelect.vue'
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
const props = defineProps<{
modelValue: string | undefined
metadata: AssetMetadata | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: string | undefined]
}>()
const { modelTypes, isLoading } = useModelTypes()
const selectedModelType = computed({
get: () => props.modelValue ?? null,
set: (value: string | null) => emit('update:modelValue', value ?? undefined)
})
</script>

View File

@@ -0,0 +1,108 @@
<template>
<div class="upload-model-dialog flex flex-col justify-between gap-6 p-4 pt-6">
<!-- Step 1: Enter URL -->
<UploadModelUrlInput
v-if="currentStep === 1"
v-model="wizardData.url"
:error="uploadError"
/>
<!-- Step 2: Confirm Metadata -->
<UploadModelConfirmation
v-else-if="currentStep === 2"
v-model="selectedModelType"
:metadata="wizardData.metadata"
/>
<!-- Step 3: Upload Progress -->
<UploadModelProgress
v-else-if="currentStep === 3"
:status="uploadStatus"
:error="uploadError"
:metadata="wizardData.metadata"
:model-type="selectedModelType"
/>
<!-- Navigation Footer -->
<UploadModelFooter
:current-step="currentStep"
:is-fetching-metadata="isFetchingMetadata"
:is-uploading="isUploading"
:can-fetch-metadata="canFetchMetadata"
:can-upload-model="canUploadModel"
:upload-status="uploadStatus"
@back="goToPreviousStep"
@fetch-metadata="handleFetchMetadata"
@upload="handleUploadModel"
@close="handleClose"
/>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import UploadModelConfirmation from '@/platform/assets/components/UploadModelConfirmation.vue'
import UploadModelFooter from '@/platform/assets/components/UploadModelFooter.vue'
import UploadModelProgress from '@/platform/assets/components/UploadModelProgress.vue'
import UploadModelUrlInput from '@/platform/assets/components/UploadModelUrlInput.vue'
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
import { useUploadModelWizard } from '@/platform/assets/composables/useUploadModelWizard'
import { useDialogStore } from '@/stores/dialogStore'
const dialogStore = useDialogStore()
const { modelTypes, fetchModelTypes } = useModelTypes()
const emit = defineEmits<{
'upload-success': []
}>()
const {
currentStep,
isFetchingMetadata,
isUploading,
uploadStatus,
uploadError,
wizardData,
selectedModelType,
canFetchMetadata,
canUploadModel,
fetchMetadata,
uploadModel,
goToPreviousStep
} = useUploadModelWizard(modelTypes)
async function handleFetchMetadata() {
await fetchMetadata()
}
async function handleUploadModel() {
const success = await uploadModel()
if (success) {
emit('upload-success')
}
}
function handleClose() {
dialogStore.closeDialog({ key: 'upload-model' })
}
onMounted(() => {
fetchModelTypes()
})
</script>
<style scoped>
.upload-model-dialog {
width: 90vw;
max-width: 800px;
min-height: 400px;
}
@media (min-width: 640px) {
.upload-model-dialog {
width: auto;
min-width: 600px;
}
}
</style>

View File

@@ -0,0 +1,12 @@
<template>
<div class="flex items-center gap-3 px-4 py-2 font-bold">
<span>{{ $t('assetBrowser.uploadModelFromCivitai') }}</span>
<span
class="rounded-full bg-white px-1.5 py-0 text-xxs font-medium uppercase text-black"
>
{{ $t('g.beta') }}
</span>
</div>
</template>
<script setup lang="ts"></script>

View File

@@ -0,0 +1,72 @@
<template>
<div class="flex justify-end gap-2">
<TextButton
v-if="currentStep !== 1 && currentStep !== 3"
:label="$t('g.back')"
type="secondary"
size="md"
:disabled="isFetchingMetadata || isUploading"
@click="emit('back')"
/>
<span v-else />
<IconTextButton
v-if="currentStep === 1"
:label="$t('g.continue')"
type="primary"
size="md"
:disabled="!canFetchMetadata || isFetchingMetadata"
@click="emit('fetchMetadata')"
>
<template #icon>
<i
v-if="isFetchingMetadata"
class="icon-[lucide--loader-circle] animate-spin"
/>
</template>
</IconTextButton>
<IconTextButton
v-else-if="currentStep === 2"
:label="$t('assetBrowser.upload')"
type="primary"
size="md"
:disabled="!canUploadModel || isUploading"
@click="emit('upload')"
>
<template #icon>
<i
v-if="isUploading"
class="icon-[lucide--loader-circle] animate-spin"
/>
</template>
</IconTextButton>
<TextButton
v-else-if="currentStep === 3 && uploadStatus === 'success'"
:label="$t('assetBrowser.finish')"
type="primary"
size="md"
@click="emit('close')"
/>
</div>
</template>
<script setup lang="ts">
import IconTextButton from '@/components/button/IconTextButton.vue'
import TextButton from '@/components/button/TextButton.vue'
defineProps<{
currentStep: number
isFetchingMetadata: boolean
isUploading: boolean
canFetchMetadata: boolean
canUploadModel: boolean
uploadStatus: 'idle' | 'uploading' | 'success' | 'error'
}>()
const emit = defineEmits<{
(e: 'back'): void
(e: 'fetchMetadata'): void
(e: 'upload'): void
(e: 'close'): void
}>()
</script>

View File

@@ -0,0 +1,68 @@
<template>
<div class="flex flex-1 flex-col gap-6">
<!-- Uploading State -->
<div
v-if="status === 'uploading'"
class="flex flex-1 flex-col items-center justify-center gap-6"
>
<i
class="icon-[lucide--loader-circle] animate-spin text-6xl text-primary"
/>
<div class="text-center">
<p class="m-0 text-sm font-bold">
{{ $t('assetBrowser.uploadingModel') }}
</p>
</div>
</div>
<!-- Success State -->
<div v-else-if="status === 'success'" class="flex flex-col gap-8">
<div class="flex flex-col gap-4">
<p class="text-sm text-muted m-0 font-bold">
{{ $t('assetBrowser.modelUploaded') }}
</p>
<p class="text-sm text-muted m-0">
{{ $t('assetBrowser.findInLibrary', { type: modelType }) }}
</p>
</div>
<div class="flex flex-row items-start p-8 bg-neutral-800 rounded-lg">
<div class="flex flex-col justify-center items-start gap-1 flex-1">
<p class="text-sm m-0">
{{ metadata?.name || metadata?.filename }}
</p>
<p class="text-sm text-muted m-0">
{{ modelType }}
</p>
</div>
</div>
</div>
<!-- Error State -->
<div
v-else-if="status === 'error'"
class="flex flex-1 flex-col items-center justify-center gap-6"
>
<i class="icon-[lucide--x-circle] text-6xl text-error" />
<div class="text-center">
<p class="m-0 text-sm font-bold">
{{ $t('assetBrowser.uploadFailed') }}
</p>
<p v-if="error" class="text-sm text-muted mb-0">
{{ error }}
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
defineProps<{
status: 'idle' | 'uploading' | 'success' | 'error'
error?: string
metadata: AssetMetadata | null
modelType: string | undefined
}>()
</script>

View File

@@ -0,0 +1,49 @@
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<p class="text-sm text-muted m-0">
{{ $t('assetBrowser.uploadModelDescription1') }}
</p>
<ul class="list-disc space-y-1 pl-5 mt-0 text-sm text-muted">
<li>{{ $t('assetBrowser.uploadModelDescription2') }}</li>
<li>{{ $t('assetBrowser.uploadModelDescription3') }}</li>
</ul>
</div>
<div class="flex flex-col gap-2">
<label class="text-sm text-muted mb-0">
{{ $t('assetBrowser.civitaiLinkLabel') }}
</label>
<InputText
v-model="url"
:placeholder="$t('assetBrowser.civitaiLinkPlaceholder')"
class="w-full"
/>
<p v-if="error" class="text-xs text-error">
{{ error }}
</p>
<p v-else class="text-xs text-muted">
{{ $t('assetBrowser.civitaiLinkExample') }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
import InputText from 'primevue/inputtext'
import { computed } from 'vue'
const props = defineProps<{
modelValue: string
error?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const url = computed({
get: () => props.modelValue,
set: (value: string) => emit('update:modelValue', value)
})
</script>

View File

@@ -0,0 +1,73 @@
import { createSharedComposable, useAsyncState } from '@vueuse/core'
import { api } from '@/scripts/api'
/**
* Format folder name to display name
* Converts "upscale_models" -> "Upscale Models"
* Converts "loras" -> "LoRAs"
*/
function formatDisplayName(folderName: string): string {
// Special cases for acronyms and proper nouns
const specialCases: Record<string, string> = {
loras: 'LoRAs',
ipadapter: 'IP-Adapter',
sams: 'SAMs',
clip_vision: 'CLIP Vision',
animatediff_motion_lora: 'AnimateDiff Motion LoRA',
animatediff_models: 'AnimateDiff Models',
vae: 'VAE',
sam2: 'SAM 2',
controlnet: 'ControlNet',
gligen: 'GLIGEN'
}
if (specialCases[folderName]) {
return specialCases[folderName]
}
return folderName
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
interface ModelTypeOption {
name: string // Display name
value: string // Actual tag value
}
/**
* Composable for fetching and managing model types from the API
* Uses shared state to ensure data is only fetched once
*/
export const useModelTypes = createSharedComposable(() => {
const {
state: modelTypes,
isLoading,
error,
execute: fetchModelTypes
} = useAsyncState(
async (): Promise<ModelTypeOption[]> => {
const response = await api.getModelFolders()
return response.map((folder) => ({
name: formatDisplayName(folder.name),
value: folder.name
}))
},
[] as ModelTypeOption[],
{
immediate: false,
onError: (err) => {
console.error('Failed to fetch model types:', err)
}
}
)
return {
modelTypes,
isLoading,
error,
fetchModelTypes
}
})

View File

@@ -0,0 +1,184 @@
import type { Ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { st } from '@/i18n'
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
interface WizardData {
url: string
metadata: AssetMetadata | null
name: string
tags: string[]
}
interface ModelTypeOption {
name: string
value: string
}
export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
const currentStep = ref(1)
const isFetchingMetadata = ref(false)
const isUploading = ref(false)
const uploadStatus = ref<'idle' | 'uploading' | 'success' | 'error'>('idle')
const uploadError = ref('')
const wizardData = ref<WizardData>({
url: '',
metadata: null,
name: '',
tags: []
})
const selectedModelType = ref<string | undefined>(undefined)
// Clear error when URL changes
watch(
() => wizardData.value.url,
() => {
uploadError.value = ''
}
)
// Validation
const canFetchMetadata = computed(() => {
return wizardData.value.url.trim().length > 0
})
const canUploadModel = computed(() => {
return !!selectedModelType.value
})
function isCivitaiUrl(url: string): boolean {
try {
const hostname = new URL(url).hostname.toLowerCase()
return hostname === 'civitai.com' || hostname.endsWith('.civitai.com')
} catch {
return false
}
}
async function fetchMetadata() {
if (!canFetchMetadata.value) return
// Clean and normalize URL
let cleanedUrl = wizardData.value.url.trim()
try {
cleanedUrl = new URL(encodeURI(cleanedUrl)).toString()
} catch {
// If URL parsing fails, just use the trimmed input
}
wizardData.value.url = cleanedUrl
if (!isCivitaiUrl(wizardData.value.url)) {
uploadError.value = st(
'assetBrowser.onlyCivitaiUrlsSupported',
'Only Civitai URLs are supported'
)
return
}
isFetchingMetadata.value = true
try {
const metadata = await assetService.getAssetMetadata(wizardData.value.url)
wizardData.value.metadata = metadata
// Pre-fill name from metadata
wizardData.value.name = metadata.filename || metadata.name || ''
// Pre-fill model type from metadata tags if available
if (metadata.tags && metadata.tags.length > 0) {
wizardData.value.tags = metadata.tags
// Try to detect model type from tags
const typeTag = metadata.tags.find((tag) =>
modelTypes.value.some((type) => type.value === tag)
)
if (typeTag) {
selectedModelType.value = typeTag
}
}
currentStep.value = 2
} catch (error) {
console.error('Failed to retrieve metadata:', error)
uploadError.value =
error instanceof Error
? error.message
: st(
'assetBrowser.uploadModelFailedToRetrieveMetadata',
'Failed to retrieve metadata. Please check the link and try again.'
)
currentStep.value = 1
} finally {
isFetchingMetadata.value = false
}
}
async function uploadModel() {
if (!canUploadModel.value) return
isUploading.value = true
uploadStatus.value = 'uploading'
try {
const tags = selectedModelType.value
? ['models', selectedModelType.value]
: ['models']
const filename =
wizardData.value.metadata?.filename ||
wizardData.value.metadata?.name ||
'model'
await assetService.uploadAssetFromUrl({
url: wizardData.value.url,
name: filename,
tags,
user_metadata: {
source: 'civitai',
source_url: wizardData.value.url,
model_type: selectedModelType.value
}
})
uploadStatus.value = 'success'
currentStep.value = 3
return true
} catch (error) {
console.error('Failed to upload asset:', error)
uploadStatus.value = 'error'
uploadError.value =
error instanceof Error ? error.message : 'Failed to upload model'
currentStep.value = 3
return false
} finally {
isUploading.value = false
}
}
function goToPreviousStep() {
if (currentStep.value > 1) {
currentStep.value = currentStep.value - 1
}
}
return {
// State
currentStep,
isFetchingMetadata,
isUploading,
uploadStatus,
uploadError,
wizardData,
selectedModelType,
// Computed
canFetchMetadata,
canUploadModel,
// Actions
fetchMetadata,
uploadModel,
goToPreviousStep
}
}

View File

@@ -33,6 +33,29 @@ const zModelFile = z.object({
pathIndex: z.number()
})
const zValidationError = z.object({
code: z.string(),
message: z.string(),
field: z.string()
})
const zValidationResult = z.object({
is_valid: z.boolean(),
errors: z.array(zValidationError).optional(),
warnings: z.array(zValidationError).optional()
})
const zAssetMetadata = z.object({
content_length: z.number(),
final_url: z.string(),
content_type: z.string().optional(),
filename: z.string().optional(),
name: z.string().optional(),
tags: z.array(z.string()).optional(),
preview_url: z.string().optional(),
validation: zValidationResult.optional()
})
// Filename validation schema
export const assetFilenameSchema = z
.string()
@@ -48,6 +71,7 @@ export const assetResponseSchema = zAssetResponse
// Export types derived from Zod schemas
export type AssetItem = z.infer<typeof zAsset>
export type AssetResponse = z.infer<typeof zAssetResponse>
export type AssetMetadata = z.infer<typeof zAssetMetadata>
export type ModelFolder = z.infer<typeof zModelFolder>
export type ModelFile = z.infer<typeof zModelFile>

View File

@@ -1,8 +1,10 @@
import { fromZodError } from 'zod-validation-error'
import { st } from '@/i18n'
import { assetResponseSchema } from '@/platform/assets/schemas/assetSchema'
import type {
AssetItem,
AssetMetadata,
AssetResponse,
ModelFile,
ModelFolder
@@ -10,6 +12,36 @@ import type {
import { api } from '@/scripts/api'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
/**
* Maps CivitAI validation error codes to localized error messages
*/
function getLocalizedErrorMessage(errorCode: string): string {
const errorMessages: Record<string, string> = {
FILE_TOO_LARGE: st('assetBrowser.errorFileTooLarge', 'File too large'),
FORMAT_NOT_ALLOWED: st(
'assetBrowser.errorFormatNotAllowed',
'Format not allowed'
),
UNSAFE_PICKLE_SCAN: st(
'assetBrowser.errorUnsafePickleScan',
'Unsafe pickle scan'
),
UNSAFE_VIRUS_SCAN: st(
'assetBrowser.errorUnsafeVirusScan',
'Unsafe virus scan'
),
MODEL_TYPE_NOT_SUPPORTED: st(
'assetBrowser.errorModelTypeNotSupported',
'Model type not supported'
)
}
return (
errorMessages[errorCode] ||
st('assetBrowser.errorUnknown', 'Unknown error') ||
'Unknown error'
)
}
const ASSETS_ENDPOINT = '/assets'
const EXPERIMENTAL_WARNING = `EXPERIMENTAL: If you are seeing this please make sure "Comfy.Assets.UseAssetAPI" is set to "false" in your ComfyUI Settings.\n`
const DEFAULT_LIMIT = 500
@@ -249,6 +281,77 @@ function createAssetService() {
}
}
/**
* Retrieves metadata from a download URL without downloading the file
*
* @param url - Download URL to retrieve metadata from (will be URL-encoded)
* @returns Promise with metadata including content_length, final_url, filename, etc.
* @throws Error if metadata retrieval fails
*/
async function getAssetMetadata(url: string): Promise<AssetMetadata> {
const encodedUrl = encodeURIComponent(url)
const res = await api.fetchApi(
`${ASSETS_ENDPOINT}/remote-metadata?url=${encodedUrl}`
)
if (!res.ok) {
const errorData = await res.json().catch(() => ({}))
throw new Error(
getLocalizedErrorMessage(errorData.code || 'UNKNOWN_ERROR')
)
}
const data: AssetMetadata = await res.json()
if (data.validation?.is_valid === false) {
throw new Error(
getLocalizedErrorMessage(
data.validation?.errors?.[0]?.code || 'UNKNOWN_ERROR'
)
)
}
return data
}
/**
* Uploads an asset by providing a URL to download from
*
* @param params - Upload parameters
* @param params.url - HTTP/HTTPS URL to download from
* @param params.name - Display name (determines extension)
* @param params.tags - Optional freeform tags
* @param params.user_metadata - Optional custom metadata object
* @param params.preview_id - Optional UUID for preview asset
* @returns Promise<AssetItem & { created_new: boolean }> - Asset object with created_new flag
* @throws Error if upload fails
*/
async function uploadAssetFromUrl(params: {
url: string
name: string
tags?: string[]
user_metadata?: Record<string, any>
preview_id?: string
}): Promise<AssetItem & { created_new: boolean }> {
const res = await api.fetchApi(ASSETS_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(params)
})
if (!res.ok) {
throw new Error(
st(
'assetBrowser.errorUploadFailed',
'Failed to upload asset. Please try again.'
)
)
}
return await res.json()
}
return {
getAssetModelFolders,
getAssetModels,
@@ -256,7 +359,9 @@ function createAssetService() {
getAssetsForNodeType,
getAssetDetails,
getAssetsByTag,
deleteAsset
deleteAsset,
getAssetMetadata,
uploadAssetFromUrl
}
}

View File

@@ -1,3 +1,5 @@
import type { TelemetryEventName } from '@/platform/telemetry/types'
/**
* Server health alert configuration from the backend
*/
@@ -31,4 +33,5 @@ export type RemoteConfig = {
comfy_api_base_url?: string
comfy_platform_base_url?: string
firebase_config?: FirebaseRuntimeConfig
telemetry_disabled_events?: TelemetryEventName[]
}

View File

@@ -8,9 +8,11 @@ import {
} from '@/platform/settings/settingStore'
import type { ISettingGroup, SettingParams } from '@/platform/settings/types'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
export function useSettingSearch() {
const settingStore = useSettingStore()
const { shouldRenderVueNodes } = useVueFeatureFlags()
const searchQuery = ref<string>('')
const filteredSettingIds = ref<string[]>([])
@@ -54,7 +56,11 @@ export function useSettingSearch() {
const allSettings = Object.values(settingStore.settingsById)
const filteredSettings = allSettings.filter((setting) => {
// Filter out hidden and deprecated settings, just like in normal settings tree
if (setting.type === 'hidden' || setting.deprecated) {
if (
setting.type === 'hidden' ||
setting.deprecated ||
(shouldRenderVueNodes.value && setting.hideInVueNodes)
) {
return false
}

View File

@@ -10,6 +10,7 @@ import type { SettingParams } from '@/platform/settings/types'
import { isElectron } from '@/utils/envUtil'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { buildTree } from '@/utils/treeUtil'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
interface SettingPanelItem {
node: SettingTreeNode
@@ -31,10 +32,14 @@ export function useSettingUI(
const settingStore = useSettingStore()
const activeCategory = ref<SettingTreeNode | null>(null)
const { shouldRenderVueNodes } = useVueFeatureFlags()
const settingRoot = computed<SettingTreeNode>(() => {
const root = buildTree(
Object.values(settingStore.settingsById).filter(
(setting: SettingParams) => setting.type !== 'hidden'
(setting: SettingParams) =>
setting.type !== 'hidden' &&
!(shouldRenderVueNodes.value && setting.hideInVueNodes)
),
(setting: SettingParams) => setting.category || setting.id.split('.')
)

View File

@@ -919,7 +919,8 @@ export const CORE_SETTINGS: SettingParams[] = [
step: 1
},
defaultValue: 8,
versionAdded: '1.26.7'
versionAdded: '1.26.7',
hideInVueNodes: true
},
{
id: 'Comfy.Canvas.SelectionToolbox',

View File

@@ -47,6 +47,7 @@ export interface SettingParams<TValue = unknown> extends FormItem {
// sortOrder for sorting settings within a group. Higher values appear first.
// Default is 0 if not specified.
sortOrder?: number
hideInVueNodes?: boolean
}
/**

View File

@@ -1,4 +1,5 @@
import type { OverridedMixpanel } from 'mixpanel-browser'
import { watch } from 'vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import {
@@ -41,9 +42,30 @@ import type {
WorkflowCreatedMetadata,
WorkflowImportMetadata
} from '../../types'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import { TelemetryEvents } from '../../types'
import { normalizeSurveyResponses } from '../../utils/surveyNormalization'
const DEFAULT_DISABLED_EVENTS = [
TelemetryEvents.WORKFLOW_OPENED,
TelemetryEvents.PAGE_VISIBILITY_CHANGED,
TelemetryEvents.TAB_COUNT_TRACKING,
TelemetryEvents.NODE_SEARCH,
TelemetryEvents.NODE_SEARCH_RESULT_SELECTED,
TelemetryEvents.TEMPLATE_FILTER_CHANGED,
TelemetryEvents.SETTING_CHANGED,
TelemetryEvents.HELP_CENTER_OPENED,
TelemetryEvents.HELP_RESOURCE_CLICKED,
TelemetryEvents.HELP_CENTER_CLOSED,
TelemetryEvents.WORKFLOW_CREATED,
TelemetryEvents.UI_BUTTON_CLICKED
] as const satisfies TelemetryEventName[]
const TELEMETRY_EVENT_SET = new Set<TelemetryEventName>(
Object.values(TelemetryEvents) as TelemetryEventName[]
)
interface QueuedEvent {
eventName: TelemetryEventName
properties?: TelemetryEventProperties
@@ -67,8 +89,19 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
private eventQueue: QueuedEvent[] = []
private isInitialized = false
private lastTriggerSource: ExecutionTriggerSource | undefined
private disabledEvents = new Set<TelemetryEventName>(DEFAULT_DISABLED_EVENTS)
constructor() {
this.configureDisabledEvents(
(window.__CONFIG__ as Partial<RemoteConfig> | undefined) ?? null
)
watch(
remoteConfig,
(config) => {
this.configureDisabledEvents(config)
},
{ immediate: true }
)
const token = window.__CONFIG__?.mixpanel_token
if (token) {
@@ -131,6 +164,10 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
return
}
if (this.disabledEvents.has(eventName)) {
return
}
const event: QueuedEvent = { eventName, properties }
if (this.isInitialized && this.mixpanel) {
@@ -146,6 +183,27 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
}
}
private configureDisabledEvents(config: Partial<RemoteConfig> | null): void {
const disabledSource =
config?.telemetry_disabled_events ?? DEFAULT_DISABLED_EVENTS
this.disabledEvents = this.buildEventSet(disabledSource)
}
private buildEventSet(values: TelemetryEventName[]): Set<TelemetryEventName> {
return new Set(
values.filter((value) => {
const isValid = TELEMETRY_EVENT_SET.has(value)
if (!isValid && import.meta.env.DEV) {
console.warn(
`Unknown telemetry event name in disabled list: ${value}`
)
}
return isValid
})
)
}
trackSignupOpened(): void {
this.trackEvent(TelemetryEvents.USER_SIGN_UP_OPENED)
}

View File

@@ -13,6 +13,7 @@ import type {
ComfyWorkflowJSON,
NodeId
} from '@/platform/workflow/validation/schemas/workflowSchema'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
import { api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
@@ -329,6 +330,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
tabActivationHistory.value.shift()
}
useCanvasStore().linearMode = !!loadedWorkflow.activeState.extra?.linearMode
return loadedWorkflow
}

View File

@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
import { clearPreservedQuery } from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useTemplateWorkflows } from './useTemplateWorkflows'
@@ -13,9 +14,10 @@ import { useTemplateWorkflows } from './useTemplateWorkflows'
* Supports URLs like:
* - /?template=flux_simple (loads with default source)
* - /?template=flux_simple&source=custom (loads from custom source)
* - /?template=flux_simple&mode=linear (loads template in linear mode)
*
* Input validation:
* - Template and source parameters must match: ^[a-zA-Z0-9_-]+$
* - Template, source, and mode parameters must match: ^[a-zA-Z0-9_-]+$
* - Invalid formats are rejected with console warnings
*/
export function useTemplateUrlLoader() {
@@ -24,7 +26,10 @@ export function useTemplateUrlLoader() {
const { t } = useI18n()
const toast = useToast()
const templateWorkflows = useTemplateWorkflows()
const canvasStore = useCanvasStore()
const TEMPLATE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.TEMPLATE
const SUPPORTED_MODES = ['linear'] as const
type SupportedMode = (typeof SUPPORTED_MODES)[number]
/**
* Validates parameter format to prevent path traversal and injection attacks
@@ -34,12 +39,20 @@ export function useTemplateUrlLoader() {
}
/**
* Removes template and source parameters from URL
* Type guard to check if a value is a supported mode
*/
const isSupportedMode = (mode: string): mode is SupportedMode => {
return SUPPORTED_MODES.includes(mode as SupportedMode)
}
/**
* Removes template, source, and mode parameters from URL
*/
const cleanupUrlParams = () => {
const newQuery = { ...route.query }
delete newQuery.template
delete newQuery.source
delete newQuery.mode
void router.replace({ query: newQuery })
}
@@ -70,6 +83,24 @@ export function useTemplateUrlLoader() {
return
}
const modeParam = route.query.mode as string | undefined
if (
modeParam &&
(typeof modeParam !== 'string' || !isValidParameter(modeParam))
) {
console.warn(
`[useTemplateUrlLoader] Invalid mode parameter format: ${modeParam}`
)
return
}
if (modeParam && !isSupportedMode(modeParam)) {
console.warn(
`[useTemplateUrlLoader] Unsupported mode parameter: ${modeParam}. Supported modes: ${SUPPORTED_MODES.join(', ')}`
)
}
try {
await templateWorkflows.loadTemplates()
@@ -87,6 +118,9 @@ export function useTemplateUrlLoader() {
}),
life: 3000
})
} else if (modeParam === 'linear') {
// Set linear mode after successful template load
canvasStore.linearMode = true
}
} catch (error) {
console.error(

View File

@@ -40,6 +40,8 @@ export const useCanvasStore = defineStore('canvas', () => {
// Reactive scale percentage that syncs with app.canvas.ds.scale
const appScalePercentage = ref(100)
const linearMode = ref(false)
// Set up scale synchronization when canvas is available
let originalOnChanged: ((scale: number, offset: Point) => void) | undefined =
undefined
@@ -138,6 +140,7 @@ export const useCanvasStore = defineStore('canvas', () => {
groupSelected,
rerouteSelected,
appScalePercentage,
linearMode,
updateSelectedItems,
getCanvas,
setAppZoomFromPercentage,

View File

@@ -29,12 +29,6 @@ vi.mock('@/renderer/core/layout/transform/useTransformState', () => {
}
})
vi.mock('@/renderer/extensions/vueNodes/lod/useLOD', () => ({
useLOD: vi.fn(() => ({
isLOD: false
}))
}))
function createMockCanvas(): LGraphCanvas {
return {
canvas: {

View File

@@ -1,31 +0,0 @@
import type { InjectionKey } from 'vue'
import type { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
/**
* Lightweight, injectable transform state used by layout-aware components.
*
* Consumers use this interface to convert coordinates between LiteGraph's
* canvas space and the DOM's screen space, access the current pan/zoom
* (camera), and perform basic viewport culling checks.
*
* Coordinate mapping:
* - screen = (canvas + offset) * scale
* - canvas = screen / scale - offset
*
* The full implementation and additional helpers live in
* `useTransformState()`. This interface deliberately exposes only the
* minimal surface needed outside that composable.
*
* @example
* const state = inject(TransformStateKey)!
* const screen = state.canvasToScreen({ x: 100, y: 50 })
*/
export interface TransformState
extends Pick<
ReturnType<typeof useTransformState>,
'screenToCanvas' | 'canvasToScreen' | 'camera' | 'isNodeInViewport'
> {}
export const TransformStateKey: InjectionKey<TransformState> =
Symbol('transformState')

View File

@@ -9,6 +9,8 @@ import { computed, customRef, ref } from 'vue'
import type { ComputedRef, Ref } from 'vue'
import * as Y from 'yjs'
import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
import { ACTOR_CONFIG } from '@/renderer/core/layout/constants'
import { LayoutSource } from '@/renderer/core/layout/types'
import type {
@@ -1414,8 +1416,8 @@ class LayoutStoreImpl implements LayoutStore {
batchUpdateNodeBounds(updates: NodeBoundsUpdate[]): void {
if (updates.length === 0) return
// Set source to Vue for these DOM-driven updates
const originalSource = this.currentSource
const shouldNormalizeHeights = originalSource === LayoutSource.DOM
this.currentSource = LayoutSource.Vue
const nodeIds: NodeId[] = []
@@ -1426,8 +1428,15 @@ class LayoutStoreImpl implements LayoutStore {
if (!ynode) continue
const currentLayout = yNodeToLayout(ynode)
const normalizedBounds = shouldNormalizeHeights
? {
...bounds,
height: removeNodeTitleHeight(bounds.height)
}
: bounds
boundsRecord[nodeId] = {
bounds,
bounds: normalizedBounds,
previousBounds: currentLayout.bounds
}
nodeIds.push(nodeId)

View File

@@ -8,6 +8,7 @@ import { onUnmounted, ref } from 'vue'
import type { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { addNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
/**
* Composable for syncing LiteGraph with the Layout system
@@ -43,12 +44,13 @@ export function useLayoutSync() {
liteNode.pos[1] = layout.position.y
}
const targetHeight = addNodeTitleHeight(layout.size.height)
if (
liteNode.size[0] !== layout.size.width ||
liteNode.size[1] !== layout.size.height
liteNode.size[1] !== targetHeight
) {
// Use setSize() to trigger onResize callback
liteNode.setSize([layout.size.width, layout.size.height])
liteNode.setSize([layout.size.width, targetHeight])
}
}

View File

@@ -4,8 +4,7 @@
:class="
cn(
'absolute inset-0 w-full h-full pointer-events-none',
isInteracting ? 'transform-pane--interacting' : 'will-change-auto',
isLOD && 'isLOD'
isInteracting ? 'transform-pane--interacting' : 'will-change-auto'
)
"
:style="transformStyle"
@@ -17,13 +16,11 @@
<script setup lang="ts">
import { useRafFn } from '@vueuse/core'
import { computed, provide } from 'vue'
import { computed } from 'vue'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
import { useTransformSettling } from '@/renderer/core/layout/transform/useTransformSettling'
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
import { useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
import { cn } from '@/utils/tailwindUtil'
interface TransformPaneProps {
@@ -32,29 +29,13 @@ interface TransformPaneProps {
const props = defineProps<TransformPaneProps>()
const {
camera,
transformStyle,
syncWithCanvas,
canvasToScreen,
screenToCanvas,
isNodeInViewport
} = useTransformState()
const { isLOD } = useLOD(camera)
const { transformStyle, syncWithCanvas } = useTransformState()
const canvasElement = computed(() => props.canvas?.canvas)
const { isTransforming: isInteracting } = useTransformSettling(canvasElement, {
settleDelay: 512
})
provide(TransformStateKey, {
camera,
canvasToScreen,
screenToCanvas,
isNodeInViewport
})
const emit = defineEmits<{
transformUpdate: []
}>()

View File

@@ -52,6 +52,7 @@
import { computed, reactive, readonly } from 'vue'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import { createSharedComposable } from '@vueuse/core'
interface Point {
x: number
@@ -64,7 +65,7 @@ interface Camera {
z: number // scale/zoom
}
export const useTransformState = () => {
function useTransformStateIndividual() {
// Reactive state mirroring LiteGraph's canvas transform
const camera = reactive<Camera>({
x: 0,
@@ -91,7 +92,7 @@ export const useTransformState = () => {
*
* @param canvas - LiteGraph canvas instance with DragAndScale (ds) transform state
*/
const syncWithCanvas = (canvas: LGraphCanvas) => {
function syncWithCanvas(canvas: LGraphCanvas) {
if (!canvas || !canvas.ds) return
// Mirror LiteGraph's transform state to Vue's reactive state
@@ -112,7 +113,7 @@ export const useTransformState = () => {
* @param point - Point in canvas coordinate system
* @returns Point in screen coordinate system
*/
const canvasToScreen = (point: Point): Point => {
function canvasToScreen(point: Point): Point {
return {
x: (point.x + camera.x) * camera.z,
y: (point.y + camera.y) * camera.z
@@ -138,10 +139,10 @@ export const useTransformState = () => {
}
// Get node's screen bounds for culling
const getNodeScreenBounds = (
pos: ArrayLike<number>,
size: ArrayLike<number>
): DOMRect => {
function getNodeScreenBounds(
pos: [number, number],
size: [number, number]
): DOMRect {
const topLeft = canvasToScreen({ x: pos[0], y: pos[1] })
const width = size[0] * camera.z
const height = size[1] * camera.z
@@ -150,23 +151,23 @@ export const useTransformState = () => {
}
// Helper: Calculate zoom-adjusted margin for viewport culling
const calculateAdjustedMargin = (baseMargin: number): number => {
function calculateAdjustedMargin(baseMargin: number): number {
if (camera.z < 0.1) return Math.min(baseMargin * 5, 2.0)
if (camera.z > 3.0) return Math.max(baseMargin * 0.5, 0.05)
return baseMargin
}
// Helper: Check if node is too small to be visible at current zoom
const isNodeTooSmall = (nodeSize: ArrayLike<number>): boolean => {
function isNodeTooSmall(nodeSize: [number, number]): boolean {
const nodeScreenSize = Math.max(nodeSize[0], nodeSize[1]) * camera.z
return nodeScreenSize < 4
}
// Helper: Calculate expanded viewport bounds with margin
const getExpandedViewportBounds = (
function getExpandedViewportBounds(
viewport: { width: number; height: number },
margin: number
) => {
) {
const marginX = viewport.width * margin
const marginY = viewport.height * margin
return {
@@ -178,11 +179,11 @@ export const useTransformState = () => {
}
// Helper: Test if node intersects with viewport bounds
const testViewportIntersection = (
function testViewportIntersection(
screenPos: { x: number; y: number },
nodeSize: ArrayLike<number>,
nodeSize: [number, number],
bounds: { left: number; right: number; top: number; bottom: number }
): boolean => {
): boolean {
const nodeRight = screenPos.x + nodeSize[0] * camera.z
const nodeBottom = screenPos.y + nodeSize[1] * camera.z
@@ -195,12 +196,12 @@ export const useTransformState = () => {
}
// Check if node is within viewport with frustum and size-based culling
const isNodeInViewport = (
nodePos: ArrayLike<number>,
nodeSize: ArrayLike<number>,
function isNodeInViewport(
nodePos: [number, number],
nodeSize: [number, number],
viewport: { width: number; height: number },
margin: number = 0.2
): boolean => {
): boolean {
// Early exit for tiny nodes
if (isNodeTooSmall(nodeSize)) return false
@@ -212,10 +213,10 @@ export const useTransformState = () => {
}
// Get viewport bounds in canvas coordinates (for spatial index queries)
const getViewportBounds = (
function getViewportBounds(
viewport: { width: number; height: number },
margin: number = 0.2
) => {
) {
const marginX = viewport.width * margin
const marginY = viewport.height * margin
@@ -244,3 +245,7 @@ export const useTransformState = () => {
getViewportBounds
}
}
export const useTransformState = createSharedComposable(
useTransformStateIndividual
)

View File

@@ -10,6 +10,7 @@ import type { ComputedRef, Ref } from 'vue'
export enum LayoutSource {
Canvas = 'canvas',
Vue = 'vue',
DOM = 'dom',
External = 'external'
}

View File

@@ -0,0 +1,7 @@
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
export const removeNodeTitleHeight = (height: number) =>
Math.max(0, height - (LiteGraph.NODE_TITLE_HEIGHT || 0))
export const addNodeTitleHeight = (height: number) =>
height + LiteGraph.NODE_TITLE_HEIGHT

View File

@@ -11,9 +11,9 @@ interface SpatialBounds {
height: number
}
interface PositionedNode {
pos: ArrayLike<number>
size: ArrayLike<number>
export interface PositionedNode {
pos: [number, number]
size: [number, number]
}
/**

View File

@@ -1,5 +1,6 @@
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import { calculateNodeBounds } from '@/renderer/core/spatial/boundsCalculator'
import type { PositionedNode } from '@/renderer/core/spatial/boundsCalculator'
import type {
IMinimapDataSource,
@@ -29,10 +30,12 @@ export abstract class AbstractMinimapDataSource implements IMinimapDataSource {
}
// Convert MinimapNodeData to the format expected by calculateNodeBounds
const compatibleNodes = nodes.map((node) => ({
pos: [node.x, node.y],
size: [node.width, node.height]
}))
const compatibleNodes = nodes.map(
(node): PositionedNode => ({
pos: [node.x, node.y],
size: [node.width, node.height]
})
)
const bounds = calculateNodeBounds(compatibleNodes)
if (!bounds) {

View File

@@ -83,20 +83,17 @@
</div>
</div>
<div class="relative">
<!-- Video Dimensions -->
<div class="mt-2 text-center text-xs text-white">
<span v-if="videoError" class="text-red-400">
{{ $t('g.errorLoadingVideo') }}
</span>
<span v-else-if="isLoading" class="text-smoke-400">
{{ $t('g.loading') }}...
</span>
<span v-else>
{{ actualDimensions || $t('g.calculatingDimensions') }}
</span>
</div>
<LODFallback />
<!-- Video Dimensions -->
<div class="mt-2 text-center text-xs text-white">
<span v-if="videoError" class="text-red-400">
{{ $t('g.errorLoadingVideo') }}
</span>
<span v-else-if="isLoading" class="text-smoke-400">
{{ $t('g.loading') }}...
</span>
<span v-else>
{{ actualDimensions || $t('g.calculatingDimensions') }}
</span>
</div>
</div>
</template>
@@ -110,8 +107,6 @@ import { useI18n } from 'vue-i18n'
import { downloadFile } from '@/base/common/downloadUtil'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import LODFallback from './components/LODFallback.vue'
interface VideoPreviewProps {
/** Array of video URLs to display */
readonly imageUrls: readonly string[] // Named imageUrls for consistency with parent components

View File

@@ -93,20 +93,17 @@
</div>
</div>
<div class="relative">
<!-- Image Dimensions -->
<div class="mt-2 text-center text-xs text-white">
<span v-if="imageError" class="text-red-400">
{{ $t('g.errorLoadingImage') }}
</span>
<span v-else-if="isLoading" class="text-smoke-400">
{{ $t('g.loading') }}...
</span>
<span v-else>
{{ actualDimensions || $t('g.calculatingDimensions') }}
</span>
</div>
<LODFallback />
<!-- Image Dimensions -->
<div class="mt-2 text-center text-xs text-white">
<span v-if="imageError" class="text-red-400">
{{ $t('g.errorLoadingImage') }}
</span>
<span v-else-if="isLoading" class="text-smoke-400">
{{ $t('g.loading') }}...
</span>
<span v-else>
{{ actualDimensions || $t('g.calculatingDimensions') }}
</span>
</div>
</div>
</template>
@@ -122,8 +119,6 @@ import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import LODFallback from './LODFallback.vue'
interface ImagePreviewProps {
/** Array of image URLs to display */
readonly imageUrls: readonly string[]

View File

@@ -5,19 +5,18 @@
<SlotConnectionDot
ref="connectionDotRef"
:color="slotColor"
:class="cn('-translate-x-1/2', 'w-3', errorClassesDot)"
:class="cn('-translate-x-1/2 w-3', errorClassesDot)"
@pointerdown="onPointerDown"
/>
<!-- Slot Name -->
<div class="relative h-full flex items-center min-w-0">
<div class="h-full flex items-center min-w-0">
<span
v-if="!dotOnly"
:class="cn('truncate text-xs font-normal lod-toggle', labelClasses)"
:class="cn('truncate text-xs font-normal', labelClasses)"
>
{{ slotData.localized_name || slotData.name || `Input ${index}` }}
</span>
<LODFallback />
</div>
</div>
</template>
@@ -37,7 +36,6 @@ import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composabl
import { useExecutionStore } from '@/stores/executionStore'
import { cn } from '@/utils/tailwindUtil'
import LODFallback from './LODFallback.vue'
import SlotConnectionDot from './SlotConnectionDot.vue'
interface InputSlotProps {
@@ -48,6 +46,7 @@ interface InputSlotProps {
connected?: boolean
compatible?: boolean
dotOnly?: boolean
socketless?: boolean
}
const props = defineProps<InputSlotProps>()
@@ -121,7 +120,8 @@ const slotWrapperClass = computed(() =>
'lg-slot--connected': props.connected,
'lg-slot--compatible': props.compatible,
'opacity-40': shouldDim.value
}
},
props.socketless && 'pointer-events-none invisible'
)
)

View File

@@ -19,12 +19,12 @@
'outline-transparent outline-2',
borderClass,
outlineClass,
cursorClass,
{
'before:rounded-2xl before:pointer-events-none before:absolute before:bg-bypass/60 before:inset-0':
bypassed,
'before:rounded-2xl before:pointer-events-none before:absolute before:inset-0':
muted,
'will-change-transform': isDragging,
'ring-4 ring-primary-500 bg-primary-500/10': isDraggingOver
},
@@ -39,10 +39,10 @@
zIndex: zIndex,
opacity: nodeOpacity,
'--component-node-background': nodeBodyBackgroundColor
},
dragStyle
}
]"
v-bind="pointerHandlers"
v-bind="remainingPointerHandlers"
@pointerdown="nodeOnPointerdown"
@wheel="handleWheel"
@contextmenu="handleContextMenu"
@dragover.prevent="handleDragOver"
@@ -99,18 +99,14 @@
/>
</div>
<!-- Node Body - rendered based on LOD level and collapsed state -->
<div
class="flex flex-1 flex-col gap-1 pb-2"
:data-testid="`node-body-${nodeData.id}`"
>
<!-- Slots only rendered at full detail -->
<NodeSlots :node-data="nodeData" />
<!-- Widgets rendered at reduced+ detail -->
<NodeWidgets v-if="nodeData.widgets?.length" :node-data="nodeData" />
<!-- Custom content at reduced+ detail -->
<div v-if="hasCustomContent" class="min-h-0 flex-1 flex">
<NodeContent :node-data="nodeData" :media="nodeMedia" />
</div>
@@ -137,24 +133,31 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, inject, onErrorCaptured, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onErrorCaptured, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { toggleNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { st } from '@/i18n'
import { LGraphEventMode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import {
LGraphCanvas,
LGraphEventMode,
LiteGraph
} from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
import SlotConnectionDot from '@/renderer/extensions/vueNodes/components/SlotConnectionDot.vue'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import { useNodePointerInteractions } from '@/renderer/extensions/vueNodes/composables/useNodePointerInteractions'
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
import { useNodeExecutionState } from '@/renderer/extensions/vueNodes/execution/useNodeExecutionState'
import { useNodeDrag } from '@/renderer/extensions/vueNodes/layout/useNodeDrag'
import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout'
import { useNodePreviewState } from '@/renderer/extensions/vueNodes/preview/useNodePreviewState'
import { nonWidgetedInputs } from '@/renderer/extensions/vueNodes/utils/nodeDataUtils'
@@ -188,16 +191,13 @@ const { nodeData, error = null } = defineProps<LGraphNodeProps>()
const { t } = useI18n()
const {
handleNodeCollapse,
handleNodeTitleUpdate,
handleNodeSelect,
handleNodeRightClick
} = useNodeEventHandlers()
const { handleNodeCollapse, handleNodeTitleUpdate, handleNodeRightClick } =
useNodeEventHandlers()
const { bringNodeToFront } = useNodeZIndex()
useVueElementTracking(() => nodeData.id, 'node')
const transformState = inject(TransformStateKey)
const transformState = useTransformState()
if (!transformState) {
throw new Error(
'TransformState must be provided for node resize functionality'
@@ -272,10 +272,24 @@ onErrorCaptured((error) => {
})
const { position, size, zIndex, moveNodeTo } = useNodeLayout(() => nodeData.id)
const { pointerHandlers, isDragging, dragStyle } = useNodePointerInteractions(
() => nodeData,
handleNodeSelect
)
const { pointerHandlers } = useNodePointerInteractions(() => nodeData.id)
const { onPointerdown, ...remainingPointerHandlers } = pointerHandlers
const { startDrag } = useNodeDrag()
async function nodeOnPointerdown(event: PointerEvent) {
if (event.altKey && lgraphNode.value) {
const result = LGraphCanvas.cloneNodes([lgraphNode.value])
if (result?.created?.length) {
const [newNode] = result.created
startDrag(event, `${newNode.id}`)
layoutStore.isDraggingVueNodes.value = true
await nextTick()
bringNodeToFront(`${newNode.id}`)
return
}
}
onPointerdown(event)
}
// Handle right-click context menu
const handleContextMenu = (event: MouseEvent) => {
@@ -283,7 +297,7 @@ const handleContextMenu = (event: MouseEvent) => {
event.stopPropagation()
// First handle the standard right-click behavior (selection)
handleNodeRightClick(event as PointerEvent, nodeData)
handleNodeRightClick(event as PointerEvent, nodeData.id)
// Show the node options menu at the cursor position
const targetElement = event.currentTarget as HTMLElement
@@ -422,6 +436,16 @@ const outlineClass = computed(() => {
)
})
const cursorClass = computed(() => {
return cn(
nodeData.flags?.pinned
? 'cursor-default'
: layoutStore.isDraggingVueNodes.value
? 'cursor-grabbing'
: 'cursor-grab'
)
})
// Event handlers
const handleCollapse = () => {
handleNodeCollapse(nodeData.id, !isCollapsed.value)

View File

@@ -1,5 +0,0 @@
<template>
<div
class="lod-fallback absolute inset-0 h-full w-full bg-node-component-widget-skeleton-surface"
></div>
</template>

View File

@@ -18,7 +18,7 @@
<div class="flex items-center justify-between gap-2.5 min-w-0">
<!-- Collapse/Expand Button -->
<div class="relative grow-1 flex items-center gap-2.5 min-w-0 flex-1">
<div class="lod-toggle flex shrink-0 items-center px-0.5">
<div class="flex shrink-0 items-center px-0.5">
<IconButton
size="fit-content"
type="transparent"
@@ -44,7 +44,7 @@
<!-- Node Title -->
<div
v-tooltip.top="tooltipConfig"
class="lod-toggle flex min-w-0 flex-1 items-center gap-2 text-sm font-bold"
class="flex min-w-0 flex-1 items-center gap-2 text-sm font-bold"
data-testid="node-title"
>
<div class="truncate min-w-0 flex-1">
@@ -57,10 +57,9 @@
/>
</div>
</div>
<LODFallback />
</div>
<div class="lod-toggle flex shrink-0 items-center justify-between gap-2">
<div class="flex shrink-0 items-center justify-between gap-2">
<NodeBadge
v-for="badge of nodeBadges"
:key="badge.text"
@@ -112,7 +111,6 @@ import {
} from '@/utils/graphTraversalUtil'
import { cn } from '@/utils/tailwindUtil'
import LODFallback from './LODFallback.vue'
import type { NodeBadgeProps } from './NodeBadge.vue'
interface NodeHeaderProps {

View File

@@ -40,6 +40,7 @@
}"
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
:index="widget.slotMetadata.index"
:socketless="widget.simplified.spec?.socketless"
dot-only
/>
</div>

View File

@@ -5,11 +5,10 @@
<!-- Slot Name -->
<span
v-if="!dotOnly"
class="lod-toggle text-xs font-normal truncate text-node-component-slot-text"
class="text-xs font-normal truncate text-node-component-slot-text"
>
{{ slotData.localized_name || slotData.name || `Output ${index}` }}
</span>
<LODFallback />
</div>
<!-- Connection Dot -->
<SlotConnectionDot
@@ -35,7 +34,6 @@ import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composabl
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
import { cn } from '@/utils/tailwindUtil'
import LODFallback from './LODFallback.vue'
import SlotConnectionDot from './SlotConnectionDot.vue'
interface OutputSlotProps {

View File

@@ -1,141 +0,0 @@
# ComfyUI Widget LOD System: Architecture and Implementation
## Executive Summary
The ComfyUI widget Level of Detail (LOD) system has evolved from a reactive, Vue-based approach to a CSS-driven, non-reactive implementation. This architectural shift was driven by performance requirements at scale (300-500+ nodes) and a deeper understanding of browser rendering pipelines. The current system prioritizes consistent performance over granular control, leveraging CSS visibility rules rather than component mounting/unmounting.
## The Two Approaches: Reactive vs. Static LOD
### Approach 1: Reactive LOD (Original Design)
The original design envisioned a system where each widget would reactively respond to zoom level changes, controlling its own detail level through Vue's reactivity system. Widgets would import LOD utilities, compute what to show based on zoom level, and conditionally render elements using `v-if` and `v-show` directives.
**The promise of this approach was compelling:** widgets could intelligently manage their complexity, progressively revealing detail as users zoomed in, much like how mapping applications work. Developers would have fine-grained control over performance optimization.
### Approach 2: Static LOD with CSS (Current Implementation)
The implemented system takes a fundamentally different approach. All widget content is loaded and remains in the DOM at all times. Visual simplification happens through CSS rules, primarily using `visibility: hidden` and simplified visual representations (gray rectangles) at distant zoom levels. No reactive updates occur when zoom changes—only CSS rules apply differently.
**This approach seems counterintuitive at first:** aren't we wasting resources by keeping everything loaded? The answer reveals a deeper truth about modern browser rendering.
## The GPU Texture Bottleneck
The key insight driving the current architecture comes from understanding how browsers handle CSS transforms:
When you apply a CSS transform to a parent element (the "transformpane" in ComfyUI's case), the browser promotes that entire subtree to a compositor layer. This creates a single GPU texture containing all the transformed content. Here's where traditional performance intuitions break down:
### Traditional Assumption
"If we render less content, we get better performance. Therefore, hiding complex widgets should improve zoom/pan performance."
### Actual Browser Behavior
When all nodes are children of a single transformed parent:
1. The browser creates one large GPU texture for the entire node graph
2. The texture dimensions are determined by the bounding box of all content
3. Whether individual pixels are simple (solid rectangles) or complex (detailed widgets) has minimal impact
4. The performance bottleneck is the texture size itself, not the complexity of rasterization
This means that even if we reduce every node to a simple gray rectangle, we're still paying the cost of a massive GPU texture when viewing hundreds of nodes simultaneously. The texture dimensions remain the same whether it contains simple or complex content.
## Two Distinct Performance Concerns
The analysis reveals two often-conflated performance considerations that should be understood separately:
### 1. Rendering Performance
**Question:** How fast can the browser paint and composite the node graph during interactions?
**Traditional thinking:** Show less content → render faster
**Reality with CSS transforms:** GPU texture size dominates performance, not content complexity
The CSS transform approach means that zoom, pan, and drag operations are already optimized—they're just transforming an existing GPU texture. The cost is in the initial rasterization and texture upload, which happens regardless of content complexity when texture dimensions are fixed.
### 2. Memory and Lifecycle Management
**Question:** How much memory do widget instances consume, and what's the cost of maintaining them?
This is where unmounting widgets might theoretically help:
- Complex widgets (3D viewers, chart renderers) might hold significant memory
- Event listeners and reactive watchers consume resources
- Some widgets might run background processes or animations
However, the cost of mounting/unmounting hundreds of widgets on zoom changes could create worse performance problems than the memory savings provide. Vue's virtual DOM diffing for hundreds of nodes is expensive, potentially causing noticeable lag during zoom transitions.
## Design Philosophy and Trade-offs
The current CSS-based approach makes several deliberate trade-offs:
### What We Optimize For
1. **Consistent, predictable performance** - No reactivity means no sudden performance cliffs
2. **Smooth zoom/pan interactions** - CSS transforms are hardware-accelerated
3. **Simple widget development** - Widget authors don't need to implement LOD logic
4. **Reliable state preservation** - Widgets never lose state from unmounting
### What We Accept
1. **Higher baseline memory usage** - All widgets remain mounted
2. **Less granular control** - Widgets can't optimize their own LOD behavior
3. **Potential waste for exotic widgets** - A 3D renderer widget still runs when hidden
## Open Questions and Future Considerations
### Should widgets have any LOD control?
The current system provides a uniform gray rectangle fallback with CSS visibility hiding. This works for 99% of widgets, but raises questions:
**Scenario:** A widget renders a complex 3D scene or runs expensive computations
**Current behavior:** Hidden via CSS but still mounted
**Question:** Should such widgets be able to opt into unmounting at distance?
The challenge is that introducing selective unmounting would require:
- Maintaining widget state across mount/unmount cycles
- Accepting the performance cost of remounting when zooming in
- Adding complexity to the widget API
### Could we reduce GPU texture size?
Since texture dimensions are the limiting factor, could we:
- Use multiple compositor layers for different regions (chunk the transformpane)?
- Render the nodes using the canvas fallback when 500+ nodes and < 30% zoom.
These approaches would require significant architectural changes and might introduce their own performance trade-offs.
### Is there a hybrid approach?
Could we identify specific threshold scenarios where reactive LOD makes sense?
- When node count is low (< 50 nodes)
- For specifically registered "expensive" widgets
- At extreme zoom levels only
## Implementation Guidelines
Given the current architecture, here's how to work within the system:
### For Widget Developers
1. **Build widgets assuming they're always visible** - Don't rely on mount/unmount for cleanup
2. **Use CSS classes for zoom-responsive styling** - Let CSS handle visual changes
3. **Minimize background processing** - Assume your widget is always running
4. **Consider requestAnimationFrame throttling** - For animations that won't be visible when zoomed out
### For System Architects
1. **Monitor GPU memory usage** - The single texture approach has memory implications
2. **Consider viewport culling** - Not rendering off-screen nodes could reduce texture size
3. **Profile real-world workflows** - Theoretical performance differs from actual usage patterns
4. **Document the architecture clearly** - The non-obvious performance characteristics need explanation
## Conclusion
The ComfyUI LOD system represents a pragmatic choice: accepting higher memory usage and less granular control in exchange for predictable performance and implementation simplicity. By understanding that GPU texture dimensions—not rasterization complexity—drive performance in a CSS-transform-based architecture, the team has chosen an approach that may seem counterintuitive but actually aligns with browser rendering realities.
The system works well for the common case of hundreds of relatively simple widgets. Edge cases involving genuinely expensive widgets may need future consideration, but the current approach provides a solid foundation that avoids the performance pitfalls of reactive LOD at scale.
The key insight—that showing less doesn't necessarily mean rendering faster when everything lives in a single GPU texture—challenges conventional web performance wisdom and demonstrates the importance of understanding the full rendering pipeline when making architectural decisions.

View File

@@ -10,12 +10,12 @@
*/
import { createSharedComposable } from '@vueuse/core'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
import { isMultiSelectKey } from '@/renderer/extensions/vueNodes/utils/selectionUtils'
import type { NodeId } from '@/renderer/core/layout/types'
function useNodeEventHandlersIndividual() {
const canvasStore = useCanvasStore()
@@ -27,12 +27,12 @@ function useNodeEventHandlersIndividual() {
* Handle node selection events
* Supports single selection and multi-select with Ctrl/Cmd
*/
const handleNodeSelect = (event: PointerEvent, nodeData: VueNodeData) => {
function handleNodeSelect(event: PointerEvent, nodeId: NodeId) {
if (!shouldHandleNodePointerEvents.value) return
if (!canvasStore.canvas || !nodeManager.value) return
const node = nodeManager.value.getNode(nodeData.id)
const node = nodeManager.value.getNode(nodeId)
if (!node) return
const multiSelect = isMultiSelectKey(event)
@@ -53,7 +53,7 @@ function useNodeEventHandlersIndividual() {
// Bring node to front when clicked (similar to LiteGraph behavior)
// Skip if node is pinned to avoid unwanted movement
if (!node.flags?.pinned) {
bringNodeToFront(nodeData.id)
bringNodeToFront(nodeId)
}
// Update canvas selection tracking
@@ -64,7 +64,7 @@ function useNodeEventHandlersIndividual() {
* Handle node collapse/expand state changes
* Uses LiteGraph's native collapse method for proper state management
*/
const handleNodeCollapse = (nodeId: string, collapsed: boolean) => {
function handleNodeCollapse(nodeId: NodeId, collapsed: boolean) {
if (!shouldHandleNodePointerEvents.value) return
if (!nodeManager.value) return
@@ -83,7 +83,7 @@ function useNodeEventHandlersIndividual() {
* Handle node title updates
* Updates the title in LiteGraph for persistence across sessions
*/
const handleNodeTitleUpdate = (nodeId: string, newTitle: string) => {
function handleNodeTitleUpdate(nodeId: NodeId, newTitle: string) {
if (!shouldHandleNodePointerEvents.value) return
if (!nodeManager.value) return
@@ -95,41 +95,16 @@ function useNodeEventHandlersIndividual() {
node.title = newTitle
}
/**
* Handle node double-click events
* Can be used for custom actions like opening node editor
*/
const handleNodeDoubleClick = (
event: PointerEvent,
nodeData: VueNodeData
) => {
if (!shouldHandleNodePointerEvents.value) return
if (!canvasStore.canvas || !nodeManager.value) return
const node = nodeManager.value.getNode(nodeData.id)
if (!node) return
// Prevent default browser behavior
event.preventDefault()
// TODO: add custom double-click behavior here
// For now, ensure node is selected
if (!node.selected) {
handleNodeSelect(event, nodeData)
}
}
/**
* Handle node right-click context menu events
* Integrates with LiteGraph's context menu system
*/
const handleNodeRightClick = (event: PointerEvent, nodeData: VueNodeData) => {
function handleNodeRightClick(event: PointerEvent, nodeId: NodeId) {
if (!shouldHandleNodePointerEvents.value) return
if (!canvasStore.canvas || !nodeManager.value) return
const node = nodeManager.value.getNode(nodeData.id)
const node = nodeManager.value.getNode(nodeId)
if (!node) return
// Prevent default context menu
@@ -137,128 +112,17 @@ function useNodeEventHandlersIndividual() {
// Select the node if not already selected
if (!node.selected) {
handleNodeSelect(event, nodeData)
handleNodeSelect(event, nodeId)
}
// Let LiteGraph handle the context menu
// The canvas will handle showing the appropriate context menu
}
/**
* Handle node drag start events
* Prepares node for dragging and sets appropriate visual state
*/
const handleNodeDragStart = (event: DragEvent, nodeData: VueNodeData) => {
if (!shouldHandleNodePointerEvents.value) return
if (!canvasStore.canvas || !nodeManager.value) return
const node = nodeManager.value.getNode(nodeData.id)
if (!node) return
// Ensure node is selected before dragging
if (!node.selected) {
// Create a synthetic pointer event for selection
const syntheticEvent = new PointerEvent('pointerdown', {
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
bubbles: true
})
handleNodeSelect(syntheticEvent, nodeData)
}
// Set drag data for potential drop operations
if (event.dataTransfer) {
event.dataTransfer.setData('application/comfy-node-id', nodeData.id)
event.dataTransfer.effectAllowed = 'move'
}
}
/**
* Batch select multiple nodes
* Useful for selection toolbox or area selection
*/
const selectNodes = (nodeIds: string[], addToSelection = false) => {
if (!shouldHandleNodePointerEvents.value) return
if (!canvasStore.canvas || !nodeManager.value) return
if (!addToSelection) {
canvasStore.canvas.deselectAll()
}
nodeIds.forEach((nodeId) => {
const node = nodeManager.value?.getNode(nodeId)
if (node && canvasStore.canvas) {
canvasStore.canvas.select(node)
}
})
canvasStore.updateSelectedItems()
}
/**
* Ensure node is selected for shift-drag operations
* Handles special logic for promoting a node to selection when shift-dragging
* @param event - The pointer event (for multi-select key detection)
* @param nodeData - The node data for the node being dragged
* @param wasSelectedAtPointerDown - Whether the node was selected when pointer-down occurred
*/
const ensureNodeSelectedForShiftDrag = (
event: PointerEvent,
nodeData: VueNodeData,
wasSelectedAtPointerDown: boolean
) => {
if (wasSelectedAtPointerDown) return
const multiSelectKeyPressed = isMultiSelectKey(event)
if (!multiSelectKeyPressed) return
if (!canvasStore.canvas || !nodeManager.value) return
const node = nodeManager.value.getNode(nodeData.id)
if (!node || node.selected) return
const selectionCount = canvasStore.selectedItems.length
const addToSelection = selectionCount > 0
selectNodes([nodeData.id], addToSelection)
}
/**
* Deselect specific nodes
*/
const deselectNodes = (nodeIds: string[]) => {
if (!shouldHandleNodePointerEvents.value) return
if (!canvasStore.canvas || !nodeManager.value) return
nodeIds.forEach((nodeId) => {
const node = nodeManager.value?.getNode(nodeId)
if (node && canvasStore.canvas) {
canvasStore.canvas.deselect(node)
}
})
canvasStore.updateSelectedItems()
}
const deselectNode = (nodeId: string) => {
const node = nodeManager.value?.getNode(nodeId)
if (node) {
canvasStore.canvas?.deselect(node)
canvasStore.updateSelectedItems()
}
}
const toggleNodeSelectionAfterPointerUp = (
nodeId: string,
{
wasSelectedAtPointerDown,
multiSelect
}: {
wasSelectedAtPointerDown: boolean
multiSelect: boolean
}
) => {
function toggleNodeSelectionAfterPointerUp(
nodeId: NodeId,
multiSelect: boolean
) {
if (!shouldHandleNodePointerEvents.value) return
if (!canvasStore.canvas || !nodeManager.value) return
@@ -267,22 +131,19 @@ function useNodeEventHandlersIndividual() {
if (!node) return
if (!multiSelect) {
const multipleSelected = canvasStore.selectedItems.length > 1
if (multipleSelected && wasSelectedAtPointerDown) {
canvasStore.canvas.deselectAll()
canvasStore.canvas.select(node)
canvasStore.updateSelectedItems()
}
canvasStore.canvas.deselectAll()
canvasStore.canvas.select(node)
canvasStore.updateSelectedItems()
return
}
if (wasSelectedAtPointerDown) {
if (node.selected) {
canvasStore.canvas.deselect(node)
canvasStore.updateSelectedItems()
} else {
canvasStore.canvas.select(node)
}
// No action needed when the node was not previously selected since the pointer-down
// handler already added it to the selection.
canvasStore.updateSelectedItems()
}
return {
@@ -290,15 +151,9 @@ function useNodeEventHandlersIndividual() {
handleNodeSelect,
handleNodeCollapse,
handleNodeTitleUpdate,
handleNodeDoubleClick,
handleNodeRightClick,
handleNodeDragStart,
// Batch operations
selectNodes,
deselectNodes,
deselectNode,
ensureNodeSelectedForShiftDrag,
toggleNodeSelectionAfterPointerUp
}
}

View File

@@ -1,15 +1,15 @@
import { createPinia, setActivePinia } from 'pinia'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useNodePointerInteractions } from '@/renderer/extensions/vueNodes/composables/useNodePointerInteractions'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import { createTestingPinia } from '@pinia/testing'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { NodeLayout } from '@/renderer/core/layout/types'
import { useNodeDrag } from '@/renderer/extensions/vueNodes/layout/useNodeDrag'
const forwardEventToCanvasMock = vi.fn()
const deselectNodeMock = vi.fn()
const selectNodesMock = vi.fn()
const toggleNodeSelectionAfterPointerUpMock = vi.fn()
const ensureNodeSelectedForShiftDragMock = vi.fn()
const selectedItemsState: { items: Array<{ id?: string }> } = { items: [] }
// Mock the dependencies
@@ -20,19 +20,18 @@ vi.mock('@/renderer/core/canvas/useCanvasInteractions', () => ({
})
}))
vi.mock('@/renderer/extensions/vueNodes/layout/useNodeLayout', () => ({
useNodeLayout: () => ({
startDrag: vi.fn(),
endDrag: vi.fn().mockResolvedValue(undefined),
handleDrag: vi.fn().mockResolvedValue(undefined)
})
}))
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
layoutStore: {
isDraggingVueNodes: ref(false)
vi.mock('@/renderer/extensions/vueNodes/layout/useNodeDrag', () => {
const startDrag = vi.fn()
const handleDrag = vi.fn()
const endDrag = vi.fn()
return {
useNodeDrag: () => ({
startDrag,
handleDrag,
endDrag
})
}
}))
})
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
@@ -44,14 +43,23 @@ vi.mock('@/renderer/core/canvas/canvasStore', () => ({
vi.mock(
'@/renderer/extensions/vueNodes/composables/useNodeEventHandlers',
() => ({
useNodeEventHandlers: () => ({
deselectNode: deselectNodeMock,
selectNodes: selectNodesMock,
toggleNodeSelectionAfterPointerUp: toggleNodeSelectionAfterPointerUpMock,
ensureNodeSelectedForShiftDrag: ensureNodeSelectedForShiftDragMock
})
})
() => {
const handleNodeSelect = vi.fn()
const deselectNode = vi.fn()
const selectNodes = vi.fn()
const toggleNodeSelectionAfterPointerUp = vi.fn()
const ensureNodeSelectedForShiftDrag = vi.fn()
return {
useNodeEventHandlers: () => ({
handleNodeSelect,
deselectNode,
selectNodes,
toggleNodeSelectionAfterPointerUp,
ensureNodeSelectedForShiftDrag
})
}
}
)
vi.mock('@/composables/graph/useVueNodeLifecycle', () => ({
@@ -65,19 +73,35 @@ vi.mock('@/composables/graph/useVueNodeLifecycle', () => ({
})
}))
const createMockVueNodeData = (
overrides: Partial<VueNodeData> = {}
): VueNodeData => ({
id: 'test-node-123',
title: 'Test Node',
type: 'TestNodeType',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: [],
widgets: [],
...overrides
const mockData = vi.hoisted(() => {
const fakeNodeLayout: NodeLayout = {
id: '',
position: { x: 0, y: 0 },
size: { width: 100, height: 100 },
zIndex: 1,
visible: true,
bounds: {
x: 0,
y: 0,
width: 100,
height: 100
}
}
return { fakeNodeLayout }
})
vi.mock('@/renderer/core/layout/store/layoutStore', () => {
const isDraggingVueNodes = ref(false)
const fakeNodeLayoutRef = ref(mockData.fakeNodeLayout)
const getNodeLayoutRef = vi.fn(() => fakeNodeLayoutRef)
const setSource = vi.fn()
return {
layoutStore: {
isDraggingVueNodes,
getNodeLayoutRef,
setSource
}
}
})
const createPointerEvent = (
@@ -107,46 +131,34 @@ const createMouseEvent = (
describe('useNodePointerInteractions', () => {
beforeEach(async () => {
vi.clearAllMocks()
vi.restoreAllMocks()
selectedItemsState.items = []
setActivePinia(createPinia())
// Reset layout store state between tests
const { layoutStore } = await import(
'@/renderer/core/layout/store/layoutStore'
)
layoutStore.isDraggingVueNodes.value = false
setActivePinia(createTestingPinia())
})
it('should only start drag on left-click', async () => {
const mockNodeData = createMockVueNodeData()
const mockOnNodeSelect = vi.fn()
const { handleNodeSelect } = useNodeEventHandlers()
const { startDrag } = useNodeDrag()
const { pointerHandlers } = useNodePointerInteractions(
ref(mockNodeData),
mockOnNodeSelect
)
const { pointerHandlers } = useNodePointerInteractions('test-node-123')
// Right-click should not trigger selection
const rightClickEvent = createPointerEvent('pointerdown', { button: 2 })
pointerHandlers.onPointerdown(rightClickEvent)
expect(mockOnNodeSelect).not.toHaveBeenCalled()
expect(handleNodeSelect).not.toHaveBeenCalled()
// Left-click should trigger selection on pointer down
const leftClickEvent = createPointerEvent('pointerdown', { button: 0 })
pointerHandlers.onPointerdown(leftClickEvent)
expect(mockOnNodeSelect).toHaveBeenCalledWith(leftClickEvent, mockNodeData)
expect(startDrag).toHaveBeenCalledWith(leftClickEvent, 'test-node-123')
})
it('should call onNodeSelect on pointer down', async () => {
const mockNodeData = createMockVueNodeData()
const mockOnNodeSelect = vi.fn()
it.skip('should call onNodeSelect on pointer down', async () => {
const { handleNodeSelect } = useNodeEventHandlers()
const { pointerHandlers } = useNodePointerInteractions(
ref(mockNodeData),
mockOnNodeSelect
)
const { pointerHandlers } = useNodePointerInteractions('test-node-123')
// Selection should happen on pointer down
const downEvent = createPointerEvent('pointerdown', {
@@ -155,9 +167,9 @@ describe('useNodePointerInteractions', () => {
})
pointerHandlers.onPointerdown(downEvent)
expect(mockOnNodeSelect).toHaveBeenCalledWith(downEvent, mockNodeData)
expect(handleNodeSelect).toHaveBeenCalledWith(downEvent, 'test-node-123')
mockOnNodeSelect.mockClear()
vi.mocked(handleNodeSelect).mockClear()
// Even if we drag, selection already happened on pointer down
pointerHandlers.onPointerup(
@@ -165,35 +177,36 @@ describe('useNodePointerInteractions', () => {
)
// onNodeSelect should not be called again on pointer up
expect(mockOnNodeSelect).not.toHaveBeenCalled()
expect(handleNodeSelect).not.toHaveBeenCalled()
})
it('should handle drag termination via cancel and context menu', async () => {
const mockNodeData = createMockVueNodeData()
const mockOnNodeSelect = vi.fn()
const { handleNodeSelect } = useNodeEventHandlers()
const { pointerHandlers } = useNodePointerInteractions(
ref(mockNodeData),
mockOnNodeSelect
)
const { pointerHandlers } = useNodePointerInteractions('test-node-123')
// Test pointer cancel - selection happens on pointer down
pointerHandlers.onPointerdown(
createPointerEvent('pointerdown', { clientX: 100, clientY: 100 })
)
expect(mockOnNodeSelect).toHaveBeenCalledTimes(1)
// Simulate drag by moving pointer beyond threshold
pointerHandlers.onPointermove(
createPointerEvent('pointermove', { clientX: 110, clientY: 110 })
createPointerEvent('pointermove', {
clientX: 110,
clientY: 110,
buttons: 1
})
)
expect(handleNodeSelect).toHaveBeenCalledTimes(1)
pointerHandlers.onPointercancel(createPointerEvent('pointercancel'))
// Selection should have been called on pointer down only
expect(mockOnNodeSelect).toHaveBeenCalledTimes(1)
expect(handleNodeSelect).toHaveBeenCalledTimes(1)
mockOnNodeSelect.mockClear()
vi.mocked(handleNodeSelect).mockClear()
// Test context menu during drag prevents default
pointerHandlers.onPointerdown(
@@ -201,7 +214,11 @@ describe('useNodePointerInteractions', () => {
)
// Simulate drag by moving pointer beyond threshold
pointerHandlers.onPointermove(
createPointerEvent('pointermove', { clientX: 110, clientY: 110 })
createPointerEvent('pointermove', {
clientX: 110,
clientY: 110,
buttons: 1
})
)
const contextMenuEvent = createMouseEvent('contextmenu')
@@ -212,36 +229,8 @@ describe('useNodePointerInteractions', () => {
expect(preventDefaultSpy).toHaveBeenCalled()
})
it('should not call onNodeSelect when nodeData is null', async () => {
const mockNodeData = createMockVueNodeData()
const mockOnNodeSelect = vi.fn()
const nodeDataRef = ref<VueNodeData | null>(mockNodeData)
const { pointerHandlers } = useNodePointerInteractions(
nodeDataRef,
mockOnNodeSelect
)
// Clear nodeData before pointer down
nodeDataRef.value = null
await nextTick()
pointerHandlers.onPointerdown(createPointerEvent('pointerdown'))
expect(mockOnNodeSelect).not.toHaveBeenCalled()
})
it('should integrate with layout store dragging state', async () => {
const mockNodeData = createMockVueNodeData()
const mockOnNodeSelect = vi.fn()
const { layoutStore } = await import(
'@/renderer/core/layout/store/layoutStore'
)
const { pointerHandlers } = useNodePointerInteractions(
ref(mockNodeData),
mockOnNodeSelect
)
const { pointerHandlers } = useNodePointerInteractions('test-node-123')
// Pointer down alone shouldn't set dragging state
pointerHandlers.onPointerdown(
@@ -251,7 +240,11 @@ describe('useNodePointerInteractions', () => {
// Move pointer beyond threshold to start drag
pointerHandlers.onPointermove(
createPointerEvent('pointermove', { clientX: 110, clientY: 110 })
createPointerEvent('pointermove', {
clientX: 110,
clientY: 110,
buttons: 1
})
)
await nextTick()
expect(layoutStore.isDraggingVueNodes.value).toBe(true)
@@ -262,63 +255,8 @@ describe('useNodePointerInteractions', () => {
expect(layoutStore.isDraggingVueNodes.value).toBe(false)
})
it('should select node on pointer down with ctrl key for multi-select', async () => {
const mockNodeData = createMockVueNodeData()
const mockOnNodeSelect = vi.fn()
const { pointerHandlers } = useNodePointerInteractions(
ref(mockNodeData),
mockOnNodeSelect
)
// Pointer down with ctrl key should pass the event with ctrl key set
const ctrlDownEvent = createPointerEvent('pointerdown', {
ctrlKey: true,
clientX: 100,
clientY: 100
})
pointerHandlers.onPointerdown(ctrlDownEvent)
expect(mockOnNodeSelect).toHaveBeenCalledWith(ctrlDownEvent, mockNodeData)
expect(mockOnNodeSelect).toHaveBeenCalledTimes(1)
})
it('should select pinned node on pointer down but not start drag', async () => {
const mockNodeData = createMockVueNodeData({
flags: { pinned: true }
})
const mockOnNodeSelect = vi.fn()
const { layoutStore } = await import(
'@/renderer/core/layout/store/layoutStore'
)
const { pointerHandlers } = useNodePointerInteractions(
ref(mockNodeData),
mockOnNodeSelect
)
// Pointer down on pinned node
const downEvent = createPointerEvent('pointerdown')
pointerHandlers.onPointerdown(downEvent)
// Should select the node
expect(mockOnNodeSelect).toHaveBeenCalledWith(downEvent, mockNodeData)
// But should not start dragging
expect(layoutStore.isDraggingVueNodes.value).toBe(false)
})
it('should select node immediately when drag starts', async () => {
const mockNodeData = createMockVueNodeData()
const mockOnNodeSelect = vi.fn()
const { layoutStore } = await import(
'@/renderer/core/layout/store/layoutStore'
)
const { pointerHandlers } = useNodePointerInteractions(
ref(mockNodeData),
mockOnNodeSelect
)
const { pointerHandlers } = useNodePointerInteractions('test-node-123')
// Pointer down should select node immediately
const downEvent = createPointerEvent('pointerdown', {
@@ -326,24 +264,25 @@ describe('useNodePointerInteractions', () => {
clientY: 100
})
pointerHandlers.onPointerdown(downEvent)
// Selection should happen on pointer down (before move)
expect(mockOnNodeSelect).toHaveBeenCalledWith(downEvent, mockNodeData)
expect(mockOnNodeSelect).toHaveBeenCalledTimes(1)
const { handleNodeSelect } = useNodeEventHandlers()
// Dragging state should NOT be active yet
expect(layoutStore.isDraggingVueNodes.value).toBe(false)
const pointerMove = createPointerEvent('pointermove', {
clientX: 150,
clientY: 150,
buttons: 1
})
// Move the pointer beyond threshold (start dragging)
pointerHandlers.onPointermove(
createPointerEvent('pointermove', { clientX: 150, clientY: 150 })
)
pointerHandlers.onPointermove(pointerMove)
// Now dragging state should be active
expect(layoutStore.isDraggingVueNodes.value).toBe(true)
// Selection should still only have been called once (on pointer down)
expect(mockOnNodeSelect).toHaveBeenCalledTimes(1)
// Selection should happen on pointer down (before move)
expect(handleNodeSelect).toHaveBeenCalledWith(pointerMove, 'test-node-123')
expect(handleNodeSelect).toHaveBeenCalledTimes(1)
// End drag
pointerHandlers.onPointerup(
@@ -351,17 +290,12 @@ describe('useNodePointerInteractions', () => {
)
// Selection should still only have been called once
expect(mockOnNodeSelect).toHaveBeenCalledTimes(1)
expect(handleNodeSelect).toHaveBeenCalledTimes(1)
})
it('on ctrl+click: calls toggleNodeSelectionAfterPointerUp on pointer up (not pointer down)', async () => {
const mockNodeData = createMockVueNodeData()
const mockOnNodeSelect = vi.fn()
const { pointerHandlers } = useNodePointerInteractions(
ref(mockNodeData),
mockOnNodeSelect
)
const { pointerHandlers } = useNodePointerInteractions('test-node-123')
const { toggleNodeSelectionAfterPointerUp } = useNodeEventHandlers()
// Pointer down with ctrl
const downEvent = createPointerEvent('pointerdown', {
@@ -372,7 +306,7 @@ describe('useNodePointerInteractions', () => {
pointerHandlers.onPointerdown(downEvent)
// On pointer down: toggle handler should NOT be called yet
expect(toggleNodeSelectionAfterPointerUpMock).not.toHaveBeenCalled()
expect(toggleNodeSelectionAfterPointerUp).not.toHaveBeenCalled()
// Pointer up with ctrl (no drag - same position)
const upEvent = createPointerEvent('pointerup', {
@@ -383,116 +317,9 @@ describe('useNodePointerInteractions', () => {
pointerHandlers.onPointerup(upEvent)
// On pointer up: toggle handler IS called with correct params
expect(toggleNodeSelectionAfterPointerUpMock).toHaveBeenCalledWith(
mockNodeData.id,
{
wasSelectedAtPointerDown: false,
multiSelect: true
}
)
})
it('on ctrl+drag: does NOT call toggleNodeSelectionAfterPointerUp', async () => {
const mockNodeData = createMockVueNodeData()
const mockOnNodeSelect = vi.fn()
const { pointerHandlers } = useNodePointerInteractions(
ref(mockNodeData),
mockOnNodeSelect
)
// Pointer down with ctrl
const downEvent = createPointerEvent('pointerdown', {
ctrlKey: true,
clientX: 100,
clientY: 100
})
pointerHandlers.onPointerdown(downEvent)
// Move beyond drag threshold
pointerHandlers.onPointermove(
createPointerEvent('pointermove', {
ctrlKey: true,
clientX: 110,
clientY: 110
})
)
// Pointer up after drag
const upEvent = createPointerEvent('pointerup', {
ctrlKey: true,
clientX: 110,
clientY: 110
})
pointerHandlers.onPointerup(upEvent)
// When dragging: toggle handler should NOT be called
expect(toggleNodeSelectionAfterPointerUpMock).not.toHaveBeenCalled()
})
it('selects node when shift drag starts without multi selection', async () => {
selectedItemsState.items = []
const mockNodeData = createMockVueNodeData()
const mockOnNodeSelect = vi.fn()
const { pointerHandlers } = useNodePointerInteractions(
ref(mockNodeData),
mockOnNodeSelect
)
const pointerDownEvent = createPointerEvent('pointerdown', {
clientX: 0,
clientY: 0,
shiftKey: true
})
pointerHandlers.onPointerdown(pointerDownEvent)
const pointerMoveEvent = createPointerEvent('pointermove', {
clientX: 10,
clientY: 10,
shiftKey: true
})
pointerHandlers.onPointermove(pointerMoveEvent)
expect(ensureNodeSelectedForShiftDragMock).toHaveBeenCalledWith(
pointerMoveEvent,
mockNodeData,
false
)
})
it('still ensures selection when shift drag starts with existing multi select', async () => {
selectedItemsState.items = [{ id: 'a' }, { id: 'b' }]
const mockNodeData = createMockVueNodeData()
const mockOnNodeSelect = vi.fn()
const { pointerHandlers } = useNodePointerInteractions(
ref(mockNodeData),
mockOnNodeSelect
)
const pointerDownEvent = createPointerEvent('pointerdown', {
clientX: 0,
clientY: 0,
shiftKey: true
})
pointerHandlers.onPointerdown(pointerDownEvent)
const pointerMoveEvent = createPointerEvent('pointermove', {
clientX: 10,
clientY: 10,
shiftKey: true
})
pointerHandlers.onPointermove(pointerMoveEvent)
expect(ensureNodeSelectedForShiftDragMock).toHaveBeenCalledWith(
pointerMoveEvent,
mockNodeData,
false
expect(toggleNodeSelectionAfterPointerUp).toHaveBeenCalledWith(
'test-node-123',
true
)
})
})

View File

@@ -1,37 +1,22 @@
import { computed, onUnmounted, ref, toValue } from 'vue'
import { onScopeDispose, ref, toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import { isMiddlePointerInput } from '@/base/pointerUtils'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import { isMultiSelectKey } from '@/renderer/extensions/vueNodes/utils/selectionUtils'
import { useNodeDrag } from '@/renderer/extensions/vueNodes/layout/useNodeDrag'
export function useNodePointerInteractions(
nodeDataMaybe: MaybeRefOrGetter<VueNodeData | null>,
onNodeSelect: (event: PointerEvent, nodeData: VueNodeData) => void
nodeIdRef: MaybeRefOrGetter<string>
) {
const nodeData = computed(() => {
const value = toValue(nodeDataMaybe)
if (!value) {
console.warn(
'useNodePointerInteractions: nodeDataMaybe resolved to null/undefined'
)
return null
}
return value
})
// Avoid potential null access during component initialization
const nodeIdComputed = computed(() => nodeData.value?.id ?? '')
const { startDrag, endDrag, handleDrag } = useNodeLayout(nodeIdComputed)
const { startDrag, endDrag, handleDrag } = useNodeDrag()
// Use canvas interactions for proper wheel event handling and pointer event capture control
const { forwardEventToCanvas, shouldHandleNodePointerEvents } =
useCanvasInteractions()
const { toggleNodeSelectionAfterPointerUp, ensureNodeSelectedForShiftDrag } =
const { handleNodeSelect, toggleNodeSelectionAfterPointerUp } =
useNodeEventHandlers()
const { nodeManager } = useVueNodeLifecycle()
@@ -41,33 +26,15 @@ export function useNodePointerInteractions(
return true
}
// Drag state for styling
const isDragging = ref(false)
const isPointerDown = ref(false)
const wasSelectedAtPointerDown = ref(false) // Track if node was selected when pointer down occurred
const dragStyle = computed(() => {
if (nodeData.value?.flags?.pinned) {
return { cursor: 'default' }
}
return { cursor: isDragging.value ? 'grabbing' : 'grab' }
})
const startPosition = ref({ x: 0, y: 0 })
const DRAG_THRESHOLD = 3 // pixels
const handlePointerDown = (event: PointerEvent) => {
if (!nodeData.value) {
console.warn(
'LGraphNode: nodeData is null/undefined in handlePointerDown'
)
return
}
function onPointerdown(event: PointerEvent) {
if (forwardMiddlePointerIfNeeded(event)) return
// Only start drag on left-click (button 0)
if (event.button !== 0) {
return
}
if (event.button !== 0) return
// Don't handle pointer events when canvas is in panning mode - forward to canvas instead
if (!shouldHandleNodePointerEvents.value) {
@@ -75,69 +42,67 @@ export function useNodePointerInteractions(
return
}
// Track if node was selected before this pointer down
// IMPORTANT: Read from actual LGraphNode, not nodeData, to get correct state
const lgNode = nodeManager.value?.getNode(nodeData.value.id)
wasSelectedAtPointerDown.value = lgNode?.selected ?? false
onNodeSelect(event, nodeData.value)
if (nodeData.value.flags?.pinned) {
const nodeId = toValue(nodeIdRef)
if (!nodeId) {
console.warn(
'LGraphNode: nodeData is null/undefined in handlePointerDown'
)
return
}
// Record position for drag threshold calculation
startPosition.value = { x: event.clientX, y: event.clientY }
isPointerDown.value = true
// IMPORTANT: Read from actual LGraphNode to get correct state
if (nodeManager.value?.getNode(nodeId)?.flags?.pinned) {
return
}
// Don't start drag yet - wait for pointer move to exceed threshold
startDrag(event)
startPosition.value = { x: event.clientX, y: event.clientY }
startDrag(event, nodeId)
}
const handlePointerMove = (event: PointerEvent) => {
function onPointermove(event: PointerEvent) {
if (forwardMiddlePointerIfNeeded(event)) return
const nodeId = toValue(nodeIdRef)
if (nodeManager.value?.getNode(nodeId)?.flags?.pinned) {
return
}
const multiSelect = isMultiSelectKey(event)
const lmbDown = event.buttons & 1
if (lmbDown && multiSelect && !layoutStore.isDraggingVueNodes.value) {
layoutStore.isDraggingVueNodes.value = true
handleNodeSelect(event, nodeId)
startDrag(event, nodeId)
return
}
// Check if we should start dragging (pointer moved beyond threshold)
if (isPointerDown.value && !isDragging.value) {
if (lmbDown && !layoutStore.isDraggingVueNodes.value) {
const dx = event.clientX - startPosition.value.x
const dy = event.clientY - startPosition.value.y
const distance = Math.sqrt(dx * dx + dy * dy)
if (distance > DRAG_THRESHOLD && nodeData.value) {
// Start drag
isDragging.value = true
if (distance > DRAG_THRESHOLD) {
layoutStore.isDraggingVueNodes.value = true
ensureNodeSelectedForShiftDrag(
event,
nodeData.value,
wasSelectedAtPointerDown.value
)
handleNodeSelect(event, nodeId)
}
}
if (isDragging.value) {
void handleDrag(event)
if (layoutStore.isDraggingVueNodes.value) {
handleDrag(event, nodeId)
}
}
/**
* Centralized cleanup function for drag state
* Ensures consistent cleanup across all drag termination scenarios
*/
const cleanupDragState = () => {
isDragging.value = false
isPointerDown.value = false
wasSelectedAtPointerDown.value = false
function cleanupDragState() {
layoutStore.isDraggingVueNodes.value = false
}
/**
* Safely ends drag operation with proper error handling
* @param event - PointerEvent to end the drag with
*/
const safeDragEnd = async (event: PointerEvent): Promise<void> => {
function safeDragEnd(event: PointerEvent) {
try {
await endDrag(event)
const nodeId = toValue(nodeIdRef)
endDrag(event, nodeId)
} catch (error) {
console.error('Error during endDrag:', error)
} finally {
@@ -145,61 +110,39 @@ export function useNodePointerInteractions(
}
}
/**
* Common drag termination handler with fallback cleanup
*/
const handleDragTermination = (event: PointerEvent, errorContext: string) => {
safeDragEnd(event).catch((error) => {
console.error(`Failed to complete ${errorContext}:`, error)
cleanupDragState() // Fallback cleanup
})
}
const handlePointerUp = (event: PointerEvent) => {
function onPointerup(event: PointerEvent) {
if (forwardMiddlePointerIfNeeded(event)) return
const wasDragging = isDragging.value
const multiSelect = isMultiSelectKey(event)
const canHandlePointer = shouldHandleNodePointerEvents.value
if (wasDragging) {
handleDragTermination(event, 'drag end')
} else {
// Clean up pointer state even if not dragging
isPointerDown.value = false
const wasSelected = wasSelectedAtPointerDown.value
wasSelectedAtPointerDown.value = false
if (nodeData.value && canHandlePointer) {
toggleNodeSelectionAfterPointerUp(nodeData.value.id, {
wasSelectedAtPointerDown: wasSelected,
multiSelect
})
}
}
// Don't handle pointer events when canvas is in panning mode - forward to canvas instead
const canHandlePointer = shouldHandleNodePointerEvents.value
if (!canHandlePointer) {
forwardEventToCanvas(event)
return
}
const wasDragging = layoutStore.isDraggingVueNodes.value
if (wasDragging) {
safeDragEnd(event)
return
}
const multiSelect = isMultiSelectKey(event)
const nodeId = toValue(nodeIdRef)
if (nodeId) {
toggleNodeSelectionAfterPointerUp(nodeId, multiSelect)
}
}
/**
* Handles pointer cancellation events (e.g., touch cancelled by browser)
* Ensures drag state is properly cleaned up when pointer interaction is interrupted
*/
const handlePointerCancel = (event: PointerEvent) => {
if (!isDragging.value) return
handleDragTermination(event, 'drag cancellation')
function onPointercancel(event: PointerEvent) {
if (!layoutStore.isDraggingVueNodes.value) return
safeDragEnd(event)
}
/**
* Handles right-click during drag operations
* Cancels the current drag to prevent context menu from appearing while dragging
*/
const handleContextMenu = (event: MouseEvent) => {
if (!isDragging.value) return
function onContextmenu(event: MouseEvent) {
if (!layoutStore.isDraggingVueNodes.value) return
event.preventDefault()
// Simply cleanup state without calling endDrag to avoid synthetic event creation
@@ -207,22 +150,19 @@ export function useNodePointerInteractions(
}
// Cleanup on unmount to prevent resource leaks
onUnmounted(() => {
if (!isDragging.value) return
onScopeDispose(() => {
cleanupDragState()
})
const pointerHandlers = {
onPointerdown: handlePointerDown,
onPointermove: handlePointerMove,
onPointerup: handlePointerUp,
onPointercancel: handlePointerCancel,
onContextmenu: handleContextMenu
}
onPointerdown,
onPointermove,
onPointerup,
onPointercancel,
onContextmenu
} as const
return {
isDragging,
dragStyle,
pointerHandlers
}
}

View File

@@ -384,6 +384,8 @@ export function useSlotLinkInteraction({
const handlePointerMove = (event: PointerEvent) => {
if (!pointerSession.matches(event)) return
event.stopPropagation()
dragContext.pendingPointerMove = {
clientX: event.clientX,
clientY: event.clientY,
@@ -507,6 +509,7 @@ export function useSlotLinkInteraction({
}
const handlePointerUp = (event: PointerEvent) => {
event.stopPropagation()
finishInteraction(event)
}

View File

@@ -14,6 +14,7 @@ import type { MaybeRefOrGetter } from 'vue'
import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import type { Bounds, NodeId } from '@/renderer/core/layout/types'
import { LayoutSource } from '@/renderer/core/layout/types'
@@ -60,6 +61,7 @@ const trackingConfigs: Map<string, ElementTrackingConfig> = new Map([
// Single ResizeObserver instance for all Vue elements
const resizeObserver = new ResizeObserver((entries) => {
if (useCanvasStore().linearMode) return
// Canvas is ready when this code runs; no defensive guards needed.
const conv = useSharedCanvasPositionConversion()
// Group updates by type, then flush via each config's handler
@@ -105,7 +107,7 @@ const resizeObserver = new ResizeObserver((entries) => {
x: topLeftCanvas.x,
y: topLeftCanvas.y + LiteGraph.NODE_TITLE_HEIGHT,
width: Math.max(0, width),
height: Math.max(0, height - LiteGraph.NODE_TITLE_HEIGHT)
height: Math.max(0, height)
}
let updates = updatesByType.get(elementType)
@@ -121,8 +123,7 @@ const resizeObserver = new ResizeObserver((entries) => {
}
}
// Set source to Vue before processing DOM-driven updates
layoutStore.setSource(LayoutSource.Vue)
layoutStore.setSource(LayoutSource.DOM)
// Flush per-type
for (const [type, updates] of updatesByType) {

View File

@@ -1,17 +1,17 @@
import { useEventListener } from '@vueuse/core'
import { ref } from 'vue'
import type { TransformState } from '@/renderer/core/layout/injectionKeys'
import type { Point, Size } from '@/renderer/core/layout/types'
import { useNodeSnap } from '@/renderer/extensions/vueNodes/composables/useNodeSnap'
import { useShiftKeySync } from '@/renderer/extensions/vueNodes/composables/useShiftKeySync'
import type { ResizeHandleDirection } from './resizeMath'
import { createResizeSession, toCanvasDelta } from './resizeMath'
import type { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
interface UseNodeResizeOptions {
/** Transform state for coordinate conversion */
transformState: TransformState
transformState: ReturnType<typeof useTransformState>
}
interface ResizeCallbackPayload {

Some files were not shown because too many files have changed in this diff Show More