mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-24 14:45:36 +00:00
Compare commits
13 Commits
fix/empty-
...
rizumu/per
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a00502045 | ||
|
|
7081df4585 | ||
|
|
08b176d429 | ||
|
|
e653313141 | ||
|
|
a5f6c2e08c | ||
|
|
4f1e124e4d | ||
|
|
7fa78cc458 | ||
|
|
7853cca13f | ||
|
|
bf5a5070ea | ||
|
|
46ce7671ab | ||
|
|
67e363fc3c | ||
|
|
d284a32490 | ||
|
|
e4240a357d |
Binary file not shown.
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
@@ -1420,15 +1420,6 @@ audio.comfy-audio.empty-audio-widget {
|
||||
font-size 0.1s ease;
|
||||
}
|
||||
|
||||
/* Performance optimization during canvas interaction */
|
||||
.transform-pane--interacting .lg-node * {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.transform-pane--interacting .lg-node {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* ===================== Mask Editor Styles ===================== */
|
||||
/* To be migrated to Tailwind later */
|
||||
#maskEditor_brush {
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
|
||||
import { usePaneBounds } from '@/renderer/core/layout/transform/usePaneBounds'
|
||||
import { app } from '@/scripts/app'
|
||||
import { DOMWidgetImpl } from '@/scripts/domWidget'
|
||||
import { promptRenameWidget } from '@/utils/widgetUtil'
|
||||
@@ -40,6 +41,7 @@ const canvasStore = useCanvasStore()
|
||||
const settingStore = useSettingStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { t } = useI18n()
|
||||
const { offset: paneBoundsOffset } = usePaneBounds()
|
||||
const canvas: LGraphCanvas = canvasStore.getCanvas()
|
||||
|
||||
const { isSelectMode, isSelectInputsMode, isSelectOutputsMode, isArrangeMode } =
|
||||
@@ -116,8 +118,8 @@ function getBounding(nodeId: NodeId, widgetName?: string) {
|
||||
return {
|
||||
width: `${node.size[0]}px`,
|
||||
height: `${node.size[1] + titleOffset}px`,
|
||||
left: `${node.pos[0]}px`,
|
||||
top: `${node.pos[1] - titleOffset}px`
|
||||
left: `${node.pos[0] + paneBoundsOffset.x}px`,
|
||||
top: `${node.pos[1] - titleOffset + paneBoundsOffset.y}px`
|
||||
}
|
||||
if (!widget) return
|
||||
|
||||
@@ -130,8 +132,8 @@ function getBounding(nodeId: NodeId, widgetName?: string) {
|
||||
return {
|
||||
width: `${node.size[0] - marginX * 2}px`,
|
||||
height: `${height}px`,
|
||||
left: `${node.pos[0] + marginX}px`,
|
||||
top: `${node.pos[1] + widget.y + (margin ?? 0)}px`
|
||||
left: `${node.pos[0] + marginX + paneBoundsOffset.x}px`,
|
||||
top: `${node.pos[1] + widget.y + (margin ?? 0) + paneBoundsOffset.y}px`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -81,7 +81,6 @@
|
||||
? 'Execution error'
|
||||
: null
|
||||
"
|
||||
:zoom-level="canvasStore.canvas?.ds?.scale || 1"
|
||||
:data-node-id="nodeData.id"
|
||||
/>
|
||||
</TransformPane>
|
||||
|
||||
@@ -133,21 +133,6 @@ describe('TransformPane', () => {
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(mockCanvas.canvas.addEventListener).not.toHaveBeenCalledWith(
|
||||
'pointerdown',
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(mockCanvas.canvas.addEventListener).not.toHaveBeenCalledWith(
|
||||
'pointerup',
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(mockCanvas.canvas.addEventListener).not.toHaveBeenCalledWith(
|
||||
'pointercancel',
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
it('should remove event listeners on unmount', async () => {
|
||||
@@ -166,43 +151,10 @@ describe('TransformPane', () => {
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(mockCanvas.canvas.removeEventListener).not.toHaveBeenCalledWith(
|
||||
'pointerdown',
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(mockCanvas.canvas.removeEventListener).not.toHaveBeenCalledWith(
|
||||
'pointerup',
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(mockCanvas.canvas.removeEventListener).not.toHaveBeenCalledWith(
|
||||
'pointercancel',
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('interaction state management', () => {
|
||||
it('should apply interacting class during interactions', async () => {
|
||||
const mockCanvas = createMockLGraphCanvas()
|
||||
const wrapper = mount(TransformPane, {
|
||||
props: {
|
||||
canvas: mockCanvas
|
||||
}
|
||||
})
|
||||
|
||||
// Simulate interaction start by checking internal state
|
||||
// Note: This tests the CSS class application logic
|
||||
const transformPane = wrapper.find('[data-testid="transform-pane"]')
|
||||
|
||||
// Initially should not have interacting class
|
||||
expect(transformPane.classes()).not.toContain(
|
||||
'transform-pane--interacting'
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle pointer events for node delegation', async () => {
|
||||
const mockCanvas = createMockLGraphCanvas()
|
||||
const wrapper = mount(TransformPane, {
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
<template>
|
||||
<div
|
||||
ref="transformPaneRef"
|
||||
data-testid="transform-pane"
|
||||
:class="
|
||||
cn(
|
||||
'pointer-events-none absolute inset-0 size-full',
|
||||
isInteracting ? 'transform-pane--interacting' : 'will-change-auto'
|
||||
)
|
||||
"
|
||||
:style="transformStyle"
|
||||
class="pointer-events-none absolute will-change-auto contain-layout contain-size contain-style"
|
||||
:style="paneStyle"
|
||||
>
|
||||
<!-- Vue nodes will be rendered here -->
|
||||
<slot />
|
||||
@@ -16,12 +12,12 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRafFn } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
import { computed, useTemplateRef, watch } from 'vue'
|
||||
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
import { usePaneBounds } from '@/renderer/core/layout/transform/usePaneBounds'
|
||||
import { useTransformSettling } from '@/renderer/core/layout/transform/useTransformSettling'
|
||||
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
interface TransformPaneProps {
|
||||
canvas?: LGraphCanvas
|
||||
@@ -29,26 +25,55 @@ interface TransformPaneProps {
|
||||
|
||||
const props = defineProps<TransformPaneProps>()
|
||||
|
||||
const { transformStyle, syncWithCanvas } = useTransformState()
|
||||
const { syncWithCanvas, camera } = useTransformState()
|
||||
const { offset, size, expandToContain } = usePaneBounds()
|
||||
|
||||
const canvasElement = computed(() => props.canvas?.canvas)
|
||||
const { isTransforming: isInteracting } = useTransformSettling(canvasElement, {
|
||||
settleDelay: 16
|
||||
settleDelay: 256
|
||||
})
|
||||
|
||||
const transformPaneRef = useTemplateRef('transformPaneRef')
|
||||
|
||||
/** Reactive pane dimensions — only changes when bounds grow. */
|
||||
const paneStyle = computed(() => ({
|
||||
width: `${size.width}px`,
|
||||
height: `${size.height}px`
|
||||
}))
|
||||
|
||||
/**
|
||||
* Apply transform via direct DOM mutation instead of reactive template
|
||||
* bindings. The transform changes every animation frame during pan/zoom;
|
||||
* a reactive binding would cause Vue to diff the entire vnode subtree
|
||||
* (including all child node slots) on every frame.
|
||||
*/
|
||||
const adjustedTransform = computed(() => ({
|
||||
transform: `scale3d(${camera.z}, ${camera.z}, ${camera.z}) translate3d(${camera.x - offset.x}px, ${camera.y - offset.y}px, 0)`,
|
||||
transformOrigin: '0 0'
|
||||
}))
|
||||
|
||||
watch([adjustedTransform, transformPaneRef], ([newStyle, el]) => {
|
||||
if (el) {
|
||||
Object.assign(el.style, newStyle)
|
||||
}
|
||||
})
|
||||
watch([isInteracting, transformPaneRef], ([interacting, el]) => {
|
||||
if (el) {
|
||||
el.classList.toggle('will-change-transform', interacting)
|
||||
el.classList.toggle('will-change-auto', !interacting)
|
||||
}
|
||||
})
|
||||
|
||||
useRafFn(
|
||||
() => {
|
||||
if (!props.canvas) {
|
||||
return
|
||||
}
|
||||
if (!props.canvas) return
|
||||
syncWithCanvas(props.canvas)
|
||||
|
||||
const nodes = props.canvas.graph?.nodes
|
||||
if (nodes?.length) {
|
||||
expandToContain(nodes)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.transform-pane--interacting {
|
||||
will-change: transform;
|
||||
}
|
||||
</style>
|
||||
|
||||
75
src/renderer/core/layout/transform/usePaneBounds.ts
Normal file
75
src/renderer/core/layout/transform/usePaneBounds.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Manages the TransformPane's box-model size and coordinate offset so that
|
||||
* all canvas nodes sit within the div's pre-transform bounds.
|
||||
*
|
||||
* Chrome paints composited-layer children into the parent's GPU texture only
|
||||
* when they are within the parent's pre-transform box. Nodes outside the box
|
||||
* get promoted to individual compositing layers — causing "layer explosion"
|
||||
* that destroys pan/zoom frame rates.
|
||||
*
|
||||
* This composable dynamically computes the minimum offset and size needed to
|
||||
* contain every node in positive coordinate space. The offset is added to each
|
||||
* node's CSS `translate` and subtracted from the camera's `translate3d` so the
|
||||
* on-screen positions remain identical.
|
||||
*
|
||||
* Values only grow (never shrink) to avoid triggering mass re-renders when a
|
||||
* single node moves.
|
||||
*/
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
import { reactive, readonly } from 'vue'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
const PADDING = 2000
|
||||
const INITIAL_OFFSET = 5000
|
||||
|
||||
function usePaneBoundsIndividual() {
|
||||
const offset = reactive({ x: INITIAL_OFFSET, y: INITIAL_OFFSET })
|
||||
const size = reactive({
|
||||
width: INITIAL_OFFSET * 2,
|
||||
height: INITIAL_OFFSET * 2
|
||||
})
|
||||
|
||||
/**
|
||||
* Expand the pane to contain all given nodes.
|
||||
* Only grows offset and size — never shrinks — to avoid triggering
|
||||
* reactive style updates on every node when a single node moves.
|
||||
*/
|
||||
function expandToContain(nodes: LGraphNode[]) {
|
||||
if (nodes.length === 0) return
|
||||
|
||||
let minX = Infinity
|
||||
let minY = Infinity
|
||||
let maxX = -Infinity
|
||||
let maxY = -Infinity
|
||||
|
||||
for (const node of nodes) {
|
||||
const x = node.pos[0]
|
||||
const y = node.pos[1]
|
||||
minX = Math.min(minX, x)
|
||||
minY = Math.min(minY, y)
|
||||
maxX = Math.max(maxX, x + node.size[0])
|
||||
maxY = Math.max(maxY, y + node.size[1])
|
||||
}
|
||||
|
||||
const neededOffsetX = Math.max(Math.ceil(-minX) + PADDING, INITIAL_OFFSET)
|
||||
const neededOffsetY = Math.max(Math.ceil(-minY) + PADDING, INITIAL_OFFSET)
|
||||
|
||||
if (neededOffsetX > offset.x) offset.x = neededOffsetX
|
||||
if (neededOffsetY > offset.y) offset.y = neededOffsetY
|
||||
|
||||
const neededWidth = Math.ceil(maxX) + offset.x + PADDING
|
||||
const neededHeight = Math.ceil(maxY) + offset.y + PADDING
|
||||
|
||||
if (neededWidth > size.width) size.width = neededWidth
|
||||
if (neededHeight > size.height) size.height = neededHeight
|
||||
}
|
||||
|
||||
return {
|
||||
offset: readonly(offset),
|
||||
size: readonly(size),
|
||||
expandToContain
|
||||
}
|
||||
}
|
||||
|
||||
export const usePaneBounds = createSharedComposable(usePaneBoundsIndividual)
|
||||
@@ -69,11 +69,40 @@ describe('useTransformSettling', () => {
|
||||
expect(isTransforming.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should not track pan events', async () => {
|
||||
it('should track pointer drag as pan interaction', async () => {
|
||||
const { isTransforming } = useTransformSettling(element, {
|
||||
settleDelay: 200
|
||||
})
|
||||
|
||||
element.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
element.dispatchEvent(new PointerEvent('pointermove', { bubbles: true }))
|
||||
await nextTick()
|
||||
|
||||
expect(isTransforming.value).toBe(true)
|
||||
|
||||
element.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }))
|
||||
vi.advanceTimersByTime(200)
|
||||
|
||||
expect(isTransforming.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should not treat right-click as pan', async () => {
|
||||
const { isTransforming } = useTransformSettling(element, {
|
||||
settleDelay: 200
|
||||
})
|
||||
|
||||
element.dispatchEvent(
|
||||
new PointerEvent('pointerdown', { bubbles: true, button: 2 })
|
||||
)
|
||||
element.dispatchEvent(new PointerEvent('pointermove', { bubbles: true }))
|
||||
await nextTick()
|
||||
|
||||
expect(isTransforming.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should not track pointermove without pointerdown', async () => {
|
||||
const { isTransforming } = useTransformSettling(element)
|
||||
|
||||
// Pointer events should not trigger transform
|
||||
element.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
element.dispatchEvent(new PointerEvent('pointermove', { bubbles: true }))
|
||||
await nextTick()
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { MaybeRefOrGetter } from 'vue'
|
||||
interface TransformSettlingOptions {
|
||||
/**
|
||||
* Delay in ms before transform is considered "settled" after last interaction
|
||||
* @default 200
|
||||
* @default 256
|
||||
*/
|
||||
settleDelay?: number
|
||||
/**
|
||||
@@ -16,10 +16,10 @@ interface TransformSettlingOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks when canvas zoom transforms are actively changing vs settled.
|
||||
* Tracks when canvas transforms (zoom or pan) are actively changing vs settled.
|
||||
*
|
||||
* This composable helps optimize rendering quality during zoom transformations.
|
||||
* When the user is actively zooming, we can reduce rendering quality
|
||||
* This composable helps optimize rendering quality during transform interactions.
|
||||
* When the user is actively zooming or panning, we can reduce rendering quality
|
||||
* for better performance. Once the transform "settles" (stops changing), we can
|
||||
* trigger high-quality re-rasterization.
|
||||
*
|
||||
@@ -50,35 +50,72 @@ export function useTransformSettling(
|
||||
|
||||
const isTransforming = ref(false)
|
||||
|
||||
/**
|
||||
* Mark transform as active
|
||||
*/
|
||||
const markTransformActive = () => {
|
||||
isTransforming.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark transform as settled (debounced)
|
||||
*/
|
||||
const markTransformSettled = useDebounceFn(() => {
|
||||
isTransforming.value = false
|
||||
}, settleDelay)
|
||||
|
||||
/**
|
||||
* Handle zoom transform event - mark active then queue settle
|
||||
*/
|
||||
const handleWheel = () => {
|
||||
markTransformActive()
|
||||
function markInteracting() {
|
||||
isTransforming.value = true
|
||||
void markTransformSettled()
|
||||
}
|
||||
|
||||
// Register wheel event listener with auto-cleanup
|
||||
useEventListener(target, 'wheel', handleWheel, {
|
||||
capture: true,
|
||||
passive
|
||||
})
|
||||
const eventOptions = { capture: true, passive }
|
||||
|
||||
useEventListener(target, 'wheel', markInteracting, eventOptions)
|
||||
usePointerDrag(target, markInteracting, eventOptions)
|
||||
|
||||
return {
|
||||
isTransforming
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls `onDrag` on each pointermove while a pointer is held down.
|
||||
*/
|
||||
function usePointerDrag(
|
||||
target: MaybeRefOrGetter<HTMLElement | null | undefined>,
|
||||
onDrag: () => void,
|
||||
eventOptions: AddEventListenerOptions
|
||||
) {
|
||||
/** Number of active pointers (supports multi-touch correctly). */
|
||||
const pointerCount = ref(0)
|
||||
|
||||
useEventListener(
|
||||
target,
|
||||
'pointerdown',
|
||||
(e: PointerEvent) => {
|
||||
// Only primary (0) and middle (1) buttons trigger canvas pan.
|
||||
if (e.button === 0 || e.button === 1) pointerCount.value++
|
||||
},
|
||||
eventOptions
|
||||
)
|
||||
|
||||
useEventListener(
|
||||
target,
|
||||
'pointermove',
|
||||
() => {
|
||||
if (pointerCount.value > 0) onDrag()
|
||||
},
|
||||
eventOptions
|
||||
)
|
||||
|
||||
// Listen on window so the release is caught even if the pointer
|
||||
// leaves the canvas before the button is released.
|
||||
useEventListener(
|
||||
window,
|
||||
'pointerup',
|
||||
() => {
|
||||
if (pointerCount.value > 0) pointerCount.value--
|
||||
},
|
||||
eventOptions
|
||||
)
|
||||
|
||||
useEventListener(
|
||||
window,
|
||||
'pointercancel',
|
||||
() => {
|
||||
if (pointerCount.value > 0) pointerCount.value--
|
||||
},
|
||||
eventOptions
|
||||
)
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ describe('useTransformState', () => {
|
||||
it('should generate correct initial transform style', () => {
|
||||
const { transformStyle } = transformState
|
||||
expect(transformStyle.value).toEqual({
|
||||
transform: 'scale(1) translate(0px, 0px)',
|
||||
transform: 'scale3d(1, 1, 1) translate3d(0px, 0px, 0)',
|
||||
transformOrigin: '0 0'
|
||||
})
|
||||
})
|
||||
@@ -102,7 +102,7 @@ describe('useTransformState', () => {
|
||||
syncWithCanvas(mockCanvas as LGraphCanvas)
|
||||
|
||||
expect(transformStyle.value).toEqual({
|
||||
transform: 'scale(0.5) translate(150px, 75px)',
|
||||
transform: 'scale3d(0.5, 0.5, 0.5) translate3d(150px, 75px, 0)',
|
||||
transformOrigin: '0 0'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -79,7 +79,9 @@ function useTransformStateIndividual() {
|
||||
// ctx.scale(scale); ctx.translate(offset)
|
||||
// CSS applies right-to-left, so "scale() translate()" -> translate first, then scale
|
||||
// Effective mapping: screen = (canvas + offset) * scale
|
||||
transform: `scale(${camera.z}) translate(${camera.x}px, ${camera.y}px)`,
|
||||
// Using the 3D versions of scale and translate can provide a smoother experience
|
||||
// when dealing with a large number of nodes.
|
||||
transform: `scale3d(${camera.z}, ${camera.z}, ${camera.z}) translate3d(${camera.x}px, ${camera.y}px, 0)`,
|
||||
transformOrigin: '0 0'
|
||||
}))
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"
|
||||
:style="[
|
||||
{
|
||||
transform: `translate(${position.x ?? 0}px, ${(position.y ?? 0) - LiteGraph.NODE_TITLE_HEIGHT}px)`,
|
||||
transform: `translate(${(position.x ?? 0) + paneBoundsOffset.x}px, ${(position.y ?? 0) - LiteGraph.NODE_TITLE_HEIGHT + paneBoundsOffset.y}px)`,
|
||||
zIndex: zIndex,
|
||||
opacity: nodeOpacity
|
||||
}
|
||||
@@ -265,6 +265,7 @@ import {
|
||||
LiteGraph,
|
||||
RenderShape
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { usePaneBounds } from '@/renderer/core/layout/transform/usePaneBounds'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
@@ -322,6 +323,7 @@ interface LGraphNodeProps {
|
||||
const { nodeData, error = null } = defineProps<LGraphNodeProps>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { offset: paneBoundsOffset } = usePaneBounds()
|
||||
|
||||
const { isSelectMode, isSelectOutputsMode } = useAppMode()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
Reference in New Issue
Block a user