mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-17 21:21:06 +00:00
Compare commits
11 Commits
test/cover
...
feat/vue-w
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92fc6f35f3 | ||
|
|
394cefcc92 | ||
|
|
73b9079538 | ||
|
|
a9503c5a2f | ||
|
|
50db96a954 | ||
|
|
81db82306e | ||
|
|
be99596fd3 | ||
|
|
b4a6b8b5ff | ||
|
|
187c80d213 | ||
|
|
bbc7671d31 | ||
|
|
9368b8c329 |
@@ -12,6 +12,10 @@ import { createApp } from 'vue'
|
||||
import { VueFire, VueFireAuth } from 'vuefire'
|
||||
|
||||
import { getFirebaseConfig } from '@/config/firebase'
|
||||
import { exposeVueApi } from '@/utils/vueExtensionApi'
|
||||
|
||||
// Expose Vue utilities for external extensions before they load
|
||||
exposeVueApi()
|
||||
import '@/lib/litegraph/public/css/litegraph.css'
|
||||
import router from '@/router'
|
||||
|
||||
|
||||
@@ -152,7 +152,7 @@ const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
if (!shouldRenderAsVue(widget)) continue
|
||||
|
||||
const vueComponent =
|
||||
getComponent(widget.type, widget.name) ||
|
||||
getComponent(widget.type, widget.name, widget.options?.display) ||
|
||||
(widget.isDOMWidget ? WidgetDOM : WidgetLegacy)
|
||||
|
||||
const { slotMetadata, options } = widget
|
||||
|
||||
@@ -72,7 +72,8 @@ export const useFloatWidget = () => {
|
||||
/** @deprecated Use step2 instead. The 10x value is a legacy implementation. */
|
||||
step: step * 10.0,
|
||||
step2: step,
|
||||
precision
|
||||
precision,
|
||||
display: inputSpec.display
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -63,7 +63,8 @@ export const useIntWidget = () => {
|
||||
/** @deprecated Use step2 instead. The 10x value is a legacy implementation. */
|
||||
step: step * 10,
|
||||
step2: step,
|
||||
precision: 0
|
||||
precision: 0,
|
||||
display: inputSpec.display
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import type { Component } from 'vue'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
clearExtensionWidgets,
|
||||
getComponent,
|
||||
isEssential,
|
||||
registerVueWidgets,
|
||||
shouldExpand,
|
||||
shouldRenderAsVue
|
||||
} from '../widgetRegistry'
|
||||
|
||||
// Mock Vue components for testing (using object literals to avoid Vue linter)
|
||||
const MockComponent = { name: 'MockComponent' } as Component
|
||||
const MockComponent2 = { name: 'MockComponent2' } as Component
|
||||
|
||||
describe('widgetRegistry', () => {
|
||||
afterEach(() => {
|
||||
clearExtensionWidgets()
|
||||
})
|
||||
|
||||
describe('registerVueWidgets', () => {
|
||||
it('should register a custom widget', () => {
|
||||
registerVueWidgets({
|
||||
myCustomWidget: {
|
||||
component: MockComponent
|
||||
}
|
||||
})
|
||||
|
||||
const result = getComponent('myCustomWidget', 'test')
|
||||
expect(result).toBe(MockComponent)
|
||||
})
|
||||
|
||||
it('should register multiple widgets at once', () => {
|
||||
registerVueWidgets({
|
||||
widget1: { component: MockComponent },
|
||||
widget2: { component: MockComponent2 }
|
||||
})
|
||||
|
||||
expect(getComponent('widget1', 'test')).toBe(MockComponent)
|
||||
expect(getComponent('widget2', 'test')).toBe(MockComponent2)
|
||||
})
|
||||
|
||||
it('should register aliases for a widget', () => {
|
||||
registerVueWidgets({
|
||||
myWidget: {
|
||||
component: MockComponent,
|
||||
aliases: ['MY_WIDGET', 'MYWIDGET']
|
||||
}
|
||||
})
|
||||
|
||||
expect(getComponent('myWidget', 'test')).toBe(MockComponent)
|
||||
expect(getComponent('MY_WIDGET', 'test')).toBe(MockComponent)
|
||||
expect(getComponent('MYWIDGET', 'test')).toBe(MockComponent)
|
||||
})
|
||||
|
||||
it('should overwrite previous entry when re-registering same widget key', () => {
|
||||
registerVueWidgets({
|
||||
myWidget: { component: MockComponent }
|
||||
})
|
||||
|
||||
registerVueWidgets({
|
||||
myWidget: { component: MockComponent2 }
|
||||
})
|
||||
|
||||
expect(getComponent('myWidget', 'test')).toBe(MockComponent2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getComponent', () => {
|
||||
it('should return null for unknown widget type', () => {
|
||||
const result = getComponent('unknownType', 'test')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should return core widget component for known type', () => {
|
||||
const result = getComponent('int', 'test')
|
||||
expect(result).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should return core widget component for alias', () => {
|
||||
const result = getComponent('INT', 'test')
|
||||
expect(result).not.toBeNull()
|
||||
})
|
||||
|
||||
it('extension widgets should take precedence over core widgets', () => {
|
||||
registerVueWidgets({
|
||||
int: { component: MockComponent }
|
||||
})
|
||||
|
||||
const result = getComponent('int', 'test')
|
||||
expect(result).toBe(MockComponent)
|
||||
})
|
||||
|
||||
it('extension aliases should take precedence over core aliases', () => {
|
||||
registerVueWidgets({
|
||||
customInt: {
|
||||
component: MockComponent,
|
||||
aliases: ['INT']
|
||||
}
|
||||
})
|
||||
|
||||
const result = getComponent('INT', 'test')
|
||||
expect(result).toBe(MockComponent)
|
||||
})
|
||||
|
||||
it('should use displayHint to find extension widget', () => {
|
||||
registerVueWidgets({
|
||||
star_rating: { component: MockComponent }
|
||||
})
|
||||
|
||||
// Even though type is 'int', the displayHint 'star_rating' should match
|
||||
const result = getComponent('int', 'rating', 'star_rating')
|
||||
expect(result).toBe(MockComponent)
|
||||
})
|
||||
|
||||
it('displayHint alias should work', () => {
|
||||
registerVueWidgets({
|
||||
star_rating: {
|
||||
component: MockComponent,
|
||||
aliases: ['STAR_RATING']
|
||||
}
|
||||
})
|
||||
|
||||
const result = getComponent('int', 'rating', 'STAR_RATING')
|
||||
expect(result).toBe(MockComponent)
|
||||
})
|
||||
|
||||
it('should fall back to type when displayHint has no match', () => {
|
||||
const result = getComponent('int', 'test', 'unknown_display')
|
||||
// Should return core int widget, not null
|
||||
expect(result).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearExtensionWidgets', () => {
|
||||
it('should clear all registered extension widgets', () => {
|
||||
registerVueWidgets({
|
||||
myWidget: { component: MockComponent }
|
||||
})
|
||||
|
||||
expect(getComponent('myWidget', 'test')).toBe(MockComponent)
|
||||
|
||||
clearExtensionWidgets()
|
||||
|
||||
expect(getComponent('myWidget', 'test')).toBeNull()
|
||||
})
|
||||
|
||||
it('should not affect core widgets', () => {
|
||||
registerVueWidgets({
|
||||
myWidget: { component: MockComponent }
|
||||
})
|
||||
|
||||
clearExtensionWidgets()
|
||||
|
||||
expect(getComponent('int', 'test')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isEssential', () => {
|
||||
it('should return true for essential core widgets', () => {
|
||||
expect(isEssential('int')).toBe(true)
|
||||
expect(isEssential('float')).toBe(true)
|
||||
expect(isEssential('combo')).toBe(true)
|
||||
expect(isEssential('boolean')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for non-essential core widgets', () => {
|
||||
expect(isEssential('button')).toBe(false)
|
||||
expect(isEssential('color')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for unknown widget types', () => {
|
||||
expect(isEssential('unknownType')).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for extension widgets (not marked as essential)', () => {
|
||||
registerVueWidgets({
|
||||
myWidget: { component: MockComponent }
|
||||
})
|
||||
|
||||
expect(isEssential('myWidget')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldRenderAsVue', () => {
|
||||
it('should return true for widgets with a type', () => {
|
||||
expect(shouldRenderAsVue({ type: 'int' })).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for widgets without a type', () => {
|
||||
expect(shouldRenderAsVue({})).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for canvasOnly widgets', () => {
|
||||
expect(
|
||||
shouldRenderAsVue({ type: 'int', options: { canvasOnly: true } })
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('should return true for widgets with canvasOnly false', () => {
|
||||
expect(
|
||||
shouldRenderAsVue({ type: 'int', options: { canvasOnly: false } })
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldExpand', () => {
|
||||
it('should return true for textarea', () => {
|
||||
expect(shouldExpand('textarea')).toBe(true)
|
||||
expect(shouldExpand('TEXTAREA')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for markdown', () => {
|
||||
expect(shouldExpand('markdown')).toBe(true)
|
||||
expect(shouldExpand('MARKDOWN')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for load3D', () => {
|
||||
expect(shouldExpand('load3D')).toBe(true)
|
||||
expect(shouldExpand('LOAD_3D')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for non-expanding types', () => {
|
||||
expect(shouldExpand('int')).toBe(false)
|
||||
expect(shouldExpand('string')).toBe(false)
|
||||
expect(shouldExpand('combo')).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,7 @@ import { defineAsyncComponent } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
|
||||
import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager'
|
||||
import type { VueWidgetDefinition } from '@/types/comfy'
|
||||
|
||||
const WidgetButton = defineAsyncComponent(
|
||||
() => import('../components/WidgetButton.vue')
|
||||
@@ -164,34 +165,86 @@ const getComboWidgetAdditions = (): Map<string, Component> => {
|
||||
return new Map([['audio', WidgetAudioUI]])
|
||||
}
|
||||
|
||||
// Build lookup maps
|
||||
const widgets = new Map<string, WidgetDefinition>()
|
||||
const aliasMap = new Map<string, string>()
|
||||
// Build lookup maps for core widgets
|
||||
const coreWidgets = new Map<string, WidgetDefinition>()
|
||||
const coreAliasMap = new Map<string, string>()
|
||||
|
||||
for (const [type, def] of coreWidgetDefinitions) {
|
||||
widgets.set(type, def)
|
||||
coreWidgets.set(type, def)
|
||||
for (const alias of def.aliases) {
|
||||
aliasMap.set(alias, type)
|
||||
coreAliasMap.set(alias, type)
|
||||
}
|
||||
}
|
||||
|
||||
// Utility functions
|
||||
const getCanonicalType = (type: string): string => aliasMap.get(type) || type
|
||||
// Extension-registered widgets (mutable, takes precedence over core)
|
||||
const extensionWidgets = new Map<string, VueWidgetDefinition>()
|
||||
const extensionAliasMap = new Map<string, string>()
|
||||
|
||||
export const getComponent = (type: string, name: string): Component | null => {
|
||||
if (type == 'combo') {
|
||||
const comboAdditions = getComboWidgetAdditions()
|
||||
if (comboAdditions.has(name)) {
|
||||
return comboAdditions.get(name) || null
|
||||
/**
|
||||
* Register custom Vue widgets from extensions.
|
||||
* Extension widgets take precedence over core widgets for type lookup.
|
||||
*/
|
||||
export function registerVueWidgets(
|
||||
widgets: Record<string, VueWidgetDefinition>
|
||||
): void {
|
||||
for (const [type, def] of Object.entries(widgets)) {
|
||||
extensionWidgets.set(type, def)
|
||||
for (const alias of def.aliases ?? []) {
|
||||
extensionAliasMap.set(alias, type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all extension-registered widgets. Useful for testing.
|
||||
*/
|
||||
export function clearExtensionWidgets(): void {
|
||||
extensionWidgets.clear()
|
||||
extensionAliasMap.clear()
|
||||
}
|
||||
|
||||
// Utility functions - extension aliases take precedence
|
||||
const getCanonicalType = (type: string): string =>
|
||||
extensionAliasMap.get(type) ?? coreAliasMap.get(type) ?? type
|
||||
|
||||
export const getComponent = (
|
||||
type: string,
|
||||
name: string,
|
||||
displayHint?: string
|
||||
): Component | null => {
|
||||
// Check display hint first for custom Vue widgets
|
||||
// This allows extensions to override widget rendering via the "display" field
|
||||
if (displayHint) {
|
||||
const displayCanonical = getCanonicalType(displayHint)
|
||||
const extDef = extensionWidgets.get(displayCanonical)
|
||||
if (extDef) {
|
||||
return extDef.component
|
||||
}
|
||||
}
|
||||
|
||||
// Handle combo additions (existing logic)
|
||||
if (type === 'combo') {
|
||||
const comboAdditions = getComboWidgetAdditions()
|
||||
if (comboAdditions.has(name)) {
|
||||
return comboAdditions.get(name) ?? null
|
||||
}
|
||||
}
|
||||
|
||||
const canonicalType = getCanonicalType(type)
|
||||
return widgets.get(canonicalType)?.component || null
|
||||
|
||||
// Extension widgets take precedence over core widgets
|
||||
const extDef = extensionWidgets.get(canonicalType)
|
||||
if (extDef) {
|
||||
return extDef.component
|
||||
}
|
||||
|
||||
// Fall back to core widgets
|
||||
return coreWidgets.get(canonicalType)?.component ?? null
|
||||
}
|
||||
|
||||
export const isEssential = (type: string): boolean => {
|
||||
const canonicalType = getCanonicalType(type)
|
||||
return widgets.get(canonicalType)?.essential || false
|
||||
return coreWidgets.get(canonicalType)?.essential ?? false
|
||||
}
|
||||
|
||||
export const shouldRenderAsVue = (widget: Partial<SafeWidgetData>): boolean => {
|
||||
|
||||
@@ -42,7 +42,12 @@ const zNumericInputOptions = zBaseInputOptions.extend({
|
||||
step: z.number().optional(),
|
||||
/** Note: Many node authors are using INT/FLOAT to pass list of INT/FLOAT. */
|
||||
default: z.union([z.number(), z.array(z.number())]).optional(),
|
||||
display: z.enum(['slider', 'number', 'knob']).optional()
|
||||
/**
|
||||
* Display hint for widget rendering.
|
||||
* Built-in values: 'slider', 'number', 'knob'
|
||||
* Extensions can register custom values via getCustomVueWidgets hook.
|
||||
*/
|
||||
display: z.string().optional()
|
||||
})
|
||||
|
||||
export const zIntInputOptions = zNumericInputOptions.extend({
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { legacyMenuCompat } from '@/lib/litegraph/src/contextMenuCompat'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { registerVueWidgets } from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
@@ -80,6 +81,14 @@ export const useExtensionService = () => {
|
||||
})()
|
||||
}
|
||||
|
||||
if (extension.getCustomVueWidgets) {
|
||||
const getVueWidgets = extension.getCustomVueWidgets
|
||||
void (async () => {
|
||||
const vueWidgets = await getVueWidgets(app)
|
||||
registerVueWidgets(vueWidgets)
|
||||
})()
|
||||
}
|
||||
|
||||
if (extension.onAuthUserResolved) {
|
||||
const { onUserResolved } = useCurrentUser()
|
||||
const handleUserResolved = wrapWithErrorHandlingAsync(
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Component } from 'vue'
|
||||
|
||||
import type {
|
||||
IContextMenuValue,
|
||||
Positionable
|
||||
@@ -15,6 +17,22 @@ import type { BottomPanelExtension } from '@/types/extensionTypes'
|
||||
|
||||
type Widgets = Record<string, ComfyWidgetConstructor>
|
||||
|
||||
/**
|
||||
* Definition for a Vue widget that can be registered by extensions.
|
||||
*/
|
||||
export interface VueWidgetDefinition {
|
||||
/**
|
||||
* The Vue component to render for this widget type
|
||||
*/
|
||||
component: Component
|
||||
/**
|
||||
* Alternative type names that should map to this widget
|
||||
*/
|
||||
aliases?: string[]
|
||||
}
|
||||
|
||||
export type VueWidgets = Record<string, VueWidgetDefinition>
|
||||
|
||||
export interface AboutPageBadge {
|
||||
label: string
|
||||
url: string
|
||||
@@ -160,6 +178,15 @@ export interface ComfyExtension {
|
||||
*/
|
||||
getCustomWidgets?(app: ComfyApp): Promise<Widgets> | Widgets
|
||||
|
||||
/**
|
||||
* Allows the extension to add custom Vue widgets for the Vue node renderer.
|
||||
* These widgets will be used when Vue nodes are enabled and take precedence
|
||||
* over core widgets with the same type.
|
||||
* @param app The ComfyUI app instance
|
||||
* @returns An object mapping widget type names to Vue widget definitions
|
||||
*/
|
||||
getCustomVueWidgets?(app: ComfyApp): Promise<VueWidgets> | VueWidgets
|
||||
|
||||
/**
|
||||
* Allows the extension to add additional commands to the selection toolbox
|
||||
* @param selectedItem The selected item on the canvas
|
||||
|
||||
@@ -24,7 +24,7 @@ import type {
|
||||
ToastMessageOptions
|
||||
} from './extensionTypes'
|
||||
|
||||
export type { ComfyExtension } from './comfy'
|
||||
export type { ComfyExtension, VueWidgetDefinition, VueWidgets } from './comfy'
|
||||
export type { ComfyApi } from '@/scripts/api'
|
||||
export type { ComfyApp } from '@/scripts/app'
|
||||
export type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
|
||||
6
src/types/litegraph-augmentation.d.ts
vendored
6
src/types/litegraph-augmentation.d.ts
vendored
@@ -40,6 +40,12 @@ declare module '@/lib/litegraph/src/types/widgets' {
|
||||
|
||||
/** If the widget is hidden, this will be set to true. */
|
||||
hidden?: boolean
|
||||
|
||||
/**
|
||||
* Display hint from Python node definition for custom widget rendering.
|
||||
* Extensions can use this to render widgets with custom Vue components.
|
||||
*/
|
||||
display?: string
|
||||
}
|
||||
|
||||
interface IBaseWidget {
|
||||
|
||||
19
src/utils/vueExtensionApi.ts
Normal file
19
src/utils/vueExtensionApi.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Exposes Vue for external extensions to create Vue components.
|
||||
*
|
||||
* Usage in extensions:
|
||||
* ```js
|
||||
* const { h, defineComponent, ref, computed } = window.Vue
|
||||
* ```
|
||||
*/
|
||||
import * as Vue from 'vue'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Vue: typeof Vue
|
||||
}
|
||||
}
|
||||
|
||||
export function exposeVueApi(): void {
|
||||
window.Vue = Vue
|
||||
}
|
||||
Reference in New Issue
Block a user