mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-07 14:09:59 +00:00
feat/fix: App mode QA updates (#9439)
## Summary Various fixes from app mode QA ## Changes - **What**: - fix: prevent inserting nodes from workflow/apps sidebar tabs - fix: hide json extension in workflow tab - fix: hide apps nav button in apps tab when already in apps mode - fix: center text on arrange page - fix: prevent IoItems from "jumping" due to stale transform after drag and drop op - fix: refactor side panels and add custom stable pixel based sizing - fix: make outputs/inputs lists in app builder scrollable - fix: fix rerun not working correctly - feat: add text to interrupt button - feat: add enter app mode button to builder toolbar - feat: add tooltip to download button on linear view - feat: show last output of workflow in arrange tab if available - feat: show download count in download all button, hide if only 1 asset to download ## Review Focus - Rerun - I am not sure why it was triggering widget actions, removing it seemed like the correct fix - useStablePrimeVueSplitter - this is a workaround for the fact it uses percent sizing, I also tried switching to reka-ui splitters, but they also only support % sizing in our version [pixel based looks to have been added in a newer version, will log an issue to upgrade & replace splitters with this] ## Screenshots (if applicable) <img width="1314" height="1129" alt="image" src="https://github.com/user-attachments/assets/c430f9d6-7c29-4853-803e-5b6fe7086fca" /> <img width="511" height="283" alt="image" src="https://github.com/user-attachments/assets/b7e594d4-70a1-41e3-8ba1-78512f2a5c8b" /> <img width="254" height="232" alt="image" src="https://github.com/user-attachments/assets/1d146399-39ea-4b0e-928c-340b74957535" /> <img width="487" height="198" alt="image" src="https://github.com/user-attachments/assets/e2ba7f5d-8ff5-47f4-9526-61ebb99514b8" /> <img width="378" height="647" alt="image" src="https://github.com/user-attachments/assets/a47a3054-9320-4327-bdc0-b0a16e19f83d" /> <img width="1016" height="476" alt="image" src="https://github.com/user-attachments/assets/479ae50e-d380-4d56-a5c9-5df142b14ed0" /> ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9439-feat-fix-App-mode-QA-updates-31a6d73d365081b38337d63207b88817) by [Unito](https://www.unito.io)
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
|
||||
@@ -204,131 +204,145 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex items-center border-b border-border-subtle p-2 font-bold">
|
||||
{{
|
||||
isArrangeMode ? t('nodeHelpPage.inputs') : t('linearMode.builder.title')
|
||||
}}
|
||||
</div>
|
||||
<DraggableList
|
||||
v-if="isArrangeMode"
|
||||
v-slot="{ dragClass }"
|
||||
v-model="appModeStore.selectedInputs"
|
||||
>
|
||||
<div
|
||||
v-for="{ nodeId, widgetName, node, widget } in arrangeInputs"
|
||||
:key="`${nodeId}: ${widgetName}`"
|
||||
:class="cn(dragClass, 'pointer-events-auto my-2 p-2')"
|
||||
:aria-label="`${widget?.label ?? widgetName} — ${node.title}`"
|
||||
>
|
||||
<div v-if="widget" class="pointer-events-none" inert>
|
||||
<WidgetItem
|
||||
:widget="widget"
|
||||
:node="node"
|
||||
show-node-name
|
||||
hidden-widget-actions
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="pointer-events-none p-1 text-sm text-muted-foreground">
|
||||
{{ widgetName }}
|
||||
<p class="text-xs italic">
|
||||
({{ t('linearMode.builder.unknownWidget') }})
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="flex items-center border-b border-border-subtle p-2 font-bold">
|
||||
{{
|
||||
isArrangeMode ? t('nodeHelpPage.inputs') : t('linearMode.builder.title')
|
||||
}}
|
||||
</div>
|
||||
</DraggableList>
|
||||
<PropertiesAccordionItem
|
||||
v-if="isSelectInputsMode"
|
||||
:label="t('nodeHelpPage.inputs')"
|
||||
enable-empty-state
|
||||
:disabled="!appModeStore.selectedInputs.length"
|
||||
class="border-b border-border-subtle"
|
||||
:tooltip="`${t('linearMode.builder.inputsDesc')}\n${t('linearMode.builder.inputsExample')}`"
|
||||
:tooltip-delay="100"
|
||||
>
|
||||
<template #label>
|
||||
<div class="flex gap-3">
|
||||
{{ t('nodeHelpPage.inputs') }}
|
||||
<i class="icon-[lucide--circle-alert] bg-muted-foreground" />
|
||||
</div>
|
||||
</template>
|
||||
<template #empty>
|
||||
<div
|
||||
class="w-full p-4 pt-2 text-muted-foreground"
|
||||
v-text="t('linearMode.builder.promptAddInputs')"
|
||||
/>
|
||||
</template>
|
||||
<div
|
||||
class="w-full p-4 pt-2 text-muted-foreground"
|
||||
v-text="t('linearMode.builder.promptAddInputs')"
|
||||
/>
|
||||
<DraggableList v-slot="{ dragClass }" v-model="appModeStore.selectedInputs">
|
||||
<IoItem
|
||||
v-for="{
|
||||
nodeId,
|
||||
widgetName,
|
||||
label,
|
||||
subLabel,
|
||||
rename
|
||||
} in inputsWithState"
|
||||
:key="`${nodeId}: ${widgetName}`"
|
||||
:class="cn(dragClass, 'my-2 rounded-lg bg-primary-background/30 p-2')"
|
||||
:title="label ?? widgetName"
|
||||
:sub-title="subLabel"
|
||||
:rename
|
||||
:remove="
|
||||
() =>
|
||||
remove(
|
||||
appModeStore.selectedInputs,
|
||||
([id, name]) => nodeId == id && widgetName === name
|
||||
)
|
||||
"
|
||||
/>
|
||||
</DraggableList>
|
||||
</PropertiesAccordionItem>
|
||||
<PropertiesAccordionItem
|
||||
v-if="isSelectOutputsMode"
|
||||
:label="t('nodeHelpPage.outputs')"
|
||||
enable-empty-state
|
||||
:disabled="!appModeStore.selectedOutputs.length"
|
||||
:tooltip="`${t('linearMode.builder.outputsDesc')}\n${t('linearMode.builder.outputsExample')}`"
|
||||
:tooltip-delay="100"
|
||||
>
|
||||
<template #label>
|
||||
<div class="flex gap-3">
|
||||
{{ t('nodeHelpPage.outputs') }}
|
||||
<i class="icon-[lucide--circle-alert] bg-muted-foreground" />
|
||||
</div>
|
||||
</template>
|
||||
<template #empty>
|
||||
<div
|
||||
class="w-full p-4 pt-2 text-muted-foreground"
|
||||
v-text="t('linearMode.builder.promptAddOutputs')"
|
||||
/>
|
||||
</template>
|
||||
<div
|
||||
class="w-full p-4 pt-2 text-muted-foreground"
|
||||
v-text="t('linearMode.builder.promptAddOutputs')"
|
||||
/>
|
||||
<DraggableList
|
||||
v-slot="{ dragClass }"
|
||||
v-model="appModeStore.selectedOutputs"
|
||||
>
|
||||
<IoItem
|
||||
v-for="([key, title], index) in outputsWithState"
|
||||
:key
|
||||
:class="
|
||||
cn(
|
||||
dragClass,
|
||||
'my-2 rounded-lg bg-warning-background/40 p-2',
|
||||
index === 0 && 'ring-2 ring-warning-background'
|
||||
)
|
||||
"
|
||||
:title
|
||||
:sub-title="String(key)"
|
||||
:remove="() => remove(appModeStore.selectedOutputs, (k) => k == key)"
|
||||
/>
|
||||
</DraggableList>
|
||||
</PropertiesAccordionItem>
|
||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||
<DraggableList
|
||||
v-if="isArrangeMode"
|
||||
v-slot="{ dragClass }"
|
||||
v-model="appModeStore.selectedInputs"
|
||||
>
|
||||
<div
|
||||
v-for="{ nodeId, widgetName, node, widget } in arrangeInputs"
|
||||
:key="`${nodeId}: ${widgetName}`"
|
||||
:class="cn(dragClass, 'pointer-events-auto my-2 p-2')"
|
||||
:aria-label="`${widget?.label ?? widgetName} — ${node.title}`"
|
||||
>
|
||||
<div v-if="widget" class="pointer-events-none" inert>
|
||||
<WidgetItem
|
||||
:widget="widget"
|
||||
:node="node"
|
||||
show-node-name
|
||||
hidden-widget-actions
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="pointer-events-none p-1 text-sm text-muted-foreground"
|
||||
>
|
||||
{{ widgetName }}
|
||||
<p class="text-xs italic">
|
||||
({{ t('linearMode.builder.unknownWidget') }})
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DraggableList>
|
||||
<PropertiesAccordionItem
|
||||
v-if="isSelectInputsMode"
|
||||
:label="t('nodeHelpPage.inputs')"
|
||||
enable-empty-state
|
||||
:disabled="!appModeStore.selectedInputs.length"
|
||||
class="border-b border-border-subtle"
|
||||
:tooltip="`${t('linearMode.builder.inputsDesc')}\n${t('linearMode.builder.inputsExample')}`"
|
||||
:tooltip-delay="100"
|
||||
>
|
||||
<template #label>
|
||||
<div class="flex gap-3">
|
||||
{{ t('nodeHelpPage.inputs') }}
|
||||
<i class="icon-[lucide--circle-alert] bg-muted-foreground" />
|
||||
</div>
|
||||
</template>
|
||||
<template #empty>
|
||||
<div
|
||||
class="w-full p-4 pt-2 text-muted-foreground"
|
||||
v-text="t('linearMode.builder.promptAddInputs')"
|
||||
/>
|
||||
</template>
|
||||
<div
|
||||
class="w-full p-4 pt-2 text-muted-foreground"
|
||||
v-text="t('linearMode.builder.promptAddInputs')"
|
||||
/>
|
||||
<DraggableList
|
||||
v-slot="{ dragClass }"
|
||||
v-model="appModeStore.selectedInputs"
|
||||
>
|
||||
<IoItem
|
||||
v-for="{
|
||||
nodeId,
|
||||
widgetName,
|
||||
label,
|
||||
subLabel,
|
||||
rename
|
||||
} in inputsWithState"
|
||||
:key="`${nodeId}: ${widgetName}`"
|
||||
:class="
|
||||
cn(dragClass, 'my-2 rounded-lg bg-primary-background/30 p-2')
|
||||
"
|
||||
:title="label ?? widgetName"
|
||||
:sub-title="subLabel"
|
||||
:rename
|
||||
:remove="
|
||||
() =>
|
||||
remove(
|
||||
appModeStore.selectedInputs,
|
||||
([id, name]) => nodeId == id && widgetName === name
|
||||
)
|
||||
"
|
||||
/>
|
||||
</DraggableList>
|
||||
</PropertiesAccordionItem>
|
||||
<PropertiesAccordionItem
|
||||
v-if="isSelectOutputsMode"
|
||||
:label="t('nodeHelpPage.outputs')"
|
||||
enable-empty-state
|
||||
:disabled="!appModeStore.selectedOutputs.length"
|
||||
:tooltip="`${t('linearMode.builder.outputsDesc')}\n${t('linearMode.builder.outputsExample')}`"
|
||||
:tooltip-delay="100"
|
||||
>
|
||||
<template #label>
|
||||
<div class="flex gap-3">
|
||||
{{ t('nodeHelpPage.outputs') }}
|
||||
<i class="icon-[lucide--circle-alert] bg-muted-foreground" />
|
||||
</div>
|
||||
</template>
|
||||
<template #empty>
|
||||
<div
|
||||
class="w-full p-4 pt-2 text-muted-foreground"
|
||||
v-text="t('linearMode.builder.promptAddOutputs')"
|
||||
/>
|
||||
</template>
|
||||
<div
|
||||
class="w-full p-4 pt-2 text-muted-foreground"
|
||||
v-text="t('linearMode.builder.promptAddOutputs')"
|
||||
/>
|
||||
<DraggableList
|
||||
v-slot="{ dragClass }"
|
||||
v-model="appModeStore.selectedOutputs"
|
||||
>
|
||||
<IoItem
|
||||
v-for="([key, title], index) in outputsWithState"
|
||||
:key
|
||||
:class="
|
||||
cn(
|
||||
dragClass,
|
||||
'my-2 rounded-lg bg-warning-background/40 p-2',
|
||||
index === 0 && 'ring-2 ring-warning-background'
|
||||
)
|
||||
"
|
||||
:title
|
||||
:sub-title="String(key)"
|
||||
:remove="
|
||||
() => remove(appModeStore.selectedOutputs, (k) => k == key)
|
||||
"
|
||||
/>
|
||||
</DraggableList>
|
||||
</PropertiesAccordionItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Teleport
|
||||
v-if="isSelectMode && !settingStore.get('Comfy.VueNodes.Enabled')"
|
||||
|
||||
@@ -19,38 +19,31 @@
|
||||
</button>
|
||||
</template>
|
||||
<template #default="{ close }">
|
||||
<button
|
||||
:class="
|
||||
cn(
|
||||
'flex w-full items-center gap-3 rounded-md border-none bg-transparent px-3 py-2 text-sm',
|
||||
hasOutputs
|
||||
? 'cursor-pointer hover:bg-secondary-background-hover'
|
||||
: 'pointer-events-none opacity-50'
|
||||
)
|
||||
"
|
||||
:disabled="!hasOutputs"
|
||||
@click="onSave(close)"
|
||||
>
|
||||
<i class="icon-[lucide--save] size-4" />
|
||||
{{ t('g.save') }}
|
||||
</button>
|
||||
<div class="my-1 border-t border-border-default" />
|
||||
<button
|
||||
class="flex w-full cursor-pointer items-center gap-3 rounded-md border-none bg-transparent px-3 py-2 text-sm hover:bg-secondary-background-hover"
|
||||
@click="onExitBuilder(close)"
|
||||
>
|
||||
<i class="icon-[lucide--square-pen] size-4" />
|
||||
{{ t('builderMenu.exitAppBuilder') }}
|
||||
</button>
|
||||
<template v-for="(item, index) in menuItems" :key="item.label">
|
||||
<div v-if="index > 0" class="my-1 border-t border-border-default" />
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
class="flex w-full items-center justify-start gap-3 rounded-md px-3 py-2 text-sm"
|
||||
:disabled="item.disabled"
|
||||
@click="item.action(close)"
|
||||
>
|
||||
<i :class="cn(item.icon, 'size-4')" />
|
||||
{{ item.label }}
|
||||
</Button>
|
||||
</template>
|
||||
</template>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
@@ -60,10 +53,30 @@ import { cn } from '@/utils/tailwindUtil'
|
||||
const { t } = useI18n()
|
||||
const appModeStore = useAppModeStore()
|
||||
const { hasOutputs } = storeToRefs(appModeStore)
|
||||
const { setMode } = useAppMode()
|
||||
const workflowService = useWorkflowService()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
|
||||
const menuItems = computed(() => [
|
||||
{
|
||||
label: t('g.save'),
|
||||
icon: 'icon-[lucide--save]',
|
||||
disabled: !hasOutputs.value,
|
||||
action: onSave
|
||||
},
|
||||
{
|
||||
label: t('builderMenu.enterAppMode'),
|
||||
icon: 'icon-[lucide--panels-top-left]',
|
||||
action: onEnterAppMode
|
||||
},
|
||||
{
|
||||
label: t('builderMenu.exitAppBuilder'),
|
||||
icon: 'icon-[lucide--square-pen]',
|
||||
action: onExitBuilder
|
||||
}
|
||||
])
|
||||
|
||||
async function onSave(close: () => void) {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (!workflow) return
|
||||
@@ -75,6 +88,11 @@ async function onSave(close: () => void) {
|
||||
}
|
||||
}
|
||||
|
||||
function onEnterAppMode(close: () => void) {
|
||||
setMode('app')
|
||||
close()
|
||||
}
|
||||
|
||||
function onExitBuilder(close: () => void) {
|
||||
void appModeStore.exitBuilder()
|
||||
close()
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
<BaseWorkflowsSidebarTab
|
||||
:title="$t('linearMode.appModeToolbar.apps')"
|
||||
:filter="isAppWorkflow"
|
||||
:label-transform="stripAppJsonSuffix"
|
||||
hide-leaf-icon
|
||||
:search-subject="$t('linearMode.appModeToolbar.apps')"
|
||||
data-testid="apps-sidebar"
|
||||
@@ -18,8 +17,14 @@
|
||||
<NoResultsPlaceholder
|
||||
button-variant="secondary"
|
||||
text-class="text-muted-foreground text-sm"
|
||||
:message="$t('linearMode.appModeToolbar.appsEmptyMessage')"
|
||||
:button-label="$t('linearMode.appModeToolbar.enterAppMode')"
|
||||
:message="
|
||||
isAppMode
|
||||
? $t('linearMode.appModeToolbar.appsEmptyMessage')
|
||||
: `${$t('linearMode.appModeToolbar.appsEmptyMessage')}\n${$t('linearMode.appModeToolbar.appsEmptyMessageAction')}`
|
||||
"
|
||||
:button-label="
|
||||
isAppMode ? undefined : $t('linearMode.appModeToolbar.enterAppMode')
|
||||
"
|
||||
@action="enterAppMode"
|
||||
/>
|
||||
</template>
|
||||
@@ -32,16 +37,12 @@ import BaseWorkflowsSidebarTab from '@/components/sidebar/tabs/BaseWorkflowsSide
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
|
||||
const { setMode } = useAppMode()
|
||||
const { isAppMode, setMode } = useAppMode()
|
||||
|
||||
function isAppWorkflow(workflow: ComfyWorkflow): boolean {
|
||||
return workflow.suffix === 'app.json'
|
||||
}
|
||||
|
||||
function stripAppJsonSuffix(label: string): string {
|
||||
return label.replace(/\.app\.json$/i, '')
|
||||
}
|
||||
|
||||
function enterAppMode() {
|
||||
setMode('app')
|
||||
}
|
||||
|
||||
@@ -154,6 +154,7 @@ import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue
|
||||
import WorkflowTreeLeaf from '@/components/sidebar/tabs/workflows/WorkflowTreeLeaf.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTreeExpansion } from '@/composables/useTreeExpansion'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import {
|
||||
@@ -163,26 +164,23 @@ import {
|
||||
} from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import type { TreeExplorerNode, TreeNode } from '@/types/treeExplorerTypes'
|
||||
import { ensureWorkflowSuffix, getWorkflowSuffix } from '@/utils/formatUtil'
|
||||
import {
|
||||
ensureWorkflowSuffix,
|
||||
getFilenameDetails,
|
||||
getWorkflowSuffix
|
||||
} from '@/utils/formatUtil'
|
||||
import { buildTree, sortedTree } from '@/utils/treeUtil'
|
||||
|
||||
const {
|
||||
title,
|
||||
filter,
|
||||
searchSubject,
|
||||
dataTestid,
|
||||
labelTransform,
|
||||
hideLeafIcon
|
||||
} = defineProps<{
|
||||
const { title, filter, searchSubject, dataTestid, hideLeafIcon } = defineProps<{
|
||||
title: string
|
||||
filter?: (workflow: ComfyWorkflow) => boolean
|
||||
searchSubject: string
|
||||
dataTestid: string
|
||||
labelTransform?: (label: string) => string
|
||||
hideLeafIcon?: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { isAppMode } = useAppMode()
|
||||
|
||||
const applyFilter = (workflows: ComfyWorkflow[]) =>
|
||||
filter ? workflows.filter(filter) : workflows
|
||||
@@ -304,14 +302,18 @@ const renderTreeNode = (
|
||||
},
|
||||
contextMenuItems() {
|
||||
return [
|
||||
{
|
||||
label: t('g.insert'),
|
||||
icon: 'pi pi-file-export',
|
||||
command: async () => {
|
||||
const workflow = node.data
|
||||
await workflowService.insertWorkflow(workflow)
|
||||
}
|
||||
},
|
||||
...(isAppMode.value
|
||||
? []
|
||||
: [
|
||||
{
|
||||
label: t('g.insert'),
|
||||
icon: 'pi pi-file-export',
|
||||
command: async () => {
|
||||
const workflow = node.data
|
||||
await workflowService.insertWorkflow(workflow)
|
||||
}
|
||||
}
|
||||
]),
|
||||
{
|
||||
label: t('g.duplicate'),
|
||||
icon: 'pi pi-file-export',
|
||||
@@ -326,8 +328,7 @@ const renderTreeNode = (
|
||||
}
|
||||
: { handleClick }
|
||||
|
||||
const label =
|
||||
node.leaf && labelTransform ? labelTransform(node.label) : node.label
|
||||
const label = node.leaf ? getFilenameDetails(node.label).filename : node.label
|
||||
|
||||
return {
|
||||
key: node.key,
|
||||
|
||||
133
src/composables/useStablePrimeVueSplitterSizer.test.ts
Normal file
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
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 }
|
||||
}
|
||||
@@ -3160,12 +3160,13 @@
|
||||
"runCount": "Number of runs",
|
||||
"rerun": "Rerun",
|
||||
"reuseParameters": "Reuse Parameters",
|
||||
"downloadAll": "Download All",
|
||||
"downloadAll": "Download {count} assets from this run",
|
||||
"viewJob": "View Job",
|
||||
"enterNodeGraph": "Enter node graph",
|
||||
"emptyWorkflowExplanation": "Your workflow is empty. You need some nodes first to start building an app.",
|
||||
"backToWorkflow": "Back to workflow",
|
||||
"loadTemplate": "Load a template",
|
||||
"cancelThisRun": "Cancel this run",
|
||||
"welcome": {
|
||||
"title": "App Mode",
|
||||
"message": "A simplified view that hides the node graph so you can focus on creating.",
|
||||
@@ -3177,7 +3178,8 @@
|
||||
"appModeToolbar": {
|
||||
"appBuilder": "App builder",
|
||||
"apps": "Apps",
|
||||
"appsEmptyMessage": "Saved apps will show up here.\nClick below to build your first app.",
|
||||
"appsEmptyMessage": "Saved apps will show up here.",
|
||||
"appsEmptyMessageAction": "Click below to build your first app.",
|
||||
"enterAppMode": "Enter app mode"
|
||||
},
|
||||
"arrange": {
|
||||
@@ -3519,6 +3521,7 @@
|
||||
"emptyWorkflowPrompt": "Do you want to start with a template?"
|
||||
},
|
||||
"builderMenu": {
|
||||
"enterAppMode": "Enter app mode",
|
||||
"exitAppBuilder": "Exit app builder"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { defineAsyncComponent, ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
@@ -15,23 +15,15 @@ import LatentPreview from '@/renderer/extensions/linearMode/LatentPreview.vue'
|
||||
import LinearWelcome from '@/renderer/extensions/linearMode/LinearWelcome.vue'
|
||||
import LinearArrange from '@/renderer/extensions/linearMode/LinearArrange.vue'
|
||||
import LinearFeedback from '@/renderer/extensions/linearMode/LinearFeedback.vue'
|
||||
import MediaOutputPreview from '@/renderer/extensions/linearMode/MediaOutputPreview.vue'
|
||||
import OutputHistory from '@/renderer/extensions/linearMode/OutputHistory.vue'
|
||||
import { useOutputHistory } from '@/renderer/extensions/linearMode/useOutputHistory'
|
||||
import type { OutputSelection } from '@/renderer/extensions/linearMode/linearModeTypes'
|
||||
import VideoPreview from '@/renderer/extensions/linearMode/VideoPreview.vue'
|
||||
import { getMediaType } from '@/renderer/extensions/linearMode/mediaTypes'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
import { collectAllNodes } from '@/utils/graphTraversalUtil'
|
||||
import { executeWidgetsCallback } from '@/utils/litegraphUtil'
|
||||
|
||||
// Lazy-loaded to avoid pulling THREE.js into the main bundle
|
||||
const Preview3d = defineAsyncComponent(
|
||||
() => import('@/renderer/extensions/linearMode/Preview3d.vue')
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
@@ -73,17 +65,12 @@ async function loadWorkflow(item: AssetItem | undefined) {
|
||||
const changeTracker = useWorkflowStore().activeWorkflow?.changeTracker
|
||||
if (!changeTracker) return app.loadGraphData(workflow)
|
||||
changeTracker.redoQueue = []
|
||||
changeTracker.updateState([workflow], changeTracker.undoQueue)
|
||||
await changeTracker.updateState([workflow], changeTracker.undoQueue)
|
||||
}
|
||||
|
||||
async function rerun(e: Event) {
|
||||
if (!runButtonClick) return
|
||||
await loadWorkflow(selectedItem.value)
|
||||
//FIXME don't use timeouts here
|
||||
//Currently seeds fail to properly update even with timeouts?
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
executeWidgetsCallback(collectAllNodes(app.rootGraph), 'afterQueued')
|
||||
|
||||
runButtonClick(e)
|
||||
}
|
||||
</script>
|
||||
@@ -106,6 +93,7 @@ async function rerun(e: Event) {
|
||||
</template>
|
||||
<Button
|
||||
v-if="selectedOutput"
|
||||
v-tooltip.top="t('g.download')"
|
||||
size="icon"
|
||||
:aria-label="t('g.download')"
|
||||
@click="
|
||||
@@ -119,21 +107,26 @@ async function rerun(e: Event) {
|
||||
<Button
|
||||
v-if="!executionStore.isIdle && !selectedItem"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="t('menu.interrupt')"
|
||||
@click="commandStore.execute('Comfy.Interrupt')"
|
||||
>
|
||||
<i class="icon-[lucide--x]" />
|
||||
{{ t('linearMode.cancelThisRun') }}
|
||||
</Button>
|
||||
<Popover
|
||||
v-if="selectedItem"
|
||||
:entries="[
|
||||
{
|
||||
icon: 'icon-[lucide--download]',
|
||||
label: t('linearMode.downloadAll'),
|
||||
command: () => downloadAsset(selectedItem)
|
||||
},
|
||||
{ separator: true },
|
||||
...(allOutputs(selectedItem).length > 1
|
||||
? [
|
||||
{
|
||||
icon: 'icon-[lucide--download]',
|
||||
label: t('linearMode.downloadAll', {
|
||||
count: allOutputs(selectedItem).length
|
||||
}),
|
||||
command: () => downloadAsset(selectedItem)
|
||||
},
|
||||
{ separator: true }
|
||||
]
|
||||
: []),
|
||||
{
|
||||
icon: 'icon-[lucide--trash-2]',
|
||||
label: t('queue.jobMenu.deleteAsset'),
|
||||
@@ -143,32 +136,14 @@ async function rerun(e: Event) {
|
||||
/>
|
||||
</section>
|
||||
<ImagePreview
|
||||
v-if="
|
||||
(canShowPreview && latentPreview) ||
|
||||
getMediaType(selectedOutput) === 'images'
|
||||
"
|
||||
v-if="canShowPreview && latentPreview"
|
||||
:mobile
|
||||
:src="(canShowPreview && latentPreview) || selectedOutput!.url"
|
||||
:src="latentPreview"
|
||||
/>
|
||||
<VideoPreview
|
||||
v-else-if="getMediaType(selectedOutput) === 'video'"
|
||||
:src="selectedOutput!.url"
|
||||
class="flex-1 object-contain md:p-3 md:contain-size"
|
||||
/>
|
||||
<audio
|
||||
v-else-if="getMediaType(selectedOutput) === 'audio'"
|
||||
class="m-auto w-full"
|
||||
controls
|
||||
:src="selectedOutput!.url"
|
||||
/>
|
||||
<article
|
||||
v-else-if="getMediaType(selectedOutput) === 'text'"
|
||||
class="m-auto my-12 w-full max-w-lg overflow-y-auto"
|
||||
v-text="selectedOutput!.url"
|
||||
/>
|
||||
<Preview3d
|
||||
v-else-if="getMediaType(selectedOutput) === '3d'"
|
||||
:model-url="selectedOutput!.url"
|
||||
<MediaOutputPreview
|
||||
v-else-if="selectedOutput"
|
||||
:output="selectedOutput"
|
||||
:mobile
|
||||
/>
|
||||
<LatentPreview v-else-if="queueStore.runningTasks.length > 0" />
|
||||
<LinearArrange v-else-if="isArrangeMode" />
|
||||
|
||||
55
src/renderer/extensions/linearMode/MediaOutputPreview.vue
Normal file
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>
|
||||
@@ -314,18 +314,22 @@ export class DraggableList extends EventTarget {
|
||||
unsetDraggableItem() {
|
||||
this.draggableItem.style = null
|
||||
this.draggableItem.classList.remove('is-draggable')
|
||||
this.draggableItem.classList.add('is-idle')
|
||||
this.draggableItem = null
|
||||
}
|
||||
|
||||
unsetItemState() {
|
||||
this.getIdleItems().forEach((item) => {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
this.getIdleItems().forEach((item: HTMLElement) => {
|
||||
delete item.dataset.isAbove
|
||||
// @ts-expect-error fixme ts strict error
|
||||
delete item.dataset.isToggled
|
||||
// @ts-expect-error fixme ts strict error
|
||||
item.style.transform = ''
|
||||
|
||||
// Defer re-adding is-idle (which enables CSS transitions) until after
|
||||
// the browser paints items in their final positions. Without this,
|
||||
// the transition animates the stale drag transform.
|
||||
item.classList.remove('is-idle')
|
||||
requestAnimationFrame(() => {
|
||||
item.classList.add('is-idle')
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { breakpointsTailwind, unrefElement, useBreakpoints } from '@vueuse/core'
|
||||
import type { MaybeElement } from '@vueuse/core'
|
||||
import Splitter from 'primevue/splitter'
|
||||
import SplitterPanel from 'primevue/splitterpanel'
|
||||
import { storeToRefs } from 'pinia'
|
||||
@@ -19,6 +20,7 @@ import LinearProgressBar from '@/renderer/extensions/linearMode/LinearProgressBa
|
||||
import MobileDisplay from '@/renderer/extensions/linearMode/MobileDisplay.vue'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useStablePrimeVueSplitterSizer } from '@/composables/useStablePrimeVueSplitterSizer'
|
||||
import {
|
||||
BUILDER_MIN_SIZE,
|
||||
CENTER_PANEL_SIZE,
|
||||
@@ -39,9 +41,6 @@ const activeTab = computed(() => workspaceStore.sidebarTab.activeSidebarTab)
|
||||
const sidebarOnLeft = computed(
|
||||
() => settingStore.get('Comfy.Sidebar.Location') === 'left'
|
||||
)
|
||||
// Builder panel is always on the opposite side of the sidebar.
|
||||
// In arrange mode we render 3 panels to match the overlay structure,
|
||||
// so the same stateKey percentage maps to the same pixel width.
|
||||
const showLeftBuilder = computed(
|
||||
() => !sidebarOnLeft.value && isArrangeMode.value
|
||||
)
|
||||
@@ -67,6 +66,25 @@ function sidePanelMinSize(isBuilder: boolean, isHidden: boolean) {
|
||||
return SIDEBAR_MIN_SIZE
|
||||
}
|
||||
|
||||
// Remount splitter when panel structure changes so initializePanels()
|
||||
// properly sets flexBasis for the current set of panels.
|
||||
const splitterKey = computed(() => {
|
||||
const left = hasLeftPanel.value ? 'L' : ''
|
||||
const right = hasRightPanel.value ? 'R' : ''
|
||||
return isArrangeMode.value ? 'arrange' : `app-${left}${right}`
|
||||
})
|
||||
|
||||
const leftPanelRef = useTemplateRef<MaybeElement>('leftPanel')
|
||||
const rightPanelRef = useTemplateRef<MaybeElement>('rightPanel')
|
||||
|
||||
const { onResizeEnd } = useStablePrimeVueSplitterSizer(
|
||||
[
|
||||
{ ref: leftPanelRef, storageKey: 'Comfy.LinearView.LeftPanelWidth' },
|
||||
{ ref: rightPanelRef, storageKey: 'Comfy.LinearView.RightPanelWidth' }
|
||||
],
|
||||
[activeTab, splitterKey]
|
||||
)
|
||||
|
||||
const TYPEFORM_WIDGET_ID = 'gmVqFi8l'
|
||||
|
||||
const bottomLeftRef = useTemplateRef('bottomLeftRef')
|
||||
@@ -86,16 +104,15 @@ const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
|
||||
</div>
|
||||
</div>
|
||||
<Splitter
|
||||
:key="isArrangeMode ? 'arrange' : 'normal'"
|
||||
:key="splitterKey"
|
||||
class="bg-comfy-menu-secondary-bg h-[calc(100%-var(--workflow-tabs-height))] w-full border-none"
|
||||
:state-key="isArrangeMode ? 'builder-splitter' : undefined"
|
||||
:state-storage="isArrangeMode ? 'local' : undefined"
|
||||
@resizestart="({ originalEvent }) => originalEvent.preventDefault()"
|
||||
@resizestart="$event.originalEvent.preventDefault()"
|
||||
@resizeend="onResizeEnd"
|
||||
>
|
||||
<SplitterPanel
|
||||
v-if="hasLeftPanel"
|
||||
id="linearLeftPanel"
|
||||
:size="isArrangeMode ? SIDE_PANEL_SIZE : 1"
|
||||
ref="leftPanel"
|
||||
:size="SIDE_PANEL_SIZE"
|
||||
:min-size="
|
||||
sidePanelMinSize(showLeftBuilder, showRightBuilder && !activeTab)
|
||||
"
|
||||
@@ -104,17 +121,15 @@ const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
|
||||
"
|
||||
:class="
|
||||
cn(
|
||||
'arrange-panel outline-none',
|
||||
showLeftBuilder ? 'min-w-78 bg-comfy-menu-bg' : 'min-w-min'
|
||||
'arrange-panel overflow-hidden outline-none',
|
||||
showLeftBuilder ? 'min-w-78 bg-comfy-menu-bg' : 'min-w-78'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div v-if="showLeftBuilder" class="h-full overflow-y-auto">
|
||||
<AppBuilder />
|
||||
</div>
|
||||
<AppBuilder v-if="showLeftBuilder" />
|
||||
<div
|
||||
v-else-if="sidebarOnLeft && activeTab"
|
||||
class="flex h-full border-r border-border-subtle"
|
||||
class="size-full overflow-x-hidden border-r border-border-subtle"
|
||||
>
|
||||
<ExtensionSlot :extension="activeTab" />
|
||||
</div>
|
||||
@@ -126,7 +141,7 @@ const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
|
||||
</SplitterPanel>
|
||||
<SplitterPanel
|
||||
id="linearCenterPanel"
|
||||
:size="isArrangeMode ? CENTER_PANEL_SIZE : 98"
|
||||
:size="CENTER_PANEL_SIZE"
|
||||
class="relative flex min-w-0 flex-col gap-4 text-muted-foreground outline-none"
|
||||
>
|
||||
<LinearProgressBar
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user