mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-14 01:20:03 +00:00
fix: simplify ensureCorrectLayoutScale and fix link sync during Vue node drag (#9680)
## Summary Fix node layout drift from repeated `ensureCorrectLayoutScale` scaling, simplify it to a pure one-time normalizer, and fix links not following Vue nodes during drag. ## Changes - **What**: - `ensureCorrectLayoutScale` simplified to a one-time normalizer: unprojects legacy Vue-scaled coordinates back to canonical LiteGraph coordinates, marks the graph as corrected, and does nothing else. No longer touches the layout store, syncs reroutes, or changes canvas scale. - Removed no-op calls from `useVueNodeLifecycle.ts` (a renderer version string was passed where an `LGraph` was expected). - `layoutStore.finalizeOperation` now calls `notifyChange` synchronously instead of via `setTimeout`. This ensures `useLayoutSync`'s `onChange` callback pushes positions to LiteGraph `node.pos` and calls `canvas.setDirty()` within the same RAF frame as a drag update, fixing links not following Vue nodes during drag. - **Tests**: Added tests for `ensureCorrectLayoutScale` (idempotency, round-trip, unknown-renderer no-op) and `graphRenderTransform` (project/unproject round-trips, anchor caching). ## Review Focus - The `setTimeout(() => this.notifyChange(change), 0)` → `this.notifyChange(change)` change in `layoutStore.ts` is the key fix for the drag-link-sync bug. The listener (`useLayoutSync`) only writes to LiteGraph, not back to the layout store, so synchronous notification is safe. - `ensureCorrectLayoutScale` no longer has any side effects beyond normalizing coordinates and setting `workflowRendererVersion` metadata. --------- Co-authored-by: Amp <amp@ampcode.com> Co-authored-by: Christian Byrne <cbyrne@comfy.org> Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com> Co-authored-by: AustinMroz <austin@comfy.org> Co-authored-by: Hunter <huntcsg@users.noreply.github.com> Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
104
browser_tests/tests/rendererToggleStability.spec.ts
Normal file
104
browser_tests/tests/rendererToggleStability.spec.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import type { Position } from '../fixtures/types'
|
||||
|
||||
type NodeSnapshot = { id: number } & Position
|
||||
|
||||
async function getAllNodePositions(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<NodeSnapshot[]> {
|
||||
return comfyPage.page.evaluate(() =>
|
||||
window.app!.graph.nodes.map((n) => ({
|
||||
id: n.id as number,
|
||||
x: n.pos[0],
|
||||
y: n.pos[1]
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
async function getNodePosition(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: number
|
||||
): Promise<Position | undefined> {
|
||||
return comfyPage.page.evaluate((targetNodeId) => {
|
||||
const node = window.app!.graph.nodes.find((n) => n.id === targetNodeId)
|
||||
if (!node) return
|
||||
|
||||
return {
|
||||
x: node.pos[0],
|
||||
y: node.pos[1]
|
||||
}
|
||||
}, nodeId)
|
||||
}
|
||||
|
||||
async function expectNodePositionStable(
|
||||
comfyPage: ComfyPage,
|
||||
initial: NodeSnapshot,
|
||||
mode: string
|
||||
) {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const current = await getNodePosition(comfyPage, initial.id)
|
||||
return current?.x ?? Number.NaN
|
||||
},
|
||||
{ message: `node ${initial.id} x drifted in ${mode} mode` }
|
||||
)
|
||||
.toBeCloseTo(initial.x, 1)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const current = await getNodePosition(comfyPage, initial.id)
|
||||
return current?.y ?? Number.NaN
|
||||
},
|
||||
{ message: `node ${initial.id} y drifted in ${mode} mode` }
|
||||
)
|
||||
.toBeCloseTo(initial.y, 1)
|
||||
}
|
||||
|
||||
async function setVueMode(comfyPage: ComfyPage, enabled: boolean) {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', enabled)
|
||||
if (enabled) {
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
}
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'Renderer toggle stability',
|
||||
{ tag: ['@node', '@canvas'] },
|
||||
() => {
|
||||
test('node positions do not drift when toggling between Vue and LiteGraph renderers', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const TOGGLE_COUNT = 5
|
||||
|
||||
const initialPositions = await getAllNodePositions(comfyPage)
|
||||
expect(initialPositions.length).toBeGreaterThan(0)
|
||||
|
||||
for (let i = 0; i < TOGGLE_COUNT; i++) {
|
||||
await setVueMode(comfyPage, true)
|
||||
for (const initial of initialPositions) {
|
||||
await expectNodePositionStable(
|
||||
comfyPage,
|
||||
initial,
|
||||
`Vue toggle ${i + 1}`
|
||||
)
|
||||
}
|
||||
|
||||
await setVueMode(comfyPage, false)
|
||||
for (const initial of initialPositions) {
|
||||
await expectNodePositionStable(
|
||||
comfyPage,
|
||||
initial,
|
||||
`LiteGraph toggle ${i + 1}`
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -78,6 +78,14 @@ vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
menu: {
|
||||
element: document.createElement('div')
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
type WrapperOptions = {
|
||||
pinia?: ReturnType<typeof createTestingPinia>
|
||||
stubs?: Record<string, boolean | Component>
|
||||
@@ -131,6 +139,18 @@ function createWrapper({
|
||||
})
|
||||
}
|
||||
|
||||
function getLegacyCommandsContainer(
|
||||
wrapper: ReturnType<typeof createWrapper>
|
||||
): HTMLElement {
|
||||
const legacyContainer = wrapper.find(
|
||||
'[data-testid="legacy-topbar-container"]'
|
||||
).element
|
||||
if (!(legacyContainer instanceof HTMLElement)) {
|
||||
throw new Error('Expected legacy commands container to be present')
|
||||
}
|
||||
return legacyContainer
|
||||
}
|
||||
|
||||
function createJob(id: string, status: JobStatus): JobListItem {
|
||||
return {
|
||||
id,
|
||||
@@ -515,4 +535,69 @@ describe('TopMenuSection', () => {
|
||||
|
||||
expect(wrapper.find('span.bg-red-500').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('coalesces legacy topbar mutation scans to one check per frame', async () => {
|
||||
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
|
||||
|
||||
const rafCallbacks: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||||
rafCallbacks.push(cb)
|
||||
return rafCallbacks.length
|
||||
})
|
||||
vi.stubGlobal('cancelAnimationFrame', vi.fn())
|
||||
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn })
|
||||
const settingStore = useSettingStore(pinia)
|
||||
vi.mocked(settingStore.get).mockImplementation((key) => {
|
||||
if (key === 'Comfy.UseNewMenu') return 'Top'
|
||||
if (key === 'Comfy.UI.TabBarLayout') return 'Integrated'
|
||||
if (key === 'Comfy.RightSidePanel.IsOpen') return true
|
||||
return undefined
|
||||
})
|
||||
|
||||
const wrapper = createWrapper({ pinia, attachTo: document.body })
|
||||
|
||||
try {
|
||||
await nextTick()
|
||||
|
||||
const actionbarContainer = wrapper.find('.actionbar-container')
|
||||
expect(actionbarContainer.classes()).toContain('w-0')
|
||||
|
||||
const legacyContainer = getLegacyCommandsContainer(wrapper)
|
||||
const querySpy = vi.spyOn(legacyContainer, 'querySelector')
|
||||
|
||||
if (rafCallbacks.length > 0) {
|
||||
const initialCallbacks = [...rafCallbacks]
|
||||
rafCallbacks.length = 0
|
||||
initialCallbacks.forEach((callback) => callback(0))
|
||||
await nextTick()
|
||||
}
|
||||
querySpy.mockClear()
|
||||
querySpy.mockReturnValue(document.createElement('div'))
|
||||
|
||||
for (let index = 0; index < 3; index++) {
|
||||
const outer = document.createElement('div')
|
||||
const inner = document.createElement('div')
|
||||
inner.textContent = `legacy-${index}`
|
||||
outer.appendChild(inner)
|
||||
legacyContainer.appendChild(outer)
|
||||
}
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(rafCallbacks.length).toBeGreaterThan(0)
|
||||
})
|
||||
expect(querySpy).not.toHaveBeenCalled()
|
||||
|
||||
const callbacks = [...rafCallbacks]
|
||||
rafCallbacks.length = 0
|
||||
callbacks.forEach((callback) => callback(0))
|
||||
await nextTick()
|
||||
|
||||
expect(querySpy).toHaveBeenCalledTimes(1)
|
||||
expect(actionbarContainer.classes()).toContain('px-2')
|
||||
} finally {
|
||||
wrapper.unmount()
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
|
||||
<div
|
||||
ref="legacyCommandsContainerRef"
|
||||
data-testid="legacy-topbar-container"
|
||||
class="[&:not(:has(*>*:not(:empty)))]:hidden"
|
||||
></div>
|
||||
|
||||
@@ -116,7 +117,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useLocalStorage, useMutationObserver } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
|
||||
@@ -264,6 +265,7 @@ const rightSidePanelTooltipConfig = computed(() =>
|
||||
// Maintain support for legacy topbar elements attached by custom scripts
|
||||
const legacyCommandsContainerRef = ref<HTMLElement>()
|
||||
const hasLegacyContent = ref(false)
|
||||
let legacyContentCheckRafId: number | null = null
|
||||
|
||||
function checkLegacyContent() {
|
||||
const el = legacyCommandsContainerRef.value
|
||||
@@ -276,19 +278,35 @@ function checkLegacyContent() {
|
||||
el.querySelector(':scope > * > *:not(:empty)') !== null
|
||||
}
|
||||
|
||||
useMutationObserver(legacyCommandsContainerRef, checkLegacyContent, {
|
||||
function scheduleLegacyContentCheck() {
|
||||
if (legacyContentCheckRafId !== null) return
|
||||
|
||||
legacyContentCheckRafId = requestAnimationFrame(() => {
|
||||
legacyContentCheckRafId = null
|
||||
checkLegacyContent()
|
||||
})
|
||||
}
|
||||
|
||||
useMutationObserver(legacyCommandsContainerRef, scheduleLegacyContentCheck, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true
|
||||
subtree: true
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (legacyCommandsContainerRef.value) {
|
||||
app.menu.element.style.width = 'fit-content'
|
||||
legacyCommandsContainerRef.value.appendChild(app.menu.element)
|
||||
checkLegacyContent()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (legacyContentCheckRafId === null) return
|
||||
|
||||
cancelAnimationFrame(legacyContentCheckRafId)
|
||||
legacyContentCheckRafId = null
|
||||
})
|
||||
|
||||
const openCustomNodeManager = async () => {
|
||||
try {
|
||||
await managerState.openManager({
|
||||
|
||||
@@ -9,7 +9,6 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { useLayoutSync } from '@/renderer/core/layout/sync/useLayoutSync'
|
||||
import { ensureCorrectLayoutScale } from '@/renderer/extensions/vueNodes/layout/ensureCorrectLayoutScale'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
|
||||
function useVueNodeLifecycleIndividual() {
|
||||
@@ -17,7 +16,7 @@ function useVueNodeLifecycleIndividual() {
|
||||
const layoutMutations = useLayoutMutations()
|
||||
const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||
const nodeManager = shallowRef<GraphNodeManager | null>(null)
|
||||
const { startSync } = useLayoutSync()
|
||||
const { startSync, stopSync } = useLayoutSync()
|
||||
|
||||
const initializeNodeManager = () => {
|
||||
// Use canvas graph if available (handles subgraph contexts), fallback to app graph
|
||||
@@ -55,11 +54,13 @@ function useVueNodeLifecycleIndividual() {
|
||||
)
|
||||
}
|
||||
|
||||
// Initialize layout sync (one-way: Layout Store → LiteGraph)
|
||||
// Start sync AFTER seeding so bootstrap operations don't trigger
|
||||
// the Layout→LiteGraph writeback loop redundantly.
|
||||
startSync(canvasStore.canvas)
|
||||
}
|
||||
|
||||
const disposeNodeManagerAndSyncs = () => {
|
||||
stopSync()
|
||||
if (!nodeManager.value) return
|
||||
|
||||
try {
|
||||
@@ -76,9 +77,6 @@ function useVueNodeLifecycleIndividual() {
|
||||
(enabled) => {
|
||||
if (enabled) {
|
||||
initializeNodeManager()
|
||||
ensureCorrectLayoutScale(
|
||||
comfyApp.canvas?.graph?.extra.workflowRendererVersion
|
||||
)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
@@ -87,26 +85,17 @@ function useVueNodeLifecycleIndividual() {
|
||||
whenever(
|
||||
() => !shouldRenderVueNodes.value,
|
||||
() => {
|
||||
ensureCorrectLayoutScale(
|
||||
comfyApp.canvas?.graph?.extra.workflowRendererVersion
|
||||
)
|
||||
disposeNodeManagerAndSyncs()
|
||||
comfyApp.canvas?.setDirty(true, true)
|
||||
}
|
||||
)
|
||||
|
||||
// Consolidated watch for slot layout sync management
|
||||
// Clear stale slot layouts when switching modes
|
||||
watch(
|
||||
() => shouldRenderVueNodes.value,
|
||||
(vueMode, oldVueMode) => {
|
||||
const modeChanged = vueMode !== oldVueMode
|
||||
|
||||
// Clear stale slot layouts when switching modes
|
||||
if (modeChanged) {
|
||||
layoutStore.clearAllSlotLayouts()
|
||||
}
|
||||
},
|
||||
{ immediate: true, flush: 'sync' }
|
||||
() => {
|
||||
layoutStore.clearAllSlotLayouts()
|
||||
}
|
||||
)
|
||||
|
||||
// Handle case where Vue nodes are enabled but graph starts empty
|
||||
|
||||
@@ -84,7 +84,7 @@ export type {
|
||||
LGraphTriggerParam
|
||||
} from './types/graphTriggers'
|
||||
|
||||
export type RendererType = 'LG' | 'Vue'
|
||||
export type RendererType = 'LG' | 'Vue' | 'Vue-corrected'
|
||||
|
||||
export interface LGraphState {
|
||||
lastGroupId: number
|
||||
|
||||
@@ -3,7 +3,11 @@ import type { SafeParseReturnType } from 'zod'
|
||||
import { fromZodError } from 'zod-validation-error'
|
||||
import type { RendererType } from '@/lib/litegraph/src/LGraph'
|
||||
|
||||
const zRendererType = z.enum(['LG', 'Vue']) satisfies z.ZodType<RendererType>
|
||||
const zRendererType = z.enum([
|
||||
'LG',
|
||||
'Vue',
|
||||
'Vue-corrected'
|
||||
]) satisfies z.ZodType<RendererType>
|
||||
|
||||
// GroupNode is hacking node id to be a string, so we need to allow that.
|
||||
// innerNode.id = `${this.node.id}:${i}`
|
||||
|
||||
@@ -22,6 +22,7 @@ const logger = log.getLogger('LayoutMutations')
|
||||
interface LayoutMutations {
|
||||
// Single node operations (synchronous, CRDT-ready)
|
||||
moveNode(nodeId: NodeId, position: Point): void
|
||||
batchMoveNodes(updates: Array<{ nodeId: NodeId; position: Point }>): void
|
||||
resizeNode(nodeId: NodeId, size: Size): void
|
||||
setNodeZIndex(nodeId: NodeId, zIndex: number): void
|
||||
|
||||
@@ -99,6 +100,33 @@ export function useLayoutMutations(): LayoutMutations {
|
||||
})
|
||||
}
|
||||
|
||||
function batchMoveNodes(
|
||||
updates: Array<{ nodeId: NodeId; position: Point }>
|
||||
): void {
|
||||
if (updates.length === 0) return
|
||||
|
||||
const nodeBoundsUpdates = updates.flatMap(({ nodeId, position }) => {
|
||||
const normalizedNodeId = String(nodeId)
|
||||
const existing = layoutStore.getNodeLayoutRef(normalizedNodeId).value
|
||||
if (!existing) return []
|
||||
|
||||
return [
|
||||
{
|
||||
nodeId: normalizedNodeId,
|
||||
bounds: {
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
width: existing.size.width,
|
||||
height: existing.size.height
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
if (nodeBoundsUpdates.length === 0) return
|
||||
layoutStore.batchUpdateNodeBounds(nodeBoundsUpdates)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize a node
|
||||
*/
|
||||
@@ -326,6 +354,7 @@ export function useLayoutMutations(): LayoutMutations {
|
||||
setSource,
|
||||
setActor,
|
||||
moveNode,
|
||||
batchMoveNodes,
|
||||
resizeNode,
|
||||
setNodeZIndex,
|
||||
createNode,
|
||||
|
||||
@@ -168,7 +168,7 @@ describe('layoutStore CRDT operations', () => {
|
||||
actor: layoutStore.getCurrentActor()
|
||||
})
|
||||
|
||||
// Wait for onChange callback to be called (uses setTimeout internally)
|
||||
// onChange notifications are deferred to a microtask.
|
||||
await vi.waitFor(() => {
|
||||
expect(changes.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
@@ -180,6 +180,195 @@ describe('layoutStore CRDT operations', () => {
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('should only notify node-scoped listeners for their node', async () => {
|
||||
const nodeA = 'scoped-node-a'
|
||||
const nodeB = 'scoped-node-b'
|
||||
const layoutA = createTestNode(nodeA)
|
||||
const layoutB = createTestNode(nodeB)
|
||||
|
||||
layoutStore.applyOperation({
|
||||
type: 'createNode',
|
||||
entity: 'node',
|
||||
nodeId: nodeA,
|
||||
layout: layoutA,
|
||||
timestamp: Date.now(),
|
||||
source: LayoutSource.External,
|
||||
actor: 'test'
|
||||
})
|
||||
|
||||
layoutStore.applyOperation({
|
||||
type: 'createNode',
|
||||
entity: 'node',
|
||||
nodeId: nodeB,
|
||||
layout: layoutB,
|
||||
timestamp: Date.now(),
|
||||
source: LayoutSource.External,
|
||||
actor: 'test'
|
||||
})
|
||||
|
||||
const scopedChanges: LayoutChange[] = []
|
||||
const unsubscribeScoped = layoutStore.onNodeChange(nodeA, (change) => {
|
||||
scopedChanges.push(change)
|
||||
})
|
||||
|
||||
layoutStore.applyOperation({
|
||||
type: 'moveNode',
|
||||
entity: 'node',
|
||||
nodeId: nodeB,
|
||||
position: { x: 400, y: 400 },
|
||||
previousPosition: layoutB.position,
|
||||
timestamp: Date.now(),
|
||||
source: LayoutSource.Vue,
|
||||
actor: 'test'
|
||||
})
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(scopedChanges.length).toBe(0)
|
||||
})
|
||||
|
||||
layoutStore.applyOperation({
|
||||
type: 'moveNode',
|
||||
entity: 'node',
|
||||
nodeId: nodeA,
|
||||
position: { x: 200, y: 250 },
|
||||
previousPosition: layoutA.position,
|
||||
timestamp: Date.now(),
|
||||
source: LayoutSource.Canvas,
|
||||
actor: 'test'
|
||||
})
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(scopedChanges.length).toBe(1)
|
||||
})
|
||||
|
||||
expect(scopedChanges[0].nodeIds).toContain(nodeA)
|
||||
unsubscribeScoped()
|
||||
})
|
||||
|
||||
it('keeps node-scoped listeners synchronous while deferring global listeners', async () => {
|
||||
const nodeId = 'dispatch-order-node'
|
||||
const layout = createTestNode(nodeId)
|
||||
|
||||
layoutStore.applyOperation({
|
||||
type: 'createNode',
|
||||
entity: 'node',
|
||||
nodeId,
|
||||
layout,
|
||||
timestamp: Date.now(),
|
||||
source: LayoutSource.External,
|
||||
actor: 'test'
|
||||
})
|
||||
|
||||
const callOrder: string[] = []
|
||||
const unsubscribeNode = layoutStore.onNodeChange(nodeId, () => {
|
||||
callOrder.push('node')
|
||||
})
|
||||
const unsubscribeGlobal = layoutStore.onChange(() => {
|
||||
callOrder.push('global')
|
||||
})
|
||||
|
||||
layoutStore.applyOperation({
|
||||
type: 'moveNode',
|
||||
entity: 'node',
|
||||
nodeId,
|
||||
position: { x: 320, y: 180 },
|
||||
previousPosition: layout.position,
|
||||
timestamp: Date.now(),
|
||||
source: LayoutSource.Vue,
|
||||
actor: 'test'
|
||||
})
|
||||
|
||||
expect(callOrder).toEqual(['node'])
|
||||
|
||||
await Promise.resolve()
|
||||
|
||||
expect(callOrder).toEqual(['node', 'global'])
|
||||
|
||||
unsubscribeNode()
|
||||
unsubscribeGlobal()
|
||||
})
|
||||
|
||||
it('clears node-scoped listeners when reinitializing from LiteGraph', () => {
|
||||
const nodeId = 'reinit-node'
|
||||
const staleListener = vi.fn()
|
||||
|
||||
layoutStore.onNodeChange(nodeId, staleListener)
|
||||
|
||||
layoutStore.initializeFromLiteGraph([
|
||||
{
|
||||
id: nodeId,
|
||||
pos: [0, 0],
|
||||
size: [200, 100]
|
||||
}
|
||||
])
|
||||
|
||||
layoutStore.applyOperation({
|
||||
type: 'moveNode',
|
||||
entity: 'node',
|
||||
nodeId,
|
||||
position: { x: 10, y: 20 },
|
||||
previousPosition: { x: 0, y: 0 },
|
||||
timestamp: Date.now(),
|
||||
source: LayoutSource.Vue,
|
||||
actor: 'test'
|
||||
})
|
||||
|
||||
expect(staleListener).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('defers global listener fan-out until the microtask boundary', async () => {
|
||||
const nodeId = 'global-fanout-node'
|
||||
const layout = createTestNode(nodeId)
|
||||
|
||||
layoutStore.applyOperation({
|
||||
type: 'createNode',
|
||||
entity: 'node',
|
||||
nodeId,
|
||||
layout,
|
||||
timestamp: Date.now(),
|
||||
source: LayoutSource.External,
|
||||
actor: 'test'
|
||||
})
|
||||
|
||||
const globalChanges: LayoutChange[] = []
|
||||
const unsubscribe = layoutStore.onChange((change) => {
|
||||
globalChanges.push(change)
|
||||
})
|
||||
|
||||
layoutStore.applyOperation({
|
||||
type: 'moveNode',
|
||||
entity: 'node',
|
||||
nodeId,
|
||||
position: { x: 120, y: 110 },
|
||||
previousPosition: layout.position,
|
||||
timestamp: Date.now(),
|
||||
source: LayoutSource.External,
|
||||
actor: 'test'
|
||||
})
|
||||
layoutStore.applyOperation({
|
||||
type: 'moveNode',
|
||||
entity: 'node',
|
||||
nodeId,
|
||||
position: { x: 150, y: 140 },
|
||||
previousPosition: { x: 120, y: 110 },
|
||||
timestamp: Date.now(),
|
||||
source: LayoutSource.External,
|
||||
actor: 'test'
|
||||
})
|
||||
|
||||
expect(globalChanges).toHaveLength(0)
|
||||
|
||||
await Promise.resolve()
|
||||
|
||||
expect(globalChanges).toHaveLength(2)
|
||||
expect(globalChanges.map((change) => change.operation.type)).toEqual([
|
||||
'moveNode',
|
||||
'moveNode'
|
||||
])
|
||||
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('should emit change when batch updating node bounds', async () => {
|
||||
const nodeId = 'test-node-6'
|
||||
const layout = createTestNode(nodeId)
|
||||
@@ -202,7 +391,7 @@ describe('layoutStore CRDT operations', () => {
|
||||
const newBounds = { x: 40, y: 60, width: 220, height: 120 }
|
||||
layoutStore.batchUpdateNodeBounds([{ nodeId, bounds: newBounds }])
|
||||
|
||||
// Wait for onChange callback to be called (uses setTimeout internally)
|
||||
// onChange notifications are deferred to a microtask.
|
||||
await vi.waitFor(() => {
|
||||
expect(changes.length).toBeGreaterThan(0)
|
||||
const lastChange = changes[changes.length - 1]
|
||||
|
||||
@@ -119,6 +119,12 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
|
||||
// Change listeners
|
||||
private changeListeners = new Set<(change: LayoutChange) => void>()
|
||||
private nodeChangeListeners = new Map<
|
||||
NodeId,
|
||||
Set<(change: LayoutChange) => void>
|
||||
>()
|
||||
private pendingGlobalChanges: LayoutChange[] = []
|
||||
private isGlobalDispatchQueued = false
|
||||
|
||||
// CustomRef cache and trigger functions
|
||||
private nodeRefs = new Map<NodeId, Ref<NodeLayout | null>>()
|
||||
@@ -917,8 +923,10 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
}
|
||||
})
|
||||
|
||||
// Notify listeners (after transaction completes)
|
||||
setTimeout(() => this.notifyChange(change), 0)
|
||||
// Keep node-scoped listeners synchronous for immediate local feedback,
|
||||
// but queue global listener fan-out to avoid blocking hot paths.
|
||||
this.notifyNodeChange(change)
|
||||
this.queueGlobalChange(change)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -929,6 +937,25 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
return () => this.changeListeners.delete(callback)
|
||||
}
|
||||
|
||||
onNodeChange(
|
||||
nodeId: NodeId,
|
||||
callback: (change: LayoutChange) => void
|
||||
): () => void {
|
||||
const listenersForNode = this.nodeChangeListeners.get(nodeId) ?? new Set()
|
||||
listenersForNode.add(callback)
|
||||
this.nodeChangeListeners.set(nodeId, listenersForNode)
|
||||
|
||||
return () => {
|
||||
const existingListeners = this.nodeChangeListeners.get(nodeId)
|
||||
if (!existingListeners) return
|
||||
|
||||
existingListeners.delete(callback)
|
||||
if (existingListeners.size === 0) {
|
||||
this.nodeChangeListeners.delete(nodeId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current operation source
|
||||
*/
|
||||
@@ -978,6 +1005,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
// Vue components may already hold references to these refs, and clearing
|
||||
// them would break the reactivity chain. The refs will be reused when
|
||||
// nodes are recreated, and stale refs will be cleaned up over time.
|
||||
this.nodeChangeListeners.clear()
|
||||
this.spatialIndex.clear()
|
||||
this.linkSegmentSpatialIndex.clear()
|
||||
this.slotSpatialIndex.clear()
|
||||
@@ -986,6 +1014,8 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
this.linkSegmentLayouts.clear()
|
||||
this.slotLayouts.clear()
|
||||
this.rerouteLayouts.clear()
|
||||
this.pendingGlobalChanges = []
|
||||
this.isGlobalDispatchQueued = false
|
||||
|
||||
nodes.forEach((node, index) => {
|
||||
const layout: NodeLayout = {
|
||||
@@ -1374,6 +1404,30 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
|
||||
// Helper methods
|
||||
|
||||
private queueGlobalChange(change: LayoutChange): void {
|
||||
if (this.changeListeners.size === 0) return
|
||||
|
||||
this.pendingGlobalChanges.push(change)
|
||||
if (this.isGlobalDispatchQueued) return
|
||||
|
||||
this.isGlobalDispatchQueued = true
|
||||
queueMicrotask(() => {
|
||||
this.flushQueuedGlobalChanges()
|
||||
})
|
||||
}
|
||||
|
||||
private flushQueuedGlobalChanges(): void {
|
||||
this.isGlobalDispatchQueued = false
|
||||
if (this.pendingGlobalChanges.length === 0) return
|
||||
|
||||
const queuedChanges = this.pendingGlobalChanges
|
||||
this.pendingGlobalChanges = []
|
||||
|
||||
queuedChanges.forEach((queuedChange) => {
|
||||
this.notifyChange(queuedChange)
|
||||
})
|
||||
}
|
||||
|
||||
private notifyChange(change: LayoutChange): void {
|
||||
this.changeListeners.forEach((listener) => {
|
||||
try {
|
||||
@@ -1384,6 +1438,21 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
})
|
||||
}
|
||||
|
||||
private notifyNodeChange(change: LayoutChange): void {
|
||||
for (const nodeId of new Set(change.nodeIds)) {
|
||||
const listeners = this.nodeChangeListeners.get(nodeId)
|
||||
if (!listeners) continue
|
||||
|
||||
listeners.forEach((listener) => {
|
||||
try {
|
||||
listener(change)
|
||||
} catch (error) {
|
||||
console.error('Error in node-scoped layout change listener:', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// CRDT-specific methods
|
||||
getOperationsSince(timestamp: number): LayoutOperation[] {
|
||||
const operations: LayoutOperation[] = []
|
||||
|
||||
172
src/renderer/core/layout/sync/useLayoutSync.test.ts
Normal file
172
src/renderer/core/layout/sync/useLayoutSync.test.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
|
||||
const testState = vi.hoisted(() => {
|
||||
return {
|
||||
listener: null as
|
||||
| ((change: { nodeIds: string[]; source: LayoutSource }) => void)
|
||||
| null,
|
||||
layoutByNodeId: new Map<
|
||||
string,
|
||||
{
|
||||
position: { x: number; y: number }
|
||||
size: { width: number; height: number }
|
||||
}
|
||||
>(),
|
||||
unsubscribe: vi.fn(),
|
||||
rafCallback: null as FrameRequestCallback | null,
|
||||
microtaskCallback: null as (() => void) | null,
|
||||
cancelAnimationFrame: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
|
||||
layoutStore: {
|
||||
onChange: vi.fn(
|
||||
(
|
||||
callback: (change: { nodeIds: string[]; source: LayoutSource }) => void
|
||||
) => {
|
||||
testState.listener = callback
|
||||
return testState.unsubscribe
|
||||
}
|
||||
),
|
||||
getNodeLayoutRef: vi.fn((nodeId: string) => ({
|
||||
value: testState.layoutByNodeId.get(nodeId) ?? null
|
||||
}))
|
||||
}
|
||||
}))
|
||||
|
||||
import { useLayoutSync } from '@/renderer/core/layout/sync/useLayoutSync'
|
||||
|
||||
const LayoutSyncHarness = defineComponent({
|
||||
setup() {
|
||||
return useLayoutSync()
|
||||
},
|
||||
template: '<div />'
|
||||
})
|
||||
|
||||
describe('useLayoutSync', () => {
|
||||
beforeEach(() => {
|
||||
testState.listener = null
|
||||
testState.layoutByNodeId.clear()
|
||||
testState.unsubscribe.mockReset()
|
||||
testState.rafCallback = null
|
||||
testState.microtaskCallback = null
|
||||
testState.cancelAnimationFrame.mockReset()
|
||||
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||||
testState.rafCallback = cb
|
||||
return 1
|
||||
})
|
||||
vi.stubGlobal('cancelAnimationFrame', testState.cancelAnimationFrame)
|
||||
vi.stubGlobal('queueMicrotask', (cb: () => void) => {
|
||||
testState.microtaskCallback = cb
|
||||
})
|
||||
})
|
||||
|
||||
it('coalesces multiple change events into one flush per frame', () => {
|
||||
const liteNode = {
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
onResize: vi.fn()
|
||||
}
|
||||
const canvas = {
|
||||
graph: {
|
||||
getNodeById: vi.fn(() => liteNode)
|
||||
},
|
||||
setDirty: vi.fn()
|
||||
}
|
||||
|
||||
testState.layoutByNodeId.set('1', {
|
||||
position: { x: 10, y: 15 },
|
||||
size: { width: 120, height: 70 }
|
||||
})
|
||||
|
||||
const wrapper = mount(LayoutSyncHarness)
|
||||
|
||||
wrapper.vm.startSync(canvas as never)
|
||||
|
||||
testState.listener?.({ nodeIds: ['1'], source: LayoutSource.External })
|
||||
testState.listener?.({ nodeIds: ['1'], source: LayoutSource.External })
|
||||
|
||||
expect(canvas.setDirty).not.toHaveBeenCalled()
|
||||
expect(testState.microtaskCallback).toBeNull()
|
||||
|
||||
testState.rafCallback?.(0)
|
||||
|
||||
expect(canvas.setDirty).toHaveBeenCalledTimes(1)
|
||||
expect(canvas.graph.getNodeById).toHaveBeenCalledTimes(1)
|
||||
expect(liteNode.pos).toEqual([10, 15])
|
||||
expect(liteNode.size).toEqual([120, 70])
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('flushes interactive updates in a microtask without waiting for raf', () => {
|
||||
const liteNode = {
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
onResize: vi.fn()
|
||||
}
|
||||
const canvas = {
|
||||
graph: {
|
||||
getNodeById: vi.fn(() => liteNode)
|
||||
},
|
||||
setDirty: vi.fn()
|
||||
}
|
||||
|
||||
testState.layoutByNodeId.set('1', {
|
||||
position: { x: 20, y: 30 },
|
||||
size: { width: 120, height: 70 }
|
||||
})
|
||||
|
||||
const wrapper = mount(LayoutSyncHarness)
|
||||
|
||||
wrapper.vm.startSync(canvas as never)
|
||||
testState.listener?.({ nodeIds: ['1'], source: LayoutSource.Vue })
|
||||
|
||||
expect(testState.rafCallback).toBeNull()
|
||||
expect(canvas.setDirty).not.toHaveBeenCalled()
|
||||
|
||||
testState.microtaskCallback?.()
|
||||
|
||||
expect(canvas.setDirty).toHaveBeenCalledTimes(1)
|
||||
expect(liteNode.pos).toEqual([20, 30])
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('promotes queued raf work to microtask when interactive changes arrive', () => {
|
||||
const canvas = {
|
||||
graph: {
|
||||
getNodeById: vi.fn(() => ({
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
onResize: vi.fn()
|
||||
}))
|
||||
},
|
||||
setDirty: vi.fn()
|
||||
}
|
||||
|
||||
testState.layoutByNodeId.set('1', {
|
||||
position: { x: 5, y: 6 },
|
||||
size: { width: 100, height: 50 }
|
||||
})
|
||||
|
||||
const wrapper = mount(LayoutSyncHarness)
|
||||
|
||||
wrapper.vm.startSync(canvas as never)
|
||||
testState.listener?.({ nodeIds: ['1'], source: LayoutSource.External })
|
||||
expect(testState.rafCallback).toBeTruthy()
|
||||
|
||||
testState.listener?.({ nodeIds: ['1'], source: LayoutSource.DOM })
|
||||
|
||||
expect(testState.cancelAnimationFrame).toHaveBeenCalledWith(1)
|
||||
expect(testState.microtaskCallback).toBeTruthy()
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,7 @@ import { onUnmounted, ref } from 'vue'
|
||||
|
||||
import type { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
|
||||
/**
|
||||
* Composable for syncing LiteGraph with the Layout system
|
||||
@@ -15,6 +16,84 @@ import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
*/
|
||||
export function useLayoutSync() {
|
||||
const unsubscribe = ref<() => void>()
|
||||
const pendingNodeIds = new Set<string>()
|
||||
let rafId: number | null = null
|
||||
let isMicrotaskQueued = false
|
||||
let syncGeneration = 0
|
||||
|
||||
function flushPendingChanges(
|
||||
canvas: ReturnType<typeof useCanvasStore>['canvas']
|
||||
) {
|
||||
rafId = null
|
||||
isMicrotaskQueued = false
|
||||
if (!canvas?.graph || pendingNodeIds.size === 0) return
|
||||
|
||||
for (const nodeId of pendingNodeIds) {
|
||||
const layout = layoutStore.getNodeLayoutRef(nodeId).value
|
||||
if (!layout) continue
|
||||
|
||||
const liteNode = canvas.graph.getNodeById(nodeId)
|
||||
if (!liteNode) continue
|
||||
|
||||
if (
|
||||
liteNode.pos[0] !== layout.position.x ||
|
||||
liteNode.pos[1] !== layout.position.y
|
||||
) {
|
||||
liteNode.pos[0] = layout.position.x
|
||||
liteNode.pos[1] = layout.position.y
|
||||
}
|
||||
|
||||
// Note: layout.size.height is the content height without title.
|
||||
// LiteGraph's measure() will add titleHeight to get boundingRect.
|
||||
// Do NOT use addNodeTitleHeight here - that would double-count the title.
|
||||
if (
|
||||
liteNode.size[0] !== layout.size.width ||
|
||||
liteNode.size[1] !== layout.size.height
|
||||
) {
|
||||
// Update internal size directly (like position above) to avoid
|
||||
// the size setter writing back to layoutStore with Canvas source,
|
||||
// which would create a feedback loop through handleLayoutChange.
|
||||
liteNode.size[0] = layout.size.width
|
||||
liteNode.size[1] = layout.size.height
|
||||
liteNode.onResize?.(liteNode.size)
|
||||
}
|
||||
}
|
||||
|
||||
pendingNodeIds.clear()
|
||||
canvas.setDirty(true, true)
|
||||
}
|
||||
|
||||
function scheduleFlush(
|
||||
source: LayoutSource,
|
||||
canvas: ReturnType<typeof useCanvasStore>['canvas']
|
||||
) {
|
||||
const shouldFlushInMicrotask =
|
||||
source === LayoutSource.Vue || source === LayoutSource.DOM
|
||||
|
||||
if (shouldFlushInMicrotask) {
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = null
|
||||
}
|
||||
if (isMicrotaskQueued) return
|
||||
|
||||
isMicrotaskQueued = true
|
||||
const gen = syncGeneration
|
||||
queueMicrotask(() => {
|
||||
if (gen !== syncGeneration) return
|
||||
flushPendingChanges(canvas)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (rafId !== null || isMicrotaskQueued) return
|
||||
|
||||
const gen = syncGeneration
|
||||
rafId = requestAnimationFrame(() => {
|
||||
if (gen !== syncGeneration) return
|
||||
flushPendingChanges(canvas)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Start syncing from Layout → LiteGraph
|
||||
@@ -26,45 +105,25 @@ export function useLayoutSync() {
|
||||
stopSync()
|
||||
// Subscribe to layout changes
|
||||
unsubscribe.value = layoutStore.onChange((change) => {
|
||||
// Apply changes to LiteGraph regardless of source
|
||||
// The layout store is the single source of truth
|
||||
// Topology-only changes (links, reroutes) don't need LiteGraph
|
||||
// node writeback — link rendering reads from the store directly.
|
||||
if (change.nodeIds.length === 0) return
|
||||
|
||||
for (const nodeId of change.nodeIds) {
|
||||
const layout = layoutStore.getNodeLayoutRef(nodeId).value
|
||||
if (!layout) continue
|
||||
|
||||
const liteNode = canvas.graph?.getNodeById(parseInt(nodeId))
|
||||
if (!liteNode) continue
|
||||
|
||||
if (
|
||||
liteNode.pos[0] !== layout.position.x ||
|
||||
liteNode.pos[1] !== layout.position.y
|
||||
) {
|
||||
liteNode.pos[0] = layout.position.x
|
||||
liteNode.pos[1] = layout.position.y
|
||||
}
|
||||
|
||||
// Note: layout.size.height is the content height without title.
|
||||
// LiteGraph's measure() will add titleHeight to get boundingRect.
|
||||
// Do NOT use addNodeTitleHeight here - that would double-count the title.
|
||||
if (
|
||||
liteNode.size[0] !== layout.size.width ||
|
||||
liteNode.size[1] !== layout.size.height
|
||||
) {
|
||||
// Update internal size directly (like position above) to avoid
|
||||
// the size setter writing back to layoutStore with Canvas source,
|
||||
// which would create a feedback loop through handleLayoutChange.
|
||||
liteNode.size[0] = layout.size.width
|
||||
liteNode.size[1] = layout.size.height
|
||||
liteNode.onResize?.(liteNode.size)
|
||||
}
|
||||
pendingNodeIds.add(nodeId)
|
||||
}
|
||||
|
||||
// Trigger single redraw for all changes
|
||||
canvas.setDirty(true, true)
|
||||
scheduleFlush(change.source, canvas)
|
||||
})
|
||||
}
|
||||
|
||||
function stopSync() {
|
||||
syncGeneration++
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = null
|
||||
}
|
||||
isMicrotaskQueued = false
|
||||
pendingNodeIds.clear()
|
||||
unsubscribe.value?.()
|
||||
unsubscribe.value = undefined
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
RENDER_SCALE_FACTOR,
|
||||
getGraphRenderAnchor,
|
||||
unprojectBounds,
|
||||
unprojectPoint
|
||||
} from './graphRenderTransform'
|
||||
|
||||
const anchor = { x: 100, y: 100 }
|
||||
|
||||
describe('graphRenderTransform', () => {
|
||||
describe('unprojectPoint', () => {
|
||||
it('divides offset from anchor by scale', () => {
|
||||
const point = { x: 220, y: 220 }
|
||||
const result = unprojectPoint(point, anchor, RENDER_SCALE_FACTOR)
|
||||
expect(result.x).toBeCloseTo(100 + 120 / RENDER_SCALE_FACTOR)
|
||||
expect(result.y).toBeCloseTo(100 + 120 / RENDER_SCALE_FACTOR)
|
||||
})
|
||||
|
||||
it('is identity when scale is 1', () => {
|
||||
const point = { x: 250, y: 300 }
|
||||
expect(unprojectPoint(point, anchor, 1)).toEqual(point)
|
||||
})
|
||||
|
||||
it('leaves anchor point unchanged', () => {
|
||||
const result = unprojectPoint(anchor, anchor, RENDER_SCALE_FACTOR)
|
||||
expect(result).toEqual(anchor)
|
||||
})
|
||||
})
|
||||
|
||||
describe('unprojectBounds', () => {
|
||||
it('unprojects position and shrinks dimensions', () => {
|
||||
const bounds = { x: 220, y: 220, width: 120, height: 60 }
|
||||
const result = unprojectBounds(bounds, anchor, RENDER_SCALE_FACTOR)
|
||||
expect(result.x).toBeCloseTo(100 + 120 / RENDER_SCALE_FACTOR)
|
||||
expect(result.y).toBeCloseTo(100 + 120 / RENDER_SCALE_FACTOR)
|
||||
expect(result.width).toBeCloseTo(120 / RENDER_SCALE_FACTOR)
|
||||
expect(result.height).toBeCloseTo(60 / RENDER_SCALE_FACTOR)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getGraphRenderAnchor', () => {
|
||||
it('returns cached anchor on subsequent calls', () => {
|
||||
const mockGraph = {
|
||||
nodes: [
|
||||
{
|
||||
pos: [100, 200],
|
||||
size: [120, 80],
|
||||
get width() {
|
||||
return this.size[0]
|
||||
},
|
||||
get boundingRect() {
|
||||
return [this.pos[0], this.pos[1], this.size[0], this.size[1]]
|
||||
}
|
||||
},
|
||||
{
|
||||
pos: [300, 400],
|
||||
size: [100, 60],
|
||||
get width() {
|
||||
return this.size[0]
|
||||
},
|
||||
get boundingRect() {
|
||||
return [this.pos[0], this.pos[1], this.size[0], this.size[1]]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const anchor1 = getGraphRenderAnchor(mockGraph as never)
|
||||
// Mutate positions — anchor should stay frozen
|
||||
mockGraph.nodes[0].pos = [500, 600]
|
||||
const anchor2 = getGraphRenderAnchor(mockGraph as never)
|
||||
|
||||
expect(anchor1).toBe(anchor2)
|
||||
})
|
||||
|
||||
it('returns origin for empty graph', () => {
|
||||
const mockGraph = { nodes: [] }
|
||||
const anchor = getGraphRenderAnchor(mockGraph as never)
|
||||
expect(anchor).toEqual({ x: 0, y: 0 })
|
||||
})
|
||||
})
|
||||
})
|
||||
52
src/renderer/core/layout/transform/graphRenderTransform.ts
Normal file
52
src/renderer/core/layout/transform/graphRenderTransform.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
import { createBounds } from '@/lib/litegraph/src/measure'
|
||||
import type { Bounds, Point } from '@/renderer/core/layout/types'
|
||||
|
||||
export const RENDER_SCALE_FACTOR = 1.2
|
||||
|
||||
export const MIN_NODE_WIDTH = 225
|
||||
|
||||
const anchorCache = new WeakMap<LGraph, Point>()
|
||||
|
||||
/**
|
||||
* Returns a render anchor for a graph.
|
||||
*
|
||||
* @remarks
|
||||
* The anchor is computed once per graph instance and cached for that graph's
|
||||
* lifetime. This is intended for one-shot normalization passes.
|
||||
*/
|
||||
export function getGraphRenderAnchor(graph: LGraph): Point {
|
||||
const cached = anchorCache.get(graph)
|
||||
if (cached) return cached
|
||||
|
||||
const bounds = graph.nodes?.length ? createBounds(graph.nodes) : undefined
|
||||
const anchor = bounds ? { x: bounds[0], y: bounds[1] } : { x: 0, y: 0 }
|
||||
anchorCache.set(graph, anchor)
|
||||
return anchor
|
||||
}
|
||||
|
||||
export function unprojectPoint(
|
||||
point: Point,
|
||||
anchor: Point,
|
||||
scale: number
|
||||
): Point {
|
||||
if (scale === 1) return point
|
||||
return {
|
||||
x: anchor.x + (point.x - anchor.x) / scale,
|
||||
y: anchor.y + (point.y - anchor.y) / scale
|
||||
}
|
||||
}
|
||||
|
||||
export function unprojectBounds(
|
||||
bounds: Bounds,
|
||||
anchor: Point,
|
||||
scale: number
|
||||
): Bounds {
|
||||
const topLeft = unprojectPoint({ x: bounds.x, y: bounds.y }, anchor, scale)
|
||||
return {
|
||||
x: topLeft.x,
|
||||
y: topLeft.y,
|
||||
width: bounds.width / scale,
|
||||
height: bounds.height / scale
|
||||
}
|
||||
}
|
||||
@@ -318,6 +318,10 @@ export interface LayoutStore {
|
||||
|
||||
// Change subscription
|
||||
onChange(callback: (change: LayoutChange) => void): () => void
|
||||
onNodeChange(
|
||||
nodeId: NodeId,
|
||||
callback: (change: LayoutChange) => void
|
||||
): () => void
|
||||
|
||||
// Initialization
|
||||
initializeFromLiteGraph(
|
||||
|
||||
@@ -7,10 +7,11 @@
|
||||
ref="nodeContainerRef"
|
||||
tabindex="0"
|
||||
:data-node-id="nodeData.id"
|
||||
:data-collapsed="isCollapsed || undefined"
|
||||
:class="
|
||||
cn(
|
||||
'group/node lg-node absolute text-sm',
|
||||
'flex min-w-[225px] flex-col contain-layout contain-style',
|
||||
'flex min-w-(--min-node-width) flex-col contain-layout contain-style',
|
||||
cursorClass,
|
||||
isSelected && 'outline-node-component-outline',
|
||||
executing && 'outline-node-stroke-executing',
|
||||
@@ -19,13 +20,12 @@
|
||||
: 'pointer-events-none'
|
||||
)
|
||||
"
|
||||
:style="[
|
||||
{
|
||||
transform: `translate(${position.x ?? 0}px, ${(position.y ?? 0) - LiteGraph.NODE_TITLE_HEIGHT}px)`,
|
||||
zIndex: zIndex,
|
||||
opacity: nodeOpacity
|
||||
}
|
||||
]"
|
||||
:style="{
|
||||
'--min-node-width': `${MIN_NODE_WIDTH}px`,
|
||||
transform: `translate(${position.x ?? 0}px, ${(position.y ?? 0) - LiteGraph.NODE_TITLE_HEIGHT}px)`,
|
||||
zIndex: zIndex,
|
||||
opacity: nodeOpacity
|
||||
}"
|
||||
v-bind="remainingPointerHandlers"
|
||||
@pointerdown="nodeOnPointerdown"
|
||||
@wheel="handleWheel"
|
||||
@@ -75,7 +75,7 @@
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-1 flex-col border border-solid border-transparent bg-node-component-header-surface',
|
||||
'min-h-(--node-height) w-(--node-width)',
|
||||
'min-h-(--node-height) w-(--node-width) min-w-(--min-node-width)',
|
||||
shapeClass,
|
||||
hasAnyError && 'ring-4 ring-destructive-background',
|
||||
{
|
||||
@@ -275,6 +275,7 @@ import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { usePromotedPreviews } from '@/composables/node/usePromotedPreviews'
|
||||
import NodeBadges from '@/renderer/extensions/vueNodes/components/NodeBadges.vue'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import type { LayoutChange } from '@/renderer/core/layout/types'
|
||||
import AppOutput from '@/renderer/extensions/linearMode/AppOutput.vue'
|
||||
import SlotConnectionDot from '@/renderer/extensions/vueNodes/components/SlotConnectionDot.vue'
|
||||
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
|
||||
@@ -303,6 +304,7 @@ import { isTransparent } from '@/utils/colorUtil'
|
||||
|
||||
import type { CompassCorners } from '@/lib/litegraph/src/interfaces'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { MIN_NODE_WIDTH } from '@/renderer/core/layout/transform/graphRenderTransform'
|
||||
|
||||
import { RESIZE_HANDLES } from '../interactions/resize/resizeHandleConfig'
|
||||
import { useNodeResize } from '../interactions/resize/useNodeResize'
|
||||
@@ -452,18 +454,13 @@ function initSizeStyles() {
|
||||
* Handle external size changes (e.g., from extensions calling node.setSize()).
|
||||
* Updates CSS variables when layoutStore changes from Canvas/External source.
|
||||
*/
|
||||
function handleLayoutChange(change: {
|
||||
source: LayoutSource
|
||||
nodeIds: string[]
|
||||
}) {
|
||||
function handleLayoutChange(change: LayoutChange) {
|
||||
// Only handle Canvas or External source (extensions calling setSize)
|
||||
if (
|
||||
change.source !== LayoutSource.Canvas &&
|
||||
change.source !== LayoutSource.External
|
||||
)
|
||||
return
|
||||
|
||||
if (!change.nodeIds.includes(nodeData.id)) return
|
||||
if (layoutStore.isResizingVueNodes.value) return
|
||||
if (isCollapsed.value) return
|
||||
|
||||
@@ -480,7 +477,10 @@ let unsubscribeLayoutChange: (() => void) | null = null
|
||||
|
||||
onMounted(() => {
|
||||
initSizeStyles()
|
||||
unsubscribeLayoutChange = layoutStore.onChange(handleLayoutChange)
|
||||
unsubscribeLayoutChange = layoutStore.onNodeChange(
|
||||
nodeData.id,
|
||||
handleLayoutChange
|
||||
)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -490,7 +490,6 @@ onUnmounted(() => {
|
||||
const baseResizeHandleClasses =
|
||||
'absolute h-5 w-5 opacity-0 pointer-events-auto focus-visible:outline focus-visible:outline-2 focus-visible:outline-white/40'
|
||||
|
||||
const MIN_NODE_WIDTH = 225
|
||||
const mutations = useLayoutMutations()
|
||||
|
||||
const { startResize } = useNodeResize((result, element) => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, nextTick, ref } from 'vue'
|
||||
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
@@ -11,6 +12,7 @@ import type { SlotLayout } from '@/renderer/core/layout/types'
|
||||
import { useNodeSlotRegistryStore } from '@/renderer/extensions/vueNodes/stores/nodeSlotRegistryStore'
|
||||
|
||||
import {
|
||||
syncNodeSlotLayoutsFromDOM,
|
||||
flushScheduledSlotLayoutSync,
|
||||
useSlotElementTracking
|
||||
} from './useSlotElementTracking'
|
||||
@@ -131,4 +133,43 @@ describe('useSlotElementTracking', () => {
|
||||
// Should remain pending — waiting for Vue components to mount
|
||||
expect(layoutStore.pendingSlotSync).toBe(true)
|
||||
})
|
||||
|
||||
it('skips slot layout writeback when measured slot geometry is unchanged', () => {
|
||||
const slotKey = getSlotKey(NODE_ID, SLOT_INDEX, true)
|
||||
const slotEl = document.createElement('div')
|
||||
slotEl.getBoundingClientRect = vi.fn(() => new DOMRect(100, 200, 16, 16))
|
||||
|
||||
const registryStore = useNodeSlotRegistryStore()
|
||||
const node = registryStore.ensureNode(NODE_ID)
|
||||
node.slots.set(slotKey, {
|
||||
el: slotEl,
|
||||
index: SLOT_INDEX,
|
||||
type: 'input',
|
||||
cachedOffset: { x: 108, y: 208 }
|
||||
})
|
||||
|
||||
const slotSize = LiteGraph.NODE_SLOT_HEIGHT
|
||||
const halfSlotSize = slotSize / 2
|
||||
const initialLayout: SlotLayout = {
|
||||
nodeId: NODE_ID,
|
||||
index: SLOT_INDEX,
|
||||
type: 'input',
|
||||
position: { x: 108, y: 208 },
|
||||
bounds: {
|
||||
x: 108 - halfSlotSize,
|
||||
y: 208 - halfSlotSize,
|
||||
width: slotSize,
|
||||
height: slotSize
|
||||
}
|
||||
}
|
||||
layoutStore.batchUpdateSlotLayouts([
|
||||
{ key: slotKey, layout: initialLayout }
|
||||
])
|
||||
|
||||
const batchUpdateSpy = vi.spyOn(layoutStore, 'batchUpdateSlotLayouts')
|
||||
|
||||
syncNodeSlotLayoutsFromDOM(NODE_ID)
|
||||
|
||||
expect(batchUpdateSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,6 +15,7 @@ import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { SlotLayout } from '@/renderer/core/layout/types'
|
||||
import {
|
||||
isBoundsEqual,
|
||||
isPointEqual,
|
||||
isSizeEqual
|
||||
} from '@/renderer/core/layout/utils/geometry'
|
||||
@@ -32,6 +33,30 @@ function scheduleSlotLayoutSync(nodeId: string) {
|
||||
raf.schedule()
|
||||
}
|
||||
|
||||
function createSlotLayout(options: {
|
||||
nodeId: string
|
||||
index: number
|
||||
type: 'input' | 'output'
|
||||
centerCanvas: { x: number; y: number }
|
||||
}): SlotLayout {
|
||||
const { nodeId, index, type, centerCanvas } = options
|
||||
const size = LiteGraph.NODE_SLOT_HEIGHT
|
||||
const half = size / 2
|
||||
|
||||
return {
|
||||
nodeId,
|
||||
index,
|
||||
type,
|
||||
position: { x: centerCanvas.x, y: centerCanvas.y },
|
||||
bounds: {
|
||||
x: centerCanvas.x - half,
|
||||
y: centerCanvas.y - half,
|
||||
width: size,
|
||||
height: size
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function flushScheduledSlotLayoutSync() {
|
||||
if (pendingNodes.size === 0) {
|
||||
// No pending nodes - check if we should wait for Vue components to mount
|
||||
@@ -71,15 +96,15 @@ export function syncNodeSlotLayoutsFromDOM(
|
||||
|
||||
const batch: Array<{ key: string; layout: SlotLayout }> = []
|
||||
|
||||
const positionConv = conv ?? useSharedCanvasPositionConversion()
|
||||
|
||||
for (const [slotKey, entry] of node.slots) {
|
||||
const rect = entry.el.getBoundingClientRect()
|
||||
const screenCenter: [number, number] = [
|
||||
rect.left + rect.width / 2,
|
||||
rect.top + rect.height / 2
|
||||
]
|
||||
const [x, y] = (
|
||||
conv ?? useSharedCanvasPositionConversion()
|
||||
).clientPosToCanvasPos(screenCenter)
|
||||
const [x, y] = positionConv.clientPosToCanvasPos(screenCenter)
|
||||
const centerCanvas = { x, y }
|
||||
|
||||
// Cache offset relative to node position for fast updates later
|
||||
@@ -88,23 +113,24 @@ export function syncNodeSlotLayoutsFromDOM(
|
||||
y: centerCanvas.y - nodeLayout.position.y
|
||||
}
|
||||
|
||||
// Persist layout in canvas coordinates
|
||||
const size = LiteGraph.NODE_SLOT_HEIGHT
|
||||
const half = size / 2
|
||||
const nextLayout = createSlotLayout({
|
||||
nodeId,
|
||||
index: entry.index,
|
||||
type: entry.type,
|
||||
centerCanvas
|
||||
})
|
||||
const existingSlotLayout = layoutStore.getSlotLayout(slotKey)
|
||||
if (
|
||||
existingSlotLayout &&
|
||||
isPointEqual(existingSlotLayout.position, nextLayout.position) &&
|
||||
isBoundsEqual(existingSlotLayout.bounds, nextLayout.bounds)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
batch.push({
|
||||
key: slotKey,
|
||||
layout: {
|
||||
nodeId,
|
||||
index: entry.index,
|
||||
type: entry.type,
|
||||
position: { x: centerCanvas.x, y: centerCanvas.y },
|
||||
bounds: {
|
||||
x: centerCanvas.x - half,
|
||||
y: centerCanvas.y - half,
|
||||
width: size,
|
||||
height: size
|
||||
}
|
||||
}
|
||||
layout: nextLayout
|
||||
})
|
||||
}
|
||||
if (batch.length) layoutStore.batchUpdateSlotLayouts(batch)
|
||||
@@ -130,22 +156,15 @@ function updateNodeSlotsFromCache(nodeId: string) {
|
||||
x: nodeLayout.position.x + entry.cachedOffset.x,
|
||||
y: nodeLayout.position.y + entry.cachedOffset.y
|
||||
}
|
||||
const size = LiteGraph.NODE_SLOT_HEIGHT
|
||||
const half = size / 2
|
||||
|
||||
batch.push({
|
||||
key: slotKey,
|
||||
layout: {
|
||||
layout: createSlotLayout({
|
||||
nodeId,
|
||||
index: entry.index,
|
||||
type: entry.type,
|
||||
position: { x: centerCanvas.x, y: centerCanvas.y },
|
||||
bounds: {
|
||||
x: centerCanvas.x - half,
|
||||
y: centerCanvas.y - half,
|
||||
width: size,
|
||||
height: size
|
||||
}
|
||||
}
|
||||
centerCanvas
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import type { NodeId, NodeLayout } from '@/renderer/core/layout/types'
|
||||
|
||||
type ResizeEntryLike = Pick<
|
||||
ResizeObserverEntry,
|
||||
| 'target'
|
||||
| 'borderBoxSize'
|
||||
| 'contentBoxSize'
|
||||
| 'devicePixelContentBoxSize'
|
||||
| 'contentRect'
|
||||
>
|
||||
|
||||
const resizeObserverState = vi.hoisted(() => {
|
||||
const state = {
|
||||
callback: null as ResizeObserverCallback | null,
|
||||
observe: vi.fn<(element: Element) => void>(),
|
||||
unobserve: vi.fn<(element: Element) => void>(),
|
||||
disconnect: vi.fn<() => void>()
|
||||
}
|
||||
|
||||
const MockResizeObserver: typeof ResizeObserver = class MockResizeObserver implements ResizeObserver {
|
||||
observe = state.observe
|
||||
unobserve = state.unobserve
|
||||
disconnect = state.disconnect
|
||||
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
state.callback = callback
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.ResizeObserver = MockResizeObserver
|
||||
|
||||
return state
|
||||
})
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
linearMode: false,
|
||||
nodeLayouts: new Map<NodeId, NodeLayout>(),
|
||||
batchUpdateNodeBounds: vi.fn(),
|
||||
setSource: vi.fn(),
|
||||
syncNodeSlotLayoutsFromDOM: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useDocumentVisibility: () => ref<'visible' | 'hidden'>('visible')
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
linearMode: testState.linearMode
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/element/useCanvasPositionConversion', () => ({
|
||||
useSharedCanvasPositionConversion: () => ({
|
||||
clientPosToCanvasPos: ([x, y]: [number, number]) => [x, y]
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
|
||||
layoutStore: {
|
||||
batchUpdateNodeBounds: testState.batchUpdateNodeBounds,
|
||||
setSource: testState.setSource,
|
||||
getNodeLayoutRef: (nodeId: NodeId): Ref<NodeLayout | null> =>
|
||||
ref<NodeLayout | null>(testState.nodeLayouts.get(nodeId) ?? null)
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./useSlotElementTracking', () => ({
|
||||
syncNodeSlotLayoutsFromDOM: testState.syncNodeSlotLayoutsFromDOM
|
||||
}))
|
||||
|
||||
import './useVueNodeResizeTracking'
|
||||
|
||||
function createResizeEntry(options?: {
|
||||
nodeId?: NodeId
|
||||
width?: number
|
||||
height?: number
|
||||
left?: number
|
||||
top?: number
|
||||
collapsed?: boolean
|
||||
}) {
|
||||
const {
|
||||
nodeId = 'test-node',
|
||||
width = 240,
|
||||
height = 180,
|
||||
left = 100,
|
||||
top = 200,
|
||||
collapsed = false
|
||||
} = options ?? {}
|
||||
|
||||
const element = document.createElement('div')
|
||||
element.dataset.nodeId = nodeId
|
||||
if (collapsed) {
|
||||
element.dataset.collapsed = ''
|
||||
}
|
||||
const rectSpy = vi.fn(() => new DOMRect(left, top, width, height))
|
||||
element.getBoundingClientRect = rectSpy
|
||||
const boxSizes = [{ inlineSize: width, blockSize: height }]
|
||||
|
||||
const entry = {
|
||||
target: element,
|
||||
borderBoxSize: boxSizes,
|
||||
contentBoxSize: boxSizes,
|
||||
devicePixelContentBoxSize: boxSizes,
|
||||
contentRect: new DOMRect(left, top, width, height)
|
||||
} satisfies ResizeEntryLike
|
||||
|
||||
return {
|
||||
entry,
|
||||
rectSpy
|
||||
}
|
||||
}
|
||||
|
||||
function createObserverMock(): ResizeObserver {
|
||||
return {
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
function seedNodeLayout(options: {
|
||||
nodeId: NodeId
|
||||
left: number
|
||||
top: number
|
||||
width: number
|
||||
height: number
|
||||
}) {
|
||||
const { nodeId, left, top, width, height } = options
|
||||
const titleHeight = LiteGraph.NODE_TITLE_HEIGHT
|
||||
const contentHeight = height - titleHeight
|
||||
|
||||
testState.nodeLayouts.set(nodeId, {
|
||||
id: nodeId,
|
||||
position: { x: left, y: top + titleHeight },
|
||||
size: { width, height: contentHeight },
|
||||
zIndex: 0,
|
||||
visible: true,
|
||||
bounds: {
|
||||
x: left,
|
||||
y: top + titleHeight,
|
||||
width,
|
||||
height: contentHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('useVueNodeResizeTracking', () => {
|
||||
beforeEach(() => {
|
||||
testState.linearMode = false
|
||||
testState.nodeLayouts.clear()
|
||||
testState.batchUpdateNodeBounds.mockReset()
|
||||
testState.setSource.mockReset()
|
||||
testState.syncNodeSlotLayoutsFromDOM.mockReset()
|
||||
resizeObserverState.observe.mockReset()
|
||||
resizeObserverState.unobserve.mockReset()
|
||||
resizeObserverState.disconnect.mockReset()
|
||||
})
|
||||
|
||||
it('skips repeated no-op resize entries after first measurement', () => {
|
||||
const nodeId = 'test-node'
|
||||
const width = 240
|
||||
const height = 180
|
||||
const left = 100
|
||||
const top = 200
|
||||
const { entry, rectSpy } = createResizeEntry({
|
||||
nodeId,
|
||||
width,
|
||||
height,
|
||||
left,
|
||||
top
|
||||
})
|
||||
|
||||
seedNodeLayout({ nodeId, left, top, width, height })
|
||||
|
||||
resizeObserverState.callback?.([entry], createObserverMock())
|
||||
|
||||
expect(rectSpy).toHaveBeenCalledTimes(1)
|
||||
expect(testState.setSource).not.toHaveBeenCalled()
|
||||
expect(testState.batchUpdateNodeBounds).not.toHaveBeenCalled()
|
||||
expect(testState.syncNodeSlotLayoutsFromDOM).not.toHaveBeenCalled()
|
||||
|
||||
testState.setSource.mockReset()
|
||||
testState.batchUpdateNodeBounds.mockReset()
|
||||
testState.syncNodeSlotLayoutsFromDOM.mockReset()
|
||||
|
||||
resizeObserverState.callback?.([entry], createObserverMock())
|
||||
|
||||
expect(rectSpy).toHaveBeenCalledTimes(1)
|
||||
expect(testState.setSource).not.toHaveBeenCalled()
|
||||
expect(testState.batchUpdateNodeBounds).not.toHaveBeenCalled()
|
||||
expect(testState.syncNodeSlotLayoutsFromDOM).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('updates bounds on first observation when size matches but position differs', () => {
|
||||
const nodeId = 'test-node'
|
||||
const width = 240
|
||||
const height = 180
|
||||
const { entry, rectSpy } = createResizeEntry({
|
||||
nodeId,
|
||||
width,
|
||||
height,
|
||||
left: 100,
|
||||
top: 200
|
||||
})
|
||||
const titleHeight = LiteGraph.NODE_TITLE_HEIGHT
|
||||
|
||||
seedNodeLayout({
|
||||
nodeId,
|
||||
left: 90,
|
||||
top: 190,
|
||||
width,
|
||||
height
|
||||
})
|
||||
|
||||
resizeObserverState.callback?.([entry], createObserverMock())
|
||||
|
||||
expect(rectSpy).toHaveBeenCalledTimes(1)
|
||||
expect(testState.setSource).toHaveBeenCalledWith(LayoutSource.DOM)
|
||||
expect(testState.batchUpdateNodeBounds).toHaveBeenCalledWith([
|
||||
{
|
||||
nodeId,
|
||||
bounds: {
|
||||
x: 100,
|
||||
y: 200 + titleHeight,
|
||||
width,
|
||||
height
|
||||
}
|
||||
}
|
||||
])
|
||||
expect(testState.syncNodeSlotLayoutsFromDOM).toHaveBeenCalledWith(nodeId)
|
||||
})
|
||||
|
||||
it('updates node bounds + slot layouts when size changes', () => {
|
||||
const nodeId = 'test-node'
|
||||
const { entry } = createResizeEntry({
|
||||
nodeId,
|
||||
width: 240,
|
||||
height: 180,
|
||||
left: 100,
|
||||
top: 200
|
||||
})
|
||||
const titleHeight = LiteGraph.NODE_TITLE_HEIGHT
|
||||
|
||||
seedNodeLayout({
|
||||
nodeId,
|
||||
left: 100,
|
||||
top: 200,
|
||||
width: 220,
|
||||
height: 140
|
||||
})
|
||||
|
||||
resizeObserverState.callback?.([entry], createObserverMock())
|
||||
|
||||
expect(testState.setSource).toHaveBeenCalledWith(LayoutSource.DOM)
|
||||
expect(testState.batchUpdateNodeBounds).toHaveBeenCalledWith([
|
||||
{
|
||||
nodeId,
|
||||
bounds: {
|
||||
x: 100,
|
||||
y: 200 + titleHeight,
|
||||
width: 240,
|
||||
height: 180
|
||||
}
|
||||
}
|
||||
])
|
||||
expect(testState.syncNodeSlotLayoutsFromDOM).toHaveBeenCalledWith(nodeId)
|
||||
})
|
||||
|
||||
it('resyncs slot anchors for collapsed nodes without writing bounds', () => {
|
||||
const nodeId = 'test-node'
|
||||
const { entry, rectSpy } = createResizeEntry({
|
||||
nodeId,
|
||||
collapsed: true
|
||||
})
|
||||
|
||||
resizeObserverState.callback?.([entry], createObserverMock())
|
||||
|
||||
expect(rectSpy).not.toHaveBeenCalled()
|
||||
expect(testState.setSource).not.toHaveBeenCalled()
|
||||
expect(testState.batchUpdateNodeBounds).not.toHaveBeenCalled()
|
||||
expect(testState.syncNodeSlotLayoutsFromDOM).toHaveBeenCalledWith(nodeId)
|
||||
})
|
||||
})
|
||||
@@ -19,6 +19,11 @@ import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import type { Bounds, NodeId } from '@/renderer/core/layout/types'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import {
|
||||
isBoundsEqual,
|
||||
isSizeEqual
|
||||
} from '@/renderer/core/layout/utils/geometry'
|
||||
import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
|
||||
|
||||
import { syncNodeSlotLayoutsFromDOM } from './useSlotElementTracking'
|
||||
|
||||
@@ -32,6 +37,11 @@ interface ElementBoundsUpdate {
|
||||
bounds: Bounds
|
||||
}
|
||||
|
||||
interface CachedNodeMeasurement {
|
||||
nodeId: NodeId
|
||||
bounds: Bounds
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for different types of tracked elements
|
||||
*/
|
||||
@@ -63,14 +73,22 @@ const trackingConfigs: Map<string, ElementTrackingConfig> = new Map([
|
||||
|
||||
// Elements whose ResizeObserver fired while the tab was hidden
|
||||
const deferredElements = new Set<HTMLElement>()
|
||||
const elementsNeedingFreshMeasurement = new WeakSet<HTMLElement>()
|
||||
const cachedNodeMeasurements = new WeakMap<HTMLElement, CachedNodeMeasurement>()
|
||||
const visibility = useDocumentVisibility()
|
||||
|
||||
function markElementForFreshMeasurement(element: HTMLElement) {
|
||||
elementsNeedingFreshMeasurement.add(element)
|
||||
cachedNodeMeasurements.delete(element)
|
||||
}
|
||||
|
||||
watch(visibility, (state) => {
|
||||
if (state !== 'visible' || deferredElements.size === 0) return
|
||||
|
||||
// Re-observe deferred elements to trigger fresh measurements
|
||||
for (const element of deferredElements) {
|
||||
if (element.isConnected) {
|
||||
markElementForFreshMeasurement(element)
|
||||
resizeObserver.observe(element)
|
||||
}
|
||||
}
|
||||
@@ -86,6 +104,7 @@ const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.target instanceof HTMLElement) {
|
||||
deferredElements.add(entry.target)
|
||||
markElementForFreshMeasurement(entry.target)
|
||||
resizeObserver.unobserve(entry.target)
|
||||
}
|
||||
}
|
||||
@@ -97,7 +116,7 @@ const resizeObserver = new ResizeObserver((entries) => {
|
||||
// Group updates by type, then flush via each config's handler
|
||||
const updatesByType = new Map<string, ElementBoundsUpdate[]>()
|
||||
// Track nodes whose slots should be resynced after node size changes
|
||||
const nodesNeedingSlotResync = new Set<string>()
|
||||
const nodesNeedingSlotResync = new Set<NodeId>()
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!(entry.target instanceof HTMLElement)) continue
|
||||
@@ -117,6 +136,17 @@ const resizeObserver = new ResizeObserver((entries) => {
|
||||
}
|
||||
|
||||
if (!elementType || !elementId) continue
|
||||
const nodeId: NodeId | undefined =
|
||||
elementType === 'node' ? elementId : undefined
|
||||
|
||||
// Skip collapsed nodes — their DOM height is just the header, and writing
|
||||
// that back to the layout store would overwrite the stored expanded size.
|
||||
if (elementType === 'node' && element.dataset.collapsed != null) {
|
||||
if (nodeId) {
|
||||
nodesNeedingSlotResync.add(nodeId)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Use borderBoxSize when available; fall back to contentRect for older engines/tests
|
||||
// Border box is the border included FULL wxh DOM value.
|
||||
@@ -126,8 +156,35 @@ const resizeObserver = new ResizeObserver((entries) => {
|
||||
inlineSize: entry.contentRect.width,
|
||||
blockSize: entry.contentRect.height
|
||||
}
|
||||
const width = borderBox.inlineSize
|
||||
const height = borderBox.blockSize
|
||||
const width = Math.max(0, borderBox.inlineSize)
|
||||
const height = Math.max(0, borderBox.blockSize)
|
||||
const nodeLayout = nodeId
|
||||
? layoutStore.getNodeLayoutRef(nodeId).value
|
||||
: null
|
||||
const normalizedHeight = removeNodeTitleHeight(height)
|
||||
const previousMeasurement = cachedNodeMeasurements.get(element)
|
||||
const hasFreshMeasurementPending =
|
||||
elementsNeedingFreshMeasurement.has(element)
|
||||
const hasMatchingCachedNodeMeasurement =
|
||||
previousMeasurement != null &&
|
||||
previousMeasurement.nodeId === nodeId &&
|
||||
nodeLayout != null &&
|
||||
isBoundsEqual(previousMeasurement.bounds, nodeLayout.bounds)
|
||||
|
||||
// ResizeObserver emits entries where nothing changed (e.g. initial observe).
|
||||
// Skip expensive DOM reads when this exact element/node already measured at
|
||||
// the same normalized bounds and size.
|
||||
if (
|
||||
nodeLayout &&
|
||||
!hasFreshMeasurementPending &&
|
||||
isSizeEqual(nodeLayout.size, {
|
||||
width,
|
||||
height: normalizedHeight
|
||||
}) &&
|
||||
hasMatchingCachedNodeMeasurement
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Screen-space rect
|
||||
const rect = element.getBoundingClientRect()
|
||||
@@ -136,8 +193,24 @@ const resizeObserver = new ResizeObserver((entries) => {
|
||||
const bounds: Bounds = {
|
||||
x: topLeftCanvas.x,
|
||||
y: topLeftCanvas.y + LiteGraph.NODE_TITLE_HEIGHT,
|
||||
width: Math.max(0, width),
|
||||
height: Math.max(0, height)
|
||||
width,
|
||||
height
|
||||
}
|
||||
const normalizedBounds: Bounds = {
|
||||
...bounds,
|
||||
height: normalizedHeight
|
||||
}
|
||||
|
||||
elementsNeedingFreshMeasurement.delete(element)
|
||||
if (nodeId) {
|
||||
cachedNodeMeasurements.set(element, {
|
||||
nodeId,
|
||||
bounds: normalizedBounds
|
||||
})
|
||||
}
|
||||
|
||||
if (nodeLayout && isBoundsEqual(nodeLayout.bounds, normalizedBounds)) {
|
||||
continue
|
||||
}
|
||||
|
||||
let updates = updatesByType.get(elementType)
|
||||
@@ -148,17 +221,21 @@ const resizeObserver = new ResizeObserver((entries) => {
|
||||
updates.push({ id: elementId, bounds })
|
||||
|
||||
// If this entry is a node, mark it for slot layout resync
|
||||
if (elementType === 'node' && elementId) {
|
||||
nodesNeedingSlotResync.add(elementId)
|
||||
if (nodeId) {
|
||||
nodesNeedingSlotResync.add(nodeId)
|
||||
}
|
||||
}
|
||||
|
||||
layoutStore.setSource(LayoutSource.DOM)
|
||||
if (updatesByType.size === 0 && nodesNeedingSlotResync.size === 0) return
|
||||
|
||||
// Flush per-type
|
||||
for (const [type, updates] of updatesByType) {
|
||||
const config = trackingConfigs.get(type)
|
||||
if (config && updates.length) config.updateHandler(updates)
|
||||
if (updatesByType.size > 0) {
|
||||
layoutStore.setSource(LayoutSource.DOM)
|
||||
|
||||
// Flush per-type
|
||||
for (const [type, updates] of updatesByType) {
|
||||
const config = trackingConfigs.get(type)
|
||||
if (config && updates.length) config.updateHandler(updates)
|
||||
}
|
||||
}
|
||||
|
||||
// After node bounds are updated, refresh slot cached offsets and layouts
|
||||
@@ -204,6 +281,7 @@ export function useVueElementTracking(
|
||||
|
||||
// Set the data attribute expected by the RO pipeline for this type
|
||||
element.dataset[config.dataAttribute] = appIdentifier
|
||||
markElementForFreshMeasurement(element)
|
||||
resizeObserver.observe(element)
|
||||
})
|
||||
|
||||
@@ -216,6 +294,8 @@ export function useVueElementTracking(
|
||||
|
||||
// Remove the data attribute and observer
|
||||
delete element.dataset[config.dataAttribute]
|
||||
cachedNodeMeasurements.delete(element)
|
||||
elementsNeedingFreshMeasurement.delete(element)
|
||||
resizeObserver.unobserve(element)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { MockInstance } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { CompassCorners } from '@/lib/litegraph/src/interfaces'
|
||||
import { MIN_NODE_WIDTH } from '@/renderer/core/layout/transform/graphRenderTransform'
|
||||
|
||||
import type { ResizeCallbackPayload } from './useNodeResize'
|
||||
|
||||
@@ -66,7 +67,7 @@ function createMockNodeElement(
|
||||
): HTMLElement {
|
||||
const element = document.createElement('div')
|
||||
element.setAttribute('data-node-id', 'test-node')
|
||||
element.style.setProperty('min-width', '225px')
|
||||
element.style.setProperty('min-width', `${MIN_NODE_WIDTH}px`)
|
||||
element.getBoundingClientRect = () => {
|
||||
// When --node-height is '0px', return the content-driven minimum height
|
||||
const nodeHeight = element.style.getPropertyValue('--node-height')
|
||||
@@ -194,7 +195,7 @@ describe('useNodeResize', () => {
|
||||
simulateMove(-200, 0)
|
||||
|
||||
const payload: ResizeCallbackPayload = callback.mock.calls[0][0]
|
||||
expect(payload.size.width).toBe(225)
|
||||
expect(payload.size.width).toBe(MIN_NODE_WIDTH)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -235,8 +236,9 @@ describe('useNodeResize', () => {
|
||||
simulateMove(200, 0)
|
||||
|
||||
const payload: ResizeCallbackPayload = callback.mock.calls[0][0]
|
||||
expect(payload.size.width).toBe(225)
|
||||
expect(payload.position!.x).toBe(175)
|
||||
expect(payload.size.width).toBe(MIN_NODE_WIDTH)
|
||||
// x = startX + startWidth - minWidth = 100 + 300 - MIN_NODE_WIDTH
|
||||
expect(payload.position!.x).toBe(100 + 300 - MIN_NODE_WIDTH)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -255,8 +257,9 @@ describe('useNodeResize', () => {
|
||||
simulateMove(200, 0)
|
||||
|
||||
const payload: ResizeCallbackPayload = callback.mock.calls[0][0]
|
||||
expect(payload.size.width).toBe(225)
|
||||
expect(payload.position!.x).toBe(175)
|
||||
expect(payload.size.width).toBe(MIN_NODE_WIDTH)
|
||||
// x = startX + startWidth - minWidth = 100 + 300 - MIN_NODE_WIDTH
|
||||
expect(payload.position!.x).toBe(100 + 300 - MIN_NODE_WIDTH)
|
||||
})
|
||||
|
||||
it('clamps height to content minimum and fixes bottom edge', () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ref } from 'vue'
|
||||
import type { CompassCorners } from '@/lib/litegraph/src/interfaces'
|
||||
import type { Point, Size } from '@/renderer/core/layout/types'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { MIN_NODE_WIDTH } from '@/renderer/core/layout/transform/graphRenderTransform'
|
||||
import { useNodeSnap } from '@/renderer/extensions/vueNodes/composables/useNodeSnap'
|
||||
import { useShiftKeySync } from '@/renderer/extensions/vueNodes/composables/useShiftKeySync'
|
||||
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
|
||||
@@ -156,7 +157,7 @@ export function useNodeResize(
|
||||
// Enforce minimum size with position compensation (matching litegraph)
|
||||
const minWidth =
|
||||
parseFloat(nodeElement.style.getPropertyValue('min-width') || '0') ||
|
||||
225
|
||||
MIN_NODE_WIDTH
|
||||
if (newWidth < minWidth) {
|
||||
if (activeCorner.includes('W')) {
|
||||
newX =
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraph, LGraphExtra } from '@/lib/litegraph/src/LGraph'
|
||||
import type { Point, Rect } from '@/lib/litegraph/src/interfaces'
|
||||
import { RENDER_SCALE_FACTOR } from '@/renderer/core/layout/transform/graphRenderTransform'
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { canvas: undefined }
|
||||
}))
|
||||
|
||||
import { ensureCorrectLayoutScale } from './ensureCorrectLayoutScale'
|
||||
|
||||
function createNode(id: string, x: number, y: number, w: number, h: number) {
|
||||
return {
|
||||
id,
|
||||
pos: [x, y] as Point,
|
||||
size: [w, h] as Point,
|
||||
get width() {
|
||||
return this.size[0]
|
||||
},
|
||||
set width(v: number) {
|
||||
this.size[0] = v
|
||||
},
|
||||
get boundingRect(): Rect {
|
||||
return [this.pos[0], this.pos[1], this.size[0], this.size[1]]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type MockNode = ReturnType<typeof createNode>
|
||||
|
||||
function createMockGraph(
|
||||
nodes: MockNode[],
|
||||
extra: LGraphExtra = {}
|
||||
): Partial<LGraph> {
|
||||
const graph: Partial<LGraph> = {
|
||||
id: crypto.randomUUID(),
|
||||
nodes: nodes as unknown as LGraph['nodes'],
|
||||
groups: [],
|
||||
reroutes: new Map() as LGraph['reroutes'],
|
||||
extra
|
||||
}
|
||||
Object.defineProperty(graph, 'rootGraph', { get: () => graph })
|
||||
return graph
|
||||
}
|
||||
|
||||
function twoNodeLayout(): MockNode[] {
|
||||
return [
|
||||
createNode('1', 100, 100, 120, 80),
|
||||
createNode('2', 320, 140, 100, 80)
|
||||
]
|
||||
}
|
||||
|
||||
function distanceBetweenNodes(nodes: MockNode[]): number {
|
||||
const [a, b] = nodes
|
||||
return Math.hypot(b.pos[0] - a.pos[0], b.pos[1] - a.pos[1])
|
||||
}
|
||||
|
||||
function snapshotGeometry(nodes: MockNode[]) {
|
||||
return nodes.map((n) => ({
|
||||
pos: [...n.pos] as [number, number],
|
||||
size: [...n.size] as [number, number]
|
||||
}))
|
||||
}
|
||||
|
||||
describe('ensureCorrectLayoutScale (legacy normalizer)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('normalizes legacy Vue-scaled graph once', () => {
|
||||
const nodes = twoNodeLayout()
|
||||
const graph = createMockGraph(nodes, {
|
||||
workflowRendererVersion: 'Vue'
|
||||
})
|
||||
|
||||
const beforeDistance = distanceBetweenNodes(nodes)
|
||||
const result = ensureCorrectLayoutScale(undefined, graph as LGraph)
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(graph.extra?.workflowRendererVersion).toBe('Vue-corrected')
|
||||
|
||||
// Distance should shrink by 1/RENDER_SCALE_FACTOR
|
||||
const afterDistance = distanceBetweenNodes(nodes)
|
||||
expect(afterDistance / beforeDistance).toBeCloseTo(
|
||||
1 / RENDER_SCALE_FACTOR,
|
||||
5
|
||||
)
|
||||
})
|
||||
|
||||
it('is idempotent — second call is a no-op', () => {
|
||||
const nodes = twoNodeLayout()
|
||||
const graph = createMockGraph(nodes, {
|
||||
workflowRendererVersion: 'Vue'
|
||||
})
|
||||
|
||||
ensureCorrectLayoutScale(undefined, graph as LGraph)
|
||||
const afterFirst = snapshotGeometry(nodes)
|
||||
|
||||
const result = ensureCorrectLayoutScale(undefined, graph as LGraph)
|
||||
expect(result).toBe(false)
|
||||
|
||||
const afterSecond = snapshotGeometry(nodes)
|
||||
expect(afterSecond).toEqual(afterFirst)
|
||||
})
|
||||
|
||||
it('does not re-normalize when graph is already marked Vue-corrected even with Vue override', () => {
|
||||
const nodes = twoNodeLayout()
|
||||
const graph = createMockGraph(nodes, {
|
||||
workflowRendererVersion: 'Vue-corrected'
|
||||
})
|
||||
|
||||
const before = snapshotGeometry(nodes)
|
||||
const result = ensureCorrectLayoutScale('Vue', graph as LGraph)
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(snapshotGeometry(nodes)).toEqual(before)
|
||||
})
|
||||
|
||||
it('uses renderer override when workflow metadata is missing', () => {
|
||||
const nodes = twoNodeLayout()
|
||||
const graph = createMockGraph(nodes)
|
||||
|
||||
const beforeDistance = distanceBetweenNodes(nodes)
|
||||
const result = ensureCorrectLayoutScale('Vue', graph as LGraph)
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(graph.extra?.workflowRendererVersion).toBe('Vue-corrected')
|
||||
const afterDistance = distanceBetweenNodes(nodes)
|
||||
expect(afterDistance / beforeDistance).toBeCloseTo(
|
||||
1 / RENDER_SCALE_FACTOR,
|
||||
5
|
||||
)
|
||||
})
|
||||
|
||||
it('is a no-op for already-corrected graphs', () => {
|
||||
const nodes = twoNodeLayout()
|
||||
const graph = createMockGraph(nodes, {
|
||||
workflowRendererVersion: 'Vue-corrected'
|
||||
})
|
||||
|
||||
const before = snapshotGeometry(nodes)
|
||||
const result = ensureCorrectLayoutScale(undefined, graph as LGraph)
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(snapshotGeometry(nodes)).toEqual(before)
|
||||
})
|
||||
|
||||
it('is a no-op for LG metadata', () => {
|
||||
const nodes = twoNodeLayout()
|
||||
const graph = createMockGraph(nodes, {
|
||||
workflowRendererVersion: 'LG'
|
||||
})
|
||||
|
||||
const before = snapshotGeometry(nodes)
|
||||
const result = ensureCorrectLayoutScale(undefined, graph as LGraph)
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(snapshotGeometry(nodes)).toEqual(before)
|
||||
})
|
||||
|
||||
it('is a no-op for missing metadata', () => {
|
||||
const nodes = twoNodeLayout()
|
||||
const graph = createMockGraph(nodes)
|
||||
|
||||
const before = snapshotGeometry(nodes)
|
||||
const result = ensureCorrectLayoutScale(undefined, graph as LGraph)
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(snapshotGeometry(nodes)).toEqual(before)
|
||||
})
|
||||
|
||||
it('skips null subgraph IO nodes', () => {
|
||||
const nodes = twoNodeLayout()
|
||||
const graph = createMockGraph(nodes, {
|
||||
workflowRendererVersion: 'Vue'
|
||||
}) as LGraph & {
|
||||
inputNode: null
|
||||
outputNode: null
|
||||
}
|
||||
|
||||
graph.inputNode = null
|
||||
graph.outputNode = null
|
||||
|
||||
expect(() => ensureCorrectLayoutScale(undefined, graph)).not.toThrow()
|
||||
expect(graph.extra?.workflowRendererVersion).toBe('Vue-corrected')
|
||||
})
|
||||
|
||||
it('normalizes reroutes', () => {
|
||||
const nodes = twoNodeLayout()
|
||||
const graph = createMockGraph(nodes, {
|
||||
workflowRendererVersion: 'Vue'
|
||||
})
|
||||
|
||||
const reroute = { id: 1, pos: [200, 200] as Point, linkIds: new Set([1]) }
|
||||
;(graph.reroutes as Map<number, typeof reroute>).set(1, reroute)
|
||||
|
||||
ensureCorrectLayoutScale(undefined, graph as LGraph)
|
||||
|
||||
// createBounds adds 10px padding, so anchor is (90, 90)
|
||||
// Reroute at (200, 200). Relative: (110, 110). Downscaled: 110/1.2 ≈ 91.67
|
||||
// Final: 90 + 91.67 ≈ 181.67
|
||||
const anchor = 90 // min node pos (100) - createBounds padding (10)
|
||||
const relative = 200 - anchor
|
||||
expect(reroute.pos[0]).toBeCloseTo(
|
||||
anchor + relative / RENDER_SCALE_FACTOR,
|
||||
5
|
||||
)
|
||||
expect(reroute.pos[1]).toBeCloseTo(
|
||||
anchor + relative / RENDER_SCALE_FACTOR,
|
||||
5
|
||||
)
|
||||
})
|
||||
|
||||
it('normalizes groups', () => {
|
||||
const nodes = twoNodeLayout()
|
||||
const graph = createMockGraph(nodes, {
|
||||
workflowRendererVersion: 'Vue'
|
||||
})
|
||||
|
||||
const group = {
|
||||
pos: [150, 150] as Point,
|
||||
size: [300, 200] as Point
|
||||
}
|
||||
;(graph.groups as (typeof group)[]).push(group)
|
||||
|
||||
ensureCorrectLayoutScale(undefined, graph as LGraph)
|
||||
|
||||
expect(group.size[0]).toBeCloseTo(300 / RENDER_SCALE_FACTOR, 5)
|
||||
expect(group.size[1]).toBeCloseTo(200 / RENDER_SCALE_FACTOR, 5)
|
||||
})
|
||||
|
||||
it('updates group geometry in place without replacing arrays', () => {
|
||||
const nodes = twoNodeLayout()
|
||||
const graph = createMockGraph(nodes, {
|
||||
workflowRendererVersion: 'Vue'
|
||||
})
|
||||
|
||||
const groupPos = [150, 150] as Point
|
||||
const groupSize = [300, 200] as Point
|
||||
const group = {
|
||||
pos: groupPos,
|
||||
size: groupSize
|
||||
}
|
||||
;(graph.groups as (typeof group)[]).push(group)
|
||||
|
||||
ensureCorrectLayoutScale(undefined, graph as LGraph)
|
||||
|
||||
expect(group.pos).toBe(groupPos)
|
||||
expect(group.size).toBe(groupSize)
|
||||
})
|
||||
|
||||
it('repeated normalization does not compound spacing', () => {
|
||||
const distances: number[] = []
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const nodes = twoNodeLayout()
|
||||
const graph = createMockGraph(nodes, {
|
||||
workflowRendererVersion: 'Vue'
|
||||
})
|
||||
|
||||
ensureCorrectLayoutScale(undefined, graph as LGraph)
|
||||
distances.push(distanceBetweenNodes(nodes))
|
||||
}
|
||||
|
||||
// All runs should produce the same distance
|
||||
for (let i = 1; i < distances.length; i++) {
|
||||
expect(distances[i] / distances[0]).toBeCloseTo(1, 5)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,165 +1,106 @@
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
import type { LGraph, RendererType } from '@/lib/litegraph/src/LGraph'
|
||||
import { createBounds } from '@/lib/litegraph/src/measure'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import type { NodeBoundsUpdate } from '@/renderer/core/layout/types'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import type { Point as LGPoint } from '@/lib/litegraph/src/interfaces'
|
||||
import type { Point } from '@/renderer/core/layout/types'
|
||||
import {
|
||||
RENDER_SCALE_FACTOR,
|
||||
getGraphRenderAnchor,
|
||||
unprojectBounds,
|
||||
unprojectPoint
|
||||
} from '@/renderer/core/layout/transform/graphRenderTransform'
|
||||
import type { SubgraphInputNode } from '@/lib/litegraph/src/subgraph/SubgraphInputNode'
|
||||
import type { SubgraphOutputNode } from '@/lib/litegraph/src/subgraph/SubgraphOutputNode'
|
||||
|
||||
const SCALE_FACTOR = 1.2
|
||||
interface Positioned {
|
||||
pos: LGPoint
|
||||
size: LGPoint
|
||||
}
|
||||
|
||||
export function ensureCorrectLayoutScale(
|
||||
renderer: RendererType = 'LG',
|
||||
targetGraph?: LGraph
|
||||
) {
|
||||
const autoScaleLayoutSetting = useSettingStore().get(
|
||||
'Comfy.VueNodes.AutoScaleLayout'
|
||||
function unprojectPosSize(item: Positioned, anchor: Point) {
|
||||
const c = unprojectBounds(
|
||||
{
|
||||
x: item.pos[0],
|
||||
y: item.pos[1],
|
||||
width: item.size[0],
|
||||
height: item.size[1]
|
||||
},
|
||||
anchor,
|
||||
RENDER_SCALE_FACTOR
|
||||
)
|
||||
item.pos[0] = c.x
|
||||
item.pos[1] = c.y
|
||||
item.size[0] = c.width
|
||||
item.size[1] = c.height
|
||||
}
|
||||
|
||||
if (!autoScaleLayoutSetting) return
|
||||
/**
|
||||
* One-time legacy normalizer for workflows saved with Vue-scaled coordinates.
|
||||
*
|
||||
* Detects workflows saved in the old Vue coordinate space (where positions
|
||||
* were mutated by 1.2x at runtime) and normalizes them back to canonical
|
||||
* LiteGraph coordinates. Runs once per graph, then marks it as normalized.
|
||||
*
|
||||
* After normalization, rendering applies the 1.2x scale visually via CSS
|
||||
* transforms rather than mutating persisted geometry.
|
||||
*
|
||||
* @param rendererVersion - Override for the renderer version check. When
|
||||
* graph metadata is missing, this value is used as a fallback.
|
||||
* @param targetGraph - The graph to normalize.
|
||||
*/
|
||||
export function ensureCorrectLayoutScale(
|
||||
rendererVersion: RendererType | undefined,
|
||||
graph: LGraph
|
||||
): boolean {
|
||||
if (!graph.nodes) return false
|
||||
|
||||
const canvas = comfyApp.canvas
|
||||
const graph = targetGraph ?? canvas?.graph
|
||||
const currentRenderer = graph.extra?.workflowRendererVersion
|
||||
if (currentRenderer === 'Vue-corrected') return false
|
||||
|
||||
if (!graph?.nodes) return
|
||||
const renderer = currentRenderer ?? rendererVersion
|
||||
if (renderer !== 'Vue') return false
|
||||
|
||||
const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||
const anchor = getGraphRenderAnchor(graph)
|
||||
|
||||
const needsUpscale = renderer === 'LG' && shouldRenderVueNodes.value
|
||||
const needsDownscale = renderer === 'Vue' && !shouldRenderVueNodes.value
|
||||
|
||||
if (!needsUpscale && !needsDownscale) {
|
||||
// Don't scale, but ensure workflowRendererVersion is set for future checks
|
||||
graph.extra.workflowRendererVersion ??= renderer
|
||||
return
|
||||
}
|
||||
|
||||
const lgBounds = createBounds(graph.nodes)
|
||||
|
||||
if (!lgBounds) return
|
||||
|
||||
const [originX, originY] = lgBounds
|
||||
|
||||
const lgNodesById = new Map(graph.nodes.map((node) => [node.id, node]))
|
||||
|
||||
const yjsMoveNodeUpdates: NodeBoundsUpdate[] = []
|
||||
|
||||
const scaleFactor = needsUpscale ? SCALE_FACTOR : 1 / SCALE_FACTOR
|
||||
|
||||
const onActiveGraph = !targetGraph || targetGraph === canvas?.graph
|
||||
|
||||
//TODO: once we remove the need for LiteGraph.NODE_TITLE_HEIGHT in vue nodes we nned to remove everything here.
|
||||
for (const node of graph.nodes) {
|
||||
const lgNode = lgNodesById.get(node.id)
|
||||
if (!lgNode) continue
|
||||
|
||||
const [oldX, oldY] = lgNode.pos
|
||||
|
||||
const relativeX = oldX - originX
|
||||
const relativeY = oldY - originY
|
||||
|
||||
const scaledX = originX + relativeX * scaleFactor
|
||||
const scaledY = originY + relativeY * scaleFactor
|
||||
|
||||
const scaledWidth = lgNode.width * scaleFactor
|
||||
|
||||
const scaledHeight = lgNode.size[1] * scaleFactor
|
||||
|
||||
// Directly update LiteGraph node to ensure immediate consistency
|
||||
// Dont need to reference vue directly because the pos and dims are already in yjs
|
||||
lgNode.pos[0] = scaledX
|
||||
lgNode.pos[1] = scaledY
|
||||
lgNode.size[0] = scaledWidth
|
||||
lgNode.size[1] = scaledHeight
|
||||
|
||||
// Track updates for layout store (only if this is the active graph)
|
||||
if (onActiveGraph) {
|
||||
yjsMoveNodeUpdates.push({
|
||||
nodeId: String(lgNode.id),
|
||||
bounds: {
|
||||
x: scaledX,
|
||||
y: scaledY,
|
||||
width: scaledWidth,
|
||||
height: scaledHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (onActiveGraph && yjsMoveNodeUpdates.length > 0) {
|
||||
layoutStore.setSource(LayoutSource.Canvas)
|
||||
layoutStore.batchUpdateNodeBounds(yjsMoveNodeUpdates)
|
||||
const c = unprojectBounds(
|
||||
{
|
||||
x: node.pos[0],
|
||||
y: node.pos[1],
|
||||
width: node.size[0],
|
||||
height: node.size[1]
|
||||
},
|
||||
anchor,
|
||||
RENDER_SCALE_FACTOR
|
||||
)
|
||||
node.pos[0] = c.x
|
||||
node.pos[1] = c.y
|
||||
node.size[0] = c.width
|
||||
node.size[1] = c.height
|
||||
}
|
||||
|
||||
for (const reroute of graph.reroutes.values()) {
|
||||
const [oldX, oldY] = reroute.pos
|
||||
const p = unprojectPoint(
|
||||
{ x: reroute.pos[0], y: reroute.pos[1] },
|
||||
anchor,
|
||||
RENDER_SCALE_FACTOR
|
||||
)
|
||||
reroute.pos = [p.x, p.y]
|
||||
}
|
||||
|
||||
const relativeX = oldX - originX
|
||||
const relativeY = oldY - originY
|
||||
|
||||
const scaledX = originX + relativeX * scaleFactor
|
||||
const scaledY = originY + relativeY * scaleFactor
|
||||
|
||||
reroute.pos = [scaledX, scaledY]
|
||||
|
||||
if (onActiveGraph && shouldRenderVueNodes.value) {
|
||||
const layoutMutations = useLayoutMutations()
|
||||
layoutMutations.moveReroute(
|
||||
reroute.id,
|
||||
{ x: scaledX, y: scaledY },
|
||||
{ x: oldX, y: oldY }
|
||||
)
|
||||
}
|
||||
for (const group of graph.groups) {
|
||||
unprojectPosSize(group, anchor)
|
||||
}
|
||||
|
||||
if ('inputNode' in graph && 'outputNode' in graph) {
|
||||
const ioNodes = [
|
||||
for (const ioNode of [
|
||||
graph.inputNode as SubgraphInputNode,
|
||||
graph.outputNode as SubgraphOutputNode
|
||||
]
|
||||
for (const ioNode of ioNodes) {
|
||||
const [oldX, oldY] = ioNode.pos
|
||||
const [oldWidth, oldHeight] = ioNode.size
|
||||
|
||||
const relativeX = oldX - originX
|
||||
const relativeY = oldY - originY
|
||||
|
||||
const scaledX = originX + relativeX * scaleFactor
|
||||
const scaledY = originY + relativeY * scaleFactor
|
||||
|
||||
const scaledWidth = oldWidth * scaleFactor
|
||||
const scaledHeight = oldHeight * scaleFactor
|
||||
|
||||
ioNode.pos = [scaledX, scaledY]
|
||||
ioNode.size = [scaledWidth, scaledHeight]
|
||||
]) {
|
||||
if (ioNode) {
|
||||
unprojectPosSize(ioNode, anchor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
graph.groups.forEach((group) => {
|
||||
const [oldX, oldY] = group.pos
|
||||
const [oldWidth, oldHeight] = group.size
|
||||
|
||||
const relativeX = oldX - originX
|
||||
const relativeY = oldY - originY
|
||||
|
||||
const scaledX = originX + relativeX * scaleFactor
|
||||
const scaledY = originY + relativeY * scaleFactor
|
||||
|
||||
const scaledWidth = oldWidth * scaleFactor
|
||||
const scaledHeight = oldHeight * scaleFactor
|
||||
|
||||
group.pos = [scaledX, scaledY]
|
||||
group.size = [scaledWidth, scaledHeight]
|
||||
})
|
||||
|
||||
if (onActiveGraph && canvas) {
|
||||
const originScreen = canvas.ds.convertOffsetToCanvas([originX, originY])
|
||||
canvas.ds.changeScale(canvas.ds.scale / scaleFactor, originScreen)
|
||||
}
|
||||
|
||||
graph.extra.workflowRendererVersion = needsUpscale ? 'Vue' : 'LG'
|
||||
graph.extra.workflowRendererVersion = 'Vue-corrected'
|
||||
return true
|
||||
}
|
||||
|
||||
234
src/renderer/extensions/vueNodes/layout/useNodeDrag.test.ts
Normal file
234
src/renderer/extensions/vueNodes/layout/useNodeDrag.test.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import type { NodeLayout } from '@/renderer/core/layout/types'
|
||||
|
||||
const testState = vi.hoisted(() => {
|
||||
return {
|
||||
selectedNodeIds: null as unknown as Ref<Set<string>>,
|
||||
selectedItems: null as unknown as Ref<unknown[]>,
|
||||
nodeLayouts: new Map<string, Pick<NodeLayout, 'position' | 'size'>>(),
|
||||
mutationFns: {
|
||||
setSource: vi.fn(),
|
||||
moveNode: vi.fn(),
|
||||
batchMoveNodes: vi.fn()
|
||||
},
|
||||
batchUpdateNodeBounds: vi.fn(),
|
||||
nodeSnap: {
|
||||
shouldSnap: vi.fn(() => false),
|
||||
applySnapToPosition: vi.fn((pos: { x: number; y: number }) => pos)
|
||||
},
|
||||
cancelAnimationFrame: vi.fn(),
|
||||
requestAnimationFrameCallback: null as FrameRequestCallback | null
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('pinia', () => ({
|
||||
storeToRefs: <T>(store: T) => store
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
selectedNodeIds: testState.selectedNodeIds,
|
||||
selectedItems: testState.selectedItems
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/layout/operations/layoutMutations', () => ({
|
||||
useLayoutMutations: () => testState.mutationFns
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
|
||||
layoutStore: {
|
||||
getNodeLayoutRef: (nodeId: string) =>
|
||||
ref(testState.nodeLayouts.get(nodeId) ?? null),
|
||||
batchUpdateNodeBounds: testState.batchUpdateNodeBounds
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/extensions/vueNodes/composables/useNodeSnap', () => ({
|
||||
useNodeSnap: () => testState.nodeSnap
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/extensions/vueNodes/composables/useShiftKeySync', () => ({
|
||||
useShiftKeySync: () => ({
|
||||
trackShiftKey: () => () => {}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/layout/transform/useTransformState', () => ({
|
||||
useTransformState: () => ({
|
||||
screenToCanvas: ({ x, y }: { x: number; y: number }) => ({ x, y })
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/litegraphUtil', () => ({
|
||||
isLGraphGroup: () => false
|
||||
}))
|
||||
|
||||
import { useNodeDrag } from '@/renderer/extensions/vueNodes/layout/useNodeDrag'
|
||||
|
||||
describe('useNodeDrag', () => {
|
||||
beforeEach(() => {
|
||||
testState.selectedNodeIds = ref(new Set<string>())
|
||||
testState.selectedItems = ref<unknown[]>([])
|
||||
testState.nodeLayouts.clear()
|
||||
testState.mutationFns.setSource.mockReset()
|
||||
testState.mutationFns.moveNode.mockReset()
|
||||
testState.mutationFns.batchMoveNodes.mockReset()
|
||||
testState.batchUpdateNodeBounds.mockReset()
|
||||
testState.nodeSnap.shouldSnap.mockReset()
|
||||
testState.nodeSnap.shouldSnap.mockReturnValue(false)
|
||||
testState.nodeSnap.applySnapToPosition.mockReset()
|
||||
testState.nodeSnap.applySnapToPosition.mockImplementation(
|
||||
(pos: { x: number; y: number }) => pos
|
||||
)
|
||||
testState.cancelAnimationFrame.mockReset()
|
||||
testState.requestAnimationFrameCallback = null
|
||||
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||||
testState.requestAnimationFrameCallback = cb
|
||||
return 1
|
||||
})
|
||||
vi.stubGlobal('cancelAnimationFrame', testState.cancelAnimationFrame)
|
||||
})
|
||||
|
||||
it('batches multi-node drag updates into one mutation call per frame', () => {
|
||||
testState.selectedNodeIds.value = new Set(['1', '2'])
|
||||
testState.nodeLayouts.set('1', {
|
||||
position: { x: 100, y: 100 },
|
||||
size: { width: 200, height: 120 }
|
||||
})
|
||||
testState.nodeLayouts.set('2', {
|
||||
position: { x: 200, y: 180 },
|
||||
size: { width: 210, height: 130 }
|
||||
})
|
||||
|
||||
const { startDrag, handleDrag } = useNodeDrag()
|
||||
|
||||
startDrag(
|
||||
{
|
||||
clientX: 10,
|
||||
clientY: 20
|
||||
} as PointerEvent,
|
||||
'1'
|
||||
)
|
||||
|
||||
const target = document.createElement('div')
|
||||
target.hasPointerCapture = vi.fn(() => false)
|
||||
target.setPointerCapture = vi.fn()
|
||||
|
||||
handleDrag(
|
||||
{
|
||||
clientX: 30,
|
||||
clientY: 40,
|
||||
target,
|
||||
pointerId: 1
|
||||
} as unknown as PointerEvent,
|
||||
'1'
|
||||
)
|
||||
|
||||
testState.requestAnimationFrameCallback?.(0)
|
||||
|
||||
expect(testState.mutationFns.batchMoveNodes).toHaveBeenCalledTimes(1)
|
||||
expect(testState.mutationFns.batchMoveNodes).toHaveBeenCalledWith([
|
||||
{ nodeId: '1', position: { x: 120, y: 120 } },
|
||||
{ nodeId: '2', position: { x: 220, y: 200 } }
|
||||
])
|
||||
expect(testState.mutationFns.moveNode).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the same batched mutation path for single-node drags', () => {
|
||||
testState.selectedNodeIds.value = new Set(['1'])
|
||||
testState.nodeLayouts.set('1', {
|
||||
position: { x: 50, y: 80 },
|
||||
size: { width: 180, height: 110 }
|
||||
})
|
||||
|
||||
const { startDrag, handleDrag } = useNodeDrag()
|
||||
|
||||
startDrag(
|
||||
{
|
||||
clientX: 5,
|
||||
clientY: 10
|
||||
} as PointerEvent,
|
||||
'1'
|
||||
)
|
||||
|
||||
const target = document.createElement('div')
|
||||
target.hasPointerCapture = vi.fn(() => false)
|
||||
target.setPointerCapture = vi.fn()
|
||||
|
||||
handleDrag(
|
||||
{
|
||||
clientX: 25,
|
||||
clientY: 30,
|
||||
target,
|
||||
pointerId: 1
|
||||
} as unknown as PointerEvent,
|
||||
'1'
|
||||
)
|
||||
|
||||
testState.requestAnimationFrameCallback?.(0)
|
||||
|
||||
expect(testState.mutationFns.batchMoveNodes).toHaveBeenCalledTimes(1)
|
||||
expect(testState.mutationFns.batchMoveNodes).toHaveBeenCalledWith([
|
||||
{ nodeId: '1', position: { x: 70, y: 100 } }
|
||||
])
|
||||
expect(testState.mutationFns.moveNode).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cancels pending RAF and applies snap updates on endDrag', () => {
|
||||
testState.selectedNodeIds.value = new Set(['1'])
|
||||
testState.nodeLayouts.set('1', {
|
||||
position: { x: 50, y: 80 },
|
||||
size: { width: 180, height: 110 }
|
||||
})
|
||||
testState.nodeSnap.shouldSnap.mockReturnValue(true)
|
||||
testState.nodeSnap.applySnapToPosition.mockImplementation(({ x, y }) => ({
|
||||
x: x + 5,
|
||||
y: y + 7
|
||||
}))
|
||||
|
||||
const { startDrag, handleDrag, endDrag } = useNodeDrag()
|
||||
|
||||
startDrag(
|
||||
{
|
||||
clientX: 5,
|
||||
clientY: 10
|
||||
} as PointerEvent,
|
||||
'1'
|
||||
)
|
||||
|
||||
const target = document.createElement('div')
|
||||
target.hasPointerCapture = vi.fn(() => false)
|
||||
target.setPointerCapture = vi.fn()
|
||||
|
||||
handleDrag(
|
||||
{
|
||||
clientX: 25,
|
||||
clientY: 30,
|
||||
target,
|
||||
pointerId: 1
|
||||
} as unknown as PointerEvent,
|
||||
'1'
|
||||
)
|
||||
|
||||
endDrag({} as PointerEvent, '1')
|
||||
|
||||
expect(testState.cancelAnimationFrame).toHaveBeenCalledTimes(1)
|
||||
expect(testState.cancelAnimationFrame).toHaveBeenCalledWith(1)
|
||||
expect(testState.batchUpdateNodeBounds).toHaveBeenCalledTimes(1)
|
||||
expect(testState.batchUpdateNodeBounds).toHaveBeenCalledWith([
|
||||
{
|
||||
nodeId: '1',
|
||||
bounds: {
|
||||
x: 55,
|
||||
y: 87,
|
||||
width: 180,
|
||||
height: 110
|
||||
}
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -127,10 +127,10 @@ function useNodeDragIndividual() {
|
||||
y: dragStartPos.y + canvasDelta.y
|
||||
}
|
||||
|
||||
// Apply mutation through the layout system (Vue batches DOM updates automatically)
|
||||
mutations.moveNode(nodeId, newPosition)
|
||||
// Move drag updates in one transaction to avoid per-node notify fan-out.
|
||||
const updates = [{ nodeId, position: newPosition }]
|
||||
|
||||
// If we're dragging multiple selected nodes, move them all together
|
||||
// Include other selected nodes so multi-drag stays in lockstep.
|
||||
if (
|
||||
otherSelectedNodesStartPositions &&
|
||||
otherSelectedNodesStartPositions.size > 0
|
||||
@@ -143,10 +143,12 @@ function useNodeDragIndividual() {
|
||||
x: startPos.x + canvasDelta.x,
|
||||
y: startPos.y + canvasDelta.y
|
||||
}
|
||||
mutations.moveNode(otherNodeId, newOtherPosition)
|
||||
updates.push({ nodeId: otherNodeId, position: newOtherPosition })
|
||||
}
|
||||
}
|
||||
|
||||
mutations.batchMoveNodes(updates)
|
||||
|
||||
// Move selected groups using frame delta (difference from last frame)
|
||||
// This matches LiteGraph's behavior which uses delta-based movement
|
||||
if (selectedGroups && selectedGroups.length > 0 && lastCanvasDelta) {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { flushPromises, mount } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h } from 'vue'
|
||||
|
||||
import FormDropdown from './FormDropdown.vue'
|
||||
import type { FormDropdownItem } from './types'
|
||||
@@ -19,33 +18,54 @@ vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
const MockFormDropdownMenu = defineComponent({
|
||||
const MockFormDropdownMenu = {
|
||||
name: 'FormDropdownMenu',
|
||||
props: {
|
||||
items: { type: Array as () => FormDropdownItem[], default: () => [] },
|
||||
isSelected: { type: Function, default: undefined },
|
||||
filterOptions: { type: Array, default: () => [] },
|
||||
sortOptions: { type: Array, default: () => [] },
|
||||
maxSelectable: { type: Number, default: 1 },
|
||||
disabled: { type: Boolean, default: false },
|
||||
showOwnershipFilter: { type: Boolean, default: false },
|
||||
ownershipOptions: { type: Array, default: () => [] },
|
||||
showBaseModelFilter: { type: Boolean, default: false },
|
||||
baseModelOptions: { type: Array, default: () => [] }
|
||||
},
|
||||
setup() {
|
||||
return () => h('div', { class: 'mock-menu' })
|
||||
}
|
||||
})
|
||||
props: [
|
||||
'items',
|
||||
'isSelected',
|
||||
'filterOptions',
|
||||
'sortOptions',
|
||||
'maxSelectable',
|
||||
'disabled',
|
||||
'showOwnershipFilter',
|
||||
'ownershipOptions',
|
||||
'showBaseModelFilter',
|
||||
'baseModelOptions'
|
||||
],
|
||||
template: '<div class="mock-menu" />'
|
||||
}
|
||||
|
||||
function mountDropdown(items: FormDropdownItem[]) {
|
||||
const MockFormDropdownInput = {
|
||||
name: 'FormDropdownInput',
|
||||
template:
|
||||
'<button class="mock-dropdown-trigger" @click="$emit(\'select-click\', $event)">Open</button>'
|
||||
}
|
||||
|
||||
const MockPopover = {
|
||||
name: 'Popover',
|
||||
template: '<div><slot /></div>'
|
||||
}
|
||||
|
||||
interface MountDropdownOptions {
|
||||
searcher?: (
|
||||
query: string,
|
||||
items: FormDropdownItem[],
|
||||
onCleanup: (cleanupFn: () => void) => void
|
||||
) => Promise<FormDropdownItem[]>
|
||||
searchQuery?: string
|
||||
}
|
||||
|
||||
function mountDropdown(
|
||||
items: FormDropdownItem[],
|
||||
options: MountDropdownOptions = {}
|
||||
) {
|
||||
return mount(FormDropdown, {
|
||||
props: { items },
|
||||
props: { items, ...options },
|
||||
global: {
|
||||
plugins: [PrimeVue, i18n],
|
||||
stubs: {
|
||||
FormDropdownInput: true,
|
||||
Popover: { template: '<div><slot /></div>' },
|
||||
FormDropdownInput: MockFormDropdownInput,
|
||||
Popover: MockPopover,
|
||||
FormDropdownMenu: MockFormDropdownMenu
|
||||
}
|
||||
}
|
||||
@@ -56,7 +76,7 @@ function getMenuItems(
|
||||
wrapper: ReturnType<typeof mountDropdown>
|
||||
): FormDropdownItem[] {
|
||||
return wrapper
|
||||
.findComponent(MockFormDropdownMenu)
|
||||
.findComponent({ name: 'FormDropdownMenu' })
|
||||
.props('items') as FormDropdownItem[]
|
||||
}
|
||||
|
||||
@@ -112,4 +132,47 @@ describe('FormDropdown', () => {
|
||||
expect(getMenuItems(wrapper)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('avoids filtering work while dropdown is closed', async () => {
|
||||
const searcher = vi.fn(
|
||||
async (_query: string, sourceItems: FormDropdownItem[]) =>
|
||||
sourceItems.filter((item) => item.name.includes('video'))
|
||||
)
|
||||
|
||||
const wrapper = mountDropdown(
|
||||
[createItem('1', 'video-a.mp4'), createItem('2', 'video-b.mp4')],
|
||||
{ searcher }
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
expect(searcher).not.toHaveBeenCalled()
|
||||
|
||||
await wrapper.setProps({ searchQuery: 'video-a' })
|
||||
await wrapper.setProps({
|
||||
items: [createItem('3', 'video-c.mp4'), createItem('4', 'video-d.mp4')]
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(searcher).not.toHaveBeenCalled()
|
||||
expect(getMenuItems(wrapper).map((item) => item.id)).toEqual(['3', '4'])
|
||||
})
|
||||
|
||||
it('runs filtering when dropdown opens', async () => {
|
||||
const searcher = vi.fn(
|
||||
async (_query: string, sourceItems: FormDropdownItem[]) =>
|
||||
sourceItems.filter((item) => item.id === 'keep')
|
||||
)
|
||||
|
||||
const wrapper = mountDropdown(
|
||||
[createItem('keep', 'alpha'), createItem('drop', 'beta')],
|
||||
{ searcher }
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('.mock-dropdown-trigger').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(searcher).toHaveBeenCalled()
|
||||
expect(getMenuItems(wrapper).map((item) => item.id)).toEqual(['keep'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -105,13 +105,17 @@ const maxSelectable = computed(() => {
|
||||
const debouncedSearchQuery = refDebounced(searchQuery, 250, { maxWait: 1000 })
|
||||
|
||||
const filteredItems = computedAsync(async (onCancel) => {
|
||||
if (!isOpen.value) {
|
||||
return items
|
||||
}
|
||||
|
||||
let cleanupFn: (() => void) | undefined
|
||||
onCancel(() => cleanupFn?.())
|
||||
const result = await searcher(debouncedSearchQuery.value, items, (cb) => {
|
||||
cleanupFn = cb
|
||||
})
|
||||
return result
|
||||
}, [])
|
||||
}, items)
|
||||
|
||||
const defaultSorter = computed<SortOption['sorter']>(() => {
|
||||
const sorter = sortOptions.find((option) => option.id === 'default')?.sorter
|
||||
@@ -125,6 +129,10 @@ const selectedSorter = computed<SortOption['sorter']>(() => {
|
||||
return sorter || defaultSorter.value
|
||||
})
|
||||
const sortedItems = computed(() => {
|
||||
if (!isOpen.value) {
|
||||
return items
|
||||
}
|
||||
|
||||
return selectedSorter.value({ items: filteredItems.value }) || []
|
||||
})
|
||||
|
||||
@@ -135,14 +143,14 @@ function internalIsSelected(item: FormDropdownItem, index: number): boolean {
|
||||
const toggleDropdown = (event: Event) => {
|
||||
if (disabled) return
|
||||
if (popoverRef.value && triggerRef.value) {
|
||||
popoverRef.value.toggle(event, triggerRef.value)
|
||||
popoverRef.value.toggle?.(event, triggerRef.value)
|
||||
isOpen.value = !isOpen.value
|
||||
}
|
||||
}
|
||||
|
||||
const closeDropdown = () => {
|
||||
if (popoverRef.value) {
|
||||
popoverRef.value.hide()
|
||||
popoverRef.value.hide?.()
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1289,7 +1289,7 @@ export class ComfyApp {
|
||||
this.rootGraph.extra.workflowRendererVersion
|
||||
|
||||
// Scale main graph
|
||||
ensureCorrectLayoutScale(originalMainGraphRenderer)
|
||||
ensureCorrectLayoutScale(originalMainGraphRenderer, this.rootGraph)
|
||||
|
||||
// Scale all subgraphs that were loaded with the workflow
|
||||
// Use original main graph renderer as fallback (not the modified one)
|
||||
|
||||
Reference in New Issue
Block a user