Compare commits
17 Commits
range-edit
...
pysssss/ap
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85d66f84d2 | ||
|
|
d6eade60b6 | ||
|
|
6eb478dc0f | ||
|
|
05f866335b | ||
|
|
c71e8b9c5e | ||
|
|
f3120168ef | ||
|
|
6acb3a1e49 | ||
|
|
6c4c9bf58e | ||
|
|
afb2721ef9 | ||
|
|
4cacdec310 | ||
|
|
b1c10073f9 | ||
|
|
297fde5934 | ||
|
|
4669c922bc | ||
|
|
1ac110024f | ||
|
|
a257df9875 | ||
|
|
9fb93a5b0a | ||
|
|
ac12a3d9b9 |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 93 KiB |
@@ -18,7 +18,7 @@
|
||||
<Splitter
|
||||
:key="splitterRefreshKey"
|
||||
class="bg-transparent pointer-events-none border-none flex-1 overflow-hidden"
|
||||
:state-key="sidebarStateKey"
|
||||
:state-key="isSelectMode ? 'builder-splitter' : sidebarStateKey"
|
||||
state-storage="local"
|
||||
@resizestart="onResizestart"
|
||||
>
|
||||
@@ -35,8 +35,10 @@
|
||||
)
|
||||
: 'bg-comfy-menu-bg pointer-events-auto'
|
||||
"
|
||||
:min-size="sidebarLocation === 'left' ? 10 : 15"
|
||||
:size="20"
|
||||
:min-size="
|
||||
sidebarLocation === 'left' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
|
||||
"
|
||||
:size="SIDE_PANEL_SIZE"
|
||||
:style="firstPanelStyle"
|
||||
:role="sidebarLocation === 'left' ? 'complementary' : undefined"
|
||||
:aria-label="
|
||||
@@ -54,7 +56,7 @@
|
||||
</SplitterPanel>
|
||||
|
||||
<!-- Main panel (always present) -->
|
||||
<SplitterPanel :size="80" class="flex flex-col">
|
||||
<SplitterPanel :size="CENTER_PANEL_SIZE" class="flex flex-col">
|
||||
<slot name="topmenu" :sidebar-panel-visible />
|
||||
|
||||
<Splitter
|
||||
@@ -95,8 +97,10 @@
|
||||
)
|
||||
: 'bg-comfy-menu-bg pointer-events-auto'
|
||||
"
|
||||
:min-size="sidebarLocation === 'right' ? 10 : 15"
|
||||
:size="20"
|
||||
:min-size="
|
||||
sidebarLocation === 'right' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
|
||||
"
|
||||
:size="SIDE_PANEL_SIZE"
|
||||
:style="lastPanelStyle"
|
||||
:role="sidebarLocation === 'right' ? 'complementary' : undefined"
|
||||
:aria-label="
|
||||
@@ -123,8 +127,14 @@ import SplitterPanel from 'primevue/splitterpanel'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import {
|
||||
BUILDER_MIN_SIZE,
|
||||
CENTER_PANEL_SIZE,
|
||||
SIDEBAR_MIN_SIZE,
|
||||
SIDE_PANEL_SIZE
|
||||
} from '@/constants/splitterConstants'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
@@ -145,15 +155,17 @@ const unifiedWidth = computed(() =>
|
||||
|
||||
const { focusMode } = storeToRefs(workspaceStore)
|
||||
|
||||
const appModeStore = useAppModeStore()
|
||||
const { isSelectMode, isBuilderMode } = useAppMode()
|
||||
const { activeSidebarTabId, activeSidebarTab } = storeToRefs(sidebarTabStore)
|
||||
const { bottomPanelVisible } = storeToRefs(useBottomPanelStore())
|
||||
const { isOpen: rightSidePanelVisible } = storeToRefs(rightSidePanelStore)
|
||||
const showOffsideSplitter = computed(
|
||||
() => rightSidePanelVisible.value || appModeStore.mode === 'builder:select'
|
||||
() => rightSidePanelVisible.value || isSelectMode.value
|
||||
)
|
||||
|
||||
const sidebarPanelVisible = computed(() => activeSidebarTab.value !== null)
|
||||
const sidebarPanelVisible = computed(
|
||||
() => activeSidebarTab.value !== null && !isBuilderMode.value
|
||||
)
|
||||
|
||||
const sidebarStateKey = computed(() => {
|
||||
return unifiedWidth.value
|
||||
@@ -174,7 +186,7 @@ function onResizestart({ originalEvent: event }: SplitterResizeStartEvent) {
|
||||
* to recalculate the width and panel order
|
||||
*/
|
||||
const splitterRefreshKey = computed(() => {
|
||||
return `main-splitter${rightSidePanelVisible.value ? '-with-right-panel' : ''}-${sidebarLocation.value}`
|
||||
return `main-splitter${rightSidePanelVisible.value ? '-with-right-panel' : ''}${isSelectMode.value ? '-builder' : ''}-${sidebarLocation.value}`
|
||||
})
|
||||
|
||||
const firstPanelStyle = computed(() => {
|
||||
|
||||
@@ -8,12 +8,12 @@ import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemp
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const appModeStore = useAppModeStore()
|
||||
const { enableAppBuilder, setMode } = useAppMode()
|
||||
const tooltipOptions = { showDelay: 300, hideDelay: 300 }
|
||||
|
||||
const isAssetsActive = computed(
|
||||
@@ -24,7 +24,7 @@ const isWorkflowsActive = computed(
|
||||
)
|
||||
|
||||
function enterBuilderMode() {
|
||||
appModeStore.setMode('builder:select')
|
||||
setMode('builder:select')
|
||||
}
|
||||
|
||||
function openAssets() {
|
||||
@@ -61,7 +61,7 @@ function openTemplates() {
|
||||
</WorkflowActionsDropdown>
|
||||
|
||||
<Button
|
||||
v-if="appModeStore.enableAppBuilder"
|
||||
v-if="enableAppBuilder"
|
||||
v-tooltip.right="{
|
||||
value: t('linearMode.appModeToolbar.appBuilder'),
|
||||
...tooltipOptions
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { remove } from 'es-toolkit'
|
||||
import { computed, ref, toValue } from 'vue'
|
||||
import { computed, provide, 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 WidgetItem from '@/components/rightSidePanel/parameters/WidgetItem.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'
|
||||
@@ -23,8 +24,11 @@ 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 { useAppMode } from '@/composables/useAppMode'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { HideLayoutFieldKey } from '@/types/widgetTypes'
|
||||
|
||||
type BoundStyle = { top: string; left: string; width: string; height: string }
|
||||
|
||||
@@ -36,13 +40,27 @@ const workflowStore = useWorkflowStore()
|
||||
const { t } = useI18n()
|
||||
const canvas: LGraphCanvas = canvasStore.getCanvas()
|
||||
|
||||
const { isSelectMode, isArrangeMode } = useAppMode()
|
||||
const hoveringSelectable = ref(false)
|
||||
|
||||
provide(HideLayoutFieldKey, true)
|
||||
|
||||
workflowStore.activeWorkflow?.changeTracker?.reset()
|
||||
|
||||
const arrangeInputs = 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 null
|
||||
return { nodeId, widgetName, node, widget }
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => item !== null)
|
||||
)
|
||||
|
||||
const inputsWithState = computed(() =>
|
||||
appModeStore.selectedInputs.map(([nodeId, widgetName]) => {
|
||||
const node = app.rootGraph.getNodeById(nodeId)
|
||||
const node = getNodeByLocatorId(app.rootGraph, String(nodeId))
|
||||
const widget = node?.widgets?.find((w) => w.name === widgetName)
|
||||
if (!node || !widget) return { nodeId, widgetName }
|
||||
|
||||
@@ -179,17 +197,42 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex font-bold p-2 border-border-subtle border-b items-center">
|
||||
{{ t('linearMode.builder.title') }}
|
||||
{{
|
||||
isArrangeMode ? t('nodeHelpPage.inputs') : t('linearMode.builder.title')
|
||||
}}
|
||||
<Button class="ml-auto" @click="appModeStore.exitBuilder">
|
||||
{{ t('linearMode.builder.exit') }}
|
||||
</Button>
|
||||
</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, 'p-2 my-2 pointer-events-auto')"
|
||||
:aria-label="`${widget.label ?? widgetName} — ${node.title}`"
|
||||
>
|
||||
<div class="pointer-events-none" inert>
|
||||
<WidgetItem
|
||||
:widget="widget"
|
||||
:node="node"
|
||||
show-node-name
|
||||
hidden-widget-actions
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DraggableList>
|
||||
<PropertiesAccordionItem
|
||||
v-else
|
||||
: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')}`"
|
||||
:tooltip-delay="100"
|
||||
>
|
||||
<template #label>
|
||||
<div class="flex gap-3">
|
||||
@@ -232,10 +275,12 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
</DraggableList>
|
||||
</PropertiesAccordionItem>
|
||||
<PropertiesAccordionItem
|
||||
v-if="!isArrangeMode"
|
||||
: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">
|
||||
@@ -274,7 +319,10 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
</DraggableList>
|
||||
</PropertiesAccordionItem>
|
||||
|
||||
<Teleport to="body">
|
||||
<Teleport
|
||||
v-if="isSelectMode && !settingStore.get('Comfy.VueNodes.Enabled')"
|
||||
to="body"
|
||||
>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
@@ -308,13 +356,19 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
<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"
|
||||
class="absolute -top-1/2 -right-1/2 size-full p-2 bg-warning-background rounded-lg cursor-pointer pointer-events-auto"
|
||||
@click.stop="
|
||||
remove(appModeStore.selectedOutputs, (k) => k === key)
|
||||
"
|
||||
@pointerdown.stop
|
||||
>
|
||||
<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"
|
||||
class="absolute -top-1/2 -right-1/2 size-full ring-warning-background/50 ring-4 ring-inset bg-component-node-background rounded-lg cursor-pointer pointer-events-auto"
|
||||
@click.stop="appModeStore.selectedOutputs.push(key)"
|
||||
@pointerdown.stop
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
)
|
||||
"
|
||||
:aria-current="activeStep === step.id ? 'step' : undefined"
|
||||
@click="appModeStore.setMode(step.id)"
|
||||
@click="setMode(step.id)"
|
||||
>
|
||||
<StepBadge :step :index :model-value="activeStep" />
|
||||
<StepLabel :step />
|
||||
@@ -31,9 +31,9 @@
|
||||
|
||||
<!-- Save -->
|
||||
<ConnectOutputPopover
|
||||
v-if="!appModeStore.hasOutputs"
|
||||
v-if="!hasOutputs"
|
||||
:is-select-active="activeStep === 'builder:select'"
|
||||
@switch="appModeStore.setMode('builder:select')"
|
||||
@switch="setMode('builder:select')"
|
||||
>
|
||||
<button :class="cn(stepClasses, 'opacity-30 bg-transparent')">
|
||||
<StepBadge :step="saveStep" :index="2" :model-value="activeStep" />
|
||||
@@ -50,7 +50,7 @@
|
||||
: 'hover:bg-secondary-background bg-transparent'
|
||||
)
|
||||
"
|
||||
@click="appModeStore.setBuilderSaving(true)"
|
||||
@click="setSaving(true)"
|
||||
>
|
||||
<StepBadge :step="saveStep" :index="2" :model-value="activeStep" />
|
||||
<StepLabel :step="saveStep" />
|
||||
@@ -62,31 +62,25 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import type { AppMode } from '@/composables/useAppMode'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import type { AppMode } from '@/stores/appModeStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { useBuilderSave } from './useBuilderSave'
|
||||
import ConnectOutputPopover from './ConnectOutputPopover.vue'
|
||||
import StepBadge from './StepBadge.vue'
|
||||
import StepLabel from './StepLabel.vue'
|
||||
import type { BuilderToolbarStep } from './types'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appModeStore = useAppModeStore()
|
||||
const { mode, setMode } = useAppMode()
|
||||
const { hasOutputs } = storeToRefs(useAppModeStore())
|
||||
const { saving, setSaving } = useBuilderSave()
|
||||
|
||||
useEventListener(document, 'keydown', (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape') return
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
void appModeStore.exitBuilder()
|
||||
})
|
||||
|
||||
const activeStep = computed(() =>
|
||||
appModeStore.isBuilderSaving ? 'save' : appModeStore.mode
|
||||
)
|
||||
const activeStep = computed(() => (saving.value ? 'save' : mode.value))
|
||||
|
||||
const stepClasses =
|
||||
'inline-flex h-14 min-h-8 cursor-pointer items-center gap-3 rounded-lg py-2 pr-4 pl-2 transition-colors border-none'
|
||||
|
||||
@@ -33,8 +33,11 @@ const entries = computed(() => {
|
||||
</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" />
|
||||
<span class="mr-auto truncate shrink-1" v-text="title" />
|
||||
<span
|
||||
class="text-muted-foreground mr-2 text-end truncate shrink-3"
|
||||
v-text="subTitle"
|
||||
/>
|
||||
<Popover :entries>
|
||||
<template #button>
|
||||
<Button variant="muted-textonly">
|
||||
|
||||
@@ -1,30 +1,36 @@
|
||||
import { watch } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
import BuilderSaveDialogContent from './BuilderSaveDialogContent.vue'
|
||||
import BuilderSaveSuccessDialogContent from './BuilderSaveSuccessDialogContent.vue'
|
||||
import { whenever } from '@vueuse/core'
|
||||
|
||||
const SAVE_DIALOG_KEY = 'builder-save'
|
||||
const SUCCESS_DIALOG_KEY = 'builder-save-success'
|
||||
|
||||
export function useBuilderSave() {
|
||||
const appModeStore = useAppModeStore()
|
||||
const { setMode } = useAppMode()
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const workflowService = useWorkflowService()
|
||||
const dialogService = useDialogService()
|
||||
const appModeStore = useAppModeStore()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
watch(
|
||||
() => appModeStore.isBuilderSaving,
|
||||
(saving) => {
|
||||
if (saving) void onBuilderSave()
|
||||
}
|
||||
)
|
||||
const saving = ref(false)
|
||||
|
||||
whenever(saving, onBuilderSave)
|
||||
|
||||
function setSaving(value: boolean) {
|
||||
saving.value = value
|
||||
}
|
||||
|
||||
async function onBuilderSave() {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
@@ -33,13 +39,14 @@ export function useBuilderSave() {
|
||||
return
|
||||
}
|
||||
|
||||
if (!workflow.isTemporary && workflow.activeState.extra?.linearMode) {
|
||||
if (!workflow.isTemporary && workflow.initialMode != null) {
|
||||
// Re-save with the previously chosen mode — no dialog needed.
|
||||
try {
|
||||
workflow.changeTracker?.checkState()
|
||||
appModeStore.saveSelectedToWorkflow()
|
||||
appModeStore.flushSelections()
|
||||
await workflowService.saveWorkflow(workflow)
|
||||
showSuccessDialog(workflow.filename, appModeStore.isAppMode)
|
||||
} catch {
|
||||
showSuccessDialog(workflow.filename, workflow.initialMode === 'app')
|
||||
} catch (e) {
|
||||
toastErrorHandler(e)
|
||||
resetSaving()
|
||||
}
|
||||
return
|
||||
@@ -73,17 +80,19 @@ export function useBuilderSave() {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (!workflow) return
|
||||
|
||||
appModeStore.saveSelectedToWorkflow()
|
||||
appModeStore.flushSelections()
|
||||
const mode = openAsApp ? 'app' : 'graph'
|
||||
const saved = await workflowService.saveWorkflowAs(workflow, {
|
||||
filename,
|
||||
openAsApp
|
||||
initialMode: mode
|
||||
})
|
||||
|
||||
if (!saved) return
|
||||
|
||||
closeSaveDialog()
|
||||
showSuccessDialog(filename, openAsApp)
|
||||
} catch {
|
||||
} catch (e) {
|
||||
toastErrorHandler(e)
|
||||
closeSaveDialog()
|
||||
resetSaving()
|
||||
}
|
||||
@@ -97,7 +106,7 @@ export function useBuilderSave() {
|
||||
workflowName,
|
||||
savedAsApp,
|
||||
onViewApp: () => {
|
||||
appModeStore.setMode('app')
|
||||
setMode('app')
|
||||
closeSuccessDialog()
|
||||
},
|
||||
onClose: closeSuccessDialog
|
||||
@@ -118,6 +127,8 @@ export function useBuilderSave() {
|
||||
}
|
||||
|
||||
function resetSaving() {
|
||||
appModeStore.setBuilderSaving(false)
|
||||
saving.value = false
|
||||
}
|
||||
|
||||
return { saving, setSaving }
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="showUI && !appModeStore.isBuilderMode" #side-toolbar>
|
||||
<template v-if="showUI && !isBuilderMode" #side-toolbar>
|
||||
<SideToolbar />
|
||||
</template>
|
||||
<template v-if="showUI" #side-bar-panel>
|
||||
@@ -31,27 +31,24 @@
|
||||
<ExtensionSlot v-if="activeSidebarTab" :extension="activeSidebarTab" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="showUI && !appModeStore.isBuilderMode" #topmenu>
|
||||
<template v-if="showUI && !isBuilderMode" #topmenu>
|
||||
<TopMenuSection />
|
||||
</template>
|
||||
<template v-if="showUI" #bottom-panel>
|
||||
<BottomPanel />
|
||||
</template>
|
||||
<template v-if="showUI" #right-side-panel>
|
||||
<AppBuilder v-if="appModeStore.mode === 'builder:select'" />
|
||||
<NodePropertiesPanel v-else-if="!appModeStore.isBuilderMode" />
|
||||
<AppBuilder v-if="mode === 'builder:select'" />
|
||||
<NodePropertiesPanel v-else-if="!isBuilderMode" />
|
||||
</template>
|
||||
<template #graph-canvas-panel>
|
||||
<GraphCanvasMenu
|
||||
v-if="canvasMenuEnabled && !appModeStore.isBuilderMode"
|
||||
v-if="canvasMenuEnabled && !isBuilderMode"
|
||||
class="pointer-events-auto"
|
||||
/>
|
||||
<MiniMap
|
||||
v-if="
|
||||
comfyAppReady &&
|
||||
minimapEnabled &&
|
||||
betaMenuEnabled &&
|
||||
!appModeStore.isBuilderMode
|
||||
comfyAppReady && minimapEnabled && betaMenuEnabled && !isBuilderMode
|
||||
"
|
||||
class="pointer-events-auto"
|
||||
/>
|
||||
@@ -127,10 +124,10 @@ import {
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { isMiddlePointerInput } from '@/base/pointerUtils'
|
||||
import AppBuilder from '@/components/builder/AppBuilder.vue'
|
||||
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
|
||||
import TopMenuSection from '@/components/TopMenuSection.vue'
|
||||
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
|
||||
import AppBuilder from '@/components/builder/AppBuilder.vue'
|
||||
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
|
||||
import DomWidgets from '@/components/graph/DomWidgets.vue'
|
||||
import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
|
||||
@@ -184,7 +181,7 @@ import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { isNativeWindow } from '@/utils/envUtil'
|
||||
import { forEachNode } from '@/utils/graphTraversalUtil'
|
||||
@@ -205,7 +202,7 @@ const nodeSearchboxPopoverRef = shallowRef<InstanceType<
|
||||
const settingStore = useSettingStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const appModeStore = useAppModeStore()
|
||||
const { mode, isBuilderMode } = useAppMode()
|
||||
const canvasStore = useCanvasStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const executionStore = useExecutionStore()
|
||||
|
||||
@@ -11,6 +11,7 @@ const {
|
||||
enableEmptyState,
|
||||
tooltip,
|
||||
size = 'default',
|
||||
tooltipDelay = 1000,
|
||||
class: className
|
||||
} = defineProps<{
|
||||
disabled?: boolean
|
||||
@@ -18,6 +19,7 @@ const {
|
||||
enableEmptyState?: boolean
|
||||
tooltip?: string
|
||||
size?: 'default' | 'lg'
|
||||
tooltipDelay?: number
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
@@ -27,7 +29,7 @@ const isExpanded = computed(() => !isCollapse.value && !disabled)
|
||||
|
||||
const tooltipConfig = computed(() => {
|
||||
if (!tooltip) return undefined
|
||||
return { value: tooltip, showDelay: 1000 }
|
||||
return { value: tooltip, showDelay: tooltipDelay }
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -16,10 +16,7 @@ import {
|
||||
shouldExpand
|
||||
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import {
|
||||
useWidgetValueStore,
|
||||
stripGraphPrefix
|
||||
} from '@/stores/widgetValueStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||
@@ -34,6 +31,7 @@ const {
|
||||
node,
|
||||
isDraggable = false,
|
||||
hiddenFavoriteIndicator = false,
|
||||
hiddenWidgetActions = false,
|
||||
showNodeName = false,
|
||||
parents = [],
|
||||
isShownOnParents = false
|
||||
@@ -42,6 +40,7 @@ const {
|
||||
node: LGraphNode
|
||||
isDraggable?: boolean
|
||||
hiddenFavoriteIndicator?: boolean
|
||||
hiddenWidgetActions?: boolean
|
||||
showNodeName?: boolean
|
||||
parents?: SubgraphNode[]
|
||||
isShownOnParents?: boolean
|
||||
@@ -73,7 +72,7 @@ function resolveSourceWidget(): { node: LGraphNode; widget: IBaseWidget } {
|
||||
const simplifiedWidget = computed((): SimplifiedWidget => {
|
||||
const { node: sourceNode, widget: sourceWidget } = resolveSourceWidget()
|
||||
const graphId = node.graph?.rootGraph?.id
|
||||
const bareNodeId = stripGraphPrefix(String(sourceNode.id))
|
||||
const bareNodeId = String(sourceNode.id)
|
||||
const widgetState = graphId
|
||||
? widgetValueStore.getWidget(graphId, bareNodeId, sourceWidget.name)
|
||||
: undefined
|
||||
@@ -170,7 +169,10 @@ const displayLabel = customRef((track, trigger) => {
|
||||
>
|
||||
{{ sourceNodeName }}
|
||||
</span>
|
||||
<div class="flex items-center gap-1 shrink-0 pointer-events-auto">
|
||||
<div
|
||||
v-if="!hiddenWidgetActions"
|
||||
class="flex items-center gap-1 shrink-0 pointer-events-auto"
|
||||
>
|
||||
<WidgetActions
|
||||
v-model:label="displayLabel"
|
||||
:widget="widget"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
ref="containerRef"
|
||||
:class="
|
||||
cn(
|
||||
'comfy-vue-side-bar-container group/sidebar-tab flex h-full flex-col',
|
||||
'comfy-vue-side-bar-container group/sidebar-tab flex h-full flex-col w-full',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
@click="handleClick"
|
||||
>
|
||||
<i
|
||||
v-if="workflowOption.workflow.activeState?.extra?.linearMode"
|
||||
v-if="workflowOption.workflow.initialMode === 'app'"
|
||||
class="icon-[lucide--panels-top-left] bg-primary-background"
|
||||
/>
|
||||
<span
|
||||
|
||||
@@ -25,15 +25,19 @@ whenever(feedbackRef, () => {
|
||||
:href="`https://form.typeform.com/to/${dataTfWidget}`"
|
||||
target="_blank"
|
||||
variant="inverted"
|
||||
class="rounded-full size-12"
|
||||
class="flex h-10 items-center justify-center gap-2.5 px-3 py-2"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<i class="icon-[lucide--circle-question-mark] size-6" />
|
||||
<i class="icon-[lucide--circle-help] size-4" />
|
||||
</Button>
|
||||
<Popover v-else>
|
||||
<template #button>
|
||||
<Button variant="inverted" class="rounded-full size-12" v-bind="$attrs">
|
||||
<i class="icon-[lucide--circle-question-mark] size-6" />
|
||||
<Button
|
||||
variant="inverted"
|
||||
class="flex h-10 items-center justify-center gap-2.5 px-3 py-2"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<i class="icon-[lucide--circle-help] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
<div ref="feedbackRef" data-tf-auto-resize :data-tf-widget />
|
||||
|
||||
@@ -222,13 +222,9 @@ function safeWidgetMapper(
|
||||
read_only: widget.options.read_only
|
||||
}
|
||||
: undefined
|
||||
const subgraphId = node.isSubgraphNode() && node.subgraph.id
|
||||
|
||||
const localId = isPromotedWidgetView(widget)
|
||||
const nodeId = isPromotedWidgetView(widget)
|
||||
? widget.sourceNodeId
|
||||
: undefined
|
||||
const nodeId =
|
||||
subgraphId && localId ? `${subgraphId}:${localId}` : undefined
|
||||
const name = isPromotedWidgetView(widget)
|
||||
? widget.sourceWidgetName
|
||||
: widget.name
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type {
|
||||
LGraphGroup,
|
||||
LGraphNode,
|
||||
NodeId
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { getExtraOptionsForWidget } from '@/services/litegraphService'
|
||||
import { isLGraphGroup } from '@/utils/litegraphUtil'
|
||||
@@ -46,7 +51,7 @@ export enum BadgeVariant {
|
||||
// Global singleton for NodeOptions component reference
|
||||
let nodeOptionsInstance: null | NodeOptionsInstance = null
|
||||
|
||||
const hoveredWidgetName = ref<string>()
|
||||
const hoveredWidget = ref<[string, NodeId | undefined]>()
|
||||
|
||||
/**
|
||||
* Toggle the node options popover
|
||||
@@ -63,14 +68,12 @@ export function toggleNodeOptions(event: Event) {
|
||||
* Use this for contextmenu events where we always want to show at the new position
|
||||
* @param event - The trigger event (must be MouseEvent for position)
|
||||
*/
|
||||
export function showNodeOptions(event: MouseEvent) {
|
||||
hoveredWidgetName.value = undefined
|
||||
const target = event.target
|
||||
if (target instanceof HTMLElement) {
|
||||
const widgetEl = target.closest('.lg-node-widget')
|
||||
if (widgetEl instanceof HTMLElement)
|
||||
hoveredWidgetName.value = widgetEl.dataset.widgetName
|
||||
}
|
||||
export function showNodeOptions(
|
||||
event: MouseEvent,
|
||||
widgetName?: string,
|
||||
nodeId?: NodeId
|
||||
) {
|
||||
hoveredWidget.value = widgetName ? [widgetName, nodeId] : undefined
|
||||
if (nodeOptionsInstance?.show) {
|
||||
nodeOptionsInstance.show(event)
|
||||
}
|
||||
@@ -259,8 +262,16 @@ export function useMoreOptionsMenu() {
|
||||
options.push(...getImageMenuOptions(selectedNodes.value[0]))
|
||||
options.push({ type: 'divider' })
|
||||
}
|
||||
const rawName = hoveredWidgetName.value
|
||||
const widget = node?.widgets?.find((w) => w.name === rawName)
|
||||
const [widgetName, nodeId] = hoveredWidget.value ?? []
|
||||
const widget =
|
||||
nodeId !== undefined
|
||||
? node?.widgets?.find(
|
||||
(w) =>
|
||||
isPromotedWidgetView(w) &&
|
||||
w.sourceWidgetName === widgetName &&
|
||||
w.sourceNodeId === nodeId
|
||||
)
|
||||
: node?.widgets?.find((w) => w.name === widgetName)
|
||||
if (widget) {
|
||||
const widgetOptions = convertContextMenuToOptions(
|
||||
getExtraOptionsForWidget(node, widget)
|
||||
|
||||
47
src/composables/useAppMode.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
|
||||
export type AppMode = 'graph' | 'app' | 'builder:select' | 'builder:arrange'
|
||||
|
||||
const enableAppBuilder = ref(true)
|
||||
|
||||
export function useAppMode() {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const mode = computed(
|
||||
() =>
|
||||
workflowStore.activeWorkflow?.activeMode ??
|
||||
workflowStore.activeWorkflow?.initialMode ??
|
||||
'graph'
|
||||
)
|
||||
|
||||
const isBuilderMode = computed(
|
||||
() => isSelectMode.value || isArrangeMode.value
|
||||
)
|
||||
const isSelectMode = computed(() => mode.value === 'builder:select')
|
||||
const isArrangeMode = computed(() => mode.value === 'builder:arrange')
|
||||
const isAppMode = computed(
|
||||
() => mode.value === 'app' || mode.value === 'builder:arrange'
|
||||
)
|
||||
const isGraphMode = computed(
|
||||
() => mode.value === 'graph' || mode.value === 'builder:select'
|
||||
)
|
||||
|
||||
function setMode(newMode: AppMode) {
|
||||
if (newMode === mode.value) return
|
||||
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (workflow) workflow.activeMode = newMode
|
||||
}
|
||||
|
||||
return {
|
||||
mode,
|
||||
enableAppBuilder,
|
||||
isBuilderMode,
|
||||
isSelectMode,
|
||||
isArrangeMode,
|
||||
isAppMode,
|
||||
isGraphMode,
|
||||
setMode
|
||||
}
|
||||
}
|
||||
@@ -1338,8 +1338,6 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
typeof metadata?.source === 'string' ? metadata.source : 'keybind'
|
||||
const newMode = !canvasStore.linearMode
|
||||
if (newMode) useTelemetry()?.trackEnterLinear({ source })
|
||||
app.rootGraph.extra.linearMode = newMode
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
canvasStore.linearMode = newMode
|
||||
}
|
||||
}
|
||||
|
||||
11
src/constants/splitterConstants.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/** Default panel size (%) for sidebar and builder panels */
|
||||
export const SIDE_PANEL_SIZE = 20
|
||||
|
||||
/** Default panel size (%) for the center/main panel */
|
||||
export const CENTER_PANEL_SIZE = 80
|
||||
|
||||
/** Minimum panel size (%) for the sidebar */
|
||||
export const SIDEBAR_MIN_SIZE = 10
|
||||
|
||||
/** Minimum panel size (%) for the builder panel */
|
||||
export const BUILDER_MIN_SIZE = 15
|
||||
@@ -9,10 +9,7 @@ import type { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget'
|
||||
import { toConcreteWidget } from '@/lib/litegraph/src/widgets/widgetMap'
|
||||
import { t } from '@/i18n'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import {
|
||||
stripGraphPrefix,
|
||||
useWidgetValueStore
|
||||
} from '@/stores/widgetValueStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
import type { PromotedWidgetView as IPromotedWidgetView } from './promotedWidgetTypes'
|
||||
|
||||
@@ -83,7 +80,7 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
this.sourceNodeId = nodeId
|
||||
this.sourceWidgetName = widgetName
|
||||
this.graphId = subgraphNode.rootGraph.id
|
||||
this.bareNodeId = stripGraphPrefix(nodeId)
|
||||
this.bareNodeId = nodeId
|
||||
}
|
||||
|
||||
get node(): SubgraphNode {
|
||||
|
||||
@@ -1159,8 +1159,8 @@
|
||||
"queue": {
|
||||
"initializingAlmostReady": "Initializing - Almost ready",
|
||||
"inQueue": "In queue...",
|
||||
"jobAddedToQueue": "Job added to queue",
|
||||
"jobQueueing": "Job queueing",
|
||||
"jobAddedToQueue": "Job queued",
|
||||
"jobQueueing": "Job queuing",
|
||||
"completedIn": "Finished in {duration}",
|
||||
"jobMenu": {
|
||||
"openAsWorkflowNewTab": "Open as workflow in new tab",
|
||||
@@ -2996,7 +2996,8 @@
|
||||
},
|
||||
"linearMode": {
|
||||
"linearMode": "App Mode",
|
||||
"beta": "App Mode in Beta - Feedback",
|
||||
"beta": "App mode in beta",
|
||||
"giveFeedback": "Give feedback",
|
||||
"graphMode": "Graph Mode",
|
||||
"dragAndDropImage": "Click to browse or drag an image",
|
||||
"runCount": "Number of runs",
|
||||
@@ -3034,11 +3035,15 @@
|
||||
"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”",
|
||||
"inputsExample": "Examples: \u201cLoad\u00a0image\u201d, \u201cText\u00a0prompt\u201d, \u201cSteps\u201d",
|
||||
"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”"
|
||||
"outputsExample": "Examples: \u201cSave\u00a0Image\u201d or \u201cSave\u00a0Video\u201d"
|
||||
},
|
||||
"queue": {
|
||||
"clickToClear": "Click to clear queue",
|
||||
"clear": "Clear queue"
|
||||
}
|
||||
},
|
||||
"missingNodes": {
|
||||
|
||||
@@ -101,6 +101,7 @@ vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
escapeParameter: true,
|
||||
messages: {
|
||||
en: {
|
||||
subscription: {
|
||||
@@ -112,7 +113,8 @@ const i18n = createI18n({
|
||||
partnerNodesBalance: 'Partner Nodes Balance',
|
||||
partnerNodesDescription: 'Credits for partner nodes',
|
||||
totalCredits: 'Total Credits',
|
||||
creditsRemainingThisMonth: 'Credits remaining this month',
|
||||
creditsRemainingThisMonth: 'Included (Refills {date})',
|
||||
creditsRemainingThisYear: 'Included (Refills {date})',
|
||||
creditsYouveAdded: "Credits you've added",
|
||||
monthlyBonusDescription: 'Monthly bonus',
|
||||
prepaidDescription: 'Prepaid credits',
|
||||
@@ -286,6 +288,13 @@ describe('SubscriptionPanel', () => {
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.findAll('.skeleton').length).toBe(0)
|
||||
})
|
||||
|
||||
it('renders refill date with literal slashes', () => {
|
||||
mockIsActiveSubscription.value = true
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.text()).toContain('Included (Refills 12/31/24)')
|
||||
expect(wrapper.text()).not.toContain('/')
|
||||
})
|
||||
})
|
||||
|
||||
// TODO: Re-enable when migrating to VTL so we can find by user visible content.
|
||||
|
||||
@@ -256,12 +256,24 @@ const refillsDate = computed(() => {
|
||||
|
||||
const creditsRemainingLabel = computed(() =>
|
||||
isYearlySubscription.value
|
||||
? t('subscription.creditsRemainingThisYear', {
|
||||
date: refillsDate.value
|
||||
})
|
||||
: t('subscription.creditsRemainingThisMonth', {
|
||||
date: refillsDate.value
|
||||
})
|
||||
? t(
|
||||
'subscription.creditsRemainingThisYear',
|
||||
{
|
||||
date: refillsDate.value
|
||||
},
|
||||
{
|
||||
escapeParameter: false
|
||||
}
|
||||
)
|
||||
: t(
|
||||
'subscription.creditsRemainingThisMonth',
|
||||
{
|
||||
date: refillsDate.value
|
||||
},
|
||||
{
|
||||
escapeParameter: false
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
const planTotalCredits = computed(() => {
|
||||
|
||||
@@ -2,12 +2,58 @@ import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { PendingWarnings } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import type {
|
||||
LoadedComfyWorkflow,
|
||||
PendingWarnings
|
||||
} from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { ComfyWorkflow as ComfyWorkflowClass } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import type { AppMode } from '@/composables/useAppMode'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { createMockChangeTracker } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
function createModeTestWorkflow(
|
||||
options: {
|
||||
path?: string
|
||||
initialMode?: AppMode | null
|
||||
activeMode?: AppMode | null
|
||||
loaded?: boolean
|
||||
} = {}
|
||||
): LoadedComfyWorkflow {
|
||||
const workflow = new ComfyWorkflowClass({
|
||||
path: options.path ?? 'workflows/test.json',
|
||||
modified: Date.now(),
|
||||
size: 100
|
||||
})
|
||||
if ('initialMode' in options) workflow.initialMode = options.initialMode
|
||||
workflow.activeMode = options.activeMode ?? null
|
||||
if (options.loaded !== false) {
|
||||
workflow.changeTracker = createMockChangeTracker()
|
||||
workflow.content = '{}'
|
||||
workflow.originalContent = '{}'
|
||||
}
|
||||
return workflow as LoadedComfyWorkflow
|
||||
}
|
||||
|
||||
function makeWorkflowData(
|
||||
extra: Record<string, unknown> = {}
|
||||
): ComfyWorkflowJSON {
|
||||
return {
|
||||
last_node_id: 5,
|
||||
last_link_id: 3,
|
||||
nodes: [],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
version: 0.4,
|
||||
extra
|
||||
}
|
||||
}
|
||||
|
||||
const { mockShowMissingNodes, mockShowMissingModels } = vi.hoisted(() => ({
|
||||
mockShowMissingNodes: vi.fn(),
|
||||
@@ -72,6 +118,14 @@ vi.mock('@/stores/domWidgetStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspaceStore', () => ({
|
||||
useWorkspaceStore: () => ({
|
||||
get workflow() {
|
||||
return useWorkflowStore()
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
const MISSING_MODELS: PendingWarnings['missingModels'] = {
|
||||
missingModels: [
|
||||
{ name: 'model.safetensors', url: '', directory: 'checkpoints' }
|
||||
@@ -83,7 +137,7 @@ function createWorkflow(
|
||||
warnings: PendingWarnings | null = null,
|
||||
options: { loadable?: boolean; path?: string } = {}
|
||||
): ComfyWorkflow {
|
||||
return {
|
||||
const wf = {
|
||||
pendingWarnings: warnings,
|
||||
...(options.loadable && {
|
||||
path: options.path ?? 'workflows/test.json',
|
||||
@@ -91,7 +145,8 @@ function createWorkflow(
|
||||
activeState: { nodes: [], links: [] },
|
||||
changeTracker: { reset: vi.fn(), restore: vi.fn() }
|
||||
})
|
||||
} as Partial<ComfyWorkflow> as ComfyWorkflow
|
||||
} as Partial<ComfyWorkflow>
|
||||
return wf as ComfyWorkflow
|
||||
}
|
||||
|
||||
function enableWarningSettings() {
|
||||
@@ -180,12 +235,7 @@ describe('useWorkflowService', () => {
|
||||
workflowStore = useWorkflowStore()
|
||||
vi.mocked(app.loadGraphData).mockImplementation(
|
||||
async (_data, _clean, _restore, wf) => {
|
||||
;(
|
||||
workflowStore as Partial<Record<string, unknown>> as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
).activeWorkflow = wf
|
||||
workflowStore.activeWorkflow = wf as LoadedComfyWorkflow
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -256,4 +306,231 @@ describe('useWorkflowService', () => {
|
||||
expect(mockShowMissingNodes).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('per-workflow mode switching', () => {
|
||||
let appMode: ReturnType<typeof useAppMode>
|
||||
let workflowStore: ReturnType<typeof useWorkflowStore>
|
||||
let service: ReturnType<typeof useWorkflowService>
|
||||
|
||||
function mockOpenWorkflow() {
|
||||
vi.spyOn(workflowStore, 'openWorkflow').mockImplementation(async (wf) => {
|
||||
// Simulate load() setting changeTracker on first open
|
||||
if (!wf.changeTracker) {
|
||||
wf.changeTracker = createMockChangeTracker()
|
||||
wf.content = '{}'
|
||||
wf.originalContent = '{}'
|
||||
}
|
||||
const loaded = wf as LoadedComfyWorkflow
|
||||
workflowStore.activeWorkflow = loaded
|
||||
return loaded
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
appMode = useAppMode()
|
||||
workflowStore = useWorkflowStore()
|
||||
service = useWorkflowService()
|
||||
})
|
||||
|
||||
describe('mode derivation from active workflow', () => {
|
||||
it('reflects initialMode of the active workflow', () => {
|
||||
const workflow = createModeTestWorkflow({ initialMode: 'app' })
|
||||
workflowStore.activeWorkflow = workflow
|
||||
|
||||
expect(appMode.mode.value).toBe('app')
|
||||
})
|
||||
|
||||
it('activeMode takes precedence over initialMode', () => {
|
||||
const workflow = createModeTestWorkflow({
|
||||
initialMode: 'app',
|
||||
activeMode: 'graph'
|
||||
})
|
||||
workflowStore.activeWorkflow = workflow
|
||||
|
||||
expect(appMode.mode.value).toBe('graph')
|
||||
})
|
||||
|
||||
it('defaults to graph when no active workflow', () => {
|
||||
expect(appMode.mode.value).toBe('graph')
|
||||
})
|
||||
|
||||
it('updates when activeWorkflow changes', () => {
|
||||
const workflow1 = createModeTestWorkflow({
|
||||
path: 'workflows/one.json',
|
||||
initialMode: 'app'
|
||||
})
|
||||
const workflow2 = createModeTestWorkflow({
|
||||
path: 'workflows/two.json',
|
||||
activeMode: 'builder:select'
|
||||
})
|
||||
|
||||
workflowStore.activeWorkflow = workflow1
|
||||
expect(appMode.mode.value).toBe('app')
|
||||
|
||||
workflowStore.activeWorkflow = workflow2
|
||||
expect(appMode.mode.value).toBe('builder:select')
|
||||
})
|
||||
})
|
||||
|
||||
describe('setMode writes to active workflow', () => {
|
||||
it('writes activeMode without changing initialMode', () => {
|
||||
const workflow = createModeTestWorkflow({ initialMode: 'graph' })
|
||||
workflowStore.activeWorkflow = workflow
|
||||
|
||||
appMode.setMode('builder:arrange')
|
||||
|
||||
expect(workflow.activeMode).toBe('builder:arrange')
|
||||
expect(workflow.initialMode).toBe('graph')
|
||||
expect(appMode.mode.value).toBe('builder:arrange')
|
||||
})
|
||||
})
|
||||
|
||||
describe('afterLoadNewGraph initializes initialMode', () => {
|
||||
beforeEach(() => {
|
||||
mockOpenWorkflow()
|
||||
})
|
||||
|
||||
it('sets initialMode from extra.linearMode on first load', async () => {
|
||||
const workflow = createModeTestWorkflow({ loaded: false })
|
||||
|
||||
await service.afterLoadNewGraph(
|
||||
workflow,
|
||||
makeWorkflowData({ linearMode: true })
|
||||
)
|
||||
|
||||
expect(workflow.initialMode).toBe('app')
|
||||
})
|
||||
|
||||
it('leaves initialMode null when extra.linearMode is absent', async () => {
|
||||
const workflow = createModeTestWorkflow({ loaded: false })
|
||||
|
||||
await service.afterLoadNewGraph(workflow, makeWorkflowData())
|
||||
|
||||
expect(workflow.initialMode).toBeNull()
|
||||
})
|
||||
|
||||
it('sets initialMode to graph when extra.linearMode is false', async () => {
|
||||
const workflow = createModeTestWorkflow({ loaded: false })
|
||||
|
||||
await service.afterLoadNewGraph(
|
||||
workflow,
|
||||
makeWorkflowData({ linearMode: false })
|
||||
)
|
||||
|
||||
expect(workflow.initialMode).toBe('graph')
|
||||
})
|
||||
|
||||
it('does not set initialMode on tab switch even if data has linearMode', async () => {
|
||||
const workflow = createModeTestWorkflow({ loaded: false })
|
||||
|
||||
// First load — no linearMode in data
|
||||
await service.afterLoadNewGraph(workflow, makeWorkflowData())
|
||||
expect(workflow.initialMode).toBeNull()
|
||||
|
||||
// User switches to app mode at runtime
|
||||
workflow.activeMode = 'app'
|
||||
|
||||
// Tab switch / reload — data now has linearMode (leaked from graph)
|
||||
await service.afterLoadNewGraph(
|
||||
workflow,
|
||||
makeWorkflowData({ linearMode: true })
|
||||
)
|
||||
|
||||
// initialMode should NOT have been updated — only builder save sets it
|
||||
expect(workflow.initialMode).toBeNull()
|
||||
})
|
||||
|
||||
it('preserves existing initialMode on tab switch', async () => {
|
||||
const workflow = createModeTestWorkflow({
|
||||
initialMode: 'app'
|
||||
})
|
||||
|
||||
await service.afterLoadNewGraph(workflow, makeWorkflowData())
|
||||
|
||||
expect(workflow.initialMode).toBe('app')
|
||||
})
|
||||
|
||||
it('sets initialMode to app for fresh string-based loads with linearMode', async () => {
|
||||
vi.spyOn(workflowStore, 'createNewTemporary').mockReturnValue(
|
||||
createModeTestWorkflow()
|
||||
)
|
||||
|
||||
await service.afterLoadNewGraph(
|
||||
'test.json',
|
||||
makeWorkflowData({ linearMode: true })
|
||||
)
|
||||
|
||||
expect(appMode.mode.value).toBe('app')
|
||||
})
|
||||
|
||||
it('syncs linearMode to rootGraph.extra for draft persistence', async () => {
|
||||
const workflow = createModeTestWorkflow({ loaded: false })
|
||||
|
||||
await service.afterLoadNewGraph(
|
||||
workflow,
|
||||
makeWorkflowData({ linearMode: true })
|
||||
)
|
||||
|
||||
expect(app.rootGraph.extra.linearMode).toBe(true)
|
||||
})
|
||||
|
||||
it('reads initialMode from file when draft lacks linearMode (restoration)', async () => {
|
||||
const filePath = 'workflows/saved-app.json'
|
||||
const fileInitialState = makeWorkflowData({ linearMode: true })
|
||||
const mockTracker = createMockChangeTracker()
|
||||
mockTracker.initialState = fileInitialState
|
||||
|
||||
// Persisted, not-loaded workflow in the store
|
||||
const persistedWorkflow = new ComfyWorkflowClass({
|
||||
path: filePath,
|
||||
modified: Date.now(),
|
||||
size: 100
|
||||
})
|
||||
|
||||
vi.spyOn(workflowStore, 'getWorkflowByPath').mockReturnValue(
|
||||
persistedWorkflow
|
||||
)
|
||||
vi.spyOn(workflowStore, 'openWorkflow').mockImplementation(
|
||||
async (wf) => {
|
||||
wf.changeTracker = mockTracker
|
||||
wf.content = JSON.stringify(fileInitialState)
|
||||
wf.originalContent = wf.content
|
||||
workflowStore.activeWorkflow = wf as LoadedComfyWorkflow
|
||||
return wf as LoadedComfyWorkflow
|
||||
}
|
||||
)
|
||||
|
||||
// Draft data has NO linearMode (simulates rootGraph serialization)
|
||||
const draftData = makeWorkflowData()
|
||||
|
||||
await service.afterLoadNewGraph('saved-app.json', draftData)
|
||||
|
||||
// initialMode should come from the file, not the draft
|
||||
expect(persistedWorkflow.initialMode).toBe('app')
|
||||
expect(app.rootGraph.extra.linearMode).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('round-trip mode preservation', () => {
|
||||
it('each workflow retains its own mode across tab switches', () => {
|
||||
const workflow1 = createModeTestWorkflow({
|
||||
path: 'workflows/one.json',
|
||||
activeMode: 'builder:select'
|
||||
})
|
||||
const workflow2 = createModeTestWorkflow({
|
||||
path: 'workflows/two.json',
|
||||
initialMode: 'app'
|
||||
})
|
||||
|
||||
workflowStore.activeWorkflow = workflow1
|
||||
expect(appMode.mode.value).toBe('builder:select')
|
||||
|
||||
workflowStore.activeWorkflow = workflow2
|
||||
expect(appMode.mode.value).toBe('app')
|
||||
|
||||
workflowStore.activeWorkflow = workflow1
|
||||
expect(appMode.mode.value).toBe('builder:select')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,24 +7,31 @@ import type { Point, SerialisableGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useWorkflowDraftStore } from '@/platform/workflow/persistence/stores/workflowDraftStore'
|
||||
import { syncLinearMode } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import {
|
||||
ComfyWorkflow,
|
||||
useWorkflowStore
|
||||
} from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
|
||||
import { app } from '@/scripts/app'
|
||||
import { blankGraph, defaultGraph } from '@/scripts/defaultGraph'
|
||||
import { useMissingModelsDialog } from '@/composables/useMissingModelsDialog'
|
||||
import { useMissingNodesDialog } from '@/composables/useMissingNodesDialog'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import type { AppMode } from '@/composables/useAppMode'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { appendJsonExt } from '@/utils/formatUtil'
|
||||
|
||||
function linearModeToAppMode(linearMode: unknown): AppMode | null {
|
||||
if (typeof linearMode !== 'boolean') return null
|
||||
return linearMode ? 'app' : 'graph'
|
||||
}
|
||||
|
||||
export const useWorkflowService = () => {
|
||||
const settingStore = useSettingStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
@@ -93,24 +100,20 @@ export const useWorkflowService = () => {
|
||||
* Save a workflow as a new file
|
||||
* @param workflow The workflow to save
|
||||
* @param options.filename Pre-supplied filename (skips the prompt dialog)
|
||||
* @param options.openAsApp If set, updates linearMode extra before saving
|
||||
*/
|
||||
const saveWorkflowAs = async (
|
||||
workflow: ComfyWorkflow,
|
||||
options: { filename?: string; openAsApp?: boolean } = {}
|
||||
options: { filename?: string; initialMode?: AppMode } = {}
|
||||
): Promise<boolean> => {
|
||||
const newFilename = options.filename ?? (await workflow.promptSave())
|
||||
if (!newFilename) return false
|
||||
|
||||
if (options.openAsApp !== undefined) {
|
||||
app.rootGraph.extra ??= {}
|
||||
app.rootGraph.extra.linearMode = options.openAsApp
|
||||
workflow.changeTracker?.checkState()
|
||||
}
|
||||
|
||||
const newPath = workflow.directory + '/' + appendJsonExt(newFilename)
|
||||
const existingWorkflow = workflowStore.getWorkflowByPath(newPath)
|
||||
|
||||
const isSelfOverwrite =
|
||||
existingWorkflow?.path === workflow.path && !existingWorkflow?.isTemporary
|
||||
|
||||
if (existingWorkflow && !existingWorkflow.isTemporary) {
|
||||
const res = await dialogService.confirm({
|
||||
title: t('sideToolbar.workflowTab.confirmOverwriteTitle'),
|
||||
@@ -121,15 +124,20 @@ export const useWorkflowService = () => {
|
||||
|
||||
if (res !== true) return false
|
||||
|
||||
if (existingWorkflow.path === workflow.path) {
|
||||
await saveWorkflow(workflow)
|
||||
return true
|
||||
if (!isSelfOverwrite) {
|
||||
const deleted = await deleteWorkflow(existingWorkflow, true)
|
||||
if (!deleted) return false
|
||||
}
|
||||
const deleted = await deleteWorkflow(existingWorkflow, true)
|
||||
if (!deleted) return false
|
||||
}
|
||||
|
||||
if (workflow.isTemporary) {
|
||||
if (options.initialMode) workflow.initialMode = options.initialMode
|
||||
|
||||
syncLinearMode(workflow, [app.rootGraph], { flushLinearData: true })
|
||||
workflow.changeTracker?.checkState()
|
||||
|
||||
if (isSelfOverwrite) {
|
||||
await saveWorkflow(workflow)
|
||||
} else if (workflow.isTemporary) {
|
||||
await renameWorkflow(workflow, newPath)
|
||||
await workflowStore.saveWorkflow(workflow)
|
||||
} else {
|
||||
@@ -148,6 +156,8 @@ export const useWorkflowService = () => {
|
||||
if (workflow.isTemporary) {
|
||||
await saveWorkflowAs(workflow)
|
||||
} else {
|
||||
syncLinearMode(workflow, [app.rootGraph], { flushLinearData: true })
|
||||
workflow.changeTracker?.checkState()
|
||||
await workflowStore.saveWorkflow(workflow)
|
||||
}
|
||||
}
|
||||
@@ -358,13 +368,17 @@ export const useWorkflowService = () => {
|
||||
workflowData: ComfyWorkflowJSON
|
||||
) => {
|
||||
const workflowStore = useWorkspaceStore().workflow
|
||||
if (
|
||||
workflowData.extra?.linearMode !== undefined ||
|
||||
!workflowData.nodes.length
|
||||
) {
|
||||
if (workflowData.extra?.linearMode && !useCanvasStore().linearMode)
|
||||
const { isAppMode } = useAppMode()
|
||||
const wasAppMode = isAppMode.value
|
||||
|
||||
// Determine the initial app mode for fresh loads from serialized state.
|
||||
// null means linearMode was never explicitly set (not builder-saved).
|
||||
const freshLoadMode = linearModeToAppMode(workflowData.extra?.linearMode)
|
||||
|
||||
function trackIfEnteringApp(workflow: ComfyWorkflow) {
|
||||
if (!wasAppMode && workflow.initialMode === 'app') {
|
||||
useTelemetry()?.trackEnterLinear({ source: 'workflow' })
|
||||
useCanvasStore().linearMode = !!workflowData.extra?.linearMode
|
||||
}
|
||||
}
|
||||
|
||||
if (value === null || typeof value === 'string') {
|
||||
@@ -381,26 +395,39 @@ export const useWorkflowService = () => {
|
||||
if (existingWorkflow?.isPersisted && !existingWorkflow.isLoaded) {
|
||||
const loadedWorkflow =
|
||||
await workflowStore.openWorkflow(existingWorkflow)
|
||||
if (loadedWorkflow.initialMode === undefined) {
|
||||
// Prefer the file's linearMode over the draft's since the file
|
||||
// is the authoritative saved state.
|
||||
loadedWorkflow.initialMode =
|
||||
linearModeToAppMode(
|
||||
loadedWorkflow.initialState?.extra?.linearMode
|
||||
) ?? freshLoadMode
|
||||
trackIfEnteringApp(loadedWorkflow)
|
||||
}
|
||||
syncLinearMode(loadedWorkflow, [workflowData, app.rootGraph])
|
||||
loadedWorkflow.changeTracker.reset(workflowData)
|
||||
loadedWorkflow.changeTracker.restore()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (useCanvasStore().linearMode) {
|
||||
app.rootGraph.extra ??= {}
|
||||
app.rootGraph.extra.linearMode = true
|
||||
}
|
||||
|
||||
const tempWorkflow = workflowStore.createNewTemporary(
|
||||
path ? appendJsonExt(path) : undefined,
|
||||
workflowData
|
||||
)
|
||||
tempWorkflow.initialMode = freshLoadMode
|
||||
trackIfEnteringApp(tempWorkflow)
|
||||
syncLinearMode(tempWorkflow, [workflowData, app.rootGraph])
|
||||
await workflowStore.openWorkflow(tempWorkflow)
|
||||
return
|
||||
}
|
||||
|
||||
const loadedWorkflow = await workflowStore.openWorkflow(value)
|
||||
if (loadedWorkflow.initialMode === undefined) {
|
||||
loadedWorkflow.initialMode = freshLoadMode
|
||||
trackIfEnteringApp(loadedWorkflow)
|
||||
}
|
||||
syncLinearMode(loadedWorkflow, [workflowData, app.rootGraph])
|
||||
loadedWorkflow.changeTracker.reset(workflowData)
|
||||
loadedWorkflow.changeTracker.restore()
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { markRaw } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import type { ChangeTracker } from '@/scripts/changeTracker'
|
||||
import type { AppMode } from '@/composables/useAppMode'
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { UserFile } from '@/stores/userFileStore'
|
||||
import type {
|
||||
ComfyWorkflowJSON,
|
||||
@@ -9,6 +11,11 @@ import type {
|
||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
|
||||
export interface LinearData {
|
||||
inputs: [NodeId, string][]
|
||||
outputs: NodeId[]
|
||||
}
|
||||
|
||||
export interface PendingWarnings {
|
||||
missingNodeTypes?: MissingNodeType[]
|
||||
missingModels?: {
|
||||
@@ -17,6 +24,31 @@ export interface PendingWarnings {
|
||||
}
|
||||
}
|
||||
|
||||
type LinearModeTarget = { extra?: Record<string, unknown> | null } | null
|
||||
|
||||
export function syncLinearMode(
|
||||
workflow: ComfyWorkflow,
|
||||
targets: LinearModeTarget[],
|
||||
options?: { flushLinearData?: boolean }
|
||||
): void {
|
||||
for (const target of targets) {
|
||||
if (!target) continue
|
||||
if (workflow.initialMode === 'app' || workflow.initialMode === 'graph') {
|
||||
const extra = (target.extra ??= {})
|
||||
extra.linearMode = workflow.initialMode === 'app'
|
||||
} else {
|
||||
delete target.extra?.linearMode
|
||||
}
|
||||
if (options?.flushLinearData && workflow.dirtyLinearData) {
|
||||
const extra = (target.extra ??= {})
|
||||
extra.linearData = workflow.dirtyLinearData
|
||||
}
|
||||
}
|
||||
if (options?.flushLinearData && workflow.dirtyLinearData) {
|
||||
workflow.dirtyLinearData = null
|
||||
}
|
||||
}
|
||||
|
||||
export class ComfyWorkflow extends UserFile {
|
||||
static readonly basePath: string = 'workflows/'
|
||||
readonly tintCanvasBg?: string
|
||||
@@ -33,6 +65,23 @@ export class ComfyWorkflow extends UserFile {
|
||||
* Warnings deferred from load time, shown when the workflow is first focused.
|
||||
*/
|
||||
pendingWarnings: PendingWarnings | null = null
|
||||
/**
|
||||
* Initial app mode derived from the serialized workflow (extra.linearMode).
|
||||
* - `undefined`: not yet resolved (first load hasn't happened)
|
||||
* - `null`: resolved, but no mode was set (never builder-saved)
|
||||
* - `AppMode`: resolved to a specific mode
|
||||
*/
|
||||
initialMode: AppMode | null | undefined = undefined
|
||||
/**
|
||||
* Current app mode set by the user during the session.
|
||||
* Takes precedence over initialMode when present.
|
||||
*/
|
||||
activeMode: AppMode | null = null
|
||||
/**
|
||||
* In-progress builder selections not yet persisted via save.
|
||||
* Preserved across tab switches, discarded on exitBuilder.
|
||||
*/
|
||||
dirtyLinearData: LinearData | null = null
|
||||
|
||||
/**
|
||||
* @param options The path, modified, and size of the workflow.
|
||||
@@ -129,6 +178,7 @@ export class ComfyWorkflow extends UserFile {
|
||||
|
||||
override unload(): void {
|
||||
this.changeTracker = null
|
||||
this.activeMode = null
|
||||
super.unload()
|
||||
}
|
||||
|
||||
|
||||
@@ -249,6 +249,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
modified: Date.now(),
|
||||
size: -1
|
||||
})
|
||||
workflow.initialMode = existingWorkflow.initialMode
|
||||
workflow.originalContent = workflow.content = JSON.stringify(state)
|
||||
workflowLookup.value[workflow.path] = workflow
|
||||
return workflow
|
||||
|
||||
@@ -534,12 +534,24 @@ const refillsDate = computed(() => {
|
||||
|
||||
const creditsRemainingLabel = computed(() =>
|
||||
isYearlySubscription.value
|
||||
? t('subscription.creditsRemainingThisYear', {
|
||||
date: refillsDate.value
|
||||
})
|
||||
: t('subscription.creditsRemainingThisMonth', {
|
||||
date: refillsDate.value
|
||||
})
|
||||
? t(
|
||||
'subscription.creditsRemainingThisYear',
|
||||
{
|
||||
date: refillsDate.value
|
||||
},
|
||||
{
|
||||
escapeParameter: false
|
||||
}
|
||||
)
|
||||
: t(
|
||||
'subscription.creditsRemainingThisMonth',
|
||||
{
|
||||
date: refillsDate.value
|
||||
},
|
||||
{
|
||||
escapeParameter: false
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
const planTotalCredits = computed(() => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { defineStore } from 'pinia'
|
||||
import { computed, markRaw, ref, shallowRef } from 'vue'
|
||||
import type { Raw } from 'vue'
|
||||
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
|
||||
import type { Point, Positionable } from '@/lib/litegraph/src/interfaces'
|
||||
import type {
|
||||
@@ -44,10 +44,11 @@ export const useCanvasStore = defineStore('canvas', () => {
|
||||
// Reactive scale percentage that syncs with app.canvas.ds.scale
|
||||
const appScalePercentage = ref(100)
|
||||
|
||||
const { isAppMode, setMode } = useAppMode()
|
||||
const linearMode = computed({
|
||||
get: () => useAppModeStore().isAppMode,
|
||||
get: () => isAppMode.value,
|
||||
set: (val: boolean) => {
|
||||
useAppModeStore().setMode(val ? 'app' : 'graph')
|
||||
setMode(val ? 'app' : 'graph')
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
60
src/renderer/extensions/linearMode/AppInput.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { remove } from 'es-toolkit'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { mode } = useAppMode()
|
||||
const appModeStore = useAppModeStore()
|
||||
|
||||
const { id, name } = defineProps<{ id: string; name: string }>()
|
||||
|
||||
const isPromoted = computed(() => appModeStore.selectedInputs.some(matchesThis))
|
||||
|
||||
function matchesThis([nodeId, widgetName]: [NodeId, string]) {
|
||||
return id == nodeId && name === widgetName
|
||||
}
|
||||
function togglePromotion() {
|
||||
if (isPromoted.value) remove(appModeStore.selectedInputs, matchesThis)
|
||||
else appModeStore.selectedInputs.push([id, name])
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
v-if="mode === 'builder:select'"
|
||||
class="col-span-2 flex flex-row pointer-events-auto cursor-pointer gap-1 relative"
|
||||
@pointerdown.capture.stop.prevent="togglePromotion"
|
||||
@click.capture.stop.prevent
|
||||
@pointerup.capture.stop.prevent
|
||||
@pointermove.capture.stop.prevent
|
||||
>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'border-primary-background border rounded-sm size-4 self-center m-1',
|
||||
isPromoted && 'bg-primary-background flex items-center'
|
||||
)
|
||||
"
|
||||
>
|
||||
<i
|
||||
v-if="isPromoted"
|
||||
class="icon-[lucide--check] bg-primary-foreground place-center"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'grid grid-cols-2 items-stretch ring-primary-background rounded-lg pointer-events-none flex-1',
|
||||
isPromoted && 'ring-2'
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
<div class="absolute size-full hover:bg-primary-background/10 rounded-lg" />
|
||||
</div>
|
||||
<slot v-else />
|
||||
</template>
|
||||
54
src/renderer/extensions/linearMode/AppOutput.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { remove } from 'es-toolkit'
|
||||
import { computed } from 'vue'
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { isSelectMode } = useAppMode()
|
||||
const appModeStore = useAppModeStore()
|
||||
|
||||
const { id } = defineProps<{ id: string }>()
|
||||
|
||||
const isPromoted = computed(() =>
|
||||
appModeStore.selectedOutputs.some(matchesThis)
|
||||
)
|
||||
|
||||
function matchesThis(nodeId: NodeId) {
|
||||
return id == nodeId
|
||||
}
|
||||
function togglePromotion() {
|
||||
if (isPromoted.value) remove(appModeStore.selectedOutputs, matchesThis)
|
||||
else appModeStore.selectedOutputs.push(id)
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
v-if="isSelectMode"
|
||||
:class="
|
||||
cn(
|
||||
'absolute w-full h-full pointer-events-auto ring-warning-background/50 ring-5 rounded-2xl',
|
||||
isPromoted && 'ring-warning-background'
|
||||
)
|
||||
"
|
||||
@click.capture.stop.prevent
|
||||
@pointerup.capture.stop.prevent
|
||||
@pointermove.capture.stop.prevent
|
||||
@pointerdown.capture="togglePromotion"
|
||||
>
|
||||
<div class="absolute top-0 right-0 size-8">
|
||||
<div
|
||||
v-if="isPromoted"
|
||||
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>
|
||||
</template>
|
||||
@@ -1,15 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
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'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appModeStore = useAppModeStore()
|
||||
const { setMode } = useAppMode()
|
||||
const { hasOutputs } = storeToRefs(useAppModeStore())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="appModeStore.hasOutputs"
|
||||
v-if="hasOutputs"
|
||||
role="article"
|
||||
data-testid="arrange-preview"
|
||||
class="flex flex-col items-center justify-center h-full w-3/4 gap-6 p-8 mx-auto"
|
||||
@@ -47,11 +50,7 @@ const appModeStore = useAppModeStore()
|
||||
<p class="mt-0 p-0">{{ t('linearMode.arrange.outputExamples') }}</p>
|
||||
</div>
|
||||
<div class="flex flex-row gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
@click="appModeStore.setMode('builder:select')"
|
||||
>
|
||||
<Button variant="primary" size="lg" @click="setMode('builder:select')">
|
||||
{{ t('linearMode.arrange.switchToSelectButton') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,6 @@ import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import DropZone from '@/renderer/extensions/linearMode/DropZone.vue'
|
||||
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
|
||||
import { applyLightThemeColor } from '@/renderer/extensions/vueNodes/utils/nodeStyleUtils'
|
||||
import WidgetInputNumberInput from '@/renderer/extensions/vueNodes/widgets/components/WidgetInputNumber.vue'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
@@ -26,6 +25,7 @@ import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useQueueSettingsStore } from '@/stores/queueStore'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
@@ -35,7 +35,9 @@ const { batchCount } = storeToRefs(useQueueSettingsStore())
|
||||
const settingStore = useSettingStore()
|
||||
const { isActiveSubscription } = useBillingContext()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { isBuilderMode } = useAppMode()
|
||||
const appModeStore = useAppModeStore()
|
||||
const { hasOutputs } = storeToRefs(appModeStore)
|
||||
|
||||
const props = defineProps<{
|
||||
toastTo?: string | HTMLElement
|
||||
@@ -66,7 +68,11 @@ const mappedSelections = computed(() => {
|
||||
([id]) => id === nodeId
|
||||
).map(([, widgetName]) => widgetName)
|
||||
unprocessedInputs = unprocessedInputs.slice(inputGroup.length)
|
||||
const node = app.rootGraph.getNodeById(nodeId)
|
||||
const node =
|
||||
app.rootGraph.getNodeById(nodeId) ??
|
||||
[...app.rootGraph.subgraphs.values()]
|
||||
.flatMap((sg) => sg.nodes)
|
||||
.find((n) => n.id == nodeId)
|
||||
if (!node) continue
|
||||
|
||||
const nodeData = nodeToNodeData(node)
|
||||
@@ -168,7 +174,7 @@ defineExpose({ runButtonClick })
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
v-if="!appModeStore.isBuilderMode && appModeStore.hasOutputs"
|
||||
v-if="!isBuilderMode && hasOutputs"
|
||||
class="flex flex-col min-w-80 md:h-full"
|
||||
>
|
||||
<section
|
||||
@@ -236,7 +242,6 @@ defineExpose({ runButtonClick })
|
||||
/>
|
||||
<NodeWidgets
|
||||
:node-data
|
||||
:style="{ background: applyLightThemeColor(nodeData.bgcolor) }"
|
||||
class="py-3 gap-y-3 **:[.col-span-2]:grid-cols-1 *:has-[textarea]:h-50 rounded-lg max-w-100"
|
||||
/>
|
||||
</template>
|
||||
@@ -276,7 +281,6 @@ defineExpose({ runButtonClick })
|
||||
'ring-2 ring-inset ring-node-stroke-error'
|
||||
)
|
||||
"
|
||||
:style="{ background: applyLightThemeColor(nodeData.bgcolor) }"
|
||||
/>
|
||||
</DropZone>
|
||||
</template>
|
||||
@@ -299,7 +303,7 @@ defineExpose({ runButtonClick })
|
||||
<Button
|
||||
v-else
|
||||
variant="primary"
|
||||
class="w-full mt-4"
|
||||
class="w-full mt-4 text-sm"
|
||||
size="lg"
|
||||
@click="runButtonClick"
|
||||
>
|
||||
@@ -315,14 +319,18 @@ defineExpose({ runButtonClick })
|
||||
:to="toastTo"
|
||||
>
|
||||
<div
|
||||
class="bg-base-foreground text-base-background rounded-sm flex h-8 p-1 pr-2 gap-2 items-center"
|
||||
class="bg-secondary-background text-base-foreground rounded-lg flex h-8 p-1 pr-2 gap-2 items-center"
|
||||
>
|
||||
<i
|
||||
v-if="jobFinishedQueue"
|
||||
class="icon-[lucide--check] size-5 bg-success-background"
|
||||
class="icon-[lucide--check] size-5 text-muted-foreground"
|
||||
/>
|
||||
<i v-else class="icon-[lucide--loader-circle] size-4 animate-spin" />
|
||||
<span v-text="t('queue.jobAddedToQueue')" />
|
||||
<span
|
||||
v-text="
|
||||
jobFinishedQueue ? t('queue.jobAddedToQueue') : t('queue.jobQueueing')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
@@ -27,13 +27,13 @@ import { useQueueStore } from '@/stores/queueStore'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
import { collectAllNodes } from '@/utils/graphTraversalUtil'
|
||||
import { executeWidgetsCallback } from '@/utils/litegraphUtil'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const mediaActions = useMediaAssetActions()
|
||||
const queueStore = useQueueStore()
|
||||
const appModeStore = useAppModeStore()
|
||||
const { mode: appModeValue } = useAppMode()
|
||||
const { runButtonClick } = defineProps<{
|
||||
runButtonClick?: (e: Event) => void
|
||||
mobile?: boolean
|
||||
@@ -165,7 +165,7 @@ async function rerun(e: Event) {
|
||||
:model-url="selectedOutput!.url"
|
||||
/>
|
||||
<LatentPreview v-else-if="queueStore.runningTasks.length > 0" />
|
||||
<LinearArrange v-else-if="appModeStore.mode === 'builder:arrange'" />
|
||||
<LinearArrange v-else-if="appModeValue === 'builder:arrange'" />
|
||||
<LinearWelcome v-else />
|
||||
<OutputHistory @update-selection="handleSelection" />
|
||||
<OutputHistory class="not-md:mx-40" @update-selection="handleSelection" />
|
||||
</template>
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
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'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appModeStore = useAppModeStore()
|
||||
const { setMode } = useAppMode()
|
||||
const { hasOutputs } = storeToRefs(useAppModeStore())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -24,7 +27,7 @@ const appModeStore = useAppModeStore()
|
||||
<p class="mt-0">{{ t('linearMode.welcome.controls') }}</p>
|
||||
<p class="mt-0">{{ t('linearMode.welcome.sharing') }}</p>
|
||||
</div>
|
||||
<div v-if="appModeStore.hasOutputs" class="flex flex-row gap-2 text-[14px]">
|
||||
<div v-if="hasOutputs" class="flex flex-row gap-2 text-[14px]">
|
||||
<p class="mt-0 text-base-foreground">
|
||||
<i18n-t keypath="linearMode.welcome.getStarted" tag="span">
|
||||
<template #runButton>
|
||||
@@ -38,18 +41,10 @@ const appModeStore = useAppModeStore()
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="flex flex-row gap-2">
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="lg"
|
||||
@click="appModeStore.setMode('graph')"
|
||||
>
|
||||
<Button variant="textonly" size="lg" @click="setMode('graph')">
|
||||
{{ t('linearMode.welcome.backToWorkflow') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
@click="appModeStore.setMode('builder:select')"
|
||||
>
|
||||
<Button variant="primary" size="lg" @click="setMode('builder:select')">
|
||||
<i class="icon-[lucide--hammer]" />
|
||||
{{ t('linearMode.welcome.buildApp') }}
|
||||
<div
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
|
||||
import { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import OutputHistoryActiveQueueItem from '@/renderer/extensions/linearMode/OutputHistoryActiveQueueItem.vue'
|
||||
import OutputHistoryItem from '@/renderer/extensions/linearMode/OutputHistoryItem.vue'
|
||||
import { useLinearOutputStore } from '@/renderer/extensions/linearMode/linearOutputStore'
|
||||
import type {
|
||||
@@ -275,7 +276,7 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
|
||||
orientation="horizontal"
|
||||
selection-behavior="replace"
|
||||
by="id"
|
||||
class="min-w-0"
|
||||
class="min-w-0 px-4 pb-4"
|
||||
@update:model-value="onSelectionChange"
|
||||
>
|
||||
<ListboxContent as-child>
|
||||
@@ -287,23 +288,7 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
|
||||
>
|
||||
<div class="flex items-center gap-0.5 mx-auto w-fit">
|
||||
<div v-if="queueCount > 0" class="shrink-0 flex items-center gap-0.5">
|
||||
<div
|
||||
class="shrink-0 p-1 border-2 border-transparent relative"
|
||||
data-testid="linear-job"
|
||||
>
|
||||
<div
|
||||
class="size-10 rounded-sm bg-secondary-background flex items-center justify-center"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--loader-circle] size-4 animate-spin text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="queueCount > 1"
|
||||
class="absolute top-0 right-0 min-w-4 h-4 flex justify-center items-center rounded-full bg-primary-background text-text-primary text-xs"
|
||||
v-text="queueCount"
|
||||
/>
|
||||
</div>
|
||||
<OutputHistoryActiveQueueItem :queue-count="queueCount" />
|
||||
<div
|
||||
v-if="hasActiveContent || visibleHistory.length > 0"
|
||||
class="border-l border-border-default h-12 shrink-0 mx-4"
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
const { queueCount } = defineProps<{
|
||||
queueCount: number
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
|
||||
function clearQueue(close: () => void) {
|
||||
void commandStore.execute('Comfy.ClearPendingTasks')
|
||||
close()
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
class="shrink-0 p-1 border-2 border-transparent relative"
|
||||
data-testid="linear-job"
|
||||
>
|
||||
<Popover side="top" :show-arrow="false" @focus-outside.prevent>
|
||||
<template #button>
|
||||
<Button
|
||||
v-tooltip.top="t('linearMode.queue.clickToClear')"
|
||||
:aria-label="t('linearMode.queue.clickToClear')"
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
class="size-10 rounded-sm bg-secondary-background flex items-center justify-center"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--loader-circle] size-4 animate-spin text-muted-foreground"
|
||||
/>
|
||||
</Button>
|
||||
</template>
|
||||
<template #default="{ close }">
|
||||
<Button
|
||||
:disabled="queueCount === 0"
|
||||
variant="textonly"
|
||||
class="text-destructive-background px-4 text-sm"
|
||||
@click="clearQueue(close)"
|
||||
>
|
||||
<i class="icon-[lucide--list-x]" />
|
||||
{{ t('linearMode.queue.clear') }}
|
||||
</Button>
|
||||
</template>
|
||||
</Popover>
|
||||
<div
|
||||
v-if="queueCount > 1"
|
||||
aria-hidden="true"
|
||||
class="absolute top-0 right-0 min-w-4 h-4 flex justify-center items-center rounded-full bg-primary-background text-text-primary text-xs"
|
||||
v-text="queueCount"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -14,11 +14,9 @@ const { apiTarget } = vi.hoisted(() => ({
|
||||
apiTarget: new EventTarget()
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/appModeStore', () => ({
|
||||
useAppModeStore: () => ({
|
||||
get isAppMode() {
|
||||
return isAppModeRef.value
|
||||
}
|
||||
vi.mock('@/composables/useAppMode', () => ({
|
||||
useAppMode: () => ({
|
||||
isAppMode: isAppModeRef
|
||||
})
|
||||
}))
|
||||
|
||||
|
||||
@@ -5,12 +5,12 @@ import { flattenNodeOutput } from '@/renderer/extensions/linearMode/flattenNodeO
|
||||
import type { InProgressItem } from '@/renderer/extensions/linearMode/linearModeTypes'
|
||||
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useJobPreviewStore } from '@/stores/jobPreviewStore'
|
||||
|
||||
export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
const appModeStore = useAppModeStore()
|
||||
const { isAppMode } = useAppMode()
|
||||
const executionStore = useExecutionStore()
|
||||
const jobPreviewStore = useJobPreviewStore()
|
||||
|
||||
@@ -220,7 +220,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
watch(
|
||||
() => executionStore.activeJobId,
|
||||
(jobId, oldJobId) => {
|
||||
if (!appModeStore.isAppMode) return
|
||||
if (!isAppMode.value) return
|
||||
if (oldJobId && oldJobId !== jobId) {
|
||||
onJobComplete(oldJobId)
|
||||
}
|
||||
@@ -233,7 +233,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
watch(
|
||||
() => jobPreviewStore.previewsByPromptId,
|
||||
(previews) => {
|
||||
if (!appModeStore.isAppMode) return
|
||||
if (!isAppMode.value) return
|
||||
const jobId = executionStore.activeJobId
|
||||
if (!jobId) return
|
||||
const url = previews[jobId]
|
||||
@@ -243,7 +243,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
)
|
||||
|
||||
watch(
|
||||
() => appModeStore.isAppMode,
|
||||
isAppMode,
|
||||
(active, wasActive) => {
|
||||
if (active) {
|
||||
api.addEventListener('executed', handleExecuted)
|
||||
|
||||
@@ -50,6 +50,10 @@
|
||||
@dragleave="handleDragLeave"
|
||||
@drop.stop.prevent="handleDrop"
|
||||
>
|
||||
<AppOutput
|
||||
v-if="lgraphNode?.constructor?.nodeData?.output_node"
|
||||
:id="nodeData.id"
|
||||
/>
|
||||
<div
|
||||
v-if="displayHeader"
|
||||
class="flex flex-col justify-center items-center relative"
|
||||
@@ -284,6 +288,7 @@ import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { usePromotedPreviews } from '@/composables/node/usePromotedPreviews'
|
||||
import NodeBadges from '@/renderer/extensions/vueNodes/components/NodeBadges.vue'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import AppOutput from '@/renderer/extensions/linearMode/AppOutput.vue'
|
||||
import SlotConnectionDot from '@/renderer/extensions/vueNodes/components/SlotConnectionDot.vue'
|
||||
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
|
||||
import { useNodePointerInteractions } from '@/renderer/extensions/vueNodes/composables/useNodePointerInteractions'
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
<div
|
||||
v-if="!widget.hidden && (!widget.advanced || showAdvanced)"
|
||||
class="lg-node-widget group col-span-full grid grid-cols-subgrid items-stretch"
|
||||
:data-widget-name="widget.name"
|
||||
>
|
||||
<!-- Widget Input Slot Dot -->
|
||||
<div
|
||||
@@ -54,21 +53,24 @@
|
||||
/>
|
||||
</div>
|
||||
<!-- Widget Component -->
|
||||
<component
|
||||
:is="widget.vueComponent"
|
||||
v-model="widget.value"
|
||||
v-tooltip.left="widget.tooltipConfig"
|
||||
:widget="widget.simplified"
|
||||
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
|
||||
:node-type="nodeType"
|
||||
:class="
|
||||
cn(
|
||||
'col-span-2',
|
||||
widget.hasError && 'text-node-stroke-error font-bold'
|
||||
)
|
||||
"
|
||||
@update:model-value="widget.updateHandler"
|
||||
/>
|
||||
<AppInput :id="widget.id" :name="widget.name">
|
||||
<component
|
||||
:is="widget.vueComponent"
|
||||
v-model="widget.value"
|
||||
v-tooltip.left="widget.tooltipConfig"
|
||||
:widget="widget.simplified"
|
||||
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
|
||||
:node-type="nodeType"
|
||||
:class="
|
||||
cn(
|
||||
'col-span-2',
|
||||
widget.hasError && 'text-node-stroke-error font-bold'
|
||||
)
|
||||
"
|
||||
@update:model-value="widget.updateHandler"
|
||||
@contextmenu="widget.handleContextMenu"
|
||||
/>
|
||||
</AppInput>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -84,11 +86,14 @@ import type {
|
||||
WidgetSlotMetadata
|
||||
} from '@/composables/graph/useGraphNodeManager'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { showNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { st } from '@/i18n'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import AppInput from '@/renderer/extensions/linearMode/AppInput.vue'
|
||||
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
|
||||
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
|
||||
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
|
||||
import WidgetDOM from '@/renderer/extensions/vueNodes/widgets/components/WidgetDOM.vue'
|
||||
// Import widget components directly
|
||||
@@ -98,10 +103,7 @@ import {
|
||||
shouldExpand,
|
||||
shouldRenderAsVue
|
||||
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
|
||||
import {
|
||||
stripGraphPrefix,
|
||||
useWidgetValueStore
|
||||
} from '@/stores/widgetValueStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
|
||||
@@ -134,6 +136,8 @@ function handleBringToFront() {
|
||||
}
|
||||
}
|
||||
|
||||
const { handleNodeRightClick } = useNodeEventHandlers()
|
||||
|
||||
// Error boundary implementation
|
||||
const renderError = ref<string | null>(null)
|
||||
|
||||
@@ -159,9 +163,11 @@ const widgetValueStore = useWidgetValueStore()
|
||||
|
||||
interface ProcessedWidget {
|
||||
advanced: boolean
|
||||
handleContextMenu: (e: PointerEvent) => void
|
||||
hasLayoutSize: boolean
|
||||
hasError: boolean
|
||||
hidden: boolean
|
||||
id: string
|
||||
name: string
|
||||
simplified: SimplifiedWidget
|
||||
tooltipConfig: TooltipOptions
|
||||
@@ -191,7 +197,7 @@ const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
const { slotMetadata } = widget
|
||||
|
||||
// Get metadata from store (registered during BaseWidget.setNodeId)
|
||||
const bareWidgetId = stripGraphPrefix(widget.nodeId ?? nodeId)
|
||||
const bareWidgetId = widget.nodeId ?? nodeId
|
||||
const widgetState = graphId
|
||||
? widgetValueStore.getWidget(graphId, bareWidgetId, widget.name)
|
||||
: undefined
|
||||
@@ -236,15 +242,27 @@ const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
|
||||
const tooltipText = getWidgetTooltip(widget)
|
||||
const tooltipConfig = createTooltipConfig(tooltipText)
|
||||
const handleContextMenu = (e: PointerEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleNodeRightClick(e, nodeId)
|
||||
showNodeOptions(
|
||||
e,
|
||||
widget.name,
|
||||
widget.nodeId !== undefined ? String(widget.nodeId) : undefined
|
||||
)
|
||||
}
|
||||
|
||||
result.push({
|
||||
advanced: widget.options?.advanced ?? false,
|
||||
handleContextMenu,
|
||||
hasLayoutSize: widget.hasLayoutSize ?? false,
|
||||
hasError:
|
||||
nodeErrors?.errors?.some(
|
||||
(error) => error.extra_info?.input_name === widget.name
|
||||
) ?? false,
|
||||
hidden: widget.options?.hidden ?? false,
|
||||
id: widget.nodeId ?? nodeId,
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
vueComponent,
|
||||
|
||||
@@ -1,66 +1,67 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { whenever } from '@vueuse/core'
|
||||
import { reactive, readonly, computed, ref, watch } from 'vue'
|
||||
import { reactive, computed, watch } from 'vue'
|
||||
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { t } from '@/i18n'
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LinearData } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { 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 const useAppModeStore = defineStore('appMode', () => {
|
||||
const { getCanvas } = useCanvasStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { mode, setMode, isBuilderMode } = useAppMode()
|
||||
|
||||
const selectedInputs = reactive<[NodeId, string][]>([])
|
||||
const selectedOutputs = reactive<NodeId[]>([])
|
||||
const mode = ref<AppMode>('graph')
|
||||
const builderSaving = ref(false)
|
||||
const hasOutputs = computed(() => !!selectedOutputs.length)
|
||||
const enableAppBuilder = ref(true)
|
||||
|
||||
const isBuilderMode = computed(
|
||||
() => mode.value === 'builder:select' || mode.value === 'builder:arrange'
|
||||
)
|
||||
const isAppMode = computed(
|
||||
() => mode.value === 'app' || mode.value === 'builder:arrange'
|
||||
)
|
||||
const isGraphMode = computed(
|
||||
() => mode.value === 'graph' || mode.value === 'builder:select'
|
||||
)
|
||||
const isBuilderSaving = computed(
|
||||
() => builderSaving.value && isBuilderMode.value
|
||||
)
|
||||
function loadSelections(data: Partial<LinearData> | undefined) {
|
||||
selectedInputs.splice(0, selectedInputs.length, ...(data?.inputs ?? []))
|
||||
selectedOutputs.splice(0, selectedOutputs.length, ...(data?.outputs ?? []))
|
||||
}
|
||||
|
||||
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 ?? [])
|
||||
)
|
||||
loadSelections(activeWorkflow.changeTracker?.activeState?.extra?.linearData)
|
||||
}
|
||||
function saveSelectedToWorkflow() {
|
||||
app.rootGraph.extra ??= {}
|
||||
app.rootGraph.extra.linearData = {
|
||||
inputs: [...selectedInputs],
|
||||
outputs: [...selectedOutputs]
|
||||
|
||||
function flushSelections() {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (workflow) {
|
||||
workflow.dirtyLinearData = {
|
||||
inputs: [...selectedInputs],
|
||||
outputs: [...selectedOutputs]
|
||||
}
|
||||
}
|
||||
}
|
||||
whenever(() => workflowStore.activeWorkflow, resetSelectedToWorkflow, {
|
||||
immediate: true
|
||||
})
|
||||
|
||||
watch(
|
||||
() => workflowStore.activeWorkflow,
|
||||
(newWorkflow, oldWorkflow) => {
|
||||
// Persist in-progress builder selections to the outgoing workflow
|
||||
if (oldWorkflow && isBuilderMode.value) {
|
||||
oldWorkflow.dirtyLinearData = {
|
||||
inputs: [...selectedInputs],
|
||||
outputs: [...selectedOutputs]
|
||||
}
|
||||
}
|
||||
// Load from incoming workflow: dirty state first, then persisted
|
||||
if (newWorkflow) {
|
||||
loadSelections(
|
||||
newWorkflow.dirtyLinearData ??
|
||||
newWorkflow.changeTracker?.activeState?.extra?.linearData
|
||||
)
|
||||
} else {
|
||||
loadSelections(undefined)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => mode.value === 'builder:select',
|
||||
@@ -76,30 +77,18 @@ export const useAppModeStore = defineStore('appMode', () => {
|
||||
)
|
||||
return
|
||||
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (workflow) workflow.dirtyLinearData = null
|
||||
resetSelectedToWorkflow()
|
||||
mode.value = 'graph'
|
||||
setMode('graph')
|
||||
}
|
||||
|
||||
return {
|
||||
mode: readonly(mode),
|
||||
enableAppBuilder: readonly(enableAppBuilder),
|
||||
exitBuilder,
|
||||
isBuilderMode,
|
||||
isAppMode,
|
||||
isGraphMode,
|
||||
isBuilderSaving,
|
||||
hasOutputs,
|
||||
flushSelections,
|
||||
resetSelectedToWorkflow,
|
||||
saveSelectedToWorkflow,
|
||||
selectedInputs,
|
||||
selectedOutputs,
|
||||
setBuilderSaving: (newBuilderSaving: boolean) => {
|
||||
if (!isBuilderMode.value) return
|
||||
builderSaving.value = newBuilderSaving
|
||||
},
|
||||
setMode: (newMode: AppMode) => {
|
||||
if (newMode === mode.value) return
|
||||
mode.value = newMode
|
||||
}
|
||||
selectedOutputs
|
||||
}
|
||||
})
|
||||
|
||||
@@ -15,14 +15,6 @@ import type {
|
||||
*/
|
||||
type WidgetKey = `${NodeId}:${string}`
|
||||
|
||||
/**
|
||||
* Strips graph/subgraph prefixes from a scoped node ID to get the bare node ID.
|
||||
* e.g., "graph1:subgraph2:42" → "42"
|
||||
*/
|
||||
export function stripGraphPrefix(scopedId: NodeId | string): NodeId {
|
||||
return String(scopedId).replace(/^(.*:)+/, '') as NodeId
|
||||
}
|
||||
|
||||
export interface WidgetState<
|
||||
TValue = unknown,
|
||||
TType extends string = string,
|
||||
|
||||
@@ -226,7 +226,10 @@ export function createMockChangeTracker(
|
||||
undoQueue: [],
|
||||
redoQueue: [],
|
||||
changeCount: 0,
|
||||
checkState: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
store: vi.fn(),
|
||||
...overrides
|
||||
}
|
||||
return partial as Partial<ChangeTracker> as ChangeTracker
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<GraphCanvas @ready="onGraphReady" />
|
||||
</div>
|
||||
<LinearView v-if="linearMode" />
|
||||
<BuilderToolbar v-if="appModeStore.isBuilderMode" />
|
||||
<BuilderToolbar v-if="isBuilderMode" />
|
||||
</div>
|
||||
|
||||
<GlobalToast />
|
||||
@@ -70,7 +70,7 @@ import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { setupAutoQueueHandler } from '@/services/autoQueueService'
|
||||
import { useKeybindingService } from '@/platform/keybindings/keybindingService'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
@@ -88,12 +88,10 @@ import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
import BuilderToolbar from '@/components/builder/BuilderToolbar.vue'
|
||||
import { useBuilderSave } from '@/components/builder/useBuilderSave'
|
||||
import LinearView from '@/views/LinearView.vue'
|
||||
import ManagerProgressToast from '@/workbench/extensions/manager/components/ManagerProgressToast.vue'
|
||||
|
||||
setupAutoQueueHandler()
|
||||
useBuilderSave()
|
||||
useProgressFavicon()
|
||||
useBrowserTabTitle()
|
||||
|
||||
@@ -106,7 +104,7 @@ const queueStore = useQueueStore()
|
||||
const assetsStore = useAssetsStore()
|
||||
const versionCompatibilityStore = useVersionCompatibilityStore()
|
||||
const graphCanvasContainerRef = ref<HTMLDivElement | null>(null)
|
||||
const appModeStore = useAppModeStore()
|
||||
const { isBuilderMode } = useAppMode()
|
||||
const { linearMode } = storeToRefs(useCanvasStore())
|
||||
|
||||
const telemetry = useTelemetry()
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
import { breakpointsTailwind, unrefElement, useBreakpoints } from '@vueuse/core'
|
||||
import Splitter from 'primevue/splitter'
|
||||
import SplitterPanel from 'primevue/splitterpanel'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import AppBuilder from '@/components/builder/AppBuilder.vue'
|
||||
import AppModeToolbar from '@/components/appMode/AppModeToolbar.vue'
|
||||
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
|
||||
import ModeToggle from '@/components/sidebar/ModeToggle.vue'
|
||||
@@ -12,17 +14,27 @@ import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
|
||||
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
|
||||
import TypeformPopoverButton from '@/components/ui/TypeformPopoverButton.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import LinearControls from '@/renderer/extensions/linearMode/LinearControls.vue'
|
||||
import LinearPreview from '@/renderer/extensions/linearMode/LinearPreview.vue'
|
||||
import LinearProgressBar from '@/renderer/extensions/linearMode/LinearProgressBar.vue'
|
||||
import MobileMenu from '@/renderer/extensions/linearMode/MobileMenu.vue'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import {
|
||||
BUILDER_MIN_SIZE,
|
||||
CENTER_PANEL_SIZE,
|
||||
SIDEBAR_MIN_SIZE,
|
||||
SIDE_PANEL_SIZE
|
||||
} from '@/constants/splitterConstants'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const settingStore = useSettingStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const { isBuilderMode, isArrangeMode } = useAppMode()
|
||||
const appModeStore = useAppModeStore()
|
||||
const { hasOutputs } = storeToRefs(appModeStore)
|
||||
|
||||
const mobileDisplay = useBreakpoints(breakpointsTailwind).smaller('md')
|
||||
|
||||
@@ -30,13 +42,34 @@ 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
|
||||
)
|
||||
const showRightBuilder = computed(
|
||||
() => sidebarOnLeft.value && isArrangeMode.value
|
||||
)
|
||||
const hasLeftPanel = computed(
|
||||
() => (sidebarOnLeft.value && activeTab.value) || !sidebarOnLeft.value
|
||||
() =>
|
||||
isArrangeMode.value ||
|
||||
(sidebarOnLeft.value && activeTab.value) ||
|
||||
(!sidebarOnLeft.value && !isBuilderMode.value && hasOutputs.value)
|
||||
)
|
||||
const hasRightPanel = computed(
|
||||
() => sidebarOnLeft.value || (!sidebarOnLeft.value && activeTab.value)
|
||||
() =>
|
||||
isArrangeMode.value ||
|
||||
(sidebarOnLeft.value && !isBuilderMode.value && hasOutputs.value) ||
|
||||
(!sidebarOnLeft.value && activeTab.value)
|
||||
)
|
||||
|
||||
function sidePanelMinSize(isBuilder: boolean, isHidden: boolean) {
|
||||
if (isBuilder) return BUILDER_MIN_SIZE
|
||||
if (isHidden) return undefined
|
||||
return SIDEBAR_MIN_SIZE
|
||||
}
|
||||
|
||||
const bottomLeftRef = useTemplateRef('bottomLeftRef')
|
||||
const bottomRightRef = useTemplateRef('bottomRightRef')
|
||||
const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
|
||||
@@ -44,7 +77,7 @@ const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
|
||||
<template>
|
||||
<div class="absolute w-full h-full">
|
||||
<div
|
||||
class="workflow-tabs-container pointer-events-auto h-(--workflow-tabs-height) w-full"
|
||||
class="workflow-tabs-container pointer-events-auto h-(--workflow-tabs-height) w-full border-b border-interface-stroke shadow-interface"
|
||||
>
|
||||
<div class="flex h-full items-center">
|
||||
<WorkflowTabs />
|
||||
@@ -74,63 +107,96 @@ const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
|
||||
</div>
|
||||
<Splitter
|
||||
v-else
|
||||
class="h-[calc(100%-38px)] w-full bg-comfy-menu-secondary-bg"
|
||||
:pt="{ gutter: { class: 'bg-transparent w-4 -mx-1' } }"
|
||||
:key="isArrangeMode ? 'arrange' : 'normal'"
|
||||
class="h-[calc(100%-var(--workflow-tabs-height))] w-full border-none bg-comfy-menu-secondary-bg"
|
||||
:state-key="isArrangeMode ? 'builder-splitter' : undefined"
|
||||
:state-storage="isArrangeMode ? 'local' : undefined"
|
||||
@resizestart="({ originalEvent }) => originalEvent.preventDefault()"
|
||||
>
|
||||
<SplitterPanel
|
||||
v-if="hasLeftPanel"
|
||||
id="linearLeftPanel"
|
||||
:size="1"
|
||||
class="min-w-min outline-none"
|
||||
:size="isArrangeMode ? SIDE_PANEL_SIZE : 1"
|
||||
:min-size="
|
||||
sidePanelMinSize(showLeftBuilder, showRightBuilder && !activeTab)
|
||||
"
|
||||
:style="
|
||||
showRightBuilder && !activeTab ? { display: 'none' } : undefined
|
||||
"
|
||||
:class="
|
||||
cn(
|
||||
'outline-none arrange-panel',
|
||||
showLeftBuilder ? 'bg-comfy-menu-bg min-w-78' : 'min-w-min'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div v-if="showLeftBuilder" class="h-full overflow-y-auto">
|
||||
<AppBuilder />
|
||||
</div>
|
||||
<div
|
||||
v-if="sidebarOnLeft && activeTab"
|
||||
v-else-if="sidebarOnLeft && activeTab"
|
||||
class="flex h-full border-border-subtle border-r"
|
||||
>
|
||||
<ExtensionSlot :extension="activeTab" />
|
||||
</div>
|
||||
<LinearControls
|
||||
v-else
|
||||
v-else-if="!isArrangeMode"
|
||||
ref="linearWorkflowRef"
|
||||
:toast-to="unrefElement(bottomLeftRef) ?? undefined"
|
||||
/>
|
||||
</SplitterPanel>
|
||||
<SplitterPanel
|
||||
id="linearCenterPanel"
|
||||
:size="98"
|
||||
:size="isArrangeMode ? CENTER_PANEL_SIZE : 98"
|
||||
class="flex flex-col min-w-0 gap-4 px-10 pt-8 pb-4 relative text-muted-foreground outline-none"
|
||||
>
|
||||
<LinearProgressBar
|
||||
class="absolute top-0 left-0 w-[calc(100%+16px)] z-21"
|
||||
/>
|
||||
<LinearPreview :run-button-click="linearWorkflowRef?.runButtonClick" />
|
||||
<div class="absolute z-21 top-1 left-1">
|
||||
<AppModeToolbar v-if="!appModeStore.isBuilderMode" />
|
||||
<div class="absolute z-21 top-4 left-4">
|
||||
<AppModeToolbar v-if="!isBuilderMode" />
|
||||
</div>
|
||||
<div ref="bottomLeftRef" class="absolute z-20 bottom-4 left-4" />
|
||||
<div ref="bottomRightRef" class="absolute z-20 bottom-24 right-4" />
|
||||
<div ref="bottomLeftRef" class="absolute z-20 bottom-7 left-4" />
|
||||
<div ref="bottomRightRef" class="absolute z-20 bottom-7 right-4" />
|
||||
<div
|
||||
class="absolute z-20 bottom-4 right-4 text-base-foreground flex items-center gap-4"
|
||||
:class="
|
||||
cn(
|
||||
'absolute z-20 bottom-4 text-base-foreground flex items-center gap-2',
|
||||
sidebarOnLeft ? 'left-4' : 'right-4'
|
||||
)
|
||||
"
|
||||
>
|
||||
<TypeformPopoverButton
|
||||
data-tf-widget="gmVqFi8l"
|
||||
:align="
|
||||
settingStore.get('Comfy.Sidebar.Location') === 'left'
|
||||
? 'end'
|
||||
: 'start'
|
||||
"
|
||||
:align="sidebarOnLeft ? 'start' : 'end'"
|
||||
/>
|
||||
<div class="flex flex-col text-sm text-muted-foreground">
|
||||
<span>{{ t('linearMode.beta') }}</span>
|
||||
<span>{{ t('linearMode.giveFeedback') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</SplitterPanel>
|
||||
<SplitterPanel
|
||||
v-if="hasRightPanel"
|
||||
id="linearRightPanel"
|
||||
:size="1"
|
||||
class="min-w-min outline-none"
|
||||
:size="isArrangeMode ? SIDE_PANEL_SIZE : 1"
|
||||
:min-size="
|
||||
sidePanelMinSize(showRightBuilder, showLeftBuilder && !activeTab)
|
||||
"
|
||||
:style="showLeftBuilder && !activeTab ? { display: 'none' } : undefined"
|
||||
:class="
|
||||
cn(
|
||||
'outline-none arrange-panel',
|
||||
showRightBuilder ? 'bg-comfy-menu-bg min-w-78' : 'min-w-min'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div v-if="showRightBuilder" class="h-full overflow-y-auto">
|
||||
<AppBuilder />
|
||||
</div>
|
||||
<LinearControls
|
||||
v-if="sidebarOnLeft"
|
||||
v-else-if="sidebarOnLeft && !isArrangeMode"
|
||||
ref="linearWorkflowRef"
|
||||
:toast-to="unrefElement(bottomRightRef) ?? undefined"
|
||||
/>
|
||||
@@ -144,3 +210,21 @@ const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
|
||||
</Splitter>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.p-splitter-gutter) {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
:deep(.p-splitter-gutter:hover),
|
||||
:deep(.p-splitter-gutter[data-p-gutter-resizing='true']) {
|
||||
transition: background-color 0.2s ease 300ms;
|
||||
background-color: var(--p-primary-color);
|
||||
}
|
||||
|
||||
/* Hide gutter next to hidden arrange panels */
|
||||
:deep(.arrange-panel[style*='display: none'] + .p-splitter-gutter),
|
||||
:deep(.p-splitter-gutter + .arrange-panel[style*='display: none']) {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||