Compare commits
10 Commits
fix/codera
...
test-front
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26b50f484d | ||
|
|
1058b7d12d | ||
|
|
8bfd93963f | ||
|
|
3366079f59 | ||
|
|
c4dabb8f98 | ||
|
|
0b73285ca1 | ||
|
|
7a01be388f | ||
|
|
3ddff9f7b6 | ||
|
|
4ff14b5eb9 | ||
|
|
bae1081a08 |
@@ -36,14 +36,7 @@
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple",
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.65",
|
||||
"models": [
|
||||
{
|
||||
"name": "v1-5-pruned-emaonly-fp16.safetensors",
|
||||
"url": "https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly-fp16.safetensors?download=true",
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
]
|
||||
"ver": "0.3.65"
|
||||
},
|
||||
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
|
||||
},
|
||||
|
||||
@@ -432,7 +432,10 @@ export const comfyPageFixture = base.extend<{
|
||||
'Comfy.VueNodes.AutoScaleLayout': false,
|
||||
// Disable toast warning about version compatibility, as they may or
|
||||
// may not appear - depending on upstream ComfyUI dependencies
|
||||
'Comfy.VersionCompatibility.DisableWarnings': true
|
||||
'Comfy.VersionCompatibility.DisableWarnings': true,
|
||||
// Browser tests should opt into missing-model warnings explicitly so
|
||||
// workflows do not render differently based on models present on disk.
|
||||
'Comfy.Workflow.ShowMissingModelsWarning': false
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
|
||||
@@ -172,6 +172,19 @@ export class VueNodeHelpers {
|
||||
async enterSubgraph(nodeId?: string): Promise<void> {
|
||||
const locator = nodeId ? this.getNodeLocator(nodeId) : this.page
|
||||
const editButton = locator.getByTestId(TestIds.widgets.subgraphEnterButton)
|
||||
await editButton.click()
|
||||
|
||||
// The footer tab button extends below the node body (visible area),
|
||||
// but its bounding box center overlaps the node body div.
|
||||
// Click at the bottom 25% of the button which is the genuinely visible
|
||||
// and unobstructed area outside the node body boundary.
|
||||
const box = await editButton.boundingBox()
|
||||
if (!box) {
|
||||
throw new Error(
|
||||
'subgraph-enter-button has no bounding box: element may be hidden or not in DOM'
|
||||
)
|
||||
}
|
||||
await editButton.click({
|
||||
position: { x: box.width / 2, y: box.height * 0.75 }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,17 @@ test.describe('Execution error', () => {
|
||||
})
|
||||
|
||||
test.describe('Missing models warning', () => {
|
||||
test('Should be disabled by default in browser tests', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_models')
|
||||
|
||||
const dialogTitle = comfyPage.page.getByText(
|
||||
'This workflow is missing models'
|
||||
)
|
||||
await expect(dialogTitle).not.toBeVisible()
|
||||
})
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.ShowMissingModelsWarning',
|
||||
|
||||
|
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: 103 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 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: 63 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 27 KiB |
@@ -22,8 +22,10 @@ test.describe('Vue Node Bypass', () => {
|
||||
await comfyPage.page.getByText('Load Checkpoint').click()
|
||||
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
|
||||
|
||||
const checkpointNode =
|
||||
comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
|
||||
const checkpointNode = comfyPage.page
|
||||
.locator('[data-node-id]')
|
||||
.filter({ hasText: 'Load Checkpoint' })
|
||||
.getByTestId('node-inner-wrapper')
|
||||
await expect(checkpointNode).toHaveClass(BYPASS_CLASS)
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
@@ -41,8 +43,14 @@ test.describe('Vue Node Bypass', () => {
|
||||
await comfyPage.page.getByText('Load Checkpoint').click()
|
||||
await comfyPage.page.getByText('KSampler').click({ modifiers: ['Control'] })
|
||||
|
||||
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
|
||||
const ksamplerNode = comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
const checkpointNode = comfyPage.page
|
||||
.locator('[data-node-id]')
|
||||
.filter({ hasText: 'Load Checkpoint' })
|
||||
.getByTestId('node-inner-wrapper')
|
||||
const ksamplerNode = comfyPage.page
|
||||
.locator('[data-node-id]')
|
||||
.filter({ hasText: 'KSampler' })
|
||||
.getByTestId('node-inner-wrapper')
|
||||
|
||||
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
|
||||
await expect(checkpointNode).toHaveClass(BYPASS_CLASS)
|
||||
|
||||
|
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: 138 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 139 KiB |
@@ -3,7 +3,7 @@ import {
|
||||
comfyPageFixture as test
|
||||
} from '../../../fixtures/ComfyPage'
|
||||
|
||||
const ERROR_CLASS = /border-node-stroke-error/
|
||||
const ERROR_CLASS = /ring-destructive-background/
|
||||
|
||||
test.describe('Vue Node Error', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -18,9 +18,10 @@ test.describe('Vue Node Error', () => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
|
||||
|
||||
// Expect error state on missing unknown node
|
||||
const unknownNode = comfyPage.page.locator('[data-node-id]').filter({
|
||||
hasText: 'UNKNOWN NODE'
|
||||
})
|
||||
const unknownNode = comfyPage.page
|
||||
.locator('[data-node-id]')
|
||||
.filter({ hasText: 'UNKNOWN NODE' })
|
||||
.getByTestId('node-inner-wrapper')
|
||||
await expect(unknownNode).toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
|
||||
@@ -31,7 +32,10 @@ test.describe('Vue Node Error', () => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/execution_error')
|
||||
await comfyPage.runButton.click()
|
||||
|
||||
const raiseErrorNode = comfyPage.vueNodes.getNodeByTitle('Raise Error')
|
||||
const raiseErrorNode = comfyPage.page
|
||||
.locator('[data-node-id]')
|
||||
.filter({ hasText: 'Raise Error' })
|
||||
.getByTestId('node-inner-wrapper')
|
||||
await expect(raiseErrorNode).toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 107 KiB |
@@ -3,9 +3,15 @@ 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 +24,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 }
|
||||
|
||||
@@ -35,36 +43,11 @@ function openAssets() {
|
||||
function showApps() {
|
||||
void commandStore.execute('Workspace.ToggleSidebarTab.apps')
|
||||
}
|
||||
|
||||
function openTemplates() {
|
||||
useWorkflowTemplateSelectorDialog().show('sidebar')
|
||||
}
|
||||
</script>
|
||||
|
||||
<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 +64,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"
|
||||
@@ -113,19 +111,6 @@ function openTemplates() {
|
||||
>
|
||||
<i class="icon-[lucide--panels-top-left] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: t('sideToolbar.templates'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:aria-label="t('sideToolbar.templates')"
|
||||
class="size-10"
|
||||
@click="openTemplates"
|
||||
>
|
||||
<i class="icon-[comfy--template] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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,152 @@ 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 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>
|
||||
<div class="flex min-h-0 flex-1 flex-col 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"
|
||||
: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="p-4 text-muted-foreground"
|
||||
v-text="t('linearMode.builder.promptAddInputs')"
|
||||
/>
|
||||
</template>
|
||||
<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>
|
||||
<div
|
||||
v-if="isSelectInputsMode && !appModeStore.selectedInputs.length"
|
||||
class="m-4 flex flex-1 items-center justify-center rounded-lg border-2 border-dashed border-primary-background bg-primary-background/20 text-center text-sm text-primary-background"
|
||||
>
|
||||
{{ t('linearMode.builder.inputPlaceholder') }}
|
||||
</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>
|
||||
<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="p-4 text-muted-foreground"
|
||||
v-text="t('linearMode.builder.promptAddOutputs')"
|
||||
/>
|
||||
</template>
|
||||
<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
|
||||
v-if="isSelectOutputsMode && !appModeStore.selectedOutputs.length"
|
||||
class="m-4 flex flex-1 flex-col items-center justify-center gap-1 rounded-lg border-2 border-dashed border-warning-background bg-warning-background/20 text-center text-sm text-warning-background"
|
||||
>
|
||||
{{ t('linearMode.builder.outputPlaceholder') }}
|
||||
<span class="font-bold">
|
||||
{{ t('linearMode.builder.outputRequiredPlaceholder') }}
|
||||
</span>
|
||||
</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>
|
||||
|
||||
<Teleport
|
||||
v-if="isSelectMode && !settingStore.get('Comfy.VueNodes.Enabled')"
|
||||
|
||||
@@ -38,8 +38,8 @@
|
||||
<Button variant="muted-textonly" size="lg" @click="$emit('viewApp')">
|
||||
{{ $t('builderToolbar.viewApp') }}
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" @click="$emit('close')">
|
||||
{{ $t('g.close') }}
|
||||
<Button variant="secondary" size="lg" @click="$emit('exitToWorkflow')">
|
||||
{{ $t('builderToolbar.exitToWorkflow') }}
|
||||
</Button>
|
||||
</template>
|
||||
</template>
|
||||
@@ -58,5 +58,6 @@ defineProps<{
|
||||
defineEmits<{
|
||||
viewApp: []
|
||||
close: []
|
||||
exitToWorkflow: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -58,6 +58,6 @@ useEventListener(window, 'keydown', (e: KeyboardEvent) => {
|
||||
})
|
||||
|
||||
function onExitBuilder() {
|
||||
void appModeStore.exitBuilder()
|
||||
appModeStore.exitBuilder()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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,8 +88,13 @@ async function onSave(close: () => void) {
|
||||
}
|
||||
}
|
||||
|
||||
function onEnterAppMode(close: () => void) {
|
||||
setMode('app')
|
||||
close()
|
||||
}
|
||||
|
||||
function onExitBuilder(close: () => void) {
|
||||
void appModeStore.exitBuilder()
|
||||
appModeStore.exitBuilder()
|
||||
close()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -22,6 +22,10 @@ const mockApp = vi.hoisted(() => ({
|
||||
|
||||
const mockSetMode = vi.hoisted(() => vi.fn())
|
||||
|
||||
const mockAppModeStore = vi.hoisted(() => ({
|
||||
exitBuilder: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => mockDialogService
|
||||
}))
|
||||
@@ -42,6 +46,10 @@ vi.mock('@/composables/useAppMode', () => ({
|
||||
useAppMode: () => ({ setMode: mockSetMode })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/appModeStore', () => ({
|
||||
useAppModeStore: () => mockAppModeStore
|
||||
}))
|
||||
|
||||
vi.mock('./DefaultViewDialogContent.vue', () => ({
|
||||
default: { name: 'MockDefaultViewDialogContent' }
|
||||
}))
|
||||
@@ -208,6 +216,16 @@ describe('useAppSetDefaultView', () => {
|
||||
expect(mockSetMode).toHaveBeenCalledWith('app')
|
||||
})
|
||||
|
||||
it('onExitToWorkflow exits builder and closes dialog', () => {
|
||||
const confirmCall = applyAndGetConfirmDialog(true)
|
||||
confirmCall.props.onExitToWorkflow()
|
||||
|
||||
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
|
||||
key: 'builder-default-view-applied'
|
||||
})
|
||||
expect(mockAppModeStore.exitBuilder).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('onClose closes confirmation dialog', () => {
|
||||
const confirmCall = applyAndGetConfirmDialog(true)
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
import BuilderDefaultModeAppliedDialogContent from './BuilderDefaultModeAppliedDialogContent.vue'
|
||||
import DefaultViewDialogContent from './DefaultViewDialogContent.vue'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
|
||||
const DIALOG_KEY = 'builder-default-view'
|
||||
const APPLIED_DIALOG_KEY = 'builder-default-view-applied'
|
||||
@@ -16,6 +17,7 @@ export function useAppSetDefaultView() {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const dialogService = useDialogService()
|
||||
const dialogStore = useDialogStore()
|
||||
const appModeStore = useAppModeStore()
|
||||
const { setMode } = useAppMode()
|
||||
|
||||
const settingView = computed(() => dialogStore.isDialogOpen(DIALOG_KEY))
|
||||
@@ -54,6 +56,10 @@ export function useAppSetDefaultView() {
|
||||
closeAppliedDialog()
|
||||
setMode('app')
|
||||
},
|
||||
onExitToWorkflow: () => {
|
||||
closeAppliedDialog()
|
||||
appModeStore.exitBuilder()
|
||||
},
|
||||
onClose: closeAppliedDialog
|
||||
}
|
||||
})
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
:variant="buttonVariant ?? 'textonly'"
|
||||
@click="$emit('action')"
|
||||
>
|
||||
<i v-if="buttonIcon" :class="buttonIcon" />
|
||||
{{ buttonLabel }}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -37,6 +38,7 @@ const props = defineProps<{
|
||||
title?: string
|
||||
message: string
|
||||
textClass?: string
|
||||
buttonIcon?: string
|
||||
buttonLabel?: string
|
||||
buttonVariant?: ButtonVariants['variant']
|
||||
}>()
|
||||
|
||||
@@ -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,48 @@ function handleOpen(open: boolean) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function toggleLinearMode() {
|
||||
dropdownOpen.value = false
|
||||
void useCommandStore().execute('Comfy.ToggleLinear', {
|
||||
metadata: { source }
|
||||
})
|
||||
}
|
||||
|
||||
const tooltipPt = {
|
||||
root: {
|
||||
style: { transform: 'translateX(calc(50% - 16px))' }
|
||||
},
|
||||
arrow: {
|
||||
class: '!left-[16px]'
|
||||
}
|
||||
}
|
||||
</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'),
|
||||
v-tooltip.bottom="{
|
||||
value: canvasStore.linearMode
|
||||
? t('breadcrumbsMenu.enterNodeGraph')
|
||||
: t('breadcrumbsMenu.enterAppMode'),
|
||||
showDelay: 300,
|
||||
hideDelay: 300
|
||||
hideDelay: 300,
|
||||
pt: tooltipPt
|
||||
}"
|
||||
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 +93,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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div
|
||||
class="flex flex-col border-t border-border-default px-4 py-2 text-sm wrap-break-word text-muted-foreground"
|
||||
>
|
||||
<p v-if="promptTextReal">
|
||||
<p v-if="promptTextReal" :class="preserveNewlines && 'whitespace-pre-line'">
|
||||
{{ promptTextReal }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -11,8 +11,9 @@
|
||||
import { computed, toValue } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
const { promptText } = defineProps<{
|
||||
const { promptText, preserveNewlines = false } = defineProps<{
|
||||
promptText?: MaybeRefOrGetter<string>
|
||||
preserveNewlines?: boolean
|
||||
}>()
|
||||
|
||||
const promptTextReal = computed(() => toValue(promptText))
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
</Button>
|
||||
<Button
|
||||
:disabled
|
||||
variant="textonly"
|
||||
:variant="confirmVariant ?? 'textonly'"
|
||||
:class="confirmClass"
|
||||
@click="$emit('confirm')"
|
||||
>
|
||||
@@ -19,13 +19,21 @@ import type { MaybeRefOrGetter } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { ButtonVariants } from '@/components/ui/button/button.variants'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { cancelText, confirmText, confirmClass, optionsDisabled } = defineProps<{
|
||||
const {
|
||||
cancelText,
|
||||
confirmText,
|
||||
confirmClass,
|
||||
confirmVariant,
|
||||
optionsDisabled
|
||||
} = defineProps<{
|
||||
cancelText?: string
|
||||
confirmText?: string
|
||||
confirmClass?: string
|
||||
confirmVariant?: ButtonVariants['variant']
|
||||
optionsDisabled?: MaybeRefOrGetter<boolean>
|
||||
}>()
|
||||
|
||||
|
||||
@@ -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,13 @@
|
||||
<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-icon="icon-[lucide--hammer]"
|
||||
:button-label="isAppMode ? undefined : $t('linearMode.buildAnApp')"
|
||||
@action="enterAppMode"
|
||||
/>
|
||||
</template>
|
||||
@@ -32,16 +36,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,
|
||||
|
||||
@@ -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']>
|
||||
|
||||
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 }
|
||||
}
|
||||
@@ -40,12 +40,21 @@ const mockMenuItemStore = vi.hoisted(() => ({
|
||||
hasSeenLinear: false
|
||||
}))
|
||||
|
||||
const mockCanvasStore = vi.hoisted(() => ({
|
||||
linearMode: false
|
||||
}))
|
||||
|
||||
const mockAppModeStore = vi.hoisted(() => ({
|
||||
enterBuilder: vi.fn()
|
||||
enterBuilder: vi.fn(),
|
||||
pruneLinearData: vi.fn(
|
||||
(
|
||||
data?: Partial<{
|
||||
inputs: [number | string, string][]
|
||||
outputs: (number | string)[]
|
||||
}>
|
||||
) => ({
|
||||
inputs: data?.inputs ?? [],
|
||||
outputs: data?.outputs ?? []
|
||||
})
|
||||
),
|
||||
selectedInputs: [] as [number | string, string][],
|
||||
selectedOutputs: [] as (number | string)[]
|
||||
}))
|
||||
|
||||
const mockFeatureFlags = vi.hoisted(() => ({
|
||||
@@ -73,14 +82,12 @@ vi.mock('@/stores/menuItemStore', () => ({
|
||||
useMenuItemStore: vi.fn(() => mockMenuItemStore)
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: vi.fn(() => mockCanvasStore)
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/appModeStore', () => ({
|
||||
useAppModeStore: vi.fn(() => mockAppModeStore)
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useErrorHandling', () => ({}))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: vi.fn(() => mockFeatureFlags)
|
||||
}))
|
||||
@@ -110,8 +117,9 @@ describe('useWorkflowActionsMenu', () => {
|
||||
mockBookmarkStore.isBookmarked.mockReturnValue(false)
|
||||
mockSubgraphStore.isSubgraphBlueprint.mockReturnValue(false)
|
||||
mockMenuItemStore.hasSeenLinear = false
|
||||
mockCanvasStore.linearMode = false
|
||||
mockFeatureFlags.flags.linearToggleEnabled = false
|
||||
mockAppModeStore.selectedInputs.length = 0
|
||||
mockAppModeStore.selectedOutputs.length = 0
|
||||
mockWorkflowStore.activeWorkflow = {
|
||||
path: 'test.json',
|
||||
isPersisted: true
|
||||
@@ -192,7 +200,11 @@ describe('useWorkflowActionsMenu', () => {
|
||||
|
||||
it('shows "go to workflow mode" when in linear mode', () => {
|
||||
mockFeatureFlags.flags.linearToggleEnabled = true
|
||||
mockCanvasStore.linearMode = true
|
||||
mockWorkflowStore.activeWorkflow = {
|
||||
path: 'test.json',
|
||||
isPersisted: true,
|
||||
activeMode: 'app'
|
||||
} as ComfyWorkflow
|
||||
|
||||
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
|
||||
const labels = menuLabels(menuItems.value)
|
||||
@@ -310,6 +322,22 @@ describe('useWorkflowActionsMenu', () => {
|
||||
expect(mockAppModeStore.enterBuilder).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows "Edit app" when workflow has linear data', async () => {
|
||||
mockFeatureFlags.flags.linearToggleEnabled = true
|
||||
mockWorkflowStore.activeWorkflow = {
|
||||
path: 'test.json',
|
||||
isPersisted: true
|
||||
} as ComfyWorkflow
|
||||
mockAppModeStore.selectedInputs.push([1, 'widget'])
|
||||
mockAppModeStore.selectedOutputs.push(2)
|
||||
|
||||
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
|
||||
const item = findItem(menuItems.value, 'breadcrumbsMenu.editBuilderMode')
|
||||
|
||||
expect(item).toBeDefined()
|
||||
expect(item.isNew).toBeTruthy()
|
||||
})
|
||||
|
||||
it('app mode toggle executes Comfy.ToggleLinear', async () => {
|
||||
mockFeatureFlags.flags.linearToggleEnabled = true
|
||||
|
||||
|
||||
@@ -2,14 +2,16 @@ 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 {
|
||||
useWorkflowBookmarkStore,
|
||||
useWorkflowStore
|
||||
} from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useMenuItemStore } from '@/stores/menuItemStore'
|
||||
import { useSubgraphStore } from '@/stores/subgraphStore'
|
||||
@@ -51,9 +53,9 @@ export function useWorkflowActionsMenu(
|
||||
const commandStore = useCommandStore()
|
||||
const subgraphStore = useSubgraphStore()
|
||||
const menuItemStore = useMenuItemStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const { flags } = useFeatureFlags()
|
||||
const { enterBuilder } = useAppModeStore()
|
||||
const appModeStore = useAppModeStore()
|
||||
const { enterBuilder, pruneLinearData } = appModeStore
|
||||
|
||||
const targetWorkflow = computed(
|
||||
() => workflow?.value ?? workflowStore.activeWorkflow
|
||||
@@ -93,12 +95,15 @@ export function useWorkflowActionsMenu(
|
||||
items.push(item)
|
||||
}
|
||||
|
||||
const isLinearMode = canvasStore.linearMode
|
||||
const workflowMode =
|
||||
workflow?.activeMode ?? workflow?.initialMode ?? 'graph'
|
||||
const isLinearMode = workflowMode === 'app'
|
||||
const showAppModeItems =
|
||||
isRoot && (menuItemStore.hasSeenLinear || flags.linearToggleEnabled)
|
||||
const isBookmarked = bookmarkStore.isBookmarked(workflow?.path ?? '')
|
||||
|
||||
const toggleLinear = async () => {
|
||||
await ensureWorkflowActive(targetWorkflow.value)
|
||||
await commandStore.execute('Comfy.ToggleLinear', {
|
||||
metadata: { source: 'breadcrumb_menu' }
|
||||
})
|
||||
@@ -191,8 +196,9 @@ export function useWorkflowActionsMenu(
|
||||
id: 'share',
|
||||
label: t('breadcrumbsMenu.share'),
|
||||
icon: 'icon-[comfy--send]',
|
||||
command: async () => {},
|
||||
visible: false
|
||||
command: () =>
|
||||
openShareDialog().catch(useErrorHandling().toastErrorHandler),
|
||||
visible: isCloud && flags.workflowSharingEnabled
|
||||
})
|
||||
|
||||
addItem({
|
||||
@@ -214,11 +220,31 @@ export function useWorkflowActionsMenu(
|
||||
prependSeparator: true
|
||||
})
|
||||
|
||||
const isActive = workflow === workflowStore.activeWorkflow
|
||||
const rawLd = isActive
|
||||
? {
|
||||
inputs: appModeStore.selectedInputs,
|
||||
outputs: appModeStore.selectedOutputs
|
||||
}
|
||||
: workflow?.changeTracker?.activeState?.extra?.linearData
|
||||
let hasLinearData: boolean
|
||||
if (rawLd) {
|
||||
const { inputs, outputs } = pruneLinearData(rawLd)
|
||||
hasLinearData = inputs.length > 0 || outputs.length > 0
|
||||
} else {
|
||||
hasLinearData = workflow?.path?.endsWith('.app.json') ?? false
|
||||
}
|
||||
|
||||
addItem({
|
||||
id: 'enter-builder-mode',
|
||||
label: t('breadcrumbsMenu.enterBuilderMode'),
|
||||
label: hasLinearData
|
||||
? t('breadcrumbsMenu.editBuilderMode')
|
||||
: t('breadcrumbsMenu.enterBuilderMode'),
|
||||
icon: 'icon-[lucide--hammer]',
|
||||
command: () => enterBuilder(),
|
||||
command: async () => {
|
||||
await ensureWorkflowActive(targetWorkflow.value)
|
||||
enterBuilder()
|
||||
},
|
||||
visible: showAppModeItems,
|
||||
isNew: true
|
||||
})
|
||||
|
||||
@@ -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]))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -77,6 +77,7 @@ import type {
|
||||
SerialisableReroute
|
||||
} from './types/serialisation'
|
||||
import { getAllNestedItems } from './utils/collections'
|
||||
import { deduplicateSubgraphNodeIds } from './utils/subgraphDeduplication'
|
||||
|
||||
export type {
|
||||
LGraphTriggerAction,
|
||||
@@ -2475,19 +2476,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
|
||||
@@ -2495,8 +2517,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) {
|
||||
|
||||
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
|
||||
@@ -649,49 +649,52 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
}
|
||||
|
||||
this._addSubgraphInputListeners(subgraphInput, input)
|
||||
|
||||
// Find the first widget that this slot is connected to
|
||||
for (const linkId of subgraphInput.linkIds) {
|
||||
const link = this.subgraph.getLink(linkId)
|
||||
if (!link) {
|
||||
console.warn(
|
||||
`[SubgraphNode.configure] No link found for link ID ${linkId}`,
|
||||
this
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const { inputNode } = link.resolve(this.subgraph)
|
||||
if (!inputNode) {
|
||||
console.warn('Failed to resolve inputNode', link, this)
|
||||
continue
|
||||
}
|
||||
|
||||
//Manually find input since target_slot can't be trusted
|
||||
const targetInput = inputNode.inputs.find((inp) => inp.link === linkId)
|
||||
if (!targetInput) {
|
||||
console.warn('Failed to find corresponding input', link, inputNode)
|
||||
continue
|
||||
}
|
||||
|
||||
// No widget - ignore this link
|
||||
const widget = inputNode.getWidgetFromSlot(targetInput)
|
||||
if (!widget) continue
|
||||
|
||||
this._setWidget(
|
||||
subgraphInput,
|
||||
input,
|
||||
widget,
|
||||
targetInput.widget,
|
||||
inputNode
|
||||
)
|
||||
break
|
||||
}
|
||||
this._resolveInputWidget(subgraphInput, input)
|
||||
}
|
||||
|
||||
this._syncPromotions()
|
||||
}
|
||||
|
||||
private _resolveInputWidget(
|
||||
subgraphInput: SubgraphInput,
|
||||
input: INodeInputSlot
|
||||
) {
|
||||
for (const linkId of subgraphInput.linkIds) {
|
||||
const link = this.subgraph.getLink(linkId)
|
||||
if (!link) {
|
||||
console.warn(
|
||||
`[SubgraphNode.configure] No link found for link ID ${linkId}`,
|
||||
this
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const { inputNode } = link.resolve(this.subgraph)
|
||||
if (!inputNode) {
|
||||
console.warn('Failed to resolve inputNode', link, this)
|
||||
continue
|
||||
}
|
||||
|
||||
const targetInput = inputNode.inputs.find((inp) => inp.link === linkId)
|
||||
if (!targetInput) {
|
||||
console.warn('Failed to find corresponding input', link, inputNode)
|
||||
continue
|
||||
}
|
||||
|
||||
const widget = inputNode.getWidgetFromSlot(targetInput)
|
||||
if (!widget) continue
|
||||
|
||||
this._setWidget(
|
||||
subgraphInput,
|
||||
input,
|
||||
widget,
|
||||
targetInput.widget,
|
||||
inputNode
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private _setWidget(
|
||||
subgraphInput: Readonly<SubgraphInput>,
|
||||
input: INodeInputSlot,
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1330,6 +1330,7 @@
|
||||
"Rename": "Rename",
|
||||
"Save": "Save",
|
||||
"Save As": "Save As",
|
||||
"Share": "Share",
|
||||
"Show Settings Dialog": "Show Settings Dialog",
|
||||
"Set Subgraph Description": "Set Subgraph Description",
|
||||
"Set Subgraph Search Aliases": "Set Subgraph Search Aliases",
|
||||
@@ -2024,7 +2025,7 @@
|
||||
"whitelistInfo": "About non-whitelisted sites"
|
||||
},
|
||||
"login": {
|
||||
"title": "Log in to your account",
|
||||
"title": "Welcome back! Log in to your account",
|
||||
"useApiKey": "Comfy API Key",
|
||||
"signInOrSignUp": "Sign In / Sign Up",
|
||||
"forgotPasswordError": "Failed to send password reset email",
|
||||
@@ -2081,7 +2082,7 @@
|
||||
"emailNotEligibleForFreeTier": "Email sign-up is not eligible for Free Tier."
|
||||
},
|
||||
"signOut": {
|
||||
"signOut": "Log Out",
|
||||
"signOut": "Sign Out",
|
||||
"success": "Signed out successfully",
|
||||
"successDetail": "You have been signed out of your account.",
|
||||
"unsavedChangesTitle": "Unsaved Changes",
|
||||
@@ -2598,13 +2599,17 @@
|
||||
"duplicate": "Duplicate",
|
||||
"enterAppMode": "Enter app mode",
|
||||
"exitAppMode": "Exit app mode",
|
||||
"enterBuilderMode": "App builder",
|
||||
"enterBuilderMode": "Build app",
|
||||
"editBuilderMode": "Edit app",
|
||||
"workflowActions": "Workflow actions",
|
||||
"clearWorkflow": "Clear Workflow",
|
||||
"deleteWorkflow": "Delete Workflow",
|
||||
"deleteBlueprint": "Delete Blueprint",
|
||||
"enterNewName": "Enter new name",
|
||||
"missingNodesWarning": "Workflow contains unsupported nodes (highlighted red).",
|
||||
"graph": "Graph",
|
||||
"app": "App",
|
||||
"enterNodeGraph": "Enter node graph",
|
||||
"share": "Share"
|
||||
},
|
||||
"shortcuts": {
|
||||
@@ -3004,6 +3009,11 @@
|
||||
"share": "Share",
|
||||
"shareTooltip": "Share workflow"
|
||||
},
|
||||
"shareNoOutputs": {
|
||||
"title": "App has no outputs",
|
||||
"message": "You're about to share an app without outputs. It can't be used until an output is connected.\n\nShare anyway?",
|
||||
"shareAnyway": "Share anyway"
|
||||
},
|
||||
"shareWorkflow": {
|
||||
"shareLinkTab": "Share",
|
||||
"publishToHubTab": "Publish",
|
||||
@@ -3153,6 +3163,7 @@
|
||||
"linearMode": {
|
||||
"linearMode": "App Mode",
|
||||
"beta": "App mode in beta",
|
||||
"buildAnApp": "Build an app",
|
||||
"giveFeedback": "Give feedback",
|
||||
"graphMode": "Graph Mode",
|
||||
"dragAndDropImage": "Click to browse or drag an image",
|
||||
@@ -3160,25 +3171,28 @@
|
||||
"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.",
|
||||
"controls": "Your outputs appear at the bottom, your controls are on the right. Everything else stays out of the way.",
|
||||
"sharing": "Share your workflow as a simple tool anyone can use. Export it from the tab menu and when others open it, they'll see App Mode. No node graph knowledge needed.",
|
||||
"getStarted": "Click {runButton} to get started.",
|
||||
"buildApp": "Build app"
|
||||
"buildApp": "Build app",
|
||||
"noOutputs": "An app needs at least {count} to be usable.",
|
||||
"oneOutput": "1 output"
|
||||
},
|
||||
"appModeToolbar": {
|
||||
"appBuilder": "App builder",
|
||||
"apps": "Apps",
|
||||
"appsEmptyMessage": "Saved apps will show up here.\nClick below to build your first app.",
|
||||
"enterAppMode": "Enter app mode"
|
||||
"appsEmptyMessage": "Saved apps will show up here.",
|
||||
"appsEmptyMessageAction": "Click below to build your first app."
|
||||
},
|
||||
"arrange": {
|
||||
"noOutputs": "No outputs added yet",
|
||||
@@ -3203,7 +3217,10 @@
|
||||
"noOutputs": "No output nodes added yet",
|
||||
"outputsDesc": "Connect at least one output node so users can see results after running.",
|
||||
"outputsExample": "Examples: “Save Image” or “Save Video”",
|
||||
"unknownWidget": "Widget not visible"
|
||||
"unknownWidget": "Widget not visible",
|
||||
"inputPlaceholder": "Inputs will show up here",
|
||||
"outputPlaceholder": "Output nodes will show up here",
|
||||
"outputRequiredPlaceholder": "At least one node is required"
|
||||
},
|
||||
"queue": {
|
||||
"clickToClear": "Click to clear queue",
|
||||
@@ -3515,10 +3532,12 @@
|
||||
"defaultModeAppliedGraphBody": "This workflow will open as a node graph by default from now on.",
|
||||
"defaultModeAppliedGraphPrompt": "Would you like to view the app still?",
|
||||
"viewApp": "View app",
|
||||
"exitToWorkflow": "Exit to workflow",
|
||||
"emptyWorkflowTitle": "This workflow has no nodes",
|
||||
"emptyWorkflowPrompt": "Do you want to start with a template?"
|
||||
},
|
||||
"builderMenu": {
|
||||
"enterAppMode": "Enter app mode",
|
||||
"exitAppBuilder": "Exit app builder"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,57 @@
|
||||
import ShareWorkflowDialogContent from '@/platform/workflow/sharing/components/ShareWorkflowDialogContent.vue'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useWorkflowStore } from '../../management/stores/workflowStore'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const DIALOG_KEY = 'global-share-workflow'
|
||||
|
||||
export function useShareDialog() {
|
||||
const dialogService = useDialogService()
|
||||
const dialogStore = useDialogStore()
|
||||
const { pruneLinearData } = useAppModeStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
function hide() {
|
||||
dialogStore.closeDialog({ key: DIALOG_KEY })
|
||||
}
|
||||
|
||||
function show() {
|
||||
function showNoOutputsDialogIfRequired(share: () => void) {
|
||||
const wf = workflowStore.activeWorkflow
|
||||
if (!wf) return share()
|
||||
|
||||
const isAppDefault = wf.initialMode === 'app'
|
||||
const linearData = wf.changeTracker?.activeState?.extra?.linearData
|
||||
const { outputs } = pruneLinearData(linearData)
|
||||
|
||||
if (isAppDefault && outputs.length === 0) {
|
||||
const dialog = showConfirmDialog({
|
||||
headerProps: {
|
||||
title: t('shareNoOutputs.title')
|
||||
},
|
||||
props: {
|
||||
promptText: t('shareNoOutputs.message'),
|
||||
preserveNewlines: true
|
||||
},
|
||||
footerProps: {
|
||||
confirmText: t('shareNoOutputs.shareAnyway'),
|
||||
confirmVariant: 'secondary',
|
||||
onCancel: () => dialogStore.closeDialog(dialog),
|
||||
onConfirm: () => {
|
||||
dialogStore.closeDialog(dialog)
|
||||
share()
|
||||
}
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
share()
|
||||
}
|
||||
|
||||
function showShareDialog() {
|
||||
dialogService.showLayoutDialog({
|
||||
key: DIALOG_KEY,
|
||||
component: ShareWorkflowDialogContent,
|
||||
@@ -29,6 +68,10 @@ export function useShareDialog() {
|
||||
})
|
||||
}
|
||||
|
||||
function show() {
|
||||
showNoOutputsDialogIfRequired(showShareDialog)
|
||||
}
|
||||
|
||||
return {
|
||||
show,
|
||||
hide
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,6 +11,7 @@ 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,31 +15,18 @@ 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()
|
||||
const executionStore = useExecutionStore()
|
||||
const mediaActions = useMediaAssetActions()
|
||||
const queueStore = useQueueStore()
|
||||
const { isBuilderMode, isArrangeMode } = useAppMode()
|
||||
const { allOutputs } = useOutputHistory()
|
||||
const { allOutputs, isWorkflowActive, cancelActiveWorkflowJobs } =
|
||||
useOutputHistory()
|
||||
const { runButtonClick, mobile, typeformWidgetId } = defineProps<{
|
||||
runButtonClick?: (e: Event) => void
|
||||
mobile?: boolean
|
||||
@@ -50,12 +37,14 @@ const selectedItem = ref<AssetItem>()
|
||||
const selectedOutput = ref<ResultItemImpl>()
|
||||
const canShowPreview = ref(true)
|
||||
const latentPreview = ref<string>()
|
||||
const showSkeleton = ref(false)
|
||||
|
||||
function handleSelection(sel: OutputSelection) {
|
||||
selectedItem.value = sel.asset
|
||||
selectedOutput.value = sel.output
|
||||
canShowPreview.value = sel.canShowPreview
|
||||
latentPreview.value = sel.latentPreviewUrl
|
||||
showSkeleton.value = sel.showSkeleton ?? false
|
||||
}
|
||||
|
||||
function downloadAsset(item?: AssetItem) {
|
||||
@@ -73,23 +62,18 @@ 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>
|
||||
<template>
|
||||
<section
|
||||
v-if="selectedItem || selectedOutput || !executionStore.isIdle"
|
||||
v-if="selectedItem || selectedOutput || showSkeleton || isWorkflowActive"
|
||||
data-testid="linear-output-info"
|
||||
class="flex w-full flex-wrap justify-center gap-2 p-4 text-sm tabular-nums md:z-10"
|
||||
>
|
||||
@@ -106,6 +90,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="
|
||||
@@ -117,23 +102,28 @@ async function rerun(e: Event) {
|
||||
<i class="icon-[lucide--download]" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!executionStore.isIdle && !selectedItem"
|
||||
v-if="isWorkflowActive && !selectedItem"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="t('menu.interrupt')"
|
||||
@click="commandStore.execute('Comfy.Interrupt')"
|
||||
@click="cancelActiveWorkflowJobs()"
|
||||
>
|
||||
<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,34 +133,16 @@ 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"
|
||||
<MediaOutputPreview
|
||||
v-else-if="selectedOutput"
|
||||
:output="selectedOutput"
|
||||
:mobile
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
<LatentPreview v-else-if="queueStore.runningTasks.length > 0" />
|
||||
<LatentPreview v-else-if="showSkeleton || isWorkflowActive" />
|
||||
<LinearArrange v-else-if="isArrangeMode" />
|
||||
<LinearWelcome v-else />
|
||||
<div
|
||||
@@ -184,7 +156,7 @@ async function rerun(e: Event) {
|
||||
/>
|
||||
<OutputHistory
|
||||
v-if="!isBuilderMode"
|
||||
class="min-w-0"
|
||||
class="z-10 min-w-0"
|
||||
@update-selection="handleSelection"
|
||||
/>
|
||||
<LinearFeedback
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
@@ -18,14 +18,14 @@ const {
|
||||
}>()
|
||||
|
||||
const { totalPercent, currentNodePercent } = useQueueProgress()
|
||||
const queueStore = useQueueStore()
|
||||
const executionStore = useExecutionStore()
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'relative h-2 bg-secondary-background transition-opacity',
|
||||
queueStore.runningTasks.length === 0 && 'opacity-0',
|
||||
!executionStore.isActiveWorkflowRunning && 'opacity-0',
|
||||
rounded && 'rounded-sm',
|
||||
className
|
||||
)
|
||||
|
||||
@@ -4,12 +4,18 @@ import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { setMode } = useAppMode()
|
||||
const appModeStore = useAppModeStore()
|
||||
const { hasOutputs, hasNodes } = storeToRefs(appModeStore)
|
||||
const workflowStore = useWorkflowStore()
|
||||
const isAppDefault = computed(
|
||||
() => workflowStore.activeWorkflow?.initialMode === 'app'
|
||||
)
|
||||
const templateSelectorDialog = useWorkflowTemplateSelectorDialog()
|
||||
</script>
|
||||
|
||||
@@ -47,6 +53,18 @@ const templateSelectorDialog = useWorkflowTemplateSelectorDialog()
|
||||
<p v-if="!hasNodes" class="mt-0 max-w-md text-sm text-base-foreground">
|
||||
{{ t('linearMode.emptyWorkflowExplanation') }}
|
||||
</p>
|
||||
<p
|
||||
v-if="hasNodes && isAppDefault"
|
||||
class="mt-0 max-w-md text-sm text-base-foreground"
|
||||
>
|
||||
<i18n-t keypath="linearMode.welcome.noOutputs" tag="span">
|
||||
<template #count>
|
||||
<span class="font-bold text-warning-background">{{
|
||||
t('linearMode.welcome.oneOutput')
|
||||
}}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</p>
|
||||
<div class="flex flex-row gap-2">
|
||||
<Button variant="textonly" size="lg" @click="setMode('graph')">
|
||||
{{ t('linearMode.backToWorkflow') }}
|
||||
|
||||
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"
|
||||
|
||||
@@ -25,12 +25,15 @@ import type {
|
||||
} from '@/renderer/extensions/linearMode/linearModeTypes'
|
||||
import OutputPreviewItem from '@/renderer/extensions/linearMode/OutputPreviewItem.vue'
|
||||
import { useOutputHistory } from '@/renderer/extensions/linearMode/useOutputHistory'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { outputs, allOutputs, selectFirstHistory } = useOutputHistory()
|
||||
const { outputs, allOutputs, selectFirstHistory, mayBeActiveWorkflowPending } =
|
||||
useOutputHistory()
|
||||
const queueStore = useQueueStore()
|
||||
const store = useLinearOutputStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
const emit = defineEmits<{
|
||||
updateSelection: [selection: OutputSelection]
|
||||
@@ -42,7 +45,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(
|
||||
@@ -55,10 +58,7 @@ const visibleHistory = computed(() =>
|
||||
|
||||
const selectableItems = computed(() => {
|
||||
const items: SelectionValue[] = []
|
||||
if (
|
||||
queueCount.value > 0 &&
|
||||
store.activeWorkflowInProgressItems.length === 0
|
||||
) {
|
||||
if (mayBeActiveWorkflowPending.value) {
|
||||
items.push({ id: 'slot:pending', kind: 'inProgress', itemId: 'pending' })
|
||||
}
|
||||
for (const item of store.activeWorkflowInProgressItems) {
|
||||
@@ -120,7 +120,7 @@ function doEmit() {
|
||||
(i) => i.id === sel.itemId
|
||||
)
|
||||
if (!item || item.state === 'skeleton') {
|
||||
emit('updateSelection', { canShowPreview: true })
|
||||
emit('updateSelection', { canShowPreview: true, showSkeleton: true })
|
||||
} else if (item.state === 'latent') {
|
||||
emit('updateSelection', {
|
||||
canShowPreview: true,
|
||||
@@ -146,6 +146,23 @@ function doEmit() {
|
||||
|
||||
watchEffect(doEmit)
|
||||
|
||||
// On load or workflow tab switch, select the most recent item.
|
||||
// Prefer in-progress items for this workflow, then history, skipping
|
||||
// the global pending slot which may belong to another workflow.
|
||||
watch(
|
||||
() => workflowStore.activeWorkflow?.path,
|
||||
(path) => {
|
||||
if (!path) return
|
||||
const inProgress = store.activeWorkflowInProgressItems
|
||||
if (inProgress.length > 0) {
|
||||
store.selectAsLatest(`slot:${inProgress[0].id}`)
|
||||
} else {
|
||||
selectFirstHistory()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Keep history selection stable on media changes
|
||||
watch(
|
||||
() => outputs.media.value,
|
||||
@@ -303,9 +320,7 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
queueCount > 0 && store.activeWorkflowInProgressItems.length === 0
|
||||
"
|
||||
v-if="mayBeActiveWorkflowPending"
|
||||
:ref="selectedRef('slot:pending')"
|
||||
v-bind="itemAttrs('slot:pending')"
|
||||
:class="itemClass"
|
||||
|
||||
@@ -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')"
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface OutputSelection {
|
||||
output?: ResultItemImpl
|
||||
canShowPreview: boolean
|
||||
latentPreviewUrl?: string
|
||||
showSkeleton?: boolean
|
||||
}
|
||||
|
||||
export type SelectionValue =
|
||||
|
||||
@@ -124,6 +124,7 @@ describe('linearOutputStore', () => {
|
||||
|
||||
it('auto-selects skeleton on first job start when no selection', () => {
|
||||
const store = useLinearOutputStore()
|
||||
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
||||
store.onJobStart('job-1')
|
||||
|
||||
expect(store.selectedId).toBe(`slot:${store.inProgressItems[0].id}`)
|
||||
@@ -132,6 +133,7 @@ describe('linearOutputStore', () => {
|
||||
it('transitions to latent on preview', () => {
|
||||
vi.useFakeTimers()
|
||||
const store = useLinearOutputStore()
|
||||
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
||||
store.onJobStart('job-1')
|
||||
|
||||
const itemId = store.inProgressItems[0].id
|
||||
@@ -265,6 +267,7 @@ describe('linearOutputStore', () => {
|
||||
// selectAsLatest simulates "following the latest output"
|
||||
store.selectAsLatest('history:asset-1:0')
|
||||
|
||||
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
||||
store.onJobStart('job-1')
|
||||
|
||||
// Following latest → auto-select new skeleton
|
||||
@@ -286,6 +289,7 @@ describe('linearOutputStore', () => {
|
||||
|
||||
it('falls back selection when selected item is removed', () => {
|
||||
const store = useLinearOutputStore()
|
||||
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
||||
store.onJobStart('job-1')
|
||||
const firstId = `slot:${store.inProgressItems[0].id}`
|
||||
expect(store.selectedId).toBe(firstId)
|
||||
@@ -400,6 +404,8 @@ describe('linearOutputStore', () => {
|
||||
|
||||
it('two sequential runs: selection clears after each resolve', () => {
|
||||
const store = useLinearOutputStore()
|
||||
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
||||
setJobWorkflowPath('job-2', 'workflows/test-workflow.json')
|
||||
|
||||
// Run 1: 3 outputs
|
||||
store.onJobStart('job-1')
|
||||
@@ -738,6 +744,34 @@ describe('linearOutputStore', () => {
|
||||
expect(imageItems[0].output?.nodeId).toBe('2')
|
||||
})
|
||||
|
||||
it('does not auto-select for jobs belonging to another workflow', () => {
|
||||
const store = useLinearOutputStore()
|
||||
|
||||
// User is on workflow-b, following latest
|
||||
activeWorkflowPathRef.value = 'workflows/app-b.json'
|
||||
store.selectAsLatest('history:asset-b:0')
|
||||
|
||||
// Job from workflow-a starts
|
||||
setJobWorkflowPath('job-1', 'workflows/app-a.json')
|
||||
store.onJobStart('job-1')
|
||||
|
||||
// Should NOT yank selection to the other workflow's slot
|
||||
expect(store.selectedId).toBe('history:asset-b:0')
|
||||
})
|
||||
|
||||
it('auto-selects for jobs belonging to the active workflow', () => {
|
||||
const store = useLinearOutputStore()
|
||||
|
||||
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
||||
store.selectAsLatest('history:asset-a:0')
|
||||
|
||||
setJobWorkflowPath('job-1', 'workflows/app-a.json')
|
||||
store.onJobStart('job-1')
|
||||
|
||||
// Should auto-select since job matches active workflow
|
||||
expect(store.selectedId?.startsWith('slot:')).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores execution events when not in app mode', async () => {
|
||||
const { nextTick } = await import('vue')
|
||||
const store = useLinearOutputStore()
|
||||
|
||||
@@ -67,7 +67,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
inProgressItems.value = [item, ...inProgressItems.value]
|
||||
|
||||
trackedJobId.value = jobId
|
||||
autoSelect(`slot:${item.id}`)
|
||||
autoSelect(`slot:${item.id}`, jobId)
|
||||
}
|
||||
|
||||
let raf: number | null = null
|
||||
@@ -88,7 +88,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
state: 'latent',
|
||||
latentPreviewUrl: url
|
||||
}))
|
||||
if (wasEmpty) autoSelect(`slot:${existing.id}`)
|
||||
if (wasEmpty) autoSelect(`slot:${existing.id}`, jobId)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
}
|
||||
currentSkeletonId.value = item.id
|
||||
inProgressItems.value = [item, ...inProgressItems.value]
|
||||
autoSelect(`slot:${item.id}`)
|
||||
autoSelect(`slot:${item.id}`, jobId)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
output: newOutputs[0],
|
||||
latentPreviewUrl: undefined
|
||||
}
|
||||
autoSelect(`slot:${imageItem.id}`)
|
||||
autoSelect(`slot:${imageItem.id}`, jobId)
|
||||
|
||||
const extras: InProgressItem[] = newOutputs.slice(1).map((o) => ({
|
||||
id: makeItemId(jobId),
|
||||
@@ -162,7 +162,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
state: 'image' as const,
|
||||
output: o
|
||||
}))
|
||||
autoSelect(`slot:${newItems[0].id}`)
|
||||
autoSelect(`slot:${newItems[0].id}`, jobId)
|
||||
inProgressItems.value = [...newItems, ...inProgressItems.value]
|
||||
}
|
||||
|
||||
@@ -226,7 +226,12 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
isFollowing.value = true
|
||||
}
|
||||
|
||||
function autoSelect(slotId: string) {
|
||||
function autoSelect(slotId: string, jobId: string) {
|
||||
// Only auto-select if the job belongs to the active workflow
|
||||
const path = workflowStore.activeWorkflow?.path
|
||||
if (path && executionStore.jobIdToSessionWorkflowPath.get(jobId) !== path)
|
||||
return
|
||||
|
||||
const sel = selectedId.value
|
||||
if (!sel || sel.startsWith('slot:') || isFollowing.value) {
|
||||
selectedId.value = slotId
|
||||
|
||||
@@ -11,9 +11,13 @@ import { ResultItemImpl } from '@/stores/queueStore'
|
||||
const mediaRef = ref<AssetItem[]>([])
|
||||
const pendingResolveRef = ref(new Set<string>())
|
||||
const inProgressItemsRef = ref<InProgressItem[]>([])
|
||||
const activeWorkflowInProgressItemsRef = ref<InProgressItem[]>([])
|
||||
const selectedIdRef = ref<string | null>(null)
|
||||
const activeWorkflowPathRef = ref<string>('workflows/test.json')
|
||||
const jobIdToPathRef = ref(new Map<string, string>())
|
||||
const isActiveWorkflowRunningRef = ref(false)
|
||||
const runningTasksRef = ref<Array<{ jobId: string }>>([])
|
||||
const pendingTasksRef = ref<Array<{ jobId: string }>>([])
|
||||
|
||||
const selectAsLatestFn = vi.fn()
|
||||
const resolveIfReadyFn = vi.fn()
|
||||
@@ -40,6 +44,9 @@ vi.mock('@/renderer/extensions/linearMode/linearOutputStore', () => ({
|
||||
get inProgressItems() {
|
||||
return inProgressItemsRef.value
|
||||
},
|
||||
get activeWorkflowInProgressItems() {
|
||||
return activeWorkflowInProgressItemsRef.value
|
||||
},
|
||||
get selectedId() {
|
||||
return selectedIdRef.value
|
||||
},
|
||||
@@ -61,10 +68,27 @@ vi.mock('@/stores/executionStore', () => ({
|
||||
useExecutionStore: () => ({
|
||||
get jobIdToSessionWorkflowPath() {
|
||||
return jobIdToPathRef.value
|
||||
},
|
||||
get isActiveWorkflowRunning() {
|
||||
return isActiveWorkflowRunningRef.value
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/queueStore', async (importOriginal) => {
|
||||
return {
|
||||
...(await importOriginal()),
|
||||
useQueueStore: () => ({
|
||||
get runningTasks() {
|
||||
return runningTasksRef.value
|
||||
},
|
||||
get pendingTasks() {
|
||||
return pendingTasksRef.value
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const { jobDetailResults } = vi.hoisted(() => ({
|
||||
jobDetailResults: new Map<string, unknown>()
|
||||
}))
|
||||
@@ -128,9 +152,13 @@ describe(useOutputHistory, () => {
|
||||
mediaRef.value = []
|
||||
pendingResolveRef.value = new Set()
|
||||
inProgressItemsRef.value = []
|
||||
activeWorkflowInProgressItemsRef.value = []
|
||||
selectedIdRef.value = null
|
||||
activeWorkflowPathRef.value = 'workflows/test.json'
|
||||
jobIdToPathRef.value = new Map()
|
||||
isActiveWorkflowRunningRef.value = false
|
||||
runningTasksRef.value = []
|
||||
pendingTasksRef.value = []
|
||||
resolvedOutputsCacheRef.clear()
|
||||
jobDetailResults.clear()
|
||||
selectAsLatestFn.mockReset()
|
||||
@@ -378,4 +406,54 @@ describe(useOutputHistory, () => {
|
||||
expect(selectAsLatestFn).toHaveBeenCalledWith(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mayBeActiveWorkflowPending', () => {
|
||||
it('returns false when no tasks are queued', () => {
|
||||
const { mayBeActiveWorkflowPending } = useOutputHistory()
|
||||
expect(mayBeActiveWorkflowPending.value).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when there are active in-progress items', () => {
|
||||
activeWorkflowInProgressItemsRef.value = [
|
||||
{ id: 'item-1', jobId: 'job-1', state: 'skeleton' }
|
||||
]
|
||||
runningTasksRef.value = [{ jobId: 'job-1' }]
|
||||
jobIdToPathRef.value = new Map([['job-1', 'workflows/test.json']])
|
||||
|
||||
const { mayBeActiveWorkflowPending } = useOutputHistory()
|
||||
expect(mayBeActiveWorkflowPending.value).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when a running task matches the active workflow', () => {
|
||||
runningTasksRef.value = [{ jobId: 'job-1' }]
|
||||
jobIdToPathRef.value = new Map([['job-1', 'workflows/test.json']])
|
||||
|
||||
const { mayBeActiveWorkflowPending } = useOutputHistory()
|
||||
expect(mayBeActiveWorkflowPending.value).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true when a pending task matches the active workflow', () => {
|
||||
pendingTasksRef.value = [{ jobId: 'job-1' }]
|
||||
jobIdToPathRef.value = new Map([['job-1', 'workflows/test.json']])
|
||||
|
||||
const { mayBeActiveWorkflowPending } = useOutputHistory()
|
||||
expect(mayBeActiveWorkflowPending.value).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when tasks belong to another workflow', () => {
|
||||
runningTasksRef.value = [{ jobId: 'job-1' }]
|
||||
jobIdToPathRef.value = new Map([['job-1', 'workflows/other.json']])
|
||||
|
||||
const { mayBeActiveWorkflowPending } = useOutputHistory()
|
||||
expect(mayBeActiveWorkflowPending.value).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when no workflow path is set', () => {
|
||||
activeWorkflowPathRef.value = ''
|
||||
runningTasksRef.value = [{ jobId: 'job-1' }]
|
||||
|
||||
const { mayBeActiveWorkflowPending } = useOutputHistory()
|
||||
expect(mayBeActiveWorkflowPending.value).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useAsyncState } from '@vueuse/core'
|
||||
import type { ComputedRef } from 'vue'
|
||||
import { computed, ref, watchEffect } from 'vue'
|
||||
|
||||
import type { IAssetsProvider } from '@/platform/assets/composables/media/IAssetsProvider'
|
||||
@@ -9,14 +10,20 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow
|
||||
import { flattenNodeOutput } from '@/renderer/extensions/linearMode/flattenNodeOutput'
|
||||
import { useLinearOutputStore } from '@/renderer/extensions/linearMode/linearOutputStore'
|
||||
import { getJobDetail } from '@/services/jobOutputCache'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
export function useOutputHistory(): {
|
||||
outputs: IAssetsProvider
|
||||
allOutputs: (item?: AssetItem) => ResultItemImpl[]
|
||||
selectFirstHistory: () => void
|
||||
mayBeActiveWorkflowPending: ComputedRef<boolean>
|
||||
isWorkflowActive: ComputedRef<boolean>
|
||||
cancelActiveWorkflowJobs: () => Promise<void>
|
||||
} {
|
||||
const backingOutputs = useMediaAssets('output')
|
||||
void backingOutputs.fetchMediaList()
|
||||
@@ -24,6 +31,37 @@ export function useOutputHistory(): {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const appModeStore = useAppModeStore()
|
||||
const queueStore = useQueueStore()
|
||||
|
||||
function matchesActiveWorkflow(task: { jobId: string | number }): boolean {
|
||||
const path = workflowStore.activeWorkflow?.path
|
||||
if (!path) return false
|
||||
return (
|
||||
executionStore.jobIdToSessionWorkflowPath.get(String(task.jobId)) === path
|
||||
)
|
||||
}
|
||||
|
||||
function hasActiveWorkflowJobs(): boolean {
|
||||
if (!workflowStore.activeWorkflow?.path) return false
|
||||
return (
|
||||
queueStore.runningTasks.some(matchesActiveWorkflow) ||
|
||||
queueStore.pendingTasks.some(matchesActiveWorkflow)
|
||||
)
|
||||
}
|
||||
|
||||
// True when there are queued/running jobs for the active workflow but no
|
||||
// in-progress output items yet.
|
||||
const mayBeActiveWorkflowPending = computed(() => {
|
||||
if (linearStore.activeWorkflowInProgressItems.length > 0) return false
|
||||
return hasActiveWorkflowJobs()
|
||||
})
|
||||
|
||||
// True when the active workflow has running/pending jobs or in-progress items.
|
||||
const isWorkflowActive = computed(
|
||||
() =>
|
||||
linearStore.activeWorkflowInProgressItems.length > 0 ||
|
||||
hasActiveWorkflowJobs()
|
||||
)
|
||||
|
||||
function filterByOutputNodes(items: ResultItemImpl[]): ResultItemImpl[] {
|
||||
const nodeIds = appModeStore.selectedOutputs
|
||||
@@ -140,5 +178,29 @@ export function useOutputHistory(): {
|
||||
}
|
||||
})
|
||||
|
||||
return { outputs, allOutputs, selectFirstHistory }
|
||||
async function cancelActiveWorkflowJobs() {
|
||||
if (!workflowStore.activeWorkflow?.path) return
|
||||
|
||||
// Interrupt the running job if it belongs to this workflow
|
||||
if (queueStore.runningTasks.some(matchesActiveWorkflow)) {
|
||||
void useCommandStore().execute('Comfy.Interrupt')
|
||||
} else {
|
||||
// Delete first pending job for this workflow from the queue
|
||||
for (const task of queueStore.pendingTasks) {
|
||||
if (matchesActiveWorkflow(task)) {
|
||||
await api.deleteItem('queue', String(task.jobId))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
outputs,
|
||||
allOutputs,
|
||||
selectFirstHistory,
|
||||
mayBeActiveWorkflowPending,
|
||||
isWorkflowActive,
|
||||
cancelActiveWorkflowJobs
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,23 @@ vi.mock(
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
rootGraph: { getNodeById: vi.fn() },
|
||||
canvas: { setDirty: vi.fn() }
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>
|
||||
return {
|
||||
...actual,
|
||||
getNodeByLocatorId: vi.fn(() => ({
|
||||
isSubgraphNode: () => false
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/useErrorHandling', () => ({
|
||||
useErrorHandling: () => ({
|
||||
toastErrorHandler: vi.fn()
|
||||
@@ -184,8 +201,13 @@ describe('LGraphNode', () => {
|
||||
|
||||
const wrapper = mountLGraphNode({ nodeData: mockNodeData })
|
||||
|
||||
expect(wrapper.classes()).toContain('outline-3')
|
||||
// Root div should have the selection class
|
||||
expect(wrapper.classes()).toContain('outline-node-component-outline')
|
||||
|
||||
// The layered outline overlay should be present
|
||||
const overlay = wrapper.find('[data-testid="node-state-outline-overlay"]')
|
||||
expect(overlay.exists()).toBe(true)
|
||||
expect(overlay.classes()).toContain('border-node-component-outline')
|
||||
})
|
||||
|
||||
it('should render progress indicator when executing prop is true', () => {
|
||||
@@ -193,7 +215,13 @@ describe('LGraphNode', () => {
|
||||
|
||||
const wrapper = mountLGraphNode({ nodeData: mockNodeData })
|
||||
|
||||
// Root div should have the executing class
|
||||
expect(wrapper.classes()).toContain('outline-node-stroke-executing')
|
||||
|
||||
// The layered outline overlay should be present
|
||||
const overlay = wrapper.find('[data-testid="node-state-outline-overlay"]')
|
||||
expect(overlay.exists()).toBe(true)
|
||||
expect(overlay.classes()).toContain('border-node-stroke-executing')
|
||||
})
|
||||
|
||||
it('should initialize height CSS vars for collapsed nodes', () => {
|
||||
|
||||
@@ -9,25 +9,11 @@
|
||||
:data-node-id="nodeData.id"
|
||||
:class="
|
||||
cn(
|
||||
'group/node lg-node absolute bg-node-component-header-surface text-sm',
|
||||
'min-h-(--node-height) w-(--node-width) min-w-[225px] contain-layout contain-style',
|
||||
shapeClass,
|
||||
'flex touch-none flex-col',
|
||||
'border border-solid border-component-node-border',
|
||||
// hover (only when node should handle events)
|
||||
shouldHandleNodePointerEvents &&
|
||||
'ring-node-component-ring hover:ring-7',
|
||||
'outline-3 outline-transparent focus-visible:outline-node-component-outline',
|
||||
borderClass,
|
||||
outlineClass,
|
||||
'group/node lg-node absolute text-sm',
|
||||
'flex min-w-[225px] flex-col contain-layout contain-style',
|
||||
cursorClass,
|
||||
{
|
||||
[`${beforeShapeClass} before:pointer-events-none before:absolute before:inset-0 before:bg-bypass/60`]:
|
||||
bypassed,
|
||||
[`${beforeShapeClass} before:pointer-events-none before:absolute before:inset-0`]:
|
||||
muted,
|
||||
'bg-primary-500/10 ring-4 ring-primary-500': isDraggingOver
|
||||
},
|
||||
isSelected && 'outline-node-component-outline',
|
||||
executing && 'outline-node-stroke-executing',
|
||||
shouldHandleNodePointerEvents && !nodeData.flags?.ghost
|
||||
? 'pointer-events-auto'
|
||||
: 'pointer-events-none'
|
||||
@@ -37,9 +23,7 @@
|
||||
{
|
||||
transform: `translate(${position.x ?? 0}px, ${(position.y ?? 0) - LiteGraph.NODE_TITLE_HEIGHT}px)`,
|
||||
zIndex: zIndex,
|
||||
opacity: nodeOpacity,
|
||||
'--component-node-background': applyLightThemeColor(nodeData.bgcolor),
|
||||
backgroundColor: applyLightThemeColor(nodeData?.color)
|
||||
opacity: nodeOpacity
|
||||
}
|
||||
]"
|
||||
v-bind="remainingPointerHandlers"
|
||||
@@ -50,173 +34,173 @@
|
||||
@dragleave="handleDragLeave"
|
||||
@drop.stop.prevent="handleDrop"
|
||||
>
|
||||
<!-- Selection/Execution Outline Overlay -->
|
||||
<AppOutput
|
||||
v-if="
|
||||
lgraphNode?.constructor?.nodeData?.output_node && isSelectOutputsMode
|
||||
lgraphNode?.constructor?.nodeData?.output_node &&
|
||||
isSelectOutputsMode &&
|
||||
nodeData.mode === LGraphEventMode.ALWAYS
|
||||
"
|
||||
:id="nodeData.id"
|
||||
/>
|
||||
<div
|
||||
v-if="displayHeader"
|
||||
class="relative flex flex-col items-center justify-center"
|
||||
>
|
||||
<template v-if="isCollapsed">
|
||||
<SlotConnectionDot
|
||||
v-if="hasInputs"
|
||||
multi
|
||||
class="absolute left-0 -translate-x-1/2"
|
||||
/>
|
||||
<SlotConnectionDot
|
||||
v-if="hasOutputs"
|
||||
multi
|
||||
class="absolute right-0 translate-x-1/2"
|
||||
/>
|
||||
<NodeSlots :node-data="nodeData" unified />
|
||||
</template>
|
||||
<NodeHeader
|
||||
:node-data="nodeData"
|
||||
:collapsed="isCollapsed"
|
||||
:price-badges="badges.pricing"
|
||||
@collapse="handleCollapse"
|
||||
@update:title="handleHeaderTitleUpdate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isCollapsed && executing && progress !== undefined"
|
||||
v-if="isSelected || executing"
|
||||
data-testid="node-state-outline-overlay"
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-x-4 -bottom-px translate-y-1/2 rounded-full',
|
||||
progressClasses
|
||||
'pointer-events-none absolute z-0 border-3 outline-none',
|
||||
selectionShapeClass,
|
||||
hasAnyError ? '-inset-[7px]' : '-inset-[3px]',
|
||||
isSelected
|
||||
? 'border-node-component-outline'
|
||||
: 'border-node-stroke-executing',
|
||||
footerStateOutlineBottomClass
|
||||
)
|
||||
"
|
||||
:style="{ width: `${Math.min(progress * 100, 100)}%` }"
|
||||
/>
|
||||
|
||||
<template v-if="!isCollapsed">
|
||||
<div class="relative">
|
||||
<!-- Progress bar for executing state -->
|
||||
<div
|
||||
v-if="executing && progress !== undefined"
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-x-0 top-1/2 -translate-y-1/2',
|
||||
!!(progress < 1) && 'rounded-r-full',
|
||||
progressClasses
|
||||
)
|
||||
"
|
||||
:style="{ width: `${Math.min(progress * 100, 100)}%` }"
|
||||
<!-- Root Border Overlay -->
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'pointer-events-none absolute border border-solid border-component-node-border',
|
||||
rootBorderShapeClass,
|
||||
hasAnyError ? '-inset-1' : 'inset-0',
|
||||
footerRootBorderBottomClass
|
||||
)
|
||||
"
|
||||
/>
|
||||
<div
|
||||
data-testid="node-inner-wrapper"
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-1 flex-col border border-solid border-transparent bg-node-component-header-surface',
|
||||
'min-h-(--node-height) w-(--node-width)',
|
||||
shapeClass,
|
||||
hasAnyError && 'ring-4 ring-destructive-background',
|
||||
{
|
||||
[`${beforeShapeClass} before:pointer-events-none before:absolute before:inset-0 before:bg-bypass/60`]:
|
||||
bypassed,
|
||||
[`${beforeShapeClass} before:pointer-events-none before:absolute before:inset-0`]:
|
||||
muted,
|
||||
'bg-primary-500/10 ring-4 ring-primary-500': isDraggingOver
|
||||
}
|
||||
)
|
||||
"
|
||||
:style="{
|
||||
'--component-node-background': applyLightThemeColor(nodeData.bgcolor),
|
||||
backgroundColor: applyLightThemeColor(nodeData?.color)
|
||||
}"
|
||||
>
|
||||
<AppOutput
|
||||
v-if="lgraphNode?.constructor?.nodeData?.output_node && isSelectMode"
|
||||
:id="nodeData.id"
|
||||
/>
|
||||
<div
|
||||
v-if="displayHeader"
|
||||
class="relative flex flex-col items-center justify-center"
|
||||
>
|
||||
<template v-if="isCollapsed">
|
||||
<SlotConnectionDot
|
||||
v-if="hasInputs"
|
||||
multi
|
||||
class="absolute left-0 -translate-x-1/2"
|
||||
/>
|
||||
<SlotConnectionDot
|
||||
v-if="hasOutputs"
|
||||
multi
|
||||
class="absolute right-0 translate-x-1/2"
|
||||
/>
|
||||
<NodeSlots :node-data="nodeData" unified />
|
||||
</template>
|
||||
<NodeHeader
|
||||
:node-data="nodeData"
|
||||
:collapsed="isCollapsed"
|
||||
:price-badges="badges.pricing"
|
||||
@collapse="handleCollapse"
|
||||
@update:title="handleHeaderTitleUpdate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-1 flex-col gap-1 rounded-b-2xl bg-component-node-background pt-1 pb-3"
|
||||
:data-testid="`node-body-${nodeData.id}`"
|
||||
>
|
||||
<NodeSlots :node-data="nodeData" />
|
||||
v-if="isCollapsed && executing && progress !== undefined"
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-x-4 -bottom-px translate-y-1/2 rounded-full',
|
||||
progressClasses
|
||||
)
|
||||
"
|
||||
:style="{ width: `${Math.min(progress * 100, 100)}%` }"
|
||||
/>
|
||||
|
||||
<NodeWidgets v-if="nodeData.widgets?.length" :node-data="nodeData" />
|
||||
|
||||
<div v-if="hasCustomContent" class="flex min-h-0 flex-1 flex-col">
|
||||
<NodeContent
|
||||
v-if="nodeMedia"
|
||||
:node-data="nodeData"
|
||||
:media="nodeMedia"
|
||||
/>
|
||||
<NodeContent
|
||||
v-for="preview in promotedPreviews"
|
||||
:key="`${preview.interiorNodeId}-${preview.widgetName}`"
|
||||
:node-data="nodeData"
|
||||
:media="preview"
|
||||
<template v-if="!isCollapsed">
|
||||
<div class="relative">
|
||||
<!-- Progress bar for executing state -->
|
||||
<div
|
||||
v-if="executing && progress !== undefined"
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-x-0 top-1/2 -translate-y-1/2',
|
||||
!!(progress < 1) && 'rounded-r-full',
|
||||
progressClasses
|
||||
)
|
||||
"
|
||||
:style="{ width: `${Math.min(progress * 100, 100)}%` }"
|
||||
/>
|
||||
</div>
|
||||
<!-- Live mid-execution preview images -->
|
||||
<LivePreview
|
||||
v-if="shouldShowPreviewImg"
|
||||
:image-url="latestPreviewUrl"
|
||||
/>
|
||||
<NodeBadges v-bind="badges" :pricing="undefined" class="mt-auto" />
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-if="
|
||||
(hasAnyError && showErrorsTabEnabled) ||
|
||||
lgraphNode?.isSubgraphNode() ||
|
||||
showAdvancedState ||
|
||||
showAdvancedInputsButton
|
||||
"
|
||||
:class="
|
||||
cn(
|
||||
'-z-1 flex h-7 w-full divide-x divide-component-node-border overflow-hidden rounded-t-none rounded-b-2xl text-xs',
|
||||
!isCollapsed && '-mt-5 h-12'
|
||||
)
|
||||
"
|
||||
>
|
||||
<Button
|
||||
v-if="lgraphNode?.isSubgraphNode()"
|
||||
variant="textonly"
|
||||
:class="
|
||||
cn(
|
||||
'h-full flex-1 rounded-none',
|
||||
hasAnyError &&
|
||||
showErrorsTabEnabled &&
|
||||
!nodeData.color &&
|
||||
'bg-node-component-header-surface',
|
||||
isCollapsed ? 'py-2' : 'pt-7 pb-2'
|
||||
)
|
||||
"
|
||||
data-testid="subgraph-enter-button"
|
||||
@click.stop="handleEnterSubgraph"
|
||||
>
|
||||
<span class="truncate">{{
|
||||
hasAnyError && showErrorsTabEnabled
|
||||
? t('g.enter')
|
||||
: t('g.enterSubgraph')
|
||||
}}</span>
|
||||
<i class="icon-[comfy--workflow] size-4 shrink-0" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-if="hasAnyError && showErrorsTabEnabled"
|
||||
variant="textonly"
|
||||
:class="
|
||||
cn(
|
||||
'h-full flex-1 rounded-none bg-error hover:bg-destructive-background-hover',
|
||||
isCollapsed ? 'py-2' : 'pt-7 pb-2'
|
||||
)
|
||||
"
|
||||
@click.stop="useRightSidePanelStore().openPanel('errors')"
|
||||
>
|
||||
<span class="truncate">{{ t('g.error') }}</span>
|
||||
<i class="icon-[lucide--info] size-4 shrink-0" />
|
||||
</Button>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-1 flex-col gap-1 bg-component-node-background pt-1 pb-3',
|
||||
bodyRoundingClass
|
||||
)
|
||||
"
|
||||
:data-testid="`node-body-${nodeData.id}`"
|
||||
>
|
||||
<NodeSlots :node-data="nodeData" />
|
||||
|
||||
<!-- Advanced inputs (non-subgraph nodes only) -->
|
||||
<Button
|
||||
v-if="
|
||||
!lgraphNode?.isSubgraphNode() &&
|
||||
(showAdvancedState || showAdvancedInputsButton)
|
||||
"
|
||||
variant="textonly"
|
||||
:class="
|
||||
cn('h-full flex-1 rounded-none', isCollapsed ? 'py-2' : 'pt-7 pb-2')
|
||||
"
|
||||
@click.stop="showAdvancedState = !showAdvancedState"
|
||||
>
|
||||
<template v-if="showAdvancedState">
|
||||
<span class="truncate">{{
|
||||
t('rightSidePanel.hideAdvancedInputsButton')
|
||||
}}</span>
|
||||
<i class="icon-[lucide--chevron-up] size-4 shrink-0" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="truncate">{{
|
||||
t('rightSidePanel.showAdvancedInputsButton')
|
||||
}}</span>
|
||||
<i class="icon-[lucide--settings-2] size-4 shrink-0" />
|
||||
</template>
|
||||
</Button>
|
||||
<NodeWidgets v-if="nodeData.widgets?.length" :node-data="nodeData" />
|
||||
|
||||
<div v-if="hasCustomContent" class="flex min-h-0 flex-1 flex-col">
|
||||
<NodeContent
|
||||
v-if="nodeMedia"
|
||||
:node-data="nodeData"
|
||||
:media="nodeMedia"
|
||||
/>
|
||||
<NodeContent
|
||||
v-for="preview in promotedPreviews"
|
||||
:key="`${preview.interiorNodeId}-${preview.widgetName}`"
|
||||
:node-data="nodeData"
|
||||
:media="preview"
|
||||
/>
|
||||
</div>
|
||||
<!-- Live mid-execution preview images -->
|
||||
<LivePreview
|
||||
v-if="shouldShowPreviewImg"
|
||||
:image-url="latestPreviewUrl"
|
||||
/>
|
||||
<NodeBadges
|
||||
v-if="!isTransparentHeaderless"
|
||||
v-bind="badges"
|
||||
:pricing="undefined"
|
||||
class="mt-auto"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<NodeFooter
|
||||
:is-subgraph="!!lgraphNode?.isSubgraphNode()"
|
||||
:has-any-error="hasAnyError"
|
||||
:show-errors-tab-enabled="showErrorsTabEnabled"
|
||||
:is-collapsed="isCollapsed"
|
||||
:show-advanced-inputs-button="showAdvancedInputsButton"
|
||||
:show-advanced-state="showAdvancedState"
|
||||
:header-color="applyLightThemeColor(nodeData?.color)"
|
||||
:shape="nodeData.shape"
|
||||
@enter-subgraph="handleEnterSubgraph"
|
||||
@open-errors="handleOpenErrors"
|
||||
@toggle-advanced="handleToggleAdvanced"
|
||||
/>
|
||||
<template
|
||||
v-if="!isCollapsed && nodeData.resizable !== false && !isSelectMode"
|
||||
>
|
||||
@@ -229,6 +213,8 @@
|
||||
cn(
|
||||
baseResizeHandleClasses,
|
||||
handle.positionClasses,
|
||||
(handle.corner === 'SE' || handle.corner === 'SW') &&
|
||||
footerResizeHandleBottomClass,
|
||||
handle.cursorClass,
|
||||
'group-hover/node:opacity-100'
|
||||
)
|
||||
@@ -270,7 +256,6 @@ import {
|
||||
} from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { showNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
@@ -310,13 +295,13 @@ import { app } from '@/scripts/app'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { isTransparent } from '@/utils/colorUtil'
|
||||
import { isVideoOutput } from '@/utils/litegraphUtil'
|
||||
import {
|
||||
getLocatorIdFromNodeData,
|
||||
getNodeByLocatorId
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { isTransparent } from '@/utils/colorUtil'
|
||||
|
||||
import type { CompassCorners } from '@/lib/litegraph/src/interfaces'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
@@ -326,6 +311,7 @@ import { useNodeResize } from '../interactions/resize/useNodeResize'
|
||||
import LivePreview from './LivePreview.vue'
|
||||
import NodeContent from './NodeContent.vue'
|
||||
import NodeHeader from './NodeHeader.vue'
|
||||
import NodeFooter from './NodeFooter.vue'
|
||||
import NodeSlots from './NodeSlots.vue'
|
||||
import NodeWidgets from './NodeWidgets.vue'
|
||||
|
||||
@@ -563,53 +549,104 @@ const { latestPreviewUrl, shouldShowPreviewImg } = useNodePreviewState(
|
||||
}
|
||||
)
|
||||
|
||||
const borderClass = computed(() => {
|
||||
if (hasAnyError.value) return 'border-node-stroke-error bg-error'
|
||||
//FIXME need a better way to detecting transparency
|
||||
if (
|
||||
!displayHeader.value &&
|
||||
nodeData.bgcolor &&
|
||||
isTransparent(nodeData.bgcolor)
|
||||
const hasFooter = computed(() => {
|
||||
return !!(
|
||||
(hasAnyError.value && showErrorsTabEnabled.value) ||
|
||||
lgraphNode.value?.isSubgraphNode() ||
|
||||
(!lgraphNode.value?.isSubgraphNode() &&
|
||||
(showAdvancedState.value || showAdvancedInputsButton.value))
|
||||
)
|
||||
return 'border-0'
|
||||
return ''
|
||||
})
|
||||
|
||||
const outlineClass = computed(() => {
|
||||
return cn(
|
||||
isSelected.value && 'outline-node-component-outline',
|
||||
hasAnyError.value && 'outline-node-stroke-error',
|
||||
executing.value && 'outline-node-stroke-executing'
|
||||
)
|
||||
// Footer offset computed classes
|
||||
|
||||
const footerStateOutlineBottomClass = computed(() =>
|
||||
hasFooter.value ? '-bottom-[35px]' : ''
|
||||
)
|
||||
|
||||
const footerRootBorderBottomClass = computed(() =>
|
||||
hasFooter.value ? '-bottom-8' : ''
|
||||
)
|
||||
|
||||
const footerResizeHandleBottomClass = computed(() => {
|
||||
if (!hasFooter.value) return ''
|
||||
return hasAnyError.value ? 'bottom-[-31px]' : 'bottom-[-35px]'
|
||||
})
|
||||
|
||||
const cursorClass = computed(() => {
|
||||
return cn(
|
||||
nodeData.flags?.pinned
|
||||
? 'cursor-default'
|
||||
: layoutStore.isDraggingVueNodes.value
|
||||
? 'cursor-grabbing'
|
||||
: 'cursor-grab'
|
||||
)
|
||||
if (nodeData.flags?.pinned) return 'cursor-default'
|
||||
return layoutStore.isDraggingVueNodes.value
|
||||
? 'cursor-grabbing'
|
||||
: 'cursor-grab'
|
||||
})
|
||||
|
||||
const bodyRoundingClass = computed(() => {
|
||||
switch (nodeData.shape) {
|
||||
case RenderShape.BOX:
|
||||
return ''
|
||||
case RenderShape.CARD:
|
||||
return 'rounded-br-2xl'
|
||||
default:
|
||||
return 'rounded-b-2xl'
|
||||
}
|
||||
})
|
||||
|
||||
const shapeClass = computed(() => {
|
||||
switch (nodeData.shape) {
|
||||
case RenderShape.BOX:
|
||||
return 'rounded-none'
|
||||
return ''
|
||||
case RenderShape.CARD:
|
||||
return 'rounded-tl-2xl rounded-br-2xl rounded-tr-none rounded-bl-none'
|
||||
return 'rounded-tl-2xl rounded-br-2xl'
|
||||
default:
|
||||
return 'rounded-2xl'
|
||||
}
|
||||
})
|
||||
|
||||
const isTransparentHeaderless = computed(
|
||||
() =>
|
||||
!displayHeader.value &&
|
||||
!!nodeData.bgcolor &&
|
||||
isTransparent(nodeData.bgcolor)
|
||||
)
|
||||
|
||||
const rootBorderShapeClass = computed(() => {
|
||||
if (isTransparentHeaderless.value) return 'border-0'
|
||||
|
||||
const isExpanded = hasAnyError.value
|
||||
switch (nodeData.shape) {
|
||||
case RenderShape.BOX:
|
||||
return ''
|
||||
case RenderShape.CARD:
|
||||
return isExpanded
|
||||
? 'rounded-tl-[20px] rounded-br-[20px]'
|
||||
: 'rounded-tl-2xl rounded-br-2xl'
|
||||
default:
|
||||
return isExpanded ? 'rounded-[20px]' : 'rounded-2xl'
|
||||
}
|
||||
})
|
||||
|
||||
const selectionShapeClass = computed(() => {
|
||||
if (isTransparentHeaderless.value) return 'border-0'
|
||||
|
||||
const isExpanded = hasAnyError.value
|
||||
switch (nodeData.shape) {
|
||||
case RenderShape.BOX:
|
||||
return ''
|
||||
case RenderShape.CARD:
|
||||
return isExpanded
|
||||
? 'rounded-tl-[23px] rounded-br-[23px]'
|
||||
: 'rounded-tl-[19px] rounded-br-[19px]'
|
||||
default:
|
||||
return isExpanded ? 'rounded-[23px]' : 'rounded-[19px]'
|
||||
}
|
||||
})
|
||||
|
||||
const beforeShapeClass = computed(() => {
|
||||
switch (nodeData.shape) {
|
||||
case RenderShape.BOX:
|
||||
return 'before:rounded-none'
|
||||
return ''
|
||||
case RenderShape.CARD:
|
||||
return 'before:rounded-tl-2xl before:rounded-br-2xl before:rounded-tr-none before:rounded-bl-none'
|
||||
return 'before:rounded-tl-2xl before:rounded-br-2xl'
|
||||
default:
|
||||
return 'before:rounded-2xl'
|
||||
}
|
||||
@@ -624,6 +661,16 @@ const handleHeaderTitleUpdate = (newTitle: string) => {
|
||||
handleNodeTitleUpdate(nodeData.id, newTitle)
|
||||
}
|
||||
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
|
||||
const handleOpenErrors = () => {
|
||||
rightSidePanelStore.openPanel('errors')
|
||||
}
|
||||
|
||||
const handleToggleAdvanced = () => {
|
||||
showAdvancedState.value = !showAdvancedState.value
|
||||
}
|
||||
|
||||
const handleEnterSubgraph = () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: 'graph_node_open_subgraph_clicked'
|
||||
@@ -703,7 +750,6 @@ const showAdvancedState = customRef((track, trigger) => {
|
||||
|
||||
if (node instanceof SubgraphNode) {
|
||||
// Do not modify internalState for subgraph nodes
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
if (value) {
|
||||
rightSidePanelStore.focusSection('advanced-inputs')
|
||||
} else {
|
||||
|
||||
183
src/renderer/extensions/vueNodes/components/NodeFooter.vue
Normal file
@@ -0,0 +1,183 @@
|
||||
<template>
|
||||
<!-- Case 1: Subgraph + Error (Dual Tabs) -->
|
||||
<template v-if="isSubgraph && hasAnyError && showErrorsTabEnabled">
|
||||
<Button
|
||||
variant="textonly"
|
||||
:class="
|
||||
cn(
|
||||
getTabStyles(false),
|
||||
errorTabWidth,
|
||||
'-z-5 bg-destructive-background text-white hover:bg-destructive-background-hover'
|
||||
)
|
||||
"
|
||||
@click.stop="$emit('openErrors')"
|
||||
>
|
||||
<div class="flex size-full items-center justify-center gap-2">
|
||||
<span class="truncate">{{ t('g.error') }}</span>
|
||||
<i class="icon-[lucide--info] size-4 shrink-0" />
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="textonly"
|
||||
data-testid="subgraph-enter-button"
|
||||
:class="
|
||||
cn(
|
||||
getTabStyles(true),
|
||||
enterTabFullWidth,
|
||||
'-z-10 bg-node-component-header-surface'
|
||||
)
|
||||
"
|
||||
:style="{ backgroundColor: headerColor }"
|
||||
@click.stop="$emit('enterSubgraph')"
|
||||
>
|
||||
<div class="ml-auto flex h-full w-1/2 items-center justify-center gap-2">
|
||||
<span class="truncate">{{ t('g.enter') }}</span>
|
||||
<i class="icon-[comfy--workflow] size-4 shrink-0" />
|
||||
</div>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<!-- Case 2: Error Only (Full Width) -->
|
||||
<template v-else-if="hasAnyError && showErrorsTabEnabled">
|
||||
<Button
|
||||
variant="textonly"
|
||||
:class="
|
||||
cn(
|
||||
getTabStyles(false),
|
||||
enterTabFullWidth,
|
||||
'-z-5 bg-destructive-background text-white hover:bg-destructive-background-hover'
|
||||
)
|
||||
"
|
||||
@click.stop="$emit('openErrors')"
|
||||
>
|
||||
<div class="flex size-full items-center justify-center gap-2">
|
||||
<span class="truncate">{{ t('g.error') }}</span>
|
||||
<i class="icon-[lucide--info] size-4 shrink-0" />
|
||||
</div>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<!-- Case 3: Subgraph only (Full Width) -->
|
||||
<template v-else-if="isSubgraph">
|
||||
<Button
|
||||
variant="textonly"
|
||||
data-testid="subgraph-enter-button"
|
||||
:class="
|
||||
cn(
|
||||
getTabStyles(true),
|
||||
hasAnyError ? 'w-[calc(100%+8px)]' : 'w-full',
|
||||
'-z-10 bg-node-component-header-surface'
|
||||
)
|
||||
"
|
||||
:style="{ backgroundColor: headerColor }"
|
||||
@click.stop="$emit('enterSubgraph')"
|
||||
>
|
||||
<div class="flex size-full items-center justify-center gap-2">
|
||||
<span class="truncate">{{ t('g.enterSubgraph') }}</span>
|
||||
<i class="icon-[comfy--workflow] size-4 shrink-0" />
|
||||
</div>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<!-- Case 4: Advanced Footer (Regular Nodes) -->
|
||||
<div
|
||||
v-else-if="showAdvancedInputsButton || showAdvancedState"
|
||||
class="relative -z-1 -mt-5 flex h-7 w-full divide-x divide-component-node-border overflow-hidden rounded-t-none rounded-b-2xl text-xs"
|
||||
>
|
||||
<Button
|
||||
variant="textonly"
|
||||
:class="
|
||||
cn('h-full flex-1 rounded-none', isCollapsed ? 'py-2' : 'pt-7 pb-2')
|
||||
"
|
||||
@click.stop="$emit('toggleAdvanced')"
|
||||
>
|
||||
<template v-if="showAdvancedState">
|
||||
<span class="truncate">{{
|
||||
t('rightSidePanel.hideAdvancedInputsButton')
|
||||
}}</span>
|
||||
<i class="icon-[lucide--chevron-up] size-4 shrink-0" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="truncate">{{
|
||||
t('rightSidePanel.showAdvancedInputsButton')
|
||||
}}</span>
|
||||
<i class="icon-[lucide--settings-2] size-4 shrink-0" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { RenderShape } from '@/lib/litegraph/src/litegraph'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface Props {
|
||||
isSubgraph: boolean
|
||||
hasAnyError: boolean
|
||||
showErrorsTabEnabled: boolean
|
||||
isCollapsed: boolean
|
||||
showAdvancedInputsButton?: boolean
|
||||
showAdvancedState?: boolean
|
||||
headerColor?: string
|
||||
shape?: RenderShape
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'enterSubgraph'): void
|
||||
(e: 'openErrors'): void
|
||||
(e: 'toggleAdvanced'): void
|
||||
}>()
|
||||
|
||||
const footerRadiusClass = computed(() => {
|
||||
const isExpanded = props.hasAnyError
|
||||
|
||||
switch (props.shape) {
|
||||
case RenderShape.BOX:
|
||||
return ''
|
||||
case RenderShape.CARD:
|
||||
return isExpanded ? 'rounded-br-[20px]' : 'rounded-br-2xl'
|
||||
default:
|
||||
return isExpanded ? 'rounded-b-[20px]' : 'rounded-b-2xl'
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Returns shared size/position classes for footer tabs
|
||||
* @param isBackground If true, calculates styles for the background/right tab (Enter Subgraph)
|
||||
*/
|
||||
const getTabStyles = (isBackground = false) => {
|
||||
let sizeClasses = ''
|
||||
if (props.isCollapsed) {
|
||||
let pt = 'pt-10'
|
||||
if (isBackground) {
|
||||
pt = props.hasAnyError ? 'pt-10.5' : 'pt-9'
|
||||
}
|
||||
sizeClasses = cn('-mt-7.5 h-15', pt)
|
||||
} else {
|
||||
let pt = 'pt-12.5'
|
||||
if (isBackground) {
|
||||
pt = props.hasAnyError ? 'pt-12.5' : 'pt-11.5'
|
||||
}
|
||||
sizeClasses = cn('-mt-10 h-17.5', pt)
|
||||
}
|
||||
|
||||
return cn(
|
||||
'pointer-events-auto absolute top-full left-0 text-xs',
|
||||
footerRadiusClass.value,
|
||||
sizeClasses,
|
||||
props.hasAnyError ? '-translate-x-1 translate-y-0.5' : 'translate-y-0.5'
|
||||
)
|
||||
}
|
||||
|
||||
// Case 1 context: Split widths
|
||||
const errorTabWidth = 'w-[calc(50%+4px)]'
|
||||
const enterTabFullWidth = 'w-[calc(100%+8px)]'
|
||||
</script>
|
||||
@@ -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(
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LinearData } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { resolveNode } from '@/utils/litegraphUtil'
|
||||
|
||||
@@ -26,20 +27,25 @@ export const useAppModeStore = defineStore('appMode', () => {
|
||||
return !!app.rootGraph?.nodes?.length
|
||||
})
|
||||
|
||||
function loadSelections(data: Partial<LinearData> | undefined) {
|
||||
// Prune entries referencing nodes deleted in workflow mode.
|
||||
// Only check node existence, not widgets — dynamic widgets can
|
||||
// hide/show other widgets so a missing widget does not mean stale data.
|
||||
function pruneLinearData(data: Partial<LinearData> | undefined): LinearData {
|
||||
const rawInputs = data?.inputs ?? []
|
||||
const rawOutputs = data?.outputs ?? []
|
||||
|
||||
// Prune entries referencing nodes deleted in workflow mode.
|
||||
// Only check node existence, not widgets — dynamic widgets can
|
||||
// hide/show other widgets so a missing widget does not mean stale data.
|
||||
const inputs = app.rootGraph
|
||||
? rawInputs.filter(([nodeId]) => resolveNode(nodeId))
|
||||
: rawInputs
|
||||
const outputs = app.rootGraph
|
||||
? rawOutputs.filter((nodeId) => resolveNode(nodeId))
|
||||
: rawOutputs
|
||||
return {
|
||||
inputs: app.rootGraph
|
||||
? rawInputs.filter(([nodeId]) => resolveNode(nodeId))
|
||||
: rawInputs,
|
||||
outputs: app.rootGraph
|
||||
? rawOutputs.filter((nodeId) => resolveNode(nodeId))
|
||||
: rawOutputs
|
||||
}
|
||||
}
|
||||
|
||||
function loadSelections(data: Partial<LinearData> | undefined) {
|
||||
const { inputs, outputs } = pruneLinearData(data)
|
||||
selectedInputs.splice(0, selectedInputs.length, ...inputs)
|
||||
selectedOutputs.splice(0, selectedOutputs.length, ...outputs)
|
||||
}
|
||||
@@ -105,6 +111,8 @@ export const useAppModeStore = defineStore('appMode', () => {
|
||||
return
|
||||
}
|
||||
|
||||
useSidebarTabStore().activeSidebarTabId = null
|
||||
|
||||
setMode(
|
||||
mode.value === 'app' && hasOutputs.value
|
||||
? 'builder:arrange'
|
||||
@@ -112,7 +120,7 @@ export const useAppModeStore = defineStore('appMode', () => {
|
||||
)
|
||||
}
|
||||
|
||||
async function exitBuilder() {
|
||||
function exitBuilder() {
|
||||
resetSelectedToWorkflow()
|
||||
setMode('graph')
|
||||
}
|
||||
@@ -122,6 +130,7 @@ export const useAppModeStore = defineStore('appMode', () => {
|
||||
exitBuilder,
|
||||
hasNodes,
|
||||
hasOutputs,
|
||||
pruneLinearData,
|
||||
resetSelectedToWorkflow,
|
||||
selectedInputs,
|
||||
selectedOutputs
|
||||
|
||||
@@ -549,6 +549,13 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
() => runningJobIds.value.length
|
||||
)
|
||||
|
||||
const isActiveWorkflowRunning = computed(() => {
|
||||
if (!activeJobId.value) return false
|
||||
const path = workflowStore.activeWorkflow?.path
|
||||
if (!path) return false
|
||||
return jobIdToSessionWorkflowPath.value.get(activeJobId.value) === path
|
||||
})
|
||||
|
||||
return {
|
||||
isIdle,
|
||||
clientId,
|
||||
@@ -568,6 +575,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
runningJobIds,
|
||||
runningWorkflowCount,
|
||||
initializingJobIds,
|
||||
isActiveWorkflowRunning,
|
||||
isJobInitializing,
|
||||
clearInitializationByJobId,
|
||||
clearInitializationByJobIds,
|
||||
|
||||
@@ -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>
|
||||
|
||||