Compare commits

...

16 Commits

Author SHA1 Message Date
pythongosssss
def9b55e07 Remove hamburger menu from tabs (#8067)
We added the menu button to both the tabs & where the subgraph menu
button was previously in order to get feedback on where this button
should be. We've received feedback that the one on the tabs is not a
common UX element, and that having both seems like a bug, and that the
one on the graph is prefered. Due to this, we're removing the one on the
tabs.

- Remove tab menu button

Before:
<img width="733" height="224" alt="image"
src="https://github.com/user-attachments/assets/3f916d96-4bfe-482d-a8eb-8b18a7327334"
/>

After:
<img width="731" height="248" alt="image"
src="https://github.com/user-attachments/assets/5eeb31e5-e49f-409a-8eac-04773182a145"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8067-Remove-hamburger-menu-from-tabs-2e96d73d3650815aa80af4d5aa8767cd)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-01-20 17:30:04 -08:00
AustinMroz
995906a109 [backport cloud/1.37] control widget fixes (#8163)
Manual backport of #8112 and #8160 to `cloud/1.37`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8163-backport-cloud-1-37-control-widget-fixes-2ed6d73d3650815cb458e8adc44ad4bc)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-01-19 15:03:31 -08:00
Comfy Org PR Bot
05cbccefe0 [backport cloud/1.37] feat: make subgraphs blueprints appear higher in node library sidebar (#8142)
Backport of #8140 to `cloud/1.37`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8142-backport-cloud-1-37-feat-make-subgraphs-blueprints-appear-higher-in-node-library-sideb-2ec6d73d3650815db6f6ca45a800ae6c)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-01-17 21:27:39 -07:00
Comfy Org PR Bot
a55cae531f [backport cloud/1.37] Update beta message in linear mode (#8109)
Backport of #8106 to `cloud/1.37`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8109-backport-cloud-1-37-Update-beta-message-in-linear-mode-2ea6d73d36508107992ed0c0b1357f14)
by [Unito](https://www.unito.io)

Co-authored-by: Yoland Yan <4950057+yoland68@users.noreply.github.com>
2026-01-16 22:13:41 -07:00
Comfy Org PR Bot
e036d7625a [backport cloud/1.37] Fix asset selection in litegraph (#8119)
Backport of #8117 to `cloud/1.37`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8119-backport-cloud-1-37-Fix-asset-selection-in-litegraph-2eb6d73d3650811180a1e3f6779b4f60)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2026-01-16 18:28:55 -08:00
AustinMroz
3eb8c6a347 [backport cloud/1.37] Improve linear compatibility with Safari, run button metadata (#8108)
Manual backport of #8107 to `cloud/1.37`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8108-backport-cloud-1-37-Improve-linear-compatibility-with-Safari-run-button-metadata-2ea6d73d365081e79cc9f920f852a8a2)
by [Unito](https://www.unito.io)
2026-01-16 11:51:47 -08:00
Comfy Org PR Bot
ac6adb0b3f [backport cloud/1.37] Fix copypasted primitives inside subgraphs (#8096)
Backport of #8094 to `cloud/1.37`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8096-backport-cloud-1-37-Fix-copypasted-primitives-inside-subgraphs-2ea6d73d3650812e8692eb76149d8156)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2026-01-15 21:43:48 -08:00
AustinMroz
5a276f2e04 [backport cloud/1.37] Make sure toggle visibility checks remote config (#8088)
Manual backport of #8086

(this time to the correct target branch)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8088-backport-cloud-1-37-Make-sure-toggle-visibility-checks-remote-config-2ea6d73d3650813b8207d12ed42541f5)
by [Unito](https://www.unito.io)
2026-01-15 16:27:52 -08:00
AustinMroz
40b0954766 [backport cloud/1.37] Further linear fixes (#8084)
Manual backport since the bot is slow

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8084-backport-cloud-1-37-Further-linear-fixes-2e96d73d365081878a02d23ee2e848be)
by [Unito](https://www.unito.io)
2026-01-15 15:10:23 -08:00
Comfy Org PR Bot
a3cd6304a8 [backport cloud/1.37] fix: prevent Record Audio waveform from overflowing node bounds (#8082)
Backport of #8070 to `cloud/1.37`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8082-backport-cloud-1-37-fix-prevent-Record-Audio-waveform-from-overflowing-node-bounds-2e96d73d36508112b881df2c4bf5fd3c)
by [Unito](https://www.unito.io)

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-01-15 14:02:30 -08:00
Comfy Org PR Bot
9132f8725f [backport cloud/1.37] Linear mode bug fixes (#8072)
Backport of #8054 to `cloud/1.37`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8072-backport-cloud-1-37-Linear-mode-bug-fixes-2e96d73d365081dfa542f08405043203)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2026-01-15 08:32:51 -08:00
Comfy Org PR Bot
d85c46901d [backport cloud/1.37] feat(price-badges): add ByteDance SeeDance 1.5 prices (#8059)
Backport of #8046 to `cloud/1.37`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8059-backport-cloud-1-37-feat-price-badges-add-ByteDance-SeeDance-1-5-prices-2e96d73d3650817894e2e7350ccfb8c5)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Piskun <13381981+bigcat88@users.noreply.github.com>
2026-01-14 21:03:24 -08:00
Comfy Org PR Bot
cd6047fa89 [backport cloud/1.37] Fix: Update for Image Widget test (#8051)
Backport of #8031 to `cloud/1.37`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8051-backport-cloud-1-37-Fix-Update-for-Image-Widget-test-2e86d73d365081bba5e0e5f75aa8a7d9)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Alexander Brown <448862+DrJKL@users.noreply.github.com>
2026-01-14 13:28:51 -08:00
Comfy Org PR Bot
3c99e75fe0 [backport cloud/1.37] [API Nodes] add price badges for Meshy 3D nodes (#8049)
Backport of #7966 to `cloud/1.37`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8049-backport-cloud-1-37-API-Nodes-add-price-badges-for-Meshy-3D-nodes-2e86d73d3650815b8df4c0f4c2957f65)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Piskun <13381981+bigcat88@users.noreply.github.com>
2026-01-14 22:33:21 +02:00
AustinMroz
5ec29f64b6 [backport cloud/1.37] linear v2: Simple Mode (#8047)
Manual backport of #7734 to `cloud/1.37`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8047-backport-cloud-1-37-linear-v2-Simple-Mode-2e86d73d365081948861debeae9604f0)
by [Unito](https://www.unito.io)
2026-01-14 11:44:18 -08:00
Comfy Org PR Bot
c77f0cba45 [backport cloud/1.37] fix: version mismatch warning appearing in Playwright tests despite DisableWarnings setting (#8039)
Backport of #8036 to `cloud/1.37`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8039-backport-cloud-1-37-fix-version-mismatch-warning-appearing-in-Playwright-tests-despite-2e86d73d3650817d9534c0449798e7b1)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-01-13 20:43:33 -07:00
77 changed files with 1757 additions and 446 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 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: 58 KiB

After

Width:  |  Height:  |  Size: 58 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: 61 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 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: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 81 KiB

View File

@@ -194,7 +194,10 @@ test.describe('Image widget', () => {
const comboEntry = comfyPage.page.getByRole('menuitem', { const comboEntry = comfyPage.page.getByRole('menuitem', {
name: 'image32x32.webp' name: 'image32x32.webp'
}) })
await comboEntry.click({ noWaitAfter: true }) await comboEntry.click()
// Stabilization for the image swap
await comfyPage.nextFrame()
// Expect the image preview to change automatically // Expect the image preview to change automatically
await expect(comfyPage.canvas).toHaveScreenshot( await expect(comfyPage.canvas).toHaveScreenshot(

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 46 KiB

View File

@@ -247,6 +247,7 @@
--inverted-background-hover: var(--color-charcoal-600); --inverted-background-hover: var(--color-charcoal-600);
--warning-background: var(--color-gold-400); --warning-background: var(--color-gold-400);
--warning-background-hover: var(--color-gold-500); --warning-background-hover: var(--color-gold-500);
--success-background: var(--color-jade-600);
--border-default: var(--color-smoke-600); --border-default: var(--color-smoke-600);
--border-subtle: var(--color-smoke-400); --border-subtle: var(--color-smoke-400);
--muted-background: var(--color-smoke-700); --muted-background: var(--color-smoke-700);
@@ -372,6 +373,7 @@
--inverted-background-hover: var(--color-smoke-200); --inverted-background-hover: var(--color-smoke-200);
--warning-background: var(--color-gold-600); --warning-background: var(--color-gold-600);
--warning-background-hover: var(--color-gold-500); --warning-background-hover: var(--color-gold-500);
--success-background: var(--color-jade-600);
--border-default: var(--color-charcoal-200); --border-default: var(--color-charcoal-200);
--border-subtle: var(--color-charcoal-300); --border-subtle: var(--color-charcoal-300);
--muted-background: var(--color-charcoal-100); --muted-background: var(--color-charcoal-100);
@@ -516,6 +518,7 @@
--color-inverted-background-hover: var(--inverted-background-hover); --color-inverted-background-hover: var(--inverted-background-hover);
--color-warning-background: var(--warning-background); --color-warning-background: var(--warning-background);
--color-warning-background-hover: var(--warning-background-hover); --color-warning-background-hover: var(--warning-background-hover);
--color-success-background: var(--success-background);
--color-border-default: var(--border-default); --color-border-default: var(--border-default);
--color-border-subtle: var(--border-subtle); --color-border-subtle: var(--border-subtle);
--color-muted-background: var(--muted-background); --color-muted-background: var(--muted-background);

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import { t } from '@/i18n'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
const canvasStore = useCanvasStore()
</script>
<template>
<div class="p-1 bg-secondary-background rounded-lg w-10">
<Button
size="icon"
:title="t('linearMode.linearMode')"
:variant="canvasStore.linearMode ? 'inverted' : 'secondary'"
@click="useCommandStore().execute('Comfy.ToggleLinear')"
>
<i class="icon-[lucide--panels-top-left]" />
</Button>
<Button
size="icon"
:title="t('linearMode.graphMode')"
:variant="canvasStore.linearMode ? 'secondary' : 'inverted'"
@click="useCommandStore().execute('Comfy.ToggleLinear')"
>
<i class="icon-[comfy--workflow]" />
</Button>
</div>
</template>

View File

@@ -44,6 +44,9 @@
<SidebarBottomPanelToggleButton :is-small="isSmall" /> <SidebarBottomPanelToggleButton :is-small="isSmall" />
<SidebarShortcutsToggleButton :is-small="isSmall" /> <SidebarShortcutsToggleButton :is-small="isSmall" />
<SidebarSettingsButton :is-small="isSmall" /> <SidebarSettingsButton :is-small="isSmall" />
<ModeToggle
v-if="menuItemStore.hasSeenLinear || flags.linearToggleEnabled"
/>
</div> </div>
</div> </div>
<HelpCenterPopups :is-small="isSmall" /> <HelpCenterPopups :is-small="isSmall" />
@@ -57,14 +60,17 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import HelpCenterPopups from '@/components/helpcenter/HelpCenterPopups.vue' import HelpCenterPopups from '@/components/helpcenter/HelpCenterPopups.vue'
import ComfyMenuButton from '@/components/sidebar/ComfyMenuButton.vue' import ComfyMenuButton from '@/components/sidebar/ComfyMenuButton.vue'
import ModeToggle from '@/components/sidebar/ModeToggle.vue'
import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPanelToggleButton.vue' import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPanelToggleButton.vue'
import SidebarSettingsButton from '@/components/sidebar/SidebarSettingsButton.vue' import SidebarSettingsButton from '@/components/sidebar/SidebarSettingsButton.vue'
import SidebarShortcutsToggleButton from '@/components/sidebar/SidebarShortcutsToggleButton.vue' import SidebarShortcutsToggleButton from '@/components/sidebar/SidebarShortcutsToggleButton.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useSettingStore } from '@/platform/settings/settingStore' import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry' import { useTelemetry } from '@/platform/telemetry'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore' import { useCommandStore } from '@/stores/commandStore'
import { useKeybindingStore } from '@/stores/keybindingStore' import { useKeybindingStore } from '@/stores/keybindingStore'
import { useMenuItemStore } from '@/stores/menuItemStore'
import { useUserStore } from '@/stores/userStore' import { useUserStore } from '@/stores/userStore'
import { useWorkspaceStore } from '@/stores/workspaceStore' import { useWorkspaceStore } from '@/stores/workspaceStore'
import type { SidebarTabExtension } from '@/types/extensionTypes' import type { SidebarTabExtension } from '@/types/extensionTypes'
@@ -80,9 +86,11 @@ const settingStore = useSettingStore()
const userStore = useUserStore() const userStore = useUserStore()
const commandStore = useCommandStore() const commandStore = useCommandStore()
const canvasStore = useCanvasStore() const canvasStore = useCanvasStore()
const menuItemStore = useMenuItemStore()
const sideToolbarRef = ref<HTMLElement>() const sideToolbarRef = ref<HTMLElement>()
const topToolbarRef = ref<HTMLElement>() const topToolbarRef = ref<HTMLElement>()
const bottomToolbarRef = ref<HTMLElement>() const bottomToolbarRef = ref<HTMLElement>()
const { flags } = useFeatureFlags()
const isSmall = computed( const isSmall = computed(
() => settingStore.get('Comfy.Sidebar.Size') === 'small' () => settingStore.get('Comfy.Sidebar.Size') === 'small'

View File

@@ -1,6 +1,7 @@
<template> <template>
<SidebarTabTemplate <SidebarTabTemplate
:title="$t('sideToolbar.workflows')" :title="$t('sideToolbar.workflows')"
v-bind="$attrs"
class="workflows-sidebar-tab" class="workflows-sidebar-tab"
> >
<template #tool-buttons> <template #tool-buttons>

View File

@@ -4,7 +4,7 @@
variant="textonly" variant="textonly"
@click="toggleHelpCenter" @click="toggleHelpCenter"
> >
{{ $t('menu.helpAndFeedback') }} <div class="not-md:hidden">{{ $t('menu.helpAndFeedback') }}</div>
<i class="icon-[lucide--circle-help] ml-0.5" /> <i class="icon-[lucide--circle-help] ml-0.5" />
<span <span
v-if="shouldShowRedDot" v-if="shouldShowRedDot"

View File

@@ -7,15 +7,10 @@
@mouseleave="handleMouseLeave" @mouseleave="handleMouseLeave"
@click="handleClick" @click="handleClick"
> >
<Button <i
v-if="isActiveTab" v-if="workflowOption.workflow.activeState?.extra?.linearMode"
class="context-menu-button -mx-1 w-auto px-1 py-0" class="icon-[lucide--panels-top-left] bg-primary-background"
variant="muted-textonly" />
size="icon-sm"
@click.stop="handleMenuClick"
>
<i class="pi pi-bars" />
</Button>
<span class="workflow-label inline-block max-w-[150px] truncate text-sm"> <span class="workflow-label inline-block max-w-[150px] truncate text-sm">
{{ workflowOption.workflow.filename }} {{ workflowOption.workflow.filename }}
</span> </span>
@@ -43,26 +38,9 @@
:thumbnail-url="thumbnailUrl" :thumbnail-url="thumbnailUrl"
:is-active-tab="isActiveTab" :is-active-tab="isActiveTab"
/> />
<Menu
v-if="isActiveTab"
ref="menu"
:model="menuItems"
:popup="true"
:pt="{
root: {
style: 'background-color: var(--comfy-menu-bg)'
},
itemLink: {
class: 'py-2'
}
}"
/>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { MenuState } from 'primevue/menu'
import Menu from 'primevue/menu'
import { computed, onUnmounted, ref } from 'vue' import { computed, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
@@ -71,14 +49,11 @@ import {
usePragmaticDraggable, usePragmaticDraggable,
usePragmaticDroppable usePragmaticDroppable
} from '@/composables/usePragmaticDragAndDrop' } from '@/composables/usePragmaticDragAndDrop'
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
import { useSettingStore } from '@/platform/settings/settingStore' import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService' import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore' import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail' import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
import { useCommandStore } from '@/stores/commandStore'
import { useWorkspaceStore } from '@/stores/workspaceStore' import { useWorkspaceStore } from '@/stores/workspaceStore'
import WorkflowTabPopover from './WorkflowTabPopover.vue' import WorkflowTabPopover from './WorkflowTabPopover.vue'
@@ -143,12 +118,6 @@ const thumbnailUrl = computed(() => {
return workflowThumbnail.getThumbnail(props.workflowOption.workflow.key) return workflowThumbnail.getThumbnail(props.workflowOption.workflow.key)
}) })
const menu = ref<InstanceType<typeof Menu> & MenuState>()
const { menuItems } = useWorkflowActionsMenu(() =>
useCommandStore().execute('Comfy.RenameWorkflow')
)
// Event handlers that delegate to the popover component // Event handlers that delegate to the popover component
const handleMouseEnter = (event: Event) => { const handleMouseEnter = (event: Event) => {
popoverRef.value?.showPopover(event) popoverRef.value?.showPopover(event)
@@ -162,14 +131,6 @@ const handleClick = (event: Event) => {
popoverRef.value?.togglePopover(event) popoverRef.value?.togglePopover(event)
} }
const handleMenuClick = (event: MouseEvent) => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'workflow_tab_menu_selected'
})
// Show breadcrumb menu instead of emitting context click
menu.value?.toggle(event)
}
const closeWorkflows = async (options: WorkflowOption[]) => { const closeWorkflows = async (options: WorkflowOption[]) => {
for (const opt of options) { for (const opt of options) {
if ( if (

View File

@@ -0,0 +1,76 @@
<script setup lang="ts">
import {
PopoverArrow,
PopoverContent,
PopoverPortal,
PopoverRoot,
PopoverTrigger
} from 'reka-ui'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
defineOptions({
inheritAttrs: false
})
defineProps<{
entries?: { label: string; action?: () => void; icon?: string }[][]
icon?: string
to?: string | HTMLElement
}>()
</script>
<template>
<PopoverRoot v-slot="{ close }">
<PopoverTrigger as-child>
<slot name="button">
<Button size="icon">
<i :class="icon ?? 'icon-[lucide--ellipsis]'" />
</Button>
</slot>
</PopoverTrigger>
<PopoverPortal :to>
<PopoverContent
side="bottom"
:side-offset="5"
:collision-padding="10"
v-bind="$attrs"
class="rounded-lg p-2 bg-base-background shadow-sm border border-border-subtle will-change-[transform,opacity] data-[state=open]:data-[side=top]:animate-slideDownAndFade data-[state=open]:data-[side=right]:animate-slideLeftAndFade data-[state=open]:data-[side=bottom]:animate-slideUpAndFade data-[state=open]:data-[side=left]:animate-slideRightAndFade"
>
<slot>
<div class="flex flex-col p-1">
<section
v-for="(entryGroup, index) in entries ?? []"
:key="index"
class="flex flex-col border-b-2 last:border-none border-border-subtle"
>
<div
v-for="{ label, action, icon } in entryGroup"
:key="label"
:class="
cn(
'flex flex-row gap-4 p-2 rounded-sm my-1',
action &&
'cursor-pointer hover:bg-secondary-background-hover'
)
"
@click="
() => {
if (!action) return
action()
close()
}
"
>
<i v-if="icon" :class="icon" />
{{ label }}
</div>
</section>
</div>
</slot>
<PopoverArrow class="fill-base-background stroke-border-subtle" />
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
</template>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import { whenever } from '@vueuse/core'
import { useTemplateRef } from 'vue'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
defineProps<{
dataTfWidget: string
}>()
const feedbackRef = useTemplateRef('feedbackRef')
whenever(feedbackRef, () => {
const scriptEl = document.createElement('script')
scriptEl.src = '//embed.typeform.com/next/embed.js'
feedbackRef.value?.appendChild(scriptEl)
})
</script>
<template>
<Popover>
<template #button>
<Button variant="inverted" class="rounded-full size-12">
<i class="icon-[lucide--circle-question-mark] size-6" />
</Button>
</template>
<div ref="feedbackRef" data-tf-auto-resize :data-tf-widget />
</Popover>
</template>

View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
import { computed, ref, useTemplateRef } from 'vue'
const zoomPane = useTemplateRef('zoomPane')
const zoom = ref(1.0)
const panX = ref(0.0)
const panY = ref(0.0)
function handleWheel(e: WheelEvent) {
const zoomPaneEl = zoomPane.value
if (!zoomPaneEl) return
zoom.value -= e.deltaY
const { x, y, width, height } = zoomPaneEl.getBoundingClientRect()
const offsetX = e.clientX - x - width / 2
const offsetY = e.clientY - y - height / 2
const scaler = 1.1 ** (e.deltaY / -30)
panY.value = panY.value * scaler - offsetY * (scaler - 1)
panX.value = panX.value * scaler - offsetX * (scaler - 1)
}
let dragging = false
function handleDown(e: PointerEvent) {
if (e.button !== 0) return
const zoomPaneEl = zoomPane.value
if (!zoomPaneEl) return
zoomPaneEl.parentElement?.focus()
zoomPaneEl.setPointerCapture(e.pointerId)
dragging = true
}
function handleMove(e: PointerEvent) {
if (!dragging) return
panX.value += e.movementX
panY.value += e.movementY
}
const transform = computed(() => {
const scale = 1.1 ** (zoom.value / 30)
const matrix = [scale, 0, 0, scale, panX.value, panY.value]
return `matrix(${matrix.join(',')})`
})
</script>
<template>
<div
ref="zoomPane"
class="contain-size place-content-center"
@wheel="handleWheel"
@pointerdown.prevent="handleDown"
@pointermove="handleMove"
@pointerup="dragging = false"
@pointercancel="dragging = false"
>
<slot :style="{ transform }" />
</div>
</template>

View File

@@ -157,7 +157,7 @@ const normalizeWidgetValue = (value: unknown): WidgetValue => {
return undefined return undefined
} }
export function safeWidgetMapper( function safeWidgetMapper(
node: LGraphNode, node: LGraphNode,
slotMetadata: Map<string, WidgetSlotMetadata> slotMetadata: Map<string, WidgetSlotMetadata>
): (widget: IBaseWidget) => SafeWidgetData { ): (widget: IBaseWidget) => SafeWidgetData {
@@ -207,15 +207,77 @@ export function safeWidgetMapper(
} }
} }
export function isValidWidgetValue(value: unknown): value is WidgetValue { // Extract safe data from LiteGraph node for Vue consumption
return ( export function extractVueNodeData(node: LGraphNode): VueNodeData {
value === null || // Determine subgraph ID - null for root graph, string for subgraphs
value === undefined || const subgraphId =
typeof value === 'string' || node.graph && 'id' in node.graph && node.graph !== node.graph.rootGraph
typeof value === 'number' || ? String(node.graph.id)
typeof value === 'boolean' || : null
typeof value === 'object' // Extract safe widget data
) const slotMetadata = new Map<string, WidgetSlotMetadata>()
const reactiveWidgets = shallowReactive<IBaseWidget[]>(node.widgets ?? [])
Object.defineProperty(node, 'widgets', {
get() {
return reactiveWidgets
},
set(v) {
reactiveWidgets.splice(0, reactiveWidgets.length, ...v)
}
})
const reactiveInputs = shallowReactive<INodeInputSlot[]>(node.inputs ?? [])
Object.defineProperty(node, 'inputs', {
get() {
return reactiveInputs
},
set(v) {
reactiveInputs.splice(0, reactiveInputs.length, ...v)
}
})
const safeWidgets = reactiveComputed<SafeWidgetData[]>(() => {
node.inputs?.forEach((input, index) => {
if (!input?.widget?.name) return
slotMetadata.set(input.widget.name, {
index,
linked: input.link != null
})
})
return node.widgets?.map(safeWidgetMapper(node, slotMetadata)) ?? []
})
const nodeType =
node.type ||
node.constructor?.comfyClass ||
node.constructor?.title ||
node.constructor?.name ||
'Unknown'
const apiNode = node.constructor?.nodeData?.api_node ?? false
const badges = node.badges
return {
id: String(node.id),
title: typeof node.title === 'string' ? node.title : '',
type: nodeType,
mode: node.mode || 0,
titleMode: node.title_mode,
selected: node.selected || false,
executing: false, // Will be updated separately based on execution state
subgraphId,
apiNode,
badges,
hasErrors: !!node.has_errors,
widgets: safeWidgets,
inputs: reactiveInputs,
outputs: node.outputs ? [...node.outputs] : undefined,
flags: node.flags ? { ...node.flags } : undefined,
color: node.color || undefined,
bgcolor: node.bgcolor || undefined,
resizable: node.resizable,
shape: node.shape
}
} }
export function useGraphNodeManager(graph: LGraph): GraphNodeManager { export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
@@ -251,79 +313,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
} }
} }
// Extract safe data from LiteGraph node for Vue consumption
function extractVueNodeData(node: LGraphNode): VueNodeData {
// Determine subgraph ID - null for root graph, string for subgraphs
const subgraphId =
node.graph && 'id' in node.graph && node.graph !== node.graph.rootGraph
? String(node.graph.id)
: null
// Extract safe widget data
const slotMetadata = new Map<string, WidgetSlotMetadata>()
const reactiveWidgets = shallowReactive<IBaseWidget[]>(node.widgets ?? [])
Object.defineProperty(node, 'widgets', {
get() {
return reactiveWidgets
},
set(v) {
reactiveWidgets.splice(0, reactiveWidgets.length, ...v)
}
})
const reactiveInputs = shallowReactive<INodeInputSlot[]>(node.inputs ?? [])
Object.defineProperty(node, 'inputs', {
get() {
return reactiveInputs
},
set(v) {
reactiveInputs.splice(0, reactiveInputs.length, ...v)
}
})
const safeWidgets = reactiveComputed<SafeWidgetData[]>(() => {
node.inputs?.forEach((input, index) => {
if (!input?.widget?.name) return
slotMetadata.set(input.widget.name, {
index,
linked: input.link != null
})
})
return node.widgets?.map(safeWidgetMapper(node, slotMetadata)) ?? []
})
const nodeType =
node.type ||
node.constructor?.comfyClass ||
node.constructor?.title ||
node.constructor?.name ||
'Unknown'
const apiNode = node.constructor?.nodeData?.api_node ?? false
const badges = node.badges
return {
id: String(node.id),
title: typeof node.title === 'string' ? node.title : '',
type: nodeType,
mode: node.mode || 0,
titleMode: node.title_mode,
selected: node.selected || false,
executing: false, // Will be updated separately based on execution state
subgraphId,
apiNode,
badges,
hasErrors: !!node.has_errors,
widgets: safeWidgets,
inputs: reactiveInputs,
outputs: node.outputs ? [...node.outputs] : undefined,
flags: node.flags ? { ...node.flags } : undefined,
color: node.color || undefined,
bgcolor: node.bgcolor || undefined,
resizable: node.resizable,
shape: node.shape
}
}
// Get access to original LiteGraph node (non-reactive) // Get access to original LiteGraph node (non-reactive)
const getNode = (id: string): LGraphNode | undefined => { const getNode = (id: string): LGraphNode | undefined => {
return nodeRefs.get(id) return nodeRefs.get(id)

View File

@@ -2,6 +2,17 @@ import { formatCreditsFromUsd } from '@/base/credits/comfyCredits'
import type { INodeInputSlot, LGraphNode } from '@/lib/litegraph/src/litegraph' import type { INodeInputSlot, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IComboWidget } from '@/lib/litegraph/src/types/widgets' import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
/**
* Meshy credit pricing constant.
* 1 Meshy credit = $0.04 USD
* Change this value to update all Meshy node prices.
*/
const MESHY_CREDIT_PRICE_USD = 0.04
/** Convert Meshy credits to USD */
const meshyCreditsToUsd = (credits: number): number =>
credits * MESHY_CREDIT_PRICE_USD
const DEFAULT_NUMBER_OPTIONS: Intl.NumberFormatOptions = { const DEFAULT_NUMBER_OPTIONS: Intl.NumberFormatOptions = {
minimumFractionDigits: 0, minimumFractionDigits: 0,
maximumFractionDigits: 0 maximumFractionDigits: 0
@@ -209,13 +220,24 @@ const byteDanceVideoPricingCalculator = (node: LGraphNode): string => {
const resolutionWidget = node.widgets?.find( const resolutionWidget = node.widgets?.find(
(w) => w.name === 'resolution' (w) => w.name === 'resolution'
) as IComboWidget ) as IComboWidget
const generateAudioWidget = node.widgets?.find(
(w) => w.name === 'generate_audio'
) as IComboWidget | undefined
if (!modelWidget || !durationWidget || !resolutionWidget) return 'Token-based' if (!modelWidget || !durationWidget || !resolutionWidget) return 'Token-based'
const model = String(modelWidget.value).toLowerCase() const model = String(modelWidget.value).toLowerCase()
const resolution = String(resolutionWidget.value).toLowerCase() const resolution = String(resolutionWidget.value).toLowerCase()
const seconds = parseFloat(String(durationWidget.value)) const seconds = parseFloat(String(durationWidget.value))
const generateAudio =
generateAudioWidget &&
String(generateAudioWidget.value).toLowerCase() === 'true'
const priceByModel: Record<string, Record<string, [number, number]>> = { const priceByModel: Record<string, Record<string, [number, number]>> = {
'seedance-1-5-pro': {
'480p': [0.12, 0.12],
'720p': [0.26, 0.26],
'1080p': [0.58, 0.59]
},
'seedance-1-0-pro': { 'seedance-1-0-pro': {
'480p': [0.23, 0.24], '480p': [0.23, 0.24],
'720p': [0.51, 0.56], '720p': [0.51, 0.56],
@@ -233,13 +255,15 @@ const byteDanceVideoPricingCalculator = (node: LGraphNode): string => {
} }
} }
const modelKey = model.includes('seedance-1-0-pro-fast') const modelKey = model.includes('seedance-1-5-pro')
? 'seedance-1-0-pro-fast' ? 'seedance-1-5-pro'
: model.includes('seedance-1-0-pro') : model.includes('seedance-1-0-pro-fast')
? 'seedance-1-0-pro' ? 'seedance-1-0-pro-fast'
: model.includes('seedance-1-0-lite') : model.includes('seedance-1-0-pro')
? 'seedance-1-0-lite' ? 'seedance-1-0-pro'
: '' : model.includes('seedance-1-0-lite')
? 'seedance-1-0-lite'
: ''
const resKey = resolution.includes('1080') const resKey = resolution.includes('1080')
? '1080p' ? '1080p'
@@ -255,8 +279,10 @@ const byteDanceVideoPricingCalculator = (node: LGraphNode): string => {
const [min10s, max10s] = baseRange const [min10s, max10s] = baseRange
const scale = seconds / 10 const scale = seconds / 10
const minCost = min10s * scale const audioMultiplier =
const maxCost = max10s * scale modelKey === 'seedance-1-5-pro' && generateAudio ? 2 : 1
const minCost = min10s * scale * audioMultiplier
const maxCost = max10s * scale * audioMultiplier
if (minCost === maxCost) return formatCreditsLabel(minCost) if (minCost === maxCost) return formatCreditsLabel(minCost)
return formatCreditsRangeLabel(minCost, maxCost) return formatCreditsRangeLabel(minCost, maxCost)
@@ -525,6 +551,54 @@ const calculateTripo3DGenerationPrice = (
return formatCreditsLabel(dollars) return formatCreditsLabel(dollars)
} }
/**
* Meshy Image to 3D pricing calculator.
* Pricing based on should_texture widget:
* - Without texture: 20 credits
* - With texture: 30 credits
*/
const calculateMeshyImageToModelPrice = (node: LGraphNode): string => {
const shouldTextureWidget = node.widgets?.find(
(w) => w.name === 'should_texture'
) as IComboWidget
if (!shouldTextureWidget) {
return formatCreditsRangeLabel(
meshyCreditsToUsd(20),
meshyCreditsToUsd(30),
{ note: '(varies with texture)' }
)
}
const shouldTexture = String(shouldTextureWidget.value).toLowerCase()
const credits = shouldTexture === 'true' ? 30 : 20
return formatCreditsLabel(meshyCreditsToUsd(credits))
}
/**
* Meshy Multi-Image to 3D pricing calculator.
* Pricing based on should_texture widget:
* - Without texture: 5 credits
* - With texture: 15 credits
*/
const calculateMeshyMultiImageToModelPrice = (node: LGraphNode): string => {
const shouldTextureWidget = node.widgets?.find(
(w) => w.name === 'should_texture'
) as IComboWidget
if (!shouldTextureWidget) {
return formatCreditsRangeLabel(
meshyCreditsToUsd(5),
meshyCreditsToUsd(15),
{ note: '(varies with texture)' }
)
}
const shouldTexture = String(shouldTextureWidget.value).toLowerCase()
const credits = shouldTexture === 'true' ? 15 : 5
return formatCreditsLabel(meshyCreditsToUsd(credits))
}
/** /**
* Static pricing data for API nodes, now supporting both strings and functions * Static pricing data for API nodes, now supporting both strings and functions
*/ */
@@ -1812,6 +1886,27 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
TripoRefineNode: { TripoRefineNode: {
displayPrice: formatCreditsLabel(0.3) displayPrice: formatCreditsLabel(0.3)
}, },
MeshyTextToModelNode: {
displayPrice: formatCreditsLabel(meshyCreditsToUsd(20))
},
MeshyRefineNode: {
displayPrice: formatCreditsLabel(meshyCreditsToUsd(10))
},
MeshyImageToModelNode: {
displayPrice: calculateMeshyImageToModelPrice
},
MeshyMultiImageToModelNode: {
displayPrice: calculateMeshyMultiImageToModelPrice
},
MeshyRigModelNode: {
displayPrice: formatCreditsLabel(meshyCreditsToUsd(5))
},
MeshyAnimateModelNode: {
displayPrice: formatCreditsLabel(meshyCreditsToUsd(3))
},
MeshyTextureNode: {
displayPrice: formatCreditsLabel(meshyCreditsToUsd(10))
},
// Google/Gemini nodes // Google/Gemini nodes
GeminiNode: { GeminiNode: {
displayPrice: (node: LGraphNode): string => { displayPrice: (node: LGraphNode): string => {
@@ -2527,6 +2622,9 @@ export const useNodePricing = () => {
'animate_in_place' 'animate_in_place'
], ],
TripoTextureNode: ['texture_quality'], TripoTextureNode: ['texture_quality'],
// Meshy nodes
MeshyImageToModelNode: ['should_texture'],
MeshyMultiImageToModelNode: ['should_texture'],
// Google/Gemini nodes // Google/Gemini nodes
GeminiNode: ['model'], GeminiNode: ['model'],
GeminiImage2Node: ['resolution'], GeminiImage2Node: ['resolution'],
@@ -2540,9 +2638,24 @@ export const useNodePricing = () => {
'sequential_image_generation', 'sequential_image_generation',
'max_images' 'max_images'
], ],
ByteDanceTextToVideoNode: ['model', 'duration', 'resolution'], ByteDanceTextToVideoNode: [
ByteDanceImageToVideoNode: ['model', 'duration', 'resolution'], 'model',
ByteDanceFirstLastFrameNode: ['model', 'duration', 'resolution'], 'duration',
'resolution',
'generate_audio'
],
ByteDanceImageToVideoNode: [
'model',
'duration',
'resolution',
'generate_audio'
],
ByteDanceFirstLastFrameNode: [
'model',
'duration',
'resolution',
'generate_audio'
],
ByteDanceImageReferenceNode: ['model', 'duration', 'resolution'], ByteDanceImageReferenceNode: ['model', 'duration', 'resolution'],
WanTextToVideoApi: ['duration', 'size'], WanTextToVideoApi: ['duration', 'size'],
WanImageToVideoApi: ['duration', 'resolution'], WanImageToVideoApi: ['duration', 'resolution'],

View File

@@ -1234,8 +1234,13 @@ export function useCoreCommands(): ComfyCommand[] {
{ {
id: 'Comfy.ToggleLinear', id: 'Comfy.ToggleLinear',
icon: 'pi pi-database', icon: 'pi pi-database',
label: 'toggle linear mode', label: 'Toggle Simple Mode',
function: () => (canvasStore.linearMode = !canvasStore.linearMode) function: () => {
const newMode = !canvasStore.linearMode
app.rootGraph.extra.linearMode = newMode
workflowStore.activeWorkflow?.changeTracker?.checkState()
canvasStore.linearMode = newMode
}
} }
] ]

View File

@@ -16,6 +16,7 @@ export enum ServerFeatureFlag {
PRIVATE_MODELS_ENABLED = 'private_models_enabled', PRIVATE_MODELS_ENABLED = 'private_models_enabled',
ONBOARDING_SURVEY_ENABLED = 'onboarding_survey_enabled', ONBOARDING_SURVEY_ENABLED = 'onboarding_survey_enabled',
HUGGINGFACE_MODEL_IMPORT_ENABLED = 'huggingface_model_import_enabled', HUGGINGFACE_MODEL_IMPORT_ENABLED = 'huggingface_model_import_enabled',
LINEAR_TOGGLE_ENABLED = 'linear_toggle_enabled',
ASYNC_MODEL_UPLOAD_ENABLED = 'async_model_upload_enabled' ASYNC_MODEL_UPLOAD_ENABLED = 'async_model_upload_enabled'
} }
@@ -77,6 +78,12 @@ export function useFeatureFlags() {
) )
) )
}, },
get linearToggleEnabled() {
return (
remoteConfig.value.linear_toggle_enabled ??
api.getServerFeature(ServerFeatureFlag.LINEAR_TOGGLE_ENABLED, false)
)
},
get asyncModelUploadEnabled() { get asyncModelUploadEnabled() {
return ( return (
remoteConfig.value.async_model_upload_enabled ?? remoteConfig.value.async_model_upload_enabled ??

View File

@@ -2540,6 +2540,7 @@ export class Subgraph
this.inputNode.configure(data.inputNode) this.inputNode.configure(data.inputNode)
this.outputNode.configure(data.outputNode) this.outputNode.configure(data.outputNode)
for (const node of this.nodes) node.updateComputedDisabled()
} }
override configure( override configure(

View File

@@ -8,6 +8,7 @@ import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMuta
import { layoutStore } from '@/renderer/core/layout/store/layoutStore' import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types' import { LayoutSource } from '@/renderer/core/layout/types'
import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil' import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
import { forEachNode } from '@/utils/graphTraversalUtil'
import { CanvasPointer } from './CanvasPointer' import { CanvasPointer } from './CanvasPointer'
import type { ContextMenu } from './ContextMenu' import type { ContextMenu } from './ContextMenu'
@@ -4041,6 +4042,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
layoutStore.batchUpdateNodeBounds(newPositions) layoutStore.batchUpdateNodeBounds(newPositions)
this.selectItems(created) this.selectItems(created)
forEachNode(graph, (n) => n.onGraphConfigured?.())
forEachNode(graph, (n) => n.onAfterGraphConfigured?.())
graph.afterChange() graph.afterChange()
this.emitAfterChange() this.emitAfterChange()

View File

@@ -54,6 +54,10 @@ interface IWidgetKnobOptions extends IWidgetOptions<number[]> {
gradient_stops?: string gradient_stops?: string
} }
export interface IWidgetAssetOptions extends IWidgetOptions {
openModal: (widget: IBaseWidget) => void
}
/** /**
* A widget for a node. * A widget for a node.
* All types are based on IBaseWidget - additions can be made there or directly on individual types. * All types are based on IBaseWidget - additions can be made there or directly on individual types.
@@ -249,7 +253,7 @@ export interface ITextareaWidget extends IBaseWidget<string, 'textarea'> {
export interface IAssetWidget extends IBaseWidget< export interface IAssetWidget extends IBaseWidget<
string, string,
'asset', 'asset',
IWidgetOptions<string[]> IWidgetAssetOptions
> { > {
type: 'asset' type: 'asset'
value: string value: string

View File

@@ -53,6 +53,6 @@ export class AssetWidget
override onClick() { override onClick() {
//Open Modal //Open Modal
this.callback?.(this.value) this.options.openModal(this)
} }
} }

View File

@@ -141,6 +141,7 @@ export abstract class BaseWidget<
// @ts-expect-error Prevent naming conflicts with custom nodes. // @ts-expect-error Prevent naming conflicts with custom nodes.
labelBaseline, labelBaseline,
promoted, promoted,
linkedWidgets,
...safeValues ...safeValues
} = widget } = widget

View File

@@ -273,7 +273,7 @@
"label": "Help Center" "label": "Help Center"
}, },
"Comfy_ToggleLinear": { "Comfy_ToggleLinear": {
"label": "toggle linear mode" "label": "Toggle Simple Mode"
}, },
"Comfy_ToggleQPOV2": { "Comfy_ToggleQPOV2": {
"label": "Toggle Queue Panel V2" "label": "Toggle Queue Panel V2"

View File

@@ -190,6 +190,7 @@
"failed": "Failed", "failed": "Failed",
"cancelled": "Cancelled", "cancelled": "Cancelled",
"job": "Job", "job": "Job",
"asset": "{count} assets | {count} asset | {count} assets",
"untitled": "Untitled", "untitled": "Untitled",
"emDash": "—", "emDash": "—",
"enabling": "Enabling {id}", "enabling": "Enabling {id}",
@@ -677,7 +678,8 @@
"filterImage": "Image", "filterImage": "Image",
"filterVideo": "Video", "filterVideo": "Video",
"filterAudio": "Audio", "filterAudio": "Audio",
"filter3D": "3D" "filter3D": "3D",
"filterText": "Text"
}, },
"backToAssets": "Back to all assets", "backToAssets": "Back to all assets",
"searchAssets": "Search Assets", "searchAssets": "Search Assets",
@@ -1182,7 +1184,7 @@
"Experimental: Enable AssetAPI": "Experimental: Enable AssetAPI", "Experimental: Enable AssetAPI": "Experimental: Enable AssetAPI",
"Canvas Performance": "Canvas Performance", "Canvas Performance": "Canvas Performance",
"Help Center": "Help Center", "Help Center": "Help Center",
"toggle linear mode": "toggle linear mode", "toggle linear mode": "Toggle Simple Mode",
"Toggle Queue Panel V2": "Toggle Queue Panel V2", "Toggle Queue Panel V2": "Toggle Queue Panel V2",
"Toggle Theme (Dark/Light)": "Toggle Theme (Dark/Light)", "Toggle Theme (Dark/Light)": "Toggle Theme (Dark/Light)",
"Undo": "Undo", "Undo": "Undo",
@@ -2472,8 +2474,14 @@
"message": "Switch back to Nodes 2.0 anytime from the main menu." "message": "Switch back to Nodes 2.0 anytime from the main menu."
}, },
"linearMode": { "linearMode": {
"share": "Share", "linearMode": "Simple Mode",
"openWorkflow": "Open Workflow" "beta": "Simple Mode in Beta - Feedback",
"graphMode": "Graph Mode",
"dragAndDropImage": "Click to browse or drag an image",
"runCount": "Run count:",
"rerun": "Rerun",
"reuseParameters": "Reuse Parameters",
"downloadAll": "Download All"
}, },
"missingNodes": { "missingNodes": {
"cloud": { "cloud": {
@@ -2528,4 +2536,4 @@
"failed": "Failed" "failed": "Failed"
} }
} }
} }

View File

@@ -40,5 +40,6 @@ export type RemoteConfig = {
private_models_enabled?: boolean private_models_enabled?: boolean
onboarding_survey_enabled?: boolean onboarding_survey_enabled?: boolean
huggingface_model_import_enabled?: boolean huggingface_model_import_enabled?: boolean
linear_toggle_enabled?: boolean
async_model_upload_enabled?: boolean async_model_upload_enabled?: boolean
} }

View File

@@ -413,6 +413,7 @@ export type ExecutionTriggerSource =
| 'keybinding' | 'keybinding'
| 'legacy_ui' | 'legacy_ui'
| 'unknown' | 'unknown'
| 'linear'
/** /**
* Union type for all possible telemetry event properties * Union type for all possible telemetry event properties

View File

@@ -1,5 +1,5 @@
import { whenever } from '@vueuse/core' import { whenever } from '@vueuse/core'
import { computed, onMounted } from 'vue' import { computed, nextTick, onMounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useToastStore } from './toastStore' import { useToastStore } from './toastStore'
@@ -65,9 +65,12 @@ export function useFrontendVersionMismatchWarning(
versionCompatibilityStore.dismissWarning() versionCompatibilityStore.dismissWarning()
} }
onMounted(() => { onMounted(async () => {
// Only set up the watcher if immediate is true // Only set up the watcher if immediate is true
if (immediate) { if (immediate) {
// Wait for next tick to ensure reactive updates from settings load have propagated
await nextTick()
whenever( whenever(
() => versionCompatibilityStore.shouldShowWarning, () => versionCompatibilityStore.shouldShowWarning,
() => { () => {

View File

@@ -88,11 +88,16 @@ export const useVersionCompatibilityStore = defineStore(
return Date.now() < dismissedUntil return Date.now() < dismissedUntil
}) })
const warningsDisabled = computed(() =>
settingStore.get('Comfy.VersionCompatibility.DisableWarnings')
)
const shouldShowWarning = computed(() => { const shouldShowWarning = computed(() => {
const warningsDisabled = settingStore.get( return (
'Comfy.VersionCompatibility.DisableWarnings' hasVersionMismatch.value &&
!isDismissed.value &&
!warningsDisabled.value
) )
return hasVersionMismatch.value && !isDismissed.value && !warningsDisabled
}) })
const warningMessage = computed(() => { const warningMessage = computed(() => {

View File

@@ -11,6 +11,7 @@ import {
useWorkflowStore useWorkflowStore
} from '@/platform/workflow/management/stores/workflowStore' } from '@/platform/workflow/management/stores/workflowStore'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema' import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail' import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
import { app } from '@/scripts/app' import { app } from '@/scripts/app'
import { blankGraph, defaultGraph } from '@/scripts/defaultGraph' import { blankGraph, defaultGraph } from '@/scripts/defaultGraph'
@@ -311,6 +312,11 @@ export const useWorkflowService = () => {
workflowData: ComfyWorkflowJSON workflowData: ComfyWorkflowJSON
) => { ) => {
const workflowStore = useWorkspaceStore().workflow const workflowStore = useWorkspaceStore().workflow
if (
workflowData.extra?.linearMode !== undefined ||
!workflowData.nodes.length
)
useCanvasStore().linearMode = !!workflowData.extra?.linearMode
if (value === null || typeof value === 'string') { if (value === null || typeof value === 'string') {
const path = value as string | null const path = value as string | null
@@ -332,6 +338,11 @@ export const useWorkflowService = () => {
} }
} }
if (useCanvasStore().linearMode) {
app.rootGraph.extra ??= {}
app.rootGraph.extra.linearMode = true
}
const tempWorkflow = workflowStore.createNewTemporary( const tempWorkflow = workflowStore.createNewTemporary(
path ? appendJsonExt(path) : undefined, path ? appendJsonExt(path) : undefined,
workflowData workflowData

View File

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

View File

@@ -121,28 +121,7 @@ export function useTemplateWorkflows() {
if (!template || !template.sourceModule) return false if (!template || !template.sourceModule) return false
// Use the stored source module for loading // Use the stored source module for loading
const actualSourceModule = template.sourceModule sourceModule = template.sourceModule
json = await fetchTemplateJson(id, actualSourceModule)
// Use source module for name
const workflowName =
actualSourceModule === 'default'
? t(`templateWorkflows.template.${id}`, id)
: id
if (isCloud) {
useTelemetry()?.trackTemplate({
workflow_name: id,
template_source: actualSourceModule
})
}
dialogStore.closeDialog()
await app.loadGraphData(json, true, true, workflowName, {
openSource: 'template'
})
return true
} }
// Regular case for normal categories // Regular case for normal categories

View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
import { ref } from 'vue'
import { cn } from '@/utils/tailwindUtil'
defineProps<{
onDragOver?: (e: DragEvent) => boolean
onDragDrop?: (e: DragEvent) => Promise<boolean> | boolean
dropIndicator?: {
iconClass?: string
imageUrl?: string
label?: string
onClick?: (e: MouseEvent) => void
}
}>()
const canAcceptDrop = ref(false)
</script>
<template>
<div
v-if="onDragOver && onDragDrop"
:class="
cn(
'rounded-lg ring-inset ring-primary-500',
canAcceptDrop && 'ring-4 bg-primary-500/10'
)
"
@dragover.prevent="canAcceptDrop = onDragOver?.($event)"
@dragleave="canAcceptDrop = false"
@drop.stop.prevent="
(e: DragEvent) => {
onDragDrop!(e)
canAcceptDrop = false
}
"
>
<slot />
<div
v-if="dropIndicator"
:class="
cn(
'flex flex-col items-center justify-center gap-2 border-dashed rounded-lg border h-25 border-border-subtle m-3 py-2',
dropIndicator?.onClick && 'cursor-pointer'
)
"
@click.prevent="dropIndicator?.onClick?.($event)"
>
<img
v-if="dropIndicator?.imageUrl"
class="h-23"
:src="dropIndicator?.imageUrl"
/>
<template v-else>
<span v-if="dropIndicator.label" v-text="dropIndicator.label" />
<i v-if="dropIndicator.iconClass" :class="dropIndicator.iconClass" />
</template>
</div>
</div>
<slot v-else />
</template>

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import { ref, useTemplateRef } from 'vue'
import ZoomPane from '@/components/ui/ZoomPane.vue'
const { src } = defineProps<{
src: string
mobile?: boolean
}>()
const imageRef = useTemplateRef('imageRef')
const width = ref('')
const height = ref('')
</script>
<template>
<ZoomPane v-if="!mobile" v-slot="slotProps" class="flex-1 w-full">
<img
ref="imageRef"
:src
v-bind="slotProps"
class="h-full object-contain w-full"
@load="
() => {
if (!imageRef) return
width = `${imageRef.naturalWidth}`
height = `${imageRef.naturalHeight}`
}
"
/>
</ZoomPane>
<img
v-else
ref="imageRef"
class="w-full"
:src
@load="
() => {
if (!imageRef) return
width = `${imageRef.naturalWidth}`
height = `${imageRef.naturalHeight}`
}
"
/>
<span class="self-center md:z-10" v-text="`${width} x ${height}`" />
</template>

View File

@@ -0,0 +1,308 @@
<script setup lang="ts">
import { useEventListener, useTimeout } from '@vueuse/core'
import { partition } from 'es-toolkit'
import { storeToRefs } from 'pinia'
import { computed, ref, shallowRef } from 'vue'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import SubscribeToRunButton from '@/platform/cloud/subscription/components/SubscribeToRun.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import DropZone from '@/renderer/extensions/linearMode/DropZone.vue'
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
import { applyLightThemeColor } from '@/renderer/extensions/vueNodes/utils/nodeStyleUtils'
import WidgetInputNumberInput from '@/renderer/extensions/vueNodes/widgets/components/WidgetInputNumber.vue'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useQueueSettingsStore } from '@/stores/queueStore'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
const commandStore = useCommandStore()
const executionStore = useExecutionStore()
const { batchCount } = storeToRefs(useQueueSettingsStore())
const { isActiveSubscription } = useSubscription()
const workflowStore = useWorkflowStore()
const props = defineProps<{
toastTo?: string | HTMLElement
notesTo?: string | HTMLElement
mobile?: boolean
}>()
const jobFinishedQueue = ref(true)
const { ready: jobToastTimeout, start: resetJobToastTimeout } = useTimeout(
5000,
{ controls: true, immediate: false }
)
const graphNodes = shallowRef<LGraphNode[]>(app.rootGraph.nodes)
useEventListener(
app.rootGraph.events,
'configured',
() => (graphNodes.value = app.rootGraph.nodes)
)
function getDropIndicator(node: LGraphNode) {
if (node.type !== 'LoadImage') return undefined
const filename = node.widgets?.[0]?.value
const resultItem = { type: 'input', filename: `${filename}` }
return {
iconClass: 'icon-[lucide--image]',
imageUrl: filename
? api.apiURL(
`/view?${new URLSearchParams(resultItem)}${app.getPreviewFormatParam()}`
)
: undefined,
label: t('linearMode.dragAndDropImage'),
onClick: () => node.widgets?.[1]?.callback?.(undefined)
}
}
function nodeToNodeData(node: LGraphNode) {
const dropIndicator = getDropIndicator(node)
const nodeData = extractVueNodeData(node)
for (const widget of nodeData.widgets ?? []) widget.slotMetadata = undefined
return {
...nodeData,
//note lastNodeErrors uses exeuctionid, node.id is execution for root
hasErrors: !!executionStore.lastNodeErrors?.[node.id],
dropIndicator,
onDragDrop: node.onDragDrop,
onDragOver: node.onDragOver
}
}
const partitionedNodes = computed(() => {
const parts = partition(
graphNodes.value
.filter((node) => node.mode === 0 && node.widgets?.length)
.map(nodeToNodeData)
.reverse(),
(node) => ['MarkdownNote', 'Note'].includes(node.type)
)
for (const noteNode of parts[0]) {
for (const widget of noteNode.widgets ?? [])
widget.options = { ...widget.options, read_only: true }
}
return parts
})
const batchCountWidget: SimplifiedWidget<number> = {
options: { precision: 0, min: 1, max: isCloud ? 4 : 99 },
value: 1,
name: t('linearMode.runCount'),
type: 'number'
} as const
//TODO: refactor out of this file.
//code length is small, but changes should propagate
async function runButtonClick(e: Event) {
if (!jobFinishedQueue.value) return
try {
jobFinishedQueue.value = false
resetJobToastTimeout()
const isShiftPressed = 'shiftKey' in e && e.shiftKey
const commandId = isShiftPressed
? 'Comfy.QueuePromptFront'
: 'Comfy.QueuePrompt'
if (batchCount.value > 1) {
useTelemetry()?.trackUiButtonClicked({
button_id: 'queue_run_multiple_batches_submitted'
})
}
await commandStore.execute(commandId, {
metadata: {
subscribe_to_run: false,
trigger_source: 'linear'
}
})
} finally {
//TODO: Error state indicator for failed queue?
jobFinishedQueue.value = true
}
}
defineExpose({ runButtonClick })
</script>
<template>
<div class="flex flex-col min-w-80 md:h-full">
<section
v-if="mobile"
data-testid="linear-run-button"
class="p-4 pb-6 border-t border-node-component-border"
>
<WidgetInputNumberInput
v-model="batchCount"
:widget="batchCountWidget"
class="*:[.min-w-0]:w-24 grid-cols-[auto_96px]!"
/>
<SubscribeToRunButton v-if="!isActiveSubscription" class="w-full mt-4" />
<div v-else class="flex mt-4 gap-2">
<Button
variant="primary"
class="grow-1"
size="lg"
@click="runButtonClick"
>
<i class="icon-[lucide--play]" />
{{ t('menu.run') }}
</Button>
<Button
v-if="!executionStore.isIdle"
variant="destructive"
size="lg"
class="w-10 p-2"
@click="commandStore.execute('Comfy.Interrupt')"
>
<i class="icon-[lucide--x]" />
</Button>
</div>
</section>
<section
data-testid="linear-workflow-info"
class="h-12 border-x border-border-subtle py-2 px-4 gap-2 bg-comfy-menu-bg flex items-center md:contain-size"
>
<span
class="font-bold truncate"
v-text="workflowStore.activeWorkflow?.filename"
/>
<div class="flex-1" />
<Popover
v-if="partitionedNodes[0].length"
align="start"
class="overflow-y-auto overflow-x-clip max-h-(--reka-popover-content-available-height) z-100"
:reference="notesTo"
side="left"
:to="notesTo"
>
<template #button>
<Button variant="muted-textonly">
<i class="icon-[lucide--info]" />
</Button>
</template>
<div>
<template
v-for="(nodeData, index) in partitionedNodes[0]"
:key="nodeData.id"
>
<div
v-if="index !== 0"
class="w-full border-t border-border-subtle"
/>
<NodeWidgets
:node-data
:style="{ background: applyLightThemeColor(nodeData.bgcolor) }"
class="py-3 gap-y-3 **:[.col-span-2]:grid-cols-1 not-has-[textarea]:flex-0 rounded-lg max-w-100"
/>
</template>
</div>
</Popover>
<Button v-if="false"> {{ t('menuLabels.publish') }} </Button>
</section>
<div
class="border gap-2 md:h-full border-[var(--interface-stroke)] bg-comfy-menu-bg flex flex-col px-2"
>
<section
data-testid="linear-widgets"
class="grow-1 md:overflow-y-auto md:contain-size"
>
<template
v-for="(nodeData, index) of partitionedNodes[1]"
:key="nodeData.id"
>
<div
v-if="index !== 0"
class="w-full border-t-1 border-node-component-border"
/>
<DropZone
:on-drag-over="nodeData.onDragOver"
:on-drag-drop="nodeData.onDragDrop"
:drop-indicator="mobile ? undefined : nodeData.dropIndicator"
class="text-muted-foreground"
>
<NodeWidgets
:node-data
:class="
cn(
'py-3 gap-y-3 **:[.col-span-2]:grid-cols-1 not-has-[textarea]:flex-0 rounded-lg',
nodeData.hasErrors &&
'ring-2 ring-inset ring-node-stroke-error'
)
"
:style="{ background: applyLightThemeColor(nodeData.bgcolor) }"
/>
</DropZone>
</template>
</section>
<section
v-if="!mobile"
data-testid="linear-run-button"
class="p-4 pb-6 border-t border-node-component-border"
>
<WidgetInputNumberInput
v-model="batchCount"
:widget="batchCountWidget"
class="*:[.min-w-0]:w-24 grid-cols-[auto_96px]!"
/>
<SubscribeToRunButton
v-if="!isActiveSubscription"
class="w-full mt-4"
/>
<div v-else class="flex mt-4 gap-2">
<Button
variant="primary"
class="grow-1"
size="lg"
@click="runButtonClick"
>
<i class="icon-[lucide--play]" />
{{ t('menu.run') }}
</Button>
<Button
v-if="!executionStore.isIdle"
variant="destructive"
size="lg"
class="w-10 p-2"
@click="commandStore.execute('Comfy.Interrupt')"
>
<i class="icon-[lucide--x]" />
</Button>
</div>
</section>
</div>
</div>
<Teleport
v-if="(!jobToastTimeout || !jobFinishedQueue) && toastTo"
defer
:to="toastTo"
>
<div
class="bg-base-foreground text-base-background rounded-sm flex h-8 p-1 pr-2 gap-2 items-center"
>
<i
v-if="jobFinishedQueue"
class="icon-[lucide--check] size-5 bg-success-background"
/>
<i v-else class="icon-[lucide--loader-circle] size-4 animate-spin" />
<span v-text="t('queue.jobAddedToQueue')" />
</div>
</Teleport>
<Teleport v-if="false" defer :to="notesTo">
<div
class="bg-base-background text-muted-foreground flex flex-col w-90 gap-2 rounded-2xl border-1 border-border-subtle py-3"
></div>
</Teleport>
</template>

View File

@@ -0,0 +1,184 @@
<script setup lang="ts">
import { computed } from 'vue'
import { downloadFile } from '@/base/common/downloadUtil'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { d, t } from '@/i18n'
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { extractWorkflowFromAsset } from '@/platform/workflow/utils/workflowExtractionUtil'
import ImagePreview from '@/renderer/extensions/linearMode/ImagePreview.vue'
import Preview3d from '@/renderer/extensions/linearMode/Preview3d.vue'
import VideoPreview from '@/renderer/extensions/linearMode/VideoPreview.vue'
import {
getMediaType,
mediaTypes
} from '@/renderer/extensions/linearMode/mediaTypes'
import type { StatItem } from '@/renderer/extensions/linearMode/mediaTypes'
import { app } from '@/scripts/app'
import type { ResultItemImpl } from '@/stores/queueStore'
import { formatDuration } from '@/utils/dateTimeUtil'
import { collectAllNodes } from '@/utils/graphTraversalUtil'
import { executeWidgetsCallback } from '@/utils/litegraphUtil'
const mediaActions = useMediaAssetActions()
const { runButtonClick, selectedItem, selectedOutput } = defineProps<{
latentPreview?: string
runButtonClick?: (e: Event) => void
selectedItem?: AssetItem
selectedOutput?: ResultItemImpl
mobile?: boolean
}>()
const dateOptions = {
month: 'short',
day: 'numeric',
year: 'numeric'
} as const
const timeOptions = {
hour: 'numeric',
minute: 'numeric',
second: 'numeric'
} as const
function formatTime(time: string) {
if (!time) return ''
const date = new Date(time)
return `${d(date, dateOptions)} | ${d(date, timeOptions)}`
}
const itemStats = computed<StatItem[]>(() => {
if (!selectedItem) return []
const user_metadata = getOutputAssetMetadata(selectedItem.user_metadata)
if (!user_metadata) return []
const { allOutputs } = user_metadata
return [
{ content: formatTime(selectedItem.created_at) },
{ content: formatDuration(user_metadata.executionTimeInSeconds) },
allOutputs && { content: t('g.asset', allOutputs.length) },
(selectedOutput && mediaTypes[getMediaType(selectedOutput)]) ?? {}
].filter((i) => !!i)
})
function downloadAsset(item?: AssetItem) {
const user_metadata = getOutputAssetMetadata(item?.user_metadata)
for (const output of user_metadata?.allOutputs ?? [])
downloadFile(output.url, output.filename)
}
async function loadWorkflow(item: AssetItem | undefined) {
if (!item) return
const { workflow } = await extractWorkflowFromAsset(item)
if (!workflow) return
if (workflow.id !== app.rootGraph.id) return app.loadGraphData(workflow)
//update graph to new version, set old to top of undo queue
const changeTracker = useWorkflowStore().activeWorkflow?.changeTracker
if (!changeTracker) return app.loadGraphData(workflow)
changeTracker.redoQueue = []
changeTracker.updateState([workflow], changeTracker.undoQueue)
}
async function rerun(e: Event) {
if (!runButtonClick) return
await loadWorkflow(selectedItem)
//FIXME don't use timeouts here
//Currently seeds fail to properly update even with timeouts?
await new Promise((r) => setTimeout(r, 500))
executeWidgetsCallback(collectAllNodes(app.rootGraph), 'afterQueued')
runButtonClick(e)
}
</script>
<template>
<section
v-if="selectedItem"
data-testid="linear-output-info"
class="flex flex-wrap gap-2 p-1 w-full md:z-10 tabular-nums justify-between text-sm"
>
<div class="flex gap-3 text-nowrap">
<div
v-for="({ content, iconClass }, index) in itemStats"
:key="index"
class="flex items-center justify-items-center gap-1 tabular-nums"
>
<i v-if="iconClass" :class="iconClass" />
{{ content }}
</div>
</div>
<div class="flex gap-3 justify-self-end">
<Button size="md" @click="rerun">
{{ t('linearMode.rerun') }}
<i class="icon-[lucide--refresh-cw]" />
</Button>
<Button size="md" @click="() => loadWorkflow(selectedItem)">
{{ t('linearMode.reuseParameters') }}
<i class="icon-[lucide--list-restart]" />
</Button>
<div class="border-r border-border-subtle mx-1" />
<Button
size="icon"
@click="
() => {
if (selectedOutput?.url) downloadFile(selectedOutput.url)
}
"
>
<i class="icon-[lucide--download]" />
</Button>
<Popover
:entries="[
[
{
icon: 'icon-[lucide--download]',
label: t('linearMode.downloadAll'),
action: () => downloadAsset(selectedItem!)
}
],
[
{
icon: 'icon-[lucide--trash-2]',
label: t('queue.jobMenu.deleteAsset'),
action: () => mediaActions.confirmDelete(selectedItem!)
}
]
]"
/>
</div>
</section>
<ImagePreview
v-if="latentPreview ?? getMediaType(selectedOutput) === 'images'"
:mobile
:src="latentPreview ?? selectedOutput!.url"
/>
<VideoPreview
v-else-if="getMediaType(selectedOutput) === 'video'"
:src="selectedOutput!.url"
class="object-contain flex-1 md:contain-size"
/>
<audio
v-else-if="getMediaType(selectedOutput) === 'audio'"
class="w-full m-auto"
controls
:src="selectedOutput!.url"
/>
<article
v-else-if="getMediaType(selectedOutput) === 'text'"
class="w-full max-w-128 m-auto my-12 overflow-y-auto"
v-text="selectedOutput!.url"
/>
<Preview3d
v-else-if="getMediaType(selectedOutput) === '3d'"
:model-url="selectedOutput!.url"
/>
<img
v-else
class="pointer-events-none flex-1 max-h-full md:contain-size brightness-50 opacity-10"
src="/assets/images/comfy-logo-mono.svg"
/>
</template>

View File

@@ -0,0 +1,308 @@
<script setup lang="ts">
import { useEventListener, useInfiniteScroll, useScroll } from '@vueuse/core'
import { computed, ref, toRaw, useTemplateRef, watch } from 'vue'
import ModeToggle from '@/components/sidebar/ModeToggle.vue'
import SidebarIcon from '@/components/sidebar/SidebarIcon.vue'
import SidebarTemplatesButton from '@/components/sidebar/SidebarTemplatesButton.vue'
import WorkflowsSidebarTab from '@/components/sidebar/tabs/WorkflowsSidebarTab.vue'
import Button from '@/components/ui/button/Button.vue'
import { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useSettingStore } from '@/platform/settings/settingStore'
import {
getMediaType,
mediaTypes
} from '@/renderer/extensions/linearMode/mediaTypes'
import { useQueueStore } from '@/stores/queueStore'
import type { ResultItemImpl } from '@/stores/queueStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { cn } from '@/utils/tailwindUtil'
const displayWorkflows = ref(false)
const outputs = useMediaAssets('output')
const queueStore = useQueueStore()
const settingStore = useSettingStore()
const workflowTab = useWorkspaceStore()
.getSidebarTabs()
.find((w) => w.id === 'workflows')
void outputs.fetchMediaList()
defineProps<{
scrollResetButtonTo?: string | HTMLElement
mobile?: boolean
}>()
const emit = defineEmits<{
updateSelection: [
selection: [AssetItem | undefined, ResultItemImpl | undefined, boolean]
]
}>()
defineExpose({ onWheel })
const selectedIndex = ref<[number, number]>([-1, 0])
watch(selectedIndex, () => {
const [index] = selectedIndex.value
emit('updateSelection', [
outputs.media.value[index],
selectedOutput.value,
selectedIndex.value[0] <= 0
])
})
const outputsRef = useTemplateRef('outputsRef')
const { reset: resetInfiniteScroll } = useInfiniteScroll(
outputsRef,
outputs.loadMore,
{ canLoadMore: () => outputs.hasMore.value }
)
function resetOutputsScroll() {
//TODO need to also prune outputs entries?
resetInfiniteScroll()
outputsRef.value?.scrollTo(0, 0)
}
const { y: outputScrollState } = useScroll(outputsRef)
watch(selectedIndex, () => {
const [index, key] = selectedIndex.value
if (!outputsRef.value) return
const outputElement = outputsRef.value?.children?.[index]?.children?.[key]
if (!outputElement) return
//container: 'nearest' is nice, but bleeding edge and chrome only
outputElement.scrollIntoView({ block: 'nearest' })
})
function allOutputs(item?: AssetItem) {
const user_metadata = getOutputAssetMetadata(item?.user_metadata)
if (!user_metadata?.allOutputs) return []
return user_metadata.allOutputs
}
const selectedOutput = computed(() => {
const [index, key] = selectedIndex.value
if (index < 0) return undefined
const output = allOutputs(outputs.media.value[index])[key]
if (output) return output
return allOutputs(outputs.media.value[0])[0]
})
watch(
() => outputs.media.value,
(newAssets, oldAssets) => {
if (newAssets.length === oldAssets.length || oldAssets.length === 0) return
if (selectedIndex.value[0] <= 0) {
//force update
selectedIndex.value = [0, 0]
return
}
const oldId = toRaw(oldAssets[selectedIndex.value[0]]?.id)
const newIndex = toRaw(newAssets).findIndex((asset) => asset?.id === oldId)
if (newIndex === -1) selectedIndex.value = [0, 0]
else selectedIndex.value = [newIndex, selectedIndex.value[1]]
}
)
function gotoNextOutput() {
const [index, key] = selectedIndex.value
if (index < 0 || key < 0) {
selectedIndex.value = [0, 0]
return
}
const currentItem = outputs.media.value[index]
if (allOutputs(currentItem)[key + 1]) {
selectedIndex.value = [index, key + 1]
return
}
if (outputs.media.value[index + 1]) {
selectedIndex.value = [index + 1, 0]
}
//do nothing, no next output
}
function gotoPreviousOutput() {
const [index, key] = selectedIndex.value
if (key > 0) {
selectedIndex.value = [index, key - 1]
return
}
if (index > 0) {
const currentItem = outputs.media.value[index - 1]
selectedIndex.value = [index - 1, allOutputs(currentItem).length - 1]
return
}
selectedIndex.value = [0, 0]
}
let pointer = new CanvasPointer(document.body)
let scrollOffset = 0
function onWheel(e: WheelEvent) {
if (!e.ctrlKey && !e.metaKey) return
e.preventDefault()
e.stopPropagation()
if (!pointer.isTrackpadGesture(e)) {
if (e.deltaY > 0) gotoNextOutput()
else gotoPreviousOutput()
return
}
scrollOffset += e.deltaY
while (scrollOffset >= 60) {
scrollOffset -= 60
gotoNextOutput()
}
while (scrollOffset <= -60) {
scrollOffset += 60
gotoPreviousOutput()
}
}
useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
if (
(e.key !== 'ArrowDown' && e.key !== 'ArrowUp') ||
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLInputElement
)
return
e.preventDefault()
e.stopPropagation()
if (e.key === 'ArrowDown') gotoNextOutput()
else gotoPreviousOutput()
})
</script>
<template>
<div
:class="
cn(
'min-w-38 flex bg-comfy-menu-bg md:h-full border-border-subtle',
settingStore.get('Comfy.Sidebar.Location') === 'right'
? 'flex-row-reverse border-l'
: 'md:border-r'
)
"
v-bind="$attrs"
>
<div
v-if="!mobile"
class="h-full flex flex-col w-14 shrink-0 overflow-hidden items-center p-2"
>
<template v-if="workflowTab">
<SidebarIcon
:icon="workflowTab.icon"
:icon-badge="workflowTab.iconBadge"
:tooltip="workflowTab.tooltip"
:label="workflowTab.label || workflowTab.title"
:class="workflowTab.id + '-tab-button'"
:selected="displayWorkflows"
:is-small="settingStore.get('Comfy.Sidebar.Size') === 'small'"
@click="displayWorkflows = !displayWorkflows"
/>
</template>
<SidebarTemplatesButton />
<div class="flex-1" />
<ModeToggle />
</div>
<div class="border-border-subtle md:border-r" />
<WorkflowsSidebarTab v-if="displayWorkflows" class="min-w-50 grow-1" />
<article
v-else
ref="outputsRef"
data-testid="linear-outputs"
class="h-24 md:h-full min-w-24 grow-1 p-3 overflow-x-auto overflow-y-clip md:overflow-y-auto md:overflow-x-clip md:border-r-1 border-node-component-border flex md:flex-col items-center contain-size"
>
<section
v-if="
queueStore.runningTasks.length > 0 ||
queueStore.pendingTasks.length > 0
"
data-testid="linear-job"
class="py-3 not-md:h-24 md:w-full aspect-square px-1 relative"
>
<i
v-if="queueStore.runningTasks.length > 0"
class="icon-[lucide--loader-circle] size-full animate-spin"
/>
<i v-else class="icon-[lucide--ellipsis] size-full animate-pulse" />
<div
v-if="
queueStore.runningTasks.length + queueStore.pendingTasks.length > 1
"
class="absolute top-0 right-0 p-1 min-w-5 h-5 flex justify-center items-center rounded-full bg-primary-background text-text-primary"
v-text="
queueStore.runningTasks.length + queueStore.pendingTasks.length
"
/>
</section>
<template v-for="(item, index) in outputs.media.value" :key="index">
<div
class="border-border-subtle not-md:border-l md:border-t first:border-none not-md:h-21 md:w-full m-3"
/>
<template v-for="(output, key) in allOutputs(item)" :key>
<img
v-if="getMediaType(output) === 'images'"
:class="
cn(
'p-1 rounded-lg aspect-square object-cover not-md:h-20 md:w-full',
index === selectedIndex[0] &&
key === selectedIndex[1] &&
'border-2'
)
"
:src="output.url"
@click="selectedIndex = [index, key]"
/>
<div
v-else
:class="
cn(
'p-1 rounded-lg aspect-square w-full',
index === selectedIndex[0] &&
key === selectedIndex[1] &&
'border-2'
)
"
@click="selectedIndex = [index, key]"
>
<i
:class="
cn(mediaTypes[getMediaType(output)]?.iconClass, 'size-full')
"
/>
</div>
</template>
</template>
</article>
</div>
<Teleport
v-if="outputScrollState && scrollResetButtonTo"
:to="scrollResetButtonTo"
>
<Button
:class="
cn(
'p-3 size-10 bg-base-foreground',
settingStore.get('Comfy.Sidebar.Location') === 'left'
? 'left-4'
: 'right-4'
)
"
@click="resetOutputsScroll"
>
<i class="icon-[lucide--arrow-up] size-4 bg-base-background" />
</Button>
</Teleport>
</template>

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import { useTemplateRef, watch } from 'vue'
import AnimationControls from '@/components/load3d/controls/AnimationControls.vue'
import { useLoad3dViewer } from '@/composables/useLoad3dViewer'
const { modelUrl } = defineProps<{
modelUrl: string
}>()
const containerRef = useTemplateRef('containerRef')
const viewer = useLoad3dViewer()
watch([containerRef, () => modelUrl], async () => {
if (!containerRef.value || !modelUrl) return
await viewer.initializeStandaloneViewer(containerRef.value, modelUrl)
})
//TODO: refactor to add control buttons
</script>
<template>
<div
ref="containerRef"
class="relative w-full h-full"
@mouseenter="viewer.handleMouseEnter"
@mouseleave="viewer.handleMouseLeave"
@resize="viewer.handleResize"
>
<div class="pointer-events-none absolute top-0 left-0 size-full">
<AnimationControls
v-if="viewer.animations.value && viewer.animations.value.length > 0"
v-model:animations="viewer.animations.value"
v-model:playing="viewer.playing.value"
v-model:selected-speed="viewer.selectedSpeed.value"
v-model:selected-animation="viewer.selectedAnimation.value"
v-model:animation-progress="viewer.animationProgress.value"
v-model:animation-duration="viewer.animationDuration.value"
@seek="viewer.handleSeek"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import { ref, useTemplateRef } from 'vue'
const { src } = defineProps<{
src: string
}>()
const videoRef = useTemplateRef('videoRef')
const width = ref('')
const height = ref('')
</script>
<template>
<video
ref="videoRef"
:src
controls
v-bind="$attrs"
@loadedmetadata="
() => {
if (!videoRef) return
width = `${videoRef.videoWidth}`
height = `${videoRef.videoHeight}`
}
"
/>
<span class="self-center z-10" v-text="`${width} x ${height}`" />
</template>

View File

@@ -0,0 +1,33 @@
import { t } from '@/i18n'
import type { ResultItemImpl } from '@/stores/queueStore'
export type StatItem = { content?: string; iconClass?: string }
export const mediaTypes: Record<string, StatItem> = {
'3d': {
content: t('sideToolbar.mediaAssets.filter3D'),
iconClass: 'icon-[lucide--box]'
},
audio: {
content: t('sideToolbar.mediaAssets.filterAudio'),
iconClass: 'icon-[lucide--audio-lines]'
},
images: {
content: t('sideToolbar.mediaAssets.filterImage'),
iconClass: 'icon-[lucide--image]'
},
text: {
content: t('sideToolbar.mediaAssets.filterText'),
iconClass: 'icon-[lucide--text]'
},
video: {
content: t('sideToolbar.mediaAssets.filterVideo'),
iconClass: 'icon-[lucide--video]'
}
}
export function getMediaType(output?: ResultItemImpl) {
if (!output) return ''
if (output.isVideo) return 'video'
return output.mediaType
}

View File

@@ -40,7 +40,7 @@
transform: `translate(${position.x ?? 0}px, ${(position.y ?? 0) - LiteGraph.NODE_TITLE_HEIGHT}px)`, transform: `translate(${position.x ?? 0}px, ${(position.y ?? 0) - LiteGraph.NODE_TITLE_HEIGHT}px)`,
zIndex: zIndex, zIndex: zIndex,
opacity: nodeOpacity, opacity: nodeOpacity,
'--component-node-background': nodeBodyBackgroundColor '--component-node-background': applyLightThemeColor(nodeData.bgcolor)
} }
]" ]"
v-bind="remainingPointerHandlers" v-bind="remainingPointerHandlers"
@@ -168,7 +168,6 @@ import { applyLightThemeColor } from '@/renderer/extensions/vueNodes/utils/nodeS
import { app } from '@/scripts/app' import { app } from '@/scripts/app'
import { useExecutionStore } from '@/stores/executionStore' import { useExecutionStore } from '@/stores/executionStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore' import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { isTransparent } from '@/utils/colorUtil' import { isTransparent } from '@/utils/colorUtil'
import { import {
getLocatorIdFromNodeData, getLocatorIdFromNodeData,
@@ -228,19 +227,6 @@ const bypassed = computed(
) )
const muted = computed((): boolean => nodeData.mode === LGraphEventMode.NEVER) const muted = computed((): boolean => nodeData.mode === LGraphEventMode.NEVER)
const nodeBodyBackgroundColor = computed(() => {
const colorPaletteStore = useColorPaletteStore()
if (!nodeData.bgcolor) {
return ''
}
return applyLightThemeColor(
nodeData.bgcolor,
Boolean(colorPaletteStore.completedActivePalette.light_theme)
)
})
const nodeOpacity = computed(() => { const nodeOpacity = computed(() => {
const globalOpacity = useSettingStore().get('Comfy.Node.Opacity') ?? 1 const globalOpacity = useSettingStore().get('Comfy.Node.Opacity') ?? 1

View File

@@ -11,7 +11,10 @@
headerShapeClass headerShapeClass
) )
" "
:style="headerStyle" :style="{
backgroundColor: applyLightThemeColor(nodeData?.color),
opacity: useSettingStore().get('Comfy.Node.Opacity') ?? 1
}"
:data-testid="`node-header-${nodeData?.id || ''}`" :data-testid="`node-header-${nodeData?.id || ''}`"
@dblclick="handleDoubleClick" @dblclick="handleDoubleClick"
> >
@@ -104,7 +107,6 @@ import NodeBadge from '@/renderer/extensions/vueNodes/components/NodeBadge.vue'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips' import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import { applyLightThemeColor } from '@/renderer/extensions/vueNodes/utils/nodeStyleUtils' import { applyLightThemeColor } from '@/renderer/extensions/vueNodes/utils/nodeStyleUtils'
import { app } from '@/scripts/app' import { app } from '@/scripts/app'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { normalizeI18nKey } from '@/utils/formatUtil' import { normalizeI18nKey } from '@/utils/formatUtil'
import { import {
getLocatorIdFromNodeData, getLocatorIdFromNodeData,
@@ -156,23 +158,6 @@ const enterSubgraphTooltipConfig = computed(() => {
return createTooltipConfig(st('enterSubgraph', 'Enter Subgraph')) return createTooltipConfig(st('enterSubgraph', 'Enter Subgraph'))
}) })
const headerStyle = computed(() => {
const colorPaletteStore = useColorPaletteStore()
const opacity = useSettingStore().get('Comfy.Node.Opacity') ?? 1
if (!nodeData?.color) {
return { backgroundColor: '', opacity }
}
const headerColor = applyLightThemeColor(
nodeData.color,
Boolean(colorPaletteStore.completedActivePalette.light_theme)
)
return { backgroundColor: headerColor, opacity }
})
const resolveTitle = (info: VueNodeData | undefined) => { const resolveTitle = (info: VueNodeData | undefined) => {
const title = (info?.title ?? '').trim() const title = (info?.title ?? '').trim()
if (title.length > 0) return title if (title.length > 0) return title

View File

@@ -1,14 +1,13 @@
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil' import { adjustColor } from '@/utils/colorUtil'
/** /**
* Applies light theme color adjustments to a color * Applies light theme color adjustments to a color
*/ */
export function applyLightThemeColor( export function applyLightThemeColor(color?: string): string {
color: string, if (!color) return ''
isLightTheme: boolean
): string { if (!useColorPaletteStore().completedActivePalette.light_theme) return color
if (!color || !isLightTheme) {
return color
}
return adjustColor(color, { lightness: 0.5 }) return adjustColor(color, { lightness: 0.5 })
} }

View File

@@ -12,11 +12,11 @@
</div> </div>
<div <div
v-if="isRecording || isPlaying || recordedURL" v-if="isRecording || isPlaying || recordedURL"
class="flex h-14 w-full items-center gap-4 rounded-lg px-4 bg-node-component-surface text-text-secondary" class="flex h-14 w-full min-w-0 items-center gap-2 rounded-lg px-3 bg-node-component-surface text-text-secondary"
> >
<!-- Recording Status --> <!-- Recording Status -->
<div class="flex min-w-30 items-center gap-2"> <div class="flex shrink-0 items-center gap-1">
<span class="min-w-20 text-xs"> <span class="text-xs">
{{ {{
isRecording isRecording
? t('g.listening', 'Listening...') ? t('g.listening', 'Listening...')
@@ -27,11 +27,11 @@
: '' : ''
}} }}
</span> </span>
<span class="min-w-10 text-sm">{{ formatTime(timer) }}</span> <span class="text-sm">{{ formatTime(timer) }}</span>
</div> </div>
<!-- Waveform Visualization --> <!-- Waveform Visualization -->
<div class="flex h-8 flex-1 items-center gap-2 overflow-x-clip"> <div class="flex h-8 min-w-0 flex-1 items-center gap-2 overflow-hidden">
<div <div
v-for="(bar, index) in waveformBars" v-for="(bar, index) in waveformBars"
:key="index" :key="index"
@@ -45,7 +45,7 @@
<button <button
v-if="isRecording" v-if="isRecording"
:title="t('g.stopRecording', 'Stop Recording')" :title="t('g.stopRecording', 'Stop Recording')"
class="flex size-8 animate-pulse items-center justify-center rounded-full border-0 bg-smoke-500/33 transition-colors" class="flex shrink-0 size-8 animate-pulse items-center justify-center rounded-full border-0 bg-smoke-500/33 transition-colors"
@click="handleStopRecording" @click="handleStopRecording"
> >
<div class="size-2.5 rounded-sm bg-danger-100" /> <div class="size-2.5 rounded-sm bg-danger-100" />
@@ -54,7 +54,7 @@
<button <button
v-else-if="!isRecording && recordedURL && !isPlaying" v-else-if="!isRecording && recordedURL && !isPlaying"
:title="t('g.playRecording') || 'Play Recording'" :title="t('g.playRecording') || 'Play Recording'"
class="flex size-8 items-center justify-center rounded-full border-0 bg-smoke-500/33 transition-colors" class="flex shrink-0 size-8 items-center justify-center rounded-full border-0 bg-smoke-500/33 transition-colors"
@click="handlePlayRecording" @click="handlePlayRecording"
> >
<i class="text-text-secondary icon-[lucide--play] size-4" /> <i class="text-text-secondary icon-[lucide--play] size-4" />
@@ -63,7 +63,7 @@
<button <button
v-else-if="isPlaying" v-else-if="isPlaying"
:title="t('g.stopPlayback') || 'Stop Playback'" :title="t('g.stopPlayback') || 'Stop Playback'"
class="flex size-8 items-center justify-center rounded-full border-0 bg-smoke-500/33 transition-colors" class="flex shrink-0 size-8 items-center justify-center rounded-full border-0 bg-smoke-500/33 transition-colors"
@click="handleStopPlayback" @click="handleStopPlayback"
> >
<i class="text-text-secondary icon-[lucide--square] size-4" /> <i class="text-text-secondary icon-[lucide--square] size-4" />

View File

@@ -215,7 +215,8 @@ describe('useComboWidget', () => {
'asset', 'asset',
'ckpt_name', 'ckpt_name',
'model1.safetensors', 'model1.safetensors',
expect.any(Function) expect.any(Function),
expect.any(Object)
) )
expect(mockSettingStoreGet).toHaveBeenCalledWith('Comfy.Assets.UseAssetAPI') expect(mockSettingStoreGet).toHaveBeenCalledWith('Comfy.Assets.UseAssetAPI')
expect(vi.mocked(assetService.isAssetBrowserEligible)).toHaveBeenCalledWith( expect(vi.mocked(assetService.isAssetBrowserEligible)).toHaveBeenCalledWith(
@@ -250,7 +251,8 @@ describe('useComboWidget', () => {
'asset', 'asset',
'ckpt_name', 'ckpt_name',
'fallback.safetensors', 'fallback.safetensors',
expect.any(Function) expect.any(Function),
expect.any(Object)
) )
expect(mockSettingStoreGet).toHaveBeenCalledWith('Comfy.Assets.UseAssetAPI') expect(mockSettingStoreGet).toHaveBeenCalledWith('Comfy.Assets.UseAssetAPI')
expect(widget).toBe(mockWidget) expect(widget).toBe(mockWidget)
@@ -280,7 +282,8 @@ describe('useComboWidget', () => {
'asset', 'asset',
'ckpt_name', 'ckpt_name',
'Select model', // Should fallback to this instead of undefined 'Select model', // Should fallback to this instead of undefined
expect.any(Function) expect.any(Function),
expect.any(Object)
) )
expect(mockSettingStoreGet).toHaveBeenCalledWith('Comfy.Assets.UseAssetAPI') expect(mockSettingStoreGet).toHaveBeenCalledWith('Comfy.Assets.UseAssetAPI')
expect(widget).toBe(mockWidget) expect(widget).toBe(mockWidget)

View File

@@ -4,7 +4,10 @@ import MultiSelectWidget from '@/components/graph/widgets/MultiSelectWidget.vue'
import { t } from '@/i18n' import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph' import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { isAssetWidget, isComboWidget } from '@/lib/litegraph/src/litegraph' import { isAssetWidget, isComboWidget } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' import type {
IBaseWidget,
IWidgetAssetOptions
} from '@/lib/litegraph/src/types/widgets'
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog' import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
import { import {
assetFilenameSchema, assetFilenameSchema,
@@ -91,55 +94,59 @@ const createAssetBrowserWidget = (
const displayLabel = currentValue ?? t('widgets.selectModel') const displayLabel = currentValue ?? t('widgets.selectModel')
const assetBrowserDialog = useAssetBrowserDialog() const assetBrowserDialog = useAssetBrowserDialog()
async function openModal(widget: IBaseWidget) {
if (!isAssetWidget(widget)) {
throw new Error(`Expected asset widget but received ${widget.type}`)
}
await assetBrowserDialog.show({
nodeType: node.comfyClass || '',
inputName: inputSpec.name,
currentValue: widget.value,
onAssetSelected: (asset) => {
const validatedAsset = assetItemSchema.safeParse(asset)
if (!validatedAsset.success) {
console.error(
'Invalid asset item:',
validatedAsset.error.errors,
'Received:',
asset
)
return
}
const filename = validatedAsset.data.user_metadata?.filename
const validatedFilename = assetFilenameSchema.safeParse(filename)
if (!validatedFilename.success) {
console.error(
'Invalid asset filename:',
validatedFilename.error.errors,
'for asset:',
validatedAsset.data.id
)
return
}
const oldValue = widget.value
widget.value = validatedFilename.data
node.onWidgetChanged?.(
widget.name,
validatedFilename.data,
oldValue,
widget
)
}
})
}
const options: IWidgetAssetOptions = { openModal }
const widget = node.addWidget( const widget = node.addWidget(
'asset', 'asset',
inputSpec.name, inputSpec.name,
displayLabel, displayLabel,
async function (this: IBaseWidget) { () => undefined,
if (!isAssetWidget(widget)) { options
throw new Error(`Expected asset widget but received ${widget.type}`)
}
await assetBrowserDialog.show({
nodeType: node.comfyClass || '',
inputName: inputSpec.name,
currentValue: widget.value,
onAssetSelected: (asset) => {
const validatedAsset = assetItemSchema.safeParse(asset)
if (!validatedAsset.success) {
console.error(
'Invalid asset item:',
validatedAsset.error.errors,
'Received:',
asset
)
return
}
const filename = validatedAsset.data.user_metadata?.filename
const validatedFilename = assetFilenameSchema.safeParse(filename)
if (!validatedFilename.success) {
console.error(
'Invalid asset filename:',
validatedFilename.error.errors,
'for asset:',
validatedAsset.data.id
)
return
}
const oldValue = widget.value
this.value = validatedFilename.data
node.onWidgetChanged?.(
widget.name,
validatedFilename.data,
oldValue,
widget
)
}
})
}
) )
return widget return widget

View File

@@ -1224,6 +1224,8 @@ export class ComfyApp {
// Fit view if no nodes visible in restored viewport // Fit view if no nodes visible in restored viewport
this.canvas.ds.computeVisibleArea(this.canvas.viewport) this.canvas.ds.computeVisibleArea(this.canvas.viewport)
if ( if (
this.canvas.visible_area.width &&
this.canvas.visible_area.height &&
!anyItemOverlapsRect( !anyItemOverlapsRect(
this.rootGraph._nodes, this.rootGraph._nodes,
this.canvas.visible_area this.canvas.visible_area

View File

@@ -139,6 +139,9 @@ export function addValueControlWidgets(
'Allows the linked widget to be changed automatically, for example randomizing the noise seed.' 'Allows the linked widget to be changed automatically, for example randomizing the noise seed.'
valueControl[IS_CONTROL_WIDGET] = true valueControl[IS_CONTROL_WIDGET] = true
updateControlWidgetLabel(valueControl) updateControlWidgetLabel(valueControl)
Object.defineProperty(valueControl, 'disabled', {
get: () => targetWidget.computedDisabled
})
const widgets: [IComboWidget, ...IStringWidget[]] = [valueControl] const widgets: [IComboWidget, ...IStringWidget[]] = [valueControl]
const isCombo = isComboWidget(targetWidget) const isCombo = isComboWidget(targetWidget)
@@ -160,6 +163,9 @@ export function addValueControlWidgets(
updateControlWidgetLabel(comboFilter) updateControlWidgetLabel(comboFilter)
comboFilter.tooltip = comboFilter.tooltip =
"Allows for filtering the list of values when changing the value via the control generate mode. Allows for RegEx matches in the format /abc/ to only filter to values containing 'abc'." "Allows for filtering the list of values when changing the value via the control generate mode. Allows for RegEx matches in the format /abc/ to only filter to values containing 'abc'."
Object.defineProperty(comboFilter, 'disabled', {
get: () => targetWidget.computedDisabled
})
widgets.push(comboFilter) widgets.push(comboFilter)
} }

View File

@@ -40,7 +40,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
const { nodeIdToNodeLocatorId, nodeToNodeLocatorId } = useWorkflowStore() const { nodeIdToNodeLocatorId, nodeToNodeLocatorId } = useWorkflowStore()
const { executionIdToNodeLocatorId } = useExecutionStore() const { executionIdToNodeLocatorId } = useExecutionStore()
const scheduledRevoke: Record<NodeLocatorId, { stop: () => void }> = {} const scheduledRevoke: Record<NodeLocatorId, { stop: () => void }> = {}
const latestOutput = ref<string[]>([]) const latestPreview = ref<string[]>([])
function scheduleRevoke(locator: NodeLocatorId, cb: () => void) { function scheduleRevoke(locator: NodeLocatorId, cb: () => void) {
scheduledRevoke[locator]?.stop() scheduledRevoke[locator]?.stop()
@@ -147,13 +147,6 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
} }
} }
//TODO:Preview params and deduplication
latestOutput.value =
(outputs as ExecutedWsMessage['output'])?.images?.map((image) => {
const imgUrlPart = new URLSearchParams(image)
const rand = app.getRandParam()
return api.apiURL(`/view?${imgUrlPart}${rand}`)
}) ?? []
app.nodeOutputs[nodeLocatorId] = outputs app.nodeOutputs[nodeLocatorId] = outputs
nodeOutputs.value[nodeLocatorId] = outputs nodeOutputs.value[nodeLocatorId] = outputs
} }
@@ -221,7 +214,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
scheduledRevoke[nodeLocatorId].stop() scheduledRevoke[nodeLocatorId].stop()
delete scheduledRevoke[nodeLocatorId] delete scheduledRevoke[nodeLocatorId]
} }
latestOutput.value = previewImages latestPreview.value = previewImages
app.nodePreviewImages[nodeLocatorId] = previewImages app.nodePreviewImages[nodeLocatorId] = previewImages
nodePreviewImages.value[nodeLocatorId] = previewImages nodePreviewImages.value[nodeLocatorId] = previewImages
} }
@@ -391,6 +384,6 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
// State // State
nodeOutputs, nodeOutputs,
nodePreviewImages, nodePreviewImages,
latestOutput latestPreview
} }
}) })

View File

@@ -1,16 +1,26 @@
import { whenever } from '@vueuse/core'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import type { MenuItem } from 'primevue/menuitem' import type { MenuItem } from 'primevue/menuitem'
import { ref } from 'vue' import { ref } from 'vue'
import { CORE_MENU_COMMANDS } from '@/constants/coreMenuCommands' import { CORE_MENU_COMMANDS } from '@/constants/coreMenuCommands'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import type { ComfyExtension } from '@/types/comfy' import type { ComfyExtension } from '@/types/comfy'
import { useCommandStore } from './commandStore' import { useCommandStore } from './commandStore'
export const useMenuItemStore = defineStore('menuItem', () => { export const useMenuItemStore = defineStore('menuItem', () => {
const canvasStore = useCanvasStore()
const commandStore = useCommandStore() const commandStore = useCommandStore()
const menuItems = ref<MenuItem[]>([]) const menuItems = ref<MenuItem[]>([])
const menuItemHasActiveStateChildren = ref<Record<string, boolean>>({}) const menuItemHasActiveStateChildren = ref<Record<string, boolean>>({})
const hasSeenLinear = ref(false)
whenever(
() => canvasStore.linearMode,
() => (hasSeenLinear.value = true),
{ immediate: true, once: true }
)
const registerMenuGroup = (path: string[], items: MenuItem[]) => { const registerMenuGroup = (path: string[], items: MenuItem[]) => {
let currentLevel = menuItems.value let currentLevel = menuItems.value
@@ -103,6 +113,7 @@ export const useMenuItemStore = defineStore('menuItem', () => {
registerCommands, registerCommands,
loadExtensionMenuCommands, loadExtensionMenuCommands,
registerCoreMenuCommands, registerCoreMenuCommands,
menuItemHasActiveStateChildren menuItemHasActiveStateChildren,
hasSeenLinear
} }
}) })

View File

@@ -299,9 +299,10 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
const nodeDefs = computed(() => { const nodeDefs = computed(() => {
const subgraphStore = useSubgraphStore() const subgraphStore = useSubgraphStore()
// Blueprints first for discoverability in the node library sidebar
return [ return [
...Object.values(nodeDefsByName.value), ...subgraphStore.subgraphBlueprints,
...subgraphStore.subgraphBlueprints ...Object.values(nodeDefsByName.value)
] ]
}) })
const nodeDataTypes = computed(() => { const nodeDataTypes = computed(() => {

View File

@@ -75,3 +75,17 @@ export const formatClockTime = (ts: number, locale: string): string => {
second: '2-digit' second: '2-digit'
}).format(d) }).format(d)
} }
export function formatDuration(durationSeconds?: number) {
if (durationSeconds == undefined) return ''
const hours = (durationSeconds / 60 ** 2) | 0
const minutes = ((durationSeconds % 60 ** 2) / 60) | 0
const seconds = (durationSeconds % 60) | 0
const parts = []
if (hours > 0) parts.push(`${hours}h`)
if (minutes > 0) parts.push(`${minutes}m`)
if (seconds > 0) parts.push(`${seconds}s`)
return parts.join(' ')
}

View File

@@ -215,7 +215,7 @@ const onStatus = async (e: CustomEvent<StatusWsMessageStatus>) => {
await queueStore.update() await queueStore.update()
// Only update assets if the assets sidebar is currently open // Only update assets if the assets sidebar is currently open
// When sidebar is closed, AssetsSidebarTab.vue will refresh on mount // When sidebar is closed, AssetsSidebarTab.vue will refresh on mount
if (sidebarTabStore.activeSidebarTabId === 'assets') { if (sidebarTabStore.activeSidebarTabId === 'assets' || linearMode.value) {
await assetsStore.updateHistory() await assetsStore.updateHistory()
} }
} }

View File

@@ -1,192 +1,187 @@
<script setup lang="ts"> <script setup lang="ts">
import { storeToRefs } from 'pinia' import {
breakpointsTailwind,
unrefElement,
useBreakpoints,
whenever
} from '@vueuse/core'
import Splitter from 'primevue/splitter' import Splitter from 'primevue/splitter'
import SplitterPanel from 'primevue/splitterpanel' import SplitterPanel from 'primevue/splitterpanel'
import { computed } from 'vue' import { ref, useTemplateRef } from 'vue'
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
import LoginButton from '@/components/topbar/LoginButton.vue'
import TopbarBadges from '@/components/topbar/TopbarBadges.vue' import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue' import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
import Button from '@/components/ui/button/Button.vue' import TypeformPopoverButton from '@/components/ui/TypeformPopoverButton.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import {
isValidWidgetValue,
safeWidgetMapper
} from '@/composables/graph/useGraphNodeManager'
import { useAssetsSidebarTab } from '@/composables/sidebarTabs/useAssetsSidebarTab'
import { t } from '@/i18n' import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useTelemetry } from '@/platform/telemetry' import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService' import LinearControls from '@/renderer/extensions/linearMode/LinearControls.vue'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import LinearPreview from '@/renderer/extensions/linearMode/LinearPreview.vue'
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue' import OutputHistory from '@/renderer/extensions/linearMode/OutputHistory.vue'
import WidgetInputNumberInput from '@/renderer/extensions/vueNodes/widgets/components/WidgetInputNumber.vue'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore' import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { useQueueSettingsStore } from '@/stores/queueStore' import type { ResultItemImpl } from '@/stores/queueStore'
import { isElectron } from '@/utils/envUtil'
const nodeOutputStore = useNodeOutputStore() const nodeOutputStore = useNodeOutputStore()
const commandStore = useCommandStore() const settingStore = useSettingStore()
const nodeDatas = computed(() => {
function nodeToNodeData(node: LGraphNode) {
const mapper = safeWidgetMapper(node, new Map())
const widgets =
node.widgets?.map((widget) => {
const safeWidget = mapper(widget)
safeWidget.callback = function (value) {
if (!isValidWidgetValue(value)) return
widget.value = value ?? undefined
return widget.callback?.(widget.value)
}
return safeWidget
}) ?? []
//Only widgets is actually used
return {
id: `${node.id}`,
title: node.title,
type: node.type,
mode: 0,
selected: false,
executing: false,
widgets
}
}
return app.rootGraph.nodes
.filter((node) => node.mode === 0 && node.widgets?.length)
.map(nodeToNodeData)
})
const { isLoggedIn } = useCurrentUser()
const isDesktop = isElectron()
const batchCountWidget = { const mobileDisplay = useBreakpoints(breakpointsTailwind).smaller('md')
options: { step2: 1, precision: 1, min: 1, max: 100 },
value: 1,
name: t('Number of generations'),
type: 'number'
}
const { batchCount } = storeToRefs(useQueueSettingsStore()) const hasPreview = ref(false)
whenever(
() => nodeOutputStore.latestPreview[0],
() => (hasPreview.value = true)
)
//TODO: refactor out of this file. const selectedItem = ref<AssetItem>()
//code length is small, but changes should propagate const selectedOutput = ref<ResultItemImpl>()
async function runButtonClick(e: Event) { const canShowPreview = ref(true)
const isShiftPressed = 'shiftKey' in e && e.shiftKey const outputHistoryRef = useTemplateRef('outputHistoryRef')
const commandId = isShiftPressed
? 'Comfy.QueuePromptFront'
: 'Comfy.QueuePrompt'
useTelemetry()?.trackUiButtonClicked({ const topLeftRef = useTemplateRef('topLeftRef')
button_id: 'queue_run_linear' const topRightRef = useTemplateRef('topRightRef')
}) const bottomLeftRef = useTemplateRef('bottomLeftRef')
if (batchCount.value > 1) { const bottomRightRef = useTemplateRef('bottomRightRef')
useTelemetry()?.trackUiButtonClicked({ const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
button_id: 'queue_run_multiple_batches_submitted'
})
}
await commandStore.execute(commandId, {
metadata: {
subscribe_to_run: false,
trigger_source: 'button'
}
})
}
function openFeedback() {
//TODO: Does not link to a linear specific feedback section
window.open(
'https://support.comfy.org/hc/en-us/requests/new?ticket_form_id=40026345549204',
'_blank',
'noopener,noreferrer'
)
}
</script> </script>
<template> <template>
<div class="absolute w-full h-full"> <div
class="absolute w-full h-full"
@wheel.capture="(e: WheelEvent) => outputHistoryRef?.onWheel(e)"
>
<div class="workflow-tabs-container pointer-events-auto h-9.5 w-full"> <div class="workflow-tabs-container pointer-events-auto h-9.5 w-full">
<div class="flex h-full items-center"> <div class="flex h-full items-center">
<WorkflowTabs /> <WorkflowTabs />
<TopbarBadges /> <TopbarBadges />
</div> </div>
</div> </div>
<div
v-if="mobileDisplay"
class="justify-center border-border-subtle border-t overflow-y-scroll h-[calc(100%-38px)] bg-comfy-menu-bg"
>
<div class="flex flex-col text-muted-foreground">
<LinearPreview
:latent-preview="
canShowPreview && hasPreview
? nodeOutputStore.latestPreview[0]
: undefined
"
:run-button-click="linearWorkflowRef?.runButtonClick"
:selected-item
:selected-output
mobile
/>
</div>
<OutputHistory
ref="outputHistoryRef"
mobile
@update-selection="
([item, output, canShow]) => {
selectedItem = item
selectedOutput = output
canShowPreview = canShow
hasPreview = false
}
"
/>
<LinearControls ref="linearWorkflowRef" mobile />
<div class="text-base-foreground flex items-center gap-4 justify-end m-4">
<a
href="https://form.typeform.com/to/gmVqFi8l"
v-text="t('linearMode.beta')"
/>
<TypeformPopoverButton data-tf-widget="gmVqFi8l" />
</div>
</div>
<Splitter <Splitter
v-else
class="h-[calc(100%-38px)] w-full bg-comfy-menu-secondary-bg" class="h-[calc(100%-38px)] w-full bg-comfy-menu-secondary-bg"
:pt="{ gutter: { class: 'bg-transparent w-4 -mx-3' } }" :pt="{ gutter: { class: 'bg-transparent w-4 -mx-3' } }"
@resizestart="({ originalEvent }) => originalEvent.preventDefault()"
> >
<SplitterPanel :size="1" class="min-w-min bg-comfy-menu-bg"> <SplitterPanel
id="linearLeftPanel"
:size="1"
class="min-w-min outline-none"
>
<OutputHistory
v-if="settingStore.get('Comfy.Sidebar.Location') === 'left'"
ref="outputHistoryRef"
:scroll-reset-button-to="unrefElement(bottomLeftRef) ?? undefined"
@update-selection="
([item, output, canShow]) => {
selectedItem = item
selectedOutput = output
canShowPreview = canShow
hasPreview = false
}
"
/>
<LinearControls
v-else
ref="linearWorkflowRef"
:toast-to="unrefElement(bottomLeftRef) ?? undefined"
:notes-to="unrefElement(topLeftRef) ?? undefined"
/>
<div />
</SplitterPanel>
<SplitterPanel
id="linearCenterPanel"
:size="98"
class="flex flex-col min-w-min gap-4 mx-2 px-10 pt-8 pb-4 relative text-muted-foreground outline-none"
>
<LinearPreview
:latent-preview="
canShowPreview && hasPreview
? nodeOutputStore.latestPreview[0]
: undefined
"
:run-button-click="linearWorkflowRef?.runButtonClick"
:selected-item
:selected-output
/>
<div ref="topLeftRef" class="absolute z-21 top-4 left-4" />
<div ref="topRightRef" class="absolute z-21 top-4 right-4" />
<div ref="bottomLeftRef" class="absolute z-20 bottom-4 left-4" />
<div ref="bottomRightRef" class="absolute z-20 bottom-24 right-4" />
<div <div
class="sidebar-content-container h-full w-full overflow-x-hidden overflow-y-auto border-r-1 border-node-component-border" class="absolute z-20 bottom-4 right-4 text-base-foreground flex items-center gap-4"
> >
<ExtensionSlot :extension="useAssetsSidebarTab()" /> <div v-text="t('linearMode.beta')" />
<TypeformPopoverButton
data-tf-widget="gmVqFi8l"
:align="
settingStore.get('Comfy.Sidebar.Location') === 'left'
? 'end'
: 'start'
"
/>
</div> </div>
</SplitterPanel> </SplitterPanel>
<SplitterPanel <SplitterPanel
:size="98" id="linearRightPanel"
class="flex flex-row overflow-y-auto flex-wrap min-w-min gap-4 m-4" :size="1"
class="min-w-min outline-none"
> >
<img <LinearControls
v-for="previewUrl in nodeOutputStore.latestOutput" v-if="settingStore.get('Comfy.Sidebar.Location') === 'left'"
:key="previewUrl" ref="linearWorkflowRef"
class="pointer-events-none object-contain flex-1 max-h-full" :toast-to="unrefElement(bottomRightRef) ?? undefined"
:src="previewUrl" :notes-to="unrefElement(topRightRef) ?? undefined"
/> />
<img <OutputHistory
v-if="nodeOutputStore.latestOutput.length === 0" v-else
class="pointer-events-none object-contain flex-1 max-h-full brightness-50 opacity-10" ref="outputHistoryRef"
src="/assets/images/comfy-logo-mono.svg" :scroll-reset-button-to="unrefElement(bottomRightRef) ?? undefined"
@update-selection="
([item, output, canShow]) => {
selectedItem = item
selectedOutput = output
canShowPreview = canShow
hasPreview = false
}
"
/> />
</SplitterPanel> <div />
<SplitterPanel :size="1" class="flex flex-col gap-1 p-1 min-w-min">
<div
class="actionbar-container flex h-12 items-center rounded-lg border border-[var(--interface-stroke)] p-2 gap-2 bg-comfy-menu-bg justify-end"
>
<Button variant="secondary" @click="openFeedback">
{{ t('g.feedback') }}
</Button>
<Button
variant="secondary"
class="min-w-max"
@click="useCanvasStore().linearMode = false"
>
{{ t('linearMode.openWorkflow') }}
<i class="icon-[comfy--workflow]" />
</Button>
<Button
variant="inverted"
@click="useWorkflowService().exportWorkflow('workflow', 'workflow')"
>
{{ t('linearMode.share') }}
</Button>
<CurrentUserButton v-if="isLoggedIn" />
<LoginButton v-else-if="isDesktop" />
</div>
<div
class="rounded-lg border p-2 gap-2 h-full border-[var(--interface-stroke)] bg-comfy-menu-bg flex flex-col"
>
<div
class="grow-1 flex justify-start flex-col overflow-y-auto contain-size *:max-h-100"
>
<NodeWidgets
v-for="nodeData of nodeDatas"
:key="nodeData.id"
:node-data
class="border-b-1 border-node-component-border pt-1 pb-2 last:border-none"
/>
</div>
<div class="p-4 pb-0 border-t border-node-component-border">
<WidgetInputNumberInput
v-model="batchCount"
:widget="batchCountWidget"
class="*:[.min-w-56]:basis-0"
/>
<Button class="w-full mt-4" @click="runButtonClick">
<i class="icon-[lucide--play]" />
{{ t('menu.run') }}
</Button>
</div>
</div>
</SplitterPanel> </SplitterPanel>
</Splitter> </Splitter>
</div> </div>

View File

@@ -446,6 +446,9 @@ export default defineConfig({
if (id.includes('/vue') || id.includes('pinia')) { if (id.includes('/vue') || id.includes('pinia')) {
return 'vendor-vue' return 'vendor-vue'
} }
if (id.includes('reka-ui')) {
return 'vendor-reka-ui'
}
return 'vendor-other' return 'vendor-other'
} }