Merge remote-tracking branch 'origin/main' into drjkl/desloppify
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 100 KiB |
@@ -819,16 +819,13 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
|
||||
await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()
|
||||
const activeWorkflowName =
|
||||
await comfyPage.menu.workflowsTab.getActiveWorkflowName()
|
||||
const workflowPathA = `${workflowA}.json`
|
||||
const workflowPathB = `${workflowB}.json`
|
||||
|
||||
expect(openWorkflows).toEqual(
|
||||
expect.arrayContaining([workflowPathA, workflowPathB])
|
||||
expect.arrayContaining([workflowA, workflowB])
|
||||
)
|
||||
expect(openWorkflows.indexOf(workflowPathA)).toBeLessThan(
|
||||
openWorkflows.indexOf(workflowPathB)
|
||||
expect(openWorkflows.indexOf(workflowA)).toBeLessThan(
|
||||
openWorkflows.indexOf(workflowB)
|
||||
)
|
||||
expect(activeWorkflowName).toEqual(workflowPathB)
|
||||
expect(activeWorkflowName).toEqual(workflowB)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -35,18 +35,21 @@ test.describe(
|
||||
test(`Load workflow in ${fileName} (drop from filesystem)`, async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const waitForUpload = filesWithUpload.has(fileName)
|
||||
await comfyPage.dragDrop.dragAndDropFile(
|
||||
`workflowInMedia/${fileName}`,
|
||||
{ waitForUpload }
|
||||
)
|
||||
if (waitForUpload) {
|
||||
await comfyPage.page.waitForResponse(
|
||||
(resp) => resp.url().includes('/view') && resp.status() !== 0,
|
||||
{ timeout: 10000 }
|
||||
)
|
||||
const shouldUpload = filesWithUpload.has(fileName)
|
||||
const uploadRequestPromise = shouldUpload
|
||||
? comfyPage.page.waitForRequest((req) =>
|
||||
req.url().includes('/upload/')
|
||||
)
|
||||
: null
|
||||
|
||||
await comfyPage.dragDrop.dragAndDropFile(`workflowInMedia/${fileName}`)
|
||||
|
||||
if (uploadRequestPromise) {
|
||||
const request = await uploadRequestPromise
|
||||
expect(request.url()).toContain('/upload/')
|
||||
} else {
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(`${fileName}.png`)
|
||||
}
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(`${fileName}.png`)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@ test.describe('Reroute Node', { tag: ['@screenshot', '@node'] }, () => {
|
||||
})
|
||||
|
||||
test('loads from inserted workflow', async ({ comfyPage }) => {
|
||||
const workflowName = 'single_connected_reroute_node.json'
|
||||
const workflowName = 'single_connected_reroute_node'
|
||||
await comfyPage.workflow.setupWorkflowsDirectory({
|
||||
[workflowName]: 'links/single_connected_reroute_node.json'
|
||||
[`${workflowName}.json`]: `links/${workflowName}.json`
|
||||
})
|
||||
await comfyPage.setup()
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
|
||||
|
||||
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 91 KiB |
@@ -21,14 +21,12 @@ test.describe('Workflows sidebar', () => {
|
||||
|
||||
test('Can create new blank workflow', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
expect(await tab.getOpenedWorkflowNames()).toEqual([
|
||||
'*Unsaved Workflow.json'
|
||||
])
|
||||
expect(await tab.getOpenedWorkflowNames()).toEqual(['*Unsaved Workflow'])
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
|
||||
expect(await tab.getOpenedWorkflowNames()).toEqual([
|
||||
'*Unsaved Workflow.json',
|
||||
'*Unsaved Workflow (2).json'
|
||||
'*Unsaved Workflow',
|
||||
'*Unsaved Workflow (2)'
|
||||
])
|
||||
})
|
||||
|
||||
@@ -41,37 +39,37 @@ test.describe('Workflows sidebar', () => {
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
await tab.open()
|
||||
expect(await tab.getTopLevelSavedWorkflowNames()).toEqual(
|
||||
expect.arrayContaining(['workflow1.json', 'workflow2.json'])
|
||||
expect.arrayContaining(['workflow1', 'workflow2'])
|
||||
)
|
||||
})
|
||||
|
||||
test('Can duplicate workflow', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
await comfyPage.menu.topbar.saveWorkflow('workflow1.json')
|
||||
await comfyPage.menu.topbar.saveWorkflow('workflow1')
|
||||
|
||||
expect(await tab.getTopLevelSavedWorkflowNames()).toEqual(
|
||||
expect.arrayContaining(['workflow1.json'])
|
||||
expect.arrayContaining(['workflow1'])
|
||||
)
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.DuplicateWorkflow')
|
||||
expect(await tab.getOpenedWorkflowNames()).toEqual([
|
||||
'workflow1.json',
|
||||
'*workflow1 (Copy).json'
|
||||
'workflow1',
|
||||
'*workflow1 (Copy)'
|
||||
])
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.DuplicateWorkflow')
|
||||
expect(await tab.getOpenedWorkflowNames()).toEqual([
|
||||
'workflow1.json',
|
||||
'*workflow1 (Copy).json',
|
||||
'*workflow1 (Copy) (2).json'
|
||||
'workflow1',
|
||||
'*workflow1 (Copy)',
|
||||
'*workflow1 (Copy) (2)'
|
||||
])
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.DuplicateWorkflow')
|
||||
expect(await tab.getOpenedWorkflowNames()).toEqual([
|
||||
'workflow1.json',
|
||||
'*workflow1 (Copy).json',
|
||||
'*workflow1 (Copy) (2).json',
|
||||
'*workflow1 (Copy) (3).json'
|
||||
'workflow1',
|
||||
'*workflow1 (Copy)',
|
||||
'*workflow1 (Copy) (2)',
|
||||
'*workflow1 (Copy) (3)'
|
||||
])
|
||||
})
|
||||
|
||||
@@ -85,12 +83,12 @@ test.describe('Workflows sidebar', () => {
|
||||
await comfyPage.command.executeCommand('Comfy.LoadDefaultWorkflow')
|
||||
const originalNodeCount = await comfyPage.nodeOps.getNodeCount()
|
||||
|
||||
await tab.insertWorkflow(tab.getPersistedItem('workflow1.json'))
|
||||
await tab.insertWorkflow(tab.getPersistedItem('workflow1'))
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount())
|
||||
.toEqual(originalNodeCount + 1)
|
||||
|
||||
await tab.getPersistedItem('workflow1.json').click()
|
||||
await tab.getPersistedItem('workflow1').click()
|
||||
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toEqual(1)
|
||||
})
|
||||
|
||||
@@ -113,22 +111,22 @@ test.describe('Workflows sidebar', () => {
|
||||
const openedWorkflow = tab.getOpenedItem('foo/bar')
|
||||
await tab.renameWorkflow(openedWorkflow, 'foo/baz')
|
||||
expect(await tab.getOpenedWorkflowNames()).toEqual([
|
||||
'*Unsaved Workflow.json',
|
||||
'foo/baz.json'
|
||||
'*Unsaved Workflow',
|
||||
'foo/baz'
|
||||
])
|
||||
})
|
||||
|
||||
test('Can save workflow as', async ({ comfyPage }) => {
|
||||
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
|
||||
await comfyPage.menu.topbar.saveWorkflowAs('workflow3.json')
|
||||
await comfyPage.menu.topbar.saveWorkflowAs('workflow3')
|
||||
await expect
|
||||
.poll(() => comfyPage.menu.workflowsTab.getOpenedWorkflowNames())
|
||||
.toEqual(['*Unsaved Workflow.json', 'workflow3.json'])
|
||||
.toEqual(['*Unsaved Workflow', 'workflow3'])
|
||||
|
||||
await comfyPage.menu.topbar.saveWorkflowAs('workflow4.json')
|
||||
await comfyPage.menu.topbar.saveWorkflowAs('workflow4')
|
||||
await expect
|
||||
.poll(() => comfyPage.menu.workflowsTab.getOpenedWorkflowNames())
|
||||
.toEqual(['*Unsaved Workflow.json', 'workflow3.json', 'workflow4.json'])
|
||||
.toEqual(['*Unsaved Workflow', 'workflow3', 'workflow4'])
|
||||
})
|
||||
|
||||
test('Exported workflow does not contain localized slot names', async ({
|
||||
@@ -184,15 +182,15 @@ test.describe('Workflows sidebar', () => {
|
||||
})
|
||||
|
||||
test('Can save workflow as with same name', async ({ comfyPage }) => {
|
||||
await comfyPage.menu.topbar.saveWorkflow('workflow5.json')
|
||||
await comfyPage.menu.topbar.saveWorkflow('workflow5')
|
||||
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
|
||||
'workflow5.json'
|
||||
'workflow5'
|
||||
])
|
||||
|
||||
await comfyPage.menu.topbar.saveWorkflowAs('workflow5.json')
|
||||
await comfyPage.menu.topbar.saveWorkflowAs('workflow5')
|
||||
await comfyPage.confirmDialog.click('overwrite')
|
||||
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
|
||||
'workflow5.json'
|
||||
'workflow5'
|
||||
])
|
||||
})
|
||||
|
||||
@@ -212,25 +210,25 @@ test.describe('Workflows sidebar', () => {
|
||||
|
||||
test('Can overwrite other workflows with save as', async ({ comfyPage }) => {
|
||||
const topbar = comfyPage.menu.topbar
|
||||
await topbar.saveWorkflow('workflow1.json')
|
||||
await topbar.saveWorkflowAs('workflow2.json')
|
||||
await topbar.saveWorkflow('workflow1')
|
||||
await topbar.saveWorkflowAs('workflow2')
|
||||
await comfyPage.nextFrame()
|
||||
await expect
|
||||
.poll(() => comfyPage.menu.workflowsTab.getOpenedWorkflowNames())
|
||||
.toEqual(['workflow1.json', 'workflow2.json'])
|
||||
.toEqual(['workflow1', 'workflow2'])
|
||||
await expect
|
||||
.poll(() => comfyPage.menu.workflowsTab.getActiveWorkflowName())
|
||||
.toEqual('workflow2.json')
|
||||
.toEqual('workflow2')
|
||||
|
||||
await topbar.saveWorkflowAs('workflow1.json')
|
||||
await topbar.saveWorkflowAs('workflow1')
|
||||
await comfyPage.confirmDialog.click('overwrite')
|
||||
// The old workflow1.json should be deleted and the new one should be saved.
|
||||
// The old workflow1 should be deleted and the new one should be saved.
|
||||
await expect
|
||||
.poll(() => comfyPage.menu.workflowsTab.getOpenedWorkflowNames())
|
||||
.toEqual(['workflow2.json', 'workflow1.json'])
|
||||
.toEqual(['workflow2', 'workflow1'])
|
||||
await expect
|
||||
.poll(() => comfyPage.menu.workflowsTab.getActiveWorkflowName())
|
||||
.toEqual('workflow1.json')
|
||||
.toEqual('workflow1')
|
||||
})
|
||||
|
||||
test('Does not report warning when switching between opened workflows', async ({
|
||||
@@ -266,17 +264,15 @@ test.describe('Workflows sidebar', () => {
|
||||
)
|
||||
await closeButton.click()
|
||||
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
|
||||
'*Unsaved Workflow.json'
|
||||
'*Unsaved Workflow'
|
||||
])
|
||||
})
|
||||
|
||||
test('Can close saved workflow with command', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
await comfyPage.menu.topbar.saveWorkflow('workflow1.json')
|
||||
await comfyPage.menu.topbar.saveWorkflow('workflow1')
|
||||
await comfyPage.command.executeCommand('Workspace.CloseWorkflow')
|
||||
expect(await tab.getOpenedWorkflowNames()).toEqual([
|
||||
'*Unsaved Workflow.json'
|
||||
])
|
||||
expect(await tab.getOpenedWorkflowNames()).toEqual(['*Unsaved Workflow'])
|
||||
})
|
||||
|
||||
test('Can delete workflows (confirm disabled)', async ({ comfyPage }) => {
|
||||
@@ -284,7 +280,7 @@ test.describe('Workflows sidebar', () => {
|
||||
|
||||
const { topbar, workflowsTab } = comfyPage.menu
|
||||
|
||||
const filename = 'workflow18.json'
|
||||
const filename = 'workflow18'
|
||||
await topbar.saveWorkflow(filename)
|
||||
expect(await workflowsTab.getOpenedWorkflowNames()).toEqual([filename])
|
||||
|
||||
@@ -295,14 +291,14 @@ test.describe('Workflows sidebar', () => {
|
||||
|
||||
await expect(workflowsTab.getOpenedItem(filename)).not.toBeVisible()
|
||||
expect(await workflowsTab.getOpenedWorkflowNames()).toEqual([
|
||||
'*Unsaved Workflow.json'
|
||||
'*Unsaved Workflow'
|
||||
])
|
||||
})
|
||||
|
||||
test('Can delete workflows', async ({ comfyPage }) => {
|
||||
const { topbar, workflowsTab } = comfyPage.menu
|
||||
|
||||
const filename = 'workflow18.json'
|
||||
const filename = 'workflow18'
|
||||
await topbar.saveWorkflow(filename)
|
||||
expect(await workflowsTab.getOpenedWorkflowNames()).toEqual([filename])
|
||||
|
||||
@@ -314,7 +310,7 @@ test.describe('Workflows sidebar', () => {
|
||||
|
||||
await expect(workflowsTab.getOpenedItem(filename)).not.toBeVisible()
|
||||
expect(await workflowsTab.getOpenedWorkflowNames()).toEqual([
|
||||
'*Unsaved Workflow.json'
|
||||
'*Unsaved Workflow'
|
||||
])
|
||||
})
|
||||
|
||||
@@ -326,13 +322,11 @@ test.describe('Workflows sidebar', () => {
|
||||
const { workflowsTab } = comfyPage.menu
|
||||
await workflowsTab.open()
|
||||
|
||||
await workflowsTab
|
||||
.getPersistedItem('workflow1.json')
|
||||
.click({ button: 'right' })
|
||||
await workflowsTab.getPersistedItem('workflow1').click({ button: 'right' })
|
||||
await comfyPage.contextMenu.clickMenuItem('Duplicate')
|
||||
await expect
|
||||
.poll(() => workflowsTab.getOpenedWorkflowNames())
|
||||
.toEqual(['*Unsaved Workflow.json', '*workflow1 (Copy).json'])
|
||||
.toEqual(['*Unsaved Workflow', '*workflow1 (Copy)'])
|
||||
})
|
||||
|
||||
test('Can drop workflow from workflows sidebar', async ({ comfyPage }) => {
|
||||
@@ -344,7 +338,7 @@ test.describe('Workflows sidebar', () => {
|
||||
|
||||
// Wait for workflow to appear in Browse section after sync
|
||||
const workflowItem =
|
||||
comfyPage.menu.workflowsTab.getPersistedItem('workflow1.json')
|
||||
comfyPage.menu.workflowsTab.getPersistedItem('workflow1')
|
||||
await expect(workflowItem).toBeVisible({ timeout: 3000 })
|
||||
|
||||
const nodeCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
@@ -361,7 +355,7 @@ test.describe('Workflows sidebar', () => {
|
||||
}
|
||||
|
||||
await comfyPage.page.dragAndDrop(
|
||||
'.comfyui-workflows-browse .node-label:has-text("workflow1.json")',
|
||||
'.comfyui-workflows-browse .node-label:has-text("workflow1")',
|
||||
'#graph-canvas',
|
||||
{ targetPosition }
|
||||
)
|
||||
|
||||
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 108 KiB |
14
src/App.vue
@@ -1,13 +1,13 @@
|
||||
<template>
|
||||
<router-view />
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="absolute inset-0 flex items-center justify-center"
|
||||
>
|
||||
<Loader size="lg" class="text-white" />
|
||||
</div>
|
||||
<GlobalDialog />
|
||||
<BlockUI full-screen :blocked="isLoading" />
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="pointer-events-none fixed inset-0 z-1200 flex items-center justify-center"
|
||||
>
|
||||
<LogoComfyWaveLoader size="xl" color="yellow" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -15,7 +15,7 @@ import { captureException } from '@sentry/vue'
|
||||
import BlockUI from 'primevue/blockui'
|
||||
import { computed, onMounted } from 'vue'
|
||||
|
||||
import Loader from '@/components/common/Loader.vue'
|
||||
import LogoComfyWaveLoader from '@/components/loader/LogoComfyWaveLoader.vue'
|
||||
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
|
||||
import config from '@/config'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
|
||||
@@ -3,9 +3,16 @@ import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import WorkflowActionsDropdown from '@/components/common/WorkflowActionsDropdown.vue'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import {
|
||||
openShareDialog,
|
||||
prefetchShareDialog
|
||||
} from '@/platform/workflow/sharing/composables/lazyShareDialog'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
@@ -18,6 +25,8 @@ const workspaceStore = useWorkspaceStore()
|
||||
const { enableAppBuilder } = useAppMode()
|
||||
const appModeStore = useAppModeStore()
|
||||
const { enterBuilder } = appModeStore
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
const { flags } = useFeatureFlags()
|
||||
const { hasNodes } = storeToRefs(appModeStore)
|
||||
const tooltipOptions = { showDelay: 300, hideDelay: 300 }
|
||||
|
||||
@@ -43,28 +52,7 @@ function openTemplates() {
|
||||
|
||||
<template>
|
||||
<div class="pointer-events-auto flex flex-col gap-2">
|
||||
<WorkflowActionsDropdown source="app_mode_toolbar">
|
||||
<template #button="{ hasUnseenItems }">
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: t('sideToolbar.labels.menu'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="secondary"
|
||||
size="unset"
|
||||
:aria-label="t('sideToolbar.labels.menu')"
|
||||
class="relative h-10 gap-1 rounded-lg pr-2 pl-3 data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
|
||||
>
|
||||
<i class="icon-[lucide--panels-top-left] size-4" />
|
||||
<i class="icon-[lucide--chevron-down] size-4 text-muted-foreground" />
|
||||
<span
|
||||
v-if="hasUnseenItems"
|
||||
aria-hidden="true"
|
||||
class="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-primary-background"
|
||||
/>
|
||||
</Button>
|
||||
</template>
|
||||
</WorkflowActionsDropdown>
|
||||
<WorkflowActionsDropdown source="app_mode_toolbar" />
|
||||
|
||||
<Button
|
||||
v-if="enableAppBuilder"
|
||||
@@ -81,6 +69,21 @@ function openTemplates() {
|
||||
>
|
||||
<i class="icon-[lucide--hammer] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isCloud && flags.workflowSharingEnabled"
|
||||
v-tooltip.right="{
|
||||
value: t('actionbar.shareTooltip'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="secondary"
|
||||
size="unset"
|
||||
:aria-label="t('actionbar.shareTooltip')"
|
||||
class="size-10 rounded-lg"
|
||||
@click="() => openShareDialog().catch(toastErrorHandler)"
|
||||
@pointerenter="prefetchShareDialog"
|
||||
>
|
||||
<i class="icon-[lucide--send] size-4" />
|
||||
</Button>
|
||||
|
||||
<div
|
||||
class="flex w-10 flex-col overflow-hidden rounded-lg bg-secondary-background"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
data-testid="subgraph-breadcrumb"
|
||||
class="subgraph-breadcrumb -mt-4 flex w-auto items-center pt-4 drop-shadow-(--interface-panel-drop-shadow)"
|
||||
class="subgraph-breadcrumb -mt-3 flex w-auto items-center pt-4 pl-1 drop-shadow-(--interface-panel-drop-shadow)"
|
||||
:class="{
|
||||
'subgraph-breadcrumb-collapse': collapseTabs,
|
||||
'subgraph-breadcrumb-overflow': overflowingTabs
|
||||
|
||||
@@ -12,7 +12,10 @@ import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import {
|
||||
LGraphEventMode,
|
||||
TitleMode
|
||||
} from '@/lib/litegraph/src/types/globalEnums'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
@@ -159,7 +162,8 @@ function handleDown(e: MouseEvent) {
|
||||
}
|
||||
function handleClick(e: MouseEvent) {
|
||||
const [node, widget] = getHovered(e) ?? []
|
||||
if (!node) return canvasInteractions.forwardEventToCanvas(e)
|
||||
if (node?.mode !== LGraphEventMode.ALWAYS)
|
||||
return canvasInteractions.forwardEventToCanvas(e)
|
||||
|
||||
if (!widget) {
|
||||
if (!isSelectOutputsMode.value) return
|
||||
@@ -192,7 +196,10 @@ function nodeToDisplayTuple(
|
||||
const renderedOutputs = computed(() => {
|
||||
void appModeStore.selectedOutputs.length
|
||||
return canvas
|
||||
.graph!.nodes.filter((n) => n.constructor.nodeData?.output_node)
|
||||
.graph!.nodes.filter(
|
||||
(n) =>
|
||||
n.constructor.nodeData?.output_node && n.mode === LGraphEventMode.ALWAYS
|
||||
)
|
||||
.map(nodeToDisplayTuple)
|
||||
})
|
||||
const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
@@ -204,131 +211,146 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex items-center border-b border-border-subtle p-2 font-bold">
|
||||
{{
|
||||
isArrangeMode ? t('nodeHelpPage.inputs') : t('linearMode.builder.title')
|
||||
}}
|
||||
</div>
|
||||
<DraggableList
|
||||
v-if="isArrangeMode"
|
||||
v-slot="{ dragClass }"
|
||||
v-model="appModeStore.selectedInputs"
|
||||
>
|
||||
<div
|
||||
v-for="{ nodeId, widgetName, node, widget } in arrangeInputs"
|
||||
:key="`${nodeId}: ${widgetName}`"
|
||||
:class="cn(dragClass, 'pointer-events-auto my-2 p-2')"
|
||||
:aria-label="`${widget?.label ?? widgetName} — ${node.title}`"
|
||||
>
|
||||
<div v-if="widget" class="pointer-events-none" inert>
|
||||
<WidgetItem
|
||||
:widget="widget"
|
||||
:node="node"
|
||||
show-node-name
|
||||
hidden-widget-actions
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="pointer-events-none p-1 text-sm text-muted-foreground">
|
||||
{{ widgetName }}
|
||||
<p class="text-xs italic">
|
||||
({{ t('linearMode.builder.unknownWidget') }})
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="flex items-center border-b border-border-subtle p-2 font-bold">
|
||||
{{
|
||||
isArrangeMode ? t('nodeHelpPage.inputs') : t('linearMode.builder.title')
|
||||
}}
|
||||
</div>
|
||||
</DraggableList>
|
||||
<PropertiesAccordionItem
|
||||
v-if="isSelectInputsMode"
|
||||
:label="t('nodeHelpPage.inputs')"
|
||||
enable-empty-state
|
||||
:disabled="!appModeStore.selectedInputs.length"
|
||||
class="border-b border-border-subtle"
|
||||
:tooltip="`${t('linearMode.builder.inputsDesc')}\n${t('linearMode.builder.inputsExample')}`"
|
||||
:tooltip-delay="100"
|
||||
>
|
||||
<template #label>
|
||||
<div class="flex gap-3">
|
||||
{{ t('nodeHelpPage.inputs') }}
|
||||
<i class="icon-[lucide--circle-alert] bg-muted-foreground" />
|
||||
</div>
|
||||
</template>
|
||||
<template #empty>
|
||||
<div
|
||||
class="w-full p-4 pt-2 text-muted-foreground"
|
||||
v-text="t('linearMode.builder.promptAddInputs')"
|
||||
/>
|
||||
</template>
|
||||
<div
|
||||
class="w-full p-4 pt-2 text-muted-foreground"
|
||||
v-text="t('linearMode.builder.promptAddInputs')"
|
||||
/>
|
||||
<DraggableList v-slot="{ dragClass }" v-model="appModeStore.selectedInputs">
|
||||
<IoItem
|
||||
v-for="{
|
||||
nodeId,
|
||||
widgetName,
|
||||
label,
|
||||
subLabel,
|
||||
rename
|
||||
} in inputsWithState"
|
||||
:key="`${nodeId}: ${widgetName}`"
|
||||
:class="cn(dragClass, 'my-2 rounded-lg bg-primary-background/30 p-2')"
|
||||
:title="label ?? widgetName"
|
||||
:sub-title="subLabel"
|
||||
:rename
|
||||
:remove="
|
||||
() =>
|
||||
remove(
|
||||
appModeStore.selectedInputs,
|
||||
([id, name]) => nodeId == id && widgetName === name
|
||||
)
|
||||
"
|
||||
/>
|
||||
</DraggableList>
|
||||
</PropertiesAccordionItem>
|
||||
<PropertiesAccordionItem
|
||||
v-if="isSelectOutputsMode"
|
||||
:label="t('nodeHelpPage.outputs')"
|
||||
enable-empty-state
|
||||
:disabled="!appModeStore.selectedOutputs.length"
|
||||
:tooltip="`${t('linearMode.builder.outputsDesc')}\n${t('linearMode.builder.outputsExample')}`"
|
||||
:tooltip-delay="100"
|
||||
>
|
||||
<template #label>
|
||||
<div class="flex gap-3">
|
||||
{{ t('nodeHelpPage.outputs') }}
|
||||
<i class="icon-[lucide--circle-alert] bg-muted-foreground" />
|
||||
</div>
|
||||
</template>
|
||||
<template #empty>
|
||||
<div
|
||||
class="w-full p-4 pt-2 text-muted-foreground"
|
||||
v-text="t('linearMode.builder.promptAddOutputs')"
|
||||
/>
|
||||
</template>
|
||||
<div
|
||||
class="w-full p-4 pt-2 text-muted-foreground"
|
||||
v-text="t('linearMode.builder.promptAddOutputs')"
|
||||
/>
|
||||
<DraggableList
|
||||
v-slot="{ dragClass }"
|
||||
v-model="appModeStore.selectedOutputs"
|
||||
>
|
||||
<IoItem
|
||||
v-for="([key, title], index) in outputsWithState"
|
||||
:key
|
||||
:class="
|
||||
cn(
|
||||
dragClass,
|
||||
'my-2 rounded-lg bg-warning-background/40 p-2',
|
||||
index === 0 && 'ring-2 ring-warning-background'
|
||||
)
|
||||
"
|
||||
:title
|
||||
:sub-title="String(key)"
|
||||
:remove="() => remove(appModeStore.selectedOutputs, (k) => k == key)"
|
||||
/>
|
||||
</DraggableList>
|
||||
</PropertiesAccordionItem>
|
||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||
<DraggableList
|
||||
v-if="isArrangeMode"
|
||||
v-slot="{ dragClass }"
|
||||
v-model="appModeStore.selectedInputs"
|
||||
class="overflow-x-clip"
|
||||
>
|
||||
<div
|
||||
v-for="{ nodeId, widgetName, node, widget } in arrangeInputs"
|
||||
:key="`${nodeId}: ${widgetName}`"
|
||||
:class="cn(dragClass, 'pointer-events-auto my-2 p-2')"
|
||||
:aria-label="`${widget?.label ?? widgetName} — ${node.title}`"
|
||||
>
|
||||
<div v-if="widget" class="pointer-events-none" inert>
|
||||
<WidgetItem
|
||||
:widget="widget"
|
||||
:node="node"
|
||||
show-node-name
|
||||
hidden-widget-actions
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="pointer-events-none p-1 text-sm text-muted-foreground"
|
||||
>
|
||||
{{ widgetName }}
|
||||
<p class="text-xs italic">
|
||||
({{ t('linearMode.builder.unknownWidget') }})
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DraggableList>
|
||||
<PropertiesAccordionItem
|
||||
v-if="isSelectInputsMode"
|
||||
:label="t('nodeHelpPage.inputs')"
|
||||
enable-empty-state
|
||||
:disabled="!appModeStore.selectedInputs.length"
|
||||
class="border-b border-border-subtle"
|
||||
:tooltip="`${t('linearMode.builder.inputsDesc')}\n${t('linearMode.builder.inputsExample')}`"
|
||||
:tooltip-delay="100"
|
||||
>
|
||||
<template #label>
|
||||
<div class="flex gap-3">
|
||||
{{ t('nodeHelpPage.inputs') }}
|
||||
<i class="icon-[lucide--circle-alert] bg-muted-foreground" />
|
||||
</div>
|
||||
</template>
|
||||
<template #empty>
|
||||
<div
|
||||
class="w-full p-4 pt-2 text-muted-foreground"
|
||||
v-text="t('linearMode.builder.promptAddInputs')"
|
||||
/>
|
||||
</template>
|
||||
<div
|
||||
class="w-full p-4 pt-2 text-muted-foreground"
|
||||
v-text="t('linearMode.builder.promptAddInputs')"
|
||||
/>
|
||||
<DraggableList
|
||||
v-slot="{ dragClass }"
|
||||
v-model="appModeStore.selectedInputs"
|
||||
>
|
||||
<IoItem
|
||||
v-for="{
|
||||
nodeId,
|
||||
widgetName,
|
||||
label,
|
||||
subLabel,
|
||||
rename
|
||||
} in inputsWithState"
|
||||
:key="`${nodeId}: ${widgetName}`"
|
||||
:class="
|
||||
cn(dragClass, 'my-2 rounded-lg bg-primary-background/30 p-2')
|
||||
"
|
||||
:title="label ?? widgetName"
|
||||
:sub-title="subLabel"
|
||||
:rename
|
||||
:remove="
|
||||
() =>
|
||||
remove(
|
||||
appModeStore.selectedInputs,
|
||||
([id, name]) => nodeId == id && widgetName === name
|
||||
)
|
||||
"
|
||||
/>
|
||||
</DraggableList>
|
||||
</PropertiesAccordionItem>
|
||||
<PropertiesAccordionItem
|
||||
v-if="isSelectOutputsMode"
|
||||
:label="t('nodeHelpPage.outputs')"
|
||||
enable-empty-state
|
||||
:disabled="!appModeStore.selectedOutputs.length"
|
||||
:tooltip="`${t('linearMode.builder.outputsDesc')}\n${t('linearMode.builder.outputsExample')}`"
|
||||
:tooltip-delay="100"
|
||||
>
|
||||
<template #label>
|
||||
<div class="flex gap-3">
|
||||
{{ t('nodeHelpPage.outputs') }}
|
||||
<i class="icon-[lucide--circle-alert] bg-muted-foreground" />
|
||||
</div>
|
||||
</template>
|
||||
<template #empty>
|
||||
<div
|
||||
class="w-full p-4 pt-2 text-muted-foreground"
|
||||
v-text="t('linearMode.builder.promptAddOutputs')"
|
||||
/>
|
||||
</template>
|
||||
<div
|
||||
class="w-full p-4 pt-2 text-muted-foreground"
|
||||
v-text="t('linearMode.builder.promptAddOutputs')"
|
||||
/>
|
||||
<DraggableList
|
||||
v-slot="{ dragClass }"
|
||||
v-model="appModeStore.selectedOutputs"
|
||||
>
|
||||
<IoItem
|
||||
v-for="([key, title], index) in outputsWithState"
|
||||
:key
|
||||
:class="
|
||||
cn(
|
||||
dragClass,
|
||||
'my-2 rounded-lg bg-warning-background/40 p-2',
|
||||
index === 0 && 'ring-2 ring-warning-background'
|
||||
)
|
||||
"
|
||||
:title
|
||||
:sub-title="String(key)"
|
||||
:remove="
|
||||
() => remove(appModeStore.selectedOutputs, (k) => k == key)
|
||||
"
|
||||
/>
|
||||
</DraggableList>
|
||||
</PropertiesAccordionItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Teleport
|
||||
v-if="isSelectMode && !settingStore.get('Comfy.VueNodes.Enabled')"
|
||||
|
||||
@@ -19,38 +19,31 @@
|
||||
</button>
|
||||
</template>
|
||||
<template #default="{ close }">
|
||||
<button
|
||||
:class="
|
||||
cn(
|
||||
'flex w-full items-center gap-3 rounded-md border-none bg-transparent px-3 py-2 text-sm',
|
||||
hasOutputs
|
||||
? 'cursor-pointer hover:bg-secondary-background-hover'
|
||||
: 'pointer-events-none opacity-50'
|
||||
)
|
||||
"
|
||||
:disabled="!hasOutputs"
|
||||
@click="onSave(close)"
|
||||
>
|
||||
<i class="icon-[lucide--save] size-4" />
|
||||
{{ t('g.save') }}
|
||||
</button>
|
||||
<div class="my-1 border-t border-border-default" />
|
||||
<button
|
||||
class="flex w-full cursor-pointer items-center gap-3 rounded-md border-none bg-transparent px-3 py-2 text-sm hover:bg-secondary-background-hover"
|
||||
@click="onExitBuilder(close)"
|
||||
>
|
||||
<i class="icon-[lucide--square-pen] size-4" />
|
||||
{{ t('builderMenu.exitAppBuilder') }}
|
||||
</button>
|
||||
<template v-for="(item, index) in menuItems" :key="item.label">
|
||||
<div v-if="index > 0" class="my-1 border-t border-border-default" />
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
class="flex w-full items-center justify-start gap-3 rounded-md px-3 py-2 text-sm"
|
||||
:disabled="item.disabled"
|
||||
@click="item.action(close)"
|
||||
>
|
||||
<i :class="cn(item.icon, 'size-4')" />
|
||||
{{ item.label }}
|
||||
</Button>
|
||||
</template>
|
||||
</template>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
@@ -60,10 +53,30 @@ import { cn } from '@/utils/tailwindUtil'
|
||||
const { t } = useI18n()
|
||||
const appModeStore = useAppModeStore()
|
||||
const { hasOutputs } = storeToRefs(appModeStore)
|
||||
const { setMode } = useAppMode()
|
||||
const workflowService = useWorkflowService()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
|
||||
const menuItems = computed(() => [
|
||||
{
|
||||
label: t('g.save'),
|
||||
icon: 'icon-[lucide--save]',
|
||||
disabled: !hasOutputs.value,
|
||||
action: onSave
|
||||
},
|
||||
{
|
||||
label: t('builderMenu.enterAppMode'),
|
||||
icon: 'icon-[lucide--panels-top-left]',
|
||||
action: onEnterAppMode
|
||||
},
|
||||
{
|
||||
label: t('builderMenu.exitAppBuilder'),
|
||||
icon: 'icon-[lucide--square-pen]',
|
||||
action: onExitBuilder
|
||||
}
|
||||
])
|
||||
|
||||
async function onSave(close: () => void) {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (!workflow) return
|
||||
@@ -75,6 +88,11 @@ async function onSave(close: () => void) {
|
||||
}
|
||||
}
|
||||
|
||||
function onEnterAppMode(close: () => void) {
|
||||
setMode('app')
|
||||
close()
|
||||
}
|
||||
|
||||
function onExitBuilder(close: () => void) {
|
||||
void appModeStore.exitBuilder()
|
||||
close()
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
DropdownMenuRoot,
|
||||
DropdownMenuTrigger
|
||||
} from 'reka-ui'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import WorkflowActionsList from '@/components/common/WorkflowActionsList.vue'
|
||||
@@ -22,6 +23,7 @@ const { source, align = 'start' } = defineProps<{
|
||||
|
||||
const { t } = useI18n()
|
||||
const canvasStore = useCanvasStore()
|
||||
const dropdownOpen = ref(false)
|
||||
|
||||
const { menuItems } = useWorkflowActionsMenu(
|
||||
() => useCommandStore().execute('Comfy.RenameWorkflow'),
|
||||
@@ -40,22 +42,38 @@ function handleOpen(open: boolean) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function toggleLinearMode() {
|
||||
dropdownOpen.value = false
|
||||
void useCommandStore().execute('Comfy.ToggleLinear', {
|
||||
metadata: { source }
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRoot @update:open="handleOpen">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<slot name="button" :has-unseen-items="hasUnseenItems">
|
||||
<DropdownMenuRoot v-model:open="dropdownOpen" @update:open="handleOpen">
|
||||
<slot name="button" :has-unseen-items="hasUnseenItems">
|
||||
<div
|
||||
class="pointer-events-auto inline-flex items-center rounded-lg bg-secondary-background"
|
||||
>
|
||||
<Button
|
||||
v-tooltip="{
|
||||
value: t('breadcrumbsMenu.workflowActions'),
|
||||
value: canvasStore.linearMode
|
||||
? t('breadcrumbsMenu.enterNodeGraph')
|
||||
: t('breadcrumbsMenu.enterAppMode'),
|
||||
showDelay: 300,
|
||||
hideDelay: 300
|
||||
}"
|
||||
variant="secondary"
|
||||
size="unset"
|
||||
:aria-label="t('breadcrumbsMenu.workflowActions')"
|
||||
class="pointer-events-auto relative h-10 gap-1 rounded-lg pr-2 pl-3 data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
|
||||
:aria-label="
|
||||
canvasStore.linearMode
|
||||
? t('breadcrumbsMenu.enterNodeGraph')
|
||||
: t('breadcrumbsMenu.enterAppMode')
|
||||
"
|
||||
variant="base"
|
||||
class="m-1"
|
||||
@pointerdown.stop
|
||||
@click="toggleLinearMode"
|
||||
>
|
||||
<i
|
||||
class="size-4"
|
||||
@@ -65,15 +83,36 @@ function handleOpen(open: boolean) {
|
||||
: 'icon-[comfy--workflow]'
|
||||
"
|
||||
/>
|
||||
<i class="icon-[lucide--chevron-down] size-4 text-muted-foreground" />
|
||||
<span
|
||||
v-if="hasUnseenItems"
|
||||
aria-hidden="true"
|
||||
class="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-primary-background"
|
||||
/>
|
||||
</Button>
|
||||
</slot>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button
|
||||
v-tooltip="{
|
||||
value: t('breadcrumbsMenu.workflowActions'),
|
||||
showDelay: 300,
|
||||
hideDelay: 300
|
||||
}"
|
||||
variant="secondary"
|
||||
size="unset"
|
||||
:aria-label="t('breadcrumbsMenu.workflowActions')"
|
||||
class="relative h-10 gap-1 rounded-lg pr-2 pl-2.5 text-center data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
|
||||
>
|
||||
<span>{{
|
||||
canvasStore.linearMode
|
||||
? t('breadcrumbsMenu.app')
|
||||
: t('breadcrumbsMenu.graph')
|
||||
}}</span>
|
||||
<i
|
||||
class="icon-[lucide--chevron-down] size-4 text-muted-foreground"
|
||||
/>
|
||||
<span
|
||||
v-if="hasUnseenItems"
|
||||
aria-hidden="true"
|
||||
class="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-primary-background"
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</div>
|
||||
</slot>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
:align
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import Loader from './Loader.vue'
|
||||
|
||||
const meta: Meta<typeof Loader> = {
|
||||
title: 'Components/Common/Loader',
|
||||
title: 'Components/Loader/Loader',
|
||||
component: Loader,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
96
src/components/loader/LogoCFillLoader.stories.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import LogoCFillLoader from './LogoCFillLoader.vue'
|
||||
|
||||
const meta: Meta<typeof LogoCFillLoader> = {
|
||||
title: 'Components/Loader/LogoCFillLoader',
|
||||
component: LogoCFillLoader,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
backgrounds: { default: 'dark' }
|
||||
},
|
||||
argTypes: {
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['sm', 'md', 'lg', 'xl']
|
||||
},
|
||||
color: {
|
||||
control: 'select',
|
||||
options: ['yellow', 'blue', 'white', 'black']
|
||||
},
|
||||
bordered: {
|
||||
control: 'boolean'
|
||||
},
|
||||
disableAnimation: {
|
||||
control: 'boolean'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {}
|
||||
|
||||
export const Small: Story = {
|
||||
args: { size: 'sm' }
|
||||
}
|
||||
|
||||
export const Large: Story = {
|
||||
args: { size: 'lg' }
|
||||
}
|
||||
|
||||
export const ExtraLarge: Story = {
|
||||
args: { size: 'xl' }
|
||||
}
|
||||
|
||||
export const NoBorder: Story = {
|
||||
args: { bordered: false }
|
||||
}
|
||||
|
||||
export const Static: Story = {
|
||||
args: { disableAnimation: true }
|
||||
}
|
||||
|
||||
export const BrandColors: Story = {
|
||||
render: () => ({
|
||||
components: { LogoCFillLoader },
|
||||
template: `
|
||||
<div class="flex items-end gap-12">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<span class="text-xs text-neutral-400">Yellow</span>
|
||||
<LogoCFillLoader size="lg" color="yellow" />
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<span class="text-xs text-neutral-400">Blue</span>
|
||||
<LogoCFillLoader size="lg" color="blue" />
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<span class="text-xs text-neutral-400">White</span>
|
||||
<LogoCFillLoader size="lg" color="white" />
|
||||
</div>
|
||||
<div class="p-4 bg-white rounded" style="background: white">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<span class="text-xs text-neutral-600">Black</span>
|
||||
<LogoCFillLoader size="lg" color="black" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const AllSizes: Story = {
|
||||
render: () => ({
|
||||
components: { LogoCFillLoader },
|
||||
template: `
|
||||
<div class="flex items-end gap-8">
|
||||
<LogoCFillLoader size="sm" color="yellow" />
|
||||
<LogoCFillLoader size="md" color="yellow" />
|
||||
<LogoCFillLoader size="lg" color="yellow" />
|
||||
<LogoCFillLoader size="xl" color="yellow" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
100
src/components/loader/LogoCFillLoader.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<span role="status" :class="cn('inline-flex', colorClass)">
|
||||
<svg
|
||||
:width="Math.round(heightMap[size] * (VB_W / VB_H))"
|
||||
:height="heightMap[size]"
|
||||
:viewBox="`0 0 ${VB_W} ${VB_H}`"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<defs>
|
||||
<mask :id="maskId">
|
||||
<path :d="C_PATH" fill="white" />
|
||||
</mask>
|
||||
</defs>
|
||||
<path
|
||||
v-if="bordered"
|
||||
:d="C_PATH"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
fill="none"
|
||||
opacity="0.4"
|
||||
/>
|
||||
<g :mask="`url(#${maskId})`">
|
||||
<rect
|
||||
:class="disableAnimation ? undefined : 'c-fill-rect'"
|
||||
:x="-BLEED"
|
||||
:y="-BLEED"
|
||||
:width="VB_W + BLEED * 2"
|
||||
:height="VB_H + BLEED * 2"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
<span class="sr-only">{{ t('g.loading') }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useId, computed } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const {
|
||||
size = 'md',
|
||||
color = 'black',
|
||||
bordered = true,
|
||||
disableAnimation = false
|
||||
} = defineProps<{
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl'
|
||||
color?: 'yellow' | 'blue' | 'white' | 'black'
|
||||
bordered?: boolean
|
||||
disableAnimation?: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const maskId = `c-mask-${useId()}`
|
||||
|
||||
const VB_W = 185
|
||||
const VB_H = 201
|
||||
const BLEED = 1
|
||||
|
||||
// Larger than LogoComfyWaveLoader because the C logo is near-square (185×201)
|
||||
// while the COMFY wordmark is wide (879×284), so larger heights are needed
|
||||
// for visually comparable perceived size.
|
||||
const heightMap = { sm: 48, md: 80, lg: 120, xl: 200 } as const
|
||||
const colorMap = {
|
||||
yellow: 'text-brand-yellow',
|
||||
blue: 'text-brand-blue',
|
||||
white: 'text-white',
|
||||
black: 'text-black'
|
||||
} as const
|
||||
|
||||
const colorClass = computed(() => colorMap[color])
|
||||
|
||||
const C_PATH =
|
||||
'M42.1217 200.812C37.367 200.812 33.5304 199.045 31.0285 195.703C28.4569 192.27 27.7864 187.477 29.1882 182.557L34.8172 162.791C35.2661 161.217 34.9537 159.523 33.9747 158.214C32.9958 156.908 31.464 156.139 29.8371 156.139L13.6525 156.139C8.89521 156.139 5.05862 154.374 2.55797 151.032C-0.0136533 147.597-0.684085 142.804 0.71869 137.883L20.0565 70.289L22.1916 62.8625C25.0617 52.7847 35.5288 44.5943 45.528 44.5943L64.8938 44.5943C67.2048 44.5943 69.2376 43.0535 69.8738 40.8175L76.2782 18.3344C79.1454 8.26681 89.6127 0.0763962 99.6117 0.0763945L141.029 0.00258328L171.349-2.99253e-05C176.104-3.0756e-05 179.941 1.765 182.442 5.10626C185.013 8.53932 185.684 13.3324 184.282 18.2528L175.612 48.6947C172.746 58.7597 162.279 66.9475 152.28 66.9475L110.771 67.0265L91.4113 67.0265C89.1029 67.0265 87.0727 68.5647 86.4326 70.7983L70.2909 127.179C69.8394 128.756 70.1518 130.454 71.1334 131.763C72.1123 133.07 73.6441 133.839 75.2697 133.839C75.2736 133.839 102.699 133.785 102.699 133.785L132.929 133.785C137.685 133.785 141.522 135.55 144.023 138.892C146.594 142.327 147.265 147.12 145.862 152.041L137.192 182.478C134.326 192.545 123.859 200.733 113.86 200.733L72.3517 200.812L42.1217 200.812Z'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.c-fill-rect {
|
||||
animation: c-fill-up 2.5s cubic-bezier(0.25, 0, 0.3, 1) forwards;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
@keyframes c-fill-up {
|
||||
0% {
|
||||
transform: translateY(calc(v-bind(VB_H) * 1px + v-bind(BLEED) * 1px));
|
||||
}
|
||||
100% {
|
||||
transform: translateY(calc(v-bind(BLEED) * -1px));
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.c-fill-rect {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
96
src/components/loader/LogoComfyWaveLoader.stories.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import LogoComfyWaveLoader from './LogoComfyWaveLoader.vue'
|
||||
|
||||
const meta: Meta<typeof LogoComfyWaveLoader> = {
|
||||
title: 'Components/Loader/LogoComfyWaveLoader',
|
||||
component: LogoComfyWaveLoader,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
backgrounds: { default: 'dark' }
|
||||
},
|
||||
argTypes: {
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['sm', 'md', 'lg', 'xl']
|
||||
},
|
||||
color: {
|
||||
control: 'select',
|
||||
options: ['yellow', 'blue', 'white', 'black']
|
||||
},
|
||||
bordered: {
|
||||
control: 'boolean'
|
||||
},
|
||||
disableAnimation: {
|
||||
control: 'boolean'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {}
|
||||
|
||||
export const Small: Story = {
|
||||
args: { size: 'sm' }
|
||||
}
|
||||
|
||||
export const Large: Story = {
|
||||
args: { size: 'lg' }
|
||||
}
|
||||
|
||||
export const ExtraLarge: Story = {
|
||||
args: { size: 'xl' }
|
||||
}
|
||||
|
||||
export const NoBorder: Story = {
|
||||
args: { bordered: false }
|
||||
}
|
||||
|
||||
export const Static: Story = {
|
||||
args: { disableAnimation: true }
|
||||
}
|
||||
|
||||
export const BrandColors: Story = {
|
||||
render: () => ({
|
||||
components: { LogoComfyWaveLoader },
|
||||
template: `
|
||||
<div class="flex flex-col items-center gap-12">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<span class="text-xs text-neutral-400">#F0FF41 (Yellow)</span>
|
||||
<LogoComfyWaveLoader size="lg" color="yellow" />
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<span class="text-xs text-neutral-400">#172DD7 (Blue)</span>
|
||||
<LogoComfyWaveLoader size="lg" color="blue" />
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<span class="text-xs text-neutral-400">White</span>
|
||||
<LogoComfyWaveLoader size="lg" color="white" />
|
||||
</div>
|
||||
<div class="p-4 bg-white rounded" style="background: white">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<span class="text-xs text-neutral-600">Black</span>
|
||||
<LogoComfyWaveLoader size="lg" color="black" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const AllSizes: Story = {
|
||||
render: () => ({
|
||||
components: { LogoComfyWaveLoader },
|
||||
template: `
|
||||
<div class="flex flex-col items-center gap-8">
|
||||
<LogoComfyWaveLoader size="sm" color="yellow" />
|
||||
<LogoComfyWaveLoader size="md" color="yellow" />
|
||||
<LogoComfyWaveLoader size="lg" color="yellow" />
|
||||
<LogoComfyWaveLoader size="xl" color="yellow" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
111
src/components/loader/LogoComfyWaveLoader.vue
Normal file
@@ -2,7 +2,6 @@
|
||||
<BaseWorkflowsSidebarTab
|
||||
:title="$t('linearMode.appModeToolbar.apps')"
|
||||
:filter="isAppWorkflow"
|
||||
:label-transform="stripAppJsonSuffix"
|
||||
hide-leaf-icon
|
||||
:search-subject="$t('linearMode.appModeToolbar.apps')"
|
||||
data-testid="apps-sidebar"
|
||||
@@ -18,8 +17,14 @@
|
||||
<NoResultsPlaceholder
|
||||
button-variant="secondary"
|
||||
text-class="text-muted-foreground text-sm"
|
||||
:message="$t('linearMode.appModeToolbar.appsEmptyMessage')"
|
||||
:button-label="$t('linearMode.appModeToolbar.enterAppMode')"
|
||||
:message="
|
||||
isAppMode
|
||||
? $t('linearMode.appModeToolbar.appsEmptyMessage')
|
||||
: `${$t('linearMode.appModeToolbar.appsEmptyMessage')}\n${$t('linearMode.appModeToolbar.appsEmptyMessageAction')}`
|
||||
"
|
||||
:button-label="
|
||||
isAppMode ? undefined : $t('linearMode.appModeToolbar.enterAppMode')
|
||||
"
|
||||
@action="enterAppMode"
|
||||
/>
|
||||
</template>
|
||||
@@ -32,16 +37,12 @@ import BaseWorkflowsSidebarTab from '@/components/sidebar/tabs/BaseWorkflowsSide
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
|
||||
const { setMode } = useAppMode()
|
||||
const { isAppMode, setMode } = useAppMode()
|
||||
|
||||
function isAppWorkflow(workflow: ComfyWorkflow): boolean {
|
||||
return workflow.suffix === 'app.json'
|
||||
}
|
||||
|
||||
function stripAppJsonSuffix(label: string): string {
|
||||
return label.replace(/\.app\.json$/i, '')
|
||||
}
|
||||
|
||||
function enterAppMode() {
|
||||
setMode('app')
|
||||
}
|
||||
|
||||
@@ -154,6 +154,7 @@ import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue
|
||||
import WorkflowTreeLeaf from '@/components/sidebar/tabs/workflows/WorkflowTreeLeaf.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTreeExpansion } from '@/composables/useTreeExpansion'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import {
|
||||
@@ -163,26 +164,23 @@ import {
|
||||
} from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import type { TreeExplorerNode, TreeNode } from '@/types/treeExplorerTypes'
|
||||
import { ensureWorkflowSuffix, getWorkflowSuffix } from '@/utils/formatUtil'
|
||||
import {
|
||||
ensureWorkflowSuffix,
|
||||
getFilenameDetails,
|
||||
getWorkflowSuffix
|
||||
} from '@/utils/formatUtil'
|
||||
import { buildTree, sortedTree } from '@/utils/treeUtil'
|
||||
|
||||
const {
|
||||
title,
|
||||
filter,
|
||||
searchSubject,
|
||||
dataTestid,
|
||||
labelTransform,
|
||||
hideLeafIcon
|
||||
} = defineProps<{
|
||||
const { title, filter, searchSubject, dataTestid, hideLeafIcon } = defineProps<{
|
||||
title: string
|
||||
filter?: (workflow: ComfyWorkflow) => boolean
|
||||
searchSubject: string
|
||||
dataTestid: string
|
||||
labelTransform?: (label: string) => string
|
||||
hideLeafIcon?: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { isAppMode } = useAppMode()
|
||||
|
||||
const applyFilter = (workflows: ComfyWorkflow[]) =>
|
||||
filter ? workflows.filter(filter) : workflows
|
||||
@@ -304,14 +302,18 @@ const renderTreeNode = (
|
||||
},
|
||||
contextMenuItems() {
|
||||
return [
|
||||
{
|
||||
label: t('g.insert'),
|
||||
icon: 'pi pi-file-export',
|
||||
command: async () => {
|
||||
const workflow = node.data
|
||||
await workflowService.insertWorkflow(workflow)
|
||||
}
|
||||
},
|
||||
...(isAppMode.value
|
||||
? []
|
||||
: [
|
||||
{
|
||||
label: t('g.insert'),
|
||||
icon: 'pi pi-file-export',
|
||||
command: async () => {
|
||||
const workflow = node.data
|
||||
await workflowService.insertWorkflow(workflow)
|
||||
}
|
||||
}
|
||||
]),
|
||||
{
|
||||
label: t('g.duplicate'),
|
||||
icon: 'pi pi-file-export',
|
||||
@@ -326,8 +328,7 @@ const renderTreeNode = (
|
||||
}
|
||||
: { handleClick }
|
||||
|
||||
const label =
|
||||
node.leaf && labelTransform ? labelTransform(node.label) : node.label
|
||||
const label = node.leaf ? getFilenameDetails(node.label).filename : node.label
|
||||
|
||||
return {
|
||||
key: node.key,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Loader from '@/components/common/Loader.vue'
|
||||
import Loader from '@/components/loader/Loader.vue'
|
||||
import StatusBadge from '@/components/common/StatusBadge.vue'
|
||||
import type { AssetDownload } from '@/stores/assetDownloadStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
@@ -9,7 +9,8 @@ const panY = ref(0.0)
|
||||
|
||||
function handleWheel(e: WheelEvent) {
|
||||
const zoomPaneEl = zoomPane.value
|
||||
if (!zoomPaneEl) return
|
||||
if (!zoomPaneEl || (e.deltaY < 0 ? zoom.value > 1200 : zoom.value < -500))
|
||||
return
|
||||
|
||||
zoom.value -= e.deltaY
|
||||
const { x, y, width, height } = zoomPaneEl.getBoundingClientRect()
|
||||
|
||||
@@ -20,6 +20,7 @@ export const buttonVariants = cva({
|
||||
'destructive-textonly':
|
||||
'bg-transparent text-destructive-background hover:bg-destructive-background/10',
|
||||
'overlay-white': 'bg-white text-gray-600 hover:bg-white/90',
|
||||
base: 'bg-base-background text-base-foreground hover:bg-secondary-background-hover',
|
||||
gradient:
|
||||
'border-transparent bg-(image:--subscription-button-gradient) text-white hover:opacity-90'
|
||||
},
|
||||
@@ -49,6 +50,7 @@ const variants = [
|
||||
'textonly',
|
||||
'muted-textonly',
|
||||
'destructive-textonly',
|
||||
'base',
|
||||
'overlay-white',
|
||||
'gradient'
|
||||
] as const satisfies Array<ButtonVariants['variant']>
|
||||
|
||||
@@ -819,6 +819,14 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
app.canvas.pasteFromClipboard()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Canvas.PasteFromClipboardWithConnect',
|
||||
icon: 'icon-[lucide--clipboard-paste]',
|
||||
label: () => t('Paste with Connect'),
|
||||
function: () => {
|
||||
app.canvas.pasteFromClipboard({ connectInputs: true })
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Canvas.SelectAll',
|
||||
icon: 'icon-[lucide--lasso-select]',
|
||||
@@ -833,6 +841,12 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
label: 'Delete Selected Items',
|
||||
versionAdded: '1.10.5',
|
||||
function: () => {
|
||||
if (app.canvas.selectedItems.size === 0) {
|
||||
app.canvas.canvas.dispatchEvent(
|
||||
new CustomEvent('litegraph:no-items-selected', { bubbles: true })
|
||||
)
|
||||
return
|
||||
}
|
||||
app.canvas.deleteSelected()
|
||||
app.canvas.setDirty(true, true)
|
||||
}
|
||||
|
||||
133
src/composables/useStablePrimeVueSplitterSizer.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import type { SplitterResizeEndEvent } from 'primevue/splitter'
|
||||
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useStablePrimeVueSplitterSizer } from './useStablePrimeVueSplitterSizer'
|
||||
|
||||
vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...(actual as object),
|
||||
useStorage: <T>(_key: string, defaultValue: T) => ref(defaultValue)
|
||||
}
|
||||
})
|
||||
|
||||
function createPanel(width: number) {
|
||||
const el = document.createElement('div')
|
||||
Object.defineProperty(el, 'offsetWidth', { value: width })
|
||||
return ref(el)
|
||||
}
|
||||
|
||||
function resizeEndEvent(): SplitterResizeEndEvent {
|
||||
return { originalEvent: new Event('mouseup'), sizes: [] }
|
||||
}
|
||||
|
||||
async function flushWatcher() {
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
describe('useStablePrimeVueSplitterSizer', () => {
|
||||
it('captures pixel widths on resize end and applies on trigger', async () => {
|
||||
const panelRef = createPanel(400)
|
||||
const trigger = ref(0)
|
||||
|
||||
const { onResizeEnd } = useStablePrimeVueSplitterSizer(
|
||||
[{ ref: panelRef, storageKey: 'test-capture' }],
|
||||
[trigger]
|
||||
)
|
||||
await flushWatcher()
|
||||
|
||||
onResizeEnd(resizeEndEvent())
|
||||
|
||||
trigger.value++
|
||||
await flushWatcher()
|
||||
|
||||
expect(panelRef.value!.style.flexBasis).toBe('400px')
|
||||
expect(panelRef.value!.style.flexGrow).toBe('0')
|
||||
expect(panelRef.value!.style.flexShrink).toBe('0')
|
||||
})
|
||||
|
||||
it('does not apply styles when no stored width exists', async () => {
|
||||
const panelRef = createPanel(300)
|
||||
const trigger = ref(0)
|
||||
|
||||
useStablePrimeVueSplitterSizer(
|
||||
[{ ref: panelRef, storageKey: 'test-no-stored' }],
|
||||
[trigger]
|
||||
)
|
||||
await flushWatcher()
|
||||
|
||||
expect(panelRef.value!.style.flexBasis).toBe('')
|
||||
})
|
||||
|
||||
it('re-applies stored widths when watch sources change', async () => {
|
||||
const panelRef = createPanel(500)
|
||||
const trigger = ref(0)
|
||||
|
||||
const { onResizeEnd } = useStablePrimeVueSplitterSizer(
|
||||
[{ ref: panelRef, storageKey: 'test-reapply' }],
|
||||
[trigger]
|
||||
)
|
||||
await flushWatcher()
|
||||
|
||||
onResizeEnd(resizeEndEvent())
|
||||
|
||||
panelRef.value!.style.flexBasis = ''
|
||||
panelRef.value!.style.flexGrow = ''
|
||||
panelRef.value!.style.flexShrink = ''
|
||||
|
||||
trigger.value++
|
||||
await flushWatcher()
|
||||
|
||||
expect(panelRef.value!.style.flexBasis).toBe('500px')
|
||||
expect(panelRef.value!.style.flexGrow).toBe('0')
|
||||
expect(panelRef.value!.style.flexShrink).toBe('0')
|
||||
})
|
||||
|
||||
it('handles multiple panels independently', async () => {
|
||||
const leftRef = createPanel(300)
|
||||
const rightRef = createPanel(250)
|
||||
const trigger = ref(0)
|
||||
|
||||
const { onResizeEnd } = useStablePrimeVueSplitterSizer(
|
||||
[
|
||||
{ ref: leftRef, storageKey: 'test-multi-left' },
|
||||
{ ref: rightRef, storageKey: 'test-multi-right' }
|
||||
],
|
||||
[trigger]
|
||||
)
|
||||
await flushWatcher()
|
||||
|
||||
onResizeEnd(resizeEndEvent())
|
||||
|
||||
trigger.value++
|
||||
await flushWatcher()
|
||||
|
||||
expect(leftRef.value!.style.flexBasis).toBe('300px')
|
||||
expect(rightRef.value!.style.flexBasis).toBe('250px')
|
||||
})
|
||||
|
||||
it('skips panels with null refs', async () => {
|
||||
const nullRef = ref(null)
|
||||
const validRef = createPanel(200)
|
||||
const trigger = ref(0)
|
||||
|
||||
const { onResizeEnd } = useStablePrimeVueSplitterSizer(
|
||||
[
|
||||
{ ref: nullRef, storageKey: 'test-null' },
|
||||
{ ref: validRef, storageKey: 'test-valid' }
|
||||
],
|
||||
[trigger]
|
||||
)
|
||||
await flushWatcher()
|
||||
|
||||
onResizeEnd(resizeEndEvent())
|
||||
|
||||
trigger.value++
|
||||
await flushWatcher()
|
||||
|
||||
expect(validRef.value!.style.flexBasis).toBe('200px')
|
||||
})
|
||||
})
|
||||
64
src/composables/useStablePrimeVueSplitterSizer.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { SplitterResizeEndEvent } from 'primevue/splitter'
|
||||
import type { WatchSource } from 'vue'
|
||||
|
||||
import { unrefElement, useStorage } from '@vueuse/core'
|
||||
import type { MaybeComputedElementRef } from '@vueuse/core'
|
||||
import { nextTick, watch } from 'vue'
|
||||
|
||||
interface PanelConfig {
|
||||
ref: MaybeComputedElementRef
|
||||
storageKey: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Works around PrimeVue Splitter not properly initializing flexBasis
|
||||
* when panels are conditionally rendered. Captures pixel widths on
|
||||
* resize end and re-applies them as rigid flex values (flex: 0 0 Xpx)
|
||||
* when watched sources change (e.g. tab switch, panel toggle).
|
||||
*
|
||||
* @param panels - array of panel configs with template ref and storage key
|
||||
* @param watchSources - reactive sources that trigger re-application
|
||||
*/
|
||||
export function useStablePrimeVueSplitterSizer(
|
||||
panels: PanelConfig[],
|
||||
watchSources: WatchSource[]
|
||||
) {
|
||||
const storedWidths = panels.map((panel) => ({
|
||||
ref: panel.ref,
|
||||
width: useStorage<number | null>(panel.storageKey, null)
|
||||
}))
|
||||
|
||||
function resolveElement(
|
||||
ref: MaybeComputedElementRef
|
||||
): HTMLElement | undefined {
|
||||
return unrefElement(ref) as HTMLElement | undefined
|
||||
}
|
||||
|
||||
function applyStoredWidths() {
|
||||
for (const { ref, width } of storedWidths) {
|
||||
const el = resolveElement(ref)
|
||||
if (!el || width.value === null) continue
|
||||
el.style.flexBasis = `${width.value}px`
|
||||
el.style.flexGrow = '0'
|
||||
el.style.flexShrink = '0'
|
||||
}
|
||||
}
|
||||
|
||||
function onResizeEnd(_event: SplitterResizeEndEvent) {
|
||||
for (const { ref, width } of storedWidths) {
|
||||
const el = resolveElement(ref)
|
||||
if (el) width.value = el.offsetWidth
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
watchSources,
|
||||
async () => {
|
||||
await nextTick()
|
||||
applyStoredWidths()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
return { onResizeEnd }
|
||||
}
|
||||
@@ -81,6 +81,8 @@ vi.mock('@/stores/appModeStore', () => ({
|
||||
useAppModeStore: vi.fn(() => mockAppModeStore)
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useErrorHandling', () => ({}))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: vi.fn(() => mockFeatureFlags)
|
||||
}))
|
||||
|
||||
@@ -2,7 +2,10 @@ import type { ComputedRef, Ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { openShareDialog } from '@/platform/workflow/sharing/composables/lazyShareDialog'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import {
|
||||
@@ -189,11 +192,11 @@ export function useWorkflowActionsMenu(
|
||||
|
||||
addItem({
|
||||
id: 'share',
|
||||
label: t('menuLabels.Share'),
|
||||
label: t('breadcrumbsMenu.share'),
|
||||
icon: 'icon-[comfy--send]',
|
||||
command: async () => {},
|
||||
disabled: true,
|
||||
visible: isRoot
|
||||
command: () =>
|
||||
openShareDialog().catch(useErrorHandling().toastErrorHandler),
|
||||
visible: isCloud && flags.workflowSharingEnabled
|
||||
})
|
||||
|
||||
addItem({
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
LiteGraph,
|
||||
LLink
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
@@ -17,6 +18,10 @@ import {
|
||||
createTestSubgraphNode
|
||||
} from './subgraph/__fixtures__/subgraphHelpers'
|
||||
|
||||
import { duplicateSubgraphNodeIds } from './__fixtures__/duplicateSubgraphNodeIds'
|
||||
import { nestedSubgraphProxyWidgets } from './__fixtures__/nestedSubgraphProxyWidgets'
|
||||
import { nodeIdSpaceExhausted } from './__fixtures__/nodeIdSpaceExhausted'
|
||||
import { uniqueSubgraphNodeIds } from './__fixtures__/uniqueSubgraphNodeIds'
|
||||
import { test } from './__fixtures__/testExtensions'
|
||||
|
||||
function swapNodes(nodes: LGraphNode[]) {
|
||||
@@ -656,3 +661,121 @@ describe('Subgraph Unpacking', () => {
|
||||
expect(definitionIds).toContain(subgraph.id)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deduplicateSubgraphNodeIds (via configure)', () => {
|
||||
const SUBGRAPH_A = '11111111-1111-4111-8111-111111111111' as UUID
|
||||
const SUBGRAPH_B = '22222222-2222-4222-8222-222222222222' as UUID
|
||||
const SHARED_NODE_IDS = [3, 8, 37]
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
LiteGraph.registerNodeType('dummy', DummyNode)
|
||||
})
|
||||
|
||||
function loadFixture(): SerialisableGraph {
|
||||
return structuredClone(duplicateSubgraphNodeIds)
|
||||
}
|
||||
|
||||
function configureFromFixture() {
|
||||
const graphData = loadFixture()
|
||||
const graph = new LGraph()
|
||||
graph.configure(graphData)
|
||||
return { graph, graphData }
|
||||
}
|
||||
|
||||
function nodeIdSet(graph: LGraph, subgraphId: UUID) {
|
||||
return new Set(graph.subgraphs.get(subgraphId)!.nodes.map((n) => n.id))
|
||||
}
|
||||
|
||||
it('remaps duplicate node IDs so subgraphs have no overlap', () => {
|
||||
const { graph } = configureFromFixture()
|
||||
|
||||
const idsA = nodeIdSet(graph, SUBGRAPH_A)
|
||||
const idsB = nodeIdSet(graph, SUBGRAPH_B)
|
||||
|
||||
for (const id of SHARED_NODE_IDS) {
|
||||
expect(idsA.has(id as NodeId)).toBe(true)
|
||||
}
|
||||
for (const id of idsA) {
|
||||
expect(idsB.has(id)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('patches link references in remapped subgraph', () => {
|
||||
const { graph } = configureFromFixture()
|
||||
const idsB = nodeIdSet(graph, SUBGRAPH_B)
|
||||
|
||||
for (const link of graph.subgraphs.get(SUBGRAPH_B)!.links.values()) {
|
||||
expect(idsB.has(link.origin_id)).toBe(true)
|
||||
expect(idsB.has(link.target_id)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('patches promoted widget references in remapped subgraph', () => {
|
||||
const { graph } = configureFromFixture()
|
||||
const idsB = nodeIdSet(graph, SUBGRAPH_B)
|
||||
|
||||
for (const widget of graph.subgraphs.get(SUBGRAPH_B)!.widgets) {
|
||||
expect(idsB.has(widget.id)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('patches proxyWidgets in root-level nodes referencing remapped IDs', () => {
|
||||
const { graph } = configureFromFixture()
|
||||
|
||||
const idsA = new Set(
|
||||
graph.subgraphs.get(SUBGRAPH_A)!.nodes.map((n) => String(n.id))
|
||||
)
|
||||
const idsB = new Set(
|
||||
graph.subgraphs.get(SUBGRAPH_B)!.nodes.map((n) => String(n.id))
|
||||
)
|
||||
|
||||
const pw102 = graph.getNodeById(102 as NodeId)?.properties?.proxyWidgets
|
||||
expect(Array.isArray(pw102)).toBe(true)
|
||||
for (const entry of pw102 as unknown[][]) {
|
||||
expect(Array.isArray(entry)).toBe(true)
|
||||
expect(idsA.has(String(entry[0]))).toBe(true)
|
||||
}
|
||||
|
||||
const pw103 = graph.getNodeById(103 as NodeId)?.properties?.proxyWidgets
|
||||
expect(Array.isArray(pw103)).toBe(true)
|
||||
for (const entry of pw103 as unknown[][]) {
|
||||
expect(Array.isArray(entry)).toBe(true)
|
||||
expect(idsB.has(String(entry[0]))).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('patches proxyWidgets inside nested subgraph nodes', () => {
|
||||
const graph = new LGraph()
|
||||
graph.configure(structuredClone(nestedSubgraphProxyWidgets))
|
||||
|
||||
const idsB = new Set(
|
||||
graph.subgraphs.get(SUBGRAPH_B)!.nodes.map((n) => String(n.id))
|
||||
)
|
||||
|
||||
const innerNode = graph.subgraphs
|
||||
.get(SUBGRAPH_A)!
|
||||
.nodes.find((n) => n.id === (50 as NodeId))
|
||||
const pw = innerNode?.properties?.proxyWidgets
|
||||
expect(Array.isArray(pw)).toBe(true)
|
||||
for (const entry of pw as unknown[][]) {
|
||||
expect(Array.isArray(entry)).toBe(true)
|
||||
expect(idsB.has(String(entry[0]))).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('throws when node ID space is exhausted', () => {
|
||||
expect(() => {
|
||||
const graph = new LGraph()
|
||||
graph.configure(structuredClone(nodeIdSpaceExhausted))
|
||||
}).toThrow('Node ID space exhausted')
|
||||
})
|
||||
|
||||
it('is a no-op when subgraph node IDs are already unique', () => {
|
||||
const graph = new LGraph()
|
||||
graph.configure(structuredClone(uniqueSubgraphNodeIds))
|
||||
|
||||
expect(nodeIdSet(graph, SUBGRAPH_A)).toEqual(new Set([10, 11, 12]))
|
||||
expect(nodeIdSet(graph, SUBGRAPH_B)).toEqual(new Set([20, 21, 22]))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -76,6 +76,7 @@ import type {
|
||||
SerialisableReroute
|
||||
} from './types/serialisation'
|
||||
import { getAllNestedItems } from './utils/collections'
|
||||
import { deduplicateSubgraphNodeIds } from './utils/subgraphDeduplication'
|
||||
|
||||
export type {
|
||||
LGraphTriggerAction,
|
||||
@@ -2474,19 +2475,40 @@ export class LGraph
|
||||
this[i] = data[i]
|
||||
}
|
||||
|
||||
// Subgraph definitions
|
||||
// Subgraph definitions — deduplicate node IDs before configuring.
|
||||
// deduplicateSubgraphNodeIds clones internally to avoid mutating
|
||||
// the caller's data (e.g. reactive Pinia state).
|
||||
const subgraphs = data.definitions?.subgraphs
|
||||
let effectiveNodesData = nodesData
|
||||
if (subgraphs) {
|
||||
for (const subgraph of subgraphs) this.createSubgraph(subgraph)
|
||||
for (const subgraph of subgraphs)
|
||||
this.subgraphs.get(subgraph.id)?.configure(subgraph)
|
||||
}
|
||||
const reservedNodeIds = new Set<number>()
|
||||
for (const node of this._nodes) {
|
||||
if (typeof node.id === 'number') reservedNodeIds.add(node.id)
|
||||
}
|
||||
for (const sg of this.subgraphs.values()) {
|
||||
for (const node of sg.nodes) {
|
||||
if (typeof node.id === 'number') reservedNodeIds.add(node.id)
|
||||
}
|
||||
}
|
||||
for (const n of nodesData ?? []) {
|
||||
if (typeof n.id === 'number') reservedNodeIds.add(n.id)
|
||||
}
|
||||
|
||||
if (this.isRootGraph) {
|
||||
const reservedNodeIds = nodesData
|
||||
?.map((n) => n.id)
|
||||
.filter((id): id is number => typeof id === 'number')
|
||||
this.ensureGlobalIdUniqueness(reservedNodeIds)
|
||||
const deduplicated = this.isRootGraph
|
||||
? deduplicateSubgraphNodeIds(
|
||||
subgraphs,
|
||||
reservedNodeIds,
|
||||
this.state,
|
||||
nodesData
|
||||
)
|
||||
: undefined
|
||||
|
||||
const finalSubgraphs = deduplicated?.subgraphs ?? subgraphs
|
||||
effectiveNodesData = deduplicated?.rootNodes ?? nodesData
|
||||
|
||||
for (const subgraph of finalSubgraphs) this.createSubgraph(subgraph)
|
||||
for (const subgraph of finalSubgraphs)
|
||||
this.subgraphs.get(subgraph.id)?.configure(subgraph)
|
||||
}
|
||||
|
||||
let error = false
|
||||
@@ -2494,8 +2516,8 @@ export class LGraph
|
||||
|
||||
// create nodes
|
||||
this._nodes = []
|
||||
if (nodesData) {
|
||||
for (const n_info of nodesData) {
|
||||
if (effectiveNodesData) {
|
||||
for (const n_info of effectiveNodesData) {
|
||||
// stored info
|
||||
let node = LiteGraph.createNode(String(n_info.type), n_info.title)
|
||||
if (!node) {
|
||||
|
||||
@@ -3790,13 +3790,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
return
|
||||
}
|
||||
|
||||
private _noItemsSelected(): void {
|
||||
const event = new CustomEvent('litegraph:no-items-selected', {
|
||||
bubbles: true
|
||||
})
|
||||
this.canvas.dispatchEvent(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* process a key event
|
||||
*/
|
||||
@@ -3841,31 +3834,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
this.node_panel?.close()
|
||||
this.options_panel?.close()
|
||||
if (this.node_panel || this.options_panel) block_default = true
|
||||
} else if (e.keyCode === 65 && e.ctrlKey) {
|
||||
// select all Control A
|
||||
this.selectItems()
|
||||
block_default = true
|
||||
} else if (e.keyCode === 67 && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
|
||||
// copy
|
||||
if (this.selected_nodes) {
|
||||
this.copyToClipboard()
|
||||
block_default = true
|
||||
}
|
||||
} else if (e.keyCode === 86 && (e.metaKey || e.ctrlKey)) {
|
||||
// paste
|
||||
this.pasteFromClipboard({ connectInputs: e.shiftKey })
|
||||
} else if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
// delete or backspace
|
||||
// @ts-expect-error EventTarget.localName is not in standard types
|
||||
if (e.target.localName != 'input' && e.target.localName != 'textarea') {
|
||||
if (this.selectedItems.size === 0) {
|
||||
this._noItemsSelected()
|
||||
return
|
||||
}
|
||||
|
||||
this.deleteSelected()
|
||||
block_default = true
|
||||
}
|
||||
}
|
||||
|
||||
// TODO
|
||||
|
||||
163
src/lib/litegraph/src/__fixtures__/duplicateSubgraphNodeIds.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
|
||||
|
||||
/**
|
||||
* Workflow with two subgraph definitions whose internal nodes share
|
||||
* identical IDs [3, 8, 37]. Reproduces the widget-state collision bug
|
||||
* where copied subgraphs overwrote each other's widget store entries.
|
||||
*
|
||||
* SubgraphA (node 102): widgets reference node 3, link 3→8
|
||||
* SubgraphB (node 103): widgets reference node 8, link 3→37
|
||||
*/
|
||||
export const duplicateSubgraphNodeIds = {
|
||||
id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
|
||||
version: 1,
|
||||
revision: 0,
|
||||
state: {
|
||||
lastNodeId: 100,
|
||||
lastLinkId: 10,
|
||||
lastGroupId: 0,
|
||||
lastRerouteId: 0
|
||||
},
|
||||
nodes: [
|
||||
{
|
||||
id: 102,
|
||||
type: '11111111-1111-4111-8111-111111111111',
|
||||
pos: [0, 0],
|
||||
size: [200, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
properties: { proxyWidgets: [['3', 'seed']] }
|
||||
},
|
||||
{
|
||||
id: 103,
|
||||
type: '22222222-2222-4222-8222-222222222222',
|
||||
pos: [300, 0],
|
||||
size: [200, 100],
|
||||
flags: {},
|
||||
order: 1,
|
||||
mode: 0,
|
||||
properties: { proxyWidgets: [['8', 'prompt']] }
|
||||
}
|
||||
],
|
||||
definitions: {
|
||||
subgraphs: [
|
||||
{
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
version: 1,
|
||||
revision: 0,
|
||||
state: {
|
||||
lastNodeId: 0,
|
||||
lastLinkId: 0,
|
||||
lastGroupId: 0,
|
||||
lastRerouteId: 0
|
||||
},
|
||||
name: 'SubgraphA',
|
||||
config: {},
|
||||
inputNode: { id: -10, bounding: [10, 100, 150, 126] },
|
||||
outputNode: { id: -20, bounding: [400, 100, 140, 126] },
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
widgets: [{ id: 3, name: 'seed' }],
|
||||
nodes: [
|
||||
{
|
||||
id: 3,
|
||||
type: 'dummy',
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
type: 'dummy',
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 1,
|
||||
mode: 0
|
||||
},
|
||||
{
|
||||
id: 37,
|
||||
type: 'dummy',
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 2,
|
||||
mode: 0
|
||||
}
|
||||
],
|
||||
links: [
|
||||
{
|
||||
id: 1,
|
||||
origin_id: 3,
|
||||
origin_slot: 0,
|
||||
target_id: 8,
|
||||
target_slot: 0,
|
||||
type: 'number'
|
||||
}
|
||||
],
|
||||
groups: []
|
||||
},
|
||||
{
|
||||
id: '22222222-2222-4222-8222-222222222222',
|
||||
version: 1,
|
||||
revision: 0,
|
||||
state: {
|
||||
lastNodeId: 0,
|
||||
lastLinkId: 0,
|
||||
lastGroupId: 0,
|
||||
lastRerouteId: 0
|
||||
},
|
||||
name: 'SubgraphB',
|
||||
config: {},
|
||||
inputNode: { id: -10, bounding: [10, 100, 150, 126] },
|
||||
outputNode: { id: -20, bounding: [400, 100, 140, 126] },
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
widgets: [{ id: 8, name: 'prompt' }],
|
||||
nodes: [
|
||||
{
|
||||
id: 3,
|
||||
type: 'dummy',
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
type: 'dummy',
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 1,
|
||||
mode: 0
|
||||
},
|
||||
{
|
||||
id: 37,
|
||||
type: 'dummy',
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 2,
|
||||
mode: 0
|
||||
}
|
||||
],
|
||||
links: [
|
||||
{
|
||||
id: 2,
|
||||
origin_id: 3,
|
||||
origin_slot: 0,
|
||||
target_id: 37,
|
||||
target_slot: 0,
|
||||
type: 'string'
|
||||
}
|
||||
],
|
||||
groups: []
|
||||
}
|
||||
]
|
||||
}
|
||||
} as const satisfies SerialisableGraph
|
||||
177
src/lib/litegraph/src/__fixtures__/nestedSubgraphProxyWidgets.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
|
||||
|
||||
/**
|
||||
* Workflow where SubgraphA contains a nested SubgraphNode referencing
|
||||
* SubgraphB. Both subgraph definitions share internal node IDs [3, 8, 37].
|
||||
*
|
||||
* The nested SubgraphNode (id 50, inside SubgraphA) has proxyWidgets
|
||||
* pointing at SubgraphB's node 8. After deduplication remaps SubgraphB's
|
||||
* nodes, the nested proxyWidgets must also be patched.
|
||||
*
|
||||
* SubgraphA (node 102): widgets reference node 3, link 3→8,
|
||||
* contains nested SubgraphNode(50) → SubgraphB with proxyWidget ['8']
|
||||
* SubgraphB (node 103): widgets reference node 8, link 3→37
|
||||
*/
|
||||
export const nestedSubgraphProxyWidgets = {
|
||||
id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
|
||||
version: 1,
|
||||
revision: 0,
|
||||
state: {
|
||||
lastNodeId: 100,
|
||||
lastLinkId: 10,
|
||||
lastGroupId: 0,
|
||||
lastRerouteId: 0
|
||||
},
|
||||
nodes: [
|
||||
{
|
||||
id: 102,
|
||||
type: '11111111-1111-4111-8111-111111111111',
|
||||
pos: [0, 0],
|
||||
size: [200, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
properties: { proxyWidgets: [['3', 'seed']] }
|
||||
},
|
||||
{
|
||||
id: 103,
|
||||
type: '22222222-2222-4222-8222-222222222222',
|
||||
pos: [300, 0],
|
||||
size: [200, 100],
|
||||
flags: {},
|
||||
order: 1,
|
||||
mode: 0,
|
||||
properties: { proxyWidgets: [['8', 'prompt']] }
|
||||
}
|
||||
],
|
||||
definitions: {
|
||||
subgraphs: [
|
||||
{
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
version: 1,
|
||||
revision: 0,
|
||||
state: {
|
||||
lastNodeId: 0,
|
||||
lastLinkId: 0,
|
||||
lastGroupId: 0,
|
||||
lastRerouteId: 0
|
||||
},
|
||||
name: 'SubgraphA',
|
||||
config: {},
|
||||
inputNode: { id: -10, bounding: [10, 100, 150, 126] },
|
||||
outputNode: { id: -20, bounding: [400, 100, 140, 126] },
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
widgets: [{ id: 3, name: 'seed' }],
|
||||
nodes: [
|
||||
{
|
||||
id: 3,
|
||||
type: 'dummy',
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
type: 'dummy',
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 1,
|
||||
mode: 0
|
||||
},
|
||||
{
|
||||
id: 37,
|
||||
type: 'dummy',
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 2,
|
||||
mode: 0
|
||||
},
|
||||
{
|
||||
id: 50,
|
||||
type: '22222222-2222-4222-8222-222222222222',
|
||||
pos: [200, 0],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 3,
|
||||
mode: 0,
|
||||
properties: { proxyWidgets: [['8', 'prompt']] }
|
||||
}
|
||||
],
|
||||
links: [
|
||||
{
|
||||
id: 1,
|
||||
origin_id: 3,
|
||||
origin_slot: 0,
|
||||
target_id: 8,
|
||||
target_slot: 0,
|
||||
type: 'number'
|
||||
}
|
||||
],
|
||||
groups: []
|
||||
},
|
||||
{
|
||||
id: '22222222-2222-4222-8222-222222222222',
|
||||
version: 1,
|
||||
revision: 0,
|
||||
state: {
|
||||
lastNodeId: 0,
|
||||
lastLinkId: 0,
|
||||
lastGroupId: 0,
|
||||
lastRerouteId: 0
|
||||
},
|
||||
name: 'SubgraphB',
|
||||
config: {},
|
||||
inputNode: { id: -10, bounding: [10, 100, 150, 126] },
|
||||
outputNode: { id: -20, bounding: [400, 100, 140, 126] },
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
widgets: [{ id: 8, name: 'prompt' }],
|
||||
nodes: [
|
||||
{
|
||||
id: 3,
|
||||
type: 'dummy',
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
type: 'dummy',
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 1,
|
||||
mode: 0
|
||||
},
|
||||
{
|
||||
id: 37,
|
||||
type: 'dummy',
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 2,
|
||||
mode: 0
|
||||
}
|
||||
],
|
||||
links: [
|
||||
{
|
||||
id: 2,
|
||||
origin_id: 3,
|
||||
origin_slot: 0,
|
||||
target_id: 37,
|
||||
target_slot: 0,
|
||||
type: 'string'
|
||||
}
|
||||
],
|
||||
groups: []
|
||||
}
|
||||
]
|
||||
}
|
||||
} as const satisfies SerialisableGraph
|
||||
172
src/lib/litegraph/src/__fixtures__/nodeIdSpaceExhausted.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
|
||||
|
||||
/**
|
||||
* Workflow where lastNodeId is near the MAX_NODE_ID ceiling (100_000_000)
|
||||
* and root node 100_000_000 reserves the only remaining candidate ID.
|
||||
*
|
||||
* Both subgraph definitions share node IDs [3, 8, 37]. When SubgraphB's
|
||||
* duplicates need remapping, candidate 100_000_000 is already reserved,
|
||||
* so the next candidate (100_000_001) exceeds MAX_NODE_ID and must throw.
|
||||
*/
|
||||
export const nodeIdSpaceExhausted = {
|
||||
id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc',
|
||||
version: 1,
|
||||
revision: 0,
|
||||
state: {
|
||||
lastNodeId: 99_999_999,
|
||||
lastLinkId: 10,
|
||||
lastGroupId: 0,
|
||||
lastRerouteId: 0
|
||||
},
|
||||
nodes: [
|
||||
{
|
||||
id: 102,
|
||||
type: '11111111-1111-4111-8111-111111111111',
|
||||
pos: [0, 0],
|
||||
size: [200, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
properties: { proxyWidgets: [['3', 'seed']] }
|
||||
},
|
||||
{
|
||||
id: 103,
|
||||
type: '22222222-2222-4222-8222-222222222222',
|
||||
pos: [300, 0],
|
||||
size: [200, 100],
|
||||
flags: {},
|
||||
order: 1,
|
||||
mode: 0,
|
||||
properties: { proxyWidgets: [['8', 'prompt']] }
|
||||
},
|
||||
{
|
||||
id: 100_000_000,
|
||||
type: 'dummy',
|
||||
pos: [600, 0],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 2,
|
||||
mode: 0
|
||||
}
|
||||
],
|
||||
definitions: {
|
||||
subgraphs: [
|
||||
{
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
version: 1,
|
||||
revision: 0,
|
||||
state: {
|
||||
lastNodeId: 0,
|
||||
lastLinkId: 0,
|
||||
lastGroupId: 0,
|
||||
lastRerouteId: 0
|
||||
},
|
||||
name: 'SubgraphA',
|
||||
config: {},
|
||||
inputNode: { id: -10, bounding: [10, 100, 150, 126] },
|
||||
outputNode: { id: -20, bounding: [400, 100, 140, 126] },
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
widgets: [{ id: 3, name: 'seed' }],
|
||||
nodes: [
|
||||
{
|
||||
id: 3,
|
||||
type: 'dummy',
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
type: 'dummy',
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 1,
|
||||
mode: 0
|
||||
},
|
||||
{
|
||||
id: 37,
|
||||
type: 'dummy',
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 2,
|
||||
mode: 0
|
||||
}
|
||||
],
|
||||
links: [
|
||||
{
|
||||
id: 1,
|
||||
origin_id: 3,
|
||||
origin_slot: 0,
|
||||
target_id: 8,
|
||||
target_slot: 0,
|
||||
type: 'number'
|
||||
}
|
||||
],
|
||||
groups: []
|
||||
},
|
||||
{
|
||||
id: '22222222-2222-4222-8222-222222222222',
|
||||
version: 1,
|
||||
revision: 0,
|
||||
state: {
|
||||
lastNodeId: 0,
|
||||
lastLinkId: 0,
|
||||
lastGroupId: 0,
|
||||
lastRerouteId: 0
|
||||
},
|
||||
name: 'SubgraphB',
|
||||
config: {},
|
||||
inputNode: { id: -10, bounding: [10, 100, 150, 126] },
|
||||
outputNode: { id: -20, bounding: [400, 100, 140, 126] },
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
widgets: [{ id: 8, name: 'prompt' }],
|
||||
nodes: [
|
||||
{
|
||||
id: 3,
|
||||
type: 'dummy',
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
type: 'dummy',
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 1,
|
||||
mode: 0
|
||||
},
|
||||
{
|
||||
id: 37,
|
||||
type: 'dummy',
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 2,
|
||||
mode: 0
|
||||
}
|
||||
],
|
||||
links: [
|
||||
{
|
||||
id: 2,
|
||||
origin_id: 3,
|
||||
origin_slot: 0,
|
||||
target_id: 37,
|
||||
target_slot: 0,
|
||||
type: 'string'
|
||||
}
|
||||
],
|
||||
groups: []
|
||||
}
|
||||
]
|
||||
}
|
||||
} as const satisfies SerialisableGraph
|
||||
163
src/lib/litegraph/src/__fixtures__/uniqueSubgraphNodeIds.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
|
||||
|
||||
/**
|
||||
* Workflow with two subgraph definitions whose internal nodes already
|
||||
* have unique IDs. Deduplication should be a no-op — all IDs, links,
|
||||
* widgets, and proxyWidgets pass through unchanged.
|
||||
*
|
||||
* SubgraphA (node 102): nodes [10, 11, 12], link 10→11, widget ref 10
|
||||
* SubgraphB (node 103): nodes [20, 21, 22], link 20→22, widget ref 21
|
||||
*/
|
||||
export const uniqueSubgraphNodeIds = {
|
||||
id: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd',
|
||||
version: 1,
|
||||
revision: 0,
|
||||
state: {
|
||||
lastNodeId: 100,
|
||||
lastLinkId: 10,
|
||||
lastGroupId: 0,
|
||||
lastRerouteId: 0
|
||||
},
|
||||
nodes: [
|
||||
{
|
||||
id: 102,
|
||||
type: '11111111-1111-4111-8111-111111111111',
|
||||
pos: [0, 0],
|
||||
size: [200, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
properties: { proxyWidgets: [['10', 'seed']] }
|
||||
},
|
||||
{
|
||||
id: 103,
|
||||
type: '22222222-2222-4222-8222-222222222222',
|
||||
pos: [300, 0],
|
||||
size: [200, 100],
|
||||
flags: {},
|
||||
order: 1,
|
||||
mode: 0,
|
||||
properties: { proxyWidgets: [['21', 'prompt']] }
|
||||
}
|
||||
],
|
||||
definitions: {
|
||||
subgraphs: [
|
||||
{
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
version: 1,
|
||||
revision: 0,
|
||||
state: {
|
||||
lastNodeId: 0,
|
||||
lastLinkId: 0,
|
||||
lastGroupId: 0,
|
||||
lastRerouteId: 0
|
||||
},
|
||||
name: 'SubgraphA',
|
||||
config: {},
|
||||
inputNode: { id: -10, bounding: [10, 100, 150, 126] },
|
||||
outputNode: { id: -20, bounding: [400, 100, 140, 126] },
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
widgets: [{ id: 10, name: 'seed' }],
|
||||
nodes: [
|
||||
{
|
||||
id: 10,
|
||||
type: 'dummy',
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
type: 'dummy',
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 1,
|
||||
mode: 0
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
type: 'dummy',
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 2,
|
||||
mode: 0
|
||||
}
|
||||
],
|
||||
links: [
|
||||
{
|
||||
id: 1,
|
||||
origin_id: 10,
|
||||
origin_slot: 0,
|
||||
target_id: 11,
|
||||
target_slot: 0,
|
||||
type: 'number'
|
||||
}
|
||||
],
|
||||
groups: []
|
||||
},
|
||||
{
|
||||
id: '22222222-2222-4222-8222-222222222222',
|
||||
version: 1,
|
||||
revision: 0,
|
||||
state: {
|
||||
lastNodeId: 0,
|
||||
lastLinkId: 0,
|
||||
lastGroupId: 0,
|
||||
lastRerouteId: 0
|
||||
},
|
||||
name: 'SubgraphB',
|
||||
config: {},
|
||||
inputNode: { id: -10, bounding: [10, 100, 150, 126] },
|
||||
outputNode: { id: -20, bounding: [400, 100, 140, 126] },
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
widgets: [{ id: 21, name: 'prompt' }],
|
||||
nodes: [
|
||||
{
|
||||
id: 20,
|
||||
type: 'dummy',
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0
|
||||
},
|
||||
{
|
||||
id: 21,
|
||||
type: 'dummy',
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 1,
|
||||
mode: 0
|
||||
},
|
||||
{
|
||||
id: 22,
|
||||
type: 'dummy',
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 2,
|
||||
mode: 0
|
||||
}
|
||||
],
|
||||
links: [
|
||||
{
|
||||
id: 2,
|
||||
origin_id: 20,
|
||||
origin_slot: 0,
|
||||
target_id: 22,
|
||||
target_slot: 0,
|
||||
type: 'string'
|
||||
}
|
||||
],
|
||||
groups: []
|
||||
}
|
||||
]
|
||||
}
|
||||
} as const satisfies SerialisableGraph
|
||||
164
src/lib/litegraph/src/utils/subgraphDeduplication.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import type { LGraphState } from '../LGraph'
|
||||
import type { NodeId } from '../LGraphNode'
|
||||
import type {
|
||||
ExportedSubgraph,
|
||||
ExposedWidget,
|
||||
ISerialisedNode,
|
||||
SerialisableLLink
|
||||
} from '../types/serialisation'
|
||||
|
||||
const MAX_NODE_ID = 100_000_000
|
||||
|
||||
interface DeduplicationResult {
|
||||
subgraphs: ExportedSubgraph[]
|
||||
rootNodes: ISerialisedNode[] | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-deduplicates node IDs across serialized subgraph definitions before
|
||||
* they are configured. This prevents widget store key collisions when
|
||||
* multiple subgraph copies contain nodes with the same IDs.
|
||||
*
|
||||
* Also patches proxyWidgets in root-level nodes that reference the
|
||||
* remapped inner node IDs.
|
||||
*
|
||||
* Returns deep clones of the inputs — the originals are never mutated.
|
||||
*
|
||||
* @param subgraphs - Serialized subgraph definitions to deduplicate
|
||||
* @param reservedNodeIds - Node IDs already in use by root-level nodes
|
||||
* @param state - Graph state containing the `lastNodeId` counter (mutated)
|
||||
* @param rootNodes - Optional root-level nodes with proxyWidgets to patch
|
||||
*/
|
||||
export function deduplicateSubgraphNodeIds(
|
||||
subgraphs: ExportedSubgraph[],
|
||||
reservedNodeIds: Set<number>,
|
||||
state: LGraphState,
|
||||
rootNodes?: ISerialisedNode[]
|
||||
): DeduplicationResult {
|
||||
const clonedSubgraphs = structuredClone(subgraphs)
|
||||
const clonedRootNodes = rootNodes ? structuredClone(rootNodes) : undefined
|
||||
|
||||
const usedNodeIds = new Set(reservedNodeIds)
|
||||
const subgraphIdSet = new Set(clonedSubgraphs.map((sg) => sg.id))
|
||||
const remapBySubgraph = new Map<string, Map<NodeId, NodeId>>()
|
||||
|
||||
for (const subgraph of clonedSubgraphs) {
|
||||
const remappedIds = remapNodeIds(subgraph.nodes ?? [], usedNodeIds, state)
|
||||
|
||||
if (remappedIds.size === 0) continue
|
||||
remapBySubgraph.set(subgraph.id, remappedIds)
|
||||
|
||||
patchSerialisedLinks(subgraph.links ?? [], remappedIds)
|
||||
patchPromotedWidgets(subgraph.widgets ?? [], remappedIds)
|
||||
}
|
||||
|
||||
for (const subgraph of clonedSubgraphs) {
|
||||
patchProxyWidgets(subgraph.nodes ?? [], subgraphIdSet, remapBySubgraph)
|
||||
}
|
||||
|
||||
if (clonedRootNodes) {
|
||||
patchProxyWidgets(clonedRootNodes, subgraphIdSet, remapBySubgraph)
|
||||
}
|
||||
|
||||
return { subgraphs: clonedSubgraphs, rootNodes: clonedRootNodes }
|
||||
}
|
||||
|
||||
/**
|
||||
* Remaps duplicate node IDs to unique values, updating `usedNodeIds`
|
||||
* and `state.lastNodeId` as new IDs are allocated.
|
||||
*
|
||||
* @returns A map of old ID → new ID for nodes that were remapped.
|
||||
*/
|
||||
function remapNodeIds(
|
||||
nodes: ISerialisedNode[],
|
||||
usedNodeIds: Set<number>,
|
||||
state: LGraphState
|
||||
): Map<NodeId, NodeId> {
|
||||
const remappedIds = new Map<NodeId, NodeId>()
|
||||
|
||||
for (const node of nodes) {
|
||||
const id = node.id
|
||||
if (typeof id !== 'number') continue
|
||||
|
||||
if (usedNodeIds.has(id)) {
|
||||
const newId = findNextAvailableId(usedNodeIds, state)
|
||||
remappedIds.set(id, newId)
|
||||
node.id = newId
|
||||
usedNodeIds.add(newId as number)
|
||||
console.warn(
|
||||
`LiteGraph: duplicate subgraph node ID ${id} remapped to ${newId}`
|
||||
)
|
||||
} else {
|
||||
usedNodeIds.add(id)
|
||||
if (id > state.lastNodeId) state.lastNodeId = id
|
||||
}
|
||||
}
|
||||
|
||||
return remappedIds
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the next unused node ID by incrementing `state.lastNodeId`.
|
||||
* Throws if the ID space is exhausted.
|
||||
*/
|
||||
function findNextAvailableId(
|
||||
usedNodeIds: Set<number>,
|
||||
state: LGraphState
|
||||
): NodeId {
|
||||
while (true) {
|
||||
const nextId = state.lastNodeId + 1
|
||||
if (nextId > MAX_NODE_ID) {
|
||||
throw new Error('Node ID space exhausted')
|
||||
}
|
||||
state.lastNodeId = nextId
|
||||
if (!usedNodeIds.has(nextId)) return nextId as NodeId
|
||||
}
|
||||
}
|
||||
|
||||
/** Patches origin_id / target_id in serialized links. */
|
||||
function patchSerialisedLinks(
|
||||
links: SerialisableLLink[],
|
||||
remappedIds: Map<NodeId, NodeId>
|
||||
): void {
|
||||
for (const link of links) {
|
||||
const newOrigin = remappedIds.get(link.origin_id)
|
||||
if (newOrigin !== undefined) link.origin_id = newOrigin
|
||||
|
||||
const newTarget = remappedIds.get(link.target_id)
|
||||
if (newTarget !== undefined) link.target_id = newTarget
|
||||
}
|
||||
}
|
||||
|
||||
/** Patches promoted widget node references. */
|
||||
function patchPromotedWidgets(
|
||||
widgets: ExposedWidget[],
|
||||
remappedIds: Map<NodeId, NodeId>
|
||||
): void {
|
||||
for (const widget of widgets) {
|
||||
const newId = remappedIds.get(widget.id)
|
||||
if (newId !== undefined) widget.id = newId
|
||||
}
|
||||
}
|
||||
|
||||
/** Patches proxyWidgets in root-level SubgraphNode instances. */
|
||||
function patchProxyWidgets(
|
||||
rootNodes: ISerialisedNode[],
|
||||
subgraphIdSet: Set<string>,
|
||||
remapBySubgraph: Map<string, Map<NodeId, NodeId>>
|
||||
): void {
|
||||
for (const node of rootNodes) {
|
||||
if (!subgraphIdSet.has(node.type)) continue
|
||||
const remappedIds = remapBySubgraph.get(node.type)
|
||||
if (!remappedIds) continue
|
||||
|
||||
const proxyWidgets = node.properties?.proxyWidgets
|
||||
if (!Array.isArray(proxyWidgets)) continue
|
||||
|
||||
for (const entry of proxyWidgets) {
|
||||
if (!Array.isArray(entry)) continue
|
||||
const oldId = Number(entry[0]) as NodeId
|
||||
const newId = remappedIds.get(oldId)
|
||||
if (newId !== undefined) entry[0] = String(newId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -178,7 +178,7 @@
|
||||
"uploadAlreadyInProgress": "Upload already in progress",
|
||||
"capture": "capture",
|
||||
"nodes": "Nodes",
|
||||
"nodesCount": "{count} nodes | {count} node | {count} nodes",
|
||||
"nodesCount": "{count} node | {count} nodes",
|
||||
"addNode": "Add a node...",
|
||||
"filterBy": "Filter by:",
|
||||
"filterByType": "Filter by {type}...",
|
||||
@@ -222,7 +222,7 @@
|
||||
"failed": "Failed",
|
||||
"cancelled": "Cancelled",
|
||||
"job": "Job",
|
||||
"asset": "{count} assets | {count} asset | {count} assets",
|
||||
"asset": "{count} asset | {count} assets",
|
||||
"untitled": "Untitled",
|
||||
"emDash": "—",
|
||||
"enabling": "Enabling {id}",
|
||||
@@ -1262,6 +1262,7 @@
|
||||
"Move Selected Nodes Right": "Move Selected Nodes Right",
|
||||
"Move Selected Nodes Up": "Move Selected Nodes Up",
|
||||
"Paste": "Paste",
|
||||
"Paste with Connect": "Paste with Connect",
|
||||
"Reset View": "Reset View",
|
||||
"Resize Selected Nodes": "Resize Selected Nodes",
|
||||
"Select All": "Select All",
|
||||
@@ -2603,7 +2604,11 @@
|
||||
"deleteWorkflow": "Delete Workflow",
|
||||
"deleteBlueprint": "Delete Blueprint",
|
||||
"enterNewName": "Enter new name",
|
||||
"missingNodesWarning": "Workflow contains unsupported nodes (highlighted red)."
|
||||
"missingNodesWarning": "Workflow contains unsupported nodes (highlighted red).",
|
||||
"graph": "Graph",
|
||||
"app": "App",
|
||||
"enterNodeGraph": "Enter node graph",
|
||||
"share": "Share"
|
||||
},
|
||||
"shortcuts": {
|
||||
"shortcuts": "Shortcuts",
|
||||
@@ -3158,12 +3163,13 @@
|
||||
"runCount": "Number of runs",
|
||||
"rerun": "Rerun",
|
||||
"reuseParameters": "Reuse Parameters",
|
||||
"downloadAll": "Download All",
|
||||
"downloadAll": "Download {count} assets from this run",
|
||||
"viewJob": "View Job",
|
||||
"enterNodeGraph": "Enter node graph",
|
||||
"emptyWorkflowExplanation": "Your workflow is empty. You need some nodes first to start building an app.",
|
||||
"backToWorkflow": "Back to workflow",
|
||||
"loadTemplate": "Load a template",
|
||||
"cancelThisRun": "Cancel this run",
|
||||
"welcome": {
|
||||
"title": "App Mode",
|
||||
"message": "A simplified view that hides the node graph so you can focus on creating.",
|
||||
@@ -3175,7 +3181,8 @@
|
||||
"appModeToolbar": {
|
||||
"appBuilder": "App builder",
|
||||
"apps": "Apps",
|
||||
"appsEmptyMessage": "Saved apps will show up here.\nClick below to build your first app.",
|
||||
"appsEmptyMessage": "Saved apps will show up here.",
|
||||
"appsEmptyMessageAction": "Click below to build your first app.",
|
||||
"enterAppMode": "Enter app mode"
|
||||
},
|
||||
"arrange": {
|
||||
@@ -3347,7 +3354,7 @@
|
||||
}
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count} ERRORS | {count} ERROR | {count} ERRORS",
|
||||
"errorCount": "{count} ERROR | {count} ERRORS",
|
||||
"seeErrors": "See Errors"
|
||||
},
|
||||
"help": {
|
||||
@@ -3357,7 +3364,7 @@
|
||||
"progressToast": {
|
||||
"importingModels": "Importing Models",
|
||||
"downloadingModel": "Downloading model...",
|
||||
"downloadsFailed": "{count} downloads failed | {count} download failed | {count} downloads failed",
|
||||
"downloadsFailed": "{count} download failed | {count} downloads failed",
|
||||
"allDownloadsCompleted": "All downloads completed",
|
||||
"noImportsInQueue": "No {filter} in queue",
|
||||
"failed": "Failed",
|
||||
@@ -3374,7 +3381,7 @@
|
||||
"exportingAssets": "Exporting Assets",
|
||||
"preparingExport": "Preparing export...",
|
||||
"exportError": "Export failed",
|
||||
"exportFailed": "{count} export failed | {count} export failed | {count} exports failed",
|
||||
"exportFailed": "{count} export failed | {count} exports failed",
|
||||
"allExportsCompleted": "All exports completed",
|
||||
"noExportsInQueue": "No {filter} exports in queue",
|
||||
"exportStarted": "Preparing ZIP download...",
|
||||
@@ -3517,6 +3524,7 @@
|
||||
"emptyWorkflowPrompt": "Do you want to start with a template?"
|
||||
},
|
||||
"builderMenu": {
|
||||
"enterAppMode": "Enter app mode",
|
||||
"exitAppBuilder": "Exit app builder"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Loader from '@/components/common/Loader.vue'
|
||||
import Loader from '@/components/loader/Loader.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useJobActions } from '@/composables/queue/useJobActions'
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Loader from '@/components/common/Loader.vue'
|
||||
import Loader from '@/components/loader/Loader.vue'
|
||||
import HoneyToast from '@/components/honeyToast/HoneyToast.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { AssetExport } from '@/stores/assetExportStore'
|
||||
|
||||
@@ -4,7 +4,7 @@ import Popover from 'primevue/popover'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Loader from '@/components/common/Loader.vue'
|
||||
import Loader from '@/components/loader/Loader.vue'
|
||||
import HoneyToast from '@/components/honeyToast/HoneyToast.vue'
|
||||
import ProgressToastItem from '@/components/toast/ProgressToastItem.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Loader from '@/components/common/Loader.vue'
|
||||
import Loader from '@/components/loader/Loader.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import VideoHelpDialog from '@/platform/assets/components/VideoHelpDialog.vue'
|
||||
|
||||
|
||||
@@ -208,5 +208,52 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
|
||||
key: 'Escape'
|
||||
},
|
||||
commandId: 'Comfy.Graph.ExitSubgraph'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
ctrl: true,
|
||||
key: 'a'
|
||||
},
|
||||
commandId: 'Comfy.Canvas.SelectAll',
|
||||
targetElementId: 'graph-canvas-container'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
ctrl: true,
|
||||
key: 'c'
|
||||
},
|
||||
commandId: 'Comfy.Canvas.CopySelected',
|
||||
targetElementId: 'graph-canvas-container'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
ctrl: true,
|
||||
key: 'v'
|
||||
},
|
||||
commandId: 'Comfy.Canvas.PasteFromClipboard',
|
||||
targetElementId: 'graph-canvas-container'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
ctrl: true,
|
||||
shift: true,
|
||||
key: 'v'
|
||||
},
|
||||
commandId: 'Comfy.Canvas.PasteFromClipboardWithConnect',
|
||||
targetElementId: 'graph-canvas-container'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
key: 'Delete'
|
||||
},
|
||||
commandId: 'Comfy.Canvas.DeleteSelectedItems',
|
||||
targetElementId: 'graph-canvas-container'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
key: 'Backspace'
|
||||
},
|
||||
commandId: 'Comfy.Canvas.DeleteSelectedItems',
|
||||
targetElementId: 'graph-canvas-container'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,22 +1,11 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useKeybindingService } from '@/platform/keybindings/keybindingService'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
vi.mock('@/scripts/app', () => {
|
||||
return {
|
||||
app: {
|
||||
canvas: {
|
||||
processKey: vi.fn()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: vi.fn(() => ({
|
||||
get: vi.fn(() => [])
|
||||
@@ -36,13 +25,15 @@ function createTestKeyboardEvent(
|
||||
ctrlKey?: boolean
|
||||
altKey?: boolean
|
||||
metaKey?: boolean
|
||||
shiftKey?: boolean
|
||||
} = {}
|
||||
): KeyboardEvent {
|
||||
const {
|
||||
target = document.body,
|
||||
ctrlKey = false,
|
||||
altKey = false,
|
||||
metaKey = false
|
||||
metaKey = false,
|
||||
shiftKey = false
|
||||
} = options
|
||||
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
@@ -50,6 +41,7 @@ function createTestKeyboardEvent(
|
||||
ctrlKey,
|
||||
altKey,
|
||||
metaKey,
|
||||
shiftKey,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
@@ -60,8 +52,10 @@ function createTestKeyboardEvent(
|
||||
return event
|
||||
}
|
||||
|
||||
describe('keybindingService - Event Forwarding', () => {
|
||||
describe('keybindingService - Canvas Keybindings', () => {
|
||||
let keybindingService: ReturnType<typeof useKeybindingService>
|
||||
let canvasContainer: HTMLDivElement
|
||||
let canvasChild: HTMLCanvasElement
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -76,94 +70,156 @@ describe('keybindingService - Event Forwarding', () => {
|
||||
typeof useDialogStore
|
||||
>)
|
||||
|
||||
canvasContainer = document.createElement('div')
|
||||
canvasContainer.id = 'graph-canvas-container'
|
||||
canvasChild = document.createElement('canvas')
|
||||
canvasContainer.appendChild(canvasChild)
|
||||
document.body.appendChild(canvasContainer)
|
||||
|
||||
keybindingService = useKeybindingService()
|
||||
keybindingService.registerCoreKeybindings()
|
||||
})
|
||||
|
||||
it('should forward Delete key to canvas when no keybinding exists', async () => {
|
||||
const event = createTestKeyboardEvent('Delete')
|
||||
afterEach(() => {
|
||||
canvasContainer.remove()
|
||||
})
|
||||
|
||||
it('should execute DeleteSelectedItems for Delete key on canvas', async () => {
|
||||
const event = createTestKeyboardEvent('Delete', {
|
||||
target: canvasChild
|
||||
})
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(vi.mocked(app.canvas.processKey)).toHaveBeenCalledWith(event)
|
||||
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
|
||||
'Comfy.Canvas.DeleteSelectedItems'
|
||||
)
|
||||
})
|
||||
|
||||
it('should forward Backspace key to canvas when no keybinding exists', async () => {
|
||||
const event = createTestKeyboardEvent('Backspace')
|
||||
it('should execute DeleteSelectedItems for Backspace key on canvas', async () => {
|
||||
const event = createTestKeyboardEvent('Backspace', {
|
||||
target: canvasChild
|
||||
})
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(vi.mocked(app.canvas.processKey)).toHaveBeenCalledWith(event)
|
||||
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
|
||||
'Comfy.Canvas.DeleteSelectedItems'
|
||||
)
|
||||
})
|
||||
|
||||
it('should not forward Delete key when typing in input field', async () => {
|
||||
it('should not execute DeleteSelectedItems when typing in input field', async () => {
|
||||
const inputElement = document.createElement('input')
|
||||
const event = createTestKeyboardEvent('Delete', { target: inputElement })
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(vi.mocked(app.canvas.processKey)).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not forward Delete key when typing in textarea', async () => {
|
||||
it('should not execute DeleteSelectedItems when typing in textarea', async () => {
|
||||
const textareaElement = document.createElement('textarea')
|
||||
const event = createTestKeyboardEvent('Delete', { target: textareaElement })
|
||||
const event = createTestKeyboardEvent('Delete', {
|
||||
target: textareaElement
|
||||
})
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(vi.mocked(app.canvas.processKey)).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not forward Delete key when canvas processKey is not available', async () => {
|
||||
// Temporarily replace processKey with undefined - testing edge case
|
||||
const originalProcessKey = vi.mocked(app.canvas).processKey
|
||||
vi.mocked(app.canvas).processKey = undefined!
|
||||
|
||||
const event = createTestKeyboardEvent('Delete')
|
||||
|
||||
try {
|
||||
await keybindingService.keybindHandler(event)
|
||||
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
// Restore processKey for other tests
|
||||
vi.mocked(app.canvas).processKey = originalProcessKey
|
||||
}
|
||||
})
|
||||
|
||||
it('should not forward Delete key when canvas is not available', async () => {
|
||||
const originalCanvas = vi.mocked(app).canvas
|
||||
vi.mocked(app).canvas = null!
|
||||
|
||||
const event = createTestKeyboardEvent('Delete')
|
||||
|
||||
try {
|
||||
await keybindingService.keybindHandler(event)
|
||||
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
// Restore canvas for other tests
|
||||
vi.mocked(app).canvas = originalCanvas
|
||||
}
|
||||
})
|
||||
|
||||
it('should not forward non-canvas keys', async () => {
|
||||
const event = createTestKeyboardEvent('Enter')
|
||||
it('should execute SelectAll for Ctrl+A on canvas', async () => {
|
||||
const event = createTestKeyboardEvent('a', {
|
||||
ctrlKey: true,
|
||||
target: canvasChild
|
||||
})
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(vi.mocked(app.canvas.processKey)).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
|
||||
'Comfy.Canvas.SelectAll'
|
||||
)
|
||||
})
|
||||
|
||||
it('should not forward when modifier keys are pressed', async () => {
|
||||
const event = createTestKeyboardEvent('Delete', { ctrlKey: true })
|
||||
it('should execute CopySelected for Ctrl+C on canvas', async () => {
|
||||
const event = createTestKeyboardEvent('c', {
|
||||
ctrlKey: true,
|
||||
target: canvasChild
|
||||
})
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
|
||||
'Comfy.Canvas.CopySelected'
|
||||
)
|
||||
})
|
||||
|
||||
it('should execute PasteFromClipboard for Ctrl+V on canvas', async () => {
|
||||
const event = createTestKeyboardEvent('v', {
|
||||
ctrlKey: true,
|
||||
target: canvasChild
|
||||
})
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
|
||||
'Comfy.Canvas.PasteFromClipboard'
|
||||
)
|
||||
})
|
||||
|
||||
it('should execute PasteFromClipboardWithConnect for Ctrl+Shift+V on canvas', async () => {
|
||||
const event = createTestKeyboardEvent('v', {
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
target: canvasChild
|
||||
})
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
|
||||
'Comfy.Canvas.PasteFromClipboardWithConnect'
|
||||
)
|
||||
})
|
||||
|
||||
it('should execute graph-canvas bindings by normalizing to graph-canvas-container', async () => {
|
||||
const event = createTestKeyboardEvent('=', {
|
||||
altKey: true,
|
||||
target: canvasChild
|
||||
})
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
|
||||
'Comfy.Canvas.ZoomIn'
|
||||
)
|
||||
})
|
||||
|
||||
it('should not execute graph-canvas bindings when target is outside canvas', async () => {
|
||||
const outsideDiv = document.createElement('div')
|
||||
document.body.appendChild(outsideDiv)
|
||||
|
||||
const event = createTestKeyboardEvent('=', {
|
||||
altKey: true,
|
||||
target: outsideDiv
|
||||
})
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(vi.mocked(app.canvas.processKey)).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
|
||||
outsideDiv.remove()
|
||||
})
|
||||
|
||||
it('should not execute canvas commands when target is outside canvas container', async () => {
|
||||
const outsideDiv = document.createElement('div')
|
||||
document.body.appendChild(outsideDiv)
|
||||
|
||||
const event = createTestKeyboardEvent('Delete', {
|
||||
target: outsideDiv
|
||||
})
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
|
||||
outsideDiv.remove()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
@@ -15,16 +14,6 @@ export function useKeybindingService() {
|
||||
const settingStore = useSettingStore()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
function shouldForwardToCanvas(event: KeyboardEvent): boolean {
|
||||
if (event.ctrlKey || event.altKey || event.metaKey) {
|
||||
return false
|
||||
}
|
||||
|
||||
const canvasKeys = ['Delete', 'Backspace']
|
||||
|
||||
return canvasKeys.includes(event.key)
|
||||
}
|
||||
|
||||
async function keybindHandler(event: KeyboardEvent) {
|
||||
const keyCombo = KeyComboImpl.fromEvent(event)
|
||||
if (keyCombo.isModifier) {
|
||||
@@ -44,7 +33,17 @@ export function useKeybindingService() {
|
||||
}
|
||||
|
||||
const keybinding = keybindingStore.getKeybinding(keyCombo)
|
||||
if (keybinding && keybinding.targetElementId !== 'graph-canvas') {
|
||||
if (keybinding) {
|
||||
const targetElementId =
|
||||
keybinding.targetElementId === 'graph-canvas'
|
||||
? 'graph-canvas-container'
|
||||
: keybinding.targetElementId
|
||||
if (targetElementId) {
|
||||
const container = document.getElementById(targetElementId)
|
||||
if (!container?.contains(target)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if (
|
||||
event.key === 'Escape' &&
|
||||
!event.ctrlKey &&
|
||||
@@ -74,18 +73,6 @@ export function useKeybindingService() {
|
||||
return
|
||||
}
|
||||
|
||||
if (!keybinding && shouldForwardToCanvas(event)) {
|
||||
const canvas = app.canvas
|
||||
if (
|
||||
canvas &&
|
||||
canvas.processKey &&
|
||||
typeof canvas.processKey === 'function'
|
||||
) {
|
||||
canvas.processKey(event)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (event.ctrlKey || event.altKey || event.metaKey) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
v-else
|
||||
class="fixed inset-0 z-1100 flex items-center justify-center bg-(--p-mask-background)"
|
||||
>
|
||||
<Loader size="lg" class="text-white" />
|
||||
<LogoComfyWaveLoader size="xl" color="yellow" disable-animation />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
* instead of workspace tokens when the workspace feature is enabled.
|
||||
*/
|
||||
import { promiseTimeout, until } from '@vueuse/core'
|
||||
import Loader from '@/components/common/Loader.vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
@@ -31,6 +30,7 @@ import { isCloud } from '@/platform/distribution/types'
|
||||
import { refreshRemoteConfig } from '@/platform/remoteConfig/refreshRemoteConfig'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import LogoComfyWaveLoader from '@/components/loader/LogoComfyWaveLoader.vue'
|
||||
|
||||
const FIREBASE_INIT_TIMEOUT_MS = 16_000
|
||||
const CONFIG_REFRESH_TIMEOUT_MS = 10_000
|
||||
|
||||
@@ -8,7 +8,7 @@ import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { id, name } = defineProps<{
|
||||
id: string
|
||||
isSelectInputsMode: boolean
|
||||
enable: boolean
|
||||
name: string
|
||||
}>()
|
||||
|
||||
@@ -25,7 +25,7 @@ function togglePromotion() {
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
v-if="isSelectInputsMode"
|
||||
v-if="enable"
|
||||
class="pointer-events-auto relative col-span-2 flex cursor-pointer flex-row gap-1"
|
||||
@pointerdown.capture.stop.prevent="togglePromotion"
|
||||
@click.capture.stop.prevent
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
import { ref, useTemplateRef } from 'vue'
|
||||
|
||||
import ZoomPane from '@/components/ui/ZoomPane.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const { src } = defineProps<{
|
||||
src: string
|
||||
@@ -13,7 +16,11 @@ const width = ref('')
|
||||
const height = ref('')
|
||||
</script>
|
||||
<template>
|
||||
<ZoomPane v-if="!mobile" v-slot="slotProps" class="w-full flex-1">
|
||||
<ZoomPane
|
||||
v-if="!mobile"
|
||||
v-slot="slotProps"
|
||||
:class="cn('w-full flex-1', $attrs.class as string)"
|
||||
>
|
||||
<img
|
||||
ref="imageRef"
|
||||
:src
|
||||
|
||||
@@ -1,18 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { flattenNodeOutput } from '@/renderer/extensions/linearMode/flattenNodeOutput'
|
||||
import MediaOutputPreview from '@/renderer/extensions/linearMode/MediaOutputPreview.vue'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { setMode } = useAppMode()
|
||||
const { hasOutputs } = storeToRefs(useAppModeStore())
|
||||
const appModeStore = useAppModeStore()
|
||||
const { hasOutputs } = storeToRefs(appModeStore)
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
const { nodeIdToNodeLocatorId } = useWorkflowStore()
|
||||
|
||||
const existingOutput = computed(() => {
|
||||
for (const nodeId of appModeStore.selectedOutputs) {
|
||||
const locatorId = nodeIdToNodeLocatorId(nodeId)
|
||||
const nodeOutput = nodeOutputStore.nodeOutputs[locatorId]
|
||||
if (!nodeOutput) continue
|
||||
const results = flattenNodeOutput([nodeId, nodeOutput])
|
||||
if (results.length > 0) return results[0]
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MediaOutputPreview
|
||||
v-if="existingOutput"
|
||||
:output="existingOutput"
|
||||
class="px-12 py-24"
|
||||
/>
|
||||
<div
|
||||
v-if="hasOutputs"
|
||||
v-else-if="hasOutputs"
|
||||
role="article"
|
||||
data-testid="arrange-preview"
|
||||
class="mx-auto flex h-full w-3/4 flex-col items-center justify-center gap-6 p-8"
|
||||
@@ -23,7 +48,7 @@ const { hasOutputs } = storeToRefs(useAppModeStore())
|
||||
<p class="mb-0 font-bold text-base-foreground">
|
||||
{{ t('linearMode.arrange.outputs') }}
|
||||
</p>
|
||||
<p>{{ t('linearMode.arrange.resultsLabel') }}</p>
|
||||
<p class="text-center">{{ t('linearMode.arrange.resultsLabel') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -5,12 +5,13 @@ import { storeToRefs } from 'pinia'
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Loader from '@/components/common/Loader.vue'
|
||||
import Loader from '@/components/loader/Loader.vue'
|
||||
import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
|
||||
import SubscribeToRunButton from '@/platform/cloud/subscription/components/SubscribeToRun.vue'
|
||||
@@ -44,7 +45,7 @@ const props = defineProps<{
|
||||
mobile?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{ navigateAssets: [] }>()
|
||||
defineEmits<{ navigateOutputs: [] }>()
|
||||
|
||||
//NOTE: due to batching, will never be greater than 2
|
||||
const pendingJobQueues = ref(0)
|
||||
@@ -72,7 +73,7 @@ const mappedSelections = computed(() => {
|
||||
).map(([, widgetName]) => widgetName)
|
||||
unprocessedInputs = unprocessedInputs.slice(inputGroup.length)
|
||||
const node = resolveNode(nodeId)
|
||||
if (!node) continue
|
||||
if (node?.mode !== LGraphEventMode.ALWAYS) continue
|
||||
|
||||
const nodeData = nodeToNodeData(node)
|
||||
remove(nodeData.widgets ?? [], (w) => !inputGroup.includes(w.name))
|
||||
@@ -105,6 +106,7 @@ function getDropIndicator(node: LGraphNode) {
|
||||
function nodeToNodeData(node: LGraphNode) {
|
||||
const dropIndicator = getDropIndicator(node)
|
||||
const nodeData = extractVueNodeData(node)
|
||||
remove(nodeData.widgets ?? [], (w) => w.slotMetadata?.linked ?? false)
|
||||
for (const widget of nodeData.widgets ?? []) widget.slotMetadata = undefined
|
||||
|
||||
return {
|
||||
@@ -261,7 +263,7 @@ defineExpose({ runButtonClick })
|
||||
<Button
|
||||
v-if="mobile"
|
||||
variant="inverted"
|
||||
@click="$emit('navigateAssets')"
|
||||
@click="$emit('navigateOutputs')"
|
||||
>
|
||||
{{ t('linearMode.viewJob') }}
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { defineAsyncComponent, ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
@@ -15,23 +15,15 @@ import LatentPreview from '@/renderer/extensions/linearMode/LatentPreview.vue'
|
||||
import LinearWelcome from '@/renderer/extensions/linearMode/LinearWelcome.vue'
|
||||
import LinearArrange from '@/renderer/extensions/linearMode/LinearArrange.vue'
|
||||
import LinearFeedback from '@/renderer/extensions/linearMode/LinearFeedback.vue'
|
||||
import MediaOutputPreview from '@/renderer/extensions/linearMode/MediaOutputPreview.vue'
|
||||
import OutputHistory from '@/renderer/extensions/linearMode/OutputHistory.vue'
|
||||
import { useOutputHistory } from '@/renderer/extensions/linearMode/useOutputHistory'
|
||||
import type { OutputSelection } from '@/renderer/extensions/linearMode/linearModeTypes'
|
||||
import VideoPreview from '@/renderer/extensions/linearMode/VideoPreview.vue'
|
||||
import { getMediaType } from '@/renderer/extensions/linearMode/mediaTypes'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
import { collectAllNodes } from '@/utils/graphTraversalUtil'
|
||||
import { executeWidgetsCallback } from '@/utils/litegraphUtil'
|
||||
|
||||
// Lazy-loaded to avoid pulling THREE.js into the main bundle
|
||||
const Preview3d = defineAsyncComponent(
|
||||
() => import('@/renderer/extensions/linearMode/Preview3d.vue')
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
@@ -73,17 +65,12 @@ async function loadWorkflow(item: AssetItem | undefined) {
|
||||
const changeTracker = useWorkflowStore().activeWorkflow?.changeTracker
|
||||
if (!changeTracker) return app.loadGraphData(workflow)
|
||||
changeTracker.redoQueue = []
|
||||
changeTracker.updateState([workflow], changeTracker.undoQueue)
|
||||
await changeTracker.updateState([workflow], changeTracker.undoQueue)
|
||||
}
|
||||
|
||||
async function rerun(e: Event) {
|
||||
if (!runButtonClick) return
|
||||
await loadWorkflow(selectedItem.value)
|
||||
//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>
|
||||
@@ -106,6 +93,7 @@ async function rerun(e: Event) {
|
||||
</template>
|
||||
<Button
|
||||
v-if="selectedOutput"
|
||||
v-tooltip.top="t('g.download')"
|
||||
size="icon"
|
||||
:aria-label="t('g.download')"
|
||||
@click="
|
||||
@@ -119,21 +107,26 @@ async function rerun(e: Event) {
|
||||
<Button
|
||||
v-if="!executionStore.isIdle && !selectedItem"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="t('menu.interrupt')"
|
||||
@click="commandStore.execute('Comfy.Interrupt')"
|
||||
>
|
||||
<i class="icon-[lucide--x]" />
|
||||
{{ t('linearMode.cancelThisRun') }}
|
||||
</Button>
|
||||
<Popover
|
||||
v-if="selectedItem"
|
||||
:entries="[
|
||||
{
|
||||
icon: 'icon-[lucide--download]',
|
||||
label: t('linearMode.downloadAll'),
|
||||
command: () => downloadAsset(selectedItem)
|
||||
},
|
||||
{ separator: true },
|
||||
...(allOutputs(selectedItem).length > 1
|
||||
? [
|
||||
{
|
||||
icon: 'icon-[lucide--download]',
|
||||
label: t('linearMode.downloadAll', {
|
||||
count: allOutputs(selectedItem).length
|
||||
}),
|
||||
command: () => downloadAsset(selectedItem)
|
||||
},
|
||||
{ separator: true }
|
||||
]
|
||||
: []),
|
||||
{
|
||||
icon: 'icon-[lucide--trash-2]',
|
||||
label: t('queue.jobMenu.deleteAsset'),
|
||||
@@ -143,32 +136,14 @@ async function rerun(e: Event) {
|
||||
/>
|
||||
</section>
|
||||
<ImagePreview
|
||||
v-if="
|
||||
(canShowPreview && latentPreview) ||
|
||||
getMediaType(selectedOutput) === 'images'
|
||||
"
|
||||
v-if="canShowPreview && latentPreview"
|
||||
:mobile
|
||||
:src="(canShowPreview && latentPreview) || selectedOutput!.url"
|
||||
:src="latentPreview"
|
||||
/>
|
||||
<VideoPreview
|
||||
v-else-if="getMediaType(selectedOutput) === 'video'"
|
||||
:src="selectedOutput!.url"
|
||||
class="flex-1 object-contain md:p-3 md:contain-size"
|
||||
/>
|
||||
<audio
|
||||
v-else-if="getMediaType(selectedOutput) === 'audio'"
|
||||
class="m-auto w-full"
|
||||
controls
|
||||
:src="selectedOutput!.url"
|
||||
/>
|
||||
<article
|
||||
v-else-if="getMediaType(selectedOutput) === 'text'"
|
||||
class="m-auto my-12 w-full max-w-lg overflow-y-auto"
|
||||
v-text="selectedOutput!.url"
|
||||
/>
|
||||
<Preview3d
|
||||
v-else-if="getMediaType(selectedOutput) === '3d'"
|
||||
:model-url="selectedOutput!.url"
|
||||
<MediaOutputPreview
|
||||
v-else-if="selectedOutput"
|
||||
:output="selectedOutput"
|
||||
:mobile
|
||||
/>
|
||||
<LatentPreview v-else-if="queueStore.runningTasks.length > 0" />
|
||||
<LinearArrange v-else-if="isArrangeMode" />
|
||||
@@ -184,7 +159,7 @@ async function rerun(e: Event) {
|
||||
/>
|
||||
<OutputHistory
|
||||
v-if="!isBuilderMode"
|
||||
class="min-w-0"
|
||||
class="z-10 min-w-0"
|
||||
@update-selection="handleSelection"
|
||||
/>
|
||||
<LinearFeedback
|
||||
|
||||
55
src/renderer/extensions/linearMode/MediaOutputPreview.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { defineAsyncComponent, useAttrs } from 'vue'
|
||||
|
||||
import ImagePreview from '@/renderer/extensions/linearMode/ImagePreview.vue'
|
||||
import VideoPreview from '@/renderer/extensions/linearMode/VideoPreview.vue'
|
||||
import { getMediaType } from '@/renderer/extensions/linearMode/mediaTypes'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const Preview3d = defineAsyncComponent(
|
||||
() => import('@/renderer/extensions/linearMode/Preview3d.vue')
|
||||
)
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const { output } = defineProps<{
|
||||
output: ResultItemImpl
|
||||
mobile?: boolean
|
||||
}>()
|
||||
|
||||
const attrs = useAttrs()
|
||||
</script>
|
||||
<template>
|
||||
<ImagePreview
|
||||
v-if="getMediaType(output) === 'images'"
|
||||
:class="attrs.class as string"
|
||||
:mobile
|
||||
:src="output.url"
|
||||
/>
|
||||
<VideoPreview
|
||||
v-else-if="getMediaType(output) === 'video'"
|
||||
:src="output.url"
|
||||
:class="
|
||||
cn('flex-1 object-contain md:p-3 md:contain-size', attrs.class as string)
|
||||
"
|
||||
/>
|
||||
<audio
|
||||
v-else-if="getMediaType(output) === 'audio'"
|
||||
:class="cn('m-auto w-full', attrs.class as string)"
|
||||
controls
|
||||
:src="output.url"
|
||||
/>
|
||||
<article
|
||||
v-else-if="getMediaType(output) === 'text'"
|
||||
:class="
|
||||
cn('m-auto my-12 w-full max-w-lg overflow-y-auto', attrs.class as string)
|
||||
"
|
||||
v-text="output.url"
|
||||
/>
|
||||
<Preview3d
|
||||
v-else-if="getMediaType(output) === '3d'"
|
||||
:class="attrs.class as string"
|
||||
:model-url="output.url"
|
||||
/>
|
||||
</template>
|
||||
@@ -187,7 +187,7 @@ const menuEntries = computed<MenuItem[]>(() => [
|
||||
:style="{ translate }"
|
||||
>
|
||||
<div class="absolute h-full w-screen overflow-y-auto contain-size">
|
||||
<LinearControls mobile @navigate-assets="activeIndex = 2" />
|
||||
<LinearControls mobile @navigate-outputs="activeIndex = 1" />
|
||||
</div>
|
||||
<div
|
||||
class="absolute top-0 left-[100vw] flex h-full w-screen flex-col bg-base-background"
|
||||
|
||||
@@ -42,7 +42,7 @@ const queueCount = computed(
|
||||
|
||||
const itemClass = cn(
|
||||
'shrink-0 cursor-pointer rounded-lg border-2 border-transparent p-1 outline-none',
|
||||
'data-[state=checked]:border-interface-panel-job-progress-border'
|
||||
'relative data-[state=checked]:border-interface-panel-job-progress-border'
|
||||
)
|
||||
|
||||
const hasActiveContent = computed(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Loader from '@/components/common/Loader.vue'
|
||||
import Loader from '@/components/loader/Loader.vue'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import VideoPlayOverlay from '@/platform/assets/components/VideoPlayOverlay.vue'
|
||||
|
||||
const { output } = defineProps<{
|
||||
output: ResultItemImpl
|
||||
}>()
|
||||
@@ -19,6 +21,16 @@ const { output } = defineProps<{
|
||||
height="40"
|
||||
:src="output.url"
|
||||
/>
|
||||
<template v-else-if="getMediaType(output) === 'video'">
|
||||
<video
|
||||
class="pointer-events-none block size-10 rounded-sm bg-secondary-background object-cover"
|
||||
preload="metadata"
|
||||
width="40"
|
||||
height="40"
|
||||
:src="output.url"
|
||||
/>
|
||||
<VideoPlayOverlay size="sm" />
|
||||
</template>
|
||||
<i
|
||||
v-else
|
||||
:class="cn(mediaTypes[getMediaType(output)]?.iconClass, 'block size-10')"
|
||||
|
||||
@@ -52,7 +52,9 @@
|
||||
>
|
||||
<AppOutput
|
||||
v-if="
|
||||
lgraphNode?.constructor?.nodeData?.output_node && isSelectOutputsMode
|
||||
lgraphNode?.constructor?.nodeData?.output_node &&
|
||||
isSelectOutputsMode &&
|
||||
nodeData.mode === LGraphEventMode.ALWAYS
|
||||
"
|
||||
:id="nodeData.id"
|
||||
/>
|
||||
|
||||
@@ -53,7 +53,11 @@
|
||||
/>
|
||||
</div>
|
||||
<!-- Widget Component -->
|
||||
<AppInput :id="widget.id" :name="widget.name" :is-select-inputs-mode>
|
||||
<AppInput
|
||||
:id="widget.id"
|
||||
:name="widget.name"
|
||||
:enable="canSelectInputs && !widget.simplified.options?.disabled"
|
||||
>
|
||||
<component
|
||||
:is="widget.vueComponent"
|
||||
v-model="widget.value"
|
||||
@@ -89,6 +93,7 @@ import { useAppMode } from '@/composables/useAppMode'
|
||||
import { showNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { st } from '@/i18n'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
@@ -154,6 +159,9 @@ onErrorCaptured((error) => {
|
||||
return false
|
||||
})
|
||||
|
||||
const canSelectInputs = computed(
|
||||
() => isSelectInputsMode.value && nodeData?.mode === LGraphEventMode.ALWAYS
|
||||
)
|
||||
const nodeType = computed(() => nodeData?.type || '')
|
||||
const settingStore = useSettingStore()
|
||||
const showAdvanced = computed(
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
import InputText from 'primevue/inputtext'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Loader from '@/components/common/Loader.vue'
|
||||
import Loader from '@/components/loader/Loader.vue'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import {
|
||||
|
||||
@@ -670,20 +670,6 @@ export class ComfyApp {
|
||||
e.stopImmediatePropagation()
|
||||
return
|
||||
}
|
||||
|
||||
// Ctrl+C Copy
|
||||
if (e.key === 'c' && (e.metaKey || e.ctrlKey)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Ctrl+V Paste
|
||||
if (
|
||||
(e.key === 'v' || e.key == 'V') &&
|
||||
(e.metaKey || e.ctrlKey) &&
|
||||
!e.shiftKey
|
||||
) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Fall through to Litegraph defaults
|
||||
|
||||
@@ -245,6 +245,13 @@ class ComfyList {
|
||||
this._reverse = reverse || false
|
||||
this.element = $el('div.comfy-list') as HTMLDivElement
|
||||
this.element.style.display = 'none'
|
||||
|
||||
console.warn(
|
||||
'[ComfyUI] The legacy queue/history menu is deprecated. ' +
|
||||
'Core functionality in this menu may break at any time. ' +
|
||||
'Issues and feature requests related to the legacy menu will not be addressed. ' +
|
||||
'To switch to the new menu: Settings → search "Use new menu" → change from "Disabled" to "Top".'
|
||||
)
|
||||
}
|
||||
|
||||
get visible() {
|
||||
|
||||
@@ -314,18 +314,22 @@ export class DraggableList extends EventTarget {
|
||||
unsetDraggableItem() {
|
||||
this.draggableItem.style = null
|
||||
this.draggableItem.classList.remove('is-draggable')
|
||||
this.draggableItem.classList.add('is-idle')
|
||||
this.draggableItem = null
|
||||
}
|
||||
|
||||
unsetItemState() {
|
||||
this.getIdleItems().forEach((item) => {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
this.getIdleItems().forEach((item: HTMLElement) => {
|
||||
delete item.dataset.isAbove
|
||||
// @ts-expect-error fixme ts strict error
|
||||
delete item.dataset.isToggled
|
||||
// @ts-expect-error fixme ts strict error
|
||||
item.style.transform = ''
|
||||
|
||||
// Defer re-adding is-idle (which enables CSS transitions) until after
|
||||
// the browser paints items in their final positions. Without this,
|
||||
// the transition animates the stale drag transform.
|
||||
item.classList.remove('is-idle')
|
||||
requestAnimationFrame(() => {
|
||||
item.classList.add('is-idle')
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { breakpointsTailwind, unrefElement, useBreakpoints } from '@vueuse/core'
|
||||
import type { MaybeElement } from '@vueuse/core'
|
||||
import Splitter from 'primevue/splitter'
|
||||
import SplitterPanel from 'primevue/splitterpanel'
|
||||
import { storeToRefs } from 'pinia'
|
||||
@@ -19,6 +20,7 @@ import LinearProgressBar from '@/renderer/extensions/linearMode/LinearProgressBa
|
||||
import MobileDisplay from '@/renderer/extensions/linearMode/MobileDisplay.vue'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useStablePrimeVueSplitterSizer } from '@/composables/useStablePrimeVueSplitterSizer'
|
||||
import {
|
||||
BUILDER_MIN_SIZE,
|
||||
CENTER_PANEL_SIZE,
|
||||
@@ -39,9 +41,6 @@ const activeTab = computed(() => workspaceStore.sidebarTab.activeSidebarTab)
|
||||
const sidebarOnLeft = computed(
|
||||
() => settingStore.get('Comfy.Sidebar.Location') === 'left'
|
||||
)
|
||||
// Builder panel is always on the opposite side of the sidebar.
|
||||
// In arrange mode we render 3 panels to match the overlay structure,
|
||||
// so the same stateKey percentage maps to the same pixel width.
|
||||
const showLeftBuilder = computed(
|
||||
() => !sidebarOnLeft.value && isArrangeMode.value
|
||||
)
|
||||
@@ -67,6 +66,25 @@ function sidePanelMinSize(isBuilder: boolean, isHidden: boolean) {
|
||||
return SIDEBAR_MIN_SIZE
|
||||
}
|
||||
|
||||
// Remount splitter when panel structure changes so initializePanels()
|
||||
// properly sets flexBasis for the current set of panels.
|
||||
const splitterKey = computed(() => {
|
||||
const left = hasLeftPanel.value ? 'L' : ''
|
||||
const right = hasRightPanel.value ? 'R' : ''
|
||||
return isArrangeMode.value ? 'arrange' : `app-${left}${right}`
|
||||
})
|
||||
|
||||
const leftPanelRef = useTemplateRef<MaybeElement>('leftPanel')
|
||||
const rightPanelRef = useTemplateRef<MaybeElement>('rightPanel')
|
||||
|
||||
const { onResizeEnd } = useStablePrimeVueSplitterSizer(
|
||||
[
|
||||
{ ref: leftPanelRef, storageKey: 'Comfy.LinearView.LeftPanelWidth' },
|
||||
{ ref: rightPanelRef, storageKey: 'Comfy.LinearView.RightPanelWidth' }
|
||||
],
|
||||
[activeTab, splitterKey]
|
||||
)
|
||||
|
||||
const TYPEFORM_WIDGET_ID = 'gmVqFi8l'
|
||||
|
||||
const bottomLeftRef = useTemplateRef('bottomLeftRef')
|
||||
@@ -86,16 +104,15 @@ const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
|
||||
</div>
|
||||
</div>
|
||||
<Splitter
|
||||
:key="isArrangeMode ? 'arrange' : 'normal'"
|
||||
:key="splitterKey"
|
||||
class="bg-comfy-menu-secondary-bg h-[calc(100%-var(--workflow-tabs-height))] w-full border-none"
|
||||
:state-key="isArrangeMode ? 'builder-splitter' : undefined"
|
||||
:state-storage="isArrangeMode ? 'local' : undefined"
|
||||
@resizestart="({ originalEvent }) => originalEvent.preventDefault()"
|
||||
@resizestart="$event.originalEvent.preventDefault()"
|
||||
@resizeend="onResizeEnd"
|
||||
>
|
||||
<SplitterPanel
|
||||
v-if="hasLeftPanel"
|
||||
id="linearLeftPanel"
|
||||
:size="isArrangeMode ? SIDE_PANEL_SIZE : 1"
|
||||
ref="leftPanel"
|
||||
:size="SIDE_PANEL_SIZE"
|
||||
:min-size="
|
||||
sidePanelMinSize(showLeftBuilder, showRightBuilder && !activeTab)
|
||||
"
|
||||
@@ -104,17 +121,15 @@ const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
|
||||
"
|
||||
:class="
|
||||
cn(
|
||||
'arrange-panel outline-none',
|
||||
showLeftBuilder ? 'min-w-78 bg-comfy-menu-bg' : 'min-w-min'
|
||||
'arrange-panel overflow-hidden outline-none',
|
||||
showLeftBuilder ? 'min-w-78 bg-comfy-menu-bg' : 'min-w-78'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div v-if="showLeftBuilder" class="h-full overflow-y-auto">
|
||||
<AppBuilder />
|
||||
</div>
|
||||
<AppBuilder v-if="showLeftBuilder" />
|
||||
<div
|
||||
v-else-if="sidebarOnLeft && activeTab"
|
||||
class="flex h-full border-r border-border-subtle"
|
||||
class="size-full overflow-x-hidden border-r border-border-subtle"
|
||||
>
|
||||
<ExtensionSlot :extension="activeTab" />
|
||||
</div>
|
||||
@@ -126,8 +141,8 @@ const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
|
||||
</SplitterPanel>
|
||||
<SplitterPanel
|
||||
id="linearCenterPanel"
|
||||
:size="isArrangeMode ? CENTER_PANEL_SIZE : 98"
|
||||
class="relative flex min-w-0 flex-col gap-4 text-muted-foreground outline-none"
|
||||
:size="CENTER_PANEL_SIZE"
|
||||
class="relative flex min-w-[20vw] flex-col gap-4 text-muted-foreground outline-none"
|
||||
>
|
||||
<LinearProgressBar
|
||||
class="absolute top-0 left-0 z-21 w-[calc(100%+16px)]"
|
||||
@@ -144,22 +159,20 @@ const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
|
||||
</SplitterPanel>
|
||||
<SplitterPanel
|
||||
v-if="hasRightPanel"
|
||||
id="linearRightPanel"
|
||||
:size="isArrangeMode ? SIDE_PANEL_SIZE : 1"
|
||||
ref="rightPanel"
|
||||
:size="SIDE_PANEL_SIZE"
|
||||
:min-size="
|
||||
sidePanelMinSize(showRightBuilder, showLeftBuilder && !activeTab)
|
||||
"
|
||||
:style="showLeftBuilder && !activeTab ? { display: 'none' } : undefined"
|
||||
:class="
|
||||
cn(
|
||||
'arrange-panel outline-none',
|
||||
showRightBuilder ? 'min-w-78 bg-comfy-menu-bg' : 'min-w-min'
|
||||
'arrange-panel overflow-hidden outline-none',
|
||||
showRightBuilder ? 'min-w-78 bg-comfy-menu-bg' : 'min-w-78'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div v-if="showRightBuilder" class="h-full overflow-y-auto">
|
||||
<AppBuilder />
|
||||
</div>
|
||||
<AppBuilder v-if="showRightBuilder" />
|
||||
<LinearControls
|
||||
v-else-if="sidebarOnLeft && !isArrangeMode"
|
||||
ref="linearWorkflowRef"
|
||||
@@ -167,7 +180,7 @@ const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
|
||||
/>
|
||||
<div
|
||||
v-else-if="activeTab"
|
||||
class="flex h-full border-l border-border-subtle"
|
||||
class="h-full overflow-x-hidden border-l border-border-subtle"
|
||||
>
|
||||
<ExtensionSlot :extension="activeTab" />
|
||||
</div>
|
||||
|
||||