mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-14 19:51:05 +00:00
Compare commits
9 Commits
test/queue
...
rizumu/per
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3159921280 | ||
|
|
20198f1465 | ||
|
|
b0e6942e92 | ||
|
|
bf906c26c6 | ||
|
|
d6aabe7d20 | ||
|
|
56b0f6b822 | ||
|
|
5aa18edeb0 | ||
|
|
c9bf31e13b | ||
|
|
e067c4434d |
Binary file not shown.
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
@@ -213,7 +213,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,23 @@
|
||||
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 { 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 })
|
||||
|
||||
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 +26,14 @@ vi.mock('@/renderer/core/layout/transform/useTransformState', () => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
|
||||
layoutStore: {
|
||||
getAllNodes: () => computed(() => new Map()),
|
||||
getVersion: () => computed(() => 0),
|
||||
onChange: vi.fn(() => () => {})
|
||||
}
|
||||
}))
|
||||
|
||||
function createMockLGraphCanvas() {
|
||||
return createMockCanvas({
|
||||
canvas: {
|
||||
@@ -60,11 +65,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 +76,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: {
|
||||
|
||||
@@ -2,10 +2,18 @@
|
||||
<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>
|
||||
|
||||
@@ -14,6 +22,8 @@ import { useRafFn } from '@vueuse/core'
|
||||
import { computed, useTemplateRef, watch } from 'vue'
|
||||
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
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'
|
||||
|
||||
@@ -23,7 +33,7 @@ interface TransformPaneProps {
|
||||
|
||||
const props = defineProps<TransformPaneProps>()
|
||||
|
||||
const { transformStyle, syncWithCanvas } = useTransformState()
|
||||
const { camera, syncWithCanvas } = useTransformState()
|
||||
|
||||
const canvasElement = computed(() => props.canvas?.canvas)
|
||||
const { isTransforming: isInteracting } = useTransformSettling(canvasElement, {
|
||||
@@ -31,24 +41,75 @@ const { isTransforming: isInteracting } = useTransformSettling(canvasElement, {
|
||||
})
|
||||
|
||||
const transformPaneRef = useTemplateRef('transformPaneRef')
|
||||
const offsetWrapperRef = useTemplateRef('offsetWrapperRef')
|
||||
|
||||
const contentBounds = useContentBounds()
|
||||
const allNodes = layoutStore.getAllNodes()
|
||||
const storeVersion = layoutStore.getVersion()
|
||||
let lastTrackedVersion = -1
|
||||
let sampleNodeId: string | null = null
|
||||
|
||||
/**
|
||||
* Apply transform style and will-change class via direct DOM mutation
|
||||
* instead of reactive template bindings (:style / :class).
|
||||
* When the layout store version changes, expand the tracked content
|
||||
* bounds to include any nodes that moved beyond the current area.
|
||||
*
|
||||
* Detects workflow switches by checking whether a previously tracked
|
||||
* node still exists. When the entire node set is replaced (e.g. on
|
||||
* workflow load), resets bounds so they don't accumulate across
|
||||
* unrelated workflows.
|
||||
*/
|
||||
function updateContentBounds() {
|
||||
const currentVersion = storeVersion.value
|
||||
if (currentVersion === lastTrackedVersion) return
|
||||
lastTrackedVersion = currentVersion
|
||||
|
||||
const nodes = allNodes.value
|
||||
|
||||
// Detect workflow switch: if the sampled node is gone, the node set
|
||||
// was replaced wholesale — reset bounds to avoid unbounded growth.
|
||||
if (sampleNodeId !== null && nodes.size > 0 && !nodes.has(sampleNodeId)) {
|
||||
contentBounds.reset()
|
||||
}
|
||||
sampleNodeId = nodes.size > 0 ? (nodes.keys().next().value ?? null) : null
|
||||
|
||||
for (const [, layout] of nodes) {
|
||||
contentBounds.expandToInclude(layout.bounds)
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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.
|
||||
* 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.
|
||||
* 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
|
||||
|
||||
watch([transformStyle, transformPaneRef], ([newStyle, el]) => {
|
||||
if (el) {
|
||||
Object.assign(el.style, newStyle)
|
||||
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, transformPaneRef], ([interacting, el]) => {
|
||||
}
|
||||
|
||||
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 +118,11 @@ watch([isInteracting, transformPaneRef], ([interacting, el]) => {
|
||||
|
||||
useRafFn(
|
||||
() => {
|
||||
if (!props.canvas) {
|
||||
return
|
||||
}
|
||||
if (!props.canvas) return
|
||||
syncWithCanvas(props.canvas)
|
||||
updateContentBounds()
|
||||
contentBounds.flush()
|
||||
applyStyles()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
178
src/renderer/core/layout/transform/useContentBounds.test.ts
Normal file
178
src/renderer/core/layout/transform/useContentBounds.test.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
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('expandToIncludePoint', () => {
|
||||
it('expands to include a single point', () => {
|
||||
const cb = useContentBounds()
|
||||
cb.expandToIncludePoint({ x: -800, y: 500 })
|
||||
cb.flush()
|
||||
|
||||
expect(cb.offset.x).toBeGreaterThan(800)
|
||||
// y=500 is positive, doesn't push minY below 0 → offset.y stays 0
|
||||
// maxY expands to 500 + margin
|
||||
expect(cb.offset.y).toBe(0)
|
||||
expect(cb.size.height).toBeGreaterThan(500)
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
107
src/renderer/core/layout/transform/useContentBounds.ts
Normal file
107
src/renderer/core/layout/transform/useContentBounds.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { reactive, readonly } from 'vue'
|
||||
|
||||
import type { Bounds, 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
|
||||
expandToIncludePoint(point: Point): 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
|
||||
}
|
||||
}
|
||||
|
||||
function expandToIncludePoint(point: Point) {
|
||||
expandToInclude({ x: point.x, y: point.y, width: 0, height: 0 })
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
expandToIncludePoint,
|
||||
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