Further linear fixes (#8074)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8074-Further-linear-fixes-2e96d73d365081efb74bf150982c7a66)
by [Unito](https://www.unito.io)
This commit is contained in:
AustinMroz
2026-01-15 14:45:19 -08:00
committed by GitHub
parent aff7f2a296
commit 0d5ca96a2b
11 changed files with 122 additions and 76 deletions

View File

@@ -44,7 +44,7 @@
<SidebarBottomPanelToggleButton :is-small="isSmall" />
<SidebarShortcutsToggleButton :is-small="isSmall" />
<SidebarSettingsButton :is-small="isSmall" />
<ModeToggle v-if="showLinearToggle" />
<ModeToggle v-if="menuItemStore.hasSeenLinear || linearFeatureFlag" />
</div>
</div>
<HelpCenterPopups :is-small="isSmall" />
@@ -52,7 +52,7 @@
</template>
<script setup lang="ts">
import { useResizeObserver, whenever } from '@vueuse/core'
import { useResizeObserver } from '@vueuse/core'
import { debounce } from 'es-toolkit/compat'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
@@ -68,6 +68,7 @@ import { useTelemetry } from '@/platform/telemetry'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
import { useKeybindingStore } from '@/stores/keybindingStore'
import { useMenuItemStore } from '@/stores/menuItemStore'
import { useUserStore } from '@/stores/userStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import type { SidebarTabExtension } from '@/types/extensionTypes'
@@ -83,14 +84,14 @@ const settingStore = useSettingStore()
const userStore = useUserStore()
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const menuItemStore = useMenuItemStore()
const sideToolbarRef = ref<HTMLElement>()
const topToolbarRef = ref<HTMLElement>()
const bottomToolbarRef = ref<HTMLElement>()
const showLinearToggle = ref(useFeatureFlags().flags.linearToggleEnabled)
whenever(
() => canvasStore.linearMode,
() => (showLinearToggle.value = true)
const linearFeatureFlag = useFeatureFlags().featureFlag(
'linearToggleEnabled',
false
)
const isSmall = computed(

View File

@@ -54,6 +54,10 @@ interface IWidgetKnobOptions extends IWidgetOptions<number[]> {
gradient_stops?: string
}
export interface IWidgetAssetOptions extends IWidgetOptions {
openModal: () => void
}
/**
* A widget for a node.
* All types are based on IBaseWidget - additions can be made there or directly on individual types.
@@ -249,7 +253,7 @@ export interface ITextareaWidget extends IBaseWidget<string, 'textarea'> {
export interface IAssetWidget extends IBaseWidget<
string,
'asset',
IWidgetOptions<string[]>
IWidgetAssetOptions
> {
type: 'asset'
value: string

View File

@@ -53,6 +53,6 @@ export class AssetWidget
override onClick() {
//Open Modal
this.callback?.(this.value)
this.options.openModal()
}
}

View File

@@ -7,8 +7,9 @@ defineProps<{
onDragOver?: (e: DragEvent) => boolean
onDragDrop?: (e: DragEvent) => Promise<boolean> | boolean
dropIndicator?: {
label?: string
iconClass?: string
imageUrl?: string
label?: string
onClick?: (e: MouseEvent) => void
}
}>()
@@ -44,8 +45,15 @@ const canAcceptDrop = ref(false)
"
@click.prevent="dropIndicator?.onClick?.($event)"
>
<span v-if="dropIndicator.label" v-text="dropIndicator.label" />
<i v-if="dropIndicator.iconClass" :class="dropIndicator.iconClass" />
<img
v-if="dropIndicator?.imageUrl"
class="h-23"
:src="dropIndicator?.imageUrl"
/>
<template v-else>
<span v-if="dropIndicator.label" v-text="dropIndicator.label" />
<i v-if="dropIndicator.iconClass" :class="dropIndicator.iconClass" />
</template>
</div>
</div>
<slot v-else />

View File

@@ -18,6 +18,7 @@ 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'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
@@ -50,15 +51,26 @@ useEventListener(
() => (graphNodes.value = app.rootGraph.nodes)
)
function getDropIndicator(node: LGraphNode) {
if (node.type !== 'LoadImage') return undefined
const filename = node.widgets?.[0]?.value
const resultItem = { type: 'input', filename: `${filename}` }
return {
iconClass: 'icon-[lucide--image]',
imageUrl: filename
? api.apiURL(
`/view?${new URLSearchParams(resultItem)}${app.getPreviewFormatParam()}`
)
: undefined,
label: t('linearMode.dragAndDropImage'),
onClick: () => node.widgets?.[1]?.callback?.(undefined)
}
}
function nodeToNodeData(node: LGraphNode) {
const dropIndicator =
node.type !== 'LoadImage'
? undefined
: {
iconClass: 'icon-[lucide--image]',
label: t('linearMode.dragAndDropImage'),
onClick: () => node.widgets?.[1]?.callback?.(undefined)
}
const dropIndicator = getDropIndicator(node)
const nodeData = extractVueNodeData(node)
for (const widget of nodeData.widgets ?? []) widget.slotMetadata = undefined
@@ -107,7 +119,7 @@ async function runButtonClick(e: Event) {
: 'Comfy.QueuePrompt'
useTelemetry()?.trackUiButtonClicked({
button_id: 'queue_run_linear'
button_id: props.mobile ? 'queue_run_linear_mobile' : 'queue_run_linear'
})
if (batchCount.value > 1) {
useTelemetry()?.trackUiButtonClicked({
@@ -196,7 +208,7 @@ defineExpose({ runButtonClick })
<NodeWidgets
:node-data
:style="{ background: applyLightThemeColor(nodeData.bgcolor) }"
class="py-3 gap-y-3 **:[.col-span-2]:grid-cols-1 not-has-[textarea]:flex-0 rounded-lg"
class="py-3 gap-y-3 **:[.col-span-2]:grid-cols-1 not-has-[textarea]:flex-0 rounded-lg max-w-100"
/>
</template>
</div>

View File

@@ -159,7 +159,7 @@ async function rerun(e: Event) {
<VideoPreview
v-else-if="getMediaType(selectedOutput) === 'video'"
:src="selectedOutput!.url"
class="object-contain flex-1 contain-size"
class="object-contain flex-1 md:contain-size"
/>
<audio
v-else-if="getMediaType(selectedOutput) === 'audio'"
@@ -178,7 +178,7 @@ async function rerun(e: Event) {
/>
<img
v-else
class="pointer-events-none object-contain flex-1 max-h-full brightness-50 opacity-10"
class="pointer-events-none object-contain flex-1 max-h-full md:contain-size brightness-50 opacity-10"
src="/assets/images/comfy-logo-mono.svg"
/>
</template>

View File

@@ -217,7 +217,7 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
<ModeToggle />
</div>
<div class="border-border-subtle md:border-r" />
<WorkflowsSidebarTab v-if="displayWorkflows" class="min-w-50" />
<WorkflowsSidebarTab v-if="displayWorkflows" class="min-w-50 grow-1" />
<article
v-else
ref="outputsRef"

View File

@@ -215,7 +215,8 @@ describe('useComboWidget', () => {
'asset',
'ckpt_name',
'model1.safetensors',
expect.any(Function)
expect.any(Function),
expect.any(Object)
)
expect(mockSettingStoreGet).toHaveBeenCalledWith('Comfy.Assets.UseAssetAPI')
expect(vi.mocked(assetService.isAssetBrowserEligible)).toHaveBeenCalledWith(
@@ -250,7 +251,8 @@ describe('useComboWidget', () => {
'asset',
'ckpt_name',
'fallback.safetensors',
expect.any(Function)
expect.any(Function),
expect.any(Object)
)
expect(mockSettingStoreGet).toHaveBeenCalledWith('Comfy.Assets.UseAssetAPI')
expect(widget).toBe(mockWidget)
@@ -280,7 +282,8 @@ describe('useComboWidget', () => {
'asset',
'ckpt_name',
'Select model', // Should fallback to this instead of undefined
expect.any(Function)
expect.any(Function),
expect.any(Object)
)
expect(mockSettingStoreGet).toHaveBeenCalledWith('Comfy.Assets.UseAssetAPI')
expect(widget).toBe(mockWidget)

View File

@@ -4,7 +4,10 @@ import MultiSelectWidget from '@/components/graph/widgets/MultiSelectWidget.vue'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { isAssetWidget, isComboWidget } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type {
IBaseWidget,
IWidgetAssetOptions
} from '@/lib/litegraph/src/types/widgets'
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
import {
assetFilenameSchema,
@@ -91,55 +94,59 @@ const createAssetBrowserWidget = (
const displayLabel = currentValue ?? t('widgets.selectModel')
const assetBrowserDialog = useAssetBrowserDialog()
async function openModal(this: IBaseWidget) {
if (!isAssetWidget(widget)) {
throw new Error(`Expected asset widget but received ${widget.type}`)
}
await assetBrowserDialog.show({
nodeType: node.comfyClass || '',
inputName: inputSpec.name,
currentValue: widget.value,
onAssetSelected: (asset) => {
const validatedAsset = assetItemSchema.safeParse(asset)
if (!validatedAsset.success) {
console.error(
'Invalid asset item:',
validatedAsset.error.errors,
'Received:',
asset
)
return
}
const filename = validatedAsset.data.user_metadata?.filename
const validatedFilename = assetFilenameSchema.safeParse(filename)
if (!validatedFilename.success) {
console.error(
'Invalid asset filename:',
validatedFilename.error.errors,
'for asset:',
validatedAsset.data.id
)
return
}
const oldValue = widget.value
this.value = validatedFilename.data
node.onWidgetChanged?.(
widget.name,
validatedFilename.data,
oldValue,
widget
)
}
})
}
const options: IWidgetAssetOptions = { openModal }
const widget = node.addWidget(
'asset',
inputSpec.name,
displayLabel,
async function (this: IBaseWidget) {
if (!isAssetWidget(widget)) {
throw new Error(`Expected asset widget but received ${widget.type}`)
}
await assetBrowserDialog.show({
nodeType: node.comfyClass || '',
inputName: inputSpec.name,
currentValue: widget.value,
onAssetSelected: (asset) => {
const validatedAsset = assetItemSchema.safeParse(asset)
if (!validatedAsset.success) {
console.error(
'Invalid asset item:',
validatedAsset.error.errors,
'Received:',
asset
)
return
}
const filename = validatedAsset.data.user_metadata?.filename
const validatedFilename = assetFilenameSchema.safeParse(filename)
if (!validatedFilename.success) {
console.error(
'Invalid asset filename:',
validatedFilename.error.errors,
'for asset:',
validatedAsset.data.id
)
return
}
const oldValue = widget.value
this.value = validatedFilename.data
node.onWidgetChanged?.(
widget.name,
validatedFilename.data,
oldValue,
widget
)
}
})
}
() => undefined,
options
)
return widget

View File

@@ -1,16 +1,26 @@
import { whenever } from '@vueuse/core'
import { defineStore } from 'pinia'
import type { MenuItem } from 'primevue/menuitem'
import { ref } from 'vue'
import { CORE_MENU_COMMANDS } from '@/constants/coreMenuCommands'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import type { ComfyExtension } from '@/types/comfy'
import { useCommandStore } from './commandStore'
export const useMenuItemStore = defineStore('menuItem', () => {
const canvasStore = useCanvasStore()
const commandStore = useCommandStore()
const menuItems = ref<MenuItem[]>([])
const menuItemHasActiveStateChildren = ref<Record<string, boolean>>({})
const hasSeenLinear = ref(false)
whenever(
() => canvasStore.linearMode,
() => (hasSeenLinear.value = true),
{ immediate: true, once: true }
)
const registerMenuGroup = (path: string[], items: MenuItem[]) => {
let currentLevel = menuItems.value
@@ -103,6 +113,7 @@ export const useMenuItemStore = defineStore('menuItem', () => {
registerCommands,
loadExtensionMenuCommands,
registerCoreMenuCommands,
menuItemHasActiveStateChildren
menuItemHasActiveStateChildren,
hasSeenLinear
}
})

View File

@@ -139,8 +139,8 @@ const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
:selected-item
:selected-output
/>
<div ref="topLeftRef" class="absolute z-20 top-4 left-4" />
<div ref="topRightRef" class="absolute z-20 top-4 right-4" />
<div ref="topLeftRef" class="absolute z-21 top-4 left-4" />
<div ref="topRightRef" class="absolute z-21 top-4 right-4" />
<div ref="bottomLeftRef" class="absolute z-20 bottom-4 left-4" />
<div ref="bottomRightRef" class="absolute z-20 bottom-24 right-4" />
<div