mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-11 16:10:05 +00:00
Yet further app fixes (#9523)
- Prevent selection of note nodes - Prevents selection of nodes with errors - A bit broader than the reported "can select missing nodes". A node with an error is a node that can not execute and thus can not be used in an app. - Updates the typeform survey - Add a collapsible list of all api nodes(/prices) contained in an app. - Needs to be prettied up for mobile still. <img width="322" height="751" alt="image" src="https://github.com/user-attachments/assets/ebfeeada-9b80-488e-88d6-feaa8bd53629" /> ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9523-Yet-further-app-fixes-31c6d73d365081de9150fbf2d3ec54dd) by [Unito](https://www.unito.io)
This commit is contained in:
@@ -27,7 +27,7 @@ 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 { nodeTypeValidForApp, useAppModeStore } from '@/stores/appModeStore'
|
||||
import { resolveNode } from '@/utils/litegraphUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { HideLayoutFieldKey } from '@/types/widgetTypes'
|
||||
@@ -162,7 +162,11 @@ function handleDown(e: MouseEvent) {
|
||||
}
|
||||
function handleClick(e: MouseEvent) {
|
||||
const [node, widget] = getHovered(e) ?? []
|
||||
if (node?.mode !== LGraphEventMode.ALWAYS)
|
||||
if (
|
||||
node?.mode !== LGraphEventMode.ALWAYS ||
|
||||
!nodeTypeValidForApp(node.type) ||
|
||||
node.has_errors
|
||||
)
|
||||
return canvasInteractions.forwardEventToCanvas(e)
|
||||
|
||||
if (!widget) {
|
||||
@@ -198,7 +202,9 @@ const renderedOutputs = computed(() => {
|
||||
return canvas
|
||||
.graph!.nodes.filter(
|
||||
(n) =>
|
||||
n.constructor.nodeData?.output_node && n.mode === LGraphEventMode.ALWAYS
|
||||
n.constructor.nodeData?.output_node &&
|
||||
n.mode === LGraphEventMode.ALWAYS &&
|
||||
!n.has_errors
|
||||
)
|
||||
.map(nodeToDisplayTuple)
|
||||
})
|
||||
|
||||
@@ -3179,6 +3179,7 @@
|
||||
"loadTemplate": "Load a template",
|
||||
"cancelThisRun": "Cancel this run",
|
||||
"deleteAllAssets": "Delete all assets from this run",
|
||||
"hasCreditCost": "Requires additional credits",
|
||||
"welcome": {
|
||||
"title": "App Mode",
|
||||
"message": "A simplified view that hides the node graph so you can focus on creating.",
|
||||
|
||||
@@ -25,7 +25,7 @@ function togglePromotion() {
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'pointer-events-auto absolute size-full rounded-2xl ring-5 ring-warning-background/50',
|
||||
'pointer-events-auto absolute z-1 size-full rounded-2xl ring-5 ring-warning-background/50',
|
||||
isPromoted && 'ring-warning-background'
|
||||
)
|
||||
"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useEventListener, useTimeout } from '@vueuse/core'
|
||||
import { partition, remove, takeWhile } from 'es-toolkit'
|
||||
import { remove, takeWhile } from 'es-toolkit'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -19,6 +19,7 @@ import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import DropZone from '@/renderer/extensions/linearMode/DropZone.vue'
|
||||
import PartnerNodesList from '@/renderer/extensions/linearMode/PartnerNodesList.vue'
|
||||
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
@@ -119,20 +120,6 @@ function nodeToNodeData(node: LGraphNode) {
|
||||
onDragOver: node.onDragOver
|
||||
}
|
||||
}
|
||||
const partitionedNodes = computed(() => {
|
||||
const parts = partition(
|
||||
graphNodes.value
|
||||
.filter((node) => node.mode === 0 && node.widgets?.length)
|
||||
.map(nodeToNodeData)
|
||||
.reverse(),
|
||||
(node) => ['MarkdownNote', 'Note'].includes(node.type)
|
||||
)
|
||||
for (const noteNode of parts[0]) {
|
||||
for (const widget of noteNode.widgets ?? [])
|
||||
widget.options = { ...widget.options, read_only: true }
|
||||
}
|
||||
return parts
|
||||
})
|
||||
|
||||
//TODO: refactor out of this file.
|
||||
//code length is small, but changes should propagate
|
||||
@@ -180,34 +167,6 @@ defineExpose({ runButtonClick })
|
||||
v-text="workflowStore.activeWorkflow?.filename"
|
||||
/>
|
||||
<div class="flex-1" />
|
||||
<Popover
|
||||
v-if="partitionedNodes[0].length"
|
||||
align="end"
|
||||
class="z-100 max-h-(--reka-popover-content-available-height) overflow-x-clip overflow-y-auto"
|
||||
side="bottom"
|
||||
:side-offset="-8"
|
||||
>
|
||||
<template #button>
|
||||
<Button variant="muted-textonly">
|
||||
<i class="icon-[lucide--info]" />
|
||||
</Button>
|
||||
</template>
|
||||
<div>
|
||||
<template
|
||||
v-for="(nodeData, index) in partitionedNodes[0]"
|
||||
:key="nodeData.id"
|
||||
>
|
||||
<div
|
||||
v-if="index !== 0"
|
||||
class="w-full border-t border-border-subtle"
|
||||
/>
|
||||
<NodeWidgets
|
||||
:node-data
|
||||
class="max-w-100 gap-y-3 rounded-lg py-3 *:has-[textarea]:h-50 **:[.col-span-2]:grid-cols-1"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</Popover>
|
||||
<Button v-if="false"> {{ t('menuLabels.publish') }} </Button>
|
||||
</section>
|
||||
<div
|
||||
@@ -218,9 +177,7 @@ defineExpose({ runButtonClick })
|
||||
class="grow overflow-y-auto contain-size"
|
||||
>
|
||||
<template
|
||||
v-for="(nodeData, index) of appModeStore.selectedInputs.length
|
||||
? mappedSelections
|
||||
: partitionedNodes[0]"
|
||||
v-for="(nodeData, index) of mappedSelections"
|
||||
:key="nodeData.id"
|
||||
>
|
||||
<div
|
||||
@@ -274,6 +231,7 @@ defineExpose({ runButtonClick })
|
||||
</template>
|
||||
</div>
|
||||
</Teleport>
|
||||
<PartnerNodesList v-if="!mobile" />
|
||||
<section
|
||||
v-if="mobile"
|
||||
data-testid="linear-run-button"
|
||||
@@ -284,6 +242,7 @@ defineExpose({ runButtonClick })
|
||||
class="mt-4 w-full"
|
||||
/>
|
||||
<div v-else class="mt-4 flex">
|
||||
<PartnerNodesList mobile />
|
||||
<Popover side="top" @open-auto-focus.prevent>
|
||||
<template #button>
|
||||
<Button size="lg" class="-mr-3 pr-7">
|
||||
|
||||
14
src/renderer/extensions/linearMode/PartnerNodeItem.vue
Normal file
14
src/renderer/extensions/linearMode/PartnerNodeItem.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ title: string; price: string }>()
|
||||
</script>
|
||||
<template>
|
||||
<div class="not-last:mb-4">
|
||||
<div class="text-muted-foreground" v-text="title" />
|
||||
<span
|
||||
class="mt-2 flex h-5 max-w-max items-center rounded-full bg-component-node-widget-background p-2 py-3"
|
||||
>
|
||||
<i class="mr-1 icon-[lucide--component] h-4 bg-amber-400" />
|
||||
{{ price }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
85
src/renderer/extensions/linearMode/PartnerNodesList.vue
Normal file
85
src/renderer/extensions/linearMode/PartnerNodesList.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CollapsibleContent,
|
||||
CollapsibleRoot,
|
||||
CollapsibleTrigger
|
||||
} from 'reka-ui'
|
||||
import { computed, toValue } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import { usePriceBadge } from '@/composables/node/usePriceBadge'
|
||||
import PartnerNodeItem from '@/renderer/extensions/linearMode/PartnerNodeItem.vue'
|
||||
import { trackNodePrice } from '@/renderer/extensions/vueNodes/composables/usePartitionedBadges'
|
||||
import { app } from '@/scripts/app'
|
||||
import { mapAllNodes } from '@/utils/graphTraversalUtil'
|
||||
|
||||
defineProps<{ mobile?: boolean }>()
|
||||
|
||||
const { isCreditsBadge } = usePriceBadge()
|
||||
const { t } = useI18n()
|
||||
|
||||
const creditsBadges = computed(() =>
|
||||
mapAllNodes(app.graph, (node) => {
|
||||
if (node.isSubgraphNode()) return
|
||||
|
||||
const priceBadge = node.badges.find(isCreditsBadge)
|
||||
if (!priceBadge) return
|
||||
|
||||
trackNodePrice(node)
|
||||
return [node.title, toValue(priceBadge).text, node.id] as const
|
||||
})
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<Popover v-if="mobile && creditsBadges.length" side="top">
|
||||
<template #button>
|
||||
<Button class="mr-2 size-10">
|
||||
<i class="icon-[comfy--credits] bg-amber-400" />
|
||||
</Button>
|
||||
</template>
|
||||
<section
|
||||
class="max-h-(--reka-popover-content-available-height) overflow-y-auto"
|
||||
>
|
||||
<PartnerNodeItem
|
||||
v-for="[title, price, key] in creditsBadges"
|
||||
:key
|
||||
:title
|
||||
:price
|
||||
/>
|
||||
</section>
|
||||
</Popover>
|
||||
<div v-else-if="creditsBadges.length === 1">
|
||||
<PartnerNodeItem
|
||||
v-for="[title, price, key] in creditsBadges"
|
||||
:key
|
||||
:title
|
||||
:price
|
||||
class="border-t border-border-subtle pt-2"
|
||||
/>
|
||||
</div>
|
||||
<CollapsibleRoot
|
||||
v-else-if="creditsBadges.length"
|
||||
v-slot="{ open }"
|
||||
class="flex max-h-1/2 w-full flex-col"
|
||||
>
|
||||
<div class="mb-1 w-full border-b border-border-subtle" />
|
||||
<CollapsibleTrigger as-child>
|
||||
<Button variant="textonly" class="w-full text-sm">
|
||||
<i class="icon-[comfy--credits] size-4 bg-amber-400" />
|
||||
{{ t('linearMode.hasCreditCost') }}
|
||||
<i v-if="open" class="ml-auto icon-[lucide--chevron-up]" />
|
||||
<i v-else class="ml-auto icon-[lucide--chevron-down]" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent class="overflow-y-auto">
|
||||
<PartnerNodeItem
|
||||
v-for="[title, price, key] in creditsBadges"
|
||||
:key
|
||||
:title
|
||||
:price
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</CollapsibleRoot>
|
||||
</template>
|
||||
@@ -39,7 +39,8 @@
|
||||
v-if="
|
||||
lgraphNode?.constructor?.nodeData?.output_node &&
|
||||
isSelectOutputsMode &&
|
||||
nodeData.mode === LGraphEventMode.ALWAYS
|
||||
nodeData.mode === LGraphEventMode.ALWAYS &&
|
||||
!nodeData.hasErrors
|
||||
"
|
||||
:id="nodeData.id"
|
||||
/>
|
||||
@@ -91,10 +92,6 @@
|
||||
backgroundColor: applyLightThemeColor(nodeData?.color)
|
||||
}"
|
||||
>
|
||||
<AppOutput
|
||||
v-if="lgraphNode?.constructor?.nodeData?.output_node && isSelectMode"
|
||||
:id="nodeData.id"
|
||||
/>
|
||||
<div
|
||||
v-if="displayHeader"
|
||||
class="relative flex flex-col items-center justify-center"
|
||||
|
||||
@@ -109,6 +109,7 @@ import {
|
||||
shouldExpand,
|
||||
shouldRenderAsVue
|
||||
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
|
||||
import { nodeTypeValidForApp } from '@/stores/appModeStore'
|
||||
import {
|
||||
stripGraphPrefix,
|
||||
useWidgetValueStore
|
||||
@@ -160,7 +161,11 @@ onErrorCaptured((error) => {
|
||||
})
|
||||
|
||||
const canSelectInputs = computed(
|
||||
() => isSelectInputsMode.value && nodeData?.mode === LGraphEventMode.ALWAYS
|
||||
() =>
|
||||
isSelectInputsMode.value &&
|
||||
nodeData?.mode === LGraphEventMode.ALWAYS &&
|
||||
nodeTypeValidForApp(nodeData.type) &&
|
||||
!nodeData.hasErrors
|
||||
)
|
||||
const nodeType = computed(() => nodeData?.type || '')
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
@@ -4,6 +4,8 @@ import { computed, toValue } from 'vue'
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useNodePricing } from '@/composables/node/useNodePricing'
|
||||
import { usePriceBadge } from '@/composables/node/usePriceBadge'
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { NodeBadgeProps } from '@/renderer/extensions/vueNodes/components/NodeBadge.vue'
|
||||
import { app } from '@/scripts/app'
|
||||
@@ -17,6 +19,56 @@ function splitAroundFirstSpace(text: string): [string, string | undefined] {
|
||||
return [text.slice(0, index), text.slice(index + 1)]
|
||||
}
|
||||
|
||||
type TrackableNode = {
|
||||
id: NodeId
|
||||
type: string
|
||||
inputs?: INodeInputSlot[]
|
||||
}
|
||||
//TODO deduplicate reactivity tracking once more thoroughly tested
|
||||
export function trackNodePrice(node: TrackableNode) {
|
||||
const {
|
||||
getRelevantWidgetNames,
|
||||
hasDynamicPricing,
|
||||
getInputGroupPrefixes,
|
||||
getInputNames,
|
||||
getNodeRevisionRef
|
||||
} = useNodePricing()
|
||||
// Access per-node revision ref to establish dependency (each node has its own ref)
|
||||
void getNodeRevisionRef(node.id).value
|
||||
|
||||
if (!hasDynamicPricing(node.type)) return
|
||||
|
||||
// Access only the widget values that affect pricing (from widgetValueStore)
|
||||
const relevantNames = getRelevantWidgetNames(node.type)
|
||||
const widgetStore = useWidgetValueStore()
|
||||
const graphId = app.canvas?.graph?.rootGraph.id
|
||||
if (relevantNames.length > 0 && node.id != null) {
|
||||
for (const name of relevantNames) {
|
||||
// Access value from store to create reactive dependency
|
||||
if (!graphId) continue
|
||||
void widgetStore.getWidget(graphId, node.id, name)?.value
|
||||
}
|
||||
}
|
||||
// Access input connections for regular inputs
|
||||
const inputNames = getInputNames(node.type)
|
||||
if (inputNames.length > 0) {
|
||||
node?.inputs?.forEach((inp) => {
|
||||
if (inp.name && inputNames.includes(inp.name)) {
|
||||
void inp.link // Access link to create reactive dependency
|
||||
}
|
||||
})
|
||||
}
|
||||
// Access input connections for input_groups (e.g., autogrow inputs)
|
||||
const groupPrefixes = getInputGroupPrefixes(node.type)
|
||||
if (groupPrefixes.length > 0) {
|
||||
node?.inputs?.forEach((inp) => {
|
||||
if (groupPrefixes.some((prefix) => inp.name?.startsWith(prefix + '.'))) {
|
||||
void inp.link // Access link to create reactive dependency
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function usePartitionedBadges(nodeData: VueNodeData) {
|
||||
// Use per-node pricing revision to re-compute badges only when this node's pricing updates
|
||||
const {
|
||||
|
||||
@@ -12,6 +12,10 @@ import { app } from '@/scripts/app'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
import { resolveNode } from '@/utils/litegraphUtil'
|
||||
|
||||
export function nodeTypeValidForApp(type: string) {
|
||||
return !['Note', 'MarkdownNote'].includes(type)
|
||||
}
|
||||
|
||||
export const useAppModeStore = defineStore('appMode', () => {
|
||||
const { getCanvas } = useCanvasStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
@@ -85,7 +85,7 @@ const { onResizeEnd } = useStablePrimeVueSplitterSizer(
|
||||
[activeTab, splitterKey]
|
||||
)
|
||||
|
||||
const TYPEFORM_WIDGET_ID = 'gmVqFi8l'
|
||||
const TYPEFORM_WIDGET_ID = 'jmmzmlKw'
|
||||
|
||||
const bottomLeftRef = useTemplateRef('bottomLeftRef')
|
||||
const bottomRightRef = useTemplateRef('bottomRightRef')
|
||||
|
||||
Reference in New Issue
Block a user