Manage style of DOM widgets in Vue (#2946)

This commit is contained in:
Chenlei Hu
2025-03-09 16:51:42 -04:00
committed by GitHub
parent 97d9f90374
commit 3a0b337d0c
6 changed files with 339 additions and 167 deletions

View File

@@ -1,15 +1,24 @@
<template>
<div>
<DomWidget v-for="widget in widgets" :key="widget.id" :widget="widget" />
<!-- Create a new stacking context for widgets to avoid z-index issues -->
<div class="isolate">
<DomWidget
v-for="widget in widgets"
:key="widget.id"
:widget="widget"
:widget-state="domWidgetStore.widgetStates.get(widget.id)"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { LGraphNode } from '@comfyorg/litegraph'
import { computed, watch } from 'vue'
import DomWidget from '@/components/graph/widgets/DomWidget.vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { DOMWidget } from '@/scripts/domWidget'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useCanvasStore } from '@/stores/graphStore'
const domWidgetStore = useDomWidgetStore()
const widgets = computed(() =>
@@ -19,4 +28,47 @@ const widgets = computed(() =>
>
)
)
const MARGIN = 10
const updateWidgets = () => {
const lgCanvas = canvasStore.canvas
if (!lgCanvas) return
const lowQuality = lgCanvas.low_quality
for (const widget of domWidgetStore.widgetInstances.values()) {
const node = widget.node as LGraphNode
const widgetState = domWidgetStore.widgetStates.get(widget.id)
if (!widgetState) continue
widgetState.visible =
lgCanvas.isNodeVisible(node) &&
!(widget.options.hideOnZoom && lowQuality) &&
widget.isVisible()
widgetState.pos = [node.pos[0] + MARGIN, node.pos[1] + MARGIN + widget.y]
widgetState.size = [
(widget.width ?? node.width) - MARGIN * 2,
(widget.computedHeight ?? 50) - MARGIN * 2
]
// TODO: optimize this logic as it's O(n), where n is the number of nodes
widgetState.zIndex = lgCanvas.graph.nodes.indexOf(node)
widgetState.readonly = lgCanvas.read_only
}
}
const canvasStore = useCanvasStore()
watch(
() => canvasStore.canvas,
(lgCanvas) => {
if (!lgCanvas) return
lgCanvas.onDrawForeground = useChainCallback(
lgCanvas.onDrawForeground,
() => {
updateWidgets()
}
)
}
)
</script>

View File

@@ -1,19 +1,102 @@
<template>
<div ref="widgetElement" />
<div
class="dom-widget"
ref="widgetElement"
:style="style"
v-show="widgetState.visible"
/>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import type { LGraphNode } from '@comfyorg/litegraph'
import { CSSProperties, computed, onMounted, ref, watch } from 'vue'
import { useAbsolutePosition } from '@/composables/element/useAbsolutePosition'
import { useDomClipping } from '@/composables/element/useDomClipping'
import type { DOMWidget } from '@/scripts/domWidget'
import { DomWidgetState } from '@/stores/domWidgetStore'
import { useCanvasStore } from '@/stores/graphStore'
import { useSettingStore } from '@/stores/settingStore'
const { widget } = defineProps<{
const { widget, widgetState } = defineProps<{
widget: DOMWidget<HTMLElement, any>
widgetState: DomWidgetState
}>()
const widgetElement = ref<HTMLElement>()
const { style: positionStyle, updatePositionWithTransform } =
useAbsolutePosition()
const { style: clippingStyle, updateClipPath } = useDomClipping()
const style = computed<CSSProperties>(() => ({
...positionStyle.value,
...(enableDomClipping.value ? clippingStyle.value : {}),
zIndex: widgetState.zIndex,
pointerEvents: widgetState.readonly ? 'none' : 'auto'
}))
const canvasStore = useCanvasStore()
const settingStore = useSettingStore()
const enableDomClipping = computed(() =>
settingStore.get('Comfy.DOMClippingEnabled')
)
const updateDomClipping = () => {
const lgCanvas = canvasStore.canvas
const selectedNode = Object.values(
lgCanvas.selected_nodes ?? {}
)[0] as LGraphNode
const node = widget.node
const isSelected = selectedNode === 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
)
}
watch(
() => widgetState,
(newState) => {
updatePositionWithTransform(newState)
if (enableDomClipping.value) {
updateDomClipping()
}
},
{ deep: true }
)
watch(
() => widgetState.visible,
(newVisible, oldVisible) => {
if (!newVisible && oldVisible) {
widget.options.onHide?.(widget)
}
}
)
onMounted(() => {
widgetElement.value.appendChild(widget.element)
})
</script>
<style scoped>
.dom-widget > * {
@apply h-full w-full;
}
</style>

View File

@@ -23,6 +23,12 @@ export function useAbsolutePosition() {
height: '0px'
})
/**
* Update the position of the element on the litegraph canvas.
*
* @param config
* @param extraStyle
*/
const updatePosition = (
config: PositionConfig,
extraStyle?: CSSProperties
@@ -41,8 +47,36 @@ export function useAbsolutePosition() {
}
}
/**
* Update the position and size of the element on the litegraph canvas,
* with CSS transform scaling applied.
*
* @param config
* @param extraStyle
*/
const updatePositionWithTransform = (
config: PositionConfig,
extraStyle?: CSSProperties
) => {
const { pos, size, scale = canvasStore.canvas?.ds?.scale ?? 1 } = config
const [left, top] = app.canvasPosToClientPos(pos)
const [width, height] = size
style.value = {
...style.value,
transformOrigin: '0 0',
transform: `scale(${scale})`,
left: `${left}px`,
top: `${top}px`,
width: `${width}px`,
height: `${height}px`,
...extraStyle
}
}
return {
style,
updatePosition
updatePosition,
updatePositionWithTransform
}
}

