Files
ComfyUI_frontend/src/components/graph/widgets/DomWidget.vue

230 lines
6.3 KiB
Vue

<template>
<div
v-show="widgetState.visible"
ref="widgetElement"
class="dom-widget size-full"
:title="tooltip"
:style="style"
>
<component
:is="widget.component"
v-if="isComponentWidget(widget)"
class="size-full"
:model-value="widget.value"
:widget="widget"
v-bind="widget.props"
@update:model-value="emit('update:widgetValue', $event)"
/>
</div>
</template>
<script setup lang="ts">
import { useElementBounding, useEventListener, whenever } from '@vueuse/core'
import type { CSSProperties } from 'vue'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useAbsolutePosition } from '@/composables/element/useAbsolutePosition'
import { useDomClipping } from '@/composables/element/useDomClipping'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { isComponentWidget, isDOMWidget } from '@/scripts/domWidget'
import type { DomWidgetState } from '@/stores/domWidgetStore'
const { widgetState } = defineProps<{
widgetState: DomWidgetState
}>()
const widget = widgetState.widget
const emit = defineEmits<{
'update:widgetValue': [value: string | object]
}>()
const widgetElement = ref<HTMLElement | undefined>()
/**
* @note Do NOT convert style to a computed value, as it will cause lag when
* updating the style on different animation frames. Vue's computed value is
* evaluated asynchronously.
*/
const style = ref<CSSProperties>({})
const { style: positionStyle, updatePosition } = useAbsolutePosition({
useTransform: true
})
const { style: clippingStyle, updateClipPath } = useDomClipping()
const canvasStore = useCanvasStore()
const settingStore = useSettingStore()
const enableDomClipping = computed(() =>
settingStore.get('Comfy.DOMClippingEnabled')
)
const updateDomClipping = () => {
const lgCanvas = canvasStore.canvas
if (!lgCanvas || !widgetElement.value) return
const selectedNode = Object.values(lgCanvas.selected_nodes ?? {})[0]
if (!selectedNode) {
// Clear clipping when no node is selected
updateClipPath(widgetElement.value, lgCanvas.canvas, false, undefined)
return
}
const override = widgetState.positionOverride
const overrideInGraph =
override && lgCanvas.graph?.getNodeById(override.node.id)
const ownerNode = overrideInGraph ? override.node : widgetState.widget.node
const isSelected = selectedNode === ownerNode
const renderArea = selectedNode?.renderArea
const offset = lgCanvas.ds.offset
const scale = lgCanvas.ds.scale
const selectedAreaConfig = renderArea
? {
x: renderArea[0],
y: renderArea[1],
width: renderArea[2],
height: renderArea[3],
scale,
offset: [offset[0], offset[1]] as [number, number]
}
: undefined
updateClipPath(
widgetElement.value,
lgCanvas.canvas,
isSelected,
selectedAreaConfig
)
}
/**
* @note mapping between canvas position and client position depends on the
* canvas element's position, so we need to watch the canvas element's position
* and update the position of the widget accordingly.
*/
const { left, top } = useElementBounding(canvasStore.getCanvas().canvas)
function composeStyle() {
const override = widgetState.positionOverride
const isDisabled = override
? (override.widget.computedDisabled ?? widget.computedDisabled)
: widget.computedDisabled
style.value = {
...positionStyle.value,
...(enableDomClipping.value ? clippingStyle.value : {}),
zIndex: widgetState.zIndex,
pointerEvents:
!widgetState.visible || widgetState.readonly || isDisabled
? 'none'
: 'auto',
opacity: isDisabled ? 0.5 : 1
}
}
watch([() => widgetState.pos, () => widgetState.size, left, top], () => {
updatePosition(widgetState)
if (enableDomClipping.value) {
updateDomClipping()
}
composeStyle()
})
watch(
[
() => widgetState.zIndex,
() => widgetState.readonly,
() => widgetState.positionOverride,
enableDomClipping
],
() => {
if (enableDomClipping.value) {
updateDomClipping()
}
composeStyle()
}
)
// Recompose style when clippingStyle updates asynchronously via RAF.
// updateClipPath() schedules clip-path calculation in a requestAnimationFrame,
// so clippingStyle.value updates after the main watcher has already composed
// style. This watcher ensures the new clip-path is applied to the DOM.
watch(clippingStyle, composeStyle, { deep: true })
watch(
() => widgetState.visible,
(newVisible, oldVisible) => {
if (!newVisible && oldVisible) {
widget.options.onHide?.(widget)
}
}
)
useEventListener(document, 'mousedown', (event) => {
if (!isDOMWidget(widget) || !widgetState.visible || !widget.element.blur) {
return
}
if (!widget.element.contains(event.target as HTMLElement)) {
widget.element.blur()
}
})
onMounted(() => {
if (!isDOMWidget(widget)) {
return
}
useEventListener(
widget.element,
widget.options.selectOn ?? ['focus', 'click'],
() => {
const lgCanvas = canvasStore.canvas
if (!lgCanvas) return
const override = widgetState.positionOverride
const overrideInGraph =
override && lgCanvas.graph?.getNodeById(override.node.id)
const ownerNode = overrideInGraph
? override.node
: widgetState.widget.node
lgCanvas.selectNode(ownerNode)
lgCanvas.bringToFront(ownerNode)
}
)
})
const inputSpec = widget.node.constructor.nodeData
const tooltip = inputSpec?.inputs?.[widget.name]?.tooltip
// Mount DOM element when widget is or becomes visible
const mountElementIfVisible = () => {
if (!(widgetState.visible && isDOMWidget(widget) && widgetElement.value)) {
return
}
// Only append if not already a child
if (widgetElement.value.contains(widget.element)) {
return
}
widget.element.classList.add('h-full', 'w-full')
widgetElement.value.appendChild(widget.element)
}
// Check on mount - but only after next tick to ensure visibility is calculated
onMounted(() => {
nextTick(() => {
mountElementIfVisible()
}).catch((error) => {
console.error('Error mounting DOM widget element:', error)
})
})
// And watch for visibility changes
watch(
() => widgetState.visible,
() => {
mountElementIfVisible()
}
)
whenever(() => !canvasStore.linearMode, mountElementIfVisible)
</script>