Compare commits

...

13 Commits

Author SHA1 Message Date
Rizumu Ayaka
6a00502045 perf: reduce compositing layers by dynamically sizing TransformPane to contain all nodes 2026-03-13 18:18:18 +08:00
Rizumu Ayaka
7081df4585 fix: update pointer drag handling to ignore right-click and optimize event tracking 2026-03-13 00:41:20 +08:00
github-actions
08b176d429 [automated] Update test expectations 2026-03-12 15:54:47 +00:00
Rizumu Ayaka
e653313141 Merge branch 'main' into rizumu/perf/detect-pointer-drag-in-useTransformSettling-for-pan-optimization
# Conflicts:
#	browser_tests/tests/vueNodes/interactions/canvas/zoom.spec.ts-snapshots/zoomed-in-ctrl-shift-chromium-linux.png
2026-03-12 23:47:43 +08:00
github-actions
a5f6c2e08c [automated] Update test expectations 2026-03-12 12:14:45 +00:00
Rizumu Ayaka
4f1e124e4d ci: fix error 2026-03-12 20:07:47 +08:00
Rizumu Ayaka
7fa78cc458 perf: use 3D transforms for smoother rendering in useTransformState 2026-03-12 19:56:11 +08:00
Rizumu Ayaka
7853cca13f fix: listen for pointerup on window to prevent stuck drag state 2026-03-10 14:16:17 +08:00
Rizumu Ayaka
bf5a5070ea perf: remove unnecessary zoom-level binding in TransformPane 2026-03-09 22:42:18 +08:00
Rizumu Ayaka
46ce7671ab perf: optimize transform pane rendering by using direct DOM mutation 2026-03-09 21:26:14 +08:00
Rizumu Ayaka
67e363fc3c fix: legacy issues with transform-pane--interacting css class 2026-03-09 17:35:48 +08:00
Rizumu Ayaka
d284a32490 perf: restore full JSDoc for useTransformSettling 2026-03-09 17:13:28 +08:00
Rizumu Ayaka
e4240a357d perf: detect pointer drag in useTransformSettling for pan optimization 2026-03-09 16:59:09 +08:00
12 changed files with 227 additions and 113 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -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 {

View File

@@ -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`
}
}

View File

@@ -81,7 +81,6 @@
? 'Execution error'
: null
"
:zoom-level="canvasStore.canvas?.ds?.scale || 1"
:data-node-id="nodeData.id"
/>
</TransformPane>

View File

@@ -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, {

View File

@@ -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>

View 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)

View File

@@ -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()

View File

@@ -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
)
}

View File

@@ -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'
})
})

View File

@@ -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'
}))

View File

@@ -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()