View File

@@ -0,0 +1,123 @@
import { CSSProperties, ref } from 'vue'
interface Rect {
x: number
y: number
width: number
height: number
}
/**
* Finds the intersection between two rectangles
*/
function intersect(a: Rect, b: Rect): [number, number, number, number] | null {
const x1 = Math.max(a.x, b.x)
const y1 = Math.max(a.y, b.y)
const x2 = Math.min(a.x + a.width, b.x + b.width)
const y2 = Math.min(a.y + a.height, b.y + b.height)
if (x1 >= x2 || y1 >= y2) {
return null
}
return [x1, y1, x2 - x1, y2 - y1]
}
export interface ClippingOptions {
margin?: number
}
export const useDomClipping = (options: ClippingOptions = {}) => {
const style = ref<CSSProperties>({})
const { margin = 4 } = options
/**
* Calculates a clip path for an element based on its intersection with a selected area
*/
const calculateClipPath = (
elementRect: DOMRect,
canvasRect: DOMRect,
isSelected: boolean,
selectedArea?: {
x: number
y: number
width: number
height: number
scale: number
offset: [number, number]
}
): string => {
if (!isSelected && selectedArea) {
const { scale, offset } = selectedArea
// Get intersection in browser space
const intersection = intersect(
{
x: elementRect.left - canvasRect.left,
y: elementRect.top - canvasRect.top,
width: elementRect.width,
height: elementRect.height
},
{
x: (selectedArea.x + offset[0] - margin) * scale,
y: (selectedArea.y + offset[1] - margin) * scale,
width: (selectedArea.width + 2 * margin) * scale,
height: (selectedArea.height + 2 * margin) * scale
}
)
if (!intersection) {
return ''
}
// Convert intersection to canvas scale (element has scale transform)
const clipX =
(intersection[0] - elementRect.left + canvasRect.left) / scale + 'px'
const clipY =
(intersection[1] - elementRect.top + canvasRect.top) / scale + 'px'
const clipWidth = intersection[2] / scale + 'px'
const clipHeight = intersection[3] / scale + 'px'
return `polygon(0% 0%, 0% 100%, ${clipX} 100%, ${clipX} ${clipY}, calc(${clipX} + ${clipWidth}) ${clipY}, calc(${clipX} + ${clipWidth}) calc(${clipY} + ${clipHeight}), ${clipX} calc(${clipY} + ${clipHeight}), ${clipX} 100%, 100% 100%, 100% 0%)`
}
return ''
}
/**
* Updates the clip-path style based on element and selection information
*/
const updateClipPath = (
element: HTMLElement,
canvasElement: HTMLCanvasElement,
isSelected: boolean,
selectedArea?: {
x: number
y: number
width: number
height: number
scale: number
offset: [number, number]
}
) => {
const elementRect = element.getBoundingClientRect()
const canvasRect = canvasElement.getBoundingClientRect()
const clipPath = calculateClipPath(
elementRect,
canvasRect,
isSelected,
selectedArea
)
style.value = {
clipPath: clipPath || 'none',
willChange: 'clip-path'
}
}
return {
style,
updateClipPath
}
}

View File

