mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-23 14:16:00 +00:00
Compare commits
14 Commits
v1.46.0
...
rizumu/per
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c499cf1de0 | ||
|
|
23b54494cb | ||
|
|
e4d9b1c214 | ||
|
|
7e5143d2f1 | ||
|
|
bf606a209e | ||
|
|
3159921280 | ||
|
|
20198f1465 | ||
|
|
b0e6942e92 | ||
|
|
bf906c26c6 | ||
|
|
d6aabe7d20 | ||
|
|
56b0f6b822 | ||
|
|
5aa18edeb0 | ||
|
|
c9bf31e13b | ||
|
|
e067c4434d |
@@ -209,7 +209,8 @@ test.describe('Image widget', { tag: ['@screenshot', '@widget'] }, () => {
|
||||
|
||||
// Expect the image preview to change automatically
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'image_preview_drag_and_dropped.png'
|
||||
'image_preview_drag_and_dropped.png',
|
||||
{ maxDiffPixelRatio: 0.02 }
|
||||
)
|
||||
|
||||
// Expect the filename combo value to be updated
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 43 KiB |
@@ -1,26 +1,32 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, nextTick } from 'vue'
|
||||
import { computed, nextTick, reactive } from 'vue'
|
||||
|
||||
import type { NodeLayout } from '@/renderer/core/layout/types'
|
||||
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
|
||||
import { createMockCanvas } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
import TransformPane from '../transform/TransformPane.vue'
|
||||
|
||||
const mockData = vi.hoisted(() => ({
|
||||
mockTransformStyle: {
|
||||
transform: 'scale(1) translate(0px, 0px)',
|
||||
transformOrigin: '0 0'
|
||||
},
|
||||
mockCamera: { x: 0, y: 0, z: 1 }
|
||||
}))
|
||||
const mockCamera = reactive({ x: 0, y: 0, z: 1 })
|
||||
|
||||
const { mockNodes, mockVersion } = vi.hoisted(() => {
|
||||
const { ref: createRef } = require('vue')
|
||||
return {
|
||||
mockNodes: createRef(new Map()),
|
||||
mockVersion: createRef(0)
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/renderer/core/layout/transform/useTransformState', () => {
|
||||
const syncWithCanvas = vi.fn()
|
||||
return {
|
||||
useTransformState: () => ({
|
||||
camera: computed(() => mockData.mockCamera),
|
||||
transformStyle: computed(() => mockData.mockTransformStyle),
|
||||
camera: mockCamera,
|
||||
transformStyle: computed(() => ({
|
||||
transform: `scale3d(${mockCamera.z}, ${mockCamera.z}, ${mockCamera.z}) translate3d(${mockCamera.x}px, ${mockCamera.y}px, 0)`,
|
||||
transformOrigin: '0 0'
|
||||
})),
|
||||
canvasToScreen: vi.fn(),
|
||||
screenToCanvas: vi.fn(),
|
||||
isNodeInViewport: vi.fn(),
|
||||
@@ -29,6 +35,15 @@ vi.mock('@/renderer/core/layout/transform/useTransformState', () => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
|
||||
layoutStore: {
|
||||
getAllNodes: () => computed(() => mockNodes.value),
|
||||
getVersion: () => computed(() => mockVersion.value),
|
||||
onChange: vi.fn(() => () => {}),
|
||||
getNodeLayoutRef: (id: string) => computed(() => mockNodes.value.get(id) ?? null)
|
||||
}
|
||||
}))
|
||||
|
||||
function createMockLGraphCanvas() {
|
||||
return createMockCanvas({
|
||||
canvas: {
|
||||
@@ -60,11 +75,10 @@ describe('TransformPane', () => {
|
||||
expect(screen.getByTestId('transform-pane')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply transform style from composable', async () => {
|
||||
mockData.mockTransformStyle = {
|
||||
transform: 'scale(2) translate(100px, 50px)',
|
||||
transformOrigin: '0 0'
|
||||
}
|
||||
it('should apply camera transform via RAF', async () => {
|
||||
mockCamera.x = 100
|
||||
mockCamera.y = 50
|
||||
mockCamera.z = 2
|
||||
|
||||
const mockCanvas = createMockLGraphCanvas()
|
||||
render(TransformPane, {
|
||||
@@ -72,15 +86,18 @@ describe('TransformPane', () => {
|
||||
canvas: mockCanvas
|
||||
}
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
vi.advanceTimersToNextFrame()
|
||||
await nextTick()
|
||||
|
||||
const transformPane = screen.getByTestId('transform-pane')
|
||||
expect(transformPane.getAttribute('style')).toContain(
|
||||
'transform: scale(2) translate(100px, 50px)'
|
||||
)
|
||||
const style = transformPane.getAttribute('style')
|
||||
expect(style).toContain('scale3d(2, 2, 2)')
|
||||
expect(style).toContain('translate3d(100px, 50px, 0)')
|
||||
})
|
||||
|
||||
it('should render slot content', () => {
|
||||
it('should render slot content inside offset wrapper', () => {
|
||||
const mockCanvas = createMockLGraphCanvas()
|
||||
render(TransformPane, {
|
||||
props: {
|
||||
@@ -169,19 +186,42 @@ describe('TransformPane', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('transform state integration', () => {
|
||||
it('should provide transform utilities to child components', () => {
|
||||
describe('content bounds offset', () => {
|
||||
it('should adjust transform to compensate for negative-coordinate nodes', async () => {
|
||||
mockCamera.x = 10
|
||||
mockCamera.y = 20
|
||||
mockCamera.z = 1
|
||||
|
||||
const nodeLayout: NodeLayout = {
|
||||
id: '1',
|
||||
position: { x: -500, y: -300 },
|
||||
size: { width: 200, height: 100 },
|
||||
zIndex: 0,
|
||||
visible: true,
|
||||
bounds: { x: -500, y: -300, width: 200, height: 100 }
|
||||
}
|
||||
mockNodes.value = new Map([['1', nodeLayout]])
|
||||
mockVersion.value = 1
|
||||
|
||||
const mockCanvas = createMockLGraphCanvas()
|
||||
render(TransformPane, {
|
||||
props: {
|
||||
canvas: mockCanvas
|
||||
}
|
||||
props: { canvas: mockCanvas }
|
||||
})
|
||||
|
||||
const transformState = useTransformState()
|
||||
expect(transformState.syncWithCanvas).toBeDefined()
|
||||
expect(transformState.canvasToScreen).toBeDefined()
|
||||
expect(transformState.screenToCanvas).toBeDefined()
|
||||
await nextTick()
|
||||
vi.advanceTimersToNextFrame()
|
||||
await nextTick()
|
||||
|
||||
const pane = screen.getByTestId('transform-pane')
|
||||
const style = pane.getAttribute('style') ?? ''
|
||||
|
||||
// Pane should have explicit dimensions (not 100%)
|
||||
expect(style).toMatch(/width:\s*\d+px/)
|
||||
expect(style).toMatch(/height:\s*\d+px/)
|
||||
|
||||
// Camera translation should be adjusted by offset (camera - offset)
|
||||
// so it won't match the raw camera values
|
||||
expect(style).not.toContain('translate3d(10px, 20px, 0)')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -2,18 +2,29 @@
|
||||
<div
|
||||
ref="transformPaneRef"
|
||||
data-testid="transform-pane"
|
||||
class="pointer-events-none absolute inset-0 size-full will-change-auto"
|
||||
class="pointer-events-none absolute top-0 left-0 will-change-auto"
|
||||
>
|
||||
<!-- Vue nodes will be rendered here -->
|
||||
<slot />
|
||||
<!--
|
||||
Offset wrapper: applies a single 2D translate to shift all child
|
||||
nodes into positive coordinate space. This keeps every node within
|
||||
the TransformPane's CSS box, so Chrome's compositor merges them
|
||||
into one compositing layer instead of creating one per node.
|
||||
The 2D translate does NOT promote this div to its own layer.
|
||||
-->
|
||||
<div ref="offsetWrapperRef" class="absolute">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRafFn } from '@vueuse/core'
|
||||
import { computed, useTemplateRef, watch } from 'vue'
|
||||
import { computed, onUnmounted, useTemplateRef, watch } from 'vue'
|
||||
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LayoutChange } from '@/renderer/core/layout/types'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { useContentBounds } from '@/renderer/core/layout/transform/useContentBounds'
|
||||
import { useTransformSettling } from '@/renderer/core/layout/transform/useTransformSettling'
|
||||
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
|
||||
|
||||
@@ -21,34 +32,72 @@ interface TransformPaneProps {
|
||||
canvas?: LGraphCanvas
|
||||
}
|
||||
|
||||
const props = defineProps<TransformPaneProps>()
|
||||
const { canvas } = defineProps<TransformPaneProps>()
|
||||
|
||||
const { transformStyle, syncWithCanvas } = useTransformState()
|
||||
const { camera, syncWithCanvas } = useTransformState()
|
||||
|
||||
const canvasElement = computed(() => props.canvas?.canvas)
|
||||
const canvasElement = computed(() => canvas?.canvas)
|
||||
const { isTransforming: isInteracting } = useTransformSettling(canvasElement, {
|
||||
settleDelay: 256
|
||||
})
|
||||
|
||||
const transformPaneRef = useTemplateRef('transformPaneRef')
|
||||
const offsetWrapperRef = useTemplateRef('offsetWrapperRef')
|
||||
|
||||
/**
|
||||
* Apply transform style and will-change class via direct DOM mutation
|
||||
* instead of reactive template bindings (:style / :class).
|
||||
*
|
||||
* These values change every animation frame during zoom or pan.
|
||||
* If they were bound in the template, Vue would diff the entire
|
||||
* TransformPane vnode—including all child node slots—on every frame,
|
||||
* causing expensive vdom patch work across the full node list.
|
||||
* Mutating the DOM directly limits the update to a single element.
|
||||
*/
|
||||
const contentBounds = useContentBounds()
|
||||
|
||||
watch([transformStyle, transformPaneRef], ([newStyle, el]) => {
|
||||
if (el) {
|
||||
Object.assign(el.style, newStyle)
|
||||
// Collect changed node IDs between RAF frames for differential updates.
|
||||
const pendingChangedIds = new Set<string>()
|
||||
let needsFullScan = true
|
||||
|
||||
const unsubscribe = layoutStore.onChange((change: LayoutChange) => {
|
||||
if (change.type === 'create' || change.type === 'delete') {
|
||||
needsFullScan = true
|
||||
} else {
|
||||
for (const id of change.nodeIds) {
|
||||
pendingChangedIds.add(id)
|
||||
}
|
||||
}
|
||||
})
|
||||
watch([isInteracting, transformPaneRef], ([interacting, el]) => {
|
||||
onUnmounted(unsubscribe)
|
||||
|
||||
function getNodeLayout(id: string) {
|
||||
return layoutStore.getNodeLayoutRef(id).value
|
||||
}
|
||||
|
||||
// --- DOM mutation (avoids Vue vdom diffing on every frame) ---
|
||||
|
||||
/**
|
||||
* Apply transform style, pane size, and offset wrapper transform via
|
||||
* direct DOM mutation instead of reactive template bindings.
|
||||
*
|
||||
* These values change every animation frame during zoom or pan.
|
||||
* Mutating the DOM directly limits the update to two elements,
|
||||
* avoiding expensive vdom patch work across the full node list.
|
||||
*/
|
||||
function applyStyles() {
|
||||
const pane = transformPaneRef.value
|
||||
const wrapper = offsetWrapperRef.value
|
||||
if (!pane) return
|
||||
|
||||
const { x: ox, y: oy } = contentBounds.offset
|
||||
const { width, height } = contentBounds.size
|
||||
const z = camera.z
|
||||
|
||||
// TransformPane: size covers all offset content; transform compensates
|
||||
pane.style.width = width > 0 ? `${width}px` : '100%'
|
||||
pane.style.height = height > 0 ? `${height}px` : '100%'
|
||||
pane.style.transform = `scale3d(${z}, ${z}, ${z}) translate3d(${camera.x - ox}px, ${camera.y - oy}px, 0)`
|
||||
pane.style.transformOrigin = '0 0'
|
||||
|
||||
// Offset wrapper: shift child nodes into positive coordinate space
|
||||
if (wrapper) {
|
||||
wrapper.style.transform = `translate(${ox}px, ${oy}px)`
|
||||
}
|
||||
}
|
||||
|
||||
watch(isInteracting, (interacting) => {
|
||||
const el = transformPaneRef.value
|
||||
if (el) {
|
||||
el.classList.toggle('will-change-transform', interacting)
|
||||
el.classList.toggle('will-change-auto', !interacting)
|
||||
@@ -57,10 +106,19 @@ watch([isInteracting, transformPaneRef], ([interacting, el]) => {
|
||||
|
||||
useRafFn(
|
||||
() => {
|
||||
if (!props.canvas) {
|
||||
return
|
||||
if (!canvas) return
|
||||
syncWithCanvas(canvas)
|
||||
|
||||
if (needsFullScan) {
|
||||
needsFullScan = false
|
||||
contentBounds.initialize(layoutStore.getAllNodes().value)
|
||||
} else if (pendingChangedIds.size > 0) {
|
||||
contentBounds.updateChanged(pendingChangedIds, getNodeLayout)
|
||||
}
|
||||
syncWithCanvas(props.canvas)
|
||||
pendingChangedIds.clear()
|
||||
|
||||
contentBounds.flush()
|
||||
applyStyles()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
164
src/renderer/core/layout/transform/useContentBounds.test.ts
Normal file
164
src/renderer/core/layout/transform/useContentBounds.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { useContentBounds } from '@/renderer/core/layout/transform/useContentBounds'
|
||||
|
||||
describe('useContentBounds', () => {
|
||||
describe('initial state', () => {
|
||||
it('has zero offset and size', () => {
|
||||
const { offset, size } = useContentBounds()
|
||||
expect(offset).toEqual({ x: 0, y: 0 })
|
||||
expect(size).toEqual({ width: 0, height: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('expandToInclude', () => {
|
||||
it('expands to cover bounds in positive space', () => {
|
||||
const cb = useContentBounds()
|
||||
cb.expandToInclude({ x: 100, y: 200, width: 300, height: 150 })
|
||||
cb.flush()
|
||||
|
||||
// Offset should be 0 because bounds are already positive
|
||||
// (min stays 0 since initial min is 0 and bounds.x > 0)
|
||||
expect(cb.offset.x).toBe(0)
|
||||
expect(cb.offset.y).toBe(0)
|
||||
// Size covers from min(0) to max(100+300+margin)
|
||||
expect(cb.size.width).toBeGreaterThan(400)
|
||||
expect(cb.size.height).toBeGreaterThan(350)
|
||||
})
|
||||
|
||||
it('generates offset for bounds in negative space', () => {
|
||||
const cb = useContentBounds()
|
||||
cb.expandToInclude({ x: -500, y: -300, width: 100, height: 50 })
|
||||
cb.flush()
|
||||
|
||||
// offset.x should shift -500 into positive range (plus margin)
|
||||
expect(cb.offset.x).toBeGreaterThan(500)
|
||||
expect(cb.offset.y).toBeGreaterThan(300)
|
||||
})
|
||||
|
||||
it('preserves coordinate identity: offset cancels in transform chain', () => {
|
||||
const cb = useContentBounds()
|
||||
cb.expandToInclude({ x: -1000, y: -2000, width: 500, height: 300 })
|
||||
cb.flush()
|
||||
|
||||
// Simulate the transform chain for a node at (-1000, -2000)
|
||||
const nodeX = -1000
|
||||
const nodeY = -2000
|
||||
const camX = 50
|
||||
const camY = 30
|
||||
const z = 1.5
|
||||
|
||||
// With offset wrapper:
|
||||
// Node DOM position: nodeX + offset.x, nodeY + offset.y
|
||||
// TransformPane camera: camX - offset.x, camY - offset.y
|
||||
// Screen = ((nodeX + offset.x) + (camX - offset.x)) * z
|
||||
// = (nodeX + camX) * z
|
||||
const screenX = (nodeX + cb.offset.x + (camX - cb.offset.x)) * z
|
||||
const screenY = (nodeY + cb.offset.y + (camY - cb.offset.y)) * z
|
||||
|
||||
expect(screenX).toBeCloseTo((nodeX + camX) * z)
|
||||
expect(screenY).toBeCloseTo((nodeY + camY) * z)
|
||||
})
|
||||
|
||||
it('uses grow-only strategy', () => {
|
||||
const cb = useContentBounds()
|
||||
|
||||
cb.expandToInclude({ x: -500, y: -500, width: 100, height: 100 })
|
||||
cb.flush()
|
||||
const firstOffset = { x: cb.offset.x, y: cb.offset.y }
|
||||
const firstSize = { width: cb.size.width, height: cb.size.height }
|
||||
|
||||
// Expand with bounds inside existing tracked area — no change
|
||||
cb.expandToInclude({ x: -100, y: -100, width: 50, height: 50 })
|
||||
cb.flush()
|
||||
expect(cb.offset.x).toBe(firstOffset.x)
|
||||
expect(cb.offset.y).toBe(firstOffset.y)
|
||||
expect(cb.size.width).toBe(firstSize.width)
|
||||
expect(cb.size.height).toBe(firstSize.height)
|
||||
|
||||
// Expand beyond existing area — grows
|
||||
// First expansion set minX = -500 - margin, so we need x below that
|
||||
cb.expandToInclude({ x: -5000, y: 0, width: 100, height: 100 })
|
||||
cb.flush()
|
||||
expect(cb.offset.x).toBeGreaterThan(firstOffset.x)
|
||||
})
|
||||
})
|
||||
|
||||
describe('flush', () => {
|
||||
it('returns false when nothing changed', () => {
|
||||
const cb = useContentBounds()
|
||||
expect(cb.flush()).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when bounds expanded', () => {
|
||||
const cb = useContentBounds()
|
||||
cb.expandToInclude({ x: -100, y: 0, width: 50, height: 50 })
|
||||
expect(cb.flush()).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false on second flush without new expansions', () => {
|
||||
const cb = useContentBounds()
|
||||
cb.expandToInclude({ x: -100, y: 0, width: 50, height: 50 })
|
||||
cb.flush()
|
||||
expect(cb.flush()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reset', () => {
|
||||
it('clears offset and size to zero', () => {
|
||||
const cb = useContentBounds()
|
||||
cb.expandToInclude({ x: -1000, y: -500, width: 200, height: 100 })
|
||||
cb.flush()
|
||||
|
||||
expect(cb.offset.x).toBeGreaterThan(0)
|
||||
|
||||
cb.reset()
|
||||
expect(cb.offset).toEqual({ x: 0, y: 0 })
|
||||
expect(cb.size).toEqual({ width: 0, height: 0 })
|
||||
})
|
||||
|
||||
it('allows fresh expansion after reset', () => {
|
||||
const cb = useContentBounds()
|
||||
cb.expandToInclude({ x: -5000, y: -5000, width: 100, height: 100 })
|
||||
cb.flush()
|
||||
const largeOffset = cb.offset.x
|
||||
|
||||
cb.reset()
|
||||
cb.expandToInclude({ x: -100, y: -100, width: 50, height: 50 })
|
||||
cb.flush()
|
||||
|
||||
expect(cb.offset.x).toBeLessThan(largeOffset)
|
||||
expect(cb.offset.x).toBeGreaterThan(100)
|
||||
})
|
||||
})
|
||||
|
||||
describe('all node positions remain positive after offset', () => {
|
||||
it('handles mixed positive and negative coordinates', () => {
|
||||
const cb = useContentBounds()
|
||||
|
||||
const nodes = [
|
||||
{ x: -3000, y: -2000, width: 200, height: 100 },
|
||||
{ x: 500, y: 300, width: 150, height: 80 },
|
||||
{ x: -100, y: 1000, width: 250, height: 120 },
|
||||
{ x: 2000, y: -500, width: 180, height: 90 }
|
||||
]
|
||||
|
||||
for (const n of nodes) {
|
||||
cb.expandToInclude(n)
|
||||
}
|
||||
cb.flush()
|
||||
|
||||
// Every node's offset position should be non-negative
|
||||
for (const n of nodes) {
|
||||
expect(n.x + cb.offset.x).toBeGreaterThanOrEqual(0)
|
||||
expect(n.y + cb.offset.y).toBeGreaterThanOrEqual(0)
|
||||
}
|
||||
|
||||
// Every node's offset right/bottom edge should be within the size
|
||||
for (const n of nodes) {
|
||||
expect(n.x + n.width + cb.offset.x).toBeLessThanOrEqual(cb.size.width)
|
||||
expect(n.y + n.height + cb.offset.y).toBeLessThanOrEqual(cb.size.height)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
135
src/renderer/core/layout/transform/useContentBounds.ts
Normal file
135
src/renderer/core/layout/transform/useContentBounds.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { reactive, readonly } from 'vue'
|
||||
|
||||
import type { Bounds, NodeLayout, Point } from '@/renderer/core/layout/types'
|
||||
|
||||
/**
|
||||
* Margin added around tracked content bounds to avoid frequent resizing
|
||||
* when nodes move slightly beyond the current tracked area.
|
||||
*/
|
||||
const EXPAND_MARGIN = 2000
|
||||
|
||||
interface ContentBoundsState {
|
||||
/** Offset to add to node positions so all content lands in positive space */
|
||||
offset: Readonly<Point>
|
||||
/** Total size of the content area after offset adjustment */
|
||||
size: Readonly<{ width: number; height: number }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks the bounding box of all canvas content and computes an offset
|
||||
* that shifts all positions into positive coordinate space.
|
||||
*
|
||||
* This enables the TransformPane's CSS box to fully contain all child
|
||||
* nodes, preventing Chrome's compositor from creating individual
|
||||
* compositing layers for overflowing children.
|
||||
*
|
||||
* Uses a grow-only strategy: bounds expand when content moves beyond
|
||||
* the tracked area but never shrink automatically. Call `reset()` to
|
||||
* recalculate from scratch (e.g., on workflow load).
|
||||
*/
|
||||
export function useContentBounds(): ContentBoundsState & {
|
||||
expandToInclude(bounds: Bounds): void
|
||||
initialize(nodes: ReadonlyMap<string, NodeLayout>): void
|
||||
updateChanged(
|
||||
changedIds: ReadonlySet<string>,
|
||||
getLayout: (id: string) => NodeLayout | null
|
||||
): void
|
||||
flush(): boolean
|
||||
reset(): void
|
||||
} {
|
||||
const offset = reactive<Point>({ x: 0, y: 0 })
|
||||
const size = reactive({ width: 0, height: 0 })
|
||||
|
||||
let minX = 0
|
||||
let minY = 0
|
||||
let maxX = 0
|
||||
let maxY = 0
|
||||
let dirty = false
|
||||
|
||||
function expandToInclude(bounds: Bounds) {
|
||||
const bRight = bounds.x + bounds.width
|
||||
const bBottom = bounds.y + bounds.height
|
||||
|
||||
// Only expand when content actually exceeds the tracked area.
|
||||
// When expanding, add EXPAND_MARGIN as headroom to avoid frequent resizing.
|
||||
if (bounds.x < minX) {
|
||||
minX = bounds.x - EXPAND_MARGIN
|
||||
dirty = true
|
||||
}
|
||||
if (bounds.y < minY) {
|
||||
minY = bounds.y - EXPAND_MARGIN
|
||||
dirty = true
|
||||
}
|
||||
if (bRight > maxX) {
|
||||
maxX = bRight + EXPAND_MARGIN
|
||||
dirty = true
|
||||
}
|
||||
if (bBottom > maxY) {
|
||||
maxY = bBottom + EXPAND_MARGIN
|
||||
dirty = true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full scan of all nodes. Used on workflow load or when the entire
|
||||
* node set is replaced.
|
||||
*/
|
||||
function initialize(nodes: ReadonlyMap<string, NodeLayout>) {
|
||||
reset()
|
||||
for (const [, layout] of nodes) {
|
||||
expandToInclude(layout.bounds)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Differential update: only process nodes that actually changed.
|
||||
* O(k) where k is the number of changed nodes, instead of O(n).
|
||||
*/
|
||||
function updateChanged(
|
||||
changedIds: ReadonlySet<string>,
|
||||
getLayout: (id: string) => NodeLayout | null
|
||||
) {
|
||||
for (const id of changedIds) {
|
||||
const layout = getLayout(id)
|
||||
if (layout) {
|
||||
expandToInclude(layout.bounds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies pending bound changes to the reactive offset and size.
|
||||
* Returns true if the values actually changed.
|
||||
*/
|
||||
function flush(): boolean {
|
||||
if (!dirty) return false
|
||||
dirty = false
|
||||
offset.x = -minX || 0
|
||||
offset.y = -minY || 0
|
||||
size.width = maxX - minX || 0
|
||||
size.height = maxY - minY || 0
|
||||
return true
|
||||
}
|
||||
|
||||
function reset() {
|
||||
minX = 0
|
||||
minY = 0
|
||||
maxX = 0
|
||||
maxY = 0
|
||||
dirty = false
|
||||
offset.x = 0
|
||||
offset.y = 0
|
||||
size.width = 0
|
||||
size.height = 0
|
||||
}
|
||||
|
||||
return {
|
||||
offset: readonly(offset),
|
||||
size: readonly(size),
|
||||
expandToInclude,
|
||||
initialize,
|
||||
updateChanged,
|
||||
flush,
|
||||
reset
|
||||
}
|
||||
}
|
||||
@@ -35,15 +35,13 @@
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const { camera, transformStyle, canvasToScreen } = useTransformState()
|
||||
* const { camera, syncWithCanvas, canvasToScreen } = useTransformState()
|
||||
*
|
||||
* // In template
|
||||
* <div :style="transformStyle">
|
||||
* <NodeComponent
|
||||
* v-for="node in nodes"
|
||||
* :style="{ left: node.x + 'px', top: node.y + 'px' }"
|
||||
* />
|
||||
* </div>
|
||||
* // Sync camera from LiteGraph each frame (via RAF)
|
||||
* syncWithCanvas(canvas)
|
||||
*
|
||||
* // Apply transform via direct DOM mutation for performance
|
||||
* el.style.transform = `scale3d(${camera.z}, ${camera.z}, ${camera.z}) translate3d(${camera.x}px, ${camera.y}px, 0)`
|
||||
*
|
||||
* // Convert coordinates
|
||||
* const screenPos = canvasToScreen({ x: nodeX, y: nodeY })
|
||||
|
||||
Reference in New Issue
Block a user