mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-29 11:07:15 +00:00
Compare commits
13 Commits
codex/feat
...
fix/codera
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb8521d16f | ||
|
|
af5a72021b | ||
|
|
4e5bb3e540 | ||
|
|
2ccfb822b4 | ||
|
|
370003da94 | ||
|
|
3b5af4960f | ||
|
|
46895ee1a9 | ||
|
|
7f0472fde4 | ||
|
|
24ac6388d7 | ||
|
|
6b6049e48e | ||
|
|
592f992d1d | ||
|
|
76fd80aa98 | ||
|
|
63c36d3f2f |
BIN
.github/pr-assets/node-color-legacy.png
vendored
BIN
.github/pr-assets/node-color-legacy.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 65 KiB |
BIN
.github/pr-assets/node-color-nodes2.png
vendored
BIN
.github/pr-assets/node-color-nodes2.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 69 KiB |
13
.github/workflows/cloud-dispatch-build.yaml
vendored
13
.github/workflows/cloud-dispatch-build.yaml
vendored
@@ -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
62
docs/release-process.md
Normal 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 |
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
51
src/components/common/Dialogue.vue
Normal file
51
src/components/common/Dialogue.vue
Normal 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>
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[]>
|
||||
) => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 || ''
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 => ({
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
174
src/renderer/extensions/linearMode/MobileError.vue
Normal file
174
src/renderer/extensions/linearMode/MobileError.vue
Normal 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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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}`)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user