Compare commits

..

11 Commits

Author SHA1 Message Date
bymyself
3166acb825 sync position, drag, re-render conditions 2025-06-11 12:50:11 -07:00
bymyself
7e153cf10b vue node prototype 2025-06-10 18:43:05 -07:00
bymyself
e488b2abce standardize size and color 2025-06-09 04:58:17 -07:00
Christian Byrne
20e4427602 Add Vue Image Preview widget (#4116) 2025-06-09 03:52:17 -07:00
Christian Byrne
33e99da325 Add Vue File/Media Upload Widget (#4115) 2025-06-09 01:48:03 -07:00
github-actions
a7461c49c7 Update locales [skip ci] 2025-06-09 08:39:01 +00:00
Christian Byrne
102590c2c2 Add Vue Color Picker widget (#4114) 2025-06-09 01:35:06 -07:00
Christian Byrne
928dfc6b8e Add Vue Combo (dropdown) widget (#4113) 2025-06-09 00:51:08 -07:00
Christian Byrne
593ac576da Add Vue String widget and multiline textarea widgets (#4112) 2025-06-09 00:44:48 -07:00
bymyself
0858356dcf alias old float/int widgets 2025-06-09 00:24:23 -07:00
bymyself
471018a962 [feat] Add BadgedNumberInput Vue widget with state indicators
- Create BadgedNumberInput.vue component using PrimeVue InputNumber
- Add badges for random, lock, increment, decrement states
- Implement useBadgedNumberInput composable with proper DOM widget integration
- Register BADGED_NUMBER widget type in widgets registry
- Include comprehensive unit tests for widget functionality
- Widget spans node width with padding and rounded corners as specified
2025-06-08 23:21:44 -07:00
143 changed files with 10003 additions and 16446 deletions

View File

@@ -0,0 +1,53 @@
# Create a Vue Widget for ComfyUI
Your task is to create a new Vue widget for ComfyUI based on the widget specification: $ARGUMENTS
## Instructions
Follow the comprehensive guide in `vue-widget-conversion/vue-widget-guide.md` to create the widget. This guide contains step-by-step instructions, examples from actual PRs, and best practices.
### Key Steps to Follow:
1. **Understand the Widget Type**
- Analyze what type of widget is needed: $ARGUMENTS
- Identify the data type (string, number, array, object, etc.)
- Determine if it needs special behaviors (execution state awareness, dynamic management, etc.)
2. **Component Creation**
- Create Vue component in `src/components/graph/widgets/`
- REQUIRED: Use PrimeVue components (reference `vue-widget-conversion/primevue-components.md`)
- Use Composition API with `<script setup>`
- Implement proper v-model binding with `defineModel`
3. **Composable Pattern**
- Always create widget constructor composable in `src/composables/widgets/`
- Only create node-level composable in `src/composables/node/` if the widget needs dynamic management
- Follow the dual composable pattern explained in the guide
4. **Registration**
- Register in `src/scripts/widgets.ts`
- Use appropriate widget type name
5. **Testing**
- Create unit tests for composables
- Test with actual nodes that use the widget
### Important Requirements:
- **Always use PrimeVue components** - Check `vue-widget-conversion/primevue-components.md` for available components
- Use TypeScript with proper types
- Follow Vue 3 Composition API patterns
- Use Tailwind CSS for styling (no custom CSS unless absolutely necessary)
- Implement proper error handling and validation
- Consider performance (use v-show vs v-if appropriately)
### Before Starting:
1. First read through the entire guide at `vue-widget-conversion/vue-widget-guide.md`
2. Check existing widget implementations for similar patterns
3. Identify which PrimeVue component(s) best fit the widget requirements
### Widget Specification to Implement:
$ARGUMENTS
Begin by analyzing the widget requirements and proposing an implementation plan based on the guide.

View File

@@ -46,8 +46,8 @@ jobs:
id: cache-key
run: echo "key=$(date +%s)" >> $GITHUB_OUTPUT
- name: Save cache
uses: actions/cache/save@5a3ec84eff668545956fd18022155c47e93e2684
- name: Cache setup
uses: actions/cache@v3
with:
path: |
ComfyUI
@@ -62,13 +62,9 @@ jobs:
matrix:
browser: [chromium, chromium-2x, mobile-chrome]
steps:
- name: Wait for cache propagation
run: sleep 10
- name: Restore cached setup
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684
uses: actions/cache@v3
with:
fail-on-cache-miss: true
path: |
ComfyUI
ComfyUI_frontend

View File

@@ -4,7 +4,7 @@
- When trying to set tailwind classes for dark theme, use "dark-theme:" prefix rather than "dark:"
- Never add lines to PR descriptions that say "Generated with Claude Code"
- When making PR names and commit messages, if you are going to add a prefix like "docs:", "feat:", "bugfix:", use square brackets around the prefix term and do not use a colon (e.g., should be "[docs]" rather than "docs:").
- When I reference GitHub Repos related to Comfy-Org, you should proactively fetch or read the associated information in the repo. To do so, you should exhaust all options: (1) Check if we have a local copy of the repo, (2) Use the GitHub API to fetch the information; you may want to do this IN ADDITION to the other options, especially for reading specific branches/PRs/comments/reviews/metadata, and (3) curl the GitHub website and parse the html or json responses
- When I reference GitHub Repos related to Comfy-Org, you should proactively fetch or read the associated information in the repo. To do so, you should exhaust all options: (1) Check if we have a local copy of the repo, (2) Use the GitHub API to fetch the information; you may want to do this IN ADDITION to the other options, especially for reading speicifc branches/PRs/comments/reviews/metadata, and (3) curl the GitHub website and parse the html or json responses
- For information about ComfyUI, ComfyUI_frontend, or ComfyUI-Manager, you can web search or download these wikis: https://deepwiki.com/Comfy-Org/ComfyUI-Manager, https://deepwiki.com/Comfy-Org/ComfyUI_frontend/1-overview, https://deepwiki.com/comfyanonymous/ComfyUI/2-core-architecture
- If a question/project is related to Comfy-Org, Comfy, or ComfyUI ecosystem, you should proactively use the Comfy docs to answer the question. The docs may be referenced with URLs like https://docs.comfy.org
- When operating inside a repo, check for README files at key locations in the repo detailing info about the contents of that folder. E.g., top-level key folders like tests-ui, browser_tests, composables, extensions/core, stores, services often have their own README.md files. When writing code, make sure to frequently reference these README files to understand the overall architecture and design of the project. Pay close attention to the snippets to learn particular patterns that seem to be there for a reason, as you should emulate those.
@@ -12,7 +12,7 @@
- If using a lesser known or complex CLI tool, run the --help to see the documentation before deciding what to run, even if just for double-checking or verifying things.
- IMPORTANT: the most important goal when writing code is to create clean, best-practices, sustainable, and scalable public APIs and interfaces. Our app is used by thousands of users and we have thousands of mods/extensions that are constantly changing and updating; and we are also always updating. That's why it is IMPORTANT that we design systems and write code that follows practices of domain-driven design, object-oriented design, and design patterns (such that you can assure stability while allowing for all components around you to change and evolve). We ABSOLUTELY prioritize clean APIs and public interfaces that clearly define and restrict how/what the mods/extensions can access.
- If any of these technologies are referenced, you can proactively read their docs at these locations: https://primevue.org/theming, https://primevue.org/forms/, https://www.electronjs.org/docs/latest/api/browser-window, https://vitest.dev/guide/browser/, https://atlassian.design/components/pragmatic-drag-and-drop/core-package/drop-targets/, https://playwright.dev/docs/api/class-test, https://playwright.dev/docs/api/class-electron, https://www.algolia.com/doc/api-reference/rest-api/, https://pyav.org/docs/develop/cookbook/basics.html
- IMPORTANT: Never add Co-Authored by Claude or any reference to Claude or Claude Code in commit messages, PR descriptions, titles, or any documentation whatsoever
- IMPORTANT: Never add Co-Authored by Claude or any refrence to Claude or Claude Code in commit messages, PR descriptions, titles, or any documentation whatsoever
- The npm script to type check is called "typecheck" NOT "type check"
- Use the Vue 3 Composition API instead of the Options API when writing Vue components. An exception is when overriding or extending a PrimeVue component for compatibility, you may use the Options API.
- when we are solving an issue we know the link/number for, we should add "Fixes #n" (where n is the issue number) to the PR description.
@@ -46,11 +46,3 @@
* `TabMenu` → Use `Tabs` without panels
* `Steps` → Use `Stepper` without panels
* `InlineMessage` → Use `Message` component
* Use `api.apiURL()` for all backend API calls and routes
- Actual API endpoints like /prompt, /queue, /view, etc.
- Image previews: `api.apiURL('/view?...')`
- Any backend-generated content or dynamic routes
* Use `api.fileURL()` for static files served from the public folder:
- Templates: `api.fileURL('/templates/default.json')`
- Extensions: `api.fileURL(extensionPath)` for loading JS modules
- Any static assets that exist in the public directory

View File

@@ -762,7 +762,7 @@ export class ComfyPage {
y: 625
}
})
await this.page.mouse.move(10, 10)
this.page.mouse.move(10, 10)
await this.nextFrame()
}
@@ -774,7 +774,7 @@ export class ComfyPage {
},
button: 'right'
})
await this.page.mouse.move(10, 10)
this.page.mouse.move(10, 10)
await this.nextFrame()
}
@@ -1046,8 +1046,6 @@ export class ComfyPage {
}
}
export const testComfySnapToGridGridSize = 50
export const comfyPageFixture = base.extend<{
comfyPage: ComfyPage
comfyMouse: ComfyMouse
@@ -1074,8 +1072,7 @@ export const comfyPageFixture = base.extend<{
'Comfy.EnableTooltips': false,
'Comfy.userId': userId,
// Set tutorial completed to true to avoid loading the tutorial workflow.
'Comfy.TutorialCompleted': true,
'Comfy.SnapToGrid.GridSize': testComfySnapToGridGridSize
'Comfy.TutorialCompleted': true
})
} catch (e) {
console.error(e)

View File

@@ -1,12 +1,6 @@
import { expect } from '@playwright/test'
import { Position } from '@vueuse/core'
import {
type ComfyPage,
comfyPageFixture as test,
testComfySnapToGridGridSize
} from '../fixtures/ComfyPage'
import { type NodeReference } from '../fixtures/utils/litegraphUtils'
import { type ComfyPage, comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Item Interaction', () => {
test('Can select/delete all items', async ({ comfyPage }) => {
@@ -63,10 +57,8 @@ test.describe('Node Interaction', () => {
await expect(comfyPage.canvas).toHaveScreenshot('selected-node2.png')
})
const dragSelectNodes = async (
comfyPage: ComfyPage,
clipNodes: NodeReference[]
) => {
test('Can drag-select nodes with Meta (mac)', async ({ comfyPage }) => {
const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
const clipNode1Pos = await clipNodes[0].getPosition()
const clipNode2Pos = await clipNodes[1].getPosition()
const offset = 64
@@ -82,67 +74,10 @@ test.describe('Node Interaction', () => {
}
)
await comfyPage.page.keyboard.up('Meta')
}
test('Can drag-select nodes with Meta (mac)', async ({ comfyPage }) => {
const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
await dragSelectNodes(comfyPage, clipNodes)
expect(await comfyPage.getSelectedGraphNodesCount()).toBe(
clipNodes.length
)
})
test('Can move selected nodes using the Comfy.Canvas.MoveSelectedNodes.{Up|Down|Left|Right} commands', async ({
comfyPage
}) => {
const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
const getPositions = () =>
Promise.all(clipNodes.map((node) => node.getPosition()))
const testDirection = async ({
direction,
expectedPosition
}: {
direction: string
expectedPosition: (originalPosition: Position) => Position
}) => {
const originalPositions = await getPositions()
await dragSelectNodes(comfyPage, clipNodes)
await comfyPage.executeCommand(
`Comfy.Canvas.MoveSelectedNodes.${direction}`
)
await comfyPage.canvas.press(`Control+Arrow${direction}`)
const newPositions = await getPositions()
expect(newPositions).toEqual(originalPositions.map(expectedPosition))
}
await testDirection({
direction: 'Down',
expectedPosition: (originalPosition) => ({
...originalPosition,
y: originalPosition.y + testComfySnapToGridGridSize
})
})
await testDirection({
direction: 'Right',
expectedPosition: (originalPosition) => ({
...originalPosition,
x: originalPosition.x + testComfySnapToGridGridSize
})
})
await testDirection({
direction: 'Up',
expectedPosition: (originalPosition) => ({
...originalPosition,
y: originalPosition.y - testComfySnapToGridGridSize
})
})
await testDirection({
direction: 'Left',
expectedPosition: (originalPosition) => ({
...originalPosition,
x: originalPosition.x - testComfySnapToGridGridSize
})
})
})
})
test('Can drag node', async ({ comfyPage }) => {

89
copy-widget-resources.sh Executable file
View File

@@ -0,0 +1,89 @@
#!/bin/bash
# Script to copy vue-widget-conversion folder and .claude/commands/create-widget.md
# to another local copy of the same repository
# Check if destination directory was provided
if [ $# -eq 0 ]; then
echo "Usage: $0 <destination-repo-path>"
echo "Example: $0 /home/c_byrne/projects/comfyui-frontend-testing/ComfyUI_frontend-clone-8"
exit 1
fi
# Get the destination directory from first argument
DEST_DIR="$1"
# Source files/directories (relative to script location)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SOURCE_WIDGET_DIR="$SCRIPT_DIR/vue-widget-conversion"
SOURCE_COMMAND_FILE="$SCRIPT_DIR/.claude/commands/create-widget.md"
# Destination paths
DEST_WIDGET_DIR="$DEST_DIR/vue-widget-conversion"
DEST_COMMAND_DIR="$DEST_DIR/.claude/commands"
DEST_COMMAND_FILE="$DEST_COMMAND_DIR/create-widget.md"
# Check if destination directory exists
if [ ! -d "$DEST_DIR" ]; then
echo "Error: Destination directory does not exist: $DEST_DIR"
exit 1
fi
# Check if source vue-widget-conversion directory exists
if [ ! -d "$SOURCE_WIDGET_DIR" ]; then
echo "Error: Source vue-widget-conversion directory not found: $SOURCE_WIDGET_DIR"
exit 1
fi
# Check if source command file exists
if [ ! -f "$SOURCE_COMMAND_FILE" ]; then
echo "Error: Source command file not found: $SOURCE_COMMAND_FILE"
exit 1
fi
echo "Copying widget resources to: $DEST_DIR"
# Copy vue-widget-conversion directory
echo "Copying vue-widget-conversion directory..."
if [ -d "$DEST_WIDGET_DIR" ]; then
echo " Warning: Destination vue-widget-conversion already exists. Overwriting..."
rm -rf "$DEST_WIDGET_DIR"
fi
cp -r "$SOURCE_WIDGET_DIR" "$DEST_WIDGET_DIR"
echo " ✓ Copied vue-widget-conversion directory"
# Create .claude/commands directory if it doesn't exist
echo "Creating .claude/commands directory structure..."
mkdir -p "$DEST_COMMAND_DIR"
echo " ✓ Created .claude/commands directory"
# Copy create-widget.md command
echo "Copying create-widget.md command..."
cp "$SOURCE_COMMAND_FILE" "$DEST_COMMAND_FILE"
echo " ✓ Copied create-widget.md command"
# Verify the copy was successful
echo ""
echo "Verification:"
if [ -d "$DEST_WIDGET_DIR" ] && [ -f "$DEST_WIDGET_DIR/vue-widget-guide.md" ] && [ -f "$DEST_WIDGET_DIR/primevue-components.md" ]; then
echo " ✓ vue-widget-conversion directory copied successfully"
echo " - vue-widget-guide.md exists"
echo " - primevue-components.md exists"
if [ -f "$DEST_WIDGET_DIR/primevue-components.json" ]; then
echo " - primevue-components.json exists"
fi
else
echo " ✗ Error: vue-widget-conversion directory copy may have failed"
fi
if [ -f "$DEST_COMMAND_FILE" ]; then
echo " ✓ create-widget.md command copied successfully"
else
echo " ✗ Error: create-widget.md command copy may have failed"
fi
echo ""
echo "Copy complete! Widget resources are now available in: $DEST_DIR"
echo ""
echo "You can now use the widget creation command in the destination repo:"
echo " /project:create-widget <widget specification>"

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.23.2",
"version": "1.22.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@comfyorg/comfyui-frontend",
"version": "1.23.2",
"version": "1.22.1",
"license": "GPL-3.0-only",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.23.2",
"version": "1.22.1",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",

View File

@@ -1,12 +1,15 @@
<template>
<div
<hr
:class="{
'content-divider': true,
'content-divider--horizontal': orientation === 'horizontal',
'content-divider--vertical': orientation === 'vertical'
'm-0': true,
'border-t': orientation === 'horizontal',
'border-l': orientation === 'vertical',
'h-full': orientation === 'vertical',
'w-full': orientation === 'horizontal'
}"
:style="{
backgroundColor: isLightTheme ? '#DCDAE1' : '#2C2C2C'
borderColor: isLightTheme ? '#DCDAE1' : '#2C2C2C',
borderWidth: `${width}px !important`
}"
/>
</template>
@@ -26,25 +29,3 @@ const isLightTheme = computed(
() => colorPaletteStore.completedActivePalette.light_theme
)
</script>
<style scoped>
.content-divider {
display: inline-block;
margin: 0;
padding: 0;
border: none;
flex-shrink: 0;
position: relative;
z-index: 1;
}
.content-divider--horizontal {
width: 100%;
height: v-bind('width + "px"');
}
.content-divider--vertical {
height: 100%;
width: v-bind('width + "px"');
}
</style>

View File

@@ -92,21 +92,12 @@ whenever(
const updateItemSize = () => {
if (container.value) {
const firstItem = container.value.querySelector('[data-virtual-grid-item]')
// Don't update item size if the first item is not rendered yet
if (!firstItem?.clientHeight || !firstItem?.clientWidth) return
if (itemHeight.value !== firstItem.clientHeight) {
itemHeight.value = firstItem.clientHeight
}
if (itemWidth.value !== firstItem.clientWidth) {
itemWidth.value = firstItem.clientWidth
}
itemHeight.value = firstItem?.clientHeight || defaultItemHeight
itemWidth.value = firstItem?.clientWidth || defaultItemWidth
}
}
const onResize = debounce(updateItemSize, resizeDebounce)
watch([width, height], onResize, { flush: 'post' })
whenever(() => items, updateItemSize, { flush: 'post' })
onBeforeUnmount(() => {
onResize.cancel() // Clear pending debounced calls
})

View File

@@ -50,17 +50,4 @@ const dialogStore = useDialogStore()
@apply p-2 2xl:p-[var(--p-dialog-content-padding)];
@apply pt-0;
}
.manager-dialog {
height: 80vh;
max-width: 1724px;
max-height: 1026px;
}
@media (min-width: 3000px) {
.manager-dialog {
max-width: 2200px;
max-height: 1320px;
}
}
</style>

View File

@@ -5,7 +5,6 @@
title="Missing Node Types"
message="When loading the graph, the following node types were not found"
/>
<MissingCoreNodesMessage :missing-core-nodes="missingCoreNodes" />
<ListBox
:options="uniqueNodes"
option-label="label"
@@ -32,12 +31,6 @@
</template>
</ListBox>
<div v-if="isManagerInstalled" class="flex justify-end py-3">
<PackInstallButton
:disabled="isLoading || !!error || missingNodePacks.length === 0"
:node-packs="missingNodePacks"
variant="black"
:label="$t('manager.installAllMissingNodes')"
/>
<Button label="Open Manager" size="small" outlined @click="openManager" />
</div>
</template>
@@ -48,9 +41,6 @@ import ListBox from 'primevue/listbox'
import { computed } from 'vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
import { useMissingNodes } from '@/composables/nodePack/useMissingNodes'
import { useDialogService } from '@/services/dialogService'
import { useAboutPanelStore } from '@/stores/aboutPanelStore'
import type { MissingNodeType } from '@/types/comfy'
@@ -62,10 +52,6 @@ const props = defineProps<{
const aboutPanelStore = useAboutPanelStore()
// Get missing node packs from workflow with loading and error states
const { missingNodePacks, isLoading, error, missingCoreNodes } =
useMissingNodes()
// Determines if ComfyUI-Manager is installed by checking for its badge in the about panel
// This allows us to conditionally show the Manager button only when the extension is available
// TODO: Remove this check when Manager functionality is fully migrated into core

View File

@@ -1,95 +0,0 @@
<template>
<Message
v-if="hasMissingCoreNodes"
severity="info"
icon="pi pi-info-circle"
class="my-2 mx-2"
:pt="{
root: { class: 'flex-col' },
text: { class: 'flex-1' }
}"
>
<div class="flex flex-col gap-2">
<div>
{{
currentComfyUIVersion
? $t('loadWorkflowWarning.outdatedVersion', {
version: currentComfyUIVersion
})
: $t('loadWorkflowWarning.outdatedVersionGeneric')
}}
</div>
<div
v-for="[version, nodes] in sortedMissingCoreNodes"
:key="version"
class="ml-4"
>
<div
class="text-sm font-medium text-surface-600 dark-theme:text-surface-400"
>
{{
$t('loadWorkflowWarning.coreNodesFromVersion', {
version: version || 'unknown'
})
}}
</div>
<div class="ml-4 text-sm text-surface-500 dark-theme:text-surface-500">
{{ getUniqueNodeNames(nodes).join(', ') }}
</div>
</div>
</div>
</Message>
</template>
<script setup lang="ts">
import type { LGraphNode } from '@comfyorg/litegraph'
import { whenever } from '@vueuse/core'
import Message from 'primevue/message'
import { computed, ref } from 'vue'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import { compareVersions } from '@/utils/formatUtil'
const props = defineProps<{
missingCoreNodes: Record<string, LGraphNode[]>
}>()
const systemStatsStore = useSystemStatsStore()
const hasMissingCoreNodes = computed(() => {
return Object.keys(props.missingCoreNodes).length > 0
})
const currentComfyUIVersion = ref<string | null>(null)
whenever(
hasMissingCoreNodes,
async () => {
if (!systemStatsStore.systemStats) {
await systemStatsStore.fetchSystemStats()
}
currentComfyUIVersion.value =
systemStatsStore.systemStats?.system?.comfyui_version ?? null
},
{
immediate: true
}
)
const sortedMissingCoreNodes = computed(() => {
return Object.entries(props.missingCoreNodes).sort(([a], [b]) => {
// Sort by version in descending order (newest first)
return compareVersions(b, a) // Reversed for descending order
})
})
const getUniqueNodeNames = (nodes: LGraphNode[]): string[] => {
return nodes
.reduce<string[]>((acc, node) => {
if (node.type && !acc.includes(node.type)) {
acc.push(node.type)
}
return acc
}, [])
.sort()
}
</script>

View File

@@ -1,16 +1,14 @@
<template>
<div
class="h-full flex flex-col mx-auto overflow-hidden"
class="flex flex-col mx-auto overflow-hidden h-[83vh] relative"
:aria-label="$t('manager.title')"
>
<ContentDivider :width="0.3" />
<Button
v-if="isSmallScreen"
:icon="isSideNavOpen ? 'pi pi-chevron-left' : 'pi pi-chevron-right'"
severity="secondary"
filled
text
class="absolute top-1/2 -translate-y-1/2 z-10"
:class="isSideNavOpen ? 'left-[12rem]' : 'left-2'"
:class="isSideNavOpen ? 'left-[19rem]' : 'left-2'"
@click="toggleSideNav"
/>
<div class="flex flex-1 relative overflow-hidden">
@@ -20,20 +18,20 @@
:tabs="tabs"
/>
<div
class="flex-1 overflow-auto bg-gray-50 dark-theme:bg-neutral-900"
class="flex-1 overflow-auto pr-80"
:class="{
'transition-all duration-300': isSmallScreen
'transition-all duration-300': isSmallScreen,
'pl-80': isSideNavOpen || !isSmallScreen,
'pl-8': !isSideNavOpen && isSmallScreen
}"
>
<div class="px-6 flex flex-col h-full">
<div class="px-6 pt-6 flex flex-col h-full">
<RegistrySearchBar
v-model:searchQuery="searchQuery"
v-model:searchMode="searchMode"
v-model:sortField="sortField"
:search-results="searchResults"
:suggestions="suggestions"
:is-missing-tab="isMissingTab"
:sort-options="sortOptions"
/>
<div class="flex-1 overflow-auto">
<div
@@ -59,7 +57,7 @@
<VirtualGrid
id="results-grid"
:items="resultsWithKeys"
:buffer-rows="4"
:buffer-rows="3"
:grid-style="GRID_STYLE"
@approach-end="onApproachEnd"
>
@@ -77,9 +75,9 @@
</div>
</div>
</div>
<div class="w-[clamp(250px,33%,306px)] border-l-0 flex z-20">
<div class="w-80 border-l-0 absolute right-0 top-0 bottom-0 flex z-20">
<ContentDivider orientation="vertical" :width="0.2" />
<div class="w-full flex flex-col isolate">
<div class="flex-1 flex flex-col isolate">
<InfoPanel
v-if="!hasMultipleSelections && selectedNodePack"
:node-pack="selectedNodePack"
@@ -95,14 +93,7 @@
import { whenever } from '@vueuse/core'
import { merge } from 'lodash'
import Button from 'primevue/button'
import {
computed,
onBeforeUnmount,
onMounted,
onUnmounted,
ref,
watch
} from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import ContentDivider from '@/components/common/ContentDivider.vue'
@@ -115,7 +106,6 @@ import PackCard from '@/components/dialog/content/manager/packCard/PackCard.vue'
import RegistrySearchBar from '@/components/dialog/content/manager/registrySearchBar/RegistrySearchBar.vue'
import GridSkeleton from '@/components/dialog/content/manager/skeleton/GridSkeleton.vue'
import { useResponsiveCollapse } from '@/composables/element/useResponsiveCollapse'
import { useManagerStatePersistence } from '@/composables/manager/useManagerStatePersistence'
import { useInstalledPacks } from '@/composables/nodePack/useInstalledPacks'
import { usePackUpdateStatus } from '@/composables/nodePack/usePackUpdateStatus'
import { useWorkflowPacks } from '@/composables/nodePack/useWorkflowPacks'
@@ -126,15 +116,13 @@ import type { TabItem } from '@/types/comfyManagerTypes'
import { ManagerTab } from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
const { initialTab } = defineProps<{
initialTab?: ManagerTab
const { initialTab = ManagerTab.All } = defineProps<{
initialTab: ManagerTab
}>()
const { t } = useI18n()
const comfyManagerStore = useComfyManagerStore()
const { getPackById } = useComfyRegistryStore()
const persistedState = useManagerStatePersistence()
const initialState = persistedState.loadStoredState()
const GRID_STYLE = {
display: 'grid',
@@ -168,10 +156,8 @@ const tabs = ref<TabItem[]>([
icon: 'pi-sync'
}
])
const initialTabId = initialTab ?? initialState.selectedTabId
const selectedTab = ref<TabItem>(
tabs.value.find((tab) => tab.id === initialTabId) || tabs.value[0]
tabs.value.find((tab) => tab.id === initialTab) || tabs.value[0]
)
const {
@@ -181,13 +167,8 @@ const {
searchResults,
searchMode,
sortField,
suggestions,
sortOptions
} = useRegistrySearch({
initialSortField: initialState.sortField,
initialSearchMode: initialState.searchMode,
initialSearchQuery: initialState.searchQuery
})
suggestions
} = useRegistrySearch()
pageNumber.value = 0
const onApproachEnd = () => {
pageNumber.value++
@@ -219,6 +200,10 @@ const {
const filterMissingPacks = (packs: components['schemas']['Node'][]) =>
packs.filter((pack) => !comfyManagerStore.isPackInstalled(pack.id))
whenever(selectedTab, () => {
pageNumber.value = 0
})
const isUpdateAvailableTab = computed(
() => selectedTab.value?.id === ManagerTab.UpdateAvailable
)
@@ -247,11 +232,7 @@ watch(
if (!isEmptySearch.value) {
displayPacks.value = filterOutdatedPacks(installedPacks.value)
} else if (
!installedPacks.value.length &&
!installedPacksReady.value &&
!isLoadingInstalled.value
) {
} else if (!installedPacks.value.length) {
await startFetchInstalled()
} else {
displayPacks.value = filterOutdatedPacks(installedPacks.value)
@@ -445,13 +426,7 @@ whenever(selectedNodePack, async () => {
if (data?.id === pack.id) {
lastFetchedPackId.value = pack.id
const mergedPack = merge({}, pack, data)
// Update the pack in current selection without changing selection state
const packIndex = selectedNodePacks.value.findIndex(
(p) => p.id === mergedPack.id
)
if (packIndex !== -1) {
selectedNodePacks.value.splice(packIndex, 1, mergedPack)
}
selectedNodePacks.value = [mergedPack]
// Replace pack in displayPacks so that children receive a fresh prop reference
const idx = displayPacks.value.findIndex((p) => p.id === mergedPack.id)
if (idx !== -1) {
@@ -464,23 +439,13 @@ let gridContainer: HTMLElement | null = null
onMounted(() => {
gridContainer = document.getElementById('results-grid')
})
watch([searchQuery, selectedTab], () => {
watch(searchQuery, () => {
gridContainer ??= document.getElementById('results-grid')
if (gridContainer) {
pageNumber.value = 0
gridContainer.scrollTop = 0
}
})
onBeforeUnmount(() => {
persistedState.persistState({
selectedTabId: selectedTab.value?.id,
searchQuery: searchQuery.value,
searchMode: searchMode.value,
sortField: sortField.value
})
})
onUnmounted(() => {
getPackById.cancel()
})

View File

@@ -1,9 +1,14 @@
<template>
<div class="w-full">
<div class="flex items-center">
<div class="px-6 py-4">
<h2 class="text-lg font-normal text-left">
{{ $t('manager.discoverCommunityContent') }}
</h2>
</div>
<ContentDivider :width="0.3" />
</div>
</template>
<script setup lang="ts">
import ContentDivider from '@/components/common/ContentDivider.vue'
</script>

View File

@@ -1,8 +1,8 @@
<template>
<aside
class="flex translate-x-0 max-w-[250px] w-3/12 z-5 transition-transform duration-300 ease-in-out"
class="absolute translate-x-0 top-0 left-0 h-full w-80 shadow-md z-5 transition-transform duration-300 ease-in-out flex"
>
<ScrollPanel class="flex-1">
<ScrollPanel class="w-80 mt-7">
<Listbox
v-model="selectedTab"
:options="tabs"
@@ -10,20 +10,20 @@
list-style="max-height:unset"
class="w-full border-0 bg-transparent shadow-none"
:pt="{
list: { class: 'p-3 gap-2' },
option: { class: 'px-4 py-2 text-lg rounded-lg' },
list: { class: 'p-5' },
option: { class: 'px-8 py-3 text-lg rounded-xl' },
optionGroup: { class: 'p-0 text-left text-inherit' }
}"
>
<template #option="slotProps">
<div class="text-left flex items-center">
<i :class="['pi', slotProps.option.icon, 'text-sm mr-2']" />
<span class="text-sm">{{ slotProps.option.label }}</span>
<i :class="['pi', slotProps.option.icon, 'mr-3']" />
<span class="text-lg">{{ slotProps.option.label }}</span>
</div>
</template>
</Listbox>
</ScrollPanel>
<ContentDivider orientation="vertical" :width="0.3" />
<ContentDivider orientation="vertical" />
</aside>
</template>

View File

@@ -1,5 +1,6 @@
import { VueWrapper, mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import Button from 'primevue/button'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
@@ -32,12 +33,6 @@ vi.mock('@/stores/comfyManagerStore', () => ({
}))
}))
vi.mock('@/composables/nodePack/usePackUpdateStatus', () => ({
usePackUpdateStatus: vi.fn(() => ({
isUpdateAvailable: false
}))
}))
const mockToggle = vi.fn()
const mockHide = vi.fn()
const PopoverStub = {
@@ -67,7 +62,6 @@ describe('PackVersionBadge', () => {
return mount(PackVersionBadge, {
props: {
nodePack: mockNodePack,
isSelected: false,
...props
},
global: {
@@ -83,9 +77,9 @@ describe('PackVersionBadge', () => {
it('renders with installed version from store', () => {
const wrapper = mountComponent()
const badge = wrapper.find('[role="button"]')
expect(badge.exists()).toBe(true)
expect(badge.find('span').text()).toBe('1.5.0') // From mockInstalledPacks
const button = wrapper.findComponent(Button)
expect(button.exists()).toBe(true)
expect(button.props('label')).toBe('1.5.0') // From mockInstalledPacks
})
it('falls back to latest_version when not installed', () => {
@@ -102,9 +96,9 @@ describe('PackVersionBadge', () => {
props: { nodePack: uninstalledPack }
})
const badge = wrapper.find('[role="button"]')
expect(badge.exists()).toBe(true)
expect(badge.find('span').text()).toBe('3.0.0') // From latest_version
const button = wrapper.findComponent(Button)
expect(button.exists()).toBe(true)
expect(button.props('label')).toBe('3.0.0') // From latest_version
})
it('falls back to NIGHTLY when no latest_version and not installed', () => {
@@ -118,9 +112,9 @@ describe('PackVersionBadge', () => {
props: { nodePack: noVersionPack }
})
const badge = wrapper.find('[role="button"]')
expect(badge.exists()).toBe(true)
expect(badge.find('span').text()).toBe(SelectedVersion.NIGHTLY)
const button = wrapper.findComponent(Button)
expect(button.exists()).toBe(true)
expect(button.props('label')).toBe(SelectedVersion.NIGHTLY)
})
it('falls back to NIGHTLY when nodePack.id is missing', () => {
@@ -132,16 +126,16 @@ describe('PackVersionBadge', () => {
props: { nodePack: invalidPack }
})
const badge = wrapper.find('[role="button"]')
expect(badge.exists()).toBe(true)
expect(badge.find('span').text()).toBe(SelectedVersion.NIGHTLY)
const button = wrapper.findComponent(Button)
expect(button.exists()).toBe(true)
expect(button.props('label')).toBe(SelectedVersion.NIGHTLY)
})
it('toggles the popover when button is clicked', async () => {
const wrapper = mountComponent()
// Click the badge
await wrapper.find('[role="button"]').trigger('click')
// Click the button
await wrapper.findComponent(Button).trigger('click')
// Verify that the toggle method was called
expect(mockToggle).toHaveBeenCalled()
@@ -168,58 +162,4 @@ describe('PackVersionBadge', () => {
// Verify that the hide method was called
expect(mockHide).toHaveBeenCalled()
})
describe('selection state changes', () => {
it('closes the popover when card is deselected', async () => {
const wrapper = mountComponent({
props: { isSelected: true }
})
// Change isSelected from true to false
await wrapper.setProps({ isSelected: false })
await nextTick()
// Verify that the hide method was called
expect(mockHide).toHaveBeenCalled()
})
it('does not close the popover when card is selected', async () => {
const wrapper = mountComponent({
props: { isSelected: false }
})
// Change isSelected from false to true
await wrapper.setProps({ isSelected: true })
await nextTick()
// Verify that the hide method was NOT called
expect(mockHide).not.toHaveBeenCalled()
})
it('does not close the popover when isSelected remains false', async () => {
const wrapper = mountComponent({
props: { isSelected: false }
})
// Change isSelected from false to false (no change)
await wrapper.setProps({ isSelected: false })
await nextTick()
// Verify that the hide method was NOT called
expect(mockHide).not.toHaveBeenCalled()
})
it('does not close the popover when isSelected remains true', async () => {
const wrapper = mountComponent({
props: { isSelected: true }
})
// Change isSelected from true to true (no change)
await wrapper.setProps({ isSelected: true })
await nextTick()
// Verify that the hide method was NOT called
expect(mockHide).not.toHaveBeenCalled()
})
})
})

View File

@@ -1,23 +1,18 @@
<template>
<div>
<div
class="inline-flex items-center gap-1 rounded-2xl text-xs cursor-pointer px-2 py-1"
:class="{ 'bg-gray-100 dark-theme:bg-neutral-700': fill }"
<div class="relative">
<Button
:label="installedVersion"
severity="secondary"
icon="pi pi-chevron-right"
icon-pos="right"
class="rounded-xl text-xs tracking-tighter p-0"
:pt="{
label: { class: 'pl-2 pr-0 py-0.5' },
icon: { class: 'text-xs pl-0 pr-2 py-0.5' }
}"
aria-haspopup="true"
role="button"
tabindex="0"
@click="toggleVersionSelector"
@keydown.enter="toggleVersionSelector"
@keydown.space="toggleVersionSelector"
>
<i
v-if="isUpdateAvailable"
class="pi pi-arrow-circle-up text-blue-600"
style="font-size: 8px"
/>
<span>{{ installedVersion }}</span>
<i class="pi pi-chevron-right" style="font-size: 8px" />
</div>
/>
<Popover
ref="popoverRef"
@@ -36,11 +31,11 @@
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Popover from 'primevue/popover'
import { computed, ref, watch } from 'vue'
import { computed, ref } from 'vue'
import PackVersionSelectorPopover from '@/components/dialog/content/manager/PackVersionSelectorPopover.vue'
import { usePackUpdateStatus } from '@/composables/nodePack/usePackUpdateStatus'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { SelectedVersion } from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
@@ -48,17 +43,10 @@ import { isSemVer } from '@/utils/formatUtil'
const TRUNCATED_HASH_LENGTH = 7
const {
nodePack,
isSelected,
fill = true
} = defineProps<{
const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
isSelected: boolean
fill?: boolean
}>()
const { isUpdateAvailable } = usePackUpdateStatus(nodePack)
const popoverRef = ref()
const managerStore = useComfyManagerStore()
@@ -81,14 +69,4 @@ const toggleVersionSelector = (event: Event) => {
const closeVersionSelector = () => {
popoverRef.value.hide()
}
// If the card is unselected, automatically close the version selector popover
watch(
() => isSelected,
(isSelected, wasSelected) => {
if (wasSelected && !isSelected) {
closeVersionSelector()
}
}
)
</script>

View File

@@ -191,100 +191,6 @@ describe('PackVersionSelectorPopover', () => {
expect(mockGetPackVersions).toHaveBeenCalledWith(newNodePack.id)
})
describe('nodePack.id changes', () => {
it('re-fetches versions when nodePack.id changes', async () => {
// Set up the mock for the initial fetch
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
const wrapper = mountComponent()
await waitForPromises()
// Verify initial fetch
expect(mockGetPackVersions).toHaveBeenCalledTimes(1)
expect(mockGetPackVersions).toHaveBeenCalledWith(mockNodePack.id)
// Set up the mock for the second fetch
const newVersions = [
{ version: '2.0.0', createdAt: '2023-06-01' },
{ version: '1.9.0', createdAt: '2023-05-01' }
]
mockGetPackVersions.mockResolvedValueOnce(newVersions)
// Update the nodePack with a new ID
const newNodePack = {
...mockNodePack,
id: 'different-pack',
name: 'Different Pack'
}
await wrapper.setProps({ nodePack: newNodePack })
await waitForPromises()
// Should fetch versions for the new nodePack
expect(mockGetPackVersions).toHaveBeenCalledTimes(2)
expect(mockGetPackVersions).toHaveBeenLastCalledWith(newNodePack.id)
// Check that new versions are displayed
const listbox = wrapper.findComponent(Listbox)
const options = listbox.props('options')!
expect(options.some((o) => o.value === '2.0.0')).toBe(true)
expect(options.some((o) => o.value === '1.9.0')).toBe(true)
})
it('does not re-fetch when nodePack changes but id remains the same', async () => {
// Set up the mock for the initial fetch
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
const wrapper = mountComponent()
await waitForPromises()
// Verify initial fetch
expect(mockGetPackVersions).toHaveBeenCalledTimes(1)
// Update the nodePack with same ID but different properties
const updatedNodePack = {
...mockNodePack,
name: 'Updated Test Pack',
description: 'New description'
}
await wrapper.setProps({ nodePack: updatedNodePack })
await waitForPromises()
// Should NOT fetch versions again
expect(mockGetPackVersions).toHaveBeenCalledTimes(1)
})
it('maintains selected version when switching to a new pack', async () => {
// Set up the mock for the initial fetch
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
const wrapper = mountComponent()
await waitForPromises()
// Select a specific version
const listbox = wrapper.findComponent(Listbox)
await listbox.setValue('0.9.0')
expect(listbox.props('modelValue')).toBe('0.9.0')
// Set up the mock for the second fetch
mockGetPackVersions.mockResolvedValueOnce([
{ version: '3.0.0', createdAt: '2023-07-01' },
{ version: '0.9.0', createdAt: '2023-04-01' }
])
// Update to a new pack that also has version 0.9.0
const newNodePack = {
id: 'another-pack',
name: 'Another Pack',
latest_version: { version: '3.0.0' }
}
await wrapper.setProps({ nodePack: newNodePack })
await waitForPromises()
// Selected version should remain the same if available
expect(listbox.props('modelValue')).toBe('0.9.0')
})
})
describe('Unclaimed GitHub packs handling', () => {
it('falls back to nightly when no versions exist', async () => {
// Set up the mock to return versions

View File

@@ -62,7 +62,7 @@ import { whenever } from '@vueuse/core'
import Button from 'primevue/button'
import Listbox from 'primevue/listbox'
import ProgressSpinner from 'primevue/progressspinner'
import { onMounted, ref } from 'vue'
import { onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ContentDivider from '@/components/common/ContentDivider.vue'
@@ -161,11 +161,9 @@ const onNodePackChange = async () => {
}
whenever(
() => nodePack.id,
(nodePackId, oldNodePackId) => {
if (nodePackId !== oldNodePackId) {
void onNodePackChange()
}
() => nodePack,
() => {
void onNodePackChange()
},
{ deep: true, immediate: true }
)
@@ -184,4 +182,8 @@ const handleSubmit = async () => {
isQueueing.value = false
emit('submit')
}
onUnmounted(() => {
managerStore.installPack.clear()
})
</script>

View File

@@ -1,18 +1,16 @@
<template>
<Button
outlined
class="!m-0 p-0 rounded-lg text-gray-900 dark-theme:text-gray-50"
:class="[
variant === 'black'
? 'bg-neutral-900 text-white border-neutral-900'
: 'border-neutral-700',
fullWidth ? 'w-full' : 'w-min-content'
]"
class="m-0 p-0 rounded-lg border-neutral-700"
:class="{
'w-full': fullWidth,
'w-min-content': !fullWidth
}"
:disabled="loading"
v-bind="$attrs"
@click="onClick"
>
<span class="py-2 px-3 whitespace-nowrap">
<span class="py-2.5 px-3">
<template v-if="loading">
{{ loadingMessage ?? $t('g.loading') }}
</template>
@@ -29,14 +27,12 @@ import Button from 'primevue/button'
const {
label,
loadingMessage,
fullWidth = false,
variant = 'default'
fullWidth = false
} = defineProps<{
label: string
loading?: boolean
loadingMessage?: string
fullWidth?: boolean
variant?: 'default' | 'black'
}>()
const emit = defineEmits<{

View File

@@ -2,11 +2,9 @@
<PackActionButton
v-bind="$attrs"
:label="
label ??
(nodePacks.length > 1 ? $t('manager.installSelected') : $t('g.install'))
nodePacks.length > 1 ? $t('manager.installSelected') : $t('g.install')
"
:severity="variant === 'black' ? undefined : 'secondary'"
:variant="variant"
severity="secondary"
:loading="isInstalling"
:loading-message="$t('g.installing')"
@action="installAllPacks"
@@ -29,10 +27,8 @@ import type { components } from '@/types/comfyRegistryTypes'
type NodePack = components['schemas']['Node']
const { nodePacks, variant, label } = defineProps<{
const { nodePacks } = defineProps<{
nodePacks: NodePack[]
variant?: 'default' | 'black'
label?: string
}>()
const isInstalling = inject(IsInstallingKey, ref(false))

View File

@@ -1,6 +1,6 @@
<template>
<template v-if="nodePack">
<div class="flex flex-col h-full z-40 overflow-hidden relative">
<div class="flex flex-col h-full z-40 w-80 overflow-hidden relative">
<div class="top-0 z-10 px-6 pt-6 w-full">
<InfoPanelHeader :node-packs="[nodePack]" />
</div>
@@ -32,7 +32,7 @@
/>
</MetadataRow>
<MetadataRow :label="t('manager.version')">
<PackVersionBadge :node-pack="nodePack" :is-selected="true" />
<PackVersionBadge :node-pack="nodePack" />
</MetadataRow>
</div>
<div class="mb-6 overflow-hidden">
@@ -42,7 +42,7 @@
</div>
</template>
<template v-else>
<div class="pt-4 px-8 flex-1 overflow-hidden text-sm">
<div class="mt-4 mx-8 flex-1 overflow-hidden text-sm">
{{ $t('manager.infoPanelEmpty') }}
</div>
</template>
@@ -118,15 +118,7 @@ const onNodePackChange = () => {
y.value = 0
}
whenever(
() => nodePack.id,
(nodePackId, oldNodePackId) => {
if (nodePackId !== oldNodePackId) {
onNodePackChange()
}
},
{ immediate: true }
)
whenever(() => nodePack, onNodePackChange, { immediate: true, deep: true })
</script>
<style scoped>
.hidden-scrollbar {

View File

@@ -51,11 +51,7 @@ const getPackNodes = async (pack: components['schemas']['Node']) => {
if (!pack.latest_version?.version) return []
const nodeDefs = await getNodeDefs.call({
packId: pack.id,
version: pack.latest_version?.version,
// Fetch all nodes.
// TODO: Render all nodes previews and handle pagination.
// For determining length, use the `totalNumberOfPages` field of response
limit: 8192
version: pack.latest_version?.version
})
return nodeDefs?.comfy_nodes ?? []
}

View File

@@ -51,7 +51,6 @@ const isLoading = ref(false)
const registryNodeDefs = shallowRef<ListComfyNodesResponse | null>(null)
const fetchNodeDefs = async () => {
getNodeDefs.cancel()
isLoading.value = true
const { id: packId } = nodePack

View File

@@ -1,37 +1,11 @@
<template>
<div class="w-full aspect-[7/3] overflow-hidden">
<!-- default banner show -->
<div v-if="showDefaultBanner" class="w-full h-full">
<img
:src="DEFAULT_BANNER"
alt="default banner"
class="w-full h-full object-cover"
/>
</div>
<!-- banner_url or icon show -->
<div v-else class="relative w-full h-full">
<!-- blur background -->
<div
v-if="imgSrc"
class="absolute inset-0 bg-cover bg-center bg-no-repeat opacity-30"
:style="{
backgroundImage: `url(${imgSrc})`,
filter: 'blur(10px)'
}"
></div>
<!-- image -->
<img
:src="isImageError ? DEFAULT_BANNER : imgSrc"
:alt="nodePack.name + ' banner'"
:class="
isImageError
? 'relative w-full h-full object-cover z-10'
: 'relative w-full h-full object-contain z-10'
"
@error="isImageError = true"
/>
</div>
</div>
<img
:src="isImageError ? DEFAULT_BANNER : imgSrc"
:alt="nodePack.name + ' banner'"
class="object-cover"
:style="{ width: cssWidth, height: cssHeight }"
@error="isImageError = true"
/>
</template>
<script setup lang="ts">
@@ -41,12 +15,27 @@ import { components } from '@/types/comfyRegistryTypes'
const DEFAULT_BANNER = '/assets/images/fallback-gradient-avatar.svg'
const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
const {
nodePack,
width = '100%',
height = '12rem'
} = defineProps<{
nodePack: components['schemas']['Node'] & { banner?: string } // Temporary measure until banner is in backend
width?: string
height?: string
}>()
const isImageError = ref(false)
const shouldShowFallback = computed(
() => !nodePack.banner || nodePack.banner.trim() === '' || isImageError.value
)
const imgSrc = computed(() =>
shouldShowFallback.value ? DEFAULT_BANNER : nodePack.banner
)
const showDefaultBanner = computed(() => !nodePack.banner_url && !nodePack.icon)
const imgSrc = computed(() => nodePack.banner_url || nodePack.icon)
const convertToCssValue = (value: string | number) =>
typeof value === 'number' ? `${value}rem` : value
const cssWidth = computed(() => convertToCssValue(width))
const cssHeight = computed(() => convertToCssValue(height))
</script>

View File

@@ -1,20 +1,15 @@
<template>
<Card
class="w-full h-full inline-flex flex-col justify-between items-start overflow-hidden rounded-lg shadow-elevation-3 dark-theme:bg-dark-elevation-2 transition-all duration-200"
class="w-full h-full inline-flex flex-col justify-between items-start overflow-hidden rounded-2xl shadow-elevation-3 dark-theme:bg-dark-elevation-2 transition-all duration-200"
:class="{
'selected-card': isSelected,
'outline outline-[6px] outline-[var(--p-primary-color)]': isSelected,
'opacity-60': isDisabled
}"
:pt="{
body: { class: 'p-0 flex flex-col w-full h-full rounded-lg gap-0' },
content: { class: 'flex-1 flex flex-col rounded-lg min-h-0' },
body: { class: 'p-0 flex flex-col w-full h-full rounded-2xl gap-0' },
content: { class: 'flex-1 flex flex-col rounded-2xl min-h-0' },
title: { class: 'w-full h-full rounded-t-lg cursor-pointer' },
footer: {
class: 'p-0 m-0 flex flex-col gap-0',
style: {
borderTop: isLightTheme ? '1px solid #f4f4f4' : '1px solid #2C2C2C'
}
}
footer: { class: 'p-0 m-0' }
}"
>
<template #title>
@@ -34,43 +29,64 @@
</div>
</template>
<template v-else>
<div class="pt-4 px-4 pb-3 w-full h-full">
<div class="flex flex-col gap-y-1 w-full h-full">
<span
class="text-sm font-bold truncate overflow-hidden text-ellipsis"
<div
class="self-stretch inline-flex flex-col justify-start items-start"
>
<div
class="px-4 py-3 inline-flex justify-start items-start cursor-pointer w-full"
>
<div
class="inline-flex flex-col justify-start items-start overflow-hidden gap-y-3 w-full"
>
{{ nodePack.name }}
</span>
<p
v-if="nodePack.description"
class="flex-1 text-muted text-xs font-medium break-words overflow-hidden min-h-12 line-clamp-3 my-0 leading-4 mb-1 overflow-hidden"
>
{{ nodePack.description }}
</p>
<div class="flex flex-col gap-y-2">
<div class="flex-1 flex items-center gap-2">
<div v-if="nodesCount" class="p-2 pl-0 text-xs">
{{ nodesCount }} {{ $t('g.nodes') }}
</div>
<PackVersionBadge
:node-pack="nodePack"
:is-selected="isSelected"
:fill="false"
/>
<span
class="text-base font-bold truncate overflow-hidden text-ellipsis"
>
{{ nodePack.name }}
</span>
<p
v-if="nodePack.description"
class="flex-1 justify-start text-muted text-sm font-medium break-words overflow-hidden min-h-12 line-clamp-3 my-0 leading-5"
>
{{ nodePack.description }}
</p>
<div class="flex flex-col gap-y-2">
<div
v-if="formattedLatestVersionDate"
class="px-2 py-1 flex justify-center items-center gap-1 text-xs text-muted font-medium"
class="self-stretch inline-flex justify-start items-center gap-1"
>
{{ formattedLatestVersionDate }}
<div
v-if="nodesCount"
class="pr-2 py-1 flex justify-center text-sm items-center gap-1"
>
<div
class="text-center justify-center font-medium leading-3"
>
{{ nodesCount }} {{ $t('g.nodes') }}
</div>
</div>
<div class="px-2 py-1 flex justify-center items-center gap-1">
<div
v-if="isUpdateAvailable"
class="w-4 h-4 relative overflow-hidden"
>
<i class="pi pi-arrow-circle-up text-blue-600" />
</div>
<PackVersionBadge :node-pack="nodePack" />
</div>
<div
v-if="formattedLatestVersionDate"
class="px-2 py-1 flex justify-center items-center gap-1 text-xs text-muted font-medium"
>
{{ formattedLatestVersionDate }}
</div>
</div>
<div class="flex">
<span
v-if="publisherName"
class="text-xs text-muted font-medium leading-3 max-w-40 truncate"
>
{{ publisherName }}
</span>
</div>
</div>
<div class="flex">
<span
v-if="publisherName"
class="text-xs text-muted font-medium leading-3 max-w-40 truncate"
>
{{ publisherName }}
</span>
</div>
</div>
</div>
@@ -78,6 +94,7 @@
</template>
</template>
<template #footer>
<ContentDivider :width="0.1" />
<PackCardFooter :node-pack="nodePack" />
</template>
</Card>
@@ -90,34 +107,27 @@ import ProgressSpinner from 'primevue/progressspinner'
import { computed, provide, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ContentDivider from '@/components/common/ContentDivider.vue'
import PackVersionBadge from '@/components/dialog/content/manager/PackVersionBadge.vue'
import PackBanner from '@/components/dialog/content/manager/packBanner/PackBanner.vue'
import PackCardFooter from '@/components/dialog/content/manager/packCard/PackCardFooter.vue'
import { usePackUpdateStatus } from '@/composables/nodePack/usePackUpdateStatus'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import {
IsInstallingKey,
type MergedNodePack,
type RegistryPack,
isMergedNodePack
} from '@/types/comfyManagerTypes'
import { IsInstallingKey } from '@/types/comfyManagerTypes'
import type { components } from '@/types/comfyRegistryTypes'
const { nodePack, isSelected = false } = defineProps<{
nodePack: MergedNodePack | RegistryPack
nodePack: components['schemas']['Node']
isSelected?: boolean
}>()
const { d } = useI18n()
const colorPaletteStore = useColorPaletteStore()
const isLightTheme = computed(
() => colorPaletteStore.completedActivePalette.light_theme
)
const isInstalling = ref(false)
provide(IsInstallingKey, isInstalling)
const { isPackInstalled, isPackEnabled } = useComfyManagerStore()
const { isUpdateAvailable } = usePackUpdateStatus(nodePack)
const isInstalled = computed(() => isPackInstalled(nodePack?.id))
const isDisabled = computed(
@@ -126,9 +136,9 @@ const isDisabled = computed(
whenever(isInstalled, () => (isInstalling.value = false))
const nodesCount = computed(() =>
isMergedNodePack(nodePack) ? nodePack.comfy_nodes?.length : undefined
)
// TODO: remove type assertion once comfy_nodes is added to node (pack) info type in backend
const nodesCount = computed(() => (nodePack as any).comfy_nodes?.length)
const publisherName = computed(() => {
if (!nodePack) return null
@@ -144,22 +154,3 @@ const formattedLatestVersionDate = computed(() => {
})
})
</script>
<style scoped>
.selected-card {
position: relative;
}
.selected-card::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border: 4px solid var(--p-primary-color);
border-radius: 0.5rem;
pointer-events: none;
z-index: 100;
}
</style>

View File

@@ -1,13 +1,12 @@
<template>
<div
class="min-h-12 flex justify-between items-center px-4 py-2 text-xs text-muted font-medium leading-3"
class="flex justify-between items-center px-4 py-2 text-xs text-muted font-medium leading-3"
>
<div v-if="nodePack.downloads" class="flex items-center gap-1.5">
<i class="pi pi-download text-muted"></i>
<span>{{ formattedDownloads }}</span>
</div>
<PackInstallButton v-if="!isInstalled" :node-packs="[nodePack]" />
<PackEnableToggle v-else :node-pack="nodePack" />
<PackInstallButton :node-packs="[nodePack]" />
</div>
</template>
@@ -15,18 +14,13 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import PackEnableToggle from '@/components/dialog/content/manager/button/PackEnableToggle.vue'
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import type { components } from '@/types/comfyRegistryTypes'
const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
}>()
const { isPackInstalled } = useComfyManagerStore()
const isInstalled = computed(() => isPackInstalled(nodePack?.id))
const { n } = useI18n()
const formattedDownloads = computed(() =>

View File

@@ -1,37 +1,28 @@
<template>
<div class="relative w-full p-6">
<div class="h-12 flex items-center gap-1 justify-between">
<div class="flex items-center w-5/12">
<AutoComplete
v-model.lazy="searchQuery"
:suggestions="suggestions || []"
:placeholder="$t('manager.searchPlaceholder')"
:complete-on-focus="false"
:delay="8"
option-label="query"
class="w-full"
:pt="{
pcInputText: {
root: {
autofocus: true,
class: 'w-full rounded-2xl'
}
},
loader: {
style: 'display: none'
<div class="flex items-center w-full">
<AutoComplete
v-model.lazy="searchQuery"
:suggestions="suggestions || []"
:placeholder="$t('manager.searchPlaceholder')"
:complete-on-focus="false"
:delay="8"
option-label="query"
class="w-full"
:pt="{
pcInputText: {
root: {
autofocus: true,
class: 'w-5/12 rounded-2xl'
}
}"
:show-empty-message="false"
@complete="stubTrue"
@option-select="onOptionSelect"
/>
</div>
<PackInstallButton
v-if="isMissingTab && missingNodePacks.length > 0"
variant="black"
:disabled="isLoading || !!error"
:node-packs="missingNodePacks"
:label="$t('manager.installAllMissingNodes')"
},
loader: {
style: 'display: none'
}
}"
:show-empty-message="false"
@complete="stubTrue"
@option-select="onOptionSelect"
/>
</div>
<div class="flex mt-3 text-sm">
@@ -43,7 +34,7 @@
/>
<SearchFilterDropdown
v-model:modelValue="sortField"
:options="availableSortOptions"
:options="sortOptions"
:label="$t('g.sort')"
/>
</div>
@@ -64,55 +55,43 @@ import AutoComplete, {
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
import SearchFilterDropdown from '@/components/dialog/content/manager/registrySearchBar/SearchFilterDropdown.vue'
import { useMissingNodes } from '@/composables/nodePack/useMissingNodes'
import type { NodesIndexSuggestion } from '@/services/algoliaSearchService'
import {
type SearchOption,
SortableAlgoliaField
} from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
import type {
QuerySuggestion,
SearchMode,
SortableField
} from '@/types/searchServiceTypes'
const { searchResults, sortOptions } = defineProps<{
const { searchResults } = defineProps<{
searchResults?: components['schemas']['Node'][]
suggestions?: QuerySuggestion[]
sortOptions?: SortableField[]
isMissingTab?: boolean
suggestions?: NodesIndexSuggestion[]
}>()
const searchQuery = defineModel<string>('searchQuery')
const searchMode = defineModel<SearchMode>('searchMode', { default: 'packs' })
const sortField = defineModel<string>('sortField', {
const searchMode = defineModel<string>('searchMode', { default: 'packs' })
const sortField = defineModel<SortableAlgoliaField>('sortField', {
default: SortableAlgoliaField.Downloads
})
const { t } = useI18n()
// Get missing node packs from workflow with loading and error states
const { missingNodePacks, isLoading, error } = useMissingNodes()
const hasResults = computed(
() => searchQuery.value?.trim() && searchResults?.length
)
const availableSortOptions = computed<SearchOption<string>[]>(() => {
if (!sortOptions) return []
return sortOptions.map((field) => ({
id: field.id,
label: field.label
}))
})
const filterOptions: SearchOption<SearchMode>[] = [
const sortOptions: SearchOption<SortableAlgoliaField>[] = [
{ id: SortableAlgoliaField.Downloads, label: t('manager.sort.downloads') },
{ id: SortableAlgoliaField.Created, label: t('manager.sort.created') },
{ id: SortableAlgoliaField.Updated, label: t('manager.sort.updated') },
{ id: SortableAlgoliaField.Publisher, label: t('manager.sort.publisher') },
{ id: SortableAlgoliaField.Name, label: t('g.name') }
]
const filterOptions: SearchOption<string>[] = [
{ id: 'packs', label: t('manager.filter.nodePack') },
{ id: 'nodes', label: t('g.nodes') }
]
// When a dropdown query suggestion is selected, update the search query
const onOptionSelect = (event: AutoCompleteOptionSelectEvent) => {
searchQuery.value = event.value.query
}

View File

@@ -37,7 +37,8 @@
<SelectionOverlay v-if="selectionToolboxEnabled">
<SelectionToolbox />
</SelectionOverlay>
<DomWidgets />
<DomWidgets v-if="!vueNodeRenderingEnabled" />
<VueNodeOverlay v-if="vueNodeRenderingEnabled" />
</template>
<SubgraphBreadcrumb />
</template>
@@ -56,11 +57,13 @@ import NodeTooltip from '@/components/graph/NodeTooltip.vue'
import SelectionOverlay from '@/components/graph/SelectionOverlay.vue'
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
import TitleEditor from '@/components/graph/TitleEditor.vue'
import VueNodeOverlay from '@/components/graph/nodes/VueNodeOverlay.vue'
import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vue'
import SideToolbar from '@/components/sidebar/SideToolbar.vue'
import SecondRowWorkflowTabs from '@/components/topbar/SecondRowWorkflowTabs.vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { useNodeBadge } from '@/composables/node/useNodeBadge'
import { useTestPhantomNodes } from '@/composables/nodeRendering/useTestPhantomNodes'
import { useCanvasDrop } from '@/composables/useCanvasDrop'
import { useContextMenuTranslation } from '@/composables/useContextMenuTranslation'
import { useCopy } from '@/composables/useCopy'
@@ -110,6 +113,19 @@ const tooltipEnabled = computed(() => settingStore.get('Comfy.EnableTooltips'))
const selectionToolboxEnabled = computed(() =>
settingStore.get('Comfy.Canvas.SelectionToolbox')
)
// Temporarily enable Vue node rendering for testing
const vueNodeRenderingEnabled = computed(() => true)
// Use test helper for automatic phantom mode enabling
useTestPhantomNodes()
// Debug logging
watchEffect(() => {
console.log(
'🖼 GraphCanvas: Vue node rendering enabled:',
vueNodeRenderingEnabled.value
)
})
watchEffect(() => {
nodeDefStore.showDeprecated = settingStore.get('Comfy.Node.ShowDeprecated')
@@ -277,6 +293,7 @@ onMounted(async () => {
useWorkflowAutoSave()
comfyApp.vueAppReady = true
console.log('🖼 GraphCanvas: comfyApp.vueAppReady:', comfyApp.vueAppReady)
workspaceStore.spinner = true
// ChangeTracker needs to be initialized before setup, as it will overwrite
@@ -297,7 +314,6 @@ onMounted(async () => {
throw error
}
}
CORE_SETTINGS.forEach((setting) => {
settingStore.addSetting(setting)
})
@@ -311,6 +327,7 @@ onMounted(async () => {
window.graph = comfyApp.graph
comfyAppReady.value = true
console.log('🖼 GraphCanvas: comfyAppReady:', comfyAppReady.value)
comfyApp.canvas.onSelectionChange = useChainCallback(
comfyApp.canvas.onSelectionChange,

View File

@@ -0,0 +1,441 @@
<template>
<div
ref="nodeRef"
class="_sb_node_preview vue-node"
:style="nodeStyle"
@mousedown="onMouseDown"
@contextmenu="onContextMenu"
>
<div class="_sb_table">
<!-- Node header - exactly like NodePreview -->
<div
class="node_header"
:style="{
backgroundColor: litegraphColors.NODE_DEFAULT_COLOR as string,
color: litegraphColors.NODE_TITLE_COLOR as string
}"
>
<div class="_sb_dot headdot" />
{{ (node as any).title }}
</div>
<!-- Node slot I/O - using flexbox for proper positioning -->
<div
v-for="[slotInput, slotOutput] in slotPairs"
:key="((slotInput as any)?.name || '') + ((slotOutput as any)?.name || '')"
class="slot-row-flex"
>
<!-- Left side input slot -->
<div class="slot-left" v-if="slotInput">
<div :class="['_sb_dot', (slotInput as any)?.type]" />
<span class="slot-text">{{ (slotInput as any)?.name }}</span>
</div>
<!-- Right side output slot -->
<div
class="slot-right"
v-if="slotOutput"
:style="{
color: litegraphColors.NODE_TEXT_COLOR as string
}"
>
<span class="slot-text">{{ (slotOutput as any)?.name }}</span>
<div :class="['_sb_dot', (slotOutput as any)?.type]" />
</div>
</div>
<!-- Widgets using existing widget components -->
<VueNodeBody
:widgets="nodeWidgets"
:node="node"
@widget-change="onWidgetChange"
/>
</div>
</div>
</template>
<script setup lang="ts">
import _ from 'lodash'
import { computed, ref } from 'vue'
import { useCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
import type { LGraphNode } from '@comfyorg/litegraph'
import type { NodeInteractionEvent } from '@/composables/nodeRendering/useNodeInteractionProxy'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useCanvasStore } from '@/stores/graphStore'
import VueNodeBody from './VueNodeBody.vue'
interface VueNodeProps {
node: LGraphNode
selected: boolean
executing: boolean
canvasScale: number
canvasOffset: { x: number, y: number }
updateTrigger?: number // Add update trigger to force reactivity
}
const props = defineProps<VueNodeProps>()
const emit = defineEmits<{
interaction: [event: NodeInteractionEvent]
}>()
const nodeRef = ref<HTMLElement>()
const colorPaletteStore = useColorPaletteStore()
const canvasStore = useCanvasStore()
const litegraphColors = computed(
() => colorPaletteStore.completedActivePalette.colors.litegraph_base
)
// Get canvas position conversion utilities
const canvasPositionConversion = computed(() => {
const lgCanvas = canvasStore.canvas
if (!lgCanvas?.canvas) return null
return useCanvasPositionConversion(lgCanvas.canvas, lgCanvas)
})
// Slot pairs - filter out inputs that have corresponding widgets
const slotPairs = computed(() => {
const allInputs = (props.node as any).inputs || []
const outputs = (props.node as any).outputs || []
// Get widget names to filter out inputs that have widgets
const nodeWidgetNames = new Set((props.node as any).widgets?.map((w: any) => w.name) || [])
// Only show inputs that DON'T have corresponding widgets
const slotInputs = allInputs.filter((input: any) => !nodeWidgetNames.has(input.name))
return _.zip(slotInputs, outputs)
})
// Extract widgets from the node
const nodeWidgets = computed(() => {
return (props.node as any).widgets || []
})
// Dragging will be handled by LiteGraph's phantom node
// Node styling based on position and state - using proper canvas position conversion
const nodeStyle = computed(() => {
try {
// Access update trigger to make this reactive to graph changes
props.updateTrigger
const positionConverter = canvasPositionConversion.value
if (!positionConverter) {
console.warn('🚨 VueNode: No position converter available')
return {
position: 'fixed' as const,
left: '100px',
top: '100px',
width: '200px',
minHeight: '100px',
backgroundColor: '#ff0000',
border: '2px solid #ffffff',
zIndex: 999
}
}
// Get node position and size in graph space
const nodeAny = props.node as any
const nodePos: [number, number] = [
nodeAny.pos?.[0] ?? 0,
nodeAny.pos?.[1] ?? 0
]
const nodeWidth = nodeAny.size?.[0] ?? 200
const nodeHeight = nodeAny.size?.[1] ?? 100
// Convert from canvas coordinates to client coordinates (absolute positioning)
const [clientX, clientY] = positionConverter.canvasPosToClientPos(nodePos)
// Get the current scale from the canvas
const lgCanvas = canvasStore.canvas
const scale = lgCanvas?.ds?.scale ?? 1
// Use original dimensions for positioning, apply scale via CSS transform
const scaledWidth = nodeWidth
const scaledHeight = nodeHeight
// Validate coordinates
if (!isFinite(clientX) || !isFinite(clientY) || scaledWidth <= 0 || scaledHeight <= 0) {
return {
position: 'fixed' as const,
left: '100px',
top: '100px',
width: '200px',
minHeight: '100px',
backgroundColor: '#ff0000',
border: '2px solid #ffffff',
zIndex: 999
}
}
// Use colors from palette for authentic LiteGraph appearance
const nodeAnyForColors = props.node as any
const bgColor = nodeAnyForColors.bgcolor || litegraphColors.value?.NODE_DEFAULT_BGCOLOR || '#353535'
const borderColor = props.selected
? litegraphColors.value?.NODE_BOX_OUTLINE_COLOR || '#FFF'
: (nodeAnyForColors.boxcolor || litegraphColors.value?.NODE_DEFAULT_BOXCOLOR || '#666')
return {
position: 'fixed' as const, // Use fixed positioning like other overlays
left: `${clientX}px`,
top: `${clientY}px`,
minWidth: `${scaledWidth}px`,
width: 'auto', // Allow width to expand for content
minHeight: `${scaledHeight}px`,
transform: `scale(${scale})`,
transformOrigin: '0 0', // Scale from top-left corner
zIndex: props.selected ? 10 : 1,
backgroundColor: bgColor,
borderColor: borderColor,
borderWidth: props.selected ? '2px' : '1px',
borderStyle: 'solid',
fontSize: `${litegraphColors.value?.NODE_TEXT_SIZE || 14}px`,
}
} catch (error) {
return {
position: 'fixed' as const,
left: '100px',
top: '100px',
width: '200px',
minHeight: '100px',
backgroundColor: '#ff0000',
border: '2px solid #ffffff',
zIndex: 999
}
}
})
// Note: nodeClasses could be used for conditional CSS classes if needed
// Event handlers
const onMouseDown = (event: MouseEvent) => {
// Check if the click is on a widget element
const target = event.target as HTMLElement
const isOnWidget = target.closest('.widget-content') !== null
// If clicking on a widget, don't emit the mouse down event for dragging
if (isOnWidget) {
return
}
emit('interaction', {
type: 'mousedown',
nodeId: String((props.node as any).id),
originalEvent: event
})
}
const onContextMenu = (event: MouseEvent) => {
emit('interaction', {
type: 'contextmenu',
nodeId: String((props.node as any).id),
originalEvent: event
})
}
// Note: onSlotInteraction and onTitleEdit available for future use
const onWidgetChange = (widgetIndex: number, value: any) => {
const nodeAny = props.node as any
if (nodeAny.widgets?.[widgetIndex]) {
nodeAny.widgets[widgetIndex].value = value
}
}
</script>
<style scoped>
/* Copy ALL styles from NodePreview.vue exactly */
.slot_row {
padding: 2px;
}
/* Original N-Sidebar styles */
._sb_dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: grey;
}
.node_header {
line-height: 1;
padding: 8px 13px 7px;
margin-bottom: 5px;
font-size: 15px;
text-wrap: nowrap;
overflow: hidden;
display: flex;
align-items: center;
}
.headdot {
width: 10px;
height: 10px;
float: inline-start;
margin-right: 8px;
}
.IMAGE {
background-color: #64b5f6;
}
.VAE {
background-color: #ff6e6e;
}
.LATENT {
background-color: #ff9cf9;
}
.MASK {
background-color: #81c784;
}
.CONDITIONING {
background-color: #ffa931;
}
.CLIP {
background-color: #ffd500;
}
.MODEL {
background-color: #b39ddb;
}
.CONTROL_NET {
background-color: #a5d6a7;
}
._sb_node_preview {
background-color: var(--comfy-menu-bg);
font-family: 'Open Sans', sans-serif;
font-size: small;
color: var(--descrip-text);
border: 1px solid var(--descrip-text);
min-width: 200px;
width: max-content; /* Allow expansion for wide content */
height: fit-content;
z-index: 9999;
border-radius: 12px;
overflow: visible; /* Allow content to be visible outside bounds */
font-size: 12px;
padding-bottom: 10px;
}
._sb_node_preview ._sb_description {
margin: 10px;
padding: 6px;
background: var(--border-color);
border-radius: 5px;
font-style: italic;
font-weight: 500;
font-size: 0.9rem;
word-break: break-word;
}
._sb_table {
display: grid;
grid-column-gap: 10px;
/* Spazio tra le colonne */
width: 100%;
/* Imposta la larghezza della tabella al 100% del contenitore */
}
._sb_row {
display: grid;
grid-template-columns: 10px 1fr 1fr 1fr 10px;
grid-column-gap: 10px;
align-items: center;
padding-left: 9px;
padding-right: 9px;
}
._sb_row_string {
grid-template-columns: 10px 1fr 1fr 10fr 1fr;
}
._sb_col {
border: 0 solid #000;
display: flex;
align-items: flex-end;
flex-direction: row-reverse;
flex-wrap: nowrap;
align-content: flex-start;
justify-content: flex-end;
}
._sb_inherit {
display: inherit;
}
._long_field {
background: var(--bg-color);
border: 2px solid var(--border-color);
margin: 5px 5px 0 5px;
border-radius: 10px;
line-height: 1.7;
text-wrap: nowrap;
}
._sb_arrow {
color: var(--fg-color);
}
._sb_preview_badge {
text-align: center;
background: var(--comfy-input-bg);
font-weight: bold;
color: var(--error-text);
}
/* Additional styles for Vue node functionality */
.vue-node {
position: fixed; /* Use fixed positioning for proper overlay behavior */
pointer-events: none; /* Let mouse events pass through to phantom nodes */
}
.vue-node .widget-content {
pointer-events: auto; /* Enable interaction with widgets only */
}
.vue-node:hover {
z-index: 10000; /* Bring to front on hover */
}
.slot-text {
font-size: 10px; /* Smaller font for slot labels */
}
/* New flexbox slot layout */
.slot-row-flex {
position: relative;
min-height: 20px;
padding: 2px 0;
}
.slot-left {
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
gap: 8px;
z-index: 10;
}
.slot-right {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
gap: 8px;
z-index: 10;
}
</style>

View File

@@ -0,0 +1,79 @@
<template>
<!-- Render Vue component widgets only -->
<div>
<div
v-for="widget in vueComponentWidgets"
:key="`vue-widget-${widget.name}`"
class="_sb_row _long_field"
>
<div class="_sb_col widget-content">
<component
:is="widget.component"
:model-value="widget.value"
:widget="widget"
v-bind="widget.props"
v-if="widgetsShouldShow"
@update:model-value="updateWidgetValue(widget, $event)"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { LGraphNode } from '@comfyorg/litegraph'
import type { BaseWidget } from '@comfyorg/litegraph'
import { computed, ref } from 'vue'
import { app } from '@/scripts/app'
import { isComponentWidget } from '@/scripts/domWidget'
const widgetsShouldShow = ref(true)
app.api.addEventListener('graphChanged', () => {
widgetsShouldShow.value = app.canvas.ds.scale > .55
})
console.log('app.canvas.ds.scale', app.canvas.ds.scale)
interface VueNodeBodyProps {
widgets: BaseWidget[]
node: LGraphNode
}
const props = defineProps<VueNodeBodyProps>()
// Note: emit available for future widget change events if needed
// Get Vue component widgets only
const vueComponentWidgets = computed(() => {
return props.widgets.filter((widget: any) => isComponentWidget(widget))
})
// Update widget value when component emits changes
const updateWidgetValue = (widget: any, value: any) => {
if (widget.options?.setValue) {
widget.options.setValue(value)
}
// Also trigger the widget's callback if it exists
if (widget.callback) {
widget.callback(value)
}
}
// Note: onWidgetChange available for future use if needed
</script>
<style scoped>
.vue-node-body {
min-height: 2rem;
}
.widget-container {
/* Widget containers use flexbox for natural sizing */
}
.legacy-widget {
/* Styling for non-Vue widgets */
border: 1px dashed #ccc;
}
</style>

View File

@@ -0,0 +1,142 @@
<template>
<div
class="vue-node-header flex items-center justify-between px-3 py-2"
:style="headerStyle"
>
<div class="flex items-center gap-2 flex-grow">
<!-- Collapse dot (like original LiteGraph) -->
<div
class="w-2.5 h-2.5 rounded-full cursor-pointer"
:style="{ backgroundColor: dotColor }"
@click="toggleCollapse"
/>
<!-- Editable title -->
<EditableText
v-model="editableTitle"
class="font-medium flex-grow"
:style="titleStyle"
@update:model-value="onTitleUpdate"
/>
</div>
<!-- Node controls (minimized to match LiteGraph style) -->
<div class="flex items-center gap-1">
<!-- Pin indicator (small, unobtrusive) -->
<div
v-if="node.pinned"
class="w-2 h-2 rounded-full"
:style="{ backgroundColor: litegraphColors.NODE_TITLE_COLOR }"
title="Pinned"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import type { LGraphNode } from '@comfyorg/litegraph'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import EditableText from '@/components/common/EditableText.vue'
interface VueNodeHeaderProps {
node: LGraphNode
title: string
nodeType?: string
}
const props = defineProps<VueNodeHeaderProps>()
const emit = defineEmits<{
'title-edit': [title: string]
}>()
const colorPaletteStore = useColorPaletteStore()
const litegraphColors = computed(
() => colorPaletteStore.completedActivePalette.colors.litegraph_base
)
// Local editable title
const editableTitle = ref(props.title)
// Watch for external title changes
watch(() => props.title, (newTitle) => {
editableTitle.value = newTitle
})
// Header styling to match LiteGraph
const headerStyle = computed(() => {
try {
const headerColor = props.node.color || litegraphColors.value?.NODE_DEFAULT_COLOR || '#333'
return {
backgroundColor: headerColor,
borderTopLeftRadius: '4px',
borderTopRightRadius: '4px',
fontSize: `${litegraphColors.value?.NODE_TEXT_SIZE || 14}px`,
}
} catch (error) {
console.warn('⚠️ VueNodeHeader: Error in headerStyle:', error)
return {
backgroundColor: '#333',
borderTopLeftRadius: '4px',
borderTopRightRadius: '4px',
fontSize: '14px',
}
}
})
// Title styling to match LiteGraph
const titleStyle = computed(() => {
try {
const selected = (props.node as any).selected || false
const titleColor = selected
? litegraphColors.value?.NODE_SELECTED_TITLE_COLOR || '#FFF'
: litegraphColors.value?.NODE_TITLE_COLOR || '#999'
return {
color: titleColor,
fontSize: `${litegraphColors.value?.NODE_TEXT_SIZE || 14}px`,
fontWeight: 'normal',
}
} catch (error) {
console.warn('⚠️ VueNodeHeader: Error in titleStyle:', error)
return {
color: '#999',
fontSize: '14px',
fontWeight: 'normal',
}
}
})
// Dot color (collapse indicator)
const dotColor = computed(() => {
try {
return litegraphColors.value?.NODE_TITLE_COLOR || '#999'
} catch (error) {
console.warn('⚠️ VueNodeHeader: Error in dotColor:', error)
return '#999'
}
})
const onTitleUpdate = (newTitle: string) => {
emit('title-edit', newTitle)
}
const toggleCollapse = () => {
// Use node collapse method instead of setting property directly
if (props.node.collapse) {
props.node.collapse()
} else {
// Fallback to manual property setting if method doesn't exist
;(props.node as any).collapsed = !props.node.collapsed
}
// Trigger canvas redraw
props.node.setDirtyCanvas?.(true, true)
}
</script>
<style scoped>
.rotate-180 {
transform: rotate(180deg);
}
</style>

View File

@@ -0,0 +1,144 @@
<template>
<div
class="vue-node-overlay absolute inset-0 pointer-events-none overflow-hidden"
>
<VueNode
v-for="node in phantomNodes"
:key="node.id"
:node="node"
:selected="isNodeSelected(node.id)"
:executing="isNodeExecuting(node.id)"
:canvas-scale="canvasScale"
:canvas-offset="canvasOffset"
:update-trigger="graphUpdateTrigger"
@interaction="handleNodeInteraction"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watchEffect } from 'vue'
import { useNodeInteractionProxy } from '@/composables/nodeRendering/useNodeInteractionProxy'
import { api } from '@/scripts/api'
import { isComponentWidget, isDOMWidget } from '@/scripts/domWidget'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useCanvasStore } from '@/stores/graphStore'
import VueNode from './VueNode.vue'
const { handleNodeInteraction } = useNodeInteractionProxy()
const canvasStore = useCanvasStore()
const executionStore = useExecutionStore()
// Reactive trigger for graph changes
const graphUpdateTrigger = ref(0)
// Force update phantom nodes when graph changes
const forceUpdate = () => {
graphUpdateTrigger.value++
}
// Get phantom nodes directly from canvas with reactive trigger
const phantomNodes = computed(() => {
// Access reactive trigger to ensure computed re-runs on graph changes
graphUpdateTrigger.value
if (!canvasStore.canvas?.graph) {
return []
}
const allNodes = canvasStore.canvas.graph._nodes
const phantomNodes = allNodes.filter(
(node: any) => node.phantom_mode === true
)
// Register widgets for phantom nodes if not already registered
const domWidgetStore = useDomWidgetStore()
phantomNodes.forEach((node: any) => {
if (node.widgets) {
node.widgets.forEach((widget: any) => {
// Check if it's a DOM widget that needs registration
if (
(isDOMWidget(widget) || isComponentWidget(widget)) &&
widget.id &&
!domWidgetStore.widgetStates.has(widget.id)
) {
domWidgetStore.registerWidget(widget)
}
})
}
})
return phantomNodes
})
// Simple animation frame updates - always running for smooth dragging
let rafId: number | null = null
const startFrameUpdates = () => {
const updateEveryFrame = () => {
forceUpdate()
rafId = requestAnimationFrame(updateEveryFrame)
}
updateEveryFrame()
}
const stopFrameUpdates = () => {
if (rafId) {
cancelAnimationFrame(rafId)
rafId = null
}
}
// Listen for graph changes
onMounted(() => {
// Listen to API events for graph changes (now includes ds changes)
api.addEventListener('graphChanged', forceUpdate)
// Start continuous frame updates for smooth dragging
startFrameUpdates()
// Initial update
forceUpdate()
})
onUnmounted(() => {
api.removeEventListener('graphChanged', forceUpdate)
stopFrameUpdates()
})
// Get canvas transform directly from canvas
const canvasScale = computed(() => {
return canvasStore.canvas?.ds?.scale || 1
})
const canvasOffset = computed(() => {
const canvas = canvasStore.canvas
return {
x: canvas?.ds?.offset?.[0] || 0,
y: canvas?.ds?.offset?.[1] || 0
}
})
// Check if node is selected
const isNodeSelected = (nodeId: string) => {
return canvasStore.selectedItems.some(
(item: any) => item.id === Number(nodeId)
)
}
// Check if node is executing
const isNodeExecuting = (nodeId: string) => {
return executionStore.executingNodeId === Number(nodeId)
}
</script>
<style scoped>
.vue-node-overlay {
/* Ensure overlay doesn't interfere with canvas interactions */
z-index: 1;
}
</style>

View File

@@ -0,0 +1,143 @@
<template>
<div class="vue-node-slots">
<!-- Input slots -->
<div v-if="inputs.length > 0" class="inputs mb-2">
<div
v-for="(input, index) in inputs"
:key="`input-${index}`"
class="input-slot flex items-center gap-2 px-2 py-1 hover:bg-gray-50 dark-theme:hover:bg-gray-700"
@click="onSlotClick(index, $event, 'input')"
>
<!-- Input connection point -->
<div
class="slot-connector w-3 h-3 rounded-full border-2 border-gray-400 bg-white dark-theme:bg-gray-800"
:class="getSlotColor(input.type, 'input')"
></div>
<!-- Input label -->
<span class="text-sm text-gray-700 dark-theme:text-gray-300 flex-grow">
{{ input.name || `Input ${index}` }}
</span>
<!-- Input type badge -->
<span
v-if="input.type && input.type !== '*'"
class="text-xs px-1 py-0.5 bg-gray-200 dark-theme:bg-gray-600 text-gray-600 dark-theme:text-gray-400 rounded"
>
{{ input.type }}
</span>
</div>
</div>
<!-- Output slots -->
<div v-if="outputs.length > 0" class="outputs">
<div
v-for="(output, index) in outputs"
:key="`output-${index}`"
class="output-slot flex items-center gap-2 px-2 py-1 hover:bg-gray-50 dark-theme:hover:bg-gray-700"
@click="onSlotClick(index, $event, 'output')"
>
<!-- Output type badge -->
<span
v-if="output.type && output.type !== '*'"
class="text-xs px-1 py-0.5 bg-gray-200 dark-theme:bg-gray-600 text-gray-600 dark-theme:text-gray-400 rounded"
>
{{ output.type }}
</span>
<!-- Output label -->
<span class="text-sm text-gray-700 dark-theme:text-gray-300 flex-grow text-right">
{{ output.name || `Output ${index}` }}
</span>
<!-- Output connection point -->
<div
class="slot-connector w-3 h-3 rounded-full border-2 border-gray-400 bg-white dark-theme:bg-gray-800"
:class="getSlotColor(output.type, 'output')"
></div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { INodeInputSlot, INodeOutputSlot } from '@comfyorg/litegraph'
interface VueNodeSlotsProps {
inputs: INodeInputSlot[]
outputs: INodeOutputSlot[]
}
const props = defineProps<VueNodeSlotsProps>()
const emit = defineEmits<{
'slot-click': [slotIndex: number, event: MouseEvent]
}>()
// Color mapping for different slot types
const getSlotColor = (type: string | number | undefined, _direction: 'input' | 'output') => {
if (!type || type === '*') {
return 'border-gray-400'
}
// Convert type to string for lookup
const typeStr = String(type)
// Map common ComfyUI types to colors
const typeColors: Record<string, string> = {
'IMAGE': 'border-green-500 bg-green-100 dark-theme:bg-green-900',
'LATENT': 'border-purple-500 bg-purple-100 dark-theme:bg-purple-900',
'MODEL': 'border-blue-500 bg-blue-100 dark-theme:bg-blue-900',
'CONDITIONING': 'border-yellow-500 bg-yellow-100 dark-theme:bg-yellow-900',
'VAE': 'border-red-500 bg-red-100 dark-theme:bg-red-900',
'CLIP': 'border-orange-500 bg-orange-100 dark-theme:bg-orange-900',
'STRING': 'border-gray-500 bg-gray-100 dark-theme:bg-gray-900',
'INT': 'border-indigo-500 bg-indigo-100 dark-theme:bg-indigo-900',
'FLOAT': 'border-pink-500 bg-pink-100 dark-theme:bg-pink-900'
}
return typeColors[typeStr.toUpperCase()] || 'border-gray-400'
}
const onSlotClick = (index: number, event: MouseEvent, slotType: 'input' | 'output') => {
event.stopPropagation()
// Calculate the actual slot index based on type
// For outputs, we need to add the input count to get the correct index
const slotIndex = slotType === 'output' ? props.inputs.length + index : index
emit('slot-click', slotIndex, event)
}
</script>
<style scoped>
.vue-node-slots {
font-size: 0.875rem;
}
.slot-connector {
transition: all 0.2s ease;
cursor: pointer;
}
.slot-connector:hover {
transform: scale(1.2);
border-width: 3px;
}
.input-slot {
border-left: 3px solid transparent;
}
.output-slot {
border-right: 3px solid transparent;
}
.input-slot:hover {
border-left-color: #3b82f6;
}
.output-slot:hover {
border-right-color: #3b82f6;
}
</style>

View File

@@ -0,0 +1,204 @@
<template>
<div class="badged-number-input relative w-full">
<InputGroup class="w-full rounded-lg border-none px-0.5">
<!-- State badge prefix -->
<InputGroupAddon
v-if="badgeState !== 'normal'"
class="rounded-l-lg bg-[#222222] border-[#222222] shadow-none border-r-[#A0A1A2] rounded-r-none"
>
<i
:class="badgeIcon + ' text-xs'"
:title="badgeTooltip"
:style="{ color: badgeColor }"
></i>
</InputGroupAddon>
<!-- Number input for non-slider mode -->
<InputNumber
v-if="!isSliderMode"
v-model="numericValue"
:min="min"
:max="max"
:step="step"
:placeholder="placeholder"
:disabled="disabled"
size="small"
:pt="{
pcInputText: {
root: {
class: 'bg-[#222222] text-xs shadow-none rounded-none !border-0'
}
},
incrementButton: {
class: 'text-xs shadow-none bg-[#222222] rounded-l-none !border-0'
},
decrementButton: {
class: {
'text-xs shadow-none bg-[#222222] rounded-r-none !border-0':
badgeState === 'normal',
'text-xs shadow-none bg-[#222222] rounded-none !border-0':
badgeState !== 'normal'
}
}
}"
class="flex-1 rounded-none"
show-buttons
button-layout="horizontal"
:increment-button-icon="'pi pi-plus'"
:decrement-button-icon="'pi pi-minus'"
/>
<!-- Slider mode -->
<div
v-else
:class="{
'rounded-r-lg': badgeState !== 'normal',
'rounded-lg': badgeState === 'normal'
}"
class="flex-1 flex items-center gap-2 px-1 bg-surface-0 border border-surface-300"
>
<Slider
v-model="numericValue"
:min="min"
:max="max"
:step="step"
:disabled="disabled"
class="flex-1"
/>
<InputNumber
v-model="numericValue"
:min="min"
:max="max"
:step="step"
:disabled="disabled"
class="w-16 rounded-md"
:pt="{
pcInputText: {
root: {
class: 'bg-[#222222] text-xs shadow-none border-[#222222]'
}
}
}"
:show-buttons="false"
size="small"
/>
</div>
</InputGroup>
</div>
</template>
<script setup lang="ts">
import InputGroup from 'primevue/inputgroup'
import InputGroupAddon from 'primevue/inputgroupaddon'
import InputNumber from 'primevue/inputnumber'
import Slider from 'primevue/slider'
import { computed } from 'vue'
import type { ComponentWidget } from '@/scripts/domWidget'
type BadgeState = 'normal' | 'random' | 'lock' | 'increment' | 'decrement'
const modelValue = defineModel<string>({ required: true })
const {
widget,
badgeState = 'normal',
disabled = false
} = defineProps<{
widget: ComponentWidget<string>
badgeState?: BadgeState
disabled?: boolean
}>()
// Convert string model value to/from number for the InputNumber component
const numericValue = computed({
get: () => parseFloat(modelValue.value) || 0,
set: (value: number) => {
modelValue.value = value.toString()
}
})
// Extract options from input spec
const inputSpec = widget.inputSpec
const min = (inputSpec as any).min ?? 0
const max = (inputSpec as any).max ?? 100
const step = (inputSpec as any).step ?? 1
const placeholder = (inputSpec as any).placeholder ?? 'Enter number'
// Check if slider mode should be enabled
const isSliderMode = computed(() => {
console.log('inputSpec', inputSpec)
return (inputSpec as any).slider === true
})
// Badge configuration
const badgeIcon = computed(() => {
switch (badgeState) {
case 'random':
return 'pi pi-refresh'
case 'lock':
return 'pi pi-lock'
case 'increment':
return 'pi pi-arrow-up'
case 'decrement':
return 'pi pi-arrow-down'
default:
return ''
}
})
const badgeColor = computed(() => {
switch (badgeState) {
case 'random':
return 'var(--p-primary-color)'
case 'lock':
return 'var(--p-orange-500)'
case 'increment':
return 'var(--p-green-500)'
case 'decrement':
return 'var(--p-red-500)'
default:
return 'var(--p-text-color)'
}
})
const badgeTooltip = computed(() => {
switch (badgeState) {
case 'random':
return 'Random mode: Value randomizes after each run'
case 'lock':
return 'Locked: Value never changes'
case 'increment':
return 'Auto-increment: Value increases after each run'
case 'decrement':
return 'Auto-decrement: Value decreases after each run'
default:
return ''
}
})
</script>
<style scoped>
.badged-number-input {
padding: 4px;
}
/* Ensure proper styling for the input group */
:deep(.p-inputgroup) {
border-radius: 0.5rem;
}
:deep(.p-inputnumber) {
flex: 1;
}
:deep(.p-inputnumber-input) {
border-radius: inherit;
}
/* Badge styling */
:deep(.p-badge) {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
}
</style>

View File

@@ -0,0 +1,545 @@
<template>
<div class="color-picker-widget">
<div
:style="{ width: widgetWidth }"
class="flex items-center gap-2 p-2 rounded-lg border border-surface-300 bg-surface-0 w-full"
>
<!-- Color picker preview and popup trigger -->
<div class="relative">
<div
:style="{ backgroundColor: parsedColor.hex }"
class="w-4 h-4 rounded border-2 border-surface-400 cursor-pointer hover:border-surface-500 transition-colors"
title="Click to edit color"
@click="toggleColorPicker"
/>
<!-- Color picker popover -->
<Popover ref="colorPickerPopover" class="!p-0">
<ColorPicker
v-model="colorValue"
format="hex"
class="border-none"
@update:model-value="updateColorFromPicker"
/>
</Popover>
</div>
<!-- Color component inputs -->
<div class="flex gap-5">
<InputNumber
v-for="component in colorComponents"
:key="component.name"
v-model="component.value"
:min="component.min"
:max="component.max"
:step="component.step"
:placeholder="component.name"
class="flex-1 text-xs max-w-8"
:pt="{
pcInputText: {
root: {
class:
'max-w-12 bg-[#222222] text-xs shadow-none border-[#222222]'
}
}
}"
:show-buttons="false"
size="small"
@update:model-value="updateColorFromComponents"
/>
</div>
<!-- Format dropdown -->
<Select
v-model="currentFormat"
:options="colorFormats"
option-label="label"
option-value="value"
class="w-24 ml-3 bg-[#222222] text-xs shadow-none border-none p-0"
size="small"
@update:model-value="handleFormatChange"
/>
</div>
</div>
</template>
<script setup lang="ts">
import ColorPicker from 'primevue/colorpicker'
import InputNumber from 'primevue/inputnumber'
import Popover from 'primevue/popover'
import Select from 'primevue/select'
import { computed, ref, watch } from 'vue'
import type { ComponentWidget } from '@/scripts/domWidget'
interface ColorComponent {
name: string
value: number
min: number
max: number
step: number
}
interface ParsedColor {
hex: string
rgb: { r: number; g: number; b: number; a: number }
hsl: { h: number; s: number; l: number; a: number }
hsv: { h: number; s: number; v: number; a: number }
}
type ColorFormat = 'rgba' | 'hsla' | 'hsva' | 'hex'
const modelValue = defineModel<string>({ required: true })
const { widget } = defineProps<{
widget: ComponentWidget<string>
}>()
// Color format options
const colorFormats = [
{ label: 'RGBA', value: 'rgba' },
{ label: 'HSLA', value: 'hsla' },
{ label: 'HSVA', value: 'hsva' },
{ label: 'HEX', value: 'hex' }
]
// Current format state
const currentFormat = ref<ColorFormat>('rgba')
// Color picker popover reference
const colorPickerPopover = ref()
// Internal color value for the PrimeVue ColorPicker
const colorValue = ref<string>('#ff0000')
// Calculate widget width based on node size with padding
const widgetWidth = computed(() => {
if (!widget?.node?.size) return 'auto'
const nodeWidth = widget.node.size[0]
const WIDGET_PADDING = 16 // Account for padding around the widget
const maxWidth = Math.max(200, nodeWidth - WIDGET_PADDING) // Minimum 200px, but scale with node
return `${maxWidth}px`
})
// Parse color string to various formats
const parsedColor = computed<ParsedColor>(() => {
const value = modelValue.value || '#ff0000'
// Handle different input formats
if (value.startsWith('#')) {
return parseHexColor(value)
} else if (value.startsWith('rgb')) {
return parseRgbaColor(value)
} else if (value.startsWith('hsl')) {
return parseHslaColor(value)
} else if (value.startsWith('hsv')) {
return parseHsvaColor(value)
}
return parseHexColor('#ff0000') // Default fallback
})
// Get color components based on current format
const colorComponents = computed<ColorComponent[]>(() => {
const { rgb, hsl, hsv } = parsedColor.value
switch (currentFormat.value) {
case 'rgba':
return [
{ name: 'R', value: rgb.r, min: 0, max: 255, step: 1 },
{ name: 'G', value: rgb.g, min: 0, max: 255, step: 1 },
{ name: 'B', value: rgb.b, min: 0, max: 255, step: 1 },
{ name: 'A', value: rgb.a, min: 0, max: 1, step: 0.01 }
]
case 'hsla':
return [
{ name: 'H', value: hsl.h, min: 0, max: 360, step: 1 },
{ name: 'S', value: hsl.s, min: 0, max: 100, step: 1 },
{ name: 'L', value: hsl.l, min: 0, max: 100, step: 1 },
{ name: 'A', value: hsl.a, min: 0, max: 1, step: 0.01 }
]
case 'hsva':
return [
{ name: 'H', value: hsv.h, min: 0, max: 360, step: 1 },
{ name: 'S', value: hsv.s, min: 0, max: 100, step: 1 },
{ name: 'V', value: hsv.v, min: 0, max: 100, step: 1 },
{ name: 'A', value: hsv.a, min: 0, max: 1, step: 0.01 }
]
case 'hex':
return [] // No components for hex format
default:
return []
}
})
// Watch for changes in modelValue to update colorValue
watch(
() => modelValue.value,
(newValue) => {
if (newValue && newValue !== colorValue.value) {
colorValue.value = parsedColor.value.hex
}
},
{ immediate: true }
)
// Toggle color picker popover
function toggleColorPicker(event: Event) {
colorPickerPopover.value.toggle(event)
}
// Update color from picker
function updateColorFromPicker(value: string) {
colorValue.value = value
updateModelValue(parseHexColor(value))
}
// Update color from component inputs
function updateColorFromComponents() {
const components = colorComponents.value
if (components.length === 0) return
let newColor: ParsedColor
const rgbFromHsl = hslToRgb(
components[0].value,
components[1].value,
components[2].value,
components[3].value
)
const rgbFromHsv = hsvToRgb(
components[0].value,
components[1].value,
components[2].value,
components[3].value
)
switch (currentFormat.value) {
case 'rgba':
newColor = {
hex: rgbToHex(
components[0].value,
components[1].value,
components[2].value
),
rgb: {
r: components[0].value,
g: components[1].value,
b: components[2].value,
a: components[3].value
},
hsl: rgbToHsl(
components[0].value,
components[1].value,
components[2].value,
components[3].value
),
hsv: rgbToHsv(
components[0].value,
components[1].value,
components[2].value,
components[3].value
)
}
break
case 'hsla':
newColor = {
hex: rgbToHex(rgbFromHsl.r, rgbFromHsl.g, rgbFromHsl.b),
rgb: rgbFromHsl,
hsl: {
h: components[0].value,
s: components[1].value,
l: components[2].value,
a: components[3].value
},
hsv: rgbToHsv(rgbFromHsl.r, rgbFromHsl.g, rgbFromHsl.b, rgbFromHsl.a)
}
break
case 'hsva':
newColor = {
hex: rgbToHex(rgbFromHsv.r, rgbFromHsv.g, rgbFromHsv.b),
rgb: rgbFromHsv,
hsl: rgbToHsl(rgbFromHsv.r, rgbFromHsv.g, rgbFromHsv.b, rgbFromHsv.a),
hsv: {
h: components[0].value,
s: components[1].value,
v: components[2].value,
a: components[3].value
}
}
break
default:
return
}
updateModelValue(newColor)
}
// Handle format change
function handleFormatChange() {
updateModelValue(parsedColor.value)
}
// Update the model value based on current format
function updateModelValue(color: ParsedColor) {
switch (currentFormat.value) {
case 'rgba':
modelValue.value = `rgba(${color.rgb.r}, ${color.rgb.g}, ${color.rgb.b}, ${color.rgb.a})`
break
case 'hsla':
modelValue.value = `hsla(${color.hsl.h}, ${color.hsl.s}%, ${color.hsl.l}%, ${color.hsl.a})`
break
case 'hsva':
modelValue.value = `hsva(${color.hsv.h}, ${color.hsv.s}%, ${color.hsv.v}%, ${color.hsv.a})`
break
case 'hex':
modelValue.value = color.hex
break
}
colorValue.value = color.hex
}
// Color parsing functions
function parseHexColor(hex: string): ParsedColor {
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
const a = hex.length === 9 ? parseInt(hex.slice(7, 9), 16) / 255 : 1
return {
hex,
rgb: { r, g, b, a },
hsl: rgbToHsl(r, g, b, a),
hsv: rgbToHsv(r, g, b, a)
}
}
function parseRgbaColor(rgba: string): ParsedColor {
const match = rgba.match(/rgba?\(([^)]+)\)/)
if (!match) return parseHexColor('#ff0000')
const [r, g, b, a = 1] = match[1].split(',').map((v) => parseFloat(v.trim()))
return {
hex: rgbToHex(r, g, b),
rgb: { r, g, b, a },
hsl: rgbToHsl(r, g, b, a),
hsv: rgbToHsv(r, g, b, a)
}
}
function parseHslaColor(hsla: string): ParsedColor {
const match = hsla.match(/hsla?\(([^)]+)\)/)
if (!match) return parseHexColor('#ff0000')
const [h, s, l, a = 1] = match[1]
.split(',')
.map((v) => parseFloat(v.trim().replace('%', '')))
const rgb = hslToRgb(h, s, l, a)
return {
hex: rgbToHex(rgb.r, rgb.g, rgb.b),
rgb,
hsl: { h, s, l, a },
hsv: rgbToHsv(rgb.r, rgb.g, rgb.b, rgb.a)
}
}
function parseHsvaColor(hsva: string): ParsedColor {
const match = hsva.match(/hsva?\(([^)]+)\)/)
if (!match) return parseHexColor('#ff0000')
const [h, s, v, a = 1] = match[1]
.split(',')
.map((val) => parseFloat(val.trim().replace('%', '')))
const rgb = hsvToRgb(h, s, v, a)
return {
hex: rgbToHex(rgb.r, rgb.g, rgb.b),
rgb,
hsl: rgbToHsl(rgb.r, rgb.g, rgb.b, rgb.a),
hsv: { h, s, v, a }
}
}
// Color conversion utility functions
function rgbToHex(r: number, g: number, b: number): string {
return (
'#' +
[r, g, b].map((x) => Math.round(x).toString(16).padStart(2, '0')).join('')
)
}
function rgbToHsl(r: number, g: number, b: number, a: number) {
r /= 255
g /= 255
b /= 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
let h: number, s: number
const l = (max + min) / 2
if (max === min) {
h = s = 0
} else {
const d = max - min
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
switch (max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0)
break
case g:
h = (b - r) / d + 2
break
case b:
h = (r - g) / d + 4
break
default:
h = 0
}
h /= 6
}
return {
h: Math.round(h * 360),
s: Math.round(s * 100),
l: Math.round(l * 100),
a
}
}
function rgbToHsv(r: number, g: number, b: number, a: number) {
r /= 255
g /= 255
b /= 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
let h: number
const v = max
const s = max === 0 ? 0 : (max - min) / max
if (max === min) {
h = 0
} else {
const d = max - min
switch (max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0)
break
case g:
h = (b - r) / d + 2
break
case b:
h = (r - g) / d + 4
break
default:
h = 0
}
h /= 6
}
return {
h: Math.round(h * 360),
s: Math.round(s * 100),
v: Math.round(v * 100),
a
}
}
function hslToRgb(h: number, s: number, l: number, a: number) {
h /= 360
s /= 100
l /= 100
const hue2rgb = (p: number, q: number, t: number) => {
if (t < 0) t += 1
if (t > 1) t -= 1
if (t < 1 / 6) return p + (q - p) * 6 * t
if (t < 1 / 2) return q
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6
return p
}
let r: number, g: number, b: number
if (s === 0) {
r = g = b = l
} else {
const q = l < 0.5 ? l * (1 + s) : l + s - l * s
const p = 2 * l - q
r = hue2rgb(p, q, h + 1 / 3)
g = hue2rgb(p, q, h)
b = hue2rgb(p, q, h - 1 / 3)
}
return {
r: Math.round(r * 255),
g: Math.round(g * 255),
b: Math.round(b * 255),
a
}
}
function hsvToRgb(h: number, s: number, v: number, a: number) {
h /= 360
s /= 100
v /= 100
const c = v * s
const x = c * (1 - Math.abs(((h * 6) % 2) - 1))
const m = v - c
let r: number, g: number, b: number
if (h < 1 / 6) {
;[r, g, b] = [c, x, 0]
} else if (h < 2 / 6) {
;[r, g, b] = [x, c, 0]
} else if (h < 3 / 6) {
;[r, g, b] = [0, c, x]
} else if (h < 4 / 6) {
;[r, g, b] = [0, x, c]
} else if (h < 5 / 6) {
;[r, g, b] = [x, 0, c]
} else {
;[r, g, b] = [c, 0, x]
}
return {
r: Math.round((r + m) * 255),
g: Math.round((g + m) * 255),
b: Math.round((b + m) * 255),
a
}
}
</script>
<style scoped>
.color-picker-widget {
min-height: 40px;
overflow: hidden; /* Prevent overflow outside node bounds */
}
/* Ensure proper styling for small inputs */
:deep(.p-inputnumber-input) {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
}
:deep(.p-select) {
font-size: 0.75rem;
}
:deep(.p-select .p-select-label) {
padding: 0.25rem 0.5rem;
}
:deep(.p-colorpicker) {
border: none;
}
</style>

View File

@@ -0,0 +1,40 @@
<template>
<div class="px-2">
<Select
v-model="selectedValue"
:options="computedOptions"
:placeholder="placeholder"
class="w-full rounded-lg bg-[#222222] text-xs border-[#222222] shadow-none"
:disabled="isLoading"
/>
</div>
</template>
<script setup lang="ts">
import Select from 'primevue/select'
import { computed } from 'vue'
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComponentWidget } from '@/scripts/domWidget'
const selectedValue = defineModel<string>()
const { widget } = defineProps<{
widget?: ComponentWidget<string>
}>()
const inputSpec = (widget?.inputSpec ?? {}) as ComboInputSpec
const placeholder = 'Select option'
const isLoading = computed(() => selectedValue.value === 'Loading...')
// For remote widgets, we need to dynamically get options
const computedOptions = computed(() => {
if (inputSpec.remote) {
// For remote widgets, the options may be dynamically updated
// The useRemoteWidget will update the inputSpec.options
return inputSpec.options ?? []
}
return inputSpec.options ?? []
})
// Tooltip support is available via inputSpec.tooltip if needed in the future
</script>

View File

@@ -0,0 +1,210 @@
<template>
<div class="image-preview-widget relative w-full">
<!-- Single image or grid view -->
<div
v-if="images.length > 0"
class="relative rounded-lg overflow-hidden bg-gray-100 dark-theme:bg-gray-800"
:style="{ minHeight: `${minHeight}px` }"
>
<!-- Single image view -->
<div
v-if="selectedImageIndex !== null && images[selectedImageIndex]"
class="relative flex items-center justify-center w-full h-full"
>
<img
:src="images[selectedImageIndex].src"
:alt="`Preview ${selectedImageIndex + 1}`"
class="max-w-full max-h-full object-contain"
@error="handleImageError"
/>
<!-- Action buttons overlay -->
<div class="absolute top-2 right-2 flex gap-1">
<Button
v-if="images.length > 1"
icon="pi pi-times"
size="small"
severity="secondary"
class="w-8 h-8 rounded-lg bg-black/60 text-white border-none hover:bg-black/80"
@click="showGrid"
/>
<Button
icon="pi pi-pencil"
size="small"
severity="secondary"
class="w-8 h-8 rounded-lg bg-black/60 text-white border-none hover:bg-black/80"
@click="handleEdit"
/>
<Button
icon="pi pi-sun"
size="small"
severity="secondary"
class="w-8 h-8 rounded-lg bg-black/60 text-white border-none hover:bg-black/80"
@click="handleBrightness"
/>
<Button
icon="pi pi-download"
size="small"
severity="secondary"
class="w-8 h-8 rounded-lg bg-black/60 text-white border-none hover:bg-black/80"
@click="handleSave"
/>
</div>
<!-- Navigation for multiple images -->
<div
v-if="images.length > 1"
class="absolute bottom-2 right-2 bg-black/60 text-white px-2 py-1 rounded text-sm cursor-pointer hover:bg-black/80"
@click="nextImage"
>
{{ selectedImageIndex + 1 }}/{{ images.length }}
</div>
</div>
<!-- Grid view for multiple images -->
<div
v-else-if="allowBatch && images.length > 1"
class="grid gap-1 p-2"
:style="gridStyle"
>
<div
v-for="(image, index) in images"
:key="index"
class="relative aspect-square bg-gray-200 dark-theme:bg-gray-700 rounded cursor-pointer overflow-hidden hover:ring-2 hover:ring-blue-500"
@click="selectImage(index)"
>
<img
:src="image.src"
:alt="`Thumbnail ${index + 1}`"
class="w-full h-full object-cover"
@error="handleImageError"
/>
</div>
</div>
<!-- Single image in grid mode -->
<div v-else-if="images.length === 1" class="p-2">
<div
class="relative bg-gray-200 dark-theme:bg-gray-700 rounded cursor-pointer overflow-hidden"
@click="selectImage(0)"
>
<img
:src="images[0].src"
:alt="'Preview'"
class="w-full h-auto object-contain"
@error="handleImageError"
/>
</div>
</div>
</div>
<!-- Empty state -->
<div
v-else
class="flex items-center justify-center w-full bg-gray-100 dark-theme:bg-gray-800 rounded-lg"
:style="{ minHeight: `${minHeight}px` }"
>
<div class="text-gray-500 text-sm">No images to preview</div>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { computed, ref } from 'vue'
import type { ComponentWidget } from '@/scripts/domWidget'
interface ImageData {
src: string
width?: number
height?: number
}
const modelValue = defineModel<string | string[]>({ required: true })
const { widget } = defineProps<{
widget: ComponentWidget<string | string[]>
}>()
// Widget configuration
const inputSpec = widget.inputSpec
const allowBatch = computed(() => Boolean(inputSpec.allow_batch))
const imageFolder = computed(() => inputSpec.image_folder || 'input')
// State
const selectedImageIndex = ref<number | null>(null)
const minHeight = 320
// Convert model value to image data
const images = computed<ImageData[]>(() => {
const value = modelValue.value
if (!value) return []
const paths = Array.isArray(value) ? value : [value]
return paths.map((path) => ({
src: path.startsWith('http')
? path
: `api/view?filename=${encodeURIComponent(path)}&type=${imageFolder.value}`, // TODO: add subfolder
width: undefined,
height: undefined
}))
})
// Grid layout for batch images
const gridStyle = computed(() => {
const count = images.value.length
if (count <= 1) return {}
const cols = Math.ceil(Math.sqrt(count))
return {
gridTemplateColumns: `repeat(${cols}, 1fr)`
}
})
// Methods
const selectImage = (index: number) => {
selectedImageIndex.value = index
}
const showGrid = () => {
selectedImageIndex.value = null
}
const nextImage = () => {
if (images.value.length === 0) return
const current = selectedImageIndex.value ?? -1
const next = (current + 1) % images.value.length
selectedImageIndex.value = next
}
const handleImageError = (event: Event) => {
const img = event.target as HTMLImageElement
img.style.display = 'none'
console.warn('Failed to load image:', img.src)
}
// Stub button handlers for now
const handleEdit = () => {
console.log('Edit button clicked - functionality to be implemented')
}
const handleBrightness = () => {
console.log('Brightness button clicked - functionality to be implemented')
}
const handleSave = () => {
console.log('Save button clicked - functionality to be implemented')
}
// Initialize to show first image if available
if (images.value.length === 1) {
selectedImageIndex.value = 0
}
</script>
<style scoped>
.image-preview-widget {
/* Ensure proper dark theme styling */
}
</style>

View File

@@ -0,0 +1,150 @@
<template>
<div class="media-loader-widget w-full px-2 max-h-44">
<div
class="upload-area border-2 border-dashed border-surface-300 dark-theme:border-surface-600 rounded-lg p-6 text-center bg-surface-50 dark-theme:bg-surface-800 hover:bg-surface-100 dark-theme:hover:bg-surface-700 transition-colors cursor-pointer"
:class="{
'border-primary-500 bg-primary-50 dark-theme:bg-primary-950': isDragOver
}"
@click="triggerFileUpload"
@dragover.prevent="onDragOver"
@dragleave.prevent="onDragLeave"
@drop.prevent="onDrop"
>
<div class="flex flex-col items-center gap-2">
<i
class="pi pi-cloud-upload text-2xl text-surface-500 dark-theme:text-surface-400"
></i>
<div class="text-sm text-surface-600 dark-theme:text-surface-300">
<span>Drop your file here or </span>
<span
class="text-primary-600 dark-theme:text-primary-400 hover:text-primary-700 dark-theme:hover:text-primary-300 underline cursor-pointer"
@click.stop="triggerFileUpload"
>
browse files
</span>
</div>
<div
v-if="accept"
class="text-xs text-surface-500 dark-theme:text-surface-400"
>
Accepted formats: {{ formatAcceptTypes }}
</div>
</div>
</div>
<!-- Hidden file input -->
<input
ref="fileInput"
type="file"
:accept="accept"
multiple
class="hidden"
@change="onFileSelect"
/>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { ComponentWidget } from '@/scripts/domWidget'
// Props and model
const modelValue = defineModel<string[]>({ required: true, default: () => [] })
const { widget, accept } = defineProps<{
widget: ComponentWidget<string[]>
accept?: string
}>()
// Reactive state
const fileInput = ref<HTMLInputElement>()
const isDragOver = ref(false)
// Computed properties
const formatAcceptTypes = computed(() => {
if (!accept) return ''
return accept
.split(',')
.map((type) =>
type
.trim()
.replace('image/', '')
.replace('video/', '')
.replace('audio/', '')
)
.join(', ')
})
// Methods
const triggerFileUpload = () => {
fileInput.value?.click()
}
const onFileSelect = (event: Event) => {
const target = event.target as HTMLInputElement
if (target.files) {
handleFiles(Array.from(target.files))
}
}
const onDragOver = (event: DragEvent) => {
event.preventDefault()
isDragOver.value = true
}
const onDragLeave = (event: DragEvent) => {
event.preventDefault()
isDragOver.value = false
}
const onDrop = (event: DragEvent) => {
event.preventDefault()
isDragOver.value = false
if (event.dataTransfer?.files) {
handleFiles(Array.from(event.dataTransfer.files))
}
}
const handleFiles = (files: File[]) => {
// Filter files based on accept prop if provided
let validFiles = files
if (accept) {
const acceptTypes = accept
.split(',')
.map((type) => type.trim().toLowerCase())
validFiles = files.filter((file) => {
return acceptTypes.some((acceptType) => {
if (acceptType.includes('*')) {
// Handle wildcard types like "image/*"
const baseType = acceptType.split('/')[0]
return file.type.startsWith(baseType + '/')
}
return file.type.toLowerCase() === acceptType
})
})
}
if (validFiles.length > 0) {
// Emit files to parent component for handling upload
const fileNames = validFiles.map((file) => file.name)
modelValue.value = fileNames
// Trigger the widget's upload handler if available
if ((widget.options as any)?.onFilesSelected) {
;(widget.options as any).onFilesSelected(validFiles)
}
}
}
</script>
<style scoped>
.upload-area {
min-height: 80px;
transition: all 0.2s ease;
}
.upload-area:hover {
border-color: var(--p-primary-500);
}
</style>

View File

@@ -0,0 +1,45 @@
<template>
<div class="w-full px-2">
<!-- Single line text input -->
<InputText
v-if="!isMultiline"
v-model="modelValue"
:placeholder="placeholder"
class="w-full rounded-lg px-3 py-2 text-sm bg-[#222222] text-xs mt-0.5 border-[#222222] shadow-none"
/>
<!-- Multi-line textarea -->
<Textarea
v-else
v-model="modelValue"
:placeholder="placeholder"
:auto-resize="true"
:rows="3"
class="w-full rounded-lg px-3 py-2 text-sm resize-none bg-[#222222] text-xs mt-0.5 border-[#222222] shadow-none"
/>
</div>
</template>
<script setup lang="ts">
import InputText from 'primevue/inputtext'
import Textarea from 'primevue/textarea'
import { computed } from 'vue'
import type { StringInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComponentWidget } from '@/scripts/domWidget'
const modelValue = defineModel<string>({ required: true })
const { widget } = defineProps<{
widget: ComponentWidget<string>
}>()
const inputSpec = widget.inputSpec as StringInputSpec
const isMultiline = computed(() => inputSpec.multiline === true)
const placeholder = computed(
() =>
inputSpec.placeholder ??
inputSpec.default ??
inputSpec.defaultVal ??
inputSpec.name
)
</script>

View File

@@ -1,54 +0,0 @@
import {
ManagerState,
ManagerTab,
SortableAlgoliaField
} from '@/types/comfyManagerTypes'
const STORAGE_KEY = 'Comfy.Manager.UI.State'
export const useManagerStatePersistence = () => {
/**
* Load the UI state from localStorage.
*/
const loadStoredState = (): ManagerState => {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
return JSON.parse(stored)
}
} catch (e) {
console.error('Failed to load manager UI state:', e)
}
return {
selectedTabId: ManagerTab.All,
searchQuery: '',
searchMode: 'packs',
sortField: SortableAlgoliaField.Downloads
}
}
/**
* Persist the UI state to localStorage.
*/
const persistState = (state: ManagerState) => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
}
/**
* Reset the UI state to the default values.
*/
const reset = () => {
persistState({
selectedTabId: ManagerTab.All,
searchQuery: '',
searchMode: 'packs',
sortField: SortableAlgoliaField.Downloads
})
}
return {
loadStoredState,
persistState,
reset
}
}

View File

@@ -0,0 +1,77 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import { useImagePreviewWidget } from '@/composables/widgets/useImagePreviewWidget'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
const IMAGE_PREVIEW_WIDGET_NAME = '$$node-image-preview'
/**
* Composable for handling node-level operations for ImagePreview widget
*/
export function useNodeImagePreview() {
const imagePreviewWidget = useImagePreviewWidget()
const findImagePreviewWidget = (node: LGraphNode) =>
node.widgets?.find((w) => w.name === IMAGE_PREVIEW_WIDGET_NAME)
const addImagePreviewWidget = (
node: LGraphNode,
inputSpec?: Partial<InputSpec>
) =>
imagePreviewWidget(node, {
name: IMAGE_PREVIEW_WIDGET_NAME,
type: 'IMAGEPREVIEW',
allow_batch: true,
image_folder: 'input',
...inputSpec
} as InputSpec)
/**
* Shows image preview widget for a node
* @param node The graph node to show the widget for
* @param images The images to display (can be single image or array)
* @param options Configuration options
*/
function showImagePreview(
node: LGraphNode,
images: string | string[],
options: {
allow_batch?: boolean
image_folder?: string
imageInputName?: string
} = {}
) {
const widget =
findImagePreviewWidget(node) ??
addImagePreviewWidget(node, {
allow_batch: options.allow_batch,
image_folder: options.image_folder || 'input'
})
// Set the widget value
widget.value = images
node.setDirtyCanvas?.(true)
}
/**
* Removes image preview widget from a node
* @param node The graph node to remove the widget from
*/
function removeImagePreview(node: LGraphNode) {
if (!node.widgets) return
const widgetIdx = node.widgets.findIndex(
(w) => w.name === IMAGE_PREVIEW_WIDGET_NAME
)
if (widgetIdx > -1) {
node.widgets[widgetIdx].onRemove?.()
node.widgets.splice(widgetIdx, 1)
}
}
return {
showImagePreview,
removeImagePreview
}
}

View File

@@ -3,29 +3,15 @@ import type { LGraphNode } from '@comfyorg/litegraph'
import { useNodeDragAndDrop } from '@/composables/node/useNodeDragAndDrop'
import { useNodeFileInput } from '@/composables/node/useNodeFileInput'
import { useNodePaste } from '@/composables/node/useNodePaste'
import type { ResultItemType } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { useToastStore } from '@/stores/toastStore'
const PASTED_IMAGE_EXPIRY_MS = 2000
interface ImageUploadFormFields {
/**
* The folder to upload the file to.
* @example 'input', 'output', 'temp'
*/
type: ResultItemType
}
const uploadFile = async (
file: File,
isPasted: boolean,
formFields: Partial<ImageUploadFormFields> = {}
) => {
const uploadFile = async (file: File, isPasted: boolean) => {
const body = new FormData()
body.append('image', file)
if (isPasted) body.append('subfolder', 'pasted')
if (formFields.type) body.append('type', formFields.type)
const resp = await api.fetchApi('/upload/image', {
method: 'POST',
@@ -50,11 +36,6 @@ interface ImageUploadOptions {
* @example 'image/png,image/jpeg,image/webp,video/webm,video/mp4'
*/
accept?: string
/**
* The folder to upload the file to.
* @example 'input', 'output', 'temp'
*/
folder?: ResultItemType
}
/**
@@ -72,9 +53,7 @@ export const useNodeImageUpload = (
const handleUpload = async (file: File) => {
try {
const path = await uploadFile(file, isPastedFile(file), {
type: options.folder
})
const path = await uploadFile(file, isPastedFile(file))
if (!path) return
return path
} catch (error) {
@@ -89,7 +68,10 @@ export const useNodeImageUpload = (
return validPaths
}
// Handle drag & drop
// Note: MediaLoader widget functionality is handled directly by
// useImageUploadMediaWidget.ts to avoid circular dependencies
// Traditional approach: Handle drag & drop
useNodeDragAndDrop(node, {
fileFilter,
onDrop: handleUploadBatch

View File

@@ -0,0 +1,122 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import { useMediaLoaderWidget } from '@/composables/widgets/useMediaLoaderWidget'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { api } from '@/scripts/api'
import { useToastStore } from '@/stores/toastStore'
const MEDIA_LOADER_WIDGET_NAME = '$$node-media-loader'
const PASTED_IMAGE_EXPIRY_MS = 2000
const uploadFile = async (file: File, isPasted: boolean) => {
const body = new FormData()
body.append('image', file)
if (isPasted) body.append('subfolder', 'pasted')
const resp = await api.fetchApi('/upload/image', {
method: 'POST',
body
})
if (resp.status !== 200) {
useToastStore().addAlert(resp.status + ' - ' + resp.statusText)
return
}
const data = await resp.json()
return data.subfolder ? `${data.subfolder}/${data.name}` : data.name
}
interface MediaUploadOptions {
fileFilter?: (file: File) => boolean
onUploadComplete: (paths: string[]) => void
allow_batch?: boolean
accept?: string
}
/**
* Composable for handling media upload with Vue MediaLoader widget
*/
export function useNodeMediaUpload() {
const mediaLoaderWidget = useMediaLoaderWidget()
const findMediaLoaderWidget = (node: LGraphNode) =>
node.widgets?.find((w) => w.name === MEDIA_LOADER_WIDGET_NAME)
const addMediaLoaderWidget = (
node: LGraphNode,
options: MediaUploadOptions
) => {
const isPastedFile = (file: File): boolean =>
file.name === 'image.png' &&
file.lastModified - Date.now() < PASTED_IMAGE_EXPIRY_MS
const handleUpload = async (file: File) => {
try {
const path = await uploadFile(file, isPastedFile(file))
if (!path) return
return path
} catch (error) {
useToastStore().addAlert(String(error))
}
}
// Create the MediaLoader widget
const widget = mediaLoaderWidget(node, {
name: MEDIA_LOADER_WIDGET_NAME,
type: 'MEDIA_LOADER'
} as InputSpec)
// Connect the widget to the upload handler
if (widget.options) {
;(widget.options as any).onFilesSelected = async (files: File[]) => {
const filteredFiles = options.fileFilter
? files.filter(options.fileFilter)
: files
const paths = await Promise.all(filteredFiles.map(handleUpload))
const validPaths = paths.filter((p): p is string => !!p)
if (validPaths.length) {
options.onUploadComplete(validPaths)
}
}
}
return widget
}
/**
* Shows media loader widget for a node
* @param node The graph node to show the widget for
* @param options Upload configuration options
*/
function showMediaLoader(node: LGraphNode, options: MediaUploadOptions) {
const widget =
findMediaLoaderWidget(node) ?? addMediaLoaderWidget(node, options)
node.setDirtyCanvas?.(true)
return widget
}
/**
* Removes media loader widget from a node
* @param node The graph node to remove the widget from
*/
function removeMediaLoader(node: LGraphNode) {
if (!node.widgets) return
const widgetIdx = node.widgets.findIndex(
(w) => w.name === MEDIA_LOADER_WIDGET_NAME
)
if (widgetIdx > -1) {
node.widgets[widgetIdx].onRemove?.()
node.widgets.splice(widgetIdx, 1)
}
}
return {
showMediaLoader,
removeMediaLoader,
addMediaLoaderWidget
}
}

View File

@@ -1,4 +1,3 @@
import { whenever } from '@vueuse/core'
import { computed, onUnmounted } from 'vue'
import { useNodePacks } from '@/composables/nodePack/useNodePacks'
@@ -19,16 +18,6 @@ export const useInstalledPacks = (options: UseNodePacksOptions = {}) => {
const filterInstalledPack = (packs: components['schemas']['Node'][]) =>
packs.filter((pack) => comfyManagerStore.isPackInstalled(pack.id))
const startFetchInstalled = async () => {
await comfyManagerStore.refreshInstalledList()
await startFetch()
}
// When installedPackIds changes, we need to update the nodePacks
whenever(installedPackIds, async () => {
await startFetch()
})
onUnmounted(() => {
cleanup()
})
@@ -38,7 +27,7 @@ export const useInstalledPacks = (options: UseNodePacksOptions = {}) => {
isLoading,
isReady,
installedPacks: nodePacks,
startFetchInstalled,
startFetchInstalled: startFetch,
filterInstalledPack
}
}

View File

@@ -1,76 +0,0 @@
import { LGraphNode } from '@comfyorg/litegraph'
import { NodeProperty } from '@comfyorg/litegraph/dist/LGraphNode'
import { groupBy } from 'lodash'
import { computed, onMounted } from 'vue'
import { useWorkflowPacks } from '@/composables/nodePack/useWorkflowPacks'
import { app } from '@/scripts/app'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import type { components } from '@/types/comfyRegistryTypes'
/**
* Composable to find missing NodePacks from workflow
* Uses the same filtering approach as ManagerDialogContent.vue
* Automatically fetches workflow pack data when initialized
*/
export const useMissingNodes = () => {
const nodeDefStore = useNodeDefStore()
const comfyManagerStore = useComfyManagerStore()
const { workflowPacks, isLoading, error, startFetchWorkflowPacks } =
useWorkflowPacks()
// Same filtering logic as ManagerDialogContent.vue
const filterMissingPacks = (packs: components['schemas']['Node'][]) =>
packs.filter((pack) => !comfyManagerStore.isPackInstalled(pack.id))
// Filter only uninstalled packs from workflow packs
const missingNodePacks = computed(() => {
if (!workflowPacks.value.length) return []
return filterMissingPacks(workflowPacks.value)
})
/**
* Check if a pack is the ComfyUI builtin node pack (nodes that come pre-installed)
* @param packId - The id of the pack to check
* @returns True if the pack is the comfy-core pack, false otherwise
*/
const isCorePack = (packId: NodeProperty) => {
return packId === 'comfy-core'
}
/**
* Check if a node is a missing core node
* A missing core node is a node that is in the workflow and originates from
* the comfy-core pack (pre-installed) but not registered in the node def
* store (the node def was not found on the server)
* @param node - The node to check
* @returns True if the node is a missing core node, false otherwise
*/
const isMissingCoreNode = (node: LGraphNode) => {
const packId = node.properties?.cnr_id
if (packId === undefined || !isCorePack(packId)) return false
const nodeName = node.type
const isRegisteredNodeDef = !!nodeDefStore.nodeDefsByName[nodeName]
return !isRegisteredNodeDef
}
const missingCoreNodes = computed<Record<string, LGraphNode[]>>(() => {
const missingNodes = app.graph.nodes.filter(isMissingCoreNode)
return groupBy(missingNodes, (node) => String(node.properties?.ver || ''))
})
// Automatically fetch workflow pack data when composable is used
onMounted(async () => {
if (!workflowPacks.value.length && !isLoading.value) {
await startFetchWorkflowPacks()
}
})
return {
missingNodePacks,
missingCoreNodes,
isLoading,
error
}
}

View File

@@ -0,0 +1,71 @@
import { computed } from 'vue'
import { useCanvasStore } from '@/stores/graphStore'
export interface NodeInteractionEvent {
type: 'mousedown' | 'contextmenu' | 'slot-click'
nodeId: string
originalEvent: MouseEvent
slotIndex?: number
}
export function useNodeInteractionProxy() {
const canvasStore = useCanvasStore()
// Get canvas reference
const canvas = computed(() => canvasStore.canvas)
const handleNodeInteraction = (event: NodeInteractionEvent) => {
const { type, nodeId, originalEvent } = event
if (!canvas.value?.graph) return
const node = canvas.value.graph.getNodeById(Number(nodeId))
if (!node) return
switch (type) {
case 'mousedown':
// Convert Vue event coordinates back to canvas coordinates
const rect = canvas.value.canvas.getBoundingClientRect()
const canvasX = originalEvent.clientX - rect.left
const canvasY = originalEvent.clientY - rect.top
// Transform to graph coordinates
const graphPos = canvas.value.convertOffsetToCanvas([canvasX, canvasY])
// Note: simulatedEvent not currently used but kept for future expansion
// Trigger node selection and dragging
canvas.value.selectNode(node, originalEvent.ctrlKey || originalEvent.metaKey)
canvas.value.node_dragged = node
// Start drag operation if not holding modifier keys
if (!originalEvent.ctrlKey && !originalEvent.metaKey && !originalEvent.shiftKey) {
canvas.value.dragging_canvas = false
canvas.value.node_dragged = node
canvas.value.drag_start = [originalEvent.clientX, originalEvent.clientY]
}
break
case 'contextmenu':
// Show context menu for the node
originalEvent.preventDefault()
canvas.value.showContextMenu(originalEvent, node)
break
case 'slot-click':
// Handle slot connection interactions
if (event.slotIndex !== undefined) {
const slot = node.inputs?.[event.slotIndex] || node.outputs?.[event.slotIndex]
if (slot) {
canvas.value.processSlotClick(node, event.slotIndex, originalEvent)
}
}
break
}
}
return {
handleNodeInteraction
}
}

View File

@@ -0,0 +1,124 @@
import { ref, computed, readonly, watchEffect } from 'vue'
import type { LGraphNode } from '@comfyorg/litegraph'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { useCanvasStore } from '@/stores/graphStore'
// Note: useEventListener imported but not currently used - may be used for future enhancements
export interface NodePosition {
id: string
x: number
y: number
width: number
height: number
}
export function useNodePositionSync() {
const canvasStore = useCanvasStore()
const nodePositions = ref<Record<string, NodePosition>>({})
const canvasScale = ref(1)
const canvasOffset = ref({ x: 0, y: 0 })
// Get canvas reference
const canvas = computed(() => canvasStore.canvas)
// Sync canvas transform (scale and offset)
watchEffect(() => {
if (!canvas.value) return
const updateTransform = () => {
if (!canvas.value?.ds) return
canvasScale.value = canvas.value.ds.scale
canvasOffset.value = {
x: canvas.value.ds.offset[0],
y: canvas.value.ds.offset[1]
}
}
// Hook into the canvas draw cycle to update transform
canvas.value.onDrawForeground = useChainCallback(
canvas.value.onDrawForeground,
updateTransform
)
// Initial transform update
updateTransform()
})
// Sync node positions
const syncNodePositions = () => {
if (!canvas.value?.graph) return
const positions: Record<string, NodePosition> = {}
for (const node of canvas.value.graph._nodes) {
positions[node.id] = {
id: String(node.id),
x: node.pos[0],
y: node.pos[1],
width: node.size[0],
height: node.size[1]
}
}
nodePositions.value = positions
}
// Listen for node position changes
watchEffect(() => {
if (!canvas.value) return
// Hook into various node update events
const originalOnNodeMoved = canvas.value.onNodeMoved
canvas.value.onNodeMoved = useChainCallback(
originalOnNodeMoved,
syncNodePositions
)
// Hook into general graph changes
const originalOnGraphChanged = canvas.value.onGraphChanged
canvas.value.onGraphChanged = useChainCallback(
originalOnGraphChanged,
syncNodePositions
)
// Initial sync
syncNodePositions()
})
// Get visible nodes (within viewport bounds)
const visibleNodes = computed(() => {
if (!canvas.value?.graph) {
console.log('🚫 useNodePositionSync: No canvas or graph available')
return []
}
const allNodes = canvas.value.graph._nodes
console.log('🔍 useNodePositionSync: Checking', allNodes.length, 'total nodes')
const phantomNodes = allNodes.filter((node: LGraphNode) => {
const isPhantom = node.phantom_mode === true
if (isPhantom) {
console.log('👻 Found phantom node:', { id: node.id, title: node.title, phantom_mode: node.phantom_mode })
}
return isPhantom
})
console.log('📊 useNodePositionSync: Found', phantomNodes.length, 'phantom nodes out of', allNodes.length, 'total')
// TODO: Add viewport culling for performance
// For now, return all phantom nodes
return phantomNodes
})
// Manual sync function for external triggers
const forceSync = () => {
syncNodePositions()
}
return {
nodePositions: readonly(nodePositions),
canvasScale: readonly(canvasScale),
canvasOffset: readonly(canvasOffset),
visibleNodes: readonly(visibleNodes),
forceSync
}
}

View File

@@ -0,0 +1,142 @@
import { computed } from 'vue'
import type { LGraphNode } from '@comfyorg/litegraph'
import { useCanvasStore } from '@/stores/graphStore'
import { useSettingStore } from '@/stores/settingStore'
export function usePhantomNodes() {
const canvasStore = useCanvasStore()
const settingStore = useSettingStore()
// Get canvas reference
const canvas = computed(() => canvasStore.canvas)
// Check if Vue node rendering is enabled
const vueRenderingEnabled = computed(() => true) // Temporarily enabled for testing
/**
* Enable phantom mode for a specific node
* @param nodeId The ID of the node to make phantom
*/
const enablePhantomMode = (nodeId: string | number) => {
if (!canvas.value?.graph) return false
const node = canvas.value.graph.getNodeById(Number(nodeId))
if (!node) return false
node.phantom_mode = true
// Trigger canvas redraw to hide the node visually
canvas.value.setDirty(true, true)
return true
}
/**
* Disable phantom mode for a specific node
* @param nodeId The ID of the node to make visible again
*/
const disablePhantomMode = (nodeId: string | number) => {
if (!canvas.value?.graph) return false
const node = canvas.value.graph.getNodeById(Number(nodeId))
if (!node) return false
node.phantom_mode = false
// Trigger canvas redraw to show the node visually
canvas.value.setDirty(true, true)
return true
}
/**
* Toggle phantom mode for a specific node
* @param nodeId The ID of the node to toggle
*/
const togglePhantomMode = (nodeId: string | number) => {
if (!canvas.value?.graph) return false
const node = canvas.value.graph.getNodeById(Number(nodeId))
if (!node) return false
const newMode = !node.phantom_mode
node.phantom_mode = newMode
// Trigger canvas redraw
canvas.value.setDirty(true, true)
return newMode
}
/**
* Enable phantom mode for all nodes (global Vue rendering)
*/
const enableAllPhantomMode = () => {
if (!canvas.value?.graph) return 0
let count = 0
for (const node of canvas.value.graph._nodes) {
if (!node.phantom_mode) {
node.phantom_mode = true
count++
}
}
if (count > 0) {
canvas.value.setDirty(true, true)
}
return count
}
/**
* Disable phantom mode for all nodes (back to canvas rendering)
*/
const disableAllPhantomMode = () => {
if (!canvas.value?.graph) return 0
let count = 0
for (const node of canvas.value.graph._nodes) {
if (node.phantom_mode) {
node.phantom_mode = false
count++
}
}
if (count > 0) {
canvas.value.setDirty(true, true)
}
return count
}
/**
* Get all phantom nodes
*/
const getPhantomNodes = (): LGraphNode[] => {
if (!canvas.value?.graph) return []
return canvas.value.graph._nodes.filter((node: LGraphNode) =>
node.phantom_mode === true
)
}
/**
* Check if a node is in phantom mode
* @param nodeId The ID of the node to check
*/
const isPhantomNode = (nodeId: string | number): boolean => {
if (!canvas.value?.graph) return false
const node = canvas.value.graph.getNodeById(Number(nodeId))
return node?.phantom_mode === true
}
return {
vueRenderingEnabled,
enablePhantomMode,
disablePhantomMode,
togglePhantomMode,
enableAllPhantomMode,
disableAllPhantomMode,
getPhantomNodes,
isPhantomNode
}
}

View File

@@ -0,0 +1,59 @@
import { onMounted, onUnmounted } from 'vue'
import { usePhantomNodes } from './usePhantomNodes'
import { useCanvasStore } from '@/stores/graphStore'
import { api } from '@/scripts/api'
/**
* Development helper to automatically enable phantom mode for testing
*/
export function useTestPhantomNodes() {
const { enableAllPhantomMode, getPhantomNodes } = usePhantomNodes()
const canvasStore = useCanvasStore()
let graphChangeHandler: (() => void) | null = null
onMounted(() => {
// Function to enable phantom mode for all nodes
const enablePhantomModeForAllNodes = () => {
if (canvasStore.canvas?.graph) {
const count = enableAllPhantomMode()
if (count > 0) {
console.log(`✅ Enabled phantom mode for ${count} nodes`)
}
return count
}
return 0
}
// Listen for graph changes to immediately enable phantom mode for new nodes
graphChangeHandler = () => {
enablePhantomModeForAllNodes()
}
api.addEventListener('graphChanged', graphChangeHandler)
// Initial attempt when mounted
setTimeout(() => {
enablePhantomModeForAllNodes()
}, 100) // Much shorter timeout just to ensure canvas is ready
})
onUnmounted(() => {
if (graphChangeHandler) {
api.removeEventListener('graphChanged', graphChangeHandler)
}
})
// Expose helper functions to global scope for manual testing
if (typeof window !== 'undefined') {
(window as any).testPhantomNodes = {
enableAll: enableAllPhantomMode,
getPhantom: getPhantomNodes,
enableSingle: (nodeId: string) => {
const { enablePhantomMode } = usePhantomNodes()
return enablePhantomMode(nodeId)
}
}
console.log('🚀 Phantom node testing helpers available on window.testPhantomNodes')
}
}

View File

@@ -4,7 +4,6 @@ import {
LGraphNode,
LiteGraph
} from '@comfyorg/litegraph'
import { Point } from '@comfyorg/litegraph'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import {
@@ -28,8 +27,6 @@ import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
const moveSelectedNodesVersionAdded = '1.22.2'
export function useCoreCommands(): ComfyCommand[] {
const workflowService = useWorkflowService()
const workflowStore = useWorkflowStore()
@@ -61,20 +58,6 @@ export function useCoreCommands(): ComfyCommand[] {
})
}
const moveSelectedNodes = (
positionUpdater: (pos: Point, gridSize: number) => Point
) => {
const selectedNodes = getSelectedNodes()
if (selectedNodes.length === 0) return
const gridSize = useSettingStore().get('Comfy.SnapToGrid.GridSize')
selectedNodes.forEach((node) => {
node.pos = positionUpdater(node.pos, gridSize)
})
app.canvas.state.selectionChanged = true
app.canvas.setDirty(true, true)
}
const commands = [
{
id: 'Comfy.NewBlankWorkflow',
@@ -272,6 +255,8 @@ export function useCoreCommands(): ComfyCommand[] {
return
}
app.canvas.fitViewToSelectionAnimated()
// Trigger re-render of Vue nodes after view change
api.dispatchCustomEvent('graphChanged')
}
},
{
@@ -658,19 +643,19 @@ export function useCoreCommands(): ComfyCommand[] {
{
id: 'Comfy.Manager.CustomNodesManager',
icon: 'pi pi-puzzle',
label: 'Toggle the Custom Nodes Manager',
label: 'Custom Nodes Manager',
versionAdded: '1.12.10',
function: () => {
dialogService.toggleManagerDialog()
dialogService.showManagerDialog()
}
},
{
id: 'Comfy.Manager.ToggleManagerProgressDialog',
icon: 'pi pi-spinner',
label: 'Toggle the Custom Nodes Manager Progress Bar',
label: 'Toggle Progress Dialog',
versionAdded: '1.13.9',
function: () => {
dialogService.toggleManagerProgressDialog()
dialogService.showManagerProgressDialog()
}
},
{
@@ -690,34 +675,6 @@ export function useCoreCommands(): ComfyCommand[] {
function: async () => {
await firebaseAuthActions.logout()
}
},
{
id: 'Comfy.Canvas.MoveSelectedNodes.Up',
icon: 'pi pi-arrow-up',
label: 'Move Selected Nodes Up',
versionAdded: moveSelectedNodesVersionAdded,
function: () => moveSelectedNodes(([x, y], gridSize) => [x, y - gridSize])
},
{
id: 'Comfy.Canvas.MoveSelectedNodes.Down',
icon: 'pi pi-arrow-down',
label: 'Move Selected Nodes Down',
versionAdded: moveSelectedNodesVersionAdded,
function: () => moveSelectedNodes(([x, y], gridSize) => [x, y + gridSize])
},
{
id: 'Comfy.Canvas.MoveSelectedNodes.Left',
icon: 'pi pi-arrow-left',
label: 'Move Selected Nodes Left',
versionAdded: moveSelectedNodesVersionAdded,
function: () => moveSelectedNodes(([x, y], gridSize) => [x - gridSize, y])
},
{
id: 'Comfy.Canvas.MoveSelectedNodes.Right',
icon: 'pi pi-arrow-right',
label: 'Move Selected Nodes Right',
versionAdded: moveSelectedNodesVersionAdded,
function: () => moveSelectedNodes(([x, y], gridSize) => [x + gridSize, y])
}
]

View File

@@ -15,10 +15,7 @@ export const useProgressFavicon = () => {
if (isIdle) {
favicon.value = defaultFavicon
} else {
const frame = Math.min(
Math.max(0, Math.floor(progress * totalFrames)),
totalFrames - 1
)
const frame = Math.floor(progress * totalFrames)
favicon.value = `/assets/images/favicon_progress_16x16/frame_${frame}.png`
}
}

View File

@@ -1,61 +1,91 @@
import { watchDebounced } from '@vueuse/core'
import { orderBy } from 'lodash'
import { computed, ref, watch } from 'vue'
import type { Hit } from 'algoliasearch/dist/lite/browser'
import { memoize, orderBy } from 'lodash'
import { computed, onUnmounted, ref, watch } from 'vue'
import { DEFAULT_PAGE_SIZE } from '@/constants/searchConstants'
import { useRegistrySearchGateway } from '@/services/gateway/registrySearchGateway'
import type { SearchAttribute } from '@/types/algoliaTypes'
import {
AlgoliaNodePack,
SearchAttribute,
useAlgoliaSearchService
} from '@/services/algoliaSearchService'
import type { NodesIndexSuggestion } from '@/services/algoliaSearchService'
import { SortableAlgoliaField } from '@/types/comfyManagerTypes'
import type { components } from '@/types/comfyRegistryTypes'
import type { QuerySuggestion, SearchMode } from '@/types/searchServiceTypes'
type RegistryNodePack = components['schemas']['Node']
const SEARCH_DEBOUNCE_TIME = 320
const DEFAULT_PAGE_SIZE = 64
const DEFAULT_SORT_FIELD = SortableAlgoliaField.Downloads // Set in the index configuration
const DEFAULT_MAX_CACHE_SIZE = 64
const SORT_DIRECTIONS: Record<SortableAlgoliaField, 'asc' | 'desc'> = {
[SortableAlgoliaField.Downloads]: 'desc',
[SortableAlgoliaField.Created]: 'desc',
[SortableAlgoliaField.Updated]: 'desc',
[SortableAlgoliaField.Publisher]: 'asc',
[SortableAlgoliaField.Name]: 'asc'
}
const isDateField = (field: SortableAlgoliaField): boolean =>
field === SortableAlgoliaField.Created ||
field === SortableAlgoliaField.Updated
/**
* Composable for managing UI state of Comfy Node Registry search.
*/
export function useRegistrySearch(
options: {
initialSortField?: string
initialSearchMode?: SearchMode
initialSearchQuery?: string
initialPageNumber?: number
maxCacheSize?: number
} = {}
) {
const {
initialSortField = DEFAULT_SORT_FIELD,
initialSearchMode = 'packs',
initialSearchQuery = '',
initialPageNumber = 0
} = options
const { maxCacheSize = DEFAULT_MAX_CACHE_SIZE } = options
const isLoading = ref(false)
const sortField = ref<string>(initialSortField)
const searchMode = ref<SearchMode>(initialSearchMode)
const sortField = ref<SortableAlgoliaField>(SortableAlgoliaField.Downloads)
const searchMode = ref<'nodes' | 'packs'>('packs')
const pageSize = ref(DEFAULT_PAGE_SIZE)
const pageNumber = ref(initialPageNumber)
const searchQuery = ref(initialSearchQuery)
const searchResults = ref<RegistryNodePack[]>([])
const suggestions = ref<QuerySuggestion[]>([])
const pageNumber = ref(0)
const searchQuery = ref('')
const results = ref<AlgoliaNodePack[]>([])
const suggestions = ref<NodesIndexSuggestion[]>([])
const searchAttributes = computed<SearchAttribute[]>(() =>
searchMode.value === 'nodes' ? ['comfy_nodes'] : ['name', 'description']
)
const searchGateway = useRegistrySearchGateway()
const resultsAsRegistryPacks = computed(() =>
results.value ? results.value.map(algoliaToRegistry) : []
)
const resultsAsNodes = computed(() =>
results.value
? results.value.reduce(
(acc, hit) => acc.concat(hit.comfy_nodes),
[] as string[]
)
: []
)
const { searchPacks, clearSearchCache, getSortValue, getSortableFields } =
searchGateway
const { searchPacksCached, toRegistryPack, clearSearchPacksCache } =
useAlgoliaSearchService({
maxCacheSize
})
const algoliaToRegistry = memoize(
toRegistryPack,
(algoliaNode: AlgoliaNodePack) => algoliaNode.id
)
const getSortValue = (pack: Hit<AlgoliaNodePack>) => {
if (isDateField(sortField.value)) {
const value = pack[sortField.value]
return value ? new Date(value).getTime() : 0
} else {
const value = pack[sortField.value]
return value ?? 0
}
}
const updateSearchResults = async (options: { append?: boolean }) => {
isLoading.value = true
if (!options.append) {
pageNumber.value = 0
}
const { nodePacks, querySuggestions } = await searchPacks(
const { nodePacks, querySuggestions } = await searchPacksCached(
searchQuery.value,
{
pageSize: pageSize.value,
@@ -68,22 +98,17 @@ export function useRegistrySearch(
// Results are sorted by the default field to begin with -- so don't manually sort again
if (sortField.value && sortField.value !== DEFAULT_SORT_FIELD) {
// Get the sort direction from the provider's sortable fields
const sortableFields = getSortableFields()
const fieldConfig = sortableFields.find((f) => f.id === sortField.value)
const direction = fieldConfig?.direction || 'desc'
sortedPacks = orderBy(
nodePacks,
[(pack) => getSortValue(pack, sortField.value)],
[direction]
[getSortValue],
[SORT_DIRECTIONS[sortField.value]]
)
}
if (options.append && searchResults.value?.length) {
searchResults.value = searchResults.value.concat(sortedPacks)
if (options.append && results.value?.length) {
results.value = results.value.concat(sortedPacks)
} else {
searchResults.value = sortedPacks
results.value = sortedPacks
}
suggestions.value = querySuggestions
isLoading.value = false
@@ -99,9 +124,7 @@ export function useRegistrySearch(
immediate: true
})
const sortOptions = computed(() => {
return getSortableFields()
})
onUnmounted(clearSearchPacksCache)
return {
isLoading,
@@ -111,8 +134,7 @@ export function useRegistrySearch(
searchMode,
searchQuery,
suggestions,
searchResults,
sortOptions,
clearCache: clearSearchCache
searchResults: resultsAsRegistryPacks,
nodeSearchResults: resultsAsNodes
}
}

View File

@@ -0,0 +1,166 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import { reactive, ref } from 'vue'
import BadgedNumberInput from '@/components/graph/widgets/BadgedNumberInput.vue'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
type BadgeState = 'normal' | 'random' | 'lock' | 'increment' | 'decrement'
type NumberWidgetMode = 'int' | 'float'
interface BadgedNumberInputOptions {
defaultValue?: number
badgeState?: BadgeState
disabled?: boolean
serialize?: boolean
mode?: NumberWidgetMode
}
// Helper function to map control widget values to badge states
const mapControlValueToBadgeState = (controlValue: string): BadgeState => {
switch (controlValue) {
case 'fixed':
return 'lock'
case 'increment':
return 'increment'
case 'decrement':
return 'decrement'
case 'randomize':
return 'random'
default:
return 'normal'
}
}
export const useBadgedNumberInput = (
options: BadgedNumberInputOptions = {}
) => {
const {
defaultValue = 0,
disabled = false,
serialize = true,
mode = 'int'
} = options
const widgetConstructor: ComfyWidgetConstructorV2 = (
node: LGraphNode,
inputSpec: InputSpec
) => {
// Initialize widget value as string to conform to ComponentWidgetImpl requirements
const widgetValue = ref<string>(defaultValue.toString())
// Determine if we should show control widget and badge
const shouldShowControlWidget =
inputSpec.control_after_generate ??
// Legacy compatibility: seed inputs get control widgets
['seed', 'noise_seed'].includes(inputSpec.name)
// Create reactive props object for the component
const componentProps = reactive({
badgeState:
options.badgeState ??
(shouldShowControlWidget ? 'random' : ('normal' as BadgeState)),
disabled
})
const controlWidget: any = null
// Create the main widget instance
const widget = new ComponentWidgetImpl<
string | object,
Omit<
InstanceType<typeof BadgedNumberInput>['$props'],
'widget' | 'modelValue'
>
>({
node,
name: inputSpec.name,
component: BadgedNumberInput,
inputSpec,
props: componentProps,
options: {
// Required: getter for widget value - return as string
getValue: () => widgetValue.value as string | object,
// Required: setter for widget value - accept number, string or object
setValue: (value: string | object | number) => {
let numValue: number
if (typeof value === 'object') {
numValue = parseFloat(JSON.stringify(value))
} else {
numValue =
typeof value === 'number' ? value : parseFloat(String(value))
}
if (!isNaN(numValue)) {
// Apply int/float specific value processing
if (mode === 'int') {
const step = (inputSpec as any).step ?? 1
if (step === 1) {
numValue = Math.round(numValue)
} else {
const min = (inputSpec as any).min ?? 0
const offset = min % step
numValue =
Math.round((numValue - offset) / step) * step + offset
}
}
widgetValue.value = numValue.toString()
}
},
// Optional: whether to serialize this widget's value
serialize
}
})
// Add control widget if needed - temporarily disabled to fix circular dependency
if (shouldShowControlWidget) {
// TODO: Re-implement control widget functionality without circular dependency
console.warn(
'Control widget functionality temporarily disabled due to circular dependency'
)
// controlWidget = addValueControlWidget(
// node,
// widget as any, // Cast to satisfy the interface
// 'randomize',
// undefined,
// undefined,
// transformInputSpecV2ToV1(inputSpec)
// )
// Set up reactivity to update badge state when control widget changes
if (controlWidget) {
const originalCallback = controlWidget.callback
controlWidget.callback = function (value: string) {
componentProps.badgeState = mapControlValueToBadgeState(value)
if (originalCallback) {
originalCallback.call(this, value)
}
}
// Initialize badge state
componentProps.badgeState = mapControlValueToBadgeState(
controlWidget.value || 'randomize'
)
// Link the widgets
;(widget as any).linkedWidgets = [controlWidget]
}
}
// Register the widget with the node
addWidget(node, widget)
return widget
}
return widgetConstructor
}
// Export types for use in other modules
export type { BadgeState, BadgedNumberInputOptions, NumberWidgetMode }

View File

@@ -4,7 +4,7 @@ import {
type InputSpec,
isBooleanInputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
export const useBooleanWidget = () => {
const widgetConstructor: ComfyWidgetConstructorV2 = (

View File

@@ -4,9 +4,8 @@ import { ref } from 'vue'
import ChatHistoryWidget from '@/components/graph/widgets/ChatHistoryWidget.vue'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
const PADDING = 16
export const useChatHistoryWidget = (
options: {
@@ -32,7 +31,6 @@ export const useChatHistoryWidget = (
setValue: (value: string | object) => {
widgetValue.value = typeof value === 'string' ? value : String(value)
},
getMinHeight: () => 400 + PADDING
}
})
addWidget(node, widget)

View File

@@ -0,0 +1,200 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import { ref } from 'vue'
import ColorPickerWidget from '@/components/graph/widgets/ColorPickerWidget.vue'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
interface ColorPickerWidgetOptions {
defaultValue?: string
defaultFormat?: 'rgba' | 'hsla' | 'hsva' | 'hex'
serialize?: boolean
}
export const useColorPickerWidget = (
options: ColorPickerWidgetOptions = {}
) => {
const {
defaultValue = 'rgba(255, 0, 0, 1)',
serialize = true
} = options
const widgetConstructor: ComfyWidgetConstructorV2 = (
node: LGraphNode,
inputSpec: InputSpec
) => {
// Initialize widget value as string
const widgetValue = ref<string>(defaultValue)
// Create the main widget instance
const widget = new ComponentWidgetImpl<string>({
node,
name: inputSpec.name,
component: ColorPickerWidget,
inputSpec,
options: {
// Required: getter for widget value
getValue: () => widgetValue.value,
// Required: setter for widget value
setValue: (value: string | any) => {
// Handle different input types
if (typeof value === 'string') {
// Validate and normalize color string
const normalizedValue = normalizeColorString(value)
if (normalizedValue) {
widgetValue.value = normalizedValue
}
} else if (typeof value === 'object' && value !== null) {
// Handle object input (e.g., from PrimeVue ColorPicker)
if (value.hex) {
widgetValue.value = value.hex
} else {
// Try to convert object to string
widgetValue.value = String(value)
}
} else {
// Fallback to string conversion
widgetValue.value = String(value)
}
},
// Optional: whether to serialize this widget's value
serialize
}
})
// Register the widget with the node
addWidget(node, widget as any)
return widget
}
return widgetConstructor
}
/**
* Normalizes color string inputs to ensure consistent format
* @param colorString - The input color string
* @returns Normalized color string or null if invalid
*/
function normalizeColorString(colorString: string): string | null {
if (!colorString || typeof colorString !== 'string') {
return null
}
const trimmed = colorString.trim()
// Handle hex colors
if (trimmed.startsWith('#')) {
if (/^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/.test(trimmed)) {
// Convert 3-digit hex to 6-digit
if (trimmed.length === 4) {
return (
'#' +
trimmed[1] +
trimmed[1] +
trimmed[2] +
trimmed[2] +
trimmed[3] +
trimmed[3]
)
}
return trimmed.toLowerCase()
}
return null
}
// Handle rgb/rgba colors
if (trimmed.startsWith('rgb')) {
const rgbaMatch = trimmed.match(
/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+))?\s*\)/
)
if (rgbaMatch) {
const [, r, g, b, a] = rgbaMatch
const red = Math.max(0, Math.min(255, parseInt(r)))
const green = Math.max(0, Math.min(255, parseInt(g)))
const blue = Math.max(0, Math.min(255, parseInt(b)))
const alpha = a ? Math.max(0, Math.min(1, parseFloat(a))) : 1
if (alpha === 1) {
return `rgb(${red}, ${green}, ${blue})`
} else {
return `rgba(${red}, ${green}, ${blue}, ${alpha})`
}
}
return null
}
// Handle hsl/hsla colors
if (trimmed.startsWith('hsl')) {
const hslaMatch = trimmed.match(
/hsla?\(\s*(\d+)\s*,\s*(\d+)%?\s*,\s*(\d+)%?\s*(?:,\s*([\d.]+))?\s*\)/
)
if (hslaMatch) {
const [, h, s, l, a] = hslaMatch
const hue = Math.max(0, Math.min(360, parseInt(h)))
const saturation = Math.max(0, Math.min(100, parseInt(s)))
const lightness = Math.max(0, Math.min(100, parseInt(l)))
const alpha = a ? Math.max(0, Math.min(1, parseFloat(a))) : 1
if (alpha === 1) {
return `hsl(${hue}, ${saturation}%, ${lightness}%)`
} else {
return `hsla(${hue}, ${saturation}%, ${lightness}%, ${alpha})`
}
}
return null
}
// Handle hsv/hsva colors (custom format)
if (trimmed.startsWith('hsv')) {
const hsvaMatch = trimmed.match(
/hsva?\(\s*(\d+)\s*,\s*(\d+)%?\s*,\s*(\d+)%?\s*(?:,\s*([\d.]+))?\s*\)/
)
if (hsvaMatch) {
const [, h, s, v, a] = hsvaMatch
const hue = Math.max(0, Math.min(360, parseInt(h)))
const saturation = Math.max(0, Math.min(100, parseInt(s)))
const value = Math.max(0, Math.min(100, parseInt(v)))
const alpha = a ? Math.max(0, Math.min(1, parseFloat(a))) : 1
if (alpha === 1) {
return `hsv(${hue}, ${saturation}%, ${value}%)`
} else {
return `hsva(${hue}, ${saturation}%, ${value}%, ${alpha})`
}
}
return null
}
// Handle named colors by converting to hex (basic set)
const namedColors: Record<string, string> = {
red: '#ff0000',
green: '#008000',
blue: '#0000ff',
white: '#ffffff',
black: '#000000',
yellow: '#ffff00',
cyan: '#00ffff',
magenta: '#ff00ff',
orange: '#ffa500',
purple: '#800080',
pink: '#ffc0cb',
brown: '#a52a2a',
gray: '#808080',
grey: '#808080'
}
const lowerTrimmed = trimmed.toLowerCase()
if (namedColors[lowerTrimmed]) {
return namedColors[lowerTrimmed]
}
// If we can't parse it, return null
return null
}
// Export types for use in other modules
export type { ColorPickerWidgetOptions }

View File

@@ -1,9 +1,7 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import type { IComboWidget } from '@comfyorg/litegraph/dist/types/widgets'
import { ref } from 'vue'
import MultiSelectWidget from '@/components/graph/widgets/MultiSelectWidget.vue'
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
import {
ComboInputSpec,
type InputSpec,
@@ -14,19 +12,11 @@ import {
ComponentWidgetImpl,
addWidget
} from '@/scripts/domWidget'
import {
type ComfyWidgetConstructorV2,
addValueControlWidgets
} from '@/scripts/widgets'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
import { useRemoteWidget } from './useRemoteWidget'
import { useDropdownComboWidget } from './useDropdownComboWidget'
const getDefaultValue = (inputSpec: ComboInputSpec) => {
if (inputSpec.default) return inputSpec.default
if (inputSpec.options?.length) return inputSpec.options[0]
if (inputSpec.remote) return 'Loading...'
return undefined
}
// Default value logic is now handled in useDropdownComboWidget
const addMultiSelectWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => {
const widgetValue = ref<string[]>([])
@@ -39,7 +29,9 @@ const addMultiSelectWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => {
getValue: () => widgetValue.value,
setValue: (value: string[]) => {
widgetValue.value = value
}
},
// Optional: whether to serialize this widget's value
serialize: true
}
})
addWidget(node, widget as BaseDOMWidget<object | string>)
@@ -49,49 +41,9 @@ const addMultiSelectWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => {
}
const addComboWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => {
const defaultValue = getDefaultValue(inputSpec)
const comboOptions = inputSpec.options ?? []
const widget = node.addWidget(
'combo',
inputSpec.name,
defaultValue,
() => {},
{
values: comboOptions
}
) as IComboWidget
if (inputSpec.remote) {
const remoteWidget = useRemoteWidget({
remoteConfig: inputSpec.remote,
defaultValue,
node,
widget
})
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()
}
})
}
if (inputSpec.control_after_generate) {
widget.linkedWidgets = addValueControlWidgets(
node,
widget,
undefined,
undefined,
transformInputSpecV2ToV1(inputSpec)
)
}
return widget
// Use the new dropdown combo widget for single-selection combo widgets
const dropdownWidget = useDropdownComboWidget()
return dropdownWidget(node, inputSpec)
}
export const useComboWidget = () => {

View File

@@ -0,0 +1,94 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import { ref } from 'vue'
import DropdownComboWidget from '@/components/graph/widgets/DropdownComboWidget.vue'
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
import type {
ComboInputSpec,
InputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
import { addValueControlWidgets } from '@/scripts/widgets'
import { useRemoteWidget } from './useRemoteWidget'
const getDefaultValue = (inputSpec: ComboInputSpec) => {
if (inputSpec.default) return inputSpec.default
if (inputSpec.options?.length) return inputSpec.options[0]
if (inputSpec.remote) return 'Loading...'
return ''
}
export const useDropdownComboWidget = (
options: { defaultValue?: string } = {}
) => {
const widgetConstructor: ComfyWidgetConstructorV2 = (
node: LGraphNode,
inputSpec: InputSpec
) => {
// Type assertion to ComboInputSpec since this is specifically for combo widgets
const comboInputSpec = inputSpec as ComboInputSpec
// Initialize widget value
const defaultValue = options.defaultValue ?? getDefaultValue(comboInputSpec)
const widgetValue = ref<string>(defaultValue)
// Create the widget instance
const widget = new ComponentWidgetImpl<string>({
node,
name: inputSpec.name,
component: DropdownComboWidget,
inputSpec,
options: {
// Required: getter for widget value
getValue: () => widgetValue.value,
// Required: setter for widget value
setValue: (value: string) => {
widgetValue.value = value
},
// Optional: whether to serialize this widget's value
serialize: true
}
})
// Handle remote widget functionality
if (comboInputSpec.remote) {
const remoteWidget = useRemoteWidget({
remoteConfig: comboInputSpec.remote,
defaultValue,
node,
widget: widget as any // Cast to be compatible with the remote widget interface
})
if (comboInputSpec.remote.refresh_button) {
remoteWidget.addRefreshButton()
}
// Update the widget to use remote data
// Note: The remote widget will handle updating the options through the inputSpec
}
// Handle control_after_generate widgets
if (comboInputSpec.control_after_generate) {
const linkedWidgets = addValueControlWidgets(
node,
widget as any, // Cast to be compatible with legacy widget interface
undefined,
undefined,
transformInputSpecV2ToV1(comboInputSpec)
)
// Store reference to linked widgets (mimicking original behavior)
;(widget as any).linkedWidgets = linkedWidgets
}
// Register the widget with the node
addWidget(node, widget as any)
return widget
}
return widgetConstructor
}

View File

@@ -6,7 +6,7 @@ import {
type InputSpec,
isFloatInputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
import { useSettingStore } from '@/stores/settingStore'
function onFloatValueChange(this: INumericWidget, v: number) {

View File

@@ -1,317 +1,49 @@
import {
BaseWidget,
type CanvasPointer,
type LGraphNode,
LiteGraph
} from '@comfyorg/litegraph'
import type {
IBaseWidget,
IWidgetOptions
} from '@comfyorg/litegraph/dist/types/widgets'
import type { LGraphNode } from '@comfyorg/litegraph'
import { ref } from 'vue'
import ImagePreviewWidget from '@/components/graph/widgets/ImagePreviewWidget.vue'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { app } from '@/scripts/app'
import { calculateImageGrid } from '@/scripts/ui/imagePreview'
import { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { useCanvasStore } from '@/stores/graphStore'
import { useSettingStore } from '@/stores/settingStore'
import { is_all_same_aspect_ratio } from '@/utils/imageUtil'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
const renderPreview = (
ctx: CanvasRenderingContext2D,
node: LGraphNode,
shiftY: number
// Removed PADDING constant as it's no longer needed for CSS flexbox layout
export const useImagePreviewWidget = (
options: { defaultValue?: string | string[] } = {}
) => {
const canvas = useCanvasStore().getCanvas()
const mouse = canvas.graph_mouse
if (!canvas.pointer_is_down && node.pointerDown) {
if (
mouse[0] === node.pointerDown.pos[0] &&
mouse[1] === node.pointerDown.pos[1]
) {
node.imageIndex = node.pointerDown.index
}
node.pointerDown = null
}
const imgs = node.imgs ?? []
let { imageIndex } = node
const numImages = imgs.length
if (numImages === 1 && !imageIndex) {
// This skips the thumbnail render section below
node.imageIndex = imageIndex = 0
}
const settingStore = useSettingStore()
const allowImageSizeDraw = settingStore.get('Comfy.Node.AllowImageSizeDraw')
const IMAGE_TEXT_SIZE_TEXT_HEIGHT = allowImageSizeDraw ? 15 : 0
const dw = node.size[0]
const dh = node.size[1] - shiftY - IMAGE_TEXT_SIZE_TEXT_HEIGHT
if (imageIndex == null) {
// No image selected; draw thumbnails of all
let cellWidth: number
let cellHeight: number
let shiftX: number
let cell_padding: number
let cols: number
const compact_mode = is_all_same_aspect_ratio(imgs)
if (!compact_mode) {
// use rectangle cell style and border line
cell_padding = 2
// Prevent infinite canvas2d scale-up
const largestDimension = imgs.reduce(
(acc, current) =>
Math.max(acc, current.naturalWidth, current.naturalHeight),
0
)
const fakeImgs = []
fakeImgs.length = imgs.length
fakeImgs[0] = {
naturalWidth: largestDimension,
naturalHeight: largestDimension
}
;({ cellWidth, cellHeight, cols, shiftX } = calculateImageGrid(
fakeImgs,
dw,
dh
))
} else {
cell_padding = 0
;({ cellWidth, cellHeight, cols, shiftX } = calculateImageGrid(
imgs,
dw,
dh
))
}
let anyHovered = false
node.imageRects = []
for (let i = 0; i < numImages; i++) {
const img = imgs[i]
const row = Math.floor(i / cols)
const col = i % cols
const x = col * cellWidth + shiftX
const y = row * cellHeight + shiftY
if (!anyHovered) {
anyHovered = LiteGraph.isInsideRectangle(
mouse[0],
mouse[1],
x + node.pos[0],
y + node.pos[1],
cellWidth,
cellHeight
)
if (anyHovered) {
node.overIndex = i
let value = 110
if (canvas.pointer_is_down) {
if (!node.pointerDown || node.pointerDown.index !== i) {
node.pointerDown = { index: i, pos: [...mouse] }
}
value = 125
}
ctx.filter = `contrast(${value}%) brightness(${value}%)`
canvas.canvas.style.cursor = 'pointer'
}
}
node.imageRects.push([x, y, cellWidth, cellHeight])
const wratio = cellWidth / img.width
const hratio = cellHeight / img.height
const ratio = Math.min(wratio, hratio)
const imgHeight = ratio * img.height
const imgY = row * cellHeight + shiftY + (cellHeight - imgHeight) / 2
const imgWidth = ratio * img.width
const imgX = col * cellWidth + shiftX + (cellWidth - imgWidth) / 2
ctx.drawImage(
img,
imgX + cell_padding,
imgY + cell_padding,
imgWidth - cell_padding * 2,
imgHeight - cell_padding * 2
)
if (!compact_mode) {
// rectangle cell and border line style
ctx.strokeStyle = '#8F8F8F'
ctx.lineWidth = 1
ctx.strokeRect(
x + cell_padding,
y + cell_padding,
cellWidth - cell_padding * 2,
cellHeight - cell_padding * 2
)
}
ctx.filter = 'none'
}
if (!anyHovered) {
node.pointerDown = null
node.overIndex = null
}
return
}
// Draw individual
const img = imgs[imageIndex]
let w = img.naturalWidth
let h = img.naturalHeight
const scaleX = dw / w
const scaleY = dh / h
const scale = Math.min(scaleX, scaleY, 1)
w *= scale
h *= scale
const x = (dw - w) / 2
const y = (dh - h) / 2 + shiftY
ctx.drawImage(img, x, y, w, h)
// Draw image size text below the image
if (allowImageSizeDraw) {
ctx.fillStyle = LiteGraph.NODE_TEXT_COLOR
ctx.textAlign = 'center'
ctx.font = '10px sans-serif'
const sizeText = `${Math.round(img.naturalWidth)} × ${Math.round(img.naturalHeight)}`
const textY = y + h + 10
ctx.fillText(sizeText, x + w / 2, textY)
}
const drawButton = (
x: number,
y: number,
sz: number,
text: string
): boolean => {
const hovered = LiteGraph.isInsideRectangle(
mouse[0],
mouse[1],
x + node.pos[0],
y + node.pos[1],
sz,
sz
)
let fill = '#333'
let textFill = '#fff'
let isClicking = false
if (hovered) {
canvas.canvas.style.cursor = 'pointer'
if (canvas.pointer_is_down) {
fill = '#1e90ff'
isClicking = true
} else {
fill = '#eee'
textFill = '#000'
}
}
ctx.fillStyle = fill
ctx.beginPath()
ctx.roundRect(x, y, sz, sz, [4])
ctx.fill()
ctx.fillStyle = textFill
ctx.font = '12px Arial'
ctx.textAlign = 'center'
ctx.fillText(text, x + 15, y + 20)
return isClicking
}
if (!(numImages > 1)) return
const imageNum = (node.imageIndex ?? 0) + 1
if (drawButton(dw - 40, dh + shiftY - 40, 30, `${imageNum}/${numImages}`)) {
const i = imageNum >= numImages ? 0 : imageNum
if (!node.pointerDown || node.pointerDown.index !== i) {
node.pointerDown = { index: i, pos: [...mouse] }
}
}
if (drawButton(dw - 40, shiftY + 10, 30, `x`)) {
if (!node.pointerDown || node.pointerDown.index !== null) {
node.pointerDown = { index: null, pos: [...mouse] }
}
}
}
class ImagePreviewWidget extends BaseWidget {
constructor(
node: LGraphNode,
name: string,
options: IWidgetOptions<string | object>
) {
const widget: IBaseWidget = {
name,
options,
type: 'custom',
/** Dummy value to satisfy type requirements. */
value: '',
y: 0
}
super(widget, node)
// Don't serialize the widget value
this.serialize = false
}
override drawWidget(ctx: CanvasRenderingContext2D): void {
renderPreview(ctx, this.node, this.y)
}
override onPointerDown(pointer: CanvasPointer, node: LGraphNode): boolean {
pointer.onDragStart = () => {
const { canvas } = app
const { graph } = canvas
canvas.emitBeforeChange()
graph?.beforeChange()
// Ensure that dragging is properly cleaned up, on success or failure.
pointer.finally = () => {
canvas.isDragging = false
graph?.afterChange()
canvas.emitAfterChange()
}
canvas.processSelect(node, pointer.eDown)
canvas.isDragging = true
}
pointer.onDragEnd = (e) => {
const { canvas } = app
if (e.shiftKey || LiteGraph.alwaysSnapToGrid)
canvas.graph?.snapToGrid(canvas.selectedItems)
canvas.setDirty(true, true)
}
return true
}
override onClick(): void {}
override computeLayoutSize() {
return {
minHeight: 220,
minWidth: 1
}
}
}
export const useImagePreviewWidget = () => {
const widgetConstructor: ComfyWidgetConstructorV2 = (
node: LGraphNode,
inputSpec: InputSpec
) => {
return node.addCustomWidget(
new ImagePreviewWidget(node, inputSpec.name, {
serialize: false
})
// Initialize widget value
const widgetValue = ref<string | string[]>(
options.defaultValue ?? (inputSpec.allow_batch ? [] : '')
)
// Create the Vue-based widget instance
const widget = new ComponentWidgetImpl<string | string[]>({
node,
name: inputSpec.name,
component: ImagePreviewWidget,
inputSpec,
options: {
// Required: getter for widget value
getValue: () => widgetValue.value,
// Required: setter for widget value
setValue: (value: string | string[]) => {
widgetValue.value = value
},
// Optional: whether to serialize this widget's value
serialize: false
}
})
// Register the widget with the node
addWidget(node, widget as any)
return widget
}
return widgetConstructor

View File

@@ -0,0 +1,208 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import { IComboWidget } from '@comfyorg/litegraph/dist/types/widgets'
import { ref } from 'vue'
import MediaLoaderWidget from '@/components/graph/widgets/MediaLoaderWidget.vue'
import { useNodeImage, useNodeVideo } from '@/composables/node/useNodeImage'
import { useNodeImagePreview } from '@/composables/node/useNodeImagePreview'
import { useValueTransform } from '@/composables/useValueTransform'
import type { ResultItem } from '@/schemas/apiSchema'
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
import type { InputSpec } from '@/schemas/nodeDefSchema'
import { api } from '@/scripts/api'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { useToastStore } from '@/stores/toastStore'
import { createAnnotatedPath } from '@/utils/formatUtil'
import { addToComboValues } from '@/utils/litegraphUtil'
const ACCEPTED_IMAGE_TYPES = 'image/png,image/jpeg,image/webp'
const ACCEPTED_VIDEO_TYPES = 'video/webm,video/mp4'
const PASTED_IMAGE_EXPIRY_MS = 2000
const uploadFile = async (file: File, isPasted: boolean) => {
const body = new FormData()
body.append('image', file)
if (isPasted) body.append('subfolder', 'pasted')
const resp = await api.fetchApi('/upload/image', {
method: 'POST',
body
})
if (resp.status !== 200) {
useToastStore().addAlert(resp.status + ' - ' + resp.statusText)
return
}
const data = await resp.json()
return data.subfolder ? `${data.subfolder}/${data.name}` : data.name
}
type InternalFile = string | ResultItem
type InternalValue = InternalFile | InternalFile[]
type ExposedValue = string | string[]
const isImageFile = (file: File) => file.type.startsWith('image/')
const isVideoFile = (file: File) => file.type.startsWith('video/')
const findFileComboWidget = (node: LGraphNode, inputName: string) =>
node.widgets!.find((w) => w.name === inputName) as IComboWidget & {
value: ExposedValue
}
export const useImageUploadMediaWidget = () => {
const widgetConstructor: ComfyWidgetConstructor = (
node: LGraphNode,
inputName: string,
inputData: InputSpec
) => {
const inputOptions = inputData[1] ?? {}
const { imageInputName, allow_batch, image_folder = 'input' } = inputOptions
const nodeOutputStore = useNodeOutputStore()
const isAnimated = !!inputOptions.animated_image_upload
const isVideo = !!inputOptions.video_upload
const accept = isVideo ? ACCEPTED_VIDEO_TYPES : ACCEPTED_IMAGE_TYPES
const { showPreview } = isVideo ? useNodeVideo(node) : useNodeImage(node)
const { showImagePreview } = useNodeImagePreview()
const fileFilter = isVideo ? isVideoFile : isImageFile
// @ts-expect-error InputSpec is not typed correctly
const fileComboWidget = findFileComboWidget(node, imageInputName)
const initialFile = `${fileComboWidget.value}`
const formatPath = (value: InternalFile) =>
// @ts-expect-error InputSpec is not typed correctly
createAnnotatedPath(value, { rootFolder: image_folder })
const transform = (internalValue: InternalValue): ExposedValue => {
if (!internalValue) return initialFile
if (Array.isArray(internalValue))
return allow_batch
? internalValue.map(formatPath)
: formatPath(internalValue[0])
return formatPath(internalValue)
}
Object.defineProperty(
fileComboWidget,
'value',
useValueTransform(transform, initialFile)
)
// Convert the V1 input spec to V2 format for the MediaLoader widget
const inputSpecV2 = transformInputSpecV1ToV2(inputData, { name: inputName })
// State for MediaLoader widget
const uploadedFiles = ref<string[]>([])
// Create the MediaLoader widget directly
const uploadWidget = new ComponentWidgetImpl<string[], { accept?: string }>(
{
node,
name: inputName,
component: MediaLoaderWidget,
inputSpec: inputSpecV2,
props: {
accept
},
options: {
getValue: () => uploadedFiles.value,
setValue: (value: string[]) => {
uploadedFiles.value = value
},
serialize: false,
onFilesSelected: async (files: File[]) => {
const isPastedFile = (file: File): boolean =>
file.name === 'image.png' &&
file.lastModified - Date.now() < PASTED_IMAGE_EXPIRY_MS
const handleUpload = async (file: File) => {
try {
const path = await uploadFile(file, isPastedFile(file))
if (!path) return
return path
} catch (error) {
useToastStore().addAlert(String(error))
}
}
// Filter and upload files
const filteredFiles = files.filter(fileFilter)
const paths = await Promise.all(filteredFiles.map(handleUpload))
const validPaths = paths.filter((p): p is string => !!p)
if (validPaths.length) {
validPaths.forEach((path) =>
addToComboValues(fileComboWidget, path)
)
const output = allow_batch ? validPaths : validPaths[0]
// @ts-expect-error litegraph combo value type does not support arrays yet
fileComboWidget.value = output
// Update widget value to show file names
uploadedFiles.value = Array.isArray(output) ? output : [output]
// Trigger the combo widget callback to update all dependent widgets
fileComboWidget.callback?.(output)
}
}
} as any
}
)
// Register the widget with the node
addWidget(node, uploadWidget as any)
// Store the original callback if it exists
const originalCallback = fileComboWidget.callback
// Add our own callback to the combo widget to render an image when it changes
fileComboWidget.callback = function (value?: any) {
// Call original callback first if it exists
originalCallback?.call(this, value)
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, {
isAnimated
})
// Use Vue widget for image preview, fallback to DOM widget for video
if (!isVideo) {
showImagePreview(node, fileComboWidget.value, {
allow_batch: allow_batch as boolean,
image_folder: image_folder as string,
imageInputName: imageInputName as string
})
}
node.graph?.setDirtyCanvas(true)
}
// On load if we have a value then render the image
// The value isnt set immediately so we need to wait a moment
// No change callbacks seem to be fired on initial setting of the value
requestAnimationFrame(() => {
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, {
isAnimated
})
// Use appropriate preview method
if (isVideo) {
showPreview({ block: false })
} else {
showImagePreview(node, fileComboWidget.value, {
allow_batch: allow_batch as boolean,
image_folder: image_folder as string,
imageInputName: imageInputName as string
})
}
})
return { widget: uploadWidget }
}
return widgetConstructor
}

View File

@@ -2,14 +2,14 @@ import type { LGraphNode } from '@comfyorg/litegraph'
import { IComboWidget } from '@comfyorg/litegraph/dist/types/widgets'
import { useNodeImage, useNodeVideo } from '@/composables/node/useNodeImage'
import { useNodeImagePreview } from '@/composables/node/useNodeImagePreview'
import { useNodeImageUpload } from '@/composables/node/useNodeImageUpload'
import { useValueTransform } from '@/composables/useValueTransform'
import { t } from '@/i18n'
import type { ResultItem, ResultItemType } from '@/schemas/apiSchema'
import type { ResultItem } from '@/schemas/apiSchema'
import type { InputSpec } from '@/schemas/nodeDefSchema'
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { isImageUploadInput } from '@/types/nodeDefAugmentation'
import { createAnnotatedPath } from '@/utils/formatUtil'
import { addToComboValues } from '@/utils/litegraphUtil'
@@ -34,26 +34,22 @@ export const useImageUploadWidget = () => {
inputName: string,
inputData: InputSpec
) => {
if (!isImageUploadInput(inputData)) {
throw new Error(
'Image upload widget requires imageInputName augmentation'
)
}
const inputOptions = inputData[1]
const inputOptions = inputData[1] ?? {}
const { imageInputName, allow_batch, image_folder = 'input' } = inputOptions
const folder: ResultItemType | undefined = image_folder
const nodeOutputStore = useNodeOutputStore()
const isAnimated = !!inputOptions.animated_image_upload
const isVideo = !!inputOptions.video_upload
const accept = isVideo ? ACCEPTED_VIDEO_TYPES : ACCEPTED_IMAGE_TYPES
const { showPreview } = isVideo ? useNodeVideo(node) : useNodeImage(node)
const { showImagePreview } = useNodeImagePreview()
const fileFilter = isVideo ? isVideoFile : isImageFile
// @ts-expect-error InputSpec is not typed correctly
const fileComboWidget = findFileComboWidget(node, imageInputName)
const initialFile = `${fileComboWidget.value}`
const formatPath = (value: InternalFile) =>
// @ts-expect-error InputSpec is not typed correctly
createAnnotatedPath(value, { rootFolder: image_folder })
const transform = (internalValue: InternalValue): ExposedValue => {
@@ -73,10 +69,10 @@ export const useImageUploadWidget = () => {
// Setup file upload handling
const { openFileSelection } = useNodeImageUpload(node, {
// @ts-expect-error InputSpec is not typed correctly
allow_batch,
fileFilter,
accept,
folder,
onUploadComplete: (output) => {
output.forEach((path) => addToComboValues(fileComboWidget, path))
// @ts-expect-error litegraph combo value type does not support arrays yet
@@ -102,6 +98,16 @@ export const useImageUploadWidget = () => {
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, {
isAnimated
})
// Use Vue widget for image preview, fallback to DOM widget for video
if (!isVideo) {
showImagePreview(node, fileComboWidget.value, {
allow_batch: allow_batch as boolean,
image_folder: image_folder as string,
imageInputName: imageInputName as string
})
}
node.graph?.setDirtyCanvas(true)
}
@@ -112,7 +118,17 @@ export const useImageUploadWidget = () => {
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, {
isAnimated
})
showPreview({ block: false })
// Use appropriate preview method
if (isVideo) {
showPreview({ block: false })
} else {
showImagePreview(node, fileComboWidget.value, {
allow_batch: allow_batch as boolean,
image_folder: image_folder as string,
imageInputName: imageInputName as string
})
}
})
return { widget: uploadWidget }

View File

@@ -1,15 +1,11 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import type { INumericWidget } from '@comfyorg/litegraph/dist/types/widgets'
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
import {
type InputSpec,
isIntInputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import {
type ComfyWidgetConstructorV2,
addValueControlWidget
} from '@/scripts/widgets'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
import { useSettingStore } from '@/stores/settingStore'
function onValueChange(this: INumericWidget, v: number) {
@@ -81,15 +77,19 @@ export const useIntWidget = () => {
['seed', 'noise_seed'].includes(inputSpec.name)
if (controlAfterGenerate) {
const seedControl = addValueControlWidget(
node,
widget,
'randomize',
undefined,
undefined,
transformInputSpecV2ToV1(inputSpec)
// TODO: Re-implement control widget functionality without circular dependency
console.warn(
'Control widget functionality temporarily disabled for int widgets due to circular dependency'
)
widget.linkedWidgets = [seedControl]
// const seedControl = addValueControlWidget(
// node,
// widget,
// 'randomize',
// undefined,
// undefined,
// transformInputSpecV2ToV1(inputSpec)
// )
// widget.linkedWidgets = [seedControl]
}
return widget

View File

@@ -10,7 +10,7 @@ import { Markdown as TiptapMarkdown } from 'tiptap-markdown'
import { type InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { app } from '@/scripts/app'
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
function addMarkdownWidget(
node: LGraphNode,

View File

@@ -0,0 +1,66 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import { ref } from 'vue'
import MediaLoaderWidget from '@/components/graph/widgets/MediaLoaderWidget.vue'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import {
ComponentWidgetImpl,
type DOMWidgetOptions,
addWidget
} from '@/scripts/domWidget'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
interface MediaLoaderOptions {
defaultValue?: string[]
accept?: string
onFilesSelected?: (files: File[]) => void
}
interface MediaLoaderWidgetOptions extends DOMWidgetOptions<string[]> {
onFilesSelected?: (files: File[]) => void
}
export const useMediaLoaderWidget = (options: MediaLoaderOptions = {}) => {
const widgetConstructor: ComfyWidgetConstructorV2 = (
node: LGraphNode,
inputSpec: InputSpec
) => {
// Initialize widget value
const widgetValue = ref<string[]>(options.defaultValue ?? [])
// Create the widget instance
const widget = new ComponentWidgetImpl<string[], { accept?: string }>({
node,
name: inputSpec.name,
component: MediaLoaderWidget,
inputSpec,
props: {
accept: options.accept
},
options: {
// Required: getter for widget value
getValue: () => widgetValue.value,
// Required: setter for widget value
setValue: (value: string[]) => {
widgetValue.value = Array.isArray(value) ? value : []
},
// Optional: whether to serialize this widget's value
serialize: true,
// Custom option for file selection callback
onFilesSelected: options.onFilesSelected
} as MediaLoaderWidgetOptions
})
// Register the widget with the node
addWidget(node, widget as any)
return widget
}
return widgetConstructor
}

View File

@@ -4,15 +4,10 @@ import { ref } from 'vue'
import TextPreviewWidget from '@/components/graph/widgets/TextPreviewWidget.vue'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
const PADDING = 16
export const useTextPreviewWidget = (
options: {
minHeight?: number
} = {}
) => {
export const useTextPreviewWidget = () => {
const widgetConstructor: ComfyWidgetConstructorV2 = (
node: LGraphNode,
inputSpec: InputSpec
@@ -28,7 +23,6 @@ export const useTextPreviewWidget = (
setValue: (value: string | object) => {
widgetValue.value = typeof value === 'string' ? value : String(value)
},
getMinHeight: () => options.minHeight ?? 42 + PADDING,
serialize: false
}
})

View File

@@ -2,9 +2,7 @@ import { LGraphNode } from '@comfyorg/litegraph'
import { IWidget } from '@comfyorg/litegraph'
import axios from 'axios'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { RemoteWidgetConfig } from '@/schemas/nodeDefSchema'
import { api } from '@/scripts/api'
const MAX_RETRIES = 5
const TIMEOUT = 4096
@@ -222,46 +220,6 @@ export function useRemoteWidget<
node.addWidget('button', 'refresh', 'refresh', widget.refresh)
}
/**
* Add auto-refresh toggle widget and execution success listener
*/
function addAutoRefreshToggle() {
let autoRefreshEnabled = false
// Handler for execution success
const handleExecutionSuccess = () => {
if (autoRefreshEnabled && widget.refresh) {
widget.refresh()
}
}
// Add toggle widget
const autoRefreshWidget = node.addWidget(
'toggle',
'Auto-refresh after generation',
false,
(value: boolean) => {
autoRefreshEnabled = value
},
{
serialize: false
}
)
// Register event listener
api.addEventListener('execution_success', handleExecutionSuccess)
// Cleanup on node removal
node.onRemoved = useChainCallback(node.onRemoved, function () {
api.removeEventListener('execution_success', handleExecutionSuccess)
})
return autoRefreshWidget
}
// Always add auto-refresh toggle for remote widgets
addAutoRefreshToggle()
return {
getCachedValue,
getValue,

View File

@@ -5,7 +5,7 @@ import {
isStringInputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import { app } from '@/scripts/app'
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
import { useSettingStore } from '@/stores/settingStore'
function addMultilineWidget(
@@ -91,6 +91,55 @@ function addMultilineWidget(
return widget
}
function addSingleLineWidget(
node: LGraphNode,
name: string,
opts: { defaultVal: string; placeholder?: string }
) {
const inputEl = document.createElement('input')
inputEl.className = 'comfy-text-input'
inputEl.type = 'text'
inputEl.value = opts.defaultVal
inputEl.placeholder = opts.placeholder || name
const widget = node.addDOMWidget(name, 'text', inputEl, {
getValue(): string {
return inputEl.value
},
setValue(v: string) {
inputEl.value = v
}
})
widget.inputEl = inputEl
widget.options.minNodeSize = [200, 40]
inputEl.addEventListener('input', () => {
widget.callback?.(widget.value)
})
// Allow middle mouse button panning
inputEl.addEventListener('pointerdown', (event: PointerEvent) => {
if (event.button === 1) {
app.canvas.processMouseDown(event)
}
})
inputEl.addEventListener('pointermove', (event: PointerEvent) => {
if ((event.buttons & 4) === 4) {
app.canvas.processMouseMove(event)
}
})
inputEl.addEventListener('pointerup', (event: PointerEvent) => {
if (event.button === 1) {
app.canvas.processMouseUp(event)
}
})
return widget
}
export const useStringWidget = () => {
const widgetConstructor: ComfyWidgetConstructorV2 = (
node: LGraphNode,
@@ -108,7 +157,10 @@ export const useStringWidget = () => {
defaultVal,
placeholder: inputSpec.placeholder
})
: node.addWidget('text', inputSpec.name, defaultVal, () => {}, {})
: addSingleLineWidget(node, inputSpec.name, {
defaultVal,
placeholder: inputSpec.placeholder
})
if (typeof inputSpec.dynamicPrompts === 'boolean') {
widget.dynamicPrompts = inputSpec.dynamicPrompts

View File

@@ -0,0 +1,60 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import { ref } from 'vue'
import StringWidget from '@/components/graph/widgets/StringWidget.vue'
import {
type InputSpec,
isStringInputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
// Removed PADDING constant as it's no longer needed for CSS flexbox layout
export const useStringWidgetVue = (options: { defaultValue?: string } = {}) => {
const widgetConstructor: ComfyWidgetConstructorV2 = (
node: LGraphNode,
inputSpec: InputSpec
) => {
if (!isStringInputSpec(inputSpec)) {
throw new Error(`Invalid input data: ${inputSpec}`)
}
// Initialize widget value
const widgetValue = ref<string>(
inputSpec.default ?? options.defaultValue ?? ''
)
// Create the Vue-based widget instance
const widget = new ComponentWidgetImpl<string>({
node,
name: inputSpec.name,
component: StringWidget,
inputSpec,
options: {
// Required: getter for widget value
getValue: () => widgetValue.value,
// Required: setter for widget value
setValue: (value: string) => {
widgetValue.value = value
},
// Optional: whether to serialize this widget's value
serialize: true
}
})
// Add dynamic prompts support if specified
if (typeof inputSpec.dynamicPrompts === 'boolean') {
widget.dynamicPrompts = inputSpec.dynamicPrompts
}
// Register the widget with the node
addWidget(node, widget as any)
return widget
}
return widgetConstructor
}

View File

@@ -430,7 +430,14 @@ export const CORE_SETTINGS: SettingParams[] = [
defaultValue: 'Top',
name: 'Use new menu',
type: 'combo',
options: ['Disabled', 'Top', 'Bottom']
options: ['Disabled', 'Top', 'Bottom'],
migrateDeprecatedValue: (value: string) => {
// Floating is now supported by dragging the docked actionbar off.
if (value === 'Floating') {
return 'Top'
}
return value
}
},
{
id: 'Comfy.Workflow.WorkflowTabsPosition',
@@ -463,7 +470,15 @@ export const CORE_SETTINGS: SettingParams[] = [
type: 'hidden',
defaultValue: [] as Keybinding[],
versionAdded: '1.3.7',
versionModified: '1.7.3'
versionModified: '1.7.3',
migrateDeprecatedValue: (value: any[]) => {
return value.map((keybinding) => {
if (keybinding['targetSelector'] === '#graph-canvas') {
keybinding['targetElementId'] = 'graph-canvas'
}
return keybinding
})
}
},
{
id: 'Comfy.Keybinding.NewBindings',
@@ -701,7 +716,11 @@ export const CORE_SETTINGS: SettingParams[] = [
name: 'The active color palette id',
type: 'hidden',
defaultValue: 'dark',
versionModified: '1.6.7'
versionModified: '1.6.7',
migrateDeprecatedValue(value: string) {
// Legacy custom palettes were prefixed with 'custom_'
return value.startsWith('custom_') ? value.replace('custom_', '') : value
}
},
{
id: 'Comfy.CustomColorPalettes',

View File

@@ -1,3 +0,0 @@
export const SEARCH_CACHE_MAX_SIZE = 64
export const DEFAULT_PAGE_SIZE = 64
export const MIN_CHARS_FOR_SUGGESTIONS_ALGOLIA = 2

View File

@@ -5,7 +5,7 @@ import { EventManagerInterface, PreviewManagerInterface } from './interfaces'
export class PreviewManager implements PreviewManagerInterface {
previewCamera: THREE.Camera
previewContainer: HTMLDivElement = null!
previewContainer: HTMLDivElement = {} as HTMLDivElement
showPreview: boolean = true
previewWidth: number = 120

View File

@@ -5,7 +5,6 @@ import { useNodeDragAndDrop } from '@/composables/node/useNodeDragAndDrop'
import { useNodeFileInput } from '@/composables/node/useNodeFileInput'
import { useNodePaste } from '@/composables/node/useNodePaste'
import { t } from '@/i18n'
import type { ResultItemType } from '@/schemas/apiSchema'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import type { DOMWidget } from '@/scripts/domWidget'
import { useToastStore } from '@/stores/toastStore'
@@ -13,6 +12,8 @@ import { useToastStore } from '@/stores/toastStore'
import { api } from '../../scripts/api'
import { app } from '../../scripts/app'
type FolderType = 'input' | 'output' | 'temp'
function splitFilePath(path: string): [string, string] {
const folder_separator = path.lastIndexOf('/')
if (folder_separator === -1) {
@@ -27,7 +28,7 @@ function splitFilePath(path: string): [string, string] {
function getResourceURL(
subfolder: string,
filename: string,
type: ResultItemType = 'input'
type: FolderType = 'input'
): string {
const params = [
'filename=' + encodeURIComponent(filename),

View File

@@ -44,18 +44,6 @@
"Comfy_Canvas_FitView": {
"label": "Fit view to selected nodes"
},
"Comfy_Canvas_MoveSelectedNodes_Down": {
"label": "Move Selected Nodes Down"
},
"Comfy_Canvas_MoveSelectedNodes_Left": {
"label": "Move Selected Nodes Left"
},
"Comfy_Canvas_MoveSelectedNodes_Right": {
"label": "Move Selected Nodes Right"
},
"Comfy_Canvas_MoveSelectedNodes_Up": {
"label": "Move Selected Nodes Up"
},
"Comfy_Canvas_ResetView": {
"label": "Reset View"
},
@@ -147,10 +135,10 @@
"label": "Load Default Workflow"
},
"Comfy_Manager_CustomNodesManager": {
"label": "Toggle the Custom Nodes Manager"
"label": "Custom Nodes Manager"
},
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "Toggle the Custom Nodes Manager Progress Bar"
"label": "Toggle Progress Dialog"
},
"Comfy_MaskEditor_OpenMaskEditor": {
"label": "Open Mask Editor for Selected Node"

View File

@@ -161,7 +161,6 @@
"lastUpdated": "Last Updated",
"noDescription": "No description available",
"installSelected": "Install Selected",
"installAllMissingNodes": "Install All Missing Nodes",
"packsSelected": "Packs Selected",
"status": {
"active": "Active",
@@ -231,6 +230,16 @@
"black": "Black",
"custom": "Custom"
},
"widgets": {
"colorPicker": {
"clickToEdit": "Click to edit color",
"selectColor": "Select a color",
"formatRGBA": "RGBA",
"formatHSLA": "HSLA",
"formatHSVA": "HSVA",
"formatHEX": "HEX"
}
},
"contextMenu": {
"Inputs": "Inputs",
"Outputs": "Outputs",
@@ -795,10 +804,6 @@
"Browse Templates": "Browse Templates",
"Delete Selected Items": "Delete Selected Items",
"Fit view to selected nodes": "Fit view to selected nodes",
"Move Selected Nodes Down": "Move Selected Nodes Down",
"Move Selected Nodes Left": "Move Selected Nodes Left",
"Move Selected Nodes Right": "Move Selected Nodes Right",
"Move Selected Nodes Up": "Move Selected Nodes Up",
"Reset View": "Reset View",
"Resize Selected Nodes": "Resize Selected Nodes",
"Canvas Toggle Link Visibility": "Canvas Toggle Link Visibility",
@@ -829,8 +834,8 @@
"ComfyUI Issues": "ComfyUI Issues",
"Interrupt": "Interrupt",
"Load Default Workflow": "Load Default Workflow",
"Toggle the Custom Nodes Manager": "Toggle the Custom Nodes Manager",
"Toggle the Custom Nodes Manager Progress Bar": "Toggle the Custom Nodes Manager Progress Bar",
"Custom Nodes Manager": "Custom Nodes Manager",
"Toggle Progress Dialog": "Toggle Progress Dialog",
"Open Mask Editor for Selected Node": "Open Mask Editor for Selected Node",
"New": "New",
"Clipspace": "Clipspace",
@@ -1195,11 +1200,6 @@
"missingModels": "Missing Models",
"missingModelsMessage": "When loading the graph, the following models were not found"
},
"loadWorkflowWarning": {
"outdatedVersion": "Some nodes require a newer version of ComfyUI (current: {version}). Please update to use all nodes.",
"outdatedVersionGeneric": "Some nodes require a newer version of ComfyUI. Please update to use all nodes.",
"coreNodesFromVersion": "Requires ComfyUI {version}:"
},
"errorDialog": {
"defaultTitle": "An error occurred",
"loadWorkflowTitle": "Loading aborted due to error reloading workflow data",

View File

@@ -44,18 +44,6 @@
"Comfy_Canvas_FitView": {
"label": "Ajustar vista a los nodos seleccionados"
},
"Comfy_Canvas_MoveSelectedNodes_Down": {
"label": "Mover nodos seleccionados hacia abajo"
},
"Comfy_Canvas_MoveSelectedNodes_Left": {
"label": "Mover nodos seleccionados a la izquierda"
},
"Comfy_Canvas_MoveSelectedNodes_Right": {
"label": "Mover nodos seleccionados a la derecha"
},
"Comfy_Canvas_MoveSelectedNodes_Up": {
"label": "Mover nodos seleccionados hacia arriba"
},
"Comfy_Canvas_ResetView": {
"label": "Restablecer vista"
},

View File

@@ -551,11 +551,6 @@
"uploadBackgroundImage": "Subir imagen de fondo",
"uploadTexture": "Subir textura"
},
"loadWorkflowWarning": {
"coreNodesFromVersion": "Requiere ComfyUI {version}:",
"outdatedVersion": "Algunos nodos requieren una versión más reciente de ComfyUI (actual: {version}). Por favor, actualiza para usar todos los nodos.",
"outdatedVersionGeneric": "Algunos nodos requieren una versión más reciente de ComfyUI. Por favor, actualiza para usar todos los nodos."
},
"maintenance": {
"None": "Ninguno",
"OK": "OK",
@@ -591,7 +586,6 @@
},
"inWorkflow": "En Flujo de Trabajo",
"infoPanelEmpty": "Haz clic en un elemento para ver la información",
"installAllMissingNodes": "Instalar todos los nodos faltantes",
"installSelected": "Instalar Seleccionado",
"installationQueue": "Cola de Instalación",
"lastUpdated": "Última Actualización",
@@ -700,6 +694,7 @@
"ComfyUI Issues": "Problemas de ComfyUI",
"Contact Support": "Contactar soporte",
"Convert selected nodes to group node": "Convertir nodos seleccionados en nodo de grupo",
"Custom Nodes Manager": "Gestor de nodos personalizados",
"Delete Selected Items": "Eliminar elementos seleccionados",
"Desktop User Guide": "Guía de usuario de escritorio",
"Duplicate Current Workflow": "Duplicar flujo de trabajo actual",
@@ -714,10 +709,6 @@
"Interrupt": "Interrumpir",
"Load Default Workflow": "Cargar flujo de trabajo predeterminado",
"Manage group nodes": "Gestionar nodos de grupo",
"Move Selected Nodes Down": "Mover nodos seleccionados hacia abajo",
"Move Selected Nodes Left": "Mover nodos seleccionados hacia la izquierda",
"Move Selected Nodes Right": "Mover nodos seleccionados hacia la derecha",
"Move Selected Nodes Up": "Mover nodos seleccionados hacia arriba",
"Mute/Unmute Selected Nodes": "Silenciar/Activar sonido de nodos seleccionados",
"New": "Nuevo",
"Next Opened Workflow": "Siguiente flujo de trabajo abierto",
@@ -753,13 +744,12 @@
"Toggle Logs Bottom Panel": "Alternar panel inferior de registros",
"Toggle Model Library Sidebar": "Alternar barra lateral de biblioteca de modelos",
"Toggle Node Library Sidebar": "Alternar barra lateral de biblioteca de nodos",
"Toggle Progress Dialog": "Alternar diálogo de progreso",
"Toggle Queue Sidebar": "Alternar barra lateral de cola",
"Toggle Search Box": "Alternar caja de búsqueda",
"Toggle Terminal Bottom Panel": "Alternar panel inferior de terminal",
"Toggle Theme (Dark/Light)": "Alternar tema (Oscuro/Claro)",
"Toggle Workflows Sidebar": "Alternar barra lateral de flujos de trabajo",
"Toggle the Custom Nodes Manager": "Alternar el Administrador de Nodos Personalizados",
"Toggle the Custom Nodes Manager Progress Bar": "Alternar la Barra de Progreso del Administrador de Nodos Personalizados",
"Undo": "Deshacer",
"Ungroup selected group nodes": "Desagrupar nodos de grupo seleccionados",
"Workflow": "Flujo de trabajo",
@@ -1439,6 +1429,16 @@
"getStarted": "Empezar",
"title": "Bienvenido a ComfyUI"
},
"widgets": {
"colorPicker": {
"clickToEdit": "Haz clic para editar el color",
"formatHEX": "HEX",
"formatHSLA": "HSLA",
"formatHSVA": "HSVA",
"formatRGBA": "RGBA",
"selectColor": "Selecciona un color"
}
},
"workflowService": {
"enterFilename": "Introduzca el nombre del archivo",
"exportWorkflow": "Exportar flujo de trabajo",

View File

@@ -44,18 +44,6 @@
"Comfy_Canvas_FitView": {
"label": "Ajuster la vue aux nœuds sélectionnés"
},
"Comfy_Canvas_MoveSelectedNodes_Down": {
"label": "Déplacer les nœuds sélectionnés vers le bas"
},
"Comfy_Canvas_MoveSelectedNodes_Left": {
"label": "Déplacer les nœuds sélectionnés vers la gauche"
},
"Comfy_Canvas_MoveSelectedNodes_Right": {
"label": "Déplacer les nœuds sélectionnés vers la droite"
},
"Comfy_Canvas_MoveSelectedNodes_Up": {
"label": "Déplacer les nœuds sélectionnés vers le haut"
},
"Comfy_Canvas_ResetView": {
"label": "Réinitialiser la vue"
},

View File

@@ -551,11 +551,6 @@
"uploadBackgroundImage": "Télécharger l'image de fond",
"uploadTexture": "Télécharger Texture"
},
"loadWorkflowWarning": {
"coreNodesFromVersion": "Nécessite ComfyUI {version} :",
"outdatedVersion": "Certains nœuds nécessitent une version plus récente de ComfyUI (actuelle : {version}). Veuillez mettre à jour pour utiliser tous les nœuds.",
"outdatedVersionGeneric": "Certains nœuds nécessitent une version plus récente de ComfyUI. Veuillez mettre à jour pour utiliser tous les nœuds."
},
"maintenance": {
"None": "Aucun",
"OK": "OK",
@@ -591,7 +586,6 @@
},
"inWorkflow": "Dans le flux de travail",
"infoPanelEmpty": "Cliquez sur un élément pour voir les informations",
"installAllMissingNodes": "Installer tous les nœuds manquants",
"installSelected": "Installer sélectionné",
"installationQueue": "File d'attente d'installation",
"lastUpdated": "Dernière mise à jour",
@@ -700,6 +694,7 @@
"ComfyUI Issues": "Problèmes de ComfyUI",
"Contact Support": "Contacter le support",
"Convert selected nodes to group node": "Convertir les nœuds sélectionnés en nœud de groupe",
"Custom Nodes Manager": "Gestionnaire de Nœuds Personnalisés",
"Delete Selected Items": "Supprimer les éléments sélectionnés",
"Desktop User Guide": "Guide de l'utilisateur de bureau",
"Duplicate Current Workflow": "Dupliquer le flux de travail actuel",
@@ -714,10 +709,6 @@
"Interrupt": "Interrompre",
"Load Default Workflow": "Charger le flux de travail par défaut",
"Manage group nodes": "Gérer les nœuds de groupe",
"Move Selected Nodes Down": "Déplacer les nœuds sélectionnés vers le bas",
"Move Selected Nodes Left": "Déplacer les nœuds sélectionnés vers la gauche",
"Move Selected Nodes Right": "Déplacer les nœuds sélectionnés vers la droite",
"Move Selected Nodes Up": "Déplacer les nœuds sélectionnés vers le haut",
"Mute/Unmute Selected Nodes": "Mettre en sourdine/Activer le son des nœuds sélectionnés",
"New": "Nouveau",
"Next Opened Workflow": "Prochain flux de travail ouvert",
@@ -753,13 +744,12 @@
"Toggle Logs Bottom Panel": "Basculer le panneau inférieur des journaux",
"Toggle Model Library Sidebar": "Basculer la barre latérale de la bibliothèque de modèles",
"Toggle Node Library Sidebar": "Basculer la barre latérale de la bibliothèque de nœuds",
"Toggle Progress Dialog": "Basculer la boîte de dialogue de progression",
"Toggle Queue Sidebar": "Basculer la barre latérale de la file d'attente",
"Toggle Search Box": "Basculer la boîte de recherche",
"Toggle Terminal Bottom Panel": "Basculer le panneau inférieur du terminal",
"Toggle Theme (Dark/Light)": "Basculer le thème (Sombre/Clair)",
"Toggle Workflows Sidebar": "Basculer la barre latérale des flux de travail",
"Toggle the Custom Nodes Manager": "Basculer le gestionnaire de nœuds personnalisés",
"Toggle the Custom Nodes Manager Progress Bar": "Basculer la barre de progression du gestionnaire de nœuds personnalisés",
"Undo": "Annuler",
"Ungroup selected group nodes": "Dégrouper les nœuds de groupe sélectionnés",
"Workflow": "Flux de travail",
@@ -1439,6 +1429,16 @@
"getStarted": "Commencer",
"title": "Bienvenue sur ComfyUI"
},
"widgets": {
"colorPicker": {
"clickToEdit": "Cliquez pour modifier la couleur",
"formatHEX": "HEX",
"formatHSLA": "HSLA",
"formatHSVA": "HSVA",
"formatRGBA": "RGBA",
"selectColor": "Sélectionnez une couleur"
}
},
"workflowService": {
"enterFilename": "Entrez le nom du fichier",
"exportWorkflow": "Exporter le flux de travail",

View File

@@ -44,18 +44,6 @@
"Comfy_Canvas_FitView": {
"label": "選択したノードにビューを合わせる"
},
"Comfy_Canvas_MoveSelectedNodes_Down": {
"label": "選択したノードを下に移動"
},
"Comfy_Canvas_MoveSelectedNodes_Left": {
"label": "選択したノードを左に移動"
},
"Comfy_Canvas_MoveSelectedNodes_Right": {
"label": "選択したノードを右に移動"
},
"Comfy_Canvas_MoveSelectedNodes_Up": {
"label": "選択したノードを上に移動"
},
"Comfy_Canvas_ResetView": {
"label": "ビューをリセット"
},

View File

@@ -551,11 +551,6 @@
"uploadBackgroundImage": "背景画像をアップロード",
"uploadTexture": "テクスチャをアップロード"
},
"loadWorkflowWarning": {
"coreNodesFromVersion": "ComfyUI {version} が必要です:",
"outdatedVersion": "一部のードはより新しいバージョンのComfyUIが必要です現在のバージョン{version})。すべてのノードを使用するにはアップデートしてください。",
"outdatedVersionGeneric": "一部のードはより新しいバージョンのComfyUIが必要です。すべてのードを使用するにはアップデートしてください。"
},
"maintenance": {
"None": "なし",
"OK": "OK",
@@ -591,7 +586,6 @@
},
"inWorkflow": "ワークフロー内",
"infoPanelEmpty": "アイテムをクリックして情報を表示します",
"installAllMissingNodes": "すべての不足しているノードをインストール",
"installSelected": "選択したものをインストール",
"installationQueue": "インストールキュー",
"lastUpdated": "最終更新日",
@@ -700,6 +694,7 @@
"ComfyUI Issues": "ComfyUIの問題",
"Contact Support": "サポートに連絡",
"Convert selected nodes to group node": "選択したノードをグループノードに変換",
"Custom Nodes Manager": "カスタムノードマネージャ",
"Delete Selected Items": "選択したアイテムを削除",
"Desktop User Guide": "デスクトップユーザーガイド",
"Duplicate Current Workflow": "現在のワークフローを複製",
@@ -714,10 +709,6 @@
"Interrupt": "中断",
"Load Default Workflow": "デフォルトワークフローを読み込む",
"Manage group nodes": "グループノードを管理",
"Move Selected Nodes Down": "選択したノードを下へ移動",
"Move Selected Nodes Left": "選択したノードを左へ移動",
"Move Selected Nodes Right": "選択したノードを右へ移動",
"Move Selected Nodes Up": "選択したノードを上へ移動",
"Mute/Unmute Selected Nodes": "選択したノードのミュート/ミュート解除",
"New": "新規",
"Next Opened Workflow": "次に開いたワークフロー",
@@ -753,13 +744,12 @@
"Toggle Logs Bottom Panel": "ログパネル下部を切り替え",
"Toggle Model Library Sidebar": "モデルライブラリサイドバーを切り替え",
"Toggle Node Library Sidebar": "ノードライブラリサイドバーを切り替え",
"Toggle Progress Dialog": "進行状況ダイアログの切り替え",
"Toggle Queue Sidebar": "キューサイドバーを切り替え",
"Toggle Search Box": "検索ボックスの切り替え",
"Toggle Terminal Bottom Panel": "ターミナルパネル下部を切り替え",
"Toggle Theme (Dark/Light)": "テーマを切り替え(ダーク/ライト)",
"Toggle Workflows Sidebar": "ワークフローサイドバーを切り替え",
"Toggle the Custom Nodes Manager": "カスタムノードマネージャーを切り替え",
"Toggle the Custom Nodes Manager Progress Bar": "カスタムノードマネージャーの進行状況バーを切り替え",
"Undo": "元に戻す",
"Ungroup selected group nodes": "選択したグループノードのグループ解除",
"Workflow": "ワークフロー",
@@ -1439,6 +1429,16 @@
"getStarted": "はじめる",
"title": "ComfyUIへようこそ"
},
"widgets": {
"colorPicker": {
"clickToEdit": "色を編集するにはクリックしてください",
"formatHEX": "HEX",
"formatHSLA": "HSLA",
"formatHSVA": "HSVA",
"formatRGBA": "RGBA",
"selectColor": "色を選択"
}
},
"workflowService": {
"enterFilename": "ファイル名を入力",
"exportWorkflow": "ワークフローをエクスポート",

View File

@@ -44,18 +44,6 @@
"Comfy_Canvas_FitView": {
"label": "선택한 노드에 뷰 맞추기"
},
"Comfy_Canvas_MoveSelectedNodes_Down": {
"label": "선택한 노드 아래로 이동"
},
"Comfy_Canvas_MoveSelectedNodes_Left": {
"label": "선택한 노드 왼쪽으로 이동"
},
"Comfy_Canvas_MoveSelectedNodes_Right": {
"label": "선택한 노드 오른쪽으로 이동"
},
"Comfy_Canvas_MoveSelectedNodes_Up": {
"label": "선택한 노드 위로 이동"
},
"Comfy_Canvas_ResetView": {
"label": "뷰 재설정"
},

View File

@@ -551,11 +551,6 @@
"uploadBackgroundImage": "배경 이미지 업로드",
"uploadTexture": "텍스처 업로드"
},
"loadWorkflowWarning": {
"coreNodesFromVersion": "ComfyUI {version} 이상 필요:",
"outdatedVersion": "일부 노드는 더 최신 버전의 ComfyUI가 필요합니다 (현재: {version}). 모든 노드를 사용하려면 업데이트해 주세요.",
"outdatedVersionGeneric": "일부 노드는 더 최신 버전의 ComfyUI가 필요합니다. 모든 노드를 사용하려면 업데이트해 주세요."
},
"maintenance": {
"None": "없음",
"OK": "확인",
@@ -591,7 +586,6 @@
},
"inWorkflow": "워크플로우 내",
"infoPanelEmpty": "정보를 보려면 항목을 클릭하세요",
"installAllMissingNodes": "모든 누락된 노드 설치",
"installSelected": "선택한 항목 설치",
"installationQueue": "설치 대기열",
"lastUpdated": "마지막 업데이트",
@@ -700,6 +694,7 @@
"ComfyUI Issues": "ComfyUI 이슈 페이지",
"Contact Support": "고객 지원 문의",
"Convert selected nodes to group node": "선택한 노드를 그룹 노드로 변환",
"Custom Nodes Manager": "사용자 정의 노드 관리자",
"Delete Selected Items": "선택한 항목 삭제",
"Desktop User Guide": "데스크톱 사용자 가이드",
"Duplicate Current Workflow": "현재 워크플로 복제",
@@ -714,10 +709,6 @@
"Interrupt": "중단",
"Load Default Workflow": "기본 워크플로 불러오기",
"Manage group nodes": "그룹 노드 관리",
"Move Selected Nodes Down": "선택한 노드 아래로 이동",
"Move Selected Nodes Left": "선택한 노드 왼쪽으로 이동",
"Move Selected Nodes Right": "선택한 노드 오른쪽으로 이동",
"Move Selected Nodes Up": "선택한 노드 위로 이동",
"Mute/Unmute Selected Nodes": "선택한 노드 활성화/비활성화",
"New": "새로 만들기",
"Next Opened Workflow": "다음 열린 워크플로",
@@ -753,13 +744,12 @@
"Toggle Logs Bottom Panel": "로그 하단 패널 전환",
"Toggle Model Library Sidebar": "모델 라이브러리 사이드바 전환",
"Toggle Node Library Sidebar": "노드 라이브러리 사이드바 전환",
"Toggle Progress Dialog": "진행 상황 대화 상자 전환",
"Toggle Queue Sidebar": "실행 대기열 사이드바 전환",
"Toggle Search Box": "검색 상자 전환",
"Toggle Terminal Bottom Panel": "터미널 하단 패널 전환",
"Toggle Theme (Dark/Light)": "테마 전환 (어두운/밝은)",
"Toggle Workflows Sidebar": "워크플로 사이드바 전환",
"Toggle the Custom Nodes Manager": "커스텀 노드 매니저 전환",
"Toggle the Custom Nodes Manager Progress Bar": "커스텀 노드 매니저 진행률 표시줄 전환",
"Undo": "실행 취소",
"Ungroup selected group nodes": "선택한 그룹 노드 그룹 해제",
"Workflow": "워크플로",
@@ -1439,6 +1429,16 @@
"getStarted": "시작하기",
"title": "ComfyUI에 오신 것을 환영합니다"
},
"widgets": {
"colorPicker": {
"clickToEdit": "색상 편집하려면 클릭하세요",
"formatHEX": "HEX",
"formatHSLA": "HSLA",
"formatHSVA": "HSVA",
"formatRGBA": "RGBA",
"selectColor": "색상 선택"
}
},
"workflowService": {
"enterFilename": "파일 이름 입력",
"exportWorkflow": "워크플로 내보내기",

View File

@@ -44,18 +44,6 @@
"Comfy_Canvas_FitView": {
"label": "Подогнать вид к выбранным нодам"
},
"Comfy_Canvas_MoveSelectedNodes_Down": {
"label": "Переместить выбранные узлы вниз"
},
"Comfy_Canvas_MoveSelectedNodes_Left": {
"label": "Переместить выбранные узлы влево"
},
"Comfy_Canvas_MoveSelectedNodes_Right": {
"label": "Переместить выбранные узлы вправо"
},
"Comfy_Canvas_MoveSelectedNodes_Up": {
"label": "Переместить выбранные узлы вверх"
},
"Comfy_Canvas_ResetView": {
"label": "Сбросить вид"
},

View File

@@ -551,11 +551,6 @@
"uploadBackgroundImage": "Загрузить фоновое изображение",
"uploadTexture": "Загрузить текстуру"
},
"loadWorkflowWarning": {
"coreNodesFromVersion": "Требуется ComfyUI {version}:",
"outdatedVersion": "Некоторые узлы требуют более новой версии ComfyUI (текущая: {version}). Пожалуйста, обновите, чтобы использовать все узлы.",
"outdatedVersionGeneric": "Некоторые узлы требуют более новой версии ComfyUI. Пожалуйста, обновите, чтобы использовать все узлы."
},
"maintenance": {
"None": "Нет",
"OK": "OK",
@@ -591,7 +586,6 @@
},
"inWorkflow": "В рабочем процессе",
"infoPanelEmpty": "Нажмите на элемент, чтобы увидеть информацию",
"installAllMissingNodes": "Установить все отсутствующие узлы",
"installSelected": "Установить выбранное",
"installationQueue": "Очередь установки",
"lastUpdated": "Последнее обновление",
@@ -700,6 +694,7 @@
"ComfyUI Issues": "Проблемы ComfyUI",
"Contact Support": "Связаться с поддержкой",
"Convert selected nodes to group node": "Преобразовать выбранные ноды в групповую ноду",
"Custom Nodes Manager": "Менеджер Пользовательских Узлов",
"Delete Selected Items": "Удалить выбранные элементы",
"Desktop User Guide": "Руководство пользователя для настольных ПК",
"Duplicate Current Workflow": "Дублировать текущий рабочий процесс",
@@ -714,10 +709,6 @@
"Interrupt": "Прервать",
"Load Default Workflow": "Загрузить стандартный рабочий процесс",
"Manage group nodes": "Управление групповыми нодами",
"Move Selected Nodes Down": "Переместить выбранные узлы вниз",
"Move Selected Nodes Left": "Переместить выбранные узлы влево",
"Move Selected Nodes Right": "Переместить выбранные узлы вправо",
"Move Selected Nodes Up": "Переместить выбранные узлы вверх",
"Mute/Unmute Selected Nodes": "Отключить/включить звук для выбранных нод",
"New": "Новый",
"Next Opened Workflow": "Следующий открытый рабочий процесс",
@@ -753,13 +744,12 @@
"Toggle Logs Bottom Panel": "Переключение нижней панели журналов",
"Toggle Model Library Sidebar": "Переключение боковой панели библиотеки моделей",
"Toggle Node Library Sidebar": "Переключение боковой панели библиотеки нод",
"Toggle Progress Dialog": "Переключить диалоговое окно прогресса",
"Toggle Queue Sidebar": "Переключение боковой панели очереди",
"Toggle Search Box": "Переключить поисковую панель",
"Toggle Terminal Bottom Panel": "Переключение нижней панели терминала",
"Toggle Theme (Dark/Light)": "Переключение темы (Тёмная/Светлая)",
"Toggle Workflows Sidebar": "Переключение боковой панели рабочих процессов",
"Toggle the Custom Nodes Manager": "Переключить менеджер пользовательских узлов",
"Toggle the Custom Nodes Manager Progress Bar": "Переключить индикатор выполнения менеджера пользовательских узлов",
"Undo": "Отменить",
"Ungroup selected group nodes": "Разгруппировать выбранные групповые ноды",
"Workflow": "Рабочий процесс",
@@ -1439,6 +1429,16 @@
"getStarted": "Начать",
"title": "Добро пожаловать в ComfyUI"
},
"widgets": {
"colorPicker": {
"clickToEdit": "Нажмите, чтобы изменить цвет",
"formatHEX": "HEX",
"formatHSLA": "HSLA",
"formatHSVA": "HSVA",
"formatRGBA": "RGBA",
"selectColor": "Выберите цвет"
}
},
"workflowService": {
"enterFilename": "Введите название файла",
"exportWorkflow": "Экспорт рабочего процесса",

View File

@@ -44,18 +44,6 @@
"Comfy_Canvas_FitView": {
"label": "适应视图到选中节点"
},
"Comfy_Canvas_MoveSelectedNodes_Down": {
"label": "下移选中的节点"
},
"Comfy_Canvas_MoveSelectedNodes_Left": {
"label": "向左移动选中节点"
},
"Comfy_Canvas_MoveSelectedNodes_Right": {
"label": "向右移动选中节点"
},
"Comfy_Canvas_MoveSelectedNodes_Up": {
"label": "上移选中的节点"
},
"Comfy_Canvas_ResetView": {
"label": "重置视图"
},

View File

@@ -551,11 +551,6 @@
"uploadBackgroundImage": "上传背景图片",
"uploadTexture": "上传纹理"
},
"loadWorkflowWarning": {
"coreNodesFromVersion": "需要 ComfyUI {version}",
"outdatedVersion": "某些节点需要更高版本的 ComfyUI当前版本{version})。请更新以使用所有节点。",
"outdatedVersionGeneric": "某些节点需要更高版本的 ComfyUI。请更新以使用所有节点。"
},
"maintenance": {
"None": "无",
"OK": "确定",
@@ -591,7 +586,6 @@
},
"inWorkflow": "在工作流中",
"infoPanelEmpty": "点击一个项目查看信息",
"installAllMissingNodes": "安装所有缺失节点",
"installSelected": "安装选定",
"installationQueue": "安装队列",
"lastUpdated": "最后更新",
@@ -700,6 +694,7 @@
"ComfyUI Issues": "ComfyUI 问题",
"Contact Support": "联系支持",
"Convert selected nodes to group node": "将选中节点转换为组节点",
"Custom Nodes Manager": "自定义节点管理器",
"Delete Selected Items": "删除选定的项目",
"Desktop User Guide": "桌面端用户指南",
"Duplicate Current Workflow": "复制当前工作流",
@@ -714,10 +709,6 @@
"Interrupt": "中断",
"Load Default Workflow": "加载默认工作流",
"Manage group nodes": "管理组节点",
"Move Selected Nodes Down": "下移所选节点",
"Move Selected Nodes Left": "左移所选节点",
"Move Selected Nodes Right": "右移所选节点",
"Move Selected Nodes Up": "上移所选节点",
"Mute/Unmute Selected Nodes": "静音/取消静音选定节点",
"New": "新建",
"Next Opened Workflow": "下一个打开的工作流",
@@ -753,13 +744,12 @@
"Toggle Logs Bottom Panel": "切换日志底部面板",
"Toggle Model Library Sidebar": "切换模型库侧边栏",
"Toggle Node Library Sidebar": "切换节点库侧边栏",
"Toggle Progress Dialog": "切换进度对话框",
"Toggle Queue Sidebar": "切换队列侧边栏",
"Toggle Search Box": "切换搜索框",
"Toggle Terminal Bottom Panel": "切换终端底部面板",
"Toggle Theme (Dark/Light)": "切换主题(暗/亮)",
"Toggle Workflows Sidebar": "切换工作流侧边栏",
"Toggle the Custom Nodes Manager": "切换自定义节点管理器",
"Toggle the Custom Nodes Manager Progress Bar": "切换自定义节点管理器进度条",
"Undo": "撤销",
"Ungroup selected group nodes": "解散选中组节点",
"Workflow": "工作流",
@@ -1043,9 +1033,9 @@
"Extension": "扩展",
"General": "常规",
"Graph": "画面",
"Group": "组",
"Group": "组节点",
"Keybinding": "快捷键",
"Light": "光照",
"Light": "浅色",
"Link": "连线",
"LinkRelease": "释放链接",
"LiteGraph": "画面",
@@ -1439,6 +1429,16 @@
"getStarted": "开始使用",
"title": "欢迎使用 ComfyUI"
},
"widgets": {
"colorPicker": {
"clickToEdit": "点击编辑颜色",
"formatHEX": "HEX",
"formatHSLA": "HSLA",
"formatHSVA": "HSVA",
"formatRGBA": "RGBA",
"selectColor": "选择颜色"
}
},
"workflowService": {
"enterFilename": "输入文件名",
"exportWorkflow": "导出工作流",

View File

@@ -11,13 +11,10 @@ import { LinkReleaseTriggerAction } from '@/types/searchBoxTypes'
const zNodeType = z.string()
const zQueueIndex = z.number()
const zPromptId = z.string()
export const resultItemType = z.enum(['input', 'output', 'temp'])
export type ResultItemType = z.infer<typeof resultItemType>
const zResultItem = z.object({
filename: z.string().optional(),
subfolder: z.string().optional(),
type: resultItemType.optional()
type: z.string().optional()
})
export type ResultItem = z.infer<typeof zResultItem>
const zOutputs = z

View File

@@ -46,26 +46,22 @@ export function transformNodeDefV1ToV2(
const outputs: OutputSpecV2[] = []
if (nodeDefV1.output) {
if (Array.isArray(nodeDefV1.output)) {
nodeDefV1.output.forEach((outputType, index) => {
const outputSpec: OutputSpecV2 = {
index,
name: nodeDefV1.output_name?.[index] || `output_${index}`,
type: Array.isArray(outputType) ? 'COMBO' : outputType,
is_list: nodeDefV1.output_is_list?.[index] || false,
tooltip: nodeDefV1.output_tooltips?.[index]
}
nodeDefV1.output.forEach((outputType, index) => {
const outputSpec: OutputSpecV2 = {
index,
name: nodeDefV1.output_name?.[index] || `output_${index}`,
type: Array.isArray(outputType) ? 'COMBO' : outputType,
is_list: nodeDefV1.output_is_list?.[index] || false,
tooltip: nodeDefV1.output_tooltips?.[index]
}
// Add options for combo outputs
if (Array.isArray(outputType)) {
outputSpec.options = outputType
}
// Add options for combo outputs
if (Array.isArray(outputType)) {
outputSpec.options = outputType
}
outputs.push(outputSpec)
})
} else {
console.warn('nodeDefV1.output is not an array:', nodeDefV1.output)
}
outputs.push(outputSpec)
})
}
// Create the V2 node definition

View File

@@ -1,8 +1,6 @@
import { z } from 'zod'
import { fromZodError } from 'zod-validation-error'
import { resultItemType } from '@/schemas/apiSchema'
const zComboOption = z.union([z.string(), z.number()])
const zRemoteWidgetConfig = z.object({
route: z.string().url().or(z.string().startsWith('/')),
@@ -74,7 +72,7 @@ export const zStringInputOptions = zBaseInputOptions.extend({
export const zComboInputOptions = zBaseInputOptions.extend({
control_after_generate: z.boolean().optional(),
image_upload: z.boolean().optional(),
image_folder: resultItemType.optional(),
image_folder: z.enum(['input', 'output', 'temp']).optional(),
allow_batch: z.boolean().optional(),
video_upload: z.boolean().optional(),
animated_image_upload: z.boolean().optional(),

View File

@@ -1150,6 +1150,7 @@ export class ComfyApp {
}
useExtensionService().invokeExtensions('loadedGraphNode', node)
api.dispatchCustomEvent('graphChanged', { workflow: this.graph.serialize() as unknown as ComfyWorkflowJSON })
}
if (missingNodeTypes.length && showMissingNodesDialog) {
@@ -1169,6 +1170,7 @@ export class ComfyApp {
)
requestAnimationFrame(() => {
this.graph.setDirtyCanvas(true, true)
api.dispatchCustomEvent('graphChanged', { workflow: this.graph.serialize() as unknown as ComfyWorkflowJSON })
})
}

View File

@@ -271,6 +271,27 @@ export class ChangeTracker {
return v
}
// Handle wheel events (zoom/pan with mouse wheel)
const processMouseWheel = LGraphCanvas.prototype.processMouseWheel
LGraphCanvas.prototype.processMouseWheel = function (e) {
const v = processMouseWheel.apply(this, [e])
logger.debug('checkState on processMouseWheel')
checkState()
return v
}
// Handle drag events (panning)
const processMouseMove = LGraphCanvas.prototype.processMouseMove
LGraphCanvas.prototype.processMouseMove = function (e) {
const v = processMouseMove.apply(this, [e])
// Only check state if we're dragging the canvas (not a node)
if (this.dragging_canvas) {
logger.debug('checkState on processMouseMove (canvas drag)')
checkState()
}
return v
}
// Handle litegraph dialog popup for number/string widgets
const prompt = LGraphCanvas.prototype.prompt
LGraphCanvas.prototype.prompt = function (
@@ -369,10 +390,8 @@ export class ChangeTracker {
return false
}
// Compare extra properties ignoring ds
if (
!_.isEqual(_.omit(a.extra ?? {}, ['ds']), _.omit(b.extra ?? {}, ['ds']))
)
// Compare extra properties including ds for Vue node position updates
if (!_.isEqual(a.extra ?? {}, b.extra ?? {}))
return false
// Compare other properties normally

View File

@@ -241,7 +241,8 @@ export class ComponentWidgetImpl<
}) {
super({
...obj,
type: 'custom'
type: 'custom',
options: { hideOnZoom: true, ...obj.options }
})
this.component = obj.component
this.inputSpec = obj.inputSpec

View File

@@ -0,0 +1,12 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import type { IBaseWidget } from '@comfyorg/litegraph/dist/types/widgets'
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
/**
* Constructor function type for ComfyUI widgets using V2 input specification
*/
export type ComfyWidgetConstructorV2 = (
node: LGraphNode,
inputSpec: InputSpecV2
) => IBaseWidget

View File

@@ -5,27 +5,25 @@ import type {
IStringWidget
} from '@comfyorg/litegraph/dist/types/widgets'
import { useBadgedNumberInput } from '@/composables/widgets/useBadgedNumberInput'
import { useBooleanWidget } from '@/composables/widgets/useBooleanWidget'
import { useColorPickerWidget } from '@/composables/widgets/useColorPickerWidget'
import { useComboWidget } from '@/composables/widgets/useComboWidget'
import { useFloatWidget } from '@/composables/widgets/useFloatWidget'
import { useImageUploadWidget } from '@/composables/widgets/useImageUploadWidget'
import { useIntWidget } from '@/composables/widgets/useIntWidget'
import { useImagePreviewWidget } from '@/composables/widgets/useImagePreviewWidget'
import { useImageUploadMediaWidget } from '@/composables/widgets/useImageUploadMediaWidget'
import { useMarkdownWidget } from '@/composables/widgets/useMarkdownWidget'
import { useMediaLoaderWidget } from '@/composables/widgets/useMediaLoaderWidget'
import { useStringWidget } from '@/composables/widgets/useStringWidget'
import { useStringWidgetVue } from '@/composables/widgets/useStringWidgetVue'
import { t } from '@/i18n'
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { InputSpec } from '@/schemas/nodeDefSchema'
import { useSettingStore } from '@/stores/settingStore'
import type { ComfyApp } from './app'
import './domWidget'
import './errorNodeWidgets'
export type ComfyWidgetConstructorV2 = (
node: LGraphNode,
inputSpec: InputSpecV2
) => IBaseWidget
import type { ComfyWidgetConstructorV2 } from './widgetTypes'
export type ComfyWidgetConstructor = (
node: LGraphNode,
@@ -283,11 +281,18 @@ export function addValueControlWidgets(
}
export const ComfyWidgets: Record<string, ComfyWidgetConstructor> = {
INT: transformWidgetConstructorV2ToV1(useIntWidget()),
FLOAT: transformWidgetConstructorV2ToV1(useFloatWidget()),
INT: transformWidgetConstructorV2ToV1(useBadgedNumberInput({ mode: 'int' })),
FLOAT: transformWidgetConstructorV2ToV1(
useBadgedNumberInput({ mode: 'float' })
),
BOOLEAN: transformWidgetConstructorV2ToV1(useBooleanWidget()),
STRING: transformWidgetConstructorV2ToV1(useStringWidget()),
STRING: transformWidgetConstructorV2ToV1(useStringWidgetVue()),
STRING_DOM: transformWidgetConstructorV2ToV1(useStringWidget()), // Fallback to DOM-based implementation
MARKDOWN: transformWidgetConstructorV2ToV1(useMarkdownWidget()),
COMBO: transformWidgetConstructorV2ToV1(useComboWidget()),
IMAGEUPLOAD: useImageUploadWidget()
COLOR: transformWidgetConstructorV2ToV1(useColorPickerWidget()),
IMAGEUPLOAD: useImageUploadMediaWidget(),
MEDIA_LOADER: transformWidgetConstructorV2ToV1(useMediaLoaderWidget()),
IMAGEPREVIEW: transformWidgetConstructorV2ToV1(useImagePreviewWidget()),
BADGED_NUMBER: transformWidgetConstructorV2ToV1(useBadgedNumberInput())
}

View File

@@ -0,0 +1,257 @@
import QuickLRU from '@alloc/quick-lru'
import type {
BaseSearchParamsWithoutQuery,
Hit,
SearchQuery,
SearchResponse
} from 'algoliasearch/dist/lite/browser'
import { liteClient as algoliasearch } from 'algoliasearch/dist/lite/builds/browser'
import { omit } from 'lodash'
import { components } from '@/types/comfyRegistryTypes'
import { paramsToCacheKey } from '@/utils/formatUtil'
const DEFAULT_MAX_CACHE_SIZE = 64
const DEFAULT_MIN_CHARS_FOR_SUGGESTIONS = 2
type SafeNestedProperty<
T,
K1 extends keyof T,
K2 extends keyof NonNullable<T[K1]>
> = T[K1] extends undefined | null ? undefined : NonNullable<T[K1]>[K2]
type RegistryNodePack = components['schemas']['Node']
type SearchPacksResult = {
nodePacks: Hit<AlgoliaNodePack>[]
querySuggestions: Hit<NodesIndexSuggestion>[]
}
export interface AlgoliaNodePack {
objectID: RegistryNodePack['id']
name: RegistryNodePack['name']
publisher_id: SafeNestedProperty<RegistryNodePack, 'publisher', 'id'>
description: RegistryNodePack['description']
comfy_nodes: string[]
total_install: RegistryNodePack['downloads']
id: RegistryNodePack['id']
create_time: string
update_time: SafeNestedProperty<
RegistryNodePack,
'latest_version',
'createdAt'
>
license: RegistryNodePack['license']
repository_url: RegistryNodePack['repository']
status: RegistryNodePack['status']
latest_version: SafeNestedProperty<
RegistryNodePack,
'latest_version',
'version'
>
latest_version_status: SafeNestedProperty<
RegistryNodePack,
'latest_version',
'status'
>
comfy_node_extract_status: SafeNestedProperty<
RegistryNodePack,
'latest_version',
'comfy_node_extract_status'
>
icon_url: RegistryNodePack['icon']
}
export type SearchAttribute = keyof AlgoliaNodePack
const RETRIEVE_ATTRIBUTES: SearchAttribute[] = [
'comfy_nodes',
'name',
'description',
'latest_version',
'status',
'publisher_id',
'total_install',
'create_time',
'update_time',
'license',
'repository_url',
'latest_version_status',
'comfy_node_extract_status',
'id',
'icon_url'
]
export interface NodesIndexSuggestion {
nb_words: number
nodes_index: {
exact_nb_hits: number
facets: {
exact_matches: Record<string, number>
analytics: Record<string, any>
}
}
objectID: RegistryNodePack['id']
popularity: number
query: string
}
type SearchNodePacksParams = BaseSearchParamsWithoutQuery & {
pageSize: number
pageNumber: number
restrictSearchableAttributes: SearchAttribute[]
}
interface AlgoliaSearchServiceOptions {
/**
* Maximum number of search results to store in the cache.
* The cache is automatically cleared when the component is unmounted.
* @default 64
*/
maxCacheSize?: number
/**
* Minimum number of characters for suggestions. An additional query
* will be made to the suggestions/completions index for queries that
* are this length or longer.
* @default 3
*/
minCharsForSuggestions?: number
}
export const useAlgoliaSearchService = (
options: AlgoliaSearchServiceOptions = {}
) => {
const {
maxCacheSize = DEFAULT_MAX_CACHE_SIZE,
minCharsForSuggestions = DEFAULT_MIN_CHARS_FOR_SUGGESTIONS
} = options
const searchClient = algoliasearch(__ALGOLIA_APP_ID__, __ALGOLIA_API_KEY__, {
hosts: [
{
url: 'search.comfy.org/api/search',
accept: 'read',
protocol: 'https'
}
],
baseHeaders: {
'X-Algolia-Application-Id': __ALGOLIA_APP_ID__,
'X-Algolia-API-Key': __ALGOLIA_API_KEY__
}
})
const searchPacksCache = new QuickLRU<string, SearchPacksResult>({
maxSize: maxCacheSize
})
const toRegistryLatestVersion = (
algoliaNode: AlgoliaNodePack
): RegistryNodePack['latest_version'] => {
return {
version: algoliaNode.latest_version,
createdAt: algoliaNode.update_time,
status: algoliaNode.latest_version_status,
comfy_node_extract_status:
algoliaNode.comfy_node_extract_status ?? undefined
}
}
const toRegistryPublisher = (
algoliaNode: AlgoliaNodePack
): RegistryNodePack['publisher'] => {
return {
id: algoliaNode.publisher_id,
name: algoliaNode.publisher_id
}
}
/**
* Convert from node pack in Algolia format to Comfy Registry format
*/
function toRegistryPack(algoliaNode: AlgoliaNodePack): RegistryNodePack {
return {
id: algoliaNode.id ?? algoliaNode.objectID,
name: algoliaNode.name,
description: algoliaNode.description,
repository: algoliaNode.repository_url,
license: algoliaNode.license,
downloads: algoliaNode.total_install,
status: algoliaNode.status,
icon: algoliaNode.icon_url,
latest_version: toRegistryLatestVersion(algoliaNode),
publisher: toRegistryPublisher(algoliaNode),
// @ts-expect-error remove when comfy_nodes is added to node (pack) info
comfy_nodes: algoliaNode.comfy_nodes
}
}
/**
* Search for node packs in Algolia
*/
const searchPacks = async (
query: string,
params: SearchNodePacksParams
): Promise<SearchPacksResult> => {
const { pageSize, pageNumber } = params
const rest = omit(params, ['pageSize', 'pageNumber'])
const requests: SearchQuery[] = [
{
query,
indexName: 'nodes_index',
attributesToRetrieve: RETRIEVE_ATTRIBUTES,
...rest,
hitsPerPage: pageSize,
page: pageNumber
}
]
const shouldQuerySuggestions = query.length >= minCharsForSuggestions
// If the query is long enough, also query the suggestions index
if (shouldQuerySuggestions) {
requests.push({
indexName: 'nodes_index_query_suggestions',
query
})
}
const { results } = await searchClient.search<
AlgoliaNodePack | NodesIndexSuggestion
>({
requests,
strategy: 'none'
})
const [nodePacks, querySuggestions = { hits: [] }] = results as [
SearchResponse<AlgoliaNodePack>,
SearchResponse<NodesIndexSuggestion>
]
return {
nodePacks: nodePacks.hits,
querySuggestions: querySuggestions.hits
}
}
const searchPacksCached = async (
query: string,
params: SearchNodePacksParams
): Promise<SearchPacksResult> => {
const cacheKey = paramsToCacheKey({ query, ...params })
const cachedResult = searchPacksCache.get(cacheKey)
if (cachedResult !== undefined) return cachedResult
const result = await searchPacks(query, params)
searchPacksCache.set(cacheKey, result)
return result
}
const clearSearchPacksCache = () => {
searchPacksCache.clear()
}
return {
searchPacks,
searchPacksCached,
toRegistryPack,
clearSearchPacksCache
}
}

View File

@@ -21,6 +21,7 @@ import TemplateWorkflowsDialogHeader from '@/components/templates/TemplateWorkfl
import { t } from '@/i18n'
import type { ExecutionErrorWsMessage } from '@/schemas/apiSchema'
import { type ShowDialogOptions, useDialogStore } from '@/stores/dialogStore'
import { ManagerTab } from '@/types/comfyManagerTypes'
export type ConfirmationDialogType =
| 'default'
@@ -128,26 +129,19 @@ export const useDialogService = () => {
}
function showManagerDialog(
props: InstanceType<typeof ManagerDialogContent>['$props'] = {}
props: InstanceType<typeof ManagerDialogContent>['$props'] = {
initialTab: ManagerTab.All
}
) {
dialogStore.showDialog({
key: 'global-manager',
component: ManagerDialogContent,
headerComponent: ManagerHeader,
dialogComponentProps: {
closable: true,
closable: false,
pt: {
pcCloseButton: {
root: {
class:
'bg-gray-500 dark-theme:bg-neutral-700 w-9 h-9 p-1.5 rounded-full text-white'
}
},
header: { class: '!py-0 px-6 !m-0 h-[68px]' },
content: {
class: '!p-0 h-full w-[90vw] max-w-full flex-1 overflow-hidden'
},
root: { class: 'manager-dialog' }
header: { class: '!p-0 !m-0' },
content: { class: '!px-0 h-[83vh] w-[90vw] overflow-y-hidden' }
}
},
props
@@ -163,7 +157,6 @@ export const useDialogService = () => {
headerComponent: ManagerProgressHeader,
footerComponent: ManagerProgressFooter,
props: options?.props,
priority: 2,
dialogComponentProps: {
closable: false,
modal: false,
@@ -404,26 +397,6 @@ export const useDialogService = () => {
}
}
function toggleManagerDialog(
props?: InstanceType<typeof ManagerDialogContent>['$props']
) {
if (dialogStore.isDialogOpen('global-manager')) {
dialogStore.closeDialog({ key: 'global-manager' })
} else {
showManagerDialog(props)
}
}
function toggleManagerProgressDialog(
props?: InstanceType<typeof ManagerProgressDialogContent>['$props']
) {
if (dialogStore.isDialogOpen('global-manager-progress-dialog')) {
dialogStore.closeDialog({ key: 'global-manager-progress-dialog' })
} else {
showManagerProgressDialog({ props })
}
}
return {
showLoadWorkflowWarning,
showMissingModelsWarning,
@@ -441,8 +414,6 @@ export const useDialogService = () => {
showUpdatePasswordDialog,
showExtensionDialog,
prompt,
confirm,
toggleManagerDialog,
toggleManagerProgressDialog
confirm
}
}

View File

@@ -1,227 +0,0 @@
import { useAlgoliaSearchProvider } from '@/services/providers/algoliaSearchProvider'
import { useComfyRegistrySearchProvider } from '@/services/providers/registrySearchProvider'
import type { SearchNodePacksParams } from '@/types/algoliaTypes'
import type { components } from '@/types/comfyRegistryTypes'
import type {
NodePackSearchProvider,
SearchPacksResult
} from '@/types/searchServiceTypes'
type RegistryNodePack = components['schemas']['Node']
interface ProviderState {
provider: NodePackSearchProvider
name: string
isHealthy: boolean
lastError?: Error
lastAttempt?: Date
consecutiveFailures: number
}
const CIRCUIT_BREAKER_THRESHOLD = 3 // Number of failures before circuit opens
const CIRCUIT_BREAKER_TIMEOUT = 60000 // 1 minute before retry
/**
* API Gateway for registry search providers with circuit breaker pattern.
* Acts as a single entry point that routes search requests to appropriate providers
* and handles failures gracefully by falling back to alternative providers.
*
* Implements:
* - Gateway pattern: Single entry point for all search requests
* - Circuit breaker: Prevents repeated calls to failed services
* - Automatic failover: Cascades through providers on failure
*/
export const useRegistrySearchGateway = (): NodePackSearchProvider => {
const providers: ProviderState[] = []
let activeProviderIndex = 0
// Initialize providers in priority order
try {
providers.push({
provider: useAlgoliaSearchProvider(),
name: 'Algolia',
isHealthy: true,
consecutiveFailures: 0
})
} catch (error) {
console.warn('Failed to initialize Algolia provider:', error)
}
providers.push({
provider: useComfyRegistrySearchProvider(),
name: 'ComfyRegistry',
isHealthy: true,
consecutiveFailures: 0
})
// TODO: Add an "offline" provider that operates on a local cache of the registry.
/**
* Check if a provider's circuit breaker should be closed (available to try)
*/
const isCircuitClosed = (providerState: ProviderState): boolean => {
if (providerState.consecutiveFailures < CIRCUIT_BREAKER_THRESHOLD) {
return true
}
// Check if enough time has passed to retry
if (providerState.lastAttempt) {
const timeSinceLastAttempt =
Date.now() - providerState.lastAttempt.getTime()
if (timeSinceLastAttempt > CIRCUIT_BREAKER_TIMEOUT) {
console.info(
`Retrying ${providerState.name} provider after circuit breaker timeout`
)
return true
}
}
return false
}
/**
* Record a successful call to a provider
*/
const recordSuccess = (providerState: ProviderState) => {
providerState.isHealthy = true
providerState.consecutiveFailures = 0
providerState.lastError = undefined
}
/**
* Record a failed call to a provider
*/
const recordFailure = (providerState: ProviderState, error: Error) => {
providerState.consecutiveFailures++
providerState.lastError = error
providerState.lastAttempt = new Date()
if (providerState.consecutiveFailures >= CIRCUIT_BREAKER_THRESHOLD) {
providerState.isHealthy = false
console.warn(
`${providerState.name} provider circuit breaker opened after ${providerState.consecutiveFailures} failures`
)
}
}
/**
* Get the currently active provider based on circuit breaker states
*/
const getActiveProvider = (): NodePackSearchProvider => {
// First, try to use the current active provider if it's healthy
const currentProvider = providers[activeProviderIndex]
if (currentProvider && isCircuitClosed(currentProvider)) {
return currentProvider.provider
}
// Otherwise, find the first healthy provider
for (let i = 0; i < providers.length; i++) {
const providerState = providers[i]
if (isCircuitClosed(providerState)) {
activeProviderIndex = i
return providerState.provider
}
}
throw new Error('No available search providers')
}
/**
* Update the active provider index after a failure.
* Move to the next provider if available.
*/
const updateActiveProviderOnFailure = () => {
if (activeProviderIndex < providers.length - 1) {
activeProviderIndex++
}
}
/**
* Search for node packs.
*/
const searchPacks = async (
query: string,
params: SearchNodePacksParams
): Promise<SearchPacksResult> => {
let lastError: Error | null = null
// Start with the current active provider
for (let attempts = 0; attempts < providers.length; attempts++) {
try {
const provider = getActiveProvider()
const providerState = providers[activeProviderIndex]
const result = await provider.searchPacks(query, params)
recordSuccess(providerState)
return result
} catch (error) {
lastError = error as Error
const providerState = providers[activeProviderIndex]
recordFailure(providerState, lastError)
console.warn(
`${providerState.name} search provider failed (${providerState.consecutiveFailures} failures):`,
error
)
// Try the next provider
updateActiveProviderOnFailure()
}
}
// If we get here, all providers failed
throw new Error(
`All search providers failed. Last error: ${lastError?.message || 'Unknown error'}`
)
}
/**
* Clear the search cache for all providers that implement it.
*/
const clearSearchCache = () => {
for (const providerState of providers) {
try {
providerState.provider.clearSearchCache()
} catch (error) {
console.warn(
`Failed to clear cache for ${providerState.name} provider:`,
error
)
}
}
}
/**
* Get the sort value for a pack.
* @example
* const pack = {
* id: '123',
* name: 'Test Pack',
* downloads: 100
* }
* const sortValue = getSortValue(pack, 'downloads')
* console.log(sortValue) // 100
*/
const getSortValue = (
pack: RegistryNodePack,
sortField: string
): string | number => {
return getActiveProvider().getSortValue(pack, sortField)
}
/**
* Get the sortable fields for the active provider.
* @example
* const sortableFields = getSortableFields()
* console.log(sortableFields) // ['downloads', 'created', 'updated', 'publisher', 'name']
*/
const getSortableFields = () => {
return getActiveProvider().getSortableFields()
}
return {
searchPacks,
clearSearchCache,
getSortValue,
getSortableFields
}
}

Some files were not shown because too many files have changed in this diff Show More