From 3a0b337d0cbd32e7e9650ebc966ff991c51beb5a Mon Sep 17 00:00:00 2001 From: Chenlei Hu Date: Sun, 9 Mar 2025 16:51:42 -0400 Subject: [PATCH] Manage style of DOM widgets in Vue (#2946) --- src/components/graph/DomWidgets.vue | 58 +++++- src/components/graph/widgets/DomWidget.vue | 89 ++++++++- .../element/useAbsolutePosition.ts | 36 +++- src/composables/element/useDomClipping.ts | 123 ++++++++++++ src/scripts/domWidget.ts | 182 +++--------------- src/stores/domWidgetStore.ts | 18 ++ 6 files changed, 339 insertions(+), 167 deletions(-) create mode 100644 src/composables/element/useDomClipping.ts diff --git a/src/components/graph/DomWidgets.vue b/src/components/graph/DomWidgets.vue index 3e25646c6..88163048b 100644 --- a/src/components/graph/DomWidgets.vue +++ b/src/components/graph/DomWidgets.vue @@ -1,15 +1,24 @@ diff --git a/src/components/graph/widgets/DomWidget.vue b/src/components/graph/widgets/DomWidget.vue index 39f372760..002d6eafc 100644 --- a/src/components/graph/widgets/DomWidget.vue +++ b/src/components/graph/widgets/DomWidget.vue @@ -1,19 +1,102 @@ + + diff --git a/src/composables/element/useAbsolutePosition.ts b/src/composables/element/useAbsolutePosition.ts index 73b156b87..f71bcc4e4 100644 --- a/src/composables/element/useAbsolutePosition.ts +++ b/src/composables/element/useAbsolutePosition.ts @@ -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 } } diff --git a/src/composables/element/useDomClipping.ts b/src/composables/element/useDomClipping.ts new file mode 100644 index 000000000..843f76944 --- /dev/null +++ b/src/composables/element/useDomClipping.ts @@ -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({}) + 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 + } +} diff --git a/src/scripts/domWidget.ts b/src/scripts/domWidget.ts index 09ae7ff45..77b02e8bc 100644 --- a/src/scripts/domWidget.ts +++ b/src/scripts/domWidget.ts @@ -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 extends ICustomWidget { // ICustomWidget properties @@ -36,6 +27,10 @@ export interface DOMWidget // 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, 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() -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) - } - } - } - } - } - - return visibleNodes -} - export class DOMWidgetImpl implements DOMWidget { @@ -159,10 +68,12 @@ export class DOMWidgetImpl 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 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 } } - 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 { 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) diff --git a/src/stores/domWidgetStore.ts b/src/stores/domWidgetStore.ts index e0e005418..b8975163f 100644 --- a/src/stores/domWidgetStore.ts +++ b/src/stores/domWidgetStore.ts @@ -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>(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) ) + 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