Files
ComfyUI_frontend/src/components/graph/widgets/DomWidget.vue
Terry Jia 3238ad3d32 fix: re-mount DOM widget elements after leaving Linear mode (#8753)
## Summary

LinearView renders its own WidgetDOM instances which steal the
widget.element via replaceChildren. When LinearView unmounts
(v-if="linearMode") the element is removed from the DOM entirely.
The canvas-side WidgetDOM stays mounted but its container is now empty,
so DOM widgets (e.g. Three.js scenes) disappear.

Watch canvasStore.linearMode and reclaim the element when switching back
from Linear to Canvas mode.

## Screenshots (if applicable)
before


https://github.com/user-attachments/assets/78cea2bc-c4b3-4b21-bdb3-a521bb0d3062


after


https://github.com/user-attachments/assets/8f92c44d-9514-4001-bbdb-bc4c80468ed7

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8753-fix-re-mount-DOM-widget-elements-after-leaving-Linear-mode-3026d73d3650810b8968eff13dc84e9a)
by [Unito](https://www.unito.io)
2026-02-08 22:51:57 -05:00

194 lines
5.1 KiB
Vue

<template>
<div
v-show="widgetState.visible"
ref="widgetElement"
class="dom-widget"
:title="tooltip"
:style="style"
>
<component
:is="widget.component"
v-if="isComponentWidget(widget)"
: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 isSelected = selectedNode === widgetState.widget.node
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)
watch(
[() => widgetState, left, top],
([widgetState, _, __]) => {
updatePosition(widgetState)
if (enableDomClipping.value) {
updateDomClipping()
}
style.value = {
...positionStyle.value,
...(enableDomClipping.value ? clippingStyle.value : {}),
zIndex: widgetState.zIndex,
pointerEvents:
widgetState.readonly || widget.computedDisabled ? 'none' : 'auto',
opacity: widget.computedDisabled ? 0.5 : 1
}
},
{ 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
lgCanvas?.selectNode(widgetState.widget.node)
lgCanvas?.bringToFront(widgetState.widget.node)
}
)
})
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
}
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>
<style scoped>
@reference '../../../assets/css/style.css';
.dom-widget > * {
@apply h-full w-full;
}
</style>