fix: migrate DOM widgets to ComponentWidgetImpl and add element value bridge

- Add useDomValueBridge composable that bridges DOM element .value to Vue
  reactivity via Object.defineProperty interception + input events
- Wire bridge into addDOMWidget for extension widget compatibility
- Migrate useStringWidget from raw textarea + addDOMWidget to
  ComponentWidgetImpl + WidgetTextarea.vue
- Migrate useMarkdownWidget from Tiptap + addDOMWidget to
  ComponentWidgetImpl + WidgetMarkdown.vue
- Remove all Tiptap dependencies (no longer used anywhere)
- Add spellcheck setting support to WidgetTextarea.vue

Fixes #9194

Amp-Thread-ID: https://ampcode.com/threads/T-019c977f-02f8-701d-b258-95157da8c261
This commit is contained in:
bymyself
2026-02-25 18:57:30 -08:00
parent 908a3ea418
commit 51f6209600
9 changed files with 1114 additions and 2423 deletions

View File

@@ -71,14 +71,6 @@
"@primevue/themes": "catalog:",
"@sentry/vue": "catalog:",
"@sparkjsdev/spark": "catalog:",
"@tiptap/core": "catalog:",
"@tiptap/extension-link": "catalog:",
"@tiptap/extension-table": "catalog:",
"@tiptap/extension-table-cell": "catalog:",
"@tiptap/extension-table-header": "catalog:",
"@tiptap/extension-table-row": "catalog:",
"@tiptap/pm": "catalog:",
"@tiptap/starter-kit": "catalog:",
"@vueuse/core": "catalog:",
"@vueuse/integrations": "catalog:",
"@vueuse/router": "^14.2.0",
@@ -108,7 +100,6 @@
"reka-ui": "catalog:",
"semver": "^7.7.2",
"three": "^0.170.0",
"tiptap-markdown": "^0.8.10",
"typegpu": "catalog:",
"vue": "catalog:",
"vue-i18n": "catalog:",

3030
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,155 @@
import { effectScope, watch } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useDomValueBridge } from './useDomValueBridge'
describe('useDomValueBridge', () => {
let element: HTMLTextAreaElement
beforeEach(() => {
element = document.createElement('textarea')
element.value = 'initial'
})
it('reads initial element value', () => {
const scope = effectScope()
scope.run(() => {
const ref = useDomValueBridge(element)
expect(ref.value).toBe('initial')
})
scope.stop()
})
it('detects programmatic element.value writes', () => {
const scope = effectScope()
scope.run(() => {
const ref = useDomValueBridge(element)
const spy = vi.fn()
watch(ref, spy, { flush: 'sync' })
element.value = 'programmatic'
expect(ref.value).toBe('programmatic')
expect(spy).toHaveBeenCalledWith(
'programmatic',
'initial',
expect.anything()
)
})
scope.stop()
})
it('detects user input events', () => {
const scope = effectScope()
scope.run(() => {
const ref = useDomValueBridge(element)
const spy = vi.fn()
watch(ref, spy, { flush: 'sync' })
const nativeDesc = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
'value'
)!
nativeDesc.set!.call(element, 'typed')
element.dispatchEvent(new Event('input'))
expect(ref.value).toBe('typed')
expect(spy).toHaveBeenCalled()
})
scope.stop()
})
it('setting ref updates element value', () => {
const scope = effectScope()
scope.run(() => {
const ref = useDomValueBridge(element)
ref.value = 'from-ref'
expect(element.value).toBe('from-ref')
})
scope.stop()
})
it('chains through existing Object.defineProperty on element', () => {
const existingSetter = vi.fn()
const nativeDesc = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
'value'
)!
Object.defineProperty(element, 'value', {
configurable: true,
get() {
return nativeDesc.get!.call(element)
},
set(v: string) {
existingSetter(v)
nativeDesc.set!.call(element, v)
}
})
const scope = effectScope()
scope.run(() => {
const ref = useDomValueBridge(element)
element.value = 'new'
expect(existingSetter).toHaveBeenCalledWith('new')
expect(ref.value).toBe('new')
})
scope.stop()
})
it('restores previous descriptor on scope dispose', () => {
const scope = effectScope()
scope.run(() => {
useDomValueBridge(element)
})
const duringDesc = Object.getOwnPropertyDescriptor(element, 'value')
expect(duringDesc).toBeDefined()
scope.stop()
const afterDesc = Object.getOwnPropertyDescriptor(element, 'value')
expect(afterDesc).toBeUndefined()
})
it('restores existing override descriptor on scope dispose', () => {
const nativeDesc = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
'value'
)!
const customGetter = vi.fn(() => nativeDesc.get!.call(element))
Object.defineProperty(element, 'value', {
configurable: true,
get: customGetter,
set(v: string) {
nativeDesc.set!.call(element, v)
}
})
const scope = effectScope()
scope.run(() => {
useDomValueBridge(element)
})
scope.stop()
element.value
expect(customGetter).toHaveBeenCalled()
})
it('works with HTMLInputElement', () => {
const input = document.createElement('input')
input.value = 'input-initial'
const scope = effectScope()
scope.run(() => {
const ref = useDomValueBridge(input)
expect(ref.value).toBe('input-initial')
input.value = 'input-updated'
expect(ref.value).toBe('input-updated')
})
scope.stop()
})
})

View File

@@ -0,0 +1,70 @@
import type { Ref } from 'vue'
import { customRef, onScopeDispose } from 'vue'
type ValueElement = HTMLInputElement | HTMLTextAreaElement
export function useDomValueBridge(element: ValueElement): Ref<string> {
const proto = Object.getPrototypeOf(element)
const nativeDescriptor = Object.getOwnPropertyDescriptor(proto, 'value')
const existingDescriptor = Object.getOwnPropertyDescriptor(element, 'value')
const prevGet = existingDescriptor?.get ?? nativeDescriptor?.get
const prevSet = existingDescriptor?.set ?? nativeDescriptor?.set
if (!prevGet || !prevSet) {
return customRef((track, trigger) => ({
get() {
track()
return element.value
},
set(v: string) {
element.value = v
trigger()
}
}))
}
let notifyChange: (() => void) | undefined
const ref = customRef<string>((track, trigger) => {
notifyChange = trigger
return {
get() {
track()
return prevGet.call(element)
},
set(v: string) {
prevSet.call(element, v)
trigger()
}
}
})
Object.defineProperty(element, 'value', {
configurable: true,
enumerable: true,
get() {
return prevGet.call(element)
},
set(v: string) {
prevSet.call(element, v)
notifyChange?.()
}
})
function onInput() {
notifyChange?.()
}
element.addEventListener('input', onInput)
onScopeDispose(() => {
element.removeEventListener('input', onInput)
if (existingDescriptor) {
Object.defineProperty(element, 'value', existingDescriptor)
} else {
delete (element as unknown as Record<string, unknown>).value
}
})
return ref
}

View File

@@ -1,3 +1,4 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -39,6 +40,9 @@ function mountComponent(
placeholder?: string
) {
return mount(WidgetTextarea, {
global: {
plugins: [createTestingPinia()]
},
props: {
widget,
modelValue,

View File

@@ -28,6 +28,8 @@
"
:placeholder
:readonly="isReadOnly"
:spellcheck
data-testid="dom-widget-textarea"
data-capture-wheel="true"
@pointerdown.capture.stop="trackFocus"
@pointermove.capture.stop
@@ -56,6 +58,7 @@ import Button from '@/components/ui/button/Button.vue'
import Textarea from '@/components/ui/textarea/Textarea.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { isNodeOptionsOpen } from '@/composables/graph/useMoreOptionsMenu'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { useHideLayoutField } from '@/types/widgetTypes'
import { cn } from '@/utils/tailwindUtil'
@@ -102,6 +105,11 @@ function handleContextMenu(e: MouseEvent) {
e.preventDefault()
}
const settingStore = useSettingStore()
const spellcheck = computed(() =>
settingStore.get('Comfy.TextareaWidget.Spellcheck')
)
function handleCopy() {
copyToClipboard(modelValue.value)
}

View File

@@ -1,113 +1,30 @@
import { Editor as TiptapEditor } from '@tiptap/core'
import TiptapLink from '@tiptap/extension-link'
import TiptapTable from '@tiptap/extension-table'
import TiptapTableCell from '@tiptap/extension-table-cell'
import TiptapTableHeader from '@tiptap/extension-table-header'
import TiptapTableRow from '@tiptap/extension-table-row'
import TiptapStarterKit from '@tiptap/starter-kit'
import { Markdown as TiptapMarkdown } from 'tiptap-markdown'
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { app } from '@/scripts/app'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import type { BaseDOMWidget } from '@/scripts/domWidget'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
// TODO: This widget manually syncs with widgetValueStore via getValue/setValue.
// Consolidate with useStringWidget into shared helpers (domWidgetHelpers.ts).
import WidgetMarkdown from '../components/WidgetMarkdown.vue'
function addMarkdownWidget(
node: LGraphNode,
name: string,
inputSpec: InputSpec,
opts: { defaultVal: string }
) {
TiptapMarkdown.configure({
html: false,
breaks: true,
transformPastedText: true
})
const editor = new TiptapEditor({
extensions: [
TiptapStarterKit,
TiptapMarkdown,
TiptapLink,
TiptapTable,
TiptapTableCell,
TiptapTableHeader,
TiptapTableRow
],
content: opts.defaultVal,
editable: false
})
const widgetStore = useWidgetValueStore()
const inputEl = editor.options.element as HTMLElement
inputEl.classList.add('comfy-markdown')
const textarea = document.createElement('textarea')
inputEl.append(textarea)
const widget = node.addDOMWidget(name, 'MARKDOWN', inputEl, {
getValue(): string {
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
const storedValue = widgetStore.getWidget(graphId, node.id, name)?.value
return typeof storedValue === 'string' ? storedValue : textarea.value
},
setValue(v: string) {
textarea.value = v
editor.commands.setContent(v)
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
const widgetState = widgetStore.getWidget(graphId, node.id, name)
if (widgetState) widgetState.value = v
}
})
widget.element = inputEl
widget.options.minNodeSize = [400, 200]
inputEl.addEventListener('input', (event) => {
if (event.target instanceof HTMLTextAreaElement) {
widget.value = event.target.value
}
widget.callback?.(widget.value)
})
inputEl.addEventListener('dblclick', () => {
inputEl.classList.add('editing')
setTimeout(() => {
textarea.focus()
}, 0)
})
textarea.addEventListener('blur', () => {
inputEl.classList.remove('editing')
})
textarea.addEventListener('change', () => {
editor.commands.setContent(textarea.value)
widget.callback?.(widget.value)
})
inputEl.addEventListener('keydown', (event: KeyboardEvent) => {
event.stopPropagation()
})
inputEl.addEventListener('pointerdown', (event: PointerEvent) => {
if (event.button === 1) {
app.canvas.processMouseDown(event)
const widget = new ComponentWidgetImpl({
node,
name,
component: WidgetMarkdown,
inputSpec,
type: 'MARKDOWN',
options: {
minNodeSize: [400, 200]
}
})
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)
}
})
addWidget(node, widget as BaseDOMWidget<object | string>)
widget.value = opts.defaultVal
return widget
}
@@ -117,7 +34,7 @@ export const useMarkdownWidget = () => {
node: LGraphNode,
inputSpec: InputSpec
) => {
return addMarkdownWidget(node, inputSpec.name, {
return addMarkdownWidget(node, inputSpec.name, inputSpec, {
defaultVal: inputSpec.default ?? ''
})
}

View File

@@ -1,124 +1,32 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { isStringInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { app } from '@/scripts/app'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import type { BaseDOMWidget } from '@/scripts/domWidget'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
const TRACKPAD_DETECTION_THRESHOLD = 50
import WidgetTextarea from '../components/WidgetTextarea.vue'
// TODO: This widget manually syncs with widgetValueStore via getValue/setValue.
// Consolidate with useMarkdownWidget into shared helpers (domWidgetHelpers.ts).
function addMultilineWidget(
node: LGraphNode,
name: string,
inputSpec: InputSpec,
opts: { defaultVal: string; placeholder?: string }
) {
const widgetStore = useWidgetValueStore()
const inputEl = document.createElement('textarea')
inputEl.className = 'comfy-multiline-input'
inputEl.dataset.testid = 'dom-widget-textarea'
inputEl.value = opts.defaultVal
inputEl.placeholder = opts.placeholder || name
inputEl.spellcheck = useSettingStore().get('Comfy.TextareaWidget.Spellcheck')
const widget = node.addDOMWidget(name, 'customtext', inputEl, {
getValue(): string {
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
const widgetState = widgetStore.getWidget(graphId, node.id, name)
return (widgetState?.value as string) ?? inputEl.value
},
setValue(v: string) {
inputEl.value = v
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
const widgetState = widgetStore.getWidget(graphId, node.id, name)
if (widgetState) widgetState.value = v
const widget = new ComponentWidgetImpl({
node,
name,
component: WidgetTextarea,
inputSpec,
type: 'customtext',
props: { placeholder: opts.placeholder || name },
options: {
minNodeSize: [400, 200]
}
})
widget.element = inputEl
widget.inputEl = inputEl
widget.options.minNodeSize = [400, 200]
inputEl.addEventListener('input', (event) => {
if (event.target instanceof HTMLTextAreaElement) {
widget.value = event.target.value
}
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)
}
})
inputEl.addEventListener('wheel', (event: WheelEvent) => {
const gesturesEnabled = useSettingStore().get(
'LiteGraph.Pointer.TrackpadGestures'
)
const deltaX = event.deltaX
const deltaY = event.deltaY
const canScrollY = inputEl.scrollHeight > inputEl.clientHeight
const isHorizontal = Math.abs(deltaX) > Math.abs(deltaY)
// Prevent pinch zoom from zooming the page
if (event.ctrlKey) {
event.preventDefault()
event.stopPropagation()
app.canvas.processMouseWheel(event)
return
}
// Detect if this is likely a trackpad gesture vs mouse wheel
// Trackpads usually have deltaX or smaller deltaY values (< TRACKPAD_DETECTION_THRESHOLD)
// Mouse wheels typically have larger discrete deltaY values (>= TRACKPAD_DETECTION_THRESHOLD)
const isLikelyTrackpad =
Math.abs(deltaX) > 0 || Math.abs(deltaY) < TRACKPAD_DETECTION_THRESHOLD
// Trackpad gestures: when enabled, trackpad panning goes to canvas
if (gesturesEnabled && isLikelyTrackpad) {
event.preventDefault()
event.stopPropagation()
app.canvas.processMouseWheel(event)
return
}
// When gestures disabled: horizontal always goes to canvas (no horizontal scroll in textarea)
if (isHorizontal) {
event.preventDefault()
event.stopPropagation()
app.canvas.processMouseWheel(event)
return
}
// Vertical scrolling when gestures disabled: let textarea scroll if scrollable
if (canScrollY) {
event.stopPropagation()
return
}
// If textarea can't scroll vertically, pass to canvas
event.preventDefault()
app.canvas.processMouseWheel(event)
})
addWidget(node, widget as BaseDOMWidget<object | string>)
widget.value = opts.defaultVal
return widget
}
@@ -136,7 +44,7 @@ export const useStringWidget = () => {
const multiline = inputSpec.multiline
const widget = multiline
? addMultilineWidget(node, inputSpec.name, {
? addMultilineWidget(node, inputSpec.name, inputSpec, {
defaultVal,
placeholder: inputSpec.placeholder
})

View File

@@ -1,6 +1,7 @@
import _ from 'es-toolkit/compat'
import { type Component, toRaw } from 'vue'
import { type Component, effectScope, toRaw, watch } from 'vue'
import { useDomValueBridge } from '@/composables/element/useDomValueBridge'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import {
LGraphNode,
@@ -411,5 +412,24 @@ LGraphNode.prototype.addDOMWidget = function <
}
})
if (
element instanceof HTMLInputElement ||
element instanceof HTMLTextAreaElement
) {
const bridgeScope = effectScope()
bridgeScope.run(() => {
const bridgedValue = useDomValueBridge(element)
watch(bridgedValue, (newVal) => {
if (widget.value !== newVal) {
widget.value = newVal as V
}
})
})
widget.onRemove = useChainCallback(widget.onRemove, () => {
bridgeScope.stop()
})
}
return widget
}