Compare commits

..

13 Commits

Author SHA1 Message Date
CodeRabbit Fixer
eb8521d16f fix: Unify UserAvatar compact class to size-full to prevent width shift when toggling workspaces (#9689)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 03:21:19 +01:00
AustinMroz
af5a72021b Use preview downscaling in fewer places (#9678)
Thumbnail downscaling is currently being used in more places than it
should be.
- Nodes which display images will display incorrect resolution
indicators
<img width="255" height="372" alt="image"
src="https://github.com/user-attachments/assets/674790b6-04c8-4db0-84c2-2fa2dbaf123d"
/> <img width="255" height="372" alt="image"
src="https://github.com/user-attachments/assets/1dbe751b-7462-4408-9236-9446b005f5fc"
/>

This is particularly confusing with output nodes, which claim the output
is not of the intended resolution
- The "Download Image" and "Open Image" context menu actions will
incorrectly download the downscaled thumbnail.
- The assets panel will incorrectly display the thumbnail resolution as
the resolution of the output
- The lightbox (zoom) of an image will incorrectly display a downscaled
thumbnail.

This PR is a quick workaround to staunch the major problems
- Nodes always display full previews.
- Resolution downscaling is applied on the assert card, not on the
assetItem itself
- Due to implementation, this means that asset cards will still
incorrectly show the resolution of the thumbnail instead of the size of
the full image.

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-09 16:03:32 -07:00
Hunter
4e5bb3e540 fix: dispatch cloud build on synchronize for preview-labeled PRs (#9636)
## Summary

Cloud build dispatch was only triggering on the `labeled` event, not on
subsequent pushes to PRs that already had a preview label.

## Changes

- **What**: Add `synchronize` to `pull_request` event types and update
the `if` condition to support all three preview labels (`preview`,
`preview-cpu`, `preview-gpu`). For `labeled` events, check the added
label name; for `synchronize` events, check existing PR labels.

## Review Focus

The `if` condition now branches on `github.event.action` to use the
correct label-checking mechanism for each event type.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9636-fix-dispatch-cloud-build-on-synchronize-for-preview-labeled-PRs-31e6d73d3650814e9069e37d6199ffc9)
by [Unito](https://www.unito.io)
2026-03-09 15:55:53 -07:00
AustinMroz
2ccfb822b4 Restore hiding of linked inputs in app mode (#9671)
As a temporary fix for widgets being incorrectly hidden, #9669 allowed
all disabled widgets to be displayed.

This PR provides a more robust implementation to derive whether the
widget, as would be displayed from the root graph, is disabled.

Potential regression:
- Drag drop handlers are applied on node, not widgets. A subgraph
containing a "Load Image" node, does not allow dragging and dropping an
image onto the subgraphNode in order to load it. Because app mode
widgets would display from the original owning node prior to this PR,
these drag/drop handlers would apply. Placing "Load Image" nodes. I
believe this change makes behavior more consistent, but it warrants
consideration.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9671-Restore-hiding-of-linked-inputs-in-app-mode-31e6d73d365081688e37fbb931f3af68)
by [Unito](https://www.unito.io)
2026-03-09 13:18:05 -07:00
jaeone94
370003da94 fix: add isGraphReady guard to prevent premature graph access error logs (#9672)
## Summary
Adds `isGraphReady` getter to `ComfyApp` and uses it in
`executionErrorStore` guards to prevent false 'ComfyApp graph accessed
before initialization' error logs during early store evaluation.

## Changes
- **What**: Added `isGraphReady` boolean getter to `ComfyApp` that
safely checks graph initialization without triggering the `rootGraph`
getter's error log. Updated 5 guard sites in `executionErrorStore` to
use `app.isGraphReady` instead of `app.rootGraph`.
- **Why**: The `rootGraph` getter logs an error when accessed before
initialization. Computed properties and watch callbacks in
`executionErrorStore` are evaluated early (before graph init), causing
false error noise in the console.

## Review Focus
- `isGraphReady` is intentionally minimal — just
`!!this.rootGraphInternal` — to avoid duplicating the error-logging
behavior of `rootGraph`
- The `watch(lastNodeErrors, ...)` callback now checks `isGraphReady` at
the top and early-returns, consistent with the computed property pattern

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9672-fix-add-isGraphReady-guard-to-prevent-premature-graph-access-error-logs-31e6d73d365081be8e1fc77114ce9382)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-09 13:15:04 -07:00
Alexander Brown
3b5af4960f fix: show load widget inputs in media dropdown (#9670)
Main targeted, built on
https://github.com/Comfy-Org/ComfyUI_frontend/pull/9551

## Summary

Fix Load Image/Load Video input dropdown tabs not showing available
input assets in Vue node select dropdown.

## Changes

- **What**: Keep combo widget `options` object identity while exposing
dynamic `values` for cloud/remote combos.
- **What**: Remove temporary debug logging and restore clearer dropdown
filter branching.
- **What**: Remove stale `searcher`/`updateKey` prop plumbing in
dropdown menu/actions and update related tests.

## Review Focus

Verify `Load Image` / `Load Video` Inputs tab behavior and confirm
cloud/remote combo option values still update correctly.

Relates to #9551

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9670-fix-show-load-widget-inputs-in-media-dropdown-31e6d73d36508148b845e18268a60c2a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: bymyself <cbyrne@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-03-09 12:49:47 -07:00
Christian Byrne
46895ee1a9 docs: add release process guide (#9548)
Adds a concise guide to `docs/release-process.md` explaining how the
release workflows interact, with focus on the version semantics that
differ between minor and patch bumps.

Key sections:
- How minor bumps freeze the previous minor into `core/` and `cloud/`
branches
- How patch bumps on `main` vs `core/X.Y` differ (published vs draft
releases)
- Why unreleased commits are dual-homed when a minor bump happens
- Summary table, backporting, publishing, and bi-weekly automation

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9548-docs-add-release-process-guide-31d6d73d365081f2bdaace48a7cb81ae)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-09 12:38:40 -07:00
AustinMroz
7f0472fde4 Always use interior nodeId for app mode (#9669)
App mode stores the state of selected widgets as a tuple of `[NodeId,
WidgetName]`. With recent subgraph changes, for a given node,
`widget.name` will no longer uniquely resolve to a single widget.

- From both Vue and Litegraph, selecting an input for display in App
mode will now resolve the NodeId of the node which owns the widget
instead of the selected node.
- When displaying selections in litegraph, if the NodeId does not exist
in the current graph, instead of resolving the actual node the rootGraph
is searched for any subgraphNode which contains a view matching the
`[NodeId, WidgetName]` pair.
- When displaying widgets in App mode, the widget is always set as being
a view of the real widget (This means that they will not display a
purple promotion border.

Known Issue:
- These same subgraph changes made it so that a widget can be linked
without being disabled. This PR makes it so widgets which have been
linked instead display normally under the assumption that they are
incorrectly marked as disabled. As disabled widgets can not be selected
as inputs, this should handle normal usage fine, but a better solution
is being investigated

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9669-Always-use-interior-nodeId-for-app-mode-31e6d73d365081f8a918d0e43cb659ee)
by [Unito](https://www.unito.io)
2026-03-09 11:36:33 -07:00
Alexander Brown
24ac6388d7 style: Update share icon to be a send icon instead (#9667)
## Summary

It's a plane!

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9667-style-Update-share-icon-to-be-a-send-icon-instead-31e6d73d365081919013ea1521d26f2c)
by [Unito](https://www.unito.io)
2026-03-09 17:00:53 +00:00
Alexander Brown
6b6049e48e fix: Add a tooltip to account for assets with really long names. (#9665)
## Summary

Simple tooltip, default show delay.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9665-fix-Add-a-tooltip-to-account-for-assets-with-really-long-names-31e6d73d3650811f9057d1ec41e761b6)
by [Unito](https://www.unito.io)
2026-03-09 09:52:28 -07:00
AustinMroz
592f992d1d Even further app fixes (#9617)
- Allow dragging zoom pane with middle click
- Prevent selection of canvasOnly widgets
- These widgets would not display in app mode, so allow selection would
only cause confusion.
- Support displaying the error dialogue in app mode
- Add a somewhat involved mobile app mode error indication system
<img width="300" alt="image"
src="https://github.com/user-attachments/assets/d8793bbd-fff5-4b2a-a316-6ff154bae2c4"
/> <img width="300" alt="image"
src="https://github.com/user-attachments/assets/cb88b0f6-f7e5-409e-ae43-f1348f946b19"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9617-Even-further-app-fixes-31d6d73d365081c891dfdfe3477cfd61)
by [Unito](https://www.unito.io)
2026-03-09 09:35:31 -07:00
jaeone94
76fd80aa98 fix: hide empty actionbar container and relocate error border to floating actionbar (#9657)
## Summary
When the actionbar is floating and has no docked buttons, the container
is now hidden (zero-width, transparent border) to avoid showing an empty
rounded box. Additionally, the error/destructive border is now applied
to the floating actionbar panel itself (via `ComfyActionbar`) instead of
the container, so it appears in the correct location when floating.

## Changes
- **TopMenuSection**: Added `hasDockedButtons` and
`isActionbarContainerEmpty` computed properties to detect when the
docked container has no visible buttons; `actionbarContainerClass`
computed hides the container by collapsing it when empty and floating,
while preserving the legacy drop zone via `:has(.border-dashed)` CSS
selector
- **TopMenuSection**: Error border
(`border-destructive-background-hover`) is now only applied to the
docked container when the actionbar is **not** floating
- **ComfyActionbar**: Accepts new `hasAnyError` prop and applies the
error border to the floating panel's `panelClass` when floating

## Review Focus
- The `has-[.border-dashed]` CSS selector restores the container visuals
when a legacy drag-target element is present inside it — verify this
works as expected
- Error border placement: docked mode shows border on container,
floating mode shows border on the fixed panel

## Screenshots


https://github.com/user-attachments/assets/75caabac-e391-4bfd-b4dc-62d564e55d37
2026-03-09 21:24:00 +09:00
Hunter
63c36d3f2f feat: display original asset names instead of hashes in assets panel (#9626)
## Problem
Output assets in the assets panel show content hashes (e.g.,
`a1b2c3d4.png`) instead of display names (e.g., `ComfyUI_00001_.png`).

## Root Cause
Cloud inference replaces `filename` with the content hash in the output
transform pipeline. The hashed filename gets stored in the jobs table's
`preview_output` JSONB. The frontend uses this hash as the display name.

## Solution
- Add `display_name` field to `AssetItem` schema and `ResultItemImpl`
- Backend (cloud PR) joins job→assets table to resolve the original name
and injects `display_name` into job responses
- Frontend prefers `display_name` over `name` **only for display text
and download filenames**
- `asset.name` remains unchanged (the hash) for URLs, drag-to-canvas,
export filters, and output key dedup

## Backwards Compatible
- OSS: `display_name` is undefined, falls back to `asset.name` (which is
already the real filename in OSS)
- Cloud pre-deploy: `display_name` absent from API, falls back
gracefully
- Old jobs with no assets: `display_name` not injected, no change

## Cloud PR
https://github.com/Comfy-Org/cloud/pull/2747



https://github.com/user-attachments/assets/8a4c9cac-4ade-4ea2-9a70-9af240a56602
2026-03-09 01:06:28 -04:00
56 changed files with 891 additions and 1232 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

View File

@@ -14,7 +14,7 @@ on:
- 'cloud/*'
- 'main'
pull_request:
types: [labeled]
types: [labeled, synchronize]
workflow_dispatch:
permissions: {}
@@ -26,11 +26,18 @@ concurrency:
jobs:
dispatch:
# Fork guard: prevent forks from dispatching to the cloud repo.
# For pull_request events, only dispatch when the 'preview' label is added.
# For pull_request events, only dispatch for preview labels.
# - labeled: fires when a label is added; check the added label name.
# - synchronize: fires on push; check existing labels on the PR.
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
(github.event_name != 'pull_request' ||
github.event.label.name == 'preview')
(github.event.action == 'labeled' &&
contains(fromJSON('["preview","preview-cpu","preview-gpu"]'), github.event.label.name)) ||
(github.event.action == 'synchronize' &&
(contains(github.event.pull_request.labels.*.name, 'preview') ||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
contains(github.event.pull_request.labels.*.name, 'preview-gpu'))))
runs-on: ubuntu-latest
steps:
- name: Build client payload

62
docs/release-process.md Normal file
View File

@@ -0,0 +1,62 @@
# Release Process
## Bump Types
All releases use `release-version-bump.yaml`. Effects differ by bump type:
| Bump | Target | Creates branches? | GitHub release |
| ---------- | ---------- | ------------------------------------- | ---------------------------- |
| Minor | `main` | `core/` + `cloud/` for previous minor | Published, "latest" |
| Patch | `main` | No | Published, "latest" |
| Patch | `core/X.Y` | No | **Draft** (uncheck "latest") |
| Prerelease | any | No | Draft + prerelease |
**Minor bump** (e.g. 1.41→1.42): freezes the previous minor into `core/1.41`
and `cloud/1.41`, branched from the commit _before_ the bump. Nightly patch
bumps on `main` are convenience snapshots — no branches created.
**Patch on `core/X.Y`**: publishes a hotfix draft release. Must not be marked
"latest" so `main` stays current.
### Dual-homed commits
When a minor bump happens, unreleased commits appear in both places:
```
v1.40.1 ── A ── B ── C ── [bump to 1.41.0]
└── core/1.40
```
A, B, C become v1.41.0 on `main` AND sit on `core/1.40` (where they could
later ship as v1.40.2). Same commits, no divergence — the branch just prevents
1.41+ features from mixing in so ComfyUI can stay on 1.40.x.
## Backporting
1. Add `needs-backport` + version label to the merged PR
2. `pr-backport.yaml` cherry-picks and creates a backport PR
3. Conflicts produce a comment with details and an agent prompt
## Publishing
Merged PRs with the `Release` label trigger `release-draft-create.yaml`,
publishing to GitHub Releases (`dist.zip`), PyPI (`comfyui-frontend-package`),
and npm (`@comfyorg/comfyui-frontend-types`).
## Bi-weekly ComfyUI Integration
`release-biweekly-comfyui.yaml` runs every other Monday — if the next `core/`
branch has unreleased commits, it triggers a patch bump and drafts a PR to
`Comfy-Org/ComfyUI` updating `requirements.txt`.
## Workflows
| Workflow | Purpose |
| ------------------------------- | ------------------------------------------------ |
| `release-version-bump.yaml` | Bump version, create Release PR |
| `release-draft-create.yaml` | Build + publish to GitHub/PyPI/npm |
| `release-branch-create.yaml` | Create `core/` + `cloud/` branches (minor/major) |
| `release-biweekly-comfyui.yaml` | Auto-patch + ComfyUI requirements PR |
| `pr-backport.yaml` | Cherry-pick fixes to stable branches |
| `cloud-backport-tag.yaml` | Tag cloud branch merges |

View File

@@ -34,17 +34,7 @@
</Button>
</div>
<div
ref="actionbarContainerRef"
:class="
cn(
'actionbar-container pointer-events-auto relative flex h-12 items-center gap-2 rounded-lg border bg-comfy-menu-bg px-2 shadow-interface',
hasAnyError
? 'border-destructive-background-hover'
: 'border-interface-stroke'
)
"
>
<div ref="actionbarContainerRef" :class="actionbarContainerClass">
<ActionBarButtons />
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div
@@ -55,6 +45,7 @@
<ComfyActionbar
:top-menu-container="actionbarContainerRef"
:queue-overlay-expanded="isQueueOverlayExpanded"
:has-any-error="hasAnyError"
@update:progress-target="updateProgressTarget"
/>
<CurrentUserButton
@@ -70,7 +61,7 @@
@click="() => openShareDialog().catch(toastErrorHandler)"
@pointerenter="prefetchShareDialog"
>
<i class="icon-[lucide--share-2] size-4" />
<i class="icon-[comfy--send] size-4" />
<span class="not-md:hidden">
{{ t('actionbar.share') }}
</span>
@@ -123,7 +114,7 @@
</template>
<script setup lang="ts">
import { useLocalStorage } from '@vueuse/core'
import { useLocalStorage, useMutationObserver } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -145,6 +136,7 @@ import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useActionBarButtonStore } from '@/stores/actionBarButtonStore'
import { useQueueUIStore } from '@/stores/queueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
@@ -168,6 +160,7 @@ const { isLoggedIn } = useCurrentUser()
const { t } = useI18n()
const { toastErrorHandler } = useErrorHandling()
const executionErrorStore = useExecutionErrorStore()
const actionBarButtonStore = useActionBarButtonStore()
const queueUIStore = useQueueUIStore()
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
const { shouldShowRedDot: shouldShowConflictRedDot } =
@@ -182,6 +175,43 @@ const isActionbarEnabled = computed(
const isActionbarFloating = computed(
() => isActionbarEnabled.value && !isActionbarDocked.value
)
/**
* Whether the actionbar container has any visible docked buttons
* (excluding ComfyActionbar, which uses position:fixed when floating
* and does not contribute to the container's visual layout).
*/
const hasDockedButtons = computed(() => {
if (actionBarButtonStore.buttons.length > 0) return true
if (hasLegacyContent.value) return true
if (isLoggedIn.value && !isIntegratedTabBar.value) return true
if (isDesktop && !isIntegratedTabBar.value) return true
if (isCloud && flags.workflowSharingEnabled) return true
if (!isRightSidePanelOpen.value) return true
return false
})
const isActionbarContainerEmpty = computed(
() => isActionbarFloating.value && !hasDockedButtons.value
)
const actionbarContainerClass = computed(() => {
const base =
'actionbar-container pointer-events-auto relative flex h-12 items-center gap-2 rounded-lg border bg-comfy-menu-bg shadow-interface'
if (isActionbarContainerEmpty.value) {
return cn(
base,
'-ml-2 w-0 min-w-0 border-transparent shadow-none',
'has-[.border-dashed]:ml-0 has-[.border-dashed]:w-auto has-[.border-dashed]:min-w-auto',
'has-[.border-dashed]:border-interface-stroke has-[.border-dashed]:pl-2 has-[.border-dashed]:shadow-interface'
)
}
const borderClass =
!isActionbarFloating.value && hasAnyError.value
? 'border-destructive-background-hover'
: 'border-interface-stroke'
return cn(base, 'px-2', borderClass)
})
const isIntegratedTabBar = computed(
() => settingStore.get('Comfy.UI.TabBarLayout') !== 'Legacy'
)
@@ -233,6 +263,25 @@ const rightSidePanelTooltipConfig = computed(() =>
// Maintain support for legacy topbar elements attached by custom scripts
const legacyCommandsContainerRef = ref<HTMLElement>()
const hasLegacyContent = ref(false)
function checkLegacyContent() {
const el = legacyCommandsContainerRef.value
if (!el) {
hasLegacyContent.value = false
return
}
// Mirror the CSS: [&:not(:has(*>*:not(:empty)))]:hidden
hasLegacyContent.value =
el.querySelector(':scope > * > *:not(:empty)') !== null
}
useMutationObserver(legacyCommandsContainerRef, checkLegacyContent, {
childList: true,
subtree: true,
characterData: true
})
onMounted(() => {
if (legacyCommandsContainerRef.value) {
app.menu.element.style.width = 'fit-content'

View File

@@ -119,9 +119,14 @@ import { cn } from '@/utils/tailwindUtil'
import ComfyRunButton from './ComfyRunButton'
const { topMenuContainer, queueOverlayExpanded = false } = defineProps<{
const {
topMenuContainer,
queueOverlayExpanded = false,
hasAnyError = false
} = defineProps<{
topMenuContainer?: HTMLElement | null
queueOverlayExpanded?: boolean
hasAnyError?: boolean
}>()
const emit = defineEmits<{
@@ -435,7 +440,12 @@ const panelClass = computed(() =>
isDragging.value && 'pointer-events-none select-none',
isDocked.value
? 'static border-none bg-transparent p-0'
: 'fixed shadow-interface'
: [
'fixed shadow-interface',
hasAnyError
? 'border-destructive-background-hover'
: 'border-interface-stroke'
]
)
)
</script>

View File

@@ -8,6 +8,7 @@ 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 { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
@@ -27,7 +28,7 @@ import { DOMWidgetImpl } from '@/scripts/domWidget'
import { promptRenameWidget } from '@/utils/widgetUtil'
import { useAppMode } from '@/composables/useAppMode'
import { nodeTypeValidForApp, useAppModeStore } from '@/stores/appModeStore'
import { resolveNode } from '@/utils/litegraphUtil'
import { resolveNodeWidget } from '@/utils/litegraphUtil'
import { cn } from '@/utils/tailwindUtil'
import { HideLayoutFieldKey } from '@/types/widgetTypes'
@@ -52,18 +53,15 @@ workflowStore.activeWorkflow?.changeTracker?.reset()
const arrangeInputs = computed(() =>
appModeStore.selectedInputs
.map(([nodeId, widgetName]) => {
const node = resolveNode(nodeId)
if (!node) return null
const widget = node.widgets?.find((w) => w.name === widgetName)
return { nodeId, widgetName, node, widget }
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
return node ? { nodeId, widgetName, node, widget } : null
})
.filter((item): item is NonNullable<typeof item> => item !== null)
)
const inputsWithState = computed(() =>
appModeStore.selectedInputs.map(([nodeId, widgetName]) => {
const node = resolveNode(nodeId)
const widget = node?.widgets?.find((w) => w.name === widgetName)
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
if (!node || !widget) {
return {
nodeId,
@@ -108,7 +106,7 @@ function getHovered(
function getBounding(nodeId: NodeId, widgetName?: string) {
if (settingStore.get('Comfy.VueNodes.Enabled')) return undefined
const node = app.rootGraph.getNodeById(nodeId)
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
if (!node) return
const titleOffset =
@@ -121,7 +119,6 @@ function getBounding(nodeId: NodeId, widgetName?: string) {
left: `${node.pos[0]}px`,
top: `${node.pos[1] - titleOffset}px`
}
const widget = node.widgets?.find((w) => w.name === widgetName)
if (!widget) return
const margin = widget instanceof DOMWidgetImpl ? widget.margin : undefined
@@ -160,12 +157,16 @@ function handleClick(e: MouseEvent) {
else appModeStore.selectedOutputs.splice(index, 1)
return
}
if (!isSelectInputsMode.value) return
if (!isSelectInputsMode.value || widget.options.canvasOnly) return
const storeId = isPromotedWidgetView(widget) ? widget.sourceNodeId : node.id
const storeName = isPromotedWidgetView(widget)
? widget.sourceWidgetName
: widget.name
const index = appModeStore.selectedInputs.findIndex(
([nodeId, widgetName]) => node.id == nodeId && widget.name === widgetName
([nodeId, widgetName]) => storeId == nodeId && storeName === widgetName
)
if (index === -1) appModeStore.selectedInputs.push([node.id, widget.name])
if (index === -1) appModeStore.selectedInputs.push([storeId, storeName])
else appModeStore.selectedInputs.splice(index, 1)
}

View File

@@ -0,0 +1,51 @@
<script setup lang="ts">
import {
DialogClose,
DialogContent,
DialogOverlay,
DialogPortal,
DialogRoot,
DialogTitle,
DialogTrigger
} from 'reka-ui'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
defineProps<{ title?: string; to?: string | HTMLElement }>()
const { t } = useI18n()
</script>
<template>
<DialogRoot v-slot="{ close }">
<DialogTrigger as-child>
<slot name="button" />
</DialogTrigger>
<DialogPortal :to>
<DialogOverlay
class="data-[state=open]:animate-overlayShow fixed inset-0 z-30 bg-black/70"
/>
<DialogContent
v-bind="$attrs"
class="data-[state=open]:animate-contentShow fixed top-[50%] left-[50%] z-1700 max-h-[85vh] w-[90vw] max-w-[450px] translate-x-[-50%] translate-y-[-50%] rounded-2xl border border-border-subtle bg-base-background p-2 shadow-sm"
>
<div
v-if="title"
class="flex w-full items-center justify-between border-b border-border-subtle px-4"
>
<DialogTitle class="text-sm">{{ title }}</DialogTitle>
<DialogClose as-child>
<Button
:aria-label="t('g.close')"
size="icon"
variant="muted-textonly"
>
<i class="icon-[lucide--x]" />
</Button>
</DialogClose>
</div>
<slot :close />
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>

View File

@@ -50,7 +50,9 @@
{{ t('g.dismiss') }}
</Button>
<Button variant="secondary" size="lg" @click="seeErrors">
{{ t('errorOverlay.seeErrors') }}
{{
appMode ? t('linearMode.error.goto') : t('errorOverlay.seeErrors')
}}
</Button>
</div>
</div>
@@ -69,6 +71,8 @@ import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useErrorGroups } from '@/components/rightSidePanel/errors/useErrorGroups'
defineProps<{ appMode?: boolean }>()
const { t } = useI18n()
const executionErrorStore = useExecutionErrorStore()
const rightSidePanelStore = useRightSidePanelStore()
@@ -94,6 +98,7 @@ function dismiss() {
}
function seeErrors() {
canvasStore.linearMode = false
if (canvasStore.canvas) {
canvasStore.canvas.deselectAll()
canvasStore.updateSelectedItems()

View File

@@ -62,16 +62,9 @@ vi.mock('@/lib/litegraph/src/litegraph', async () => {
})
// Mock the colorUtil module
vi.mock('@/utils/colorUtil', async () => {
const actual = await vi.importActual<typeof import('@/utils/colorUtil')>(
'@/utils/colorUtil'
)
return {
...actual,
adjustColor: vi.fn((color: string) => color + '_light')
}
})
vi.mock('@/utils/colorUtil', () => ({
adjustColor: vi.fn((color: string) => color + '_light')
}))
// Mock the litegraphUtil module
vi.mock('@/utils/litegraphUtil', () => ({
@@ -90,25 +83,11 @@ describe('ColorPickerButton', () => {
locale: 'en',
messages: {
en: {
g: {
color: 'Color',
custom: 'Custom',
favorites: 'Favorites',
remove: 'Remove'
},
color: {
noColor: 'No Color',
red: 'Red',
green: 'Green',
blue: 'Blue'
},
shape: {
default: 'Default',
box: 'Box',
CARD: 'Card'
},
modelLibrary: {
sortRecent: 'Recent'
}
}
}

View File

@@ -21,7 +21,7 @@
</Button>
<div
v-if="showColorPicker"
class="absolute -top-10 left-1/2 z-10 min-w-44 -translate-x-1/2 rounded-lg border border-border-default bg-interface-panel-surface p-2 shadow-lg"
class="absolute -top-10 left-1/2 -translate-x-1/2"
>
<SelectButton
:model-value="selectedColorOption"
@@ -41,69 +41,11 @@
/>
</template>
</SelectButton>
<div class="mt-2 flex items-center gap-2">
<ColorPicker
data-testid="custom-color-trigger"
:model-value="currentPickerValue"
format="hex"
:aria-label="t('g.custom')"
class="h-8 w-8 overflow-hidden rounded-md border border-border-default bg-secondary-background"
:pt="{
preview: {
class: '!h-full !w-full !rounded-md !border-none'
}
}"
@update:model-value="onCustomColorUpdate"
/>
<button
class="flex size-8 cursor-pointer items-center justify-center rounded-md border border-border-default bg-secondary-background hover:bg-secondary-background-hover"
:title="isCurrentColorFavorite ? t('g.remove') : t('g.favorites')"
data-testid="toggle-favorite-color"
@click="toggleCurrentColorFavorite"
>
<i
:class="
isCurrentColorFavorite
? 'icon-[lucide--star] text-yellow-500'
: 'icon-[lucide--star-off]'
"
/>
</button>
</div>
<div v-if="favoriteColors.length" class="mt-2 flex flex-wrap gap-1">
<button
v-for="color in favoriteColors"
:key="`favorite-${color}`"
class="flex size-7 cursor-pointer items-center justify-center rounded-md border border-border-default bg-secondary-background hover:bg-secondary-background-hover"
:title="`${t('g.favorites')}: ${color.toUpperCase()}`"
@click="applySavedCustomColor(color)"
>
<div
class="size-4 rounded-full border border-border-default"
:style="{ backgroundColor: color }"
/>
</button>
</div>
<div v-if="recentColors.length" class="mt-2 flex flex-wrap gap-1">
<button
v-for="color in recentColors"
:key="`recent-${color}`"
class="flex size-7 cursor-pointer items-center justify-center rounded-md border border-border-default bg-secondary-background hover:bg-secondary-background-hover"
:title="`${t('modelLibrary.sortRecent')}: ${color.toUpperCase()}`"
@click="applySavedCustomColor(color)"
>
<div
class="size-4 rounded-full border border-border-default"
:style="{ backgroundColor: color }"
/>
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import ColorPicker from 'primevue/colorpicker'
import SelectButton from 'primevue/selectbutton'
import type { Raw } from 'vue'
import { computed, ref, watch } from 'vue'
@@ -119,26 +61,16 @@ import {
LiteGraph,
isColorable
} from '@/lib/litegraph/src/litegraph'
import { useCustomNodeColorSettings } from '@/composables/graph/useCustomNodeColorSettings'
import { useNodeCustomization } from '@/composables/graph/useNodeCustomization'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor, toHexFromFormat } from '@/utils/colorUtil'
import { adjustColor } from '@/utils/colorUtil'
import { getItemsColorOption } from '@/utils/litegraphUtil'
import { getDefaultCustomNodeColor } from '@/utils/nodeColorCustomization'
const { t } = useI18n()
const canvasStore = useCanvasStore()
const colorPaletteStore = useColorPaletteStore()
const workflowStore = useWorkflowStore()
const { applyCustomColor, getCurrentAppliedColor } = useNodeCustomization()
const {
favoriteColors,
recentColors,
isFavoriteColor,
toggleFavoriteColor
} = useCustomNodeColorSettings()
const isLightTheme = computed(
() => colorPaletteStore.completedActivePalette.light_theme
)
@@ -197,24 +129,16 @@ const applyColor = (colorOption: ColorOption | null) => {
}
const currentColorOption = ref<CanvasColorOption | null>(null)
const currentAppliedColor = computed(
() => getCurrentAppliedColor() ?? getDefaultCustomNodeColor()
)
const currentPickerValue = computed(() =>
currentAppliedColor.value.replace('#', '')
)
const currentColor = computed(() =>
currentColorOption.value
? isLightTheme.value
? toLightThemeColor(currentColorOption.value?.bgcolor)
: currentColorOption.value?.bgcolor
: currentAppliedColor.value
: null
)
const localizedCurrentColorName = computed(() => {
if (!currentColorOption.value?.bgcolor) {
return currentAppliedColor.value.toUpperCase()
}
if (!currentColorOption.value?.bgcolor) return null
const colorOption = colorOptions.find(
(option) =>
option.value.dark === currentColorOption.value?.bgcolor ||
@@ -222,25 +146,6 @@ const localizedCurrentColorName = computed(() => {
)
return colorOption?.localizedName ?? NO_COLOR_OPTION.localizedName
})
async function applySavedCustomColor(color: string) {
currentColorOption.value = null
await applyCustomColor(color)
showColorPicker.value = false
}
async function onCustomColorUpdate(value: string) {
await applySavedCustomColor(toHexFromFormat(value, 'hex'))
}
async function toggleCurrentColorFavorite() {
await toggleFavoriteColor(currentAppliedColor.value)
}
const isCurrentColorFavorite = computed(() =>
isFavoriteColor(currentAppliedColor.value)
)
const updateColorSelectionFromNode = (
newSelectedItems: Raw<Positionable[]>
) => {

View File

@@ -1,5 +1,5 @@
import { computed, reactive, ref, watch } from 'vue'
import type { Ref } from 'vue'
import { computed, reactive, ref, toValue, watch } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import Fuse from 'fuse.js'
import type { IFuseOptions } from 'fuse.js'
@@ -227,7 +227,7 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
}
export function useErrorGroups(
searchQuery: Ref<string>,
searchQuery: MaybeRefOrGetter<string>,
t: (key: string) => string
) {
const executionErrorStore = useExecutionErrorStore()
@@ -584,7 +584,7 @@ export function useErrorGroups(
})
const filteredGroups = computed<ErrorGroup[]>(() => {
const query = searchQuery.value.trim()
const query = toValue(searchQuery).trim()
return searchErrorGroups(tabErrorGroups.value, query)
})

View File

@@ -1,19 +1,12 @@
<script setup lang="ts">
import ColorPicker from 'primevue/colorpicker'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCustomNodeColorSettings } from '@/composables/graph/useCustomNodeColorSettings'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { ColorOption } from '@/lib/litegraph/src/litegraph'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil'
import {
applyCustomColorToItems,
getDefaultCustomNodeColor,
getSharedAppliedColor
} from '@/utils/nodeColorCustomization'
import { cn } from '@/utils/tailwindUtil'
import LayoutField from './LayoutField.vue'
@@ -23,7 +16,7 @@ import LayoutField from './LayoutField.vue'
* Here, we only care about the getColorOption and setColorOption methods,
* and do not concern ourselves with other methods.
*/
type PickedNode = LGraphNode | LGraphGroup
type PickedNode = Pick<LGraphNode, 'getColorOption' | 'setColorOption'>
const { nodes } = defineProps<{ nodes: PickedNode[] }>()
const emit = defineEmits<{ (e: 'changed'): void }>()
@@ -31,14 +24,6 @@ const emit = defineEmits<{ (e: 'changed'): void }>()
const { t } = useI18n()
const colorPaletteStore = useColorPaletteStore()
const {
darkerHeader,
favoriteColors,
isFavoriteColor,
recentColors,
rememberRecentColor,
toggleFavoriteColor
} = useCustomNodeColorSettings()
type NodeColorOption = {
name: string
@@ -117,127 +102,43 @@ const nodeColor = computed<NodeColorOption['name'] | null>({
emit('changed')
}
})
const currentAppliedColor = computed(
() => getSharedAppliedColor(nodes) ?? getDefaultCustomNodeColor()
)
const currentPickerValue = computed(() =>
currentAppliedColor.value.replace('#', '')
)
async function applySavedCustomColor(color: string) {
applyCustomColorToItems(nodes, color, {
darkerHeader: darkerHeader.value
})
await rememberRecentColor(color)
emit('changed')
}
async function toggleCurrentColorFavorite() {
await toggleFavoriteColor(currentAppliedColor.value)
}
const isCurrentColorFavorite = computed(() =>
isFavoriteColor(currentAppliedColor.value)
)
async function onCustomColorUpdate(value: string) {
await applySavedCustomColor(`#${value}`)
}
</script>
<template>
<LayoutField :label="t('rightSidePanel.color')">
<div class="space-y-2">
<div
class="grid grid-cols-5 justify-items-center gap-1 rounded-lg border-none bg-secondary-background p-1"
<div
class="grid grid-cols-5 justify-items-center gap-1 rounded-lg border-none bg-secondary-background p-1"
>
<button
v-for="option of colorOptions"
:key="option.name"
:class="
cn(
'flex size-8 cursor-pointer items-center justify-center rounded-lg border-0 bg-transparent text-left ring-0 outline-0',
option.name === nodeColor
? 'bg-interface-menu-component-surface-selected'
: 'hover:bg-interface-menu-component-surface-selected'
)
"
@click="nodeColor = option.name"
>
<button
v-for="option of colorOptions"
:key="option.name"
:class="
cn(
'flex size-8 cursor-pointer items-center justify-center rounded-lg border-0 bg-transparent text-left ring-0 outline-0',
<div
v-tooltip.top="option.localizedName()"
:class="cn('size-4 rounded-full ring-2 ring-gray-500/10')"
:style="{
backgroundColor: isLightTheme
? option.value.light
: option.value.dark,
'--tw-ring-color':
option.name === nodeColor
? 'bg-interface-menu-component-surface-selected'
: 'hover:bg-interface-menu-component-surface-selected'
)
"
@click="nodeColor = option.name"
>
<div
v-tooltip.top="option.localizedName()"
:class="cn('size-4 rounded-full ring-2 ring-gray-500/10')"
:style="{
backgroundColor: isLightTheme
? option.value.light
: option.value.dark,
'--tw-ring-color':
option.name === nodeColor
? isLightTheme
? option.value.ringLight
: option.value.ringDark
: undefined
}"
:data-testid="option.name"
/>
</button>
</div>
<div class="flex items-center gap-2">
<ColorPicker
:model-value="currentPickerValue"
format="hex"
:aria-label="t('g.custom')"
class="h-8 w-8 overflow-hidden rounded-md border border-border-default bg-secondary-background"
:pt="{
preview: {
class: '!h-full !w-full !rounded-md !border-none'
}
? isLightTheme
? option.value.ringLight
: option.value.ringDark
: undefined
}"
@update:model-value="onCustomColorUpdate"
:data-testid="option.name"
/>
<button
class="flex size-8 cursor-pointer items-center justify-center rounded-md border border-border-default bg-secondary-background hover:bg-secondary-background-hover"
:title="isCurrentColorFavorite ? t('g.remove') : t('g.favorites')"
@click="toggleCurrentColorFavorite"
>
<i
:class="
isCurrentColorFavorite
? 'icon-[lucide--star] text-yellow-500'
: 'icon-[lucide--star-off]'
"
/>
</button>
</div>
<div v-if="favoriteColors.length" class="flex flex-wrap gap-1">
<button
v-for="color in favoriteColors"
:key="`favorite-${color}`"
class="flex size-7 cursor-pointer items-center justify-center rounded-md border border-border-default bg-secondary-background hover:bg-secondary-background-hover"
:title="`${t('g.favorites')}: ${color.toUpperCase()}`"
@click="applySavedCustomColor(color)"
>
<div
class="size-4 rounded-full border border-border-default"
:style="{ backgroundColor: color }"
/>
</button>
</div>
<div v-if="recentColors.length" class="flex flex-wrap gap-1">
<button
v-for="color in recentColors"
:key="`recent-${color}`"
class="flex size-7 cursor-pointer items-center justify-center rounded-md border border-border-default bg-secondary-background hover:bg-secondary-background-hover"
:title="`${t('modelLibrary.sortRecent')}: ${color.toUpperCase()}`"
@click="applySavedCustomColor(color)"
>
<div
class="size-4 rounded-full border border-border-default"
:style="{ backgroundColor: color }"
/>
</button>
</div>
</button>
</div>
</LayoutField>
</template>

View File

@@ -33,7 +33,7 @@
tabindex="0"
:aria-label="
t('assetBrowser.ariaLabel.assetCard', {
name: item.asset.name,
name: getAssetDisplayName(item.asset),
type: getAssetMediaType(item.asset)
})
"
@@ -44,7 +44,7 @@
)
"
:preview-url="getAssetPreviewUrl(item.asset)"
:preview-alt="item.asset.name"
:preview-alt="getAssetDisplayName(item.asset)"
:icon-name="iconForMediaType(getAssetMediaType(item.asset))"
:is-video-preview="isVideoAsset(item.asset)"
:primary-text="getAssetPrimaryText(item.asset)"
@@ -133,8 +133,12 @@ const listGridStyle = {
gap: '0.5rem'
}
function getAssetDisplayName(asset: AssetItem): string {
return asset.display_name || asset.name
}
function getAssetPrimaryText(asset: AssetItem): string {
return truncateFilename(asset.name)
return truncateFilename(getAssetDisplayName(asset))
}
function getAssetMediaType(asset: AssetItem) {

View File

@@ -569,7 +569,7 @@ const handleZoomClick = (asset: AssetItem) => {
const dialogStore = useDialogStore()
dialogStore.showDialog({
key: 'asset-3d-viewer',
title: asset.name,
title: asset.display_name || asset.name,
component: Load3dViewerContent,
props: {
modelUrl: asset.preview_url || ''

View File

@@ -30,7 +30,7 @@
<UserAvatar
v-else
:photo-url="photoURL"
:class="compact && 'h-full w-auto'"
:class="compact && 'size-full'"
/>
<i v-if="showArrow" class="icon-[lucide--chevron-down] size-4 px-1" />

View File

@@ -24,7 +24,7 @@ function handleWheel(e: WheelEvent) {
let dragging = false
function handleDown(e: PointerEvent) {
if (e.button !== 0) return
if (e.button !== 0 && e.button !== 1) return
const zoomPaneEl = zoomPane.value
if (!zoomPaneEl) return

View File

@@ -1,49 +0,0 @@
import { computed } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import {
NODE_COLOR_DARKER_HEADER_SETTING_ID,
NODE_COLOR_FAVORITES_SETTING_ID,
NODE_COLOR_RECENTS_SETTING_ID,
normalizeNodeColor,
toggleFavoriteNodeColor,
upsertRecentNodeColor
} from '@/utils/nodeColorCustomization'
export function useCustomNodeColorSettings() {
const settingStore = useSettingStore()
const favoriteColors = computed(() =>
settingStore.get(NODE_COLOR_FAVORITES_SETTING_ID) ?? []
)
const recentColors = computed(() =>
settingStore.get(NODE_COLOR_RECENTS_SETTING_ID) ?? []
)
const darkerHeader = computed(() =>
settingStore.get(NODE_COLOR_DARKER_HEADER_SETTING_ID) ?? true
)
async function rememberRecentColor(color: string) {
const nextColors = upsertRecentNodeColor(recentColors.value, color)
await settingStore.set(NODE_COLOR_RECENTS_SETTING_ID, nextColors)
}
async function toggleFavoriteColor(color: string) {
const nextColors = toggleFavoriteNodeColor(favoriteColors.value, color)
await settingStore.set(NODE_COLOR_FAVORITES_SETTING_ID, nextColors)
}
function isFavoriteColor(color: string | null | undefined) {
if (!color) return false
return favoriteColors.value.includes(normalizeNodeColor(color))
}
return {
favoriteColors,
recentColors,
darkerHeader,
rememberRecentColor,
toggleFavoriteColor,
isFavoriteColor
}
}

View File

@@ -19,16 +19,7 @@ export function useGroupMenuOptions() {
const workflowStore = useWorkflowStore()
const settingStore = useSettingStore()
const canvasRefresh = useCanvasRefresh()
const {
applyCustomColor,
colorOptions,
favoriteColors,
recentColors,
getCurrentAppliedColor,
isLightTheme,
openCustomColorPicker,
shapeOptions
} = useNodeCustomization()
const { shapeOptions, colorOptions, isLightTheme } = useNodeCustomization()
const getFitGroupToNodesOption = (groupContext: LGraphGroup): MenuOption => ({
label: 'Fit Group To Nodes',
@@ -74,62 +65,19 @@ export function useGroupMenuOptions() {
label: t('contextMenu.Color'),
icon: 'icon-[lucide--palette]',
hasSubmenu: true,
submenu: (() => {
const presetEntries = colorOptions.map((colorOption) => ({
label: colorOption.localizedName,
color: isLightTheme.value
submenu: colorOptions.map((colorOption) => ({
label: colorOption.localizedName,
color: isLightTheme.value
? colorOption.value.light
: colorOption.value.dark,
action: () => {
groupContext.color = isLightTheme.value
? colorOption.value.light
: colorOption.value.dark,
action: () => {
groupContext.color = isLightTheme.value
? colorOption.value.light
: colorOption.value.dark
canvasRefresh.refreshCanvas()
bump()
}
}))
const presetColors = new Set(
colorOptions.map((colorOption) => colorOption.value.dark.toLowerCase())
)
const customEntries = [
...favoriteColors.value.map((color) => ({
label: `${t('g.favorites')}: ${color.toUpperCase()}`,
color
})),
...recentColors.value.map((color) => ({
label: `${t('modelLibrary.sortRecent')}: ${color.toUpperCase()}`,
color
}))
]
.filter((entry, index, entries) => {
return (
entries.findIndex((candidate) => candidate.color === entry.color) ===
index
)
})
.filter((entry) => !presetColors.has(entry.color.toLowerCase()))
.map((entry) => ({
...entry,
action: () => {
void applyCustomColor(entry.color)
bump()
}
}))
return [
...presetEntries,
...customEntries,
{
label: t('g.custom'),
color: getCurrentAppliedColor() ?? '#353535',
action: () => {
void openCustomColorPicker()
bump()
}
}
]
})()
: colorOption.value.dark
canvasRefresh.refreshCanvas()
bump()
}
}))
})
const getGroupModeOptions = (

View File

@@ -11,14 +11,7 @@ import {
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil'
import {
applyCustomColorToItems,
getDefaultCustomNodeColor,
getSharedAppliedColor,
pickHexColor
} from '@/utils/nodeColorCustomization'
import { useCustomNodeColorSettings } from './useCustomNodeColorSettings'
import { useCanvasRefresh } from './useCanvasRefresh'
interface ColorOption {
@@ -43,12 +36,6 @@ export function useNodeCustomization() {
const { t } = useI18n()
const canvasStore = useCanvasStore()
const colorPaletteStore = useColorPaletteStore()
const {
favoriteColors,
recentColors,
darkerHeader,
rememberRecentColor
} = useCustomNodeColorSettings()
const canvasRefresh = useCanvasRefresh()
const isLightTheme = computed(
() => colorPaletteStore.completedActivePalette.light_theme
@@ -114,28 +101,6 @@ export function useNodeCustomization() {
canvasRefresh.refreshCanvas()
}
const applyCustomColor = async (color: string) => {
const normalized = applyCustomColorToItems(
canvasStore.selectedItems,
color,
{
darkerHeader: darkerHeader.value
}
)
canvasRefresh.refreshCanvas()
await rememberRecentColor(normalized)
}
const openCustomColorPicker = async () => {
const color = await pickHexColor(
getCurrentAppliedColor() ?? getDefaultCustomNodeColor()
)
if (!color) return
await applyCustomColor(color)
}
const applyShape = (shapeOption: ShapeOption) => {
const selectedNodes = Array.from(canvasStore.selectedItems).filter(
(item): item is LGraphNode => item instanceof LGraphNode
@@ -190,21 +155,13 @@ export function useNodeCustomization() {
)
}
const getCurrentAppliedColor = (): string | null =>
getSharedAppliedColor(Array.from(canvasStore.selectedItems))
return {
colorOptions,
shapeOptions,
applyColor,
applyCustomColor,
applyShape,
getCurrentColor,
getCurrentAppliedColor,
getCurrentShape,
openCustomColorPicker,
favoriteColors,
recentColors,
isLightTheme
}
}

View File

@@ -11,18 +11,8 @@ import type { NodeSelectionState } from './useSelectionState'
*/
export function useNodeMenuOptions() {
const { t } = useI18n()
const {
shapeOptions,
applyShape,
applyColor,
applyCustomColor,
colorOptions,
favoriteColors,
recentColors,
getCurrentAppliedColor,
isLightTheme,
openCustomColorPicker
} = useNodeCustomization()
const { shapeOptions, applyShape, applyColor, colorOptions, isLightTheme } =
useNodeCustomization()
const {
adjustNodeSize,
toggleNodeCollapse,
@@ -39,7 +29,7 @@ export function useNodeMenuOptions() {
)
const colorSubmenu = computed(() => {
const presetEntries = colorOptions.map((colorOption) => ({
return colorOptions.map((colorOption) => ({
label: colorOption.localizedName,
color: isLightTheme.value
? colorOption.value.light
@@ -47,45 +37,6 @@ export function useNodeMenuOptions() {
action: () =>
applyColor(colorOption.name === 'noColor' ? null : colorOption)
}))
const presetColors = new Set(
colorOptions.map((colorOption) => colorOption.value.dark.toLowerCase())
)
const customEntries = [
...favoriteColors.value.map((color) => ({
label: `${t('g.favorites')}: ${color.toUpperCase()}`,
color
})),
...recentColors.value.map((color) => ({
label: `${t('modelLibrary.sortRecent')}: ${color.toUpperCase()}`,
color
}))
]
.filter((entry, index, entries) => {
return (
entries.findIndex((candidate) => candidate.color === entry.color) ===
index
)
})
.filter((entry) => !presetColors.has(entry.color.toLowerCase()))
.map((entry) => ({
...entry,
action: () => {
void applyCustomColor(entry.color)
}
}))
return [
...presetEntries,
...customEntries,
{
label: t('g.custom'),
color: getCurrentAppliedColor() ?? '#353535',
action: () => {
void openCustomColorPicker()
}
}
]
})
const getAdjustSizeOption = (): MenuOption => ({

View File

@@ -1,6 +1,5 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { NodeOutputWith } from '@/schemas/apiSchema'
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useExtensionService } from '@/services/extensionService'
@@ -29,7 +28,6 @@ useExtensionService().registerExtension({
const toUrl = (record: Record<string, string>) => {
const params = new URLSearchParams(record)
appendCloudResParam(params, record.filename)
return api.apiURL(`/view?${params}${rand}`)
}

View File

@@ -1,143 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/i18n', () => ({
st: (_key: string, fallback: string) => fallback
}))
vi.mock('@/utils/nodeColorPersistence', async () => {
const actual = await vi.importActual<typeof import('@/utils/nodeColorPersistence')>(
'@/utils/nodeColorPersistence'
)
return {
...actual,
pickHexColor: vi.fn().mockResolvedValue('#abcdef')
}
})
import type { ContextMenu } from './ContextMenu'
import { LGraphCanvas } from './LGraphCanvas'
import { LGraphGroup } from './LGraphGroup'
import { LGraphNode } from './LGraphNode'
import { LiteGraph } from './litegraph'
describe('LGraphCanvas.onMenuNodeColors', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('adds a custom color entry to the legacy submenu', () => {
const graph = {
beforeChange: vi.fn(),
afterChange: vi.fn()
}
const node = Object.assign(Object.create(LGraphNode.prototype), {
graph,
color: undefined,
bgcolor: undefined
}) as LGraphNode
const canvas = {
selectedItems: new Set([node]),
setDirty: vi.fn()
}
LGraphCanvas.active_canvas = canvas as unknown as LGraphCanvas
let capturedValues:
| ReadonlyArray<{ content?: string } | string | null>
| undefined
const originalContextMenu = LiteGraph.ContextMenu
class MockContextMenu {
constructor(values: ReadonlyArray<{ content?: string } | string | null>) {
capturedValues = values
}
}
LiteGraph.ContextMenu = MockContextMenu as unknown as typeof LiteGraph.ContextMenu
try {
LGraphCanvas.onMenuNodeColors(
{ content: 'Colors', value: null },
{} as never,
new MouseEvent('contextmenu'),
{} as ContextMenu<string | null>,
node
)
const contents = capturedValues
?.filter((value): value is { content?: string } => typeof value === 'object' && value !== null)
.map((value) => value.content ?? '')
expect(contents).toEqual(
expect.arrayContaining([
expect.stringContaining('Custom...')
])
)
} finally {
LiteGraph.ContextMenu = originalContextMenu
}
})
it('applies a picked custom color to selected nodes and groups in legacy mode', async () => {
const graph = {
beforeChange: vi.fn(),
afterChange: vi.fn()
}
const node = Object.assign(Object.create(LGraphNode.prototype), {
graph,
color: undefined,
bgcolor: undefined
}) as LGraphNode
const group = Object.assign(Object.create(LGraphGroup.prototype), {
graph,
color: undefined
}) as LGraphGroup
const canvas = {
selectedItems: new Set([node, group]),
setDirty: vi.fn()
}
LGraphCanvas.active_canvas = canvas as unknown as LGraphCanvas
let callback:
| ((value: { value?: unknown }) => void)
| undefined
const originalContextMenu = LiteGraph.ContextMenu
class MockContextMenu {
constructor(
_values: ReadonlyArray<{ content?: string } | string | null>,
options: { callback?: (value: { value?: unknown }) => void }
) {
callback = options.callback
}
}
LiteGraph.ContextMenu = MockContextMenu as unknown as typeof LiteGraph.ContextMenu
try {
LGraphCanvas.onMenuNodeColors(
{ content: 'Colors', value: null },
{} as never,
new MouseEvent('contextmenu'),
{} as ContextMenu<string | null>,
node
)
callback?.({
value: {
kind: 'custom-picker'
}
})
await Promise.resolve()
await Promise.resolve()
expect(node.bgcolor).toBe('#abcdef')
expect(node.color).not.toBe('#abcdef')
expect(group.color).toBe('#abcdef')
expect(graph.beforeChange).toHaveBeenCalled()
expect(graph.afterChange).toHaveBeenCalled()
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
} finally {
LiteGraph.ContextMenu = originalContextMenu
}
})
})

View File

@@ -2,7 +2,6 @@ import { toString } from 'es-toolkit/compat'
import { toValue } from 'vue'
import { PREFIX, SEPARATOR } from '@/constants/groupNodeConstants'
import { st } from '@/i18n'
import { MovingInputLink } from '@/lib/litegraph/src/canvas/MovingInputLink'
import { LitegraphLinkAdapter } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
import type { LinkRenderContext } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
@@ -10,12 +9,6 @@ import { getSlotPosition } from '@/renderer/core/canvas/litegraph/slotCalculatio
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
import { forEachNode } from '@/utils/graphTraversalUtil'
import {
deriveCustomNodeHeaderColor,
getDefaultCustomNodeColor,
normalizeNodeColor,
pickHexColor
} from '@/utils/nodeColorPersistence'
import { CanvasPointer } from './CanvasPointer'
import type { ContextMenu } from './ContextMenu'
@@ -163,77 +156,6 @@ interface ICreateDefaultNodeOptions extends ICreateNodeOptions {
posSizeFix?: Point
}
type LegacyColorTarget = (LGraphNode | LGraphGroup) & IColorable & Positionable
type LegacyColorMenuAction =
| { kind: 'preset'; presetName: string | null }
| { kind: 'custom'; color: string }
| { kind: 'custom-picker' }
function isLegacyColorTarget(item: unknown): item is LegacyColorTarget {
return item instanceof LGraphNode || item instanceof LGraphGroup
}
function getLegacyColorTargets(target: LegacyColorTarget): LegacyColorTarget[] {
const selected = Array.from(LGraphCanvas.active_canvas.selectedItems).filter(
isLegacyColorTarget
)
return selected.length ? selected : [target]
}
function getAppliedColorForLegacyTarget(target: LegacyColorTarget): string | null {
const presetColor = target.getColorOption()
if (presetColor) {
return target instanceof LGraphGroup
? presetColor.groupcolor
: presetColor.bgcolor
}
return target instanceof LGraphGroup ? target.color ?? null : target.bgcolor ?? null
}
function getSharedAppliedColorForLegacyTargets(
targets: LegacyColorTarget[]
): string | null {
if (!targets.length) return null
const firstColor = getAppliedColorForLegacyTarget(targets[0])
return targets.every((target) => getAppliedColorForLegacyTarget(target) === firstColor)
? firstColor
: null
}
function createLegacyColorMenuContent(label: string, color?: string): string {
if (!color) {
return `<span style='display: block; padding-left: 4px;'>${label}</span>`
}
return (
`<span style='display: block; color: #fff; padding-left: 4px;` +
` border-left: 8px solid ${color}; background-color:${color}'>${label}</span>`
)
}
function applyLegacyCustomColor(
targets: LegacyColorTarget[],
color: string,
darkerHeader: boolean = true
): string {
const normalized = normalizeNodeColor(color)
for (const target of targets) {
if (target instanceof LGraphGroup) {
target.color = normalized
} else {
target.bgcolor = normalized
target.color = deriveCustomNodeHeaderColor(normalized, darkerHeader)
}
}
return normalized
}
interface HasShowSearchCallback {
/** See {@link LGraphCanvas.showSearchBox} */
showSearchBox: (
@@ -1727,91 +1649,62 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
/** @param value Parameter is never used */
static onMenuNodeColors(
_value: IContextMenuValue<string | null>,
value: IContextMenuValue<string | null>,
_options: IContextMenuOptions,
e: MouseEvent,
menu: ContextMenu<string | null>,
node: LGraphNode | LGraphGroup
node: LGraphNode
): boolean {
if (!node || !isLegacyColorTarget(node)) throw 'no node for color'
const values: (IContextMenuValue<LegacyColorMenuAction> | null)[] = [
{
value: { kind: 'preset', presetName: null },
content: createLegacyColorMenuContent(
st('color.noColor', 'No color')
)
}
]
if (!node) throw 'no node for color'
for (const [presetName, colorOption] of Object.entries(
LGraphCanvas.node_colors
)) {
values.push({
value: { kind: 'preset', presetName },
content: createLegacyColorMenuContent(
st(`color.${presetName}`, presetName),
colorOption.bgcolor
)
})
}
values.push(null)
const values: IContextMenuValue<
string | null,
unknown,
{ value: string | null }
>[] = []
values.push({
value: { kind: 'custom-picker' },
content: createLegacyColorMenuContent(st('g.custom', 'Custom') + '...')
value: null,
content:
"<span style='display: block; padding-left: 4px;'>No color</span>"
})
new LiteGraph.ContextMenu<LegacyColorMenuAction>(values, {
for (const i in LGraphCanvas.node_colors) {
const color = LGraphCanvas.node_colors[i]
value = {
value: i,
content:
`<span style='display: block; color: #999; padding-left: 4px;` +
` border-left: 8px solid ${color.color}; background-color:${color.bgcolor}'>${i}</span>`
}
values.push(value)
}
new LiteGraph.ContextMenu<string | null>(values, {
event: e,
callback: (value) => {
if (typeof value === 'string' || value == null) return
void innerClicked(value as IContextMenuValue<LegacyColorMenuAction>)
},
parentMenu: menu as unknown as ContextMenu<LegacyColorMenuAction>,
...(node instanceof LGraphNode ? { node } : {})
callback: inner_clicked,
parentMenu: menu,
node
})
async function innerClicked(v: IContextMenuValue<LegacyColorMenuAction>) {
if (!node || !isLegacyColorTarget(node) || !v?.value) return
function inner_clicked(v: IContextMenuValue<string>) {
if (!node) return
const fApplyColor = function (item: IColorable) {
const colorOption = v.value ? LGraphCanvas.node_colors[v.value] : null
item.setColorOption(colorOption)
}
const canvas = LGraphCanvas.active_canvas
const targets = getLegacyColorTargets(node)
const graphInfo = node instanceof LGraphNode ? node : undefined
switch (v.value.kind) {
case 'preset': {
node.graph?.beforeChange(graphInfo)
const colorOption = v.value.presetName
? LGraphCanvas.node_colors[v.value.presetName]
: null
for (const target of targets) {
target.setColorOption(colorOption)
}
node.graph?.afterChange(graphInfo)
canvas.setDirty(true, true)
return
}
case 'custom': {
node.graph?.beforeChange(graphInfo)
applyLegacyCustomColor(targets, v.value.color)
node.graph?.afterChange(graphInfo)
canvas.setDirty(true, true)
return
}
case 'custom-picker': {
const currentColor = getSharedAppliedColorForLegacyTargets(targets)
const pickedColor = await pickHexColor(
currentColor ?? getDefaultCustomNodeColor()
)
if (!pickedColor) return
node.graph?.beforeChange(graphInfo)
applyLegacyCustomColor(targets, pickedColor)
node.graph?.afterChange(graphInfo)
canvas.setDirty(true, true)
return
if (
!canvas.selected_nodes ||
Object.keys(canvas.selected_nodes).length <= 1
) {
fApplyColor(node)
} else {
for (const i in canvas.selected_nodes) {
fApplyColor(canvas.selected_nodes[i])
}
}
canvas.setDirty(true, true)
}
return false

View File

@@ -3180,6 +3180,7 @@
"cancelThisRun": "Cancel this run",
"deleteAllAssets": "Delete all assets from this run",
"hasCreditCost": "Requires additional credits",
"viewGraph": "View node graph",
"welcome": {
"title": "App Mode",
"message": "A simplified view that hides the node graph so you can focus on creating.",
@@ -3224,6 +3225,19 @@
"outputPlaceholder": "Output nodes will show up here",
"outputRequiredPlaceholder": "At least one node is required"
},
"error": {
"header": "This app encountered an error",
"log": "Error Logs",
"mobileFixable": "Check {0} for errors",
"requiresGraph": "Something went wrong during generation. This could be due to invalid hidden inputs, missing resources, or workflow configuration issues.",
"promptVisitGraph": "View the node graph to see the full error.",
"getHelp": "For help, view our {0}, {1}, or {2} with the copied error.",
"goto": "Show errors in graph",
"github": "submit a GitHub issue",
"guide": "troubleshooting guide",
"support": "contact our support",
"promptShow": "Show error report"
},
"queue": {
"clickToClear": "Click to clear queue",
"clear": "Clear queue"

View File

@@ -186,7 +186,7 @@ const tooltipDelay = computed<number>(() =>
const { isLoading, error } = useImage({
src: asset.preview_url ?? '',
alt: asset.name
alt: asset.display_name || asset.name
})
function handleSelect() {

View File

@@ -5,7 +5,7 @@
:aria-label="
asset
? $t('assetBrowser.ariaLabel.assetCard', {
name: asset.name,
name: asset.display_name || asset.name,
type: fileKind
})
: $t('assetBrowser.ariaLabel.loadingAsset')
@@ -225,7 +225,7 @@ const canInspect = computed(() => isPreviewableMediaType(fileKind.value))
// Get filename without extension
const fileName = computed(() => {
return getFilenameDetails(asset?.name || '').filename
return getFilenameDetails(asset?.display_name || asset?.name || '').filename
})
// Adapt AssetItem to legacy AssetMeta format for existing components
@@ -234,8 +234,9 @@ const adaptedAsset = computed(() => {
return {
id: asset.id,
name: asset.name,
display_name: asset.display_name,
kind: fileKind.value,
src: asset.preview_url || '',
src: asset.thumbnail_url || asset.preview_url || '',
size: asset.size,
tags: asset.tags || [],
created_at: asset.created_at,

View File

@@ -6,7 +6,7 @@
<img
v-if="!error"
:src="asset.src"
:alt="asset.name"
:alt="asset.display_name || asset.name"
class="size-full object-contain transition-transform duration-300 group-hover:scale-105 group-data-[selected=true]:scale-105"
/>
<div
@@ -34,7 +34,7 @@ const emit = defineEmits<{
const { state, error, isReady } = useImage({
src: asset.src ?? '',
alt: asset.name
alt: asset.display_name || asset.name
})
whenever(

View File

@@ -39,12 +39,14 @@ export function mapTaskOutputToAssetItem(
return {
id: taskItem.jobId,
name: output.filename,
display_name: output.display_name,
size: 0,
created_at: taskItem.executionStartTimestamp
? new Date(taskItem.executionStartTimestamp).toISOString()
: new Date().toISOString(),
tags: ['output'],
preview_url: output.previewUrl,
thumbnail_url: output.previewUrl,
preview_url: output.url,
user_metadata: metadata
}
}
@@ -62,6 +64,7 @@ export function mapInputFileToAssetItem(
directory: 'input' | 'output' = 'input'
): AssetItem {
const params = new URLSearchParams({ filename, type: directory })
const preview_url = api.apiURL(`/view?${params}`)
appendCloudResParam(params, filename)
return {
@@ -70,6 +73,7 @@ export function mapInputFileToAssetItem(
size: 0,
created_at: new Date().toISOString(),
tags: [directory],
preview_url: api.apiURL(`/view?${params}`)
thumbnail_url: api.apiURL(`/view?${params}`),
preview_url
}
}

View File

@@ -68,7 +68,7 @@ export function useMediaAssetActions() {
if (!targetAsset) return
try {
const filename = targetAsset.name
const filename = targetAsset.display_name || targetAsset.name
// Prefer preview_url (already includes subfolder) with getAssetUrl as fallback
const downloadUrl = targetAsset.preview_url || getAssetUrl(targetAsset)
@@ -109,7 +109,7 @@ export function useMediaAssetActions() {
try {
assets.forEach((asset) => {
const filename = asset.name
const filename = asset.display_name || asset.name
const downloadUrl = asset.preview_url || getAssetUrl(asset)
downloadFile(downloadUrl, filename)
})

View File

@@ -9,7 +9,9 @@ const zAsset = z.object({
mime_type: z.string().nullish(),
tags: z.array(z.string()).optional().default([]),
preview_id: z.string().nullable().optional(),
display_name: z.string().optional(),
preview_url: z.string().optional(),
thumbnail_url: z.string().optional(),
created_at: z.string().optional(),
updated_at: z.string().optional(),
is_immutable: z.boolean().optional(),

View File

@@ -20,6 +20,7 @@ type OutputOverrides = Partial<{
subfolder: string
nodeId: string
url: string
display_name: string
}>
function createOutput(overrides: OutputOverrides = {}): ResultItemImpl {
@@ -32,7 +33,8 @@ function createOutput(overrides: OutputOverrides = {}): ResultItemImpl {
}
return {
...merged,
previewUrl: merged.url
previewUrl: merged.url,
display_name: merged.display_name
} as ResultItemImpl
}
@@ -125,6 +127,48 @@ describe('resolveOutputAssetItems', () => {
])
})
it('propagates display_name from output to asset item', async () => {
const output = createOutput({
filename: 'abc123hash.png',
nodeId: '1',
url: 'https://example.com/abc123hash.png',
display_name: 'ComfyUI_00001_.png'
})
const metadata: OutputAssetMetadata = {
jobId: 'job-dn',
nodeId: '1',
subfolder: 'sub',
outputCount: 1,
allOutputs: [output]
}
const results = await resolveOutputAssetItems(metadata)
expect(results).toHaveLength(1)
expect(results[0].name).toBe('abc123hash.png')
expect(results[0].display_name).toBe('ComfyUI_00001_.png')
})
it('omits display_name when not present in output', async () => {
const output = createOutput({
filename: 'file.png',
nodeId: '1',
url: 'https://example.com/file.png'
})
const metadata: OutputAssetMetadata = {
jobId: 'job-nodn',
nodeId: '1',
subfolder: 'sub',
outputCount: 1,
allOutputs: [output]
}
const results = await resolveOutputAssetItems(metadata)
expect(results).toHaveLength(1)
expect(results[0].display_name).toBeUndefined()
})
it('keeps root outputs with empty subfolders', async () => {
const output = createOutput({
filename: 'root.png',

View File

@@ -69,10 +69,12 @@ function mapOutputsToAssetItems({
items.push({
id: `${jobId}-${outputKey}`,
name: output.filename,
display_name: output.display_name,
size: 0,
created_at: createdAtValue,
tags: ['output'],
preview_url: output.previewUrl,
thumbnail_url: output.previewUrl,
preview_url: output.url,
user_metadata: {
jobId,
nodeId: output.nodeId,

View File

@@ -23,7 +23,8 @@ const zPreviewOutput = z.object({
subfolder: z.string(),
type: resultItemType,
nodeId: z.string(),
mediaType: z.string()
mediaType: z.string(),
display_name: z.string().optional()
})
/**

View File

@@ -922,27 +922,6 @@ export const CORE_SETTINGS: SettingParams[] = [
defaultValue: {} as ColorPalettes,
versionModified: '1.6.7'
},
{
id: 'Comfy.NodeColor.Favorites',
name: 'Favorite node colors',
type: 'hidden',
defaultValue: [] as string[],
versionAdded: '1.25.0'
},
{
id: 'Comfy.NodeColor.Recents',
name: 'Recent node colors',
type: 'hidden',
defaultValue: [] as string[],
versionAdded: '1.25.0'
},
{
id: 'Comfy.NodeColor.DarkerHeader',
name: 'Use a darker node header for custom colors',
type: 'hidden',
defaultValue: true,
versionAdded: '1.25.0'
},
{
id: 'Comfy.WidgetControlMode',
category: ['Comfy', 'Node Widget', 'WidgetControlMode'],

View File

@@ -46,7 +46,10 @@
onThumbnailError($event.name, $event.previewUrl)
"
/>
<span class="truncate text-xs text-base-foreground">
<span
v-tooltip="buildTooltipConfig(item.name)"
class="truncate text-xs text-base-foreground"
>
{{ item.name }}
</span>
<span
@@ -74,6 +77,7 @@ import ShareAssetThumbnail from '@/platform/workflow/sharing/components/ShareAss
import { useAssetSections } from '@/platform/workflow/sharing/composables/useAssetSections'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
const { items } = defineProps<{
items: AssetInfo[]

View File

@@ -10,6 +10,7 @@ import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import { useBillingContext } from '@/composables/billing/useBillingContext'
@@ -29,7 +30,7 @@ import { useQueueSettingsStore } from '@/stores/queueStore'
import { cn } from '@/utils/tailwindUtil'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
import { resolveNode } from '@/utils/litegraphUtil'
import { resolveNodeWidget } from '@/utils/litegraphUtil'
const { t } = useI18n()
const commandStore = useCommandStore()
const executionErrorStore = useExecutionErrorStore()
@@ -63,21 +64,41 @@ useEventListener(
)
const mappedSelections = computed(() => {
let unprocessedInputs = [...appModeStore.selectedInputs]
//FIXME strict typing here
let unprocessedInputs = appModeStore.selectedInputs.flatMap(
([nodeId, widgetName]) => {
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
return widget ? ([[node, widget]] as const) : []
}
)
const processedInputs: ReturnType<typeof nodeToNodeData>[] = []
while (unprocessedInputs.length) {
const nodeId = unprocessedInputs[0][0]
const inputGroup = takeWhile(
unprocessedInputs,
([id]) => id === nodeId
).map(([, widgetName]) => widgetName)
const [node] = unprocessedInputs[0]
const inputGroup = takeWhile(unprocessedInputs, ([n]) => n === node).map(
([, widget]) => widget
)
unprocessedInputs = unprocessedInputs.slice(inputGroup.length)
const node = resolveNode(nodeId)
//FIXME: hide widget if owning node bypassed
if (node?.mode !== LGraphEventMode.ALWAYS) continue
const nodeData = nodeToNodeData(node)
remove(nodeData.widgets ?? [], (w) => !inputGroup.includes(w.name))
remove(nodeData.widgets ?? [], (vueWidget) => {
if (vueWidget.slotMetadata?.linked) return true
if (!node.isSubgraphNode())
return !inputGroup.some((w) => w.name === vueWidget.name)
const storeNodeId = vueWidget.storeNodeId?.split(':')?.[1] ?? ''
return !inputGroup.some(
(subWidget) =>
isPromotedWidgetView(subWidget) &&
subWidget.sourceNodeId == storeNodeId &&
subWidget.sourceWidgetName === vueWidget.storeName
)
})
for (const widget of nodeData.widgets ?? []) {
widget.slotMetadata = undefined
widget.nodeId = String(node.id)
}
processedInputs.push(nodeData)
}
return processedInputs
@@ -107,8 +128,6 @@ function getDropIndicator(node: LGraphNode) {
function nodeToNodeData(node: LGraphNode) {
const dropIndicator = getDropIndicator(node)
const nodeData = extractVueNodeData(node)
remove(nodeData.widgets ?? [], (w) => w.slotMetadata?.linked ?? false)
for (const widget of nodeData.widgets ?? []) widget.slotMetadata = undefined
return {
...nodeData,

View File

@@ -15,7 +15,9 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import LinearControls from '@/renderer/extensions/linearMode/LinearControls.vue'
import LinearPreview from '@/renderer/extensions/linearMode/LinearPreview.vue'
import MobileError from '@/renderer/extensions/linearMode/MobileError.vue'
import { useColorPaletteService } from '@/services/colorPaletteService'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useQueueStore } from '@/stores/queueStore'
import { useMenuItemStore } from '@/stores/menuItemStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
@@ -31,6 +33,7 @@ const canvasStore = useCanvasStore()
const colorPaletteService = useColorPaletteService()
const colorPaletteStore = useColorPaletteStore()
const { isLoggedIn } = useCurrentUser()
const executionErrorStore = useExecutionErrorStore()
const { t } = useI18n()
const { commandIdToMenuItem } = useMenuItemStore()
const queueStore = useQueueStore()
@@ -40,7 +43,7 @@ const { toggle: toggleFullscreen } = useFullscreen(undefined, {
autoExit: true
})
const activeIndex = ref(2)
const activeIndex = ref(1)
const sliderPaneRef = useTemplateRef('sliderPaneRef')
const sliderWidth = computed(() => sliderPaneRef.value?.offsetWidth)
@@ -192,7 +195,11 @@ const menuEntries = computed<MenuItem[]>(() => [
<div
class="absolute top-0 left-[100vw] flex h-full w-screen flex-col bg-base-background"
>
<LinearPreview mobile />
<MobileError
v-if="executionErrorStore.isErrorOverlayOpen"
@navigate-controls="activeIndex = 0"
/>
<LinearPreview v-else mobile @navigate-controls="activeIndex = 0" />
</div>
<AssetsSidebarTab
class="absolute top-0 left-[200vw] h-full w-screen bg-base-background"
@@ -213,7 +220,11 @@ const menuEntries = computed<MenuItem[]>(() => [
<div class="relative size-4">
<i :class="cn('size-4', icon)" />
<div
v-if="
v-if="index === 1 && executionErrorStore.isErrorOverlayOpen"
class="absolute -top-1 -right-1 size-2 rounded-full bg-error"
/>
<div
v-else-if="
index === 1 &&
(queueStore.runningTasks.length > 0 ||
queueStore.pendingTasks.length > 0)

View File

@@ -0,0 +1,174 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Dialogue from '@/components/common/Dialogue.vue'
import { useErrorGroups } from '@/components/rightSidePanel/errors/useErrorGroups'
import Button from '@/components/ui/button/Button.vue'
import { useAppMode } from '@/composables/useAppMode'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useExternalLink } from '@/composables/useExternalLink'
import { buildSupportUrl } from '@/platform/support/config'
import { useAppModeStore } from '@/stores/appModeStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
defineEmits<{ navigateControls: [] }>()
const { t } = useI18n()
const appModeStore = useAppModeStore()
const { setMode } = useAppMode()
const executionErrorStore = useExecutionErrorStore()
const { buildDocsUrl, staticUrls } = useExternalLink()
const { allErrorGroups } = useErrorGroups('', t)
const { copyToClipboard } = useCopyToClipboard()
const guideUrl = buildDocsUrl('troubleshooting/overview', {
includeLocale: true
})
const supportUrl = buildSupportUrl()
const inputNodeIds = computed(() => {
const ids = new Set()
for (const [id] of appModeStore.selectedInputs) ids.add(String(id))
return ids
})
const accessibleNodeErrors = computed(() =>
Object.keys(executionErrorStore.lastNodeErrors ?? {}).filter((k) =>
inputNodeIds.value.has(k)
)
)
const accessibleErrors = computed(() =>
accessibleNodeErrors.value.flatMap((k) =>
executionErrorStore.lastNodeErrors![k].errors.flatMap((error) => {
const { extra_info } = error
if (!extra_info) return []
const selectedInput = appModeStore.selectedInputs.find(
([id, name]) => id == k && extra_info.input_name === name
)
if (!selectedInput) return []
return [`${selectedInput[1]}: ${error.message}`]
})
)
)
const allErrors = computed(() =>
allErrorGroups.value.flatMap((group) => {
if (group.type !== 'execution') return [group.title]
return group.cards.flatMap((c) =>
c.errors.map((e) =>
e.details
? `${c.title} (${e.details}): ${e.message}`
: `${c.title}: ${e.message}`
)
)
})
)
function copy(obj: unknown) {
copyToClipboard(JSON.stringify(obj))
}
</script>
<template>
<section class="flex h-full flex-col items-center justify-center gap-2 px-4">
<i class="icon-[lucide--circle-alert] size-6 bg-error" />
{{ t('linearMode.error.header') }}
<div class="p-1 text-muted-foreground">
<i18n-t
v-if="accessibleErrors.length"
keypath="linearMode.error.mobileFixable"
>
<Button @click="$emit('navigateControls')">
{{ t('linearMode.mobileControls') }}
</Button>
</i18n-t>
<div v-else class="text-center">
<p v-text="t('linearMode.error.requiresGraph')" />
<p v-text="t('linearMode.error.promptVisitGraph')" />
<p class="*:text-muted-foreground">
<i18n-t keypath="linearMode.error.getHelp">
<a
:href="guideUrl"
target="_blank"
v-text="t('linearMode.error.guide')"
/>
<a
:href="staticUrls.githubIssues"
target="_blank"
v-text="t('linearMode.error.github')"
/>
<a
:href="supportUrl"
target="_blank"
v-text="t('linearMode.error.support')"
/>
</i18n-t>
</p>
<Dialogue :title="t('linearMode.error.log')">
<template #button>
<Button variant="textonly">
{{ t('linearMode.error.promptShow') }}
<i class="icon-[lucide--chevron-right] size-5" />
</Button>
</template>
<template #default="{ close }">
<article class="flex flex-col gap-2 p-4">
<section class="flex max-h-[60vh] flex-col gap-2 overflow-y-auto">
<div
v-for="error in allErrors"
:key="error"
class="w-full rounded-lg bg-secondary-background p-2 text-muted-foreground"
v-text="error"
/>
</section>
<div class="flex items-center justify-end gap-4">
<Button variant="muted-textonly" size="lg" @click="close">
{{ t('g.close') }}
</Button>
<Button size="lg" @click="copy(allErrors)">
{{ t('importFailed.copyError') }}
<i class="icon-[lucide--copy]" />
</Button>
</div>
</article>
</template>
</Dialogue>
</div>
</div>
<div
v-if="accessibleErrors.length"
class="my-8 w-full rounded-lg bg-secondary-background text-muted-foreground"
>
<ul>
<li
v-for="error in accessibleErrors"
:key="error"
class="before:content"
v-text="error"
/>
</ul>
</div>
<div class="flex gap-2">
<Button
variant="textonly"
size="lg"
@click="executionErrorStore.dismissErrorOverlay()"
>
{{ t('g.dismiss') }}
</Button>
<Button variant="textonly" size="lg" @click="setMode('graph')">
{{ t('linearMode.viewGraph') }}
</Button>
<Button
v-if="accessibleErrors.length"
size="lg"
@click="copy(accessibleErrors)"
>
{{ t('importFailed.copyError') }}
<i class="icon-[lucide--copy]" />
</Button>
</div>
</section>
</template>

View File

@@ -0,0 +1,115 @@
import { flushPromises, mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import { createI18n } from 'vue-i18n'
import { describe, expect, it, vi } from 'vitest'
import { defineComponent, h } from 'vue'
import FormDropdown from './FormDropdown.vue'
import type { FormDropdownItem } from './types'
function createItem(id: string, name: string): FormDropdownItem {
return { id, preview_url: '', name, label: name }
}
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({
addAlert: vi.fn()
})
}))
const MockFormDropdownMenu = defineComponent({
name: 'FormDropdownMenu',
props: {
items: { type: Array as () => FormDropdownItem[], default: () => [] },
isSelected: { type: Function, default: undefined },
filterOptions: { type: Array, default: () => [] },
sortOptions: { type: Array, default: () => [] },
maxSelectable: { type: Number, default: 1 },
disabled: { type: Boolean, default: false },
showOwnershipFilter: { type: Boolean, default: false },
ownershipOptions: { type: Array, default: () => [] },
showBaseModelFilter: { type: Boolean, default: false },
baseModelOptions: { type: Array, default: () => [] }
},
setup() {
return () => h('div', { class: 'mock-menu' })
}
})
function mountDropdown(items: FormDropdownItem[]) {
return mount(FormDropdown, {
props: { items },
global: {
plugins: [PrimeVue, i18n],
stubs: {
FormDropdownInput: true,
Popover: { template: '<div><slot /></div>' },
FormDropdownMenu: MockFormDropdownMenu
}
}
})
}
function getMenuItems(
wrapper: ReturnType<typeof mountDropdown>
): FormDropdownItem[] {
return wrapper
.findComponent(MockFormDropdownMenu)
.props('items') as FormDropdownItem[]
}
describe('FormDropdown', () => {
describe('filteredItems updates when items prop changes', () => {
it('updates displayed items when items prop changes', async () => {
const wrapper = mountDropdown([
createItem('input-0', 'video1.mp4'),
createItem('input-1', 'video2.mp4')
])
await flushPromises()
expect(getMenuItems(wrapper)).toHaveLength(2)
await wrapper.setProps({
items: [
createItem('output-0', 'rendered1.mp4'),
createItem('output-1', 'rendered2.mp4')
]
})
await flushPromises()
const menuItems = getMenuItems(wrapper)
expect(menuItems).toHaveLength(2)
expect(menuItems[0].name).toBe('rendered1.mp4')
})
it('updates when items change but IDs stay the same', async () => {
const wrapper = mountDropdown([createItem('1', 'alpha')])
await flushPromises()
await wrapper.setProps({ items: [createItem('1', 'beta')] })
await flushPromises()
expect(getMenuItems(wrapper)[0].name).toBe('beta')
})
it('updates when switching between empty and non-empty items', async () => {
const wrapper = mountDropdown([])
await flushPromises()
expect(getMenuItems(wrapper)).toHaveLength(0)
await wrapper.setProps({ items: [createItem('1', 'video.mp4')] })
await flushPromises()
expect(getMenuItems(wrapper)).toHaveLength(1)
expect(getMenuItems(wrapper)[0].name).toBe('video.mp4')
await wrapper.setProps({ items: [] })
await flushPromises()
expect(getMenuItems(wrapper)).toHaveLength(0)
})
})
})

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { computedAsync, refDebounced } from '@vueuse/core'
import Popover from 'primevue/popover'
import { computed, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -101,9 +102,16 @@ const maxSelectable = computed(() => {
return 1
})
const itemsKey = computed(() => items.map((item) => item.id).join('|'))
const debouncedSearchQuery = refDebounced(searchQuery, 250, { maxWait: 1000 })
const filteredItems = ref<FormDropdownItem[]>([])
const filteredItems = computedAsync(async (onCancel) => {
let cleanupFn: (() => void) | undefined
onCancel(() => cleanupFn?.())
const result = await searcher(debouncedSearchQuery.value, items, (cb) => {
cleanupFn = cb
})
return result
}, [])
const defaultSorter = computed<SortOption['sorter']>(() => {
const sorter = sortOptions.find((option) => option.id === 'default')?.sorter
@@ -171,21 +179,6 @@ function handleSelection(item: FormDropdownItem, index: number) {
closeDropdown()
}
}
async function customSearcher(
query: string,
onCleanup: (cleanupFn: () => void) => void
) {
let isCleanup = false
let cleanupFn: undefined | (() => void)
onCleanup(() => {
isCleanup = true
cleanupFn?.()
})
await searcher(query, items, (cb) => (cleanupFn = cb)).then((results) => {
if (!isCleanup) filteredItems.value = results
})
}
</script>
<template>
@@ -233,11 +226,9 @@ async function customSearcher(
:show-base-model-filter
:base-model-options
:disabled
:searcher="customSearcher"
:items="sortedItems"
:is-selected="internalIsSelected"
:max-selectable
:update-key="itemsKey"
@close="closeDropdown"
@item-click="handleSelection"
/>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { CSSProperties, MaybeRefOrGetter } from 'vue'
import type { CSSProperties } from 'vue'
import { computed } from 'vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
@@ -20,11 +20,6 @@ interface Props {
isSelected: (item: FormDropdownItem, index: number) => boolean
filterOptions: FilterOption[]
sortOptions: SortOption[]
searcher?: (
query: string,
onCleanup: (cleanupFn: () => void) => void
) => Promise<void>
updateKey?: MaybeRefOrGetter<unknown>
showOwnershipFilter?: boolean
ownershipOptions?: OwnershipFilterOption[]
showBaseModelFilter?: boolean
@@ -36,8 +31,6 @@ const {
isSelected,
filterOptions,
sortOptions,
searcher,
updateKey,
showOwnershipFilter,
ownershipOptions,
showBaseModelFilter,
@@ -118,8 +111,6 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
v-model:ownership-selected="ownershipSelected"
v-model:base-model-selected="baseModelSelected"
:sort-options
:searcher
:update-key
:show-ownership-filter
:ownership-options
:show-base-model-filter

View File

@@ -1,6 +1,4 @@
<script setup lang="ts">
import type { MaybeRefOrGetter } from 'vue'
import Popover from 'primevue/popover'
import { ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -19,12 +17,7 @@ import type { LayoutMode, SortOption } from './types'
const { t } = useI18n()
defineProps<{
searcher?: (
query: string,
onCleanup: (cleanupFn: () => void) => void
) => Promise<void>
sortOptions: SortOption[]
updateKey?: MaybeRefOrGetter<unknown>
showOwnershipFilter?: boolean
ownershipOptions?: OwnershipFilterOption[]
showBaseModelFilter?: boolean
@@ -108,8 +101,6 @@ function toggleBaseModelSelection(item: FilterOption) {
<div class="text-secondary flex gap-2 px-4">
<FormSearchInput
v-model="searchQuery"
:searcher
:update-key
:class="
cn(
actionButtonStyle,

View File

@@ -109,8 +109,15 @@ function createMockNode(comfyClass = 'TestNode'): LGraphNode {
// Spy on the addWidget method
vi.spyOn(node, 'addWidget').mockImplementation(
(type, name, value, callback) => {
const widget = createMockWidget({ type, name, value })
(type, name, value, callback, options = {}) => {
const normalizedOptions =
typeof options === 'string' ? { property: options } : options
const widget = createMockWidget({
type,
name,
value,
options: normalizedOptions
})
// Store the callback function on the widget for testing
if (typeof callback === 'function') {
widget.callback = callback
@@ -320,7 +327,7 @@ describe('useComboWidget', () => {
HASH_FILENAME,
expect.any(Function),
expect.objectContaining({
values: [], // Empty initially, populated dynamically by Proxy
values: [], // Empty initially, populated via dynamic getter
getOptionLabel: expect.any(Function)
})
)
@@ -328,6 +335,23 @@ describe('useComboWidget', () => {
}
)
it('should keep the original options object for cloud input mappings', () => {
mockDistributionState.isCloud = true
const constructor = useComboWidget()
const mockNode = createMockNode('LoadImage')
const inputSpec = createMockInputSpec({
name: 'image',
options: [HASH_FILENAME]
})
const widget = constructor(mockNode, inputSpec)
const addWidgetCall = vi.mocked(mockNode.addWidget).mock.calls[0]
const options = addWidgetCall[4]
expect(widget.options).toBe(options)
})
it("should format option labels using store's getInputName function", () => {
mockDistributionState.isCloud = true
mockGetInputName.mockReturnValue('Beautiful Sunset.png')

View File

@@ -44,6 +44,29 @@ const NODE_PLACEHOLDER_MAP: Record<string, string> = {
LoadAudio: 'widgets.uploadSelect.placeholderAudio'
}
const bindDynamicValuesOption = (
widget: IBaseWidget,
getValues: () => unknown
) => {
const options = widget.options
let fallbackValues = Array.isArray(options.values)
? options.values
: ([] as unknown[])
Object.defineProperty(options, 'values', {
configurable: true,
enumerable: true,
get: () => {
const values = getValues()
if (values === undefined || values === null) return fallbackValues
return values
},
set: (values: unknown[]) => {
fallbackValues = Array.isArray(values) ? values : fallbackValues
}
})
}
const addMultiSelectWidget = (
node: LGraphNode,
inputSpec: ComboInputSpec
@@ -133,22 +156,16 @@ const createInputMappingWidget = (
})
}
const origOptions = widget.options
widget.options = new Proxy(origOptions, {
get(target, prop) {
if (prop !== 'values') {
return target[prop as keyof typeof target]
}
return assetsStore.inputAssets
.filter(
(asset) =>
getMediaTypeFromFilename(asset.name) ===
NODE_MEDIA_TYPE_MAP[node.comfyClass ?? '']
)
.map((asset) => asset.asset_hash)
.filter((hash): hash is string => !!hash)
}
})
bindDynamicValuesOption(widget, () =>
assetsStore.inputAssets
.filter(
(asset) =>
getMediaTypeFromFilename(asset.name) ===
NODE_MEDIA_TYPE_MAP[node.comfyClass ?? '']
)
.map((asset) => asset.asset_hash)
.filter((hash): hash is string => !!hash)
)
if (inputSpec.control_after_generate) {
if (!isComboWidget(widget)) {
@@ -210,15 +227,7 @@ const addComboWidget = (
})
if (inputSpec.remote.refresh_button) remoteWidget.addRefreshButton()
const origOptions = widget.options
widget.options = new Proxy(origOptions, {
get(target, prop) {
// Assertion: Proxy handler passthrough
return prop !== 'values'
? target[prop as keyof typeof target]
: remoteWidget.getValue()
}
})
bindDynamicValuesOption(widget, () => remoteWidget.getValue())
}
if (inputSpec.control_after_generate) {

View File

@@ -19,7 +19,8 @@ export type CustomNodesI18n = z.infer<typeof zCustomNodesI18n>
const zResultItem = z.object({
filename: z.string().optional(),
subfolder: z.string().optional(),
type: resultItemType.optional()
type: resultItemType.optional(),
display_name: z.string().optional()
})
export type ResultItem = z.infer<typeof zResultItem>
const zOutputs = z
@@ -293,9 +294,6 @@ export type PreviewMethod = z.infer<typeof zPreviewMethod>
const zSettings = z.object({
'Comfy.ColorPalette': z.string(),
'Comfy.CustomColorPalettes': colorPalettesSchema,
'Comfy.NodeColor.Favorites': z.array(z.string()),
'Comfy.NodeColor.Recents': z.array(z.string()),
'Comfy.NodeColor.DarkerHeader': z.boolean(),
'Comfy.Canvas.BackgroundImage': z.string().optional(),
'Comfy.ConfirmClear': z.boolean(),
'Comfy.DevMode': z.boolean(),

View File

@@ -208,6 +208,11 @@ export class ComfyApp {
return this.rootGraphInternal!
}
/** Whether the root graph has been initialized. Safe to check without triggering error logs. */
get isGraphReady(): boolean {
return !!this.rootGraphInternal
}
canvas!: LGraphCanvas
dragOverNode: LGraphNode | null = null
readonly canvasElRef = shallowRef<HTMLCanvasElement>()

View File

@@ -255,6 +255,35 @@ describe('jobOutputCache', () => {
expect(video?.mediaType).toBe('video')
})
it('preserves display_name from output items', async () => {
const { getPreviewableOutputsFromJobDetail } =
await import('@/services/jobOutputCache')
const jobDetail: JobDetail = {
id: 'job-display-name',
status: 'completed',
create_time: Date.now(),
priority: 0,
outputs: {
'node-1': {
images: [
{
filename: 'abc123hash.png',
subfolder: '',
type: 'output',
display_name: 'ComfyUI_00001_.png'
}
]
}
}
}
const result = getPreviewableOutputsFromJobDetail(jobDetail)
expect(result).toHaveLength(1)
expect(result[0].filename).toBe('abc123hash.png')
expect(result[0].display_name).toBe('ComfyUI_00001_.png')
})
it('filters non-previewable outputs and non-object items', async () => {
const { getPreviewableOutputsFromJobDetail } =
await import('@/services/jobOutputCache')

View File

@@ -238,7 +238,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
/** Graph node IDs (as strings) that have errors in the current graph scope. */
const activeGraphErrorNodeIds = computed<Set<string>>(() => {
const ids = new Set<string>()
if (!app.rootGraph) return ids
if (!app.isGraphReady) return ids
// Fall back to rootGraph when currentGraph hasn't been initialized yet
const activeGraph = canvasStore.currentGraph ?? app.rootGraph
@@ -287,7 +287,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
const activeMissingNodeGraphIds = computed<Set<string>>(() => {
const ids = new Set<string>()
if (!app.rootGraph) return ids
if (!app.isGraphReady) return ids
const activeGraph = canvasStore.currentGraph ?? app.rootGraph
@@ -357,7 +357,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
/** True if the node has errors inside it at any nesting depth. */
function isContainerWithInternalError(node: LGraphNode): boolean {
if (!app.rootGraph) return false
if (!app.isGraphReady) return false
const execId = getExecutionIdByNode(app.rootGraph, node)
if (!execId) return false
return errorAncestorExecutionIds.value.has(execId)
@@ -365,15 +365,15 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
/** True if the node has a missing node inside it at any nesting depth. */
function isContainerWithMissingNode(node: LGraphNode): boolean {
if (!app.rootGraph) return false
if (!app.isGraphReady) return false
const execId = getExecutionIdByNode(app.rootGraph, node)
if (!execId) return false
return missingAncestorExecutionIds.value.has(execId)
}
watch(lastNodeErrors, () => {
if (!app.isGraphReady) return
const rootGraph = app.rootGraph
if (!rootGraph) return
clearAllNodeErrorFlags(rootGraph)

View File

@@ -10,7 +10,6 @@ import type {
ResultItem,
ResultItemType
} from '@/schemas/apiSchema'
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { clone } from '@/scripts/utils'
@@ -120,11 +119,9 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
const rand = app.getRandParam()
const previewParam = getPreviewParam(node, outputs)
const isImage = isImageOutputs(node, outputs)
return outputs.images.map((image) => {
const params = new URLSearchParams(image)
if (isImage) appendCloudResParam(params, image.filename)
return api.apiURL(`/view?${params}${previewParam}${rand}`)
})
}

View File

@@ -37,6 +37,7 @@ interface ResultItemInit extends ResultItem {
mediaType: string
format?: string
frame_rate?: number
display_name?: string
}
export class ResultItemImpl {
@@ -48,6 +49,8 @@ export class ResultItemImpl {
// 'audio' | 'images' | ...
mediaType: string
display_name?: string
// VHS output specific fields
format?: string
frame_rate?: number
@@ -60,6 +63,8 @@ export class ResultItemImpl {
this.nodeId = obj.nodeId
this.mediaType = obj.mediaType
this.display_name = obj.display_name
this.format = obj.format
this.frame_rate = obj.frame_rate
}

View File

@@ -1,5 +1,6 @@
import _ from 'es-toolkit/compat'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { ColorOption, LGraph } from '@/lib/litegraph/src/litegraph'
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
import {
@@ -319,6 +320,31 @@ export function resolveNode(
}
return undefined
}
export function resolveNodeWidget(
nodeId: NodeId,
widgetName?: string,
graph: LGraph = app.rootGraph
): [LGraphNode, IBaseWidget] | [LGraphNode] | [] {
const node = graph.getNodeById(nodeId)
if (!widgetName) return node ? [node] : []
if (node) {
const widget = node.widgets?.find((w) => w.name === widgetName)
return widget ? [node, widget] : []
}
for (const node of graph.nodes) {
if (!node.isSubgraphNode()) continue
const widget = node.widgets?.find(
(w) =>
isPromotedWidgetView(w) &&
w.sourceWidgetName === widgetName &&
w.sourceNodeId === nodeId
)
if (widget) return [node, widget]
}
return []
}
export function isLoad3dNode(node: LGraphNode) {
return (

View File

@@ -1,88 +0,0 @@
import { describe, expect, it } from 'vitest'
import { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
applyCustomColorToItem,
getSharedAppliedColor,
getSharedCustomColor,
toggleFavoriteNodeColor,
upsertRecentNodeColor
} from './nodeColorCustomization'
describe('nodeColorCustomization', () => {
it('applies a custom color to nodes using a derived header color', () => {
const node = Object.assign(Object.create(LGraphNode.prototype), {
color: undefined,
bgcolor: undefined,
getColorOption: () => null
}) as LGraphNode
const applied = applyCustomColorToItem(node, '#abcdef', {
darkerHeader: true
})
expect(applied).toBe('#abcdef')
expect(node.bgcolor).toBe('#abcdef')
expect(node.color).not.toBe('#abcdef')
})
it('applies a custom color to groups without deriving a header color', () => {
const group = Object.assign(Object.create(LGraphGroup.prototype), {
color: undefined,
getColorOption: () => null
}) as LGraphGroup
const applied = applyCustomColorToItem(group, '#123456', {
darkerHeader: true
})
expect(applied).toBe('#123456')
expect(group.color).toBe('#123456')
})
it('returns a shared applied color for matching custom node colors', () => {
const nodeA = Object.assign(Object.create(LGraphNode.prototype), {
bgcolor: '#abcdef',
getColorOption: () => null
}) as LGraphNode
const nodeB = Object.assign(Object.create(LGraphNode.prototype), {
bgcolor: '#abcdef',
getColorOption: () => null
}) as LGraphNode
expect(getSharedAppliedColor([nodeA, nodeB])).toBe('#abcdef')
expect(getSharedCustomColor([nodeA, nodeB])).toBe('#abcdef')
})
it('returns null when selected items do not share the same color', () => {
const nodeA = Object.assign(Object.create(LGraphNode.prototype), {
bgcolor: '#abcdef',
getColorOption: () => null
}) as LGraphNode
const nodeB = Object.assign(Object.create(LGraphNode.prototype), {
bgcolor: '#123456',
getColorOption: () => null
}) as LGraphNode
expect(getSharedAppliedColor([nodeA, nodeB])).toBeNull()
expect(getSharedCustomColor([nodeA, nodeB])).toBeNull()
})
it('keeps recent colors unique and most-recent-first', () => {
const updated = upsertRecentNodeColor(
['#111111', '#222222', '#333333'],
'#222222'
)
expect(updated).toEqual(['#222222', '#111111', '#333333'])
})
it('toggles favorite colors on and off', () => {
const added = toggleFavoriteNodeColor(['#111111'], '#222222')
const removed = toggleFavoriteNodeColor(added, '#111111')
expect(added).toEqual(['#111111', '#222222'])
expect(removed).toEqual(['#222222'])
})
})

View File

@@ -1,114 +0,0 @@
import type { ColorOption } from '@/lib/litegraph/src/interfaces'
import { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { isColorable } from '@/lib/litegraph/src/utils/type'
import {
deriveCustomNodeHeaderColor,
getDefaultCustomNodeColor as getDefaultCustomNodeColorValue,
normalizeNodeColor
} from '@/utils/nodeColorPersistence'
function isColorableNodeOrGroup(
item: unknown
): item is (LGraphNode | LGraphGroup) & {
getColorOption(): ColorOption | null
} {
return (
isColorable(item) &&
(item instanceof LGraphNode || item instanceof LGraphGroup)
)
}
export function getDefaultCustomNodeColor(): string {
return getDefaultCustomNodeColorValue()
}
export function applyCustomColorToItem(
item: LGraphNode | LGraphGroup,
color: string,
options: { darkerHeader: boolean }
): string {
const normalized = normalizeNodeColor(color)
if (item instanceof LGraphGroup) {
item.color = normalized
return normalized
}
item.bgcolor = normalized
item.color = deriveCustomNodeHeaderColor(normalized, options.darkerHeader)
return normalized
}
export function applyCustomColorToItems(
items: Iterable<unknown>,
color: string,
options: { darkerHeader: boolean }
): string {
const normalized = normalizeNodeColor(color)
for (const item of items) {
if (item instanceof LGraphNode || item instanceof LGraphGroup) {
applyCustomColorToItem(item, normalized, options)
}
}
return normalized
}
function getAppliedColorFromItem(
item: (LGraphNode | LGraphGroup) & {
getColorOption(): ColorOption | null
}
): string | null {
const presetColor = item.getColorOption()
if (presetColor) {
return item instanceof LGraphGroup ? presetColor.groupcolor : presetColor.bgcolor
}
return item instanceof LGraphGroup ? item.color ?? null : item.bgcolor ?? null
}
function getCustomColorFromItem(
item: (LGraphNode | LGraphGroup) & {
getColorOption(): ColorOption | null
}
): string | null {
if (item.getColorOption()) return null
return item instanceof LGraphGroup ? item.color ?? null : item.bgcolor ?? null
}
function getSharedColor(
items: unknown[],
selector: (
item: (LGraphNode | LGraphGroup) & { getColorOption(): ColorOption | null }
) => string | null
): string | null {
const validItems = items.filter(isColorableNodeOrGroup)
if (validItems.length === 0) return null
const firstColor = selector(validItems[0])
return validItems.every((item) => selector(item) === firstColor) ? firstColor : null
}
export function getSharedAppliedColor(items: unknown[]): string | null {
return getSharedColor(items, getAppliedColorFromItem)
}
export function getSharedCustomColor(items: unknown[]): string | null {
return getSharedColor(items, getCustomColorFromItem)
}
export {
NODE_COLOR_DARKER_HEADER_SETTING_ID,
NODE_COLOR_FAVORITES_SETTING_ID,
NODE_COLOR_RECENTS_SETTING_ID,
NODE_COLOR_SWATCH_LIMIT,
deriveCustomNodeHeaderColor,
normalizeNodeColor,
pickHexColor,
toggleFavoriteNodeColor,
upsertRecentNodeColor
} from '@/utils/nodeColorPersistence'

View File

@@ -1,106 +0,0 @@
import {
adjustColor,
parseToRgb,
rgbToHex,
toHexFromFormat
} from '@/utils/colorUtil'
export const DEFAULT_CUSTOM_NODE_COLOR = '#353535'
export const NODE_COLOR_FAVORITES_SETTING_ID = 'Comfy.NodeColor.Favorites'
export const NODE_COLOR_RECENTS_SETTING_ID = 'Comfy.NodeColor.Recents'
export const NODE_COLOR_DARKER_HEADER_SETTING_ID =
'Comfy.NodeColor.DarkerHeader'
export const NODE_COLOR_SWATCH_LIMIT = 8
export function getDefaultCustomNodeColor(): string {
return rgbToHex(parseToRgb(DEFAULT_CUSTOM_NODE_COLOR)).toLowerCase()
}
export function normalizeNodeColor(color: string | null | undefined): string {
if (!color) return getDefaultCustomNodeColor()
return toHexFromFormat(color, 'hex').toLowerCase()
}
export function deriveCustomNodeHeaderColor(
backgroundColor: string,
darkerHeader: boolean
): string {
const normalized = normalizeNodeColor(backgroundColor)
if (!darkerHeader) return normalized
return rgbToHex(
parseToRgb(adjustColor(normalized, { lightness: -0.18 }))
).toLowerCase()
}
export function upsertRecentNodeColor(
colors: string[],
color: string,
limit: number = NODE_COLOR_SWATCH_LIMIT
): string[] {
const normalized = normalizeNodeColor(color)
return [normalized, ...colors.filter((value) => value !== normalized)].slice(
0,
limit
)
}
export function toggleFavoriteNodeColor(
colors: string[],
color: string,
limit: number = NODE_COLOR_SWATCH_LIMIT
): string[] {
const normalized = normalizeNodeColor(color)
if (colors.includes(normalized)) {
return colors.filter((value) => value !== normalized)
}
return [...colors, normalized].slice(-limit)
}
export async function pickHexColor(
initialColor?: string
): Promise<string | null> {
if (typeof document === 'undefined') return null
return await new Promise<string | null>((resolve) => {
const input = document.createElement('input')
input.type = 'color'
input.value = normalizeNodeColor(initialColor)
input.tabIndex = -1
input.style.position = 'fixed'
input.style.pointerEvents = 'none'
input.style.opacity = '0'
input.style.inset = '0'
let settled = false
const finish = (value: string | null) => {
if (settled) return
settled = true
input.remove()
resolve(value)
}
input.addEventListener(
'change',
() => {
finish(normalizeNodeColor(input.value))
},
{ once: true }
)
input.addEventListener(
'blur',
() => {
queueMicrotask(() => finish(null))
},
{ once: true }
)
document.body.append(input)
input.click()
})
}

View File

@@ -9,6 +9,7 @@ import { computed, useTemplateRef } from 'vue'
import AppBuilder from '@/components/builder/AppBuilder.vue'
import AppModeToolbar from '@/components/appMode/AppModeToolbar.vue'
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import ErrorOverlay from '@/components/error/ErrorOverlay.vue'
import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
import TopbarSubscribeButton from '@/components/topbar/TopbarSubscribeButton.vue'
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
@@ -156,6 +157,7 @@ const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
</div>
<div ref="bottomLeftRef" class="absolute bottom-7 left-4 z-20" />
<div ref="bottomRightRef" class="absolute right-4 bottom-7 z-20" />
<div class="absolute top-4 right-4 z-20"><ErrorOverlay app-mode /></div>
</SplitterPanel>
<SplitterPanel
v-if="hasRightPanel"