mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-30 03:01:54 +00:00
Add App I/O selection system (#8965)
Adds a system for selecting the inputs and outputs which should be displayed when inside linear mode. Functions only in litegraph currently. Vue support will require a separate, larger PR. Inputs and outputs can be re-ordered by dragging and dropping on the side panel.  ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8965-Add-App-I-O-selection-system-30b6d73d365081569b36c1682a1fdbc5) by [Unito](https://www.unito.io)
This commit is contained in:
@@ -25,7 +25,7 @@
|
|||||||
<!-- First panel: sidebar when left, properties when right -->
|
<!-- First panel: sidebar when left, properties when right -->
|
||||||
<SplitterPanel
|
<SplitterPanel
|
||||||
v-if="
|
v-if="
|
||||||
!focusMode && (sidebarLocation === 'left' || rightSidePanelVisible)
|
!focusMode && (sidebarLocation === 'left' || showOffsideSplitter)
|
||||||
"
|
"
|
||||||
:class="
|
:class="
|
||||||
sidebarLocation === 'left'
|
sidebarLocation === 'left'
|
||||||
@@ -85,7 +85,7 @@
|
|||||||
<!-- Last panel: properties when left, sidebar when right -->
|
<!-- Last panel: properties when left, sidebar when right -->
|
||||||
<SplitterPanel
|
<SplitterPanel
|
||||||
v-if="
|
v-if="
|
||||||
!focusMode && (sidebarLocation === 'right' || rightSidePanelVisible)
|
!focusMode && (sidebarLocation === 'right' || showOffsideSplitter)
|
||||||
"
|
"
|
||||||
:class="
|
:class="
|
||||||
sidebarLocation === 'right'
|
sidebarLocation === 'right'
|
||||||
@@ -124,6 +124,7 @@ import { computed } from 'vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
|
import { useAppModeStore } from '@/stores/appModeStore'
|
||||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||||
@@ -144,9 +145,13 @@ const unifiedWidth = computed(() =>
|
|||||||
|
|
||||||
const { focusMode } = storeToRefs(workspaceStore)
|
const { focusMode } = storeToRefs(workspaceStore)
|
||||||
|
|
||||||
|
const appModeStore = useAppModeStore()
|
||||||
const { activeSidebarTabId, activeSidebarTab } = storeToRefs(sidebarTabStore)
|
const { activeSidebarTabId, activeSidebarTab } = storeToRefs(sidebarTabStore)
|
||||||
const { bottomPanelVisible } = storeToRefs(useBottomPanelStore())
|
const { bottomPanelVisible } = storeToRefs(useBottomPanelStore())
|
||||||
const { isOpen: rightSidePanelVisible } = storeToRefs(rightSidePanelStore)
|
const { isOpen: rightSidePanelVisible } = storeToRefs(rightSidePanelStore)
|
||||||
|
const showOffsideSplitter = computed(
|
||||||
|
() => rightSidePanelVisible.value || appModeStore.mode === 'builder:select'
|
||||||
|
)
|
||||||
|
|
||||||
const sidebarPanelVisible = computed(() => activeSidebarTab.value !== null)
|
const sidebarPanelVisible = computed(() => activeSidebarTab.value !== null)
|
||||||
|
|
||||||
|
|||||||
324
src/components/builder/AppBuilder.vue
Normal file
324
src/components/builder/AppBuilder.vue
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { remove } from 'es-toolkit'
|
||||||
|
import { computed, ref, toValue } from 'vue'
|
||||||
|
import type { MaybeRef } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import DraggableList from '@/components/common/DraggableList.vue'
|
||||||
|
import IoItem from '@/components/builder/IoItem.vue'
|
||||||
|
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
|
||||||
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
|
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 type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||||
|
import { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget'
|
||||||
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
|
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||||
|
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||||
|
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||||
|
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
|
||||||
|
import { app } from '@/scripts/app'
|
||||||
|
import { DOMWidgetImpl } from '@/scripts/domWidget'
|
||||||
|
import { useDialogService } from '@/services/dialogService'
|
||||||
|
import { useAppModeStore } from '@/stores/appModeStore'
|
||||||
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
|
type BoundStyle = { top: string; left: string; width: string; height: string }
|
||||||
|
|
||||||
|
const appModeStore = useAppModeStore()
|
||||||
|
const canvasInteractions = useCanvasInteractions()
|
||||||
|
const canvasStore = useCanvasStore()
|
||||||
|
const settingStore = useSettingStore()
|
||||||
|
const workflowStore = useWorkflowStore()
|
||||||
|
const { t } = useI18n()
|
||||||
|
const canvas: LGraphCanvas = canvasStore.getCanvas()
|
||||||
|
|
||||||
|
const hoveringSelectable = ref(false)
|
||||||
|
|
||||||
|
workflowStore.activeWorkflow?.changeTracker?.reset()
|
||||||
|
|
||||||
|
const inputsWithState = computed(() =>
|
||||||
|
appModeStore.selectedInputs.map(([nodeId, widgetName]) => {
|
||||||
|
const node = app.rootGraph.getNodeById(nodeId)
|
||||||
|
const widget = node?.widgets?.find((w) => w.name === widgetName)
|
||||||
|
if (!node || !widget) return { nodeId, widgetName }
|
||||||
|
|
||||||
|
const input = node.inputs.find((i) => i.widget?.name === widget.name)
|
||||||
|
const rename = input && (() => renameWidget(widget, input))
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodeId,
|
||||||
|
widgetName,
|
||||||
|
label: widget.label,
|
||||||
|
subLabel: node.title,
|
||||||
|
rename
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const outputsWithState = computed<[NodeId, string][]>(() =>
|
||||||
|
appModeStore.selectedOutputs.map((nodeId) => [
|
||||||
|
nodeId,
|
||||||
|
app.rootGraph.getNodeById(nodeId)?.title ?? String(nodeId)
|
||||||
|
])
|
||||||
|
)
|
||||||
|
|
||||||
|
async function renameWidget(widget: IBaseWidget, input: INodeInputSlot) {
|
||||||
|
const newLabel = await useDialogService().prompt({
|
||||||
|
title: t('g.rename'),
|
||||||
|
message: t('g.enterNewNamePrompt'),
|
||||||
|
defaultValue: widget.label,
|
||||||
|
placeholder: widget.name
|
||||||
|
})
|
||||||
|
if (newLabel === null) return
|
||||||
|
widget.label = newLabel || undefined
|
||||||
|
input.label = newLabel || undefined
|
||||||
|
widget.callback?.(widget.value)
|
||||||
|
useCanvasStore().canvas?.setDirty(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHovered(
|
||||||
|
e: MouseEvent
|
||||||
|
): undefined | [LGraphNode, undefined] | [LGraphNode, IBaseWidget] {
|
||||||
|
const { graph } = canvas
|
||||||
|
if (!canvas || !graph) return
|
||||||
|
|
||||||
|
if (settingStore.get('Comfy.VueNodes.Enabled')) return undefined
|
||||||
|
if (!e) return
|
||||||
|
|
||||||
|
canvas.adjustMouseEvent(e)
|
||||||
|
const node = graph.getNodeOnPos(e.canvasX, e.canvasY)
|
||||||
|
if (!node) return
|
||||||
|
|
||||||
|
const widget = node.getWidgetOnPos(e.canvasX, e.canvasY, false)
|
||||||
|
|
||||||
|
if (widget || node.constructor.nodeData?.output_node) return [node, widget]
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBounding(nodeId: NodeId, widgetName?: string) {
|
||||||
|
if (settingStore.get('Comfy.VueNodes.Enabled')) return undefined
|
||||||
|
const node = app.rootGraph.getNodeById(nodeId)
|
||||||
|
if (!node) return
|
||||||
|
|
||||||
|
const titleOffset =
|
||||||
|
node.title_mode === TitleMode.NORMAL_TITLE ? LiteGraph.NODE_TITLE_HEIGHT : 0
|
||||||
|
|
||||||
|
if (!widgetName)
|
||||||
|
return {
|
||||||
|
width: `${node.size[0]}px`,
|
||||||
|
height: `${node.size[1] + titleOffset}px`,
|
||||||
|
left: `${node.pos[0]}px`,
|
||||||
|
top: `${node.pos[1] - titleOffset}px`
|
||||||
|
}
|
||||||
|
const widget = node.widgets?.find((w) => w.name === widgetName)
|
||||||
|
if (!widget) return
|
||||||
|
|
||||||
|
const margin = widget instanceof DOMWidgetImpl ? widget.margin : undefined
|
||||||
|
const marginX = margin ?? BaseWidget.margin
|
||||||
|
const height =
|
||||||
|
(widget.computedHeight !== undefined
|
||||||
|
? widget.computedHeight - 4
|
||||||
|
: LiteGraph.NODE_WIDGET_HEIGHT) - (margin ? 2 * margin - 4 : 0)
|
||||||
|
return {
|
||||||
|
width: `${node.size[0] - marginX * 2}px`,
|
||||||
|
height: `${height}px`,
|
||||||
|
left: `${node.pos[0] + marginX}px`,
|
||||||
|
top: `${node.pos[1] + widget.y + (margin ?? 0)}px`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDown(e: MouseEvent) {
|
||||||
|
const [node] = getHovered(e) ?? []
|
||||||
|
if (!node || e.button > 0) canvasInteractions.forwardEventToCanvas(e)
|
||||||
|
}
|
||||||
|
function handleClick(e: MouseEvent) {
|
||||||
|
const [node, widget] = getHovered(e) ?? []
|
||||||
|
if (!node) return canvasInteractions.forwardEventToCanvas(e)
|
||||||
|
|
||||||
|
if (!widget) {
|
||||||
|
if (!node.constructor.nodeData?.output_node)
|
||||||
|
return canvasInteractions.forwardEventToCanvas(e)
|
||||||
|
const index = appModeStore.selectedOutputs.findIndex((id) => id === node.id)
|
||||||
|
if (index === -1) appModeStore.selectedOutputs.push(node.id)
|
||||||
|
else appModeStore.selectedOutputs.splice(index, 1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = appModeStore.selectedInputs.findIndex(
|
||||||
|
([nodeId, widgetName]) => node.id === nodeId && widget.name === widgetName
|
||||||
|
)
|
||||||
|
if (index === -1) appModeStore.selectedInputs.push([node.id, widget.name])
|
||||||
|
else appModeStore.selectedInputs.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeToDisplayTuple(
|
||||||
|
n: LGraphNode
|
||||||
|
): [NodeId, MaybeRef<BoundStyle> | undefined, boolean] {
|
||||||
|
return [
|
||||||
|
n.id,
|
||||||
|
getBounding(n.id),
|
||||||
|
appModeStore.selectedOutputs.some((id) => n.id === id)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderedOutputs = computed(() => {
|
||||||
|
void appModeStore.selectedOutputs.length
|
||||||
|
return canvas
|
||||||
|
.graph!.nodes.filter((n) => n.constructor.nodeData?.output_node)
|
||||||
|
.map(nodeToDisplayTuple)
|
||||||
|
})
|
||||||
|
const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||||
|
() =>
|
||||||
|
appModeStore.selectedInputs.map(([nodeId, widgetName]) => [
|
||||||
|
`${nodeId}: ${widgetName}`,
|
||||||
|
getBounding(nodeId, widgetName)
|
||||||
|
])
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="flex font-bold p-2 border-border-subtle border-b items-center">
|
||||||
|
{{ t('linearMode.builder.title') }}
|
||||||
|
<Button class="ml-auto" @click="appModeStore.exitBuilder">
|
||||||
|
{{ t('linearMode.builder.exit') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<PropertiesAccordionItem
|
||||||
|
:label="t('nodeHelpPage.inputs')"
|
||||||
|
enable-empty-state
|
||||||
|
:disabled="!appModeStore.selectedInputs.length"
|
||||||
|
class="border-border-subtle border-b"
|
||||||
|
:tooltip="`${t('linearMode.builder.inputsDesc')}\n${t('linearMode.builder.inputsExample')}`"
|
||||||
|
>
|
||||||
|
<template #label>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
{{ t('nodeHelpPage.inputs') }}
|
||||||
|
<i class="bg-muted-foreground icon-[lucide--circle-alert]" />
|
||||||
|
</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, 'bg-primary-background/30 p-2 my-2 rounded-lg')"
|
||||||
|
:title="label ?? widgetName"
|
||||||
|
:sub-title="subLabel"
|
||||||
|
:rename
|
||||||
|
:remove="
|
||||||
|
() =>
|
||||||
|
remove(
|
||||||
|
appModeStore.selectedInputs,
|
||||||
|
([id, name]) => nodeId === id && widgetName === name
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</DraggableList>
|
||||||
|
</PropertiesAccordionItem>
|
||||||
|
<PropertiesAccordionItem
|
||||||
|
:label="t('nodeHelpPage.outputs')"
|
||||||
|
enable-empty-state
|
||||||
|
:disabled="!appModeStore.selectedOutputs.length"
|
||||||
|
:tooltip="`${t('linearMode.builder.outputsDesc')}\n${t('linearMode.builder.outputsExample')}`"
|
||||||
|
>
|
||||||
|
<template #label>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
{{ t('nodeHelpPage.outputs') }}
|
||||||
|
<i class="bg-muted-foreground icon-[lucide--circle-alert]" />
|
||||||
|
</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,
|
||||||
|
'bg-warning-background/40 p-2 my-2 rounded-lg',
|
||||||
|
index === 0 && 'ring-warning-background ring-2'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:title
|
||||||
|
:sub-title="String(key)"
|
||||||
|
:remove="() => remove(appModeStore.selectedOutputs, (k) => k === key)"
|
||||||
|
/>
|
||||||
|
</DraggableList>
|
||||||
|
</PropertiesAccordionItem>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'absolute w-full h-full pointer-events-auto',
|
||||||
|
hoveringSelectable ? 'cursor-pointer' : 'cursor-grab'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@pointerdown="handleDown"
|
||||||
|
@pointermove="hoveringSelectable = !!getHovered($event)"
|
||||||
|
@click="handleClick"
|
||||||
|
@wheel="canvasInteractions.forwardEventToCanvas"
|
||||||
|
>
|
||||||
|
<TransformPane :canvas="canvasStore.getCanvas()">
|
||||||
|
<div
|
||||||
|
v-for="[key, style] in renderedInputs"
|
||||||
|
:key
|
||||||
|
:style="toValue(style)"
|
||||||
|
class="fixed bg-primary-background/30 rounded-lg"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-for="[key, style, isSelected] in renderedOutputs"
|
||||||
|
:key
|
||||||
|
:style="toValue(style)"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'fixed ring-warning-background ring-5 rounded-2xl',
|
||||||
|
!isSelected && 'ring-warning-background/50'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div class="absolute top-0 right-0 size-8">
|
||||||
|
<div
|
||||||
|
v-if="isSelected"
|
||||||
|
class="absolute -top-1/2 -right-1/2 size-full p-2 bg-warning-background rounded-lg"
|
||||||
|
>
|
||||||
|
<i class="icon-[lucide--check] bg-text-foreground size-full" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="absolute -top-1/2 -right-1/2 size-full ring-warning-background/50 ring-4 ring-inset bg-component-node-background rounded-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TransformPane>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
@@ -62,6 +62,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useEventListener } from '@vueuse/core'
|
||||||
|
|
||||||
import { useAppModeStore } from '@/stores/appModeStore'
|
import { useAppModeStore } from '@/stores/appModeStore'
|
||||||
import type { AppMode } from '@/stores/appModeStore'
|
import type { AppMode } from '@/stores/appModeStore'
|
||||||
@@ -75,6 +76,14 @@ import type { BuilderToolbarStep } from './types'
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const appModeStore = useAppModeStore()
|
const appModeStore = useAppModeStore()
|
||||||
|
|
||||||
|
useEventListener(document, 'keydown', (e: KeyboardEvent) => {
|
||||||
|
if (e.key !== 'Escape') return
|
||||||
|
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
void appModeStore.exitBuilder()
|
||||||
|
})
|
||||||
|
|
||||||
const activeStep = computed(() =>
|
const activeStep = computed(() =>
|
||||||
appModeStore.isBuilderSaving ? 'save' : appModeStore.mode
|
appModeStore.isBuilderSaving ? 'save' : appModeStore.mode
|
||||||
)
|
)
|
||||||
|
|||||||
46
src/components/builder/IoItem.vue
Normal file
46
src/components/builder/IoItem.vue
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import Popover from '@/components/ui/Popover.vue'
|
||||||
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const { rename, remove } = defineProps<{
|
||||||
|
title: string
|
||||||
|
subTitle?: string
|
||||||
|
rename?: () => void
|
||||||
|
remove?: () => void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const entries = computed(() => {
|
||||||
|
const items = []
|
||||||
|
if (rename)
|
||||||
|
items.push({
|
||||||
|
label: t('g.rename'),
|
||||||
|
command: rename,
|
||||||
|
icon: 'icon-[lucide--pencil]'
|
||||||
|
})
|
||||||
|
if (remove)
|
||||||
|
items.push({
|
||||||
|
label: t('g.delete'),
|
||||||
|
command: remove,
|
||||||
|
icon: 'icon-[lucide--trash-2]'
|
||||||
|
})
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="p-2 my-2 rounded-lg flex items-center-safe">
|
||||||
|
<span class="mr-auto" v-text="title" />
|
||||||
|
<span class="text-muted-foreground mr-2 text-end" v-text="subTitle" />
|
||||||
|
<Popover :entries>
|
||||||
|
<template #button>
|
||||||
|
<Button variant="muted-textonly">
|
||||||
|
<i class="icon-[lucide--ellipsis]" />
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -33,12 +33,10 @@ export function useBuilderSave() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Update this to show the save dialog if it is temp OR if the user has not saved app mode before.
|
if (!workflow.isTemporary && workflow.activeState.extra?.linearMode) {
|
||||||
// If they have saved app mode before, just save the workflow, but use the initial app mode state not current.
|
|
||||||
|
|
||||||
if (!workflow.isTemporary) {
|
|
||||||
try {
|
try {
|
||||||
workflow.changeTracker?.checkState()
|
workflow.changeTracker?.checkState()
|
||||||
|
appModeStore.saveSelectedToWorkflow()
|
||||||
await workflowService.saveWorkflow(workflow)
|
await workflowService.saveWorkflow(workflow)
|
||||||
showSuccessDialog(workflow.filename, appModeStore.isAppMode)
|
showSuccessDialog(workflow.filename, appModeStore.isAppMode)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -75,6 +73,7 @@ export function useBuilderSave() {
|
|||||||
const workflow = workflowStore.activeWorkflow
|
const workflow = workflowStore.activeWorkflow
|
||||||
if (!workflow) return
|
if (!workflow) return
|
||||||
|
|
||||||
|
appModeStore.saveSelectedToWorkflow()
|
||||||
const saved = await workflowService.saveWorkflowAs(workflow, {
|
const saved = await workflowService.saveWorkflowAs(workflow, {
|
||||||
filename,
|
filename,
|
||||||
openAsApp
|
openAsApp
|
||||||
|
|||||||
59
src/components/common/DraggableList.vue
Normal file
59
src/components/common/DraggableList.vue
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<script setup lang="ts" generic="T">
|
||||||
|
import { onBeforeUnmount, ref, useTemplateRef, watchPostEffect } from 'vue'
|
||||||
|
|
||||||
|
import { DraggableList } from '@/scripts/ui/draggableList'
|
||||||
|
|
||||||
|
const modelValue = defineModel<T[]>({ required: true })
|
||||||
|
const draggableList = ref<DraggableList>()
|
||||||
|
const draggableItems = useTemplateRef('draggableItems')
|
||||||
|
|
||||||
|
watchPostEffect(() => {
|
||||||
|
void modelValue.value.length
|
||||||
|
draggableList.value?.dispose()
|
||||||
|
if (!draggableItems.value?.children?.length) return
|
||||||
|
draggableList.value = new DraggableList(
|
||||||
|
draggableItems.value,
|
||||||
|
'.draggable-item'
|
||||||
|
)
|
||||||
|
draggableList.value.applyNewItemsOrder = function () {
|
||||||
|
const reorderedItems = []
|
||||||
|
|
||||||
|
let oldPosition = -1
|
||||||
|
this.getAllItems().forEach((item, index) => {
|
||||||
|
if (item === this.draggableItem) {
|
||||||
|
oldPosition = index
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!this.isItemToggled(item)) {
|
||||||
|
reorderedItems[index] = item
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const newIndex = this.isItemAbove(item) ? index + 1 : index - 1
|
||||||
|
reorderedItems[newIndex] = item
|
||||||
|
})
|
||||||
|
|
||||||
|
for (let index = 0; index < this.getAllItems().length; index++) {
|
||||||
|
const item = reorderedItems[index]
|
||||||
|
if (typeof item === 'undefined') {
|
||||||
|
reorderedItems[index] = this.draggableItem
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const newPosition = reorderedItems.indexOf(this.draggableItem)
|
||||||
|
const itemList = modelValue.value
|
||||||
|
const [item] = itemList.splice(oldPosition, 1)
|
||||||
|
itemList.splice(newPosition, 0, item)
|
||||||
|
modelValue.value = [...itemList]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
draggableList.value?.dispose()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div ref="draggableItems" class="pb-2 px-2 space-y-0.5 mt-0.5">
|
||||||
|
<slot
|
||||||
|
drag-class="draggable-item drag-handle cursor-grab [&.is-draggable]:cursor-grabbing"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -38,7 +38,8 @@
|
|||||||
<BottomPanel />
|
<BottomPanel />
|
||||||
</template>
|
</template>
|
||||||
<template v-if="showUI" #right-side-panel>
|
<template v-if="showUI" #right-side-panel>
|
||||||
<NodePropertiesPanel v-if="!appModeStore.isBuilderMode" />
|
<AppBuilder v-if="appModeStore.mode === 'builder:select'" />
|
||||||
|
<NodePropertiesPanel v-else-if="!appModeStore.isBuilderMode" />
|
||||||
</template>
|
</template>
|
||||||
<template #graph-canvas-panel>
|
<template #graph-canvas-panel>
|
||||||
<GraphCanvasMenu
|
<GraphCanvasMenu
|
||||||
@@ -126,6 +127,7 @@ import {
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import { isMiddlePointerInput } from '@/base/pointerUtils'
|
import { isMiddlePointerInput } from '@/base/pointerUtils'
|
||||||
|
import AppBuilder from '@/components/builder/AppBuilder.vue'
|
||||||
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
|
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
|
||||||
import TopMenuSection from '@/components/TopMenuSection.vue'
|
import TopMenuSection from '@/components/TopMenuSection.vue'
|
||||||
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
|
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import DraggableList from '@/components/common/DraggableList.vue'
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
import {
|
import {
|
||||||
demoteWidget,
|
demoteWidget,
|
||||||
@@ -17,10 +18,10 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
|||||||
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||||
import { DraggableList } from '@/scripts/ui/draggableList'
|
|
||||||
import { useLitegraphService } from '@/services/litegraphService'
|
import { useLitegraphService } from '@/services/litegraphService'
|
||||||
import { usePromotionStore } from '@/stores/promotionStore'
|
import { usePromotionStore } from '@/stores/promotionStore'
|
||||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||||
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
import SubgraphNodeWidget from './SubgraphNodeWidget.vue'
|
import SubgraphNodeWidget from './SubgraphNodeWidget.vue'
|
||||||
|
|
||||||
@@ -30,9 +31,6 @@ const promotionStore = usePromotionStore()
|
|||||||
const rightSidePanelStore = useRightSidePanelStore()
|
const rightSidePanelStore = useRightSidePanelStore()
|
||||||
const { searchQuery } = storeToRefs(rightSidePanelStore)
|
const { searchQuery } = storeToRefs(rightSidePanelStore)
|
||||||
|
|
||||||
const draggableList = ref<DraggableList | undefined>(undefined)
|
|
||||||
const draggableItems = ref()
|
|
||||||
|
|
||||||
const promotionEntries = computed(() => {
|
const promotionEntries = computed(() => {
|
||||||
const node = activeNode.value
|
const node = activeNode.value
|
||||||
if (!node) return []
|
if (!node) return []
|
||||||
@@ -195,54 +193,9 @@ function showRecommended() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setDraggableState() {
|
|
||||||
draggableList.value?.dispose()
|
|
||||||
if (searchQuery.value || !draggableItems.value?.children?.length) return
|
|
||||||
draggableList.value = new DraggableList(
|
|
||||||
draggableItems.value,
|
|
||||||
'.draggable-item'
|
|
||||||
)
|
|
||||||
draggableList.value.applyNewItemsOrder = function () {
|
|
||||||
const reorderedItems = []
|
|
||||||
|
|
||||||
let oldPosition = -1
|
|
||||||
this.getAllItems().forEach((item, index) => {
|
|
||||||
if (item === this.draggableItem) {
|
|
||||||
oldPosition = index
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!this.isItemToggled(item)) {
|
|
||||||
reorderedItems[index] = item
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const newIndex = this.isItemAbove(item) ? index + 1 : index - 1
|
|
||||||
reorderedItems[newIndex] = item
|
|
||||||
})
|
|
||||||
|
|
||||||
for (let index = 0; index < this.getAllItems().length; index++) {
|
|
||||||
const item = reorderedItems[index]
|
|
||||||
if (typeof item === 'undefined') {
|
|
||||||
reorderedItems[index] = this.draggableItem
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const newPosition = reorderedItems.indexOf(this.draggableItem)
|
|
||||||
const aw = activeWidgets.value
|
|
||||||
const [w] = aw.splice(oldPosition, 1)
|
|
||||||
aw.splice(newPosition, 0, w)
|
|
||||||
activeWidgets.value = aw
|
|
||||||
}
|
|
||||||
}
|
|
||||||
watch(filteredActive, () => {
|
|
||||||
setDraggableState()
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
setDraggableState()
|
|
||||||
if (activeNode.value) pruneDisconnected(activeNode.value)
|
if (activeNode.value) pruneDisconnected(activeNode.value)
|
||||||
})
|
})
|
||||||
onBeforeUnmount(() => {
|
|
||||||
draggableList.value?.dispose()
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -280,19 +233,18 @@ onBeforeUnmount(() => {
|
|||||||
{{ $t('subgraphStore.hideAll') }}</a
|
{{ $t('subgraphStore.hideAll') }}</a
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div ref="draggableItems" class="pb-2 px-2 space-y-0.5 mt-0.5">
|
<DraggableList v-slot="{ dragClass }" v-model="activeWidgets">
|
||||||
<SubgraphNodeWidget
|
<SubgraphNodeWidget
|
||||||
v-for="[node, widget] in filteredActive"
|
v-for="[node, widget] in filteredActive"
|
||||||
:key="toKey([node, widget])"
|
:key="toKey([node, widget])"
|
||||||
class="bg-comfy-menu-bg"
|
:class="cn(!searchQuery && dragClass, 'bg-comfy-menu-bg')"
|
||||||
:node-title="node.title"
|
:node-title="node.title"
|
||||||
:widget-name="widget.name"
|
:widget-name="widget.name"
|
||||||
:is-shown="true"
|
|
||||||
:is-draggable="!searchQuery"
|
|
||||||
:is-physical="node.id === -1"
|
:is-physical="node.id === -1"
|
||||||
|
:is-draggable="!searchQuery"
|
||||||
@toggle-visibility="demote([node, widget])"
|
@toggle-visibility="demote([node, widget])"
|
||||||
/>
|
/>
|
||||||
</div>
|
</DraggableList>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -29,8 +29,7 @@ function getIcon() {
|
|||||||
cn(
|
cn(
|
||||||
'flex py-1 px-2 break-all rounded items-center gap-1',
|
'flex py-1 px-2 break-all rounded items-center gap-1',
|
||||||
'bg-node-component-surface',
|
'bg-node-component-surface',
|
||||||
props.isDraggable &&
|
props.isDraggable && 'hover:ring-1 ring-accent-background',
|
||||||
'draggable-item drag-handle cursor-grab [&.is-draggable]:cursor-grabbing hover:ring-1 ring-accent-background',
|
|
||||||
props.class
|
props.class
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
|
|||||||
@@ -367,7 +367,6 @@ export interface IBaseWidget<
|
|||||||
/** Widget type (see {@link TWidgetType}) */
|
/** Widget type (see {@link TWidgetType}) */
|
||||||
type: TType
|
type: TType
|
||||||
value?: TValue
|
value?: TValue
|
||||||
vueTrack?: () => void
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the widget value is persisted in the workflow JSON
|
* Whether the widget value is persisted in the workflow JSON
|
||||||
|
|||||||
@@ -3007,6 +3007,20 @@
|
|||||||
"switchToSelectButton": "Switch to Select",
|
"switchToSelectButton": "Switch to Select",
|
||||||
"outputs": "Outputs",
|
"outputs": "Outputs",
|
||||||
"resultsLabel": "Results generated from the selected output node(s) will be shown here after running this app"
|
"resultsLabel": "Results generated from the selected output node(s) will be shown here after running this app"
|
||||||
|
},
|
||||||
|
"builder": {
|
||||||
|
"title": "App builder mode",
|
||||||
|
"exit": "Exit builder",
|
||||||
|
"exitConfirmTitle": "Exit app builder?",
|
||||||
|
"exitConfirmMessage": "You have unsaved changes that will be lost\nExit without saving?",
|
||||||
|
"promptAddInputs": "Click on node parameters to add them here as inputs",
|
||||||
|
"noInputs": "No inputs added yet",
|
||||||
|
"inputsDesc": "Users will interact and adjust these to generate their outputs.",
|
||||||
|
"inputsExample": "Examples: “Load image”, “Text prompt”, “Steps”",
|
||||||
|
"promptAddOutputs": "Click on output nodes to add them here. These will be the generated results.",
|
||||||
|
"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”"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"missingNodes": {
|
"missingNodes": {
|
||||||
|
|||||||
@@ -277,7 +277,13 @@ const zExtra = z
|
|||||||
reroutes: z.array(zReroute).optional(),
|
reroutes: z.array(zReroute).optional(),
|
||||||
workflowRendererVersion: zRendererType.optional(),
|
workflowRendererVersion: zRendererType.optional(),
|
||||||
BlueprintDescription: z.string().optional(),
|
BlueprintDescription: z.string().optional(),
|
||||||
BlueprintSearchAliases: z.array(z.string()).optional()
|
BlueprintSearchAliases: z.array(z.string()).optional(),
|
||||||
|
linearData: z
|
||||||
|
.object({
|
||||||
|
inputs: z.array(z.tuple([zNodeId, z.string()])).optional(),
|
||||||
|
outputs: z.array(zNodeId).optional()
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
})
|
})
|
||||||
.passthrough()
|
.passthrough()
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useEventListener, useTimeout } from '@vueuse/core'
|
import { useEventListener, useTimeout } from '@vueuse/core'
|
||||||
import { partition } from 'es-toolkit'
|
import { partition, remove, takeWhile } from 'es-toolkit'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { computed, ref, shallowRef } from 'vue'
|
import { computed, ref, shallowRef } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
@@ -55,6 +55,27 @@ useEventListener(
|
|||||||
() => (graphNodes.value = app.rootGraph.nodes)
|
() => (graphNodes.value = app.rootGraph.nodes)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const mappedSelections = computed(() => {
|
||||||
|
let unprocessedInputs = [...appModeStore.selectedInputs]
|
||||||
|
//FIXME strict typing here
|
||||||
|
const processedInputs: ReturnType<typeof nodeToNodeData>[] = []
|
||||||
|
while (unprocessedInputs.length) {
|
||||||
|
const nodeId = unprocessedInputs[0][0]
|
||||||
|
const inputGroup = takeWhile(
|
||||||
|
unprocessedInputs,
|
||||||
|
([id]) => id === nodeId
|
||||||
|
).map(([, widgetName]) => widgetName)
|
||||||
|
unprocessedInputs = unprocessedInputs.slice(inputGroup.length)
|
||||||
|
const node = app.rootGraph.getNodeById(nodeId)
|
||||||
|
if (!node) continue
|
||||||
|
|
||||||
|
const nodeData = nodeToNodeData(node)
|
||||||
|
remove(nodeData.widgets ?? [], (w) => !inputGroup.includes(w.name))
|
||||||
|
processedInputs.push(nodeData)
|
||||||
|
}
|
||||||
|
return processedInputs
|
||||||
|
})
|
||||||
|
|
||||||
function getDropIndicator(node: LGraphNode) {
|
function getDropIndicator(node: LGraphNode) {
|
||||||
if (node.type !== 'LoadImage') return undefined
|
if (node.type !== 'LoadImage') return undefined
|
||||||
|
|
||||||
@@ -231,11 +252,13 @@ defineExpose({ runButtonClick })
|
|||||||
class="grow-1 md:overflow-y-auto md:contain-size"
|
class="grow-1 md:overflow-y-auto md:contain-size"
|
||||||
>
|
>
|
||||||
<template
|
<template
|
||||||
v-for="(nodeData, index) of partitionedNodes[1]"
|
v-for="(nodeData, index) of appModeStore.selectedInputs.length
|
||||||
|
? mappedSelections
|
||||||
|
: partitionedNodes[0]"
|
||||||
:key="nodeData.id"
|
:key="nodeData.id"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="index !== 0"
|
v-if="index !== 0 && !appModeStore.selectedInputs.length"
|
||||||
class="w-full border-t-1 border-node-component-border"
|
class="w-full border-t-1 border-node-component-border"
|
||||||
/>
|
/>
|
||||||
<DropZone
|
<DropZone
|
||||||
|
|||||||
@@ -1,13 +1,26 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { readonly, computed, ref } from 'vue'
|
import { whenever } from '@vueuse/core'
|
||||||
|
import { reactive, readonly, computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||||
|
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||||
|
import { useDialogService } from '@/services/dialogService'
|
||||||
|
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||||
|
import { app } from '@/scripts/app'
|
||||||
|
|
||||||
export type AppMode = 'graph' | 'app' | 'builder:select' | 'builder:arrange'
|
export type AppMode = 'graph' | 'app' | 'builder:select' | 'builder:arrange'
|
||||||
|
|
||||||
export const useAppModeStore = defineStore('appMode', () => {
|
export const useAppModeStore = defineStore('appMode', () => {
|
||||||
|
const { getCanvas } = useCanvasStore()
|
||||||
|
const workflowStore = useWorkflowStore()
|
||||||
|
|
||||||
|
const selectedInputs = reactive<[NodeId, string][]>([])
|
||||||
|
const selectedOutputs = reactive<NodeId[]>([])
|
||||||
const mode = ref<AppMode>('graph')
|
const mode = ref<AppMode>('graph')
|
||||||
const builderSaving = ref(false)
|
const builderSaving = ref(false)
|
||||||
const hasOutputs = ref(true)
|
const hasOutputs = computed(() => !!selectedOutputs.length)
|
||||||
const enableAppBuilder = ref(false)
|
const enableAppBuilder = ref(true)
|
||||||
|
|
||||||
const isBuilderMode = computed(
|
const isBuilderMode = computed(
|
||||||
() => mode.value === 'builder:select' || mode.value === 'builder:arrange'
|
() => mode.value === 'builder:select' || mode.value === 'builder:arrange'
|
||||||
@@ -22,14 +35,64 @@ export const useAppModeStore = defineStore('appMode', () => {
|
|||||||
() => builderSaving.value && isBuilderMode.value
|
() => builderSaving.value && isBuilderMode.value
|
||||||
)
|
)
|
||||||
|
|
||||||
|
function resetSelectedToWorkflow() {
|
||||||
|
const { activeWorkflow } = workflowStore
|
||||||
|
if (!activeWorkflow) return
|
||||||
|
|
||||||
|
const { activeState } = activeWorkflow.changeTracker
|
||||||
|
selectedInputs.splice(
|
||||||
|
0,
|
||||||
|
selectedInputs.length,
|
||||||
|
...(activeState.extra?.linearData?.inputs ?? [])
|
||||||
|
)
|
||||||
|
selectedOutputs.splice(
|
||||||
|
0,
|
||||||
|
selectedOutputs.length,
|
||||||
|
...(activeState.extra?.linearData?.outputs ?? [])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
function saveSelectedToWorkflow() {
|
||||||
|
app.rootGraph.extra ??= {}
|
||||||
|
app.rootGraph.extra.linearData = {
|
||||||
|
inputs: [...selectedInputs],
|
||||||
|
outputs: [...selectedOutputs]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
whenever(() => workflowStore.activeWorkflow, resetSelectedToWorkflow, {
|
||||||
|
immediate: true
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => mode.value === 'builder:select',
|
||||||
|
(inSelect) => (getCanvas().read_only = inSelect)
|
||||||
|
)
|
||||||
|
|
||||||
|
async function exitBuilder() {
|
||||||
|
if (
|
||||||
|
!(await useDialogService().confirm({
|
||||||
|
title: t('linearMode.builder.exitConfirmTitle'),
|
||||||
|
message: t('linearMode.builder.exitConfirmMessage')
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
resetSelectedToWorkflow()
|
||||||
|
mode.value = 'graph'
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mode: readonly(mode),
|
mode: readonly(mode),
|
||||||
enableAppBuilder: readonly(enableAppBuilder),
|
enableAppBuilder: readonly(enableAppBuilder),
|
||||||
|
exitBuilder,
|
||||||
isBuilderMode,
|
isBuilderMode,
|
||||||
isAppMode,
|
isAppMode,
|
||||||
isGraphMode,
|
isGraphMode,
|
||||||
isBuilderSaving,
|
isBuilderSaving,
|
||||||
hasOutputs,
|
hasOutputs,
|
||||||
|
resetSelectedToWorkflow,
|
||||||
|
saveSelectedToWorkflow,
|
||||||
|
selectedInputs,
|
||||||
|
selectedOutputs,
|
||||||
setBuilderSaving: (newBuilderSaving: boolean) => {
|
setBuilderSaving: (newBuilderSaving: boolean) => {
|
||||||
if (!isBuilderMode.value) return
|
if (!isBuilderMode.value) return
|
||||||
builderSaving.value = newBuilderSaving
|
builderSaving.value = newBuilderSaving
|
||||||
|
|||||||
Reference in New Issue
Block a user