@@ -1,24 +1,15 @@
import { LGraphCanvas, LGraphNode } from '@comfyorg/litegraph'
import type { Vector4 } from '@comfyorg/litegraph'
import type { ISerialisedNode } from '@comfyorg/litegraph/dist/types/serialisation'
import { LGraphNode } from '@comfyorg/litegraph'
import type {
ICustomWidget,
IWidgetOptions
} from '@comfyorg/litegraph/dist/types/widgets'
import _ from 'lodash'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { app } from '@/scripts/app'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useSettingStore } from '@/stores/settingStore'
import { generateRandomSuffix } from '@/utils/formatUtil'
interface Rect {
height: number
width: number
x: number
y: number
}
export interface DOMWidget<T extends HTMLElement, V extends object | string>
extends ICustomWidget<T> {
// ICustomWidget properties
@@ -36,6 +27,10 @@ export interface DOMWidget<T extends HTMLElement, V extends object | string>
// DOMWidget properties
/** The unique ID of the widget. */
id: string
/** The node that the widget belongs to. */
node: LGraphNode
/** Whether the widget is visible. */
isVisible(): boolean
}
export interface DOMWidgetOptions<
@@ -62,92 +57,6 @@ export interface DOMWidgetOptions<
afterResize?: (this: DOMWidget<T, V>, node: LGraphNode) => void
}
function intersect(a: Rect, b: Rect): Vector4 | null {
const x = Math.max(a.x, b.x)
const num1 = Math.min(a.x + a.width, b.x + b.width)
const y = Math.max(a.y, b.y)
const num2 = Math.min(a.y + a.height, b.y + b.height)
if (num1 >= x && num2 >= y) return [x, y, num1 - x, num2 - y]
else return null
}
function getClipPath(
node: LGraphNode,
element: HTMLElement,
canvasRect: DOMRect
): string {
const selectedNode: LGraphNode = Object.values(
app.canvas.selected_nodes ?? {}
)[0] as LGraphNode
if (selectedNode && selectedNode !== node) {
const elRect = element.getBoundingClientRect()
const MARGIN = 4
const { offset, scale } = app.canvas.ds
const { renderArea } = selectedNode
// Get intersection in browser space
const intersection = intersect(
{
x: elRect.left - canvasRect.left,
y: elRect.top - canvasRect.top,
width: elRect.width,
height: elRect.height
},
{
x: (renderArea[0] + offset[0] - MARGIN) * scale,
y: (renderArea[1] + offset[1] - MARGIN) * scale,
width: (renderArea[2] + 2 * MARGIN) * scale,
height: (renderArea[3] + 2 * MARGIN) * scale
}
)
if (!intersection) {
return ''
}
// Convert intersection to canvas scale (element has scale transform)
const clipX =
(intersection[0] - elRect.left + canvasRect.left) / scale + 'px'
const clipY = (intersection[1] - elRect.top + canvasRect.top) / scale + 'px'
const clipWidth = intersection[2] / scale + 'px'
const clipHeight = intersection[3] / scale + 'px'
const path = `polygon(0% 0%, 0% 100%, ${clipX} 100%, ${clipX} ${clipY}, calc(${clipX} + ${clipWidth}) ${clipY}, calc(${clipX} + ${clipWidth}) calc(${clipY} + ${clipHeight}), ${clipX} calc(${clipY} + ${clipHeight}), ${clipX} 100%, 100% 100%, 100% 0%)`
return path
}
return ''
}
// Override the compute visible nodes function to allow us to hide/show DOM elements when the node goes offscreen
const elementWidgets = new Set<LGraphNode>()
const computeVisibleNodes = LGraphCanvas.prototype.computeVisibleNodes
LGraphCanvas.prototype.computeVisibleNodes = function (
nodes?: LGraphNode[],
out?: LGraphNode[]
): LGraphNode[] {
const visibleNodes = computeVisibleNodes.call(this, nodes, out)
for (const node of app.graph.nodes) {
if (elementWidgets.has(node)) {
const hidden = visibleNodes.indexOf(node) === -1
for (const w of node.widgets ?? []) {
if (w.element) {
w.element.dataset.isInVisibleNodes = hidden ? 'false' : 'true'
const shouldOtherwiseHide = w.element.dataset.shouldHide === 'true'
const wasHidden = w.element.hidden
const actualHidden = hidden || shouldOtherwiseHide || node.collapsed
w.element.hidden = actualHidden
w.element.style.display = actualHidden ? 'none' : ''
if (actualHidden && !wasHidden) {
w.options.onHide?.(w as DOMWidget<HTMLElement, object>)
}
}
}
}
}
return visibleNodes
}
export class DOMWidgetImpl<T extends HTMLElement, V extends object | string>
implements DOMWidget<T, V>
{
@@ -159,10 +68,12 @@ export class DOMWidgetImpl<T extends HTMLElement, V extends object | string>
callback?: (value: V) => void
readonly id: string
readonly node: LGraphNode
mouseDownHandler?: (event: MouseEvent) => void
constructor(obj: {
id: string
node: LGraphNode
name: string
type: string
element: T
@@ -175,6 +86,7 @@ export class DOMWidgetImpl<T extends HTMLElement, V extends object | string>
this.options = obj.options
this.id = obj.id
this.node = obj.node
if (this.element.blur) {
this.mouseDownHandler = (event) => {
@@ -238,59 +150,16 @@ export class DOMWidgetImpl<T extends HTMLElement, V extends object | string>
}
}
draw(
ctx: CanvasRenderingContext2D,
node: LGraphNode,
widgetWidth: number,
y: number
): void {
const { offset, scale } = app.canvas.ds
const hidden =
(!!this.options.hideOnZoom && app.canvas.low_quality) ||
(this.computedHeight ?? 0) <= 0 ||
// @ts-expect-error custom widget type
this.type === 'converted-widget' ||
// @ts-expect-error custom widget type
this.type === 'hidden' ||
node.collapsed
this.element.dataset.shouldHide = hidden ? 'true' : 'false'
const isInVisibleNodes = this.element.dataset.isInVisibleNodes === 'true'
const actualHidden = hidden || !isInVisibleNodes
const wasHidden = this.element.hidden
this.element.hidden = actualHidden
this.element.style.display = actualHidden ? 'none' : ''
if (actualHidden && !wasHidden) {
this.options.onHide?.(this)
}
if (actualHidden) {
return
}
const elRect = ctx.canvas.getBoundingClientRect()
const margin = 10
const top = node.pos[0] + offset[0] + margin
const left = node.pos[1] + offset[1] + margin + y
Object.assign(this.element.style, {
transformOrigin: '0 0',
transform: `scale(${scale})`,
left: `${top * scale}px`,
top: `${left * scale}px`,
width: `${widgetWidth - margin * 2}px`,
height: `${(this.computedHeight ?? 50) - margin * 2}px`,
position: 'absolute',
zIndex: app.graph.nodes.indexOf(node),
pointerEvents: app.canvas.read_only ? 'none' : 'auto'
})
if (useSettingStore().get('Comfy.DOMClippingEnabled')) {
const clipPath = getClipPath(node, this.element, elRect)
this.element.style.clipPath = clipPath ?? 'none'
this.element.style.willChange = 'clip-path'
}
isVisible(): boolean {
return (
!_.isNil(this.computedHeight) &&
this.computedHeight > 0 &&
!['converted-widget', 'hidden'].includes(this.type) &&
!this.node.collapsed
)
}
draw(): void {
this.options.onDraw?.(this)
}
@@ -315,18 +184,15 @@ LGraphNode.prototype.addDOMWidget = function <
): DOMWidget<T, V> {
options = { hideOnZoom: true, selectOn: ['focus', 'click'], ...options }
element.hidden = true
element.style.display = 'none'
const { nodeData } = this.constructor
const tooltip = (nodeData?.input.required?.[name] ??
nodeData?.input.optional?.[name])?.[1]?.tooltip
const tooltip = nodeData?.inputs?.[name]?.tooltip
if (tooltip && !element.title) {
element.title = tooltip
}
const widget = new DOMWidgetImpl({
id: `${this.id}:${name}:${generateRandomSuffix()}`,
node: this,
name,
type,
element,
@@ -356,14 +222,10 @@ LGraphNode.prototype.addDOMWidget = function <
}
this.addCustomWidget(widget)
elementWidgets.add(this)
const onRemoved = this.onRemoved
this.onRemoved = function (this: LGraphNode) {
this.onRemoved = useChainCallback(this.onRemoved, () => {
widget.onRemove()
elementWidgets.delete(this)
onRemoved?.call(this)
}
})
this.onResize = useChainCallback(this.onResize, () => {
options.beforeResize?.call(widget, this)

View File

@@ -4,9 +4,18 @@
import { defineStore } from 'pinia'
import { markRaw, ref } from 'vue'
import type { PositionConfig } from '@/composables/element/useAbsolutePosition'
import type { DOMWidget } from '@/scripts/domWidget'
export interface DomWidgetState extends PositionConfig {
visible: boolean
readonly: boolean
zIndex: number
}
export const useDomWidgetStore = defineStore('domWidget', () => {
const widgetStates = ref<Map<string, DomWidgetState>>(new Map())
// Map to reference actual widget instances
// Widgets are stored as raw values to avoid reactivity issues
const widgetInstances = ref(
@@ -21,14 +30,23 @@ export const useDomWidgetStore = defineStore('domWidget', () => {
widget.id,
markRaw(widget as unknown as DOMWidget<HTMLElement, object | string>)
)
widgetStates.value.set(widget.id, {
visible: true,
readonly: false,
zIndex: 0,
pos: [0, 0],
size: [0, 0]
})
}
// Unregister a widget from the store
const unregisterWidget = (widgetId: string) => {
widgetInstances.value.delete(widgetId)
widgetStates.value.delete(widgetId)
}
return {
widgetStates,
widgetInstances,
registerWidget,
unregisterWidget