mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-03 11:09:10 +00:00
Compare commits
1 Commits
fix/codera
...
fix/codera
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cb09b823d |
@@ -1,78 +0,0 @@
|
||||
import { nextTick } from 'vue'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { useDomValueBridge } from './useDomValueBridge'
|
||||
|
||||
function createInput(initialValue = ''): HTMLInputElement {
|
||||
const el = document.createElement('input')
|
||||
el.value = initialValue
|
||||
return el
|
||||
}
|
||||
|
||||
function createTextarea(initialValue = ''): HTMLTextAreaElement {
|
||||
const el = document.createElement('textarea')
|
||||
el.value = initialValue
|
||||
return el
|
||||
}
|
||||
|
||||
describe('useDomValueBridge', () => {
|
||||
it('initializes the ref with the current element value', () => {
|
||||
const el = createInput('hello')
|
||||
const bridged = useDomValueBridge(el)
|
||||
expect(bridged.value).toBe('hello')
|
||||
})
|
||||
|
||||
it('updates the ref when element.value is set programmatically', () => {
|
||||
const el = createInput('')
|
||||
const bridged = useDomValueBridge(el)
|
||||
|
||||
el.value = 'updated'
|
||||
expect(bridged.value).toBe('updated')
|
||||
})
|
||||
|
||||
it('updates the ref on user input events', () => {
|
||||
const el = createInput('')
|
||||
const bridged = useDomValueBridge(el)
|
||||
|
||||
// Simulate user typing by using the original descriptor to set value,
|
||||
// then dispatching an input event
|
||||
const proto = Object.getPrototypeOf(el)
|
||||
const desc = Object.getOwnPropertyDescriptor(proto, 'value')
|
||||
desc?.set?.call(el, 'typed')
|
||||
el.dispatchEvent(new Event('input'))
|
||||
|
||||
expect(bridged.value).toBe('typed')
|
||||
})
|
||||
|
||||
it('updates the DOM element when the ref is written to', async () => {
|
||||
const el = createInput('initial')
|
||||
const bridged = useDomValueBridge(el)
|
||||
|
||||
bridged.value = 'from-ref'
|
||||
await nextTick()
|
||||
|
||||
expect(el.value).toBe('from-ref')
|
||||
})
|
||||
|
||||
it('works with textarea elements', () => {
|
||||
const el = createTextarea('initial')
|
||||
const bridged = useDomValueBridge(el)
|
||||
|
||||
expect(bridged.value).toBe('initial')
|
||||
el.value = 'new text'
|
||||
expect(bridged.value).toBe('new text')
|
||||
})
|
||||
|
||||
it('reads element value through the intercepted getter', async () => {
|
||||
const el = createInput('start')
|
||||
const bridged = useDomValueBridge(el)
|
||||
|
||||
// The getter on element.value should still work
|
||||
expect(el.value).toBe('start')
|
||||
|
||||
bridged.value = 'changed'
|
||||
await nextTick()
|
||||
// The element getter should reflect the latest set
|
||||
expect(el.value).toBe('changed')
|
||||
})
|
||||
})
|
||||
@@ -1,79 +0,0 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
type ValueElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
|
||||
|
||||
/**
|
||||
* Bridges a DOM element's `.value` property to a Vue reactive ref.
|
||||
*
|
||||
* This composable provides a clean, public API for extension authors to
|
||||
* synchronize DOM widget values with Vue reactivity (and by extension, the
|
||||
* `widgetValueStore`). It works by:
|
||||
*
|
||||
* 1. Intercepting programmatic `.value` writes via `Object.defineProperty`
|
||||
* 2. Listening for user-driven `input` events on the element
|
||||
* 3. Exposing a reactive `Ref<string>` that stays in sync with the DOM
|
||||
*
|
||||
* When the returned ref is written to, the DOM element's value is updated.
|
||||
* When the DOM element's value changes (programmatically or via user input),
|
||||
* the ref is updated.
|
||||
*
|
||||
* @param element - The DOM element to bridge (input, textarea, or select)
|
||||
* @returns A reactive ref that stays in sync with the element's value
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // In a custom widget's getValue/setValue:
|
||||
* const bridgedValue = useDomValueBridge(inputEl)
|
||||
* const widget = node.addDOMWidget(name, type, inputEl, {
|
||||
* getValue: () => bridgedValue.value,
|
||||
* setValue: (v) => { bridgedValue.value = v }
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function useDomValueBridge(element: ValueElement): Ref<string> {
|
||||
const bridgedValue = ref(element.value)
|
||||
|
||||
// Capture the original property descriptor so we can chain through it
|
||||
const proto = Object.getPrototypeOf(element)
|
||||
const originalDescriptor =
|
||||
Object.getOwnPropertyDescriptor(element, 'value') ??
|
||||
Object.getOwnPropertyDescriptor(proto, 'value')
|
||||
|
||||
// Intercept programmatic .value writes on the element
|
||||
// This catches cases where extensions or libraries set element.value directly
|
||||
try {
|
||||
Object.defineProperty(element, 'value', {
|
||||
get() {
|
||||
return originalDescriptor?.get?.call(this) ?? bridgedValue.value
|
||||
},
|
||||
set(newValue: string) {
|
||||
originalDescriptor?.set?.call(this, newValue)
|
||||
bridgedValue.value = newValue
|
||||
},
|
||||
configurable: true,
|
||||
enumerable: true
|
||||
})
|
||||
} catch {
|
||||
// If the descriptor is non-configurable, fall back to polling-free sync
|
||||
// via input events only
|
||||
}
|
||||
|
||||
// Listen for user-driven input events
|
||||
element.addEventListener('input', () => {
|
||||
// Read through the original descriptor to avoid infinite loops
|
||||
const currentValue = originalDescriptor?.get?.call(element) ?? element.value
|
||||
bridgedValue.value = currentValue
|
||||
})
|
||||
|
||||
// When the ref is written to externally, update the DOM element
|
||||
watch(bridgedValue, (newValue) => {
|
||||
const currentDomValue =
|
||||
originalDescriptor?.get?.call(element) ?? element.value
|
||||
if (currentDomValue !== newValue) {
|
||||
originalDescriptor?.set?.call(element, newValue)
|
||||
}
|
||||
})
|
||||
|
||||
return bridgedValue
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import _ from 'es-toolkit/compat'
|
||||
import { type Component, toRaw } from 'vue'
|
||||
|
||||
import { useDomValueBridge } from '@/composables/element/useDomValueBridge'
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import {
|
||||
LGraphNode,
|
||||
@@ -380,16 +379,6 @@ export const addWidget = <W extends BaseDOMWidget<object | string>>(
|
||||
})
|
||||
}
|
||||
|
||||
function isValueElement(
|
||||
el: HTMLElement
|
||||
): el is HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement {
|
||||
return (
|
||||
el instanceof HTMLInputElement ||
|
||||
el instanceof HTMLTextAreaElement ||
|
||||
el instanceof HTMLSelectElement
|
||||
)
|
||||
}
|
||||
|
||||
LGraphNode.prototype.addDOMWidget = function <
|
||||
T extends HTMLElement,
|
||||
V extends object | string
|
||||
@@ -400,19 +389,6 @@ LGraphNode.prototype.addDOMWidget = function <
|
||||
element: T,
|
||||
options: DOMWidgetOptions<V> = {}
|
||||
): DOMWidget<T, V> {
|
||||
// Auto-bridge value-bearing elements when no getValue/setValue provided.
|
||||
// This gives extension authors automatic widgetValueStore integration.
|
||||
if (!options.getValue && !options.setValue && isValueElement(element)) {
|
||||
const bridgedValue = useDomValueBridge(element)
|
||||
options = {
|
||||
...options,
|
||||
getValue: (() => bridgedValue.value) as () => V,
|
||||
setValue: ((v: V) => {
|
||||
bridgedValue.value = String(v)
|
||||
}) as (v: V) => void
|
||||
}
|
||||
}
|
||||
|
||||
const widget = new DOMWidgetImpl({
|
||||
node: this,
|
||||
name,
|
||||
|
||||
@@ -7,18 +7,11 @@ import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import * as litegraphUtil from '@/utils/litegraphUtil'
|
||||
|
||||
vi.mock('@/utils/litegraphUtil', () => ({
|
||||
isAnimatedOutput: vi.fn(),
|
||||
isVideoNode: vi.fn()
|
||||
}))
|
||||
|
||||
const mockGetNodeById = vi.fn()
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
getPreviewFormatParam: vi.fn(() => '&format=test_webp'),
|
||||
rootGraph: {
|
||||
getNodeById: (...args: unknown[]) => mockGetNodeById(...args)
|
||||
},
|
||||
@@ -151,76 +144,6 @@ describe('nodeOutputStore restoreOutputs', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('nodeOutputStore getPreviewParam', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(litegraphUtil.isAnimatedOutput).mockReturnValue(false)
|
||||
vi.mocked(litegraphUtil.isVideoNode).mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('should return empty string if output is animated', () => {
|
||||
const store = useNodeOutputStore()
|
||||
vi.mocked(litegraphUtil.isAnimatedOutput).mockReturnValue(true)
|
||||
const node = createMockNode()
|
||||
const outputs = createMockOutputs([{ filename: 'img.png' }])
|
||||
expect(store.getPreviewParam(node, outputs)).toBe('')
|
||||
expect(vi.mocked(app).getPreviewFormatParam).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return empty string if isVideoNode returns true', () => {
|
||||
const store = useNodeOutputStore()
|
||||
vi.mocked(litegraphUtil.isVideoNode).mockReturnValue(true)
|
||||
const node = createMockNode()
|
||||
const outputs = createMockOutputs([{ filename: 'img.png' }])
|
||||
expect(store.getPreviewParam(node, outputs)).toBe('')
|
||||
expect(vi.mocked(app).getPreviewFormatParam).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return empty string if outputs.images is undefined', () => {
|
||||
const store = useNodeOutputStore()
|
||||
const node = createMockNode()
|
||||
const outputs: ExecutedWsMessage['output'] = {}
|
||||
expect(store.getPreviewParam(node, outputs)).toBe('')
|
||||
expect(vi.mocked(app).getPreviewFormatParam).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return empty string if outputs.images is empty', () => {
|
||||
const store = useNodeOutputStore()
|
||||
const node = createMockNode()
|
||||
const outputs = createMockOutputs([])
|
||||
expect(store.getPreviewParam(node, outputs)).toBe('')
|
||||
expect(vi.mocked(app).getPreviewFormatParam).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return empty string if outputs.images contains SVG images', () => {
|
||||
const store = useNodeOutputStore()
|
||||
const node = createMockNode()
|
||||
const outputs = createMockOutputs([{ filename: 'img.svg' }])
|
||||
expect(store.getPreviewParam(node, outputs)).toBe('')
|
||||
expect(vi.mocked(app).getPreviewFormatParam).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return format param for standard image outputs', () => {
|
||||
const store = useNodeOutputStore()
|
||||
const node = createMockNode()
|
||||
const outputs = createMockOutputs([{ filename: 'img.png' }])
|
||||
expect(store.getPreviewParam(node, outputs)).toBe('&format=test_webp')
|
||||
expect(vi.mocked(app).getPreviewFormatParam).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should return format param for multiple standard images', () => {
|
||||
const store = useNodeOutputStore()
|
||||
const node = createMockNode()
|
||||
const outputs = createMockOutputs([
|
||||
{ filename: 'img1.png' },
|
||||
{ filename: 'img2.jpg' }
|
||||
])
|
||||
expect(store.getPreviewParam(node, outputs)).toBe('&format=test_webp')
|
||||
expect(vi.mocked(app).getPreviewFormatParam).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('nodeOutputStore syncLegacyNodeImgs', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
|
||||
@@ -11,6 +11,8 @@ import type {
|
||||
ResultItemType
|
||||
} from '@/schemas/apiSchema'
|
||||
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { clone } from '@/scripts/utils'
|
||||
@@ -97,20 +99,6 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the preview param for the node's outputs.
|
||||
*
|
||||
* If the output is an image, use the user's preferred format (from settings).
|
||||
* For non-image outputs, return an empty string, as including the preview param
|
||||
* will force the server to load the output file as an image.
|
||||
*/
|
||||
function getPreviewParam(
|
||||
node: LGraphNode,
|
||||
outputs: ExecutedWsMessage['output']
|
||||
): string {
|
||||
return isImageOutputs(node, outputs) ? app.getPreviewFormatParam() : ''
|
||||
}
|
||||
|
||||
function getNodeImageUrls(node: LGraphNode): string[] | undefined {
|
||||
const previews = getNodePreviews(node)
|
||||
if (previews?.length) return previews
|
||||
@@ -118,14 +106,17 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
const outputs = getNodeOutputs(node)
|
||||
if (!outputs?.images?.length) return
|
||||
|
||||
const rand = app.getRandParam()
|
||||
const previewParam = getPreviewParam(node, outputs)
|
||||
const isImage = isImageOutputs(node, outputs)
|
||||
|
||||
return outputs.images.map((image) => {
|
||||
const params = new URLSearchParams(image)
|
||||
if (isImage) appendCloudResParam(params, image.filename)
|
||||
return api.apiURL(`/view?${params}${previewParam}${rand}`)
|
||||
if (isImage) {
|
||||
appendCloudResParam(params, image.filename)
|
||||
const previewFormat = useSettingStore().get('Comfy.PreviewFormat')
|
||||
if (previewFormat) params.set('preview', previewFormat)
|
||||
}
|
||||
if (!isCloud) params.set('rand', String(Math.random()))
|
||||
return api.apiURL(`/view?${params}`)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -443,8 +434,6 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
getNodeOutputs,
|
||||
getNodeImageUrls,
|
||||
getNodePreviews,
|
||||
getPreviewParam,
|
||||
|
||||
// Setters
|
||||
setNodeOutputs,
|
||||
setNodeOutputsByExecutionId,
|
||||
|
||||
Reference in New Issue
Block a user