diff --git a/src/platform/workflow/persistence/composables/useWorkflowPersistence.ts b/src/platform/workflow/persistence/composables/useWorkflowPersistence.ts index 5036a976d0..cea1117a51 100644 --- a/src/platform/workflow/persistence/composables/useWorkflowPersistence.ts +++ b/src/platform/workflow/persistence/composables/useWorkflowPersistence.ts @@ -3,6 +3,7 @@ import { computed, watch } from 'vue' import { useWorkflowService } from '@/platform/workflow/core/services/workflowService' import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' +import { useLayoutSync } from '@/renderer/core/layout/sync/useLayoutSync' import { api } from '@/scripts/api' import { app as comfyApp } from '@/scripts/app' import { getStorageValue, setStorageValue } from '@/scripts/utils' @@ -12,6 +13,7 @@ import { useSettingStore } from '@/stores/settingStore' export function useWorkflowPersistence() { const workflowStore = useWorkflowStore() const settingStore = useSettingStore() + const { forceSyncAll } = useLayoutSync() const workflowPersistenceEnabled = computed(() => settingStore.get('Comfy.Workflow.Persist') @@ -19,6 +21,12 @@ export function useWorkflowPersistence() { const persistCurrentWorkflow = () => { if (!workflowPersistenceEnabled.value) return + + // Force sync all layout changes to LiteGraph before serialization + if (comfyApp.canvas) { + forceSyncAll(comfyApp.canvas) + } + const workflow = JSON.stringify(comfyApp.graph.serialize()) localStorage.setItem('workflow', workflow) if (api.clientId) { diff --git a/src/renderer/core/layout/store/layoutStore.ts b/src/renderer/core/layout/store/layoutStore.ts index 6733440867..ba221d65e0 100644 --- a/src/renderer/core/layout/store/layoutStore.ts +++ b/src/renderer/core/layout/store/layoutStore.ts @@ -1381,6 +1381,24 @@ class LayoutStoreImpl implements LayoutStore { // Restore original source this.currentSource = originalSource } + + /** + * Get all node layouts for syncing to external systems + */ + getAllNodeLayouts(): Map { + const results = new Map() + + for (const [nodeId, ynode] of this.ynodes.entries()) { + if (ynode) { + const layout = yNodeToLayout(ynode) + results.set(nodeId, layout) + } else { + results.set(nodeId, null) + } + } + + return results + } } // Create singleton instance diff --git a/src/renderer/core/layout/sync/useLayoutSync.ts b/src/renderer/core/layout/sync/useLayoutSync.ts index 2aee7974cf..49e8c4dca3 100644 --- a/src/renderer/core/layout/sync/useLayoutSync.ts +++ b/src/renderer/core/layout/sync/useLayoutSync.ts @@ -57,6 +57,40 @@ export function useLayoutSync() { }) } + /** + * Force sync all layout data to LiteGraph immediately + * Useful before workflow serialization to ensure all changes are persisted + */ + function forceSyncAll(canvas: any) { + if (!canvas?.graph) return + + const allNodeLayouts = layoutStore.getAllNodeLayouts() + for (const [nodeId, layout] of allNodeLayouts) { + const liteNode = canvas.graph.getNodeById(nodeId) + if (!liteNode || !layout) continue + + // Update position if changed + 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 + } + + // Update size if changed + if ( + liteNode.size[0] !== layout.size.width || + liteNode.size[1] !== layout.size.height + ) { + liteNode.size[0] = layout.size.width + liteNode.size[1] = layout.size.height + } + } + + canvas.setDirty(true, true) + } + /** * Stop syncing */ @@ -74,6 +108,7 @@ export function useLayoutSync() { return { startSync, - stopSync + stopSync, + forceSyncAll } } diff --git a/src/renderer/extensions/vueNodes/components/LGraphNode.vue b/src/renderer/extensions/vueNodes/components/LGraphNode.vue index 754bd323a0..2b7a87fea0 100644 --- a/src/renderer/extensions/vueNodes/components/LGraphNode.vue +++ b/src/renderer/extensions/vueNodes/components/LGraphNode.vue @@ -33,7 +33,9 @@ :style="[ { transform: `translate(${layoutPosition.x ?? position?.x ?? 0}px, ${(layoutPosition.y ?? position?.y ?? 0) - LiteGraph.NODE_TITLE_HEIGHT}px)`, - zIndex: zIndex + zIndex: zIndex, + ...(currentSize?.width && { width: `${currentSize.width}px` }), + ...(currentSize?.height && { height: `${currentSize.height}px` }) }, dragStyle ]" @@ -122,6 +124,13 @@ /> + + +
@@ -151,6 +160,7 @@ import { useNodeOutputStore } from '@/stores/imagePreviewStore' import { getNodeByLocatorId } from '@/utils/graphTraversalUtil' import { cn } from '@/utils/tailwindUtil' +import { useNodeResize } from '../composables/useNodeResize' import { useVueElementTracking } from '../composables/useVueNodeResizeTracking' import NodeContent from './NodeContent.vue' import NodeHeader from './NodeHeader.vue' @@ -194,7 +204,7 @@ const emit = defineEmits<{ 'update:title': [nodeId: string, newTitle: string] }>() -useVueElementTracking(nodeData.id, 'node') +const tracking = useVueElementTracking(nodeData.id, 'node') // Inject selection state from parent const selectedNodeIds = inject(SelectedNodeIdsKey) @@ -282,6 +292,31 @@ onMounted(() => { } }) +// Resize with local state to avoid reactive loops +const currentSize = ref<{ width: number; height: number } | null>(null) + +const { startResize } = useNodeResize( + (newSize) => { + // Update local state for immediate visual feedback + currentSize.value = newSize + }, + { + minWidth: 200, + minHeight: 100, + maxWidth: 800, + maxHeight: 600, + transformState, + onStart: () => tracking.pause(), // Pause automatic tracking + onEnd: () => { + // Sync with layout system once at the end + if (currentSize.value) { + resize(currentSize.value) + } + tracking.resume() // Resume automatic tracking + } + } +) + // Drag state for styling const isDragging = ref(false) const dragStyle = computed(() => ({ diff --git a/src/renderer/extensions/vueNodes/composables/useNodeResize.ts b/src/renderer/extensions/vueNodes/composables/useNodeResize.ts new file mode 100644 index 0000000000..2a4704d271 --- /dev/null +++ b/src/renderer/extensions/vueNodes/composables/useNodeResize.ts @@ -0,0 +1,143 @@ +/** + * Composable for node resizing functionality + * + * Provides resize handle interaction that integrates with the layout system. + * Handles pointer capture, coordinate calculations, and size constraints. + */ +import { ref } from 'vue' + +interface TransformState { + screenToCanvas: (point: { x: number; y: number }) => { x: number; y: number } + camera: { z: number } +} + +interface UseNodeResizeOptions { + /** Minimum width constraint */ + minWidth?: number + /** Minimum height constraint */ + minHeight?: number + /** Maximum width constraint */ + maxWidth?: number + /** Maximum height constraint */ + maxHeight?: number + /** Transform state for coordinate conversion */ + transformState?: TransformState + /** Called when resize starts */ + onStart?: () => void + /** Called when resize ends */ + onEnd?: () => void +} + +export function useNodeResize( + resizeCallback: (size: { width: number; height: number }) => void, + options: UseNodeResizeOptions = {} +) { + const { + minWidth = 200, + minHeight = 100, + maxWidth = 800, + maxHeight = 600, + transformState, + onStart, + onEnd + } = options + + // Resize state + const isResizing = ref(false) + const resizeStartPos = ref<{ x: number; y: number } | null>(null) + const resizeStartSize = ref<{ width: number; height: number } | null>(null) + + const startResize = (event: PointerEvent) => { + event.preventDefault() + isResizing.value = true + resizeStartPos.value = { x: event.clientX, y: event.clientY } + + // Call onStart callback (to pause tracking) + onStart?.() + + // Get the current element dimensions + const element = (event.target as HTMLElement).closest( + '.lg-node' + ) as HTMLElement + if (!element) return + const rect = element.getBoundingClientRect() + + let startWidth = rect.width + let startHeight = rect.height + + // If we have transform state, convert screen size to canvas size + if (transformState?.screenToCanvas && transformState?.camera) { + // Scale the size by the inverse of the zoom factor to get canvas units + const scale = transformState.camera.z + startWidth = rect.width / scale + startHeight = rect.height / scale + } + + resizeStartSize.value = { + width: startWidth, + height: startHeight + } + + // Capture pointer + const target = event.target as HTMLElement + target.setPointerCapture(event.pointerId) + + // Add global listeners + document.addEventListener('pointermove', handleResize) + document.addEventListener('pointerup', endResize) + } + + const handleResize = (event: PointerEvent) => { + if (!isResizing.value || !resizeStartPos.value || !resizeStartSize.value) + return + + let deltaX = event.clientX - resizeStartPos.value.x + let deltaY = event.clientY - resizeStartPos.value.y + + // Convert screen deltas to canvas coordinates if transform state is available + if (transformState?.screenToCanvas) { + const mouseDelta = { x: deltaX, y: deltaY } + const canvasOrigin = transformState.screenToCanvas({ x: 0, y: 0 }) + const canvasWithDelta = transformState.screenToCanvas(mouseDelta) + + deltaX = canvasWithDelta.x - canvasOrigin.x + deltaY = canvasWithDelta.y - canvasOrigin.y + } + + const newWidth = Math.max( + minWidth, + Math.min(maxWidth, resizeStartSize.value.width + deltaX) + ) + const newHeight = Math.max( + minHeight, + Math.min(maxHeight, resizeStartSize.value.height + deltaY) + ) + + // Call the provided resize callback + resizeCallback({ width: newWidth, height: newHeight }) + } + + const endResize = (event: PointerEvent) => { + if (!isResizing.value) return + + // Call onEnd callback (to resume tracking) + onEnd?.() + + isResizing.value = false + resizeStartPos.value = null + resizeStartSize.value = null + + // Release pointer + const target = event.target as HTMLElement + target.releasePointerCapture(event.pointerId) + + // Remove global listeners + document.removeEventListener('pointermove', handleResize) + document.removeEventListener('pointerup', endResize) + } + + return { + isResizing, + startResize + } +} diff --git a/src/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking.ts b/src/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking.ts index c6be502857..1b80823826 100644 --- a/src/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking.ts +++ b/src/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking.ts @@ -8,7 +8,14 @@ * Supports different element types (nodes, slots, widgets, etc.) with * customizable data attributes and update handlers. */ -import { getCurrentInstance, onMounted, onUnmounted } from 'vue' +import { + type Ref, + getCurrentInstance, + onMounted, + onUnmounted, + readonly, + ref +} from 'vue' import { layoutStore } from '@/renderer/core/layout/store/layoutStore' import type { Bounds, NodeId } from '@/renderer/core/layout/types' @@ -23,6 +30,15 @@ interface ElementBoundsUpdate { bounds: Bounds } +/** + * Control interface for pausing/resuming tracking + */ +interface TrackingControl { + pause(): void + resume(): void + isActive: Readonly> +} + /** * Configuration for different types of tracked elements */ @@ -52,6 +68,9 @@ const trackingConfigs: Map = new Map([ ] ]) +// Storage for tracking controls by element +const trackingControls = new WeakMap() + // Single ResizeObserver instance for all Vue elements const resizeObserver = new ResizeObserver((entries) => { // Group updates by element type @@ -61,6 +80,10 @@ const resizeObserver = new ResizeObserver((entries) => { if (!(entry.target instanceof HTMLElement)) continue const element = entry.target + // Check if tracking is paused for this element + const control = trackingControls.get(element) + if (control && !control.isActive.value) continue + // Find which type this element belongs to let elementType: string | undefined let elementId: string | undefined @@ -128,7 +151,18 @@ const resizeObserver = new ResizeObserver((entries) => { export function useVueElementTracking( appIdentifier: string, trackingType: string -) { +): TrackingControl { + const isActive = ref(true) + + const control: TrackingControl = { + pause: () => { + isActive.value = false + }, + resume: () => { + isActive.value = true + }, + isActive: readonly(isActive) + } onMounted(() => { const element = getCurrentInstance()?.proxy?.$el if (!(element instanceof HTMLElement) || !appIdentifier) return @@ -137,6 +171,10 @@ export function useVueElementTracking( if (config) { // Set the appropriate data attribute element.dataset[config.dataAttribute] = appIdentifier + + // Store the tracking control for this element + trackingControls.set(element, control) + resizeObserver.observe(element) } }) @@ -149,7 +187,13 @@ export function useVueElementTracking( if (config) { // Remove the data attribute delete element.dataset[config.dataAttribute] + + // Remove the tracking control + trackingControls.delete(element) + resizeObserver.unobserve(element) } }) + + return control } diff --git a/tests-ui/tests/utils/migration/migrateReroute.test.ts b/tests-ui/tests/utils/migration/migrateReroute.test.ts index 767eb0a2c9..ce7b62a2e7 100644 --- a/tests-ui/tests/utils/migration/migrateReroute.test.ts +++ b/tests-ui/tests/utils/migration/migrateReroute.test.ts @@ -19,7 +19,7 @@ describe('migrateReroute', () => { 'single_connected.json', 'floating.json', 'floating_branch.json' - ])('should correctly migrate %s', (fileName) => { + ])('should correctly migrate %s', async (fileName) => { // Load the legacy workflow const legacyWorkflow = loadWorkflow( `workflows/reroute/legacy/${fileName}` @@ -29,9 +29,9 @@ describe('migrateReroute', () => { const migratedWorkflow = migrateLegacyRerouteNodes(legacyWorkflow) // Compare with snapshot - expect(JSON.stringify(migratedWorkflow, null, 2)).toMatchFileSnapshot( - `workflows/reroute/native/${fileName}` - ) + await expect( + JSON.stringify(migratedWorkflow, null, 2) + ).toMatchFileSnapshot(`workflows/reroute/native/${fileName}`) }) }) })