diff --git a/frontend/src/lib/components/Dev.svelte b/frontend/src/lib/components/Dev.svelte index e348d33518..c7a197bc5d 100644 --- a/frontend/src/lib/components/Dev.svelte +++ b/frontend/src/lib/components/Dev.svelte @@ -492,7 +492,6 @@ const previewArgsStore = $state({ val: {} }) const scriptEditorDrawer = writable(undefined) - const moving = writable<{ id: string } | undefined>(undefined) const history = initHistory(flowStore.val) const stepsInputArgs = new StepsInputArgs() const selectionManager = new SelectionManager() @@ -519,7 +518,6 @@ previewArgs: previewArgsStore, scriptEditorDrawer, flowEditorDrawer: writable(undefined), - moving, history, pathStore: pathStore, flowStateStore, diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index 7e224af535..987f7aed6b 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -613,7 +613,6 @@ const previewArgsStore = $state({ val: initialArgs }) const scriptEditorDrawer = writable(undefined) const flowEditorDrawer = writable(undefined) - const moving = writable<{ id: string } | undefined>(undefined) const history = initHistory(flowStore.val) const pathStore = writable(pathStoreInit ?? initialPath) const captureOn = writable(false) @@ -641,7 +640,6 @@ previewArgs: previewArgsStore, scriptEditorDrawer, flowEditorDrawer, - moving, history, flowStateStore, flowStore, diff --git a/frontend/src/lib/components/flows/map/FlowModuleSchemaItem.svelte b/frontend/src/lib/components/flows/map/FlowModuleSchemaItem.svelte index 8d465f2c6a..8836edb708 100644 --- a/frontend/src/lib/components/flows/map/FlowModuleSchemaItem.svelte +++ b/frontend/src/lib/components/flows/map/FlowModuleSchemaItem.svelte @@ -20,7 +20,7 @@ Timer, Maximize2 } from 'lucide-svelte' - import { createEventDispatcher, getContext } from 'svelte' + import { createEventDispatcher, getContext, onDestroy } from 'svelte' import { fade } from 'svelte/transition' import type { FlowEditorContext } from '../types' import { twMerge } from 'tailwind-merge' @@ -136,6 +136,7 @@ const flowGraphContext = getGraphContext() const diffManager = flowGraphContext?.diffManager + const moveManager = flowGraphContext?.moveManager let pickableIds: Record | undefined = $state(undefined) @@ -185,8 +186,46 @@ !!id && !!$flowPropPickerConfig && !!pickableIds && Object.keys(pickableIds).includes(id) ) + let isDragging = $derived(!!moveManager?.dragging) + + // --- Drag handle logic --- + let dragCleanup: (() => void) | undefined + + function onMovePointerDown(e: Event) { + const pe = e as PointerEvent + const startX = pe.clientX + const startY = pe.clientY + let didDrag = false + + function onMovePointer(me: PointerEvent) { + const dx = me.clientX - startX + const dy = me.clientY - startY + if (!didDrag && Math.sqrt(dx * dx + dy * dy) > 5) { + didDrag = true + if (moveManager && id) { + moveManager.startDrag(id, startX, startY) + } + } + } + + function onUp() { + document.removeEventListener('pointermove', onMovePointer) + document.removeEventListener('pointerup', onUp) + dragCleanup = undefined + if (!didDrag) { + dispatch('move') + } + } + + document.addEventListener('pointermove', onMovePointer) + document.addEventListener('pointerup', onUp) + dragCleanup = onUp + } + + onDestroy(() => dragCleanup?.()) + const outputPickerVisible = $derived( - editMode && (isConnectingCandidate || alwaysShowOutputPicker) && !!id + editMode && (isConnectingCandidate || alwaysShowOutputPicker) && !!id && !isDragging ) const icon_render = $derived(icon) @@ -474,7 +513,7 @@ {/if} - {#if deletable} + {#if deletable && !isDragging} {#if maximizeSubflow !== undefined} {@render buttonMaximizeSubflow?.()} {/if} @@ -488,12 +527,13 @@ > + {#if moveManager?.movingModuleId == mod.id} +
+
{/if} @@ -152,7 +165,7 @@ {/if} -
+
{#if mod.value.type === 'forloopflow' || mod.value.type === 'whileloopflow'} - moving: Writable<{ id: string } | undefined> previewArgs: StateStore> scriptEditorDrawer: Writable flowEditorDrawer: Writable diff --git a/frontend/src/lib/components/graph/DragCoordinator.svelte b/frontend/src/lib/components/graph/DragCoordinator.svelte new file mode 100644 index 0000000000..ed277da2f0 --- /dev/null +++ b/frontend/src/lib/components/graph/DragCoordinator.svelte @@ -0,0 +1,68 @@ + + + diff --git a/frontend/src/lib/components/graph/DragGhost.svelte b/frontend/src/lib/components/graph/DragGhost.svelte new file mode 100644 index 0000000000..1ad0ca5a63 --- /dev/null +++ b/frontend/src/lib/components/graph/DragGhost.svelte @@ -0,0 +1,151 @@ + + +{#if moveManager.dragging} +
+ +
+ {#if ghost} +
+
+ +
+
+ {/if} +{/if} diff --git a/frontend/src/lib/components/graph/FlowGraphV2.svelte b/frontend/src/lib/components/graph/FlowGraphV2.svelte index f0c89e523b..cc316630f5 100644 --- a/frontend/src/lib/components/graph/FlowGraphV2.svelte +++ b/frontend/src/lib/components/graph/FlowGraphV2.svelte @@ -68,6 +68,8 @@ import { SelectionManager } from './selectionUtils.svelte' import { ChangeTracker } from '$lib/svelte5Utils.svelte' import { NoteManager } from './noteManager.svelte' + import type { MoveManager } from './moveManager.svelte' + import DragCoordinator from './DragCoordinator.svelte' import type { ModulesTestStates } from '../modulesTest.svelte' import { deepEqual } from 'fast-equals' import type { AssetWithAltAccessType } from '../assets/lib' @@ -109,7 +111,7 @@ earlyStop?: boolean cache?: boolean scroll?: boolean - moving?: string | undefined + moveManager?: MoveManager // Download: display a top level button to open the graph in a new tab download?: boolean fullSize?: boolean @@ -196,7 +198,7 @@ earlyStop = false, cache = false, scroll = false, - moving = undefined, + moveManager = undefined, download = false, fullSize = false, disableAi = false, @@ -279,6 +281,7 @@ useDataflow, showAssets, noteManager, + moveManager, clearFlowSelection, yOffset, diffManager @@ -752,7 +755,6 @@ success, $useDataflow, untrack(() => selectedId), - moving, simplifiableFlow, triggerNode ? path : undefined, expandedSubflows @@ -912,6 +914,9 @@ {:else} + {#if moveManager} + + {/if} {#if sharedViewport && onViewportChange} + import { writable } from 'svelte/store' + import { SvelteFlow, SvelteFlowProvider, type Node, type Edge, type Viewport } from '@xyflow/svelte' + import { setGraphContext } from './graphContext' + import { SelectionManager } from './selectionUtils.svelte' + import { createFlowDiffManager } from '../flows/flowDiffManager.svelte' + + import ModuleNode from './renderers/nodes/ModuleNode.svelte' + import InputNode from './renderers/nodes/InputNode.svelte' + import BranchAllStart from './renderers/nodes/BranchAllStart.svelte' + import BranchAllEndNode from './renderers/nodes/BranchAllEndNode.svelte' + import ForLoopEndNode from './renderers/nodes/ForLoopEndNode.svelte' + import ForLoopStartNode from './renderers/nodes/ForLoopStartNode.svelte' + import ResultNode from './renderers/nodes/ResultNode.svelte' + import BranchOneStart from './renderers/nodes/BranchOneStart.svelte' + import BranchOneEndNode from './renderers/nodes/branchOneEndNode.svelte' + import SubflowBound from './renderers/nodes/SubflowBound.svelte' + import NoBranchNode from './renderers/nodes/NoBranchNode.svelte' + import AssetNode from './renderers/nodes/AssetNode.svelte' + import AssetsOverflowedNode from './renderers/nodes/AssetsOverflowedNode.svelte' + import AiToolNode from './renderers/nodes/AIToolNode.svelte' + import NewAiToolNode from './renderers/nodes/NewAIToolNode.svelte' + import BaseEdge from './renderers/edges/BaseEdge.svelte' + import EmptyEdge from './renderers/edges/EmptyEdge.svelte' + import DataflowEdge from './renderers/edges/DataflowEdge.svelte' + import HiddenBaseEdge from './renderers/edges/HiddenBaseEdge.svelte' + + let { + nodes, + edges, + width, + height, + initialViewport + }: { nodes: Node[]; edges: Edge[]; width: number; height: number; initialViewport?: Viewport } = + $props() + + setGraphContext({ + selectionManager: new SelectionManager(), + useDataflow: writable(false), + showAssets: writable(false), + diffManager: createFlowDiffManager() + }) + + const nodeTypes = { + input2: InputNode, + module: ModuleNode, + branchAllStart: BranchAllStart, + branchAllEnd: BranchAllEndNode, + forLoopEnd: ForLoopEndNode, + forLoopStart: ForLoopStartNode, + result: ResultNode, + whileLoopStart: ForLoopStartNode, + whileLoopEnd: ForLoopEndNode, + branchOneStart: BranchOneStart, + branchOneEnd: BranchOneEndNode, + subflowBound: SubflowBound, + noBranch: NoBranchNode, + asset: AssetNode, + assetsOverflowed: AssetsOverflowedNode, + aiTool: AiToolNode, + newAiTool: NewAiToolNode + } as any + + const edgeTypes = { + edge: BaseEdge, + empty: EmptyEdge, + dataflowedge: DataflowEdge, + hiddenedge: HiddenBaseEdge + } as any + + const proOptions = { hideAttribution: true } + const fitViewOptions = { padding: 0 } + + +
+ + + +
diff --git a/frontend/src/lib/components/graph/graphBuilder.svelte.ts b/frontend/src/lib/components/graph/graphBuilder.svelte.ts index a200177ce5..a935ae6b69 100644 --- a/frontend/src/lib/components/graph/graphBuilder.svelte.ts +++ b/frontend/src/lib/components/graph/graphBuilder.svelte.ts @@ -119,7 +119,6 @@ export type InputN = { eventHandlers: GraphEventHandlers hasPreprocessor: boolean insertable: boolean - moving: string | undefined disableAi: boolean cache: boolean earlyStop: boolean @@ -143,7 +142,6 @@ export type ModuleN = { id: string parentIds: string[] eventHandlers: GraphEventHandlers - moving: string | undefined flowModuleState: GraphModuleState | undefined testModuleState: ModuleTestState | undefined insertable: boolean @@ -395,7 +393,6 @@ export function graphBuilder( success: boolean | undefined, useDataflow: boolean | undefined, selectedId: string | undefined, - moving: string | undefined, simplifiableFlow: SimplifiableFlow | undefined, flowPathForTriggerNode: string | undefined, expandedSubflows: Record @@ -437,7 +434,6 @@ export function graphBuilder( id: module.id, parentIds: [], eventHandlers: eventHandlers, - moving: moving, flowModuleState: extra.flowModuleStates?.[module.id], testModuleState: extra.testModuleStates?.states?.[module.id], insertable: extra.insertable, @@ -531,7 +527,6 @@ export function graphBuilder( sourceId, targetId, branch, - moving, eventHandlers, simplifiedTriggerView: simplifiableFlow?.simplifiedFlow, disableMoveIds: options?.disableMoveIds, @@ -554,7 +549,6 @@ export function graphBuilder( eventHandlers: eventHandlers, hasPreprocessor: !!preprocessorModule || flowPathForTriggerNode == undefined, insertable: extra.insertable, - moving: moving, disableAi: extra.disableAi, cache: extra.cache, earlyStop: extra.earlyStop, diff --git a/frontend/src/lib/components/graph/graphContext.ts b/frontend/src/lib/components/graph/graphContext.ts index c6a2342cb6..8540982366 100644 --- a/frontend/src/lib/components/graph/graphContext.ts +++ b/frontend/src/lib/components/graph/graphContext.ts @@ -1,6 +1,7 @@ import { getContext, setContext } from 'svelte' import type { SelectionManager } from './selectionUtils.svelte' import type { NoteManager } from './noteManager.svelte' +import type { MoveManager } from './moveManager.svelte' import type { Writable } from 'svelte/store' import type { FlowDiffManager } from '../flows/flowDiffManager.svelte' @@ -9,6 +10,7 @@ export type GraphContext = { useDataflow: Writable showAssets: Writable noteManager?: NoteManager + moveManager?: MoveManager clearFlowSelection?: () => void yOffset?: number diffManager: FlowDiffManager diff --git a/frontend/src/lib/components/graph/moveManager.svelte.ts b/frontend/src/lib/components/graph/moveManager.svelte.ts new file mode 100644 index 0000000000..7f13c44171 --- /dev/null +++ b/frontend/src/lib/components/graph/moveManager.svelte.ts @@ -0,0 +1,215 @@ +import { NODE } from './util' + +export type DropZone = { + edgeId: string + sourceId: string + targetId: string + branch: { rootId: string; branch: number } | undefined + index: number + flowPosition: { x: number; y: number } + disableMoveIds: string[] +} + +export type DropZoneRegistration = { + sourceId: string + targetId: string + branch: { rootId: string; branch: number } | undefined + index: number + disableMoveIds: string[] + centerX: number + centerY: number +} + +type DragInfo = { + moduleId: string +} + +/** + * Compute the set of node IDs belonging to a subflow (or a single step). + * Includes the module itself, all nested children (id starts with `moduleId-`), + * and structural edge endpoints whose `disableMoveIds` reference this module. + */ +export function getSubflowNodeIds( + moduleId: string, + allNodes: { id: string }[], + allEdges: { source: string; target: string; data?: any }[] +): Set { + const nodeIdPrefix = moduleId + '-' + const nodeIds = new Set() + + for (const n of allNodes) { + if (n.id === moduleId || n.id.startsWith(nodeIdPrefix)) { + nodeIds.add(n.id) + } + } + + for (const e of allEdges) { + const disableIds: string[] = e.data?.disableMoveIds ?? [] + if (disableIds.includes(moduleId)) { + nodeIds.add(e.source) + nodeIds.add(e.target) + } + } + + // Include child nodes (e.g. asset/AI tool nodes) of nodes added via edges. + // Nodes found through disableMoveIds (like inner module "b") may have children + // ("b-asset-in-...") that weren't caught by the initial prefix match on moduleId. + // Only scan children of edge-added nodes that aren't already covered by the + // moduleId prefix (those children were already captured in the first pass). + const newFromEdges: string[] = [] + for (const id of nodeIds) { + if (id !== moduleId && !id.startsWith(nodeIdPrefix)) { + newFromEdges.push(id) + } + } + if (newFromEdges.length > 0) { + for (const n of allNodes) { + if (!nodeIds.has(n.id)) { + for (const id of newFromEdges) { + if (n.id.startsWith(id + '-')) { + nodeIds.add(n.id) + break + } + } + } + } + } + + return nodeIds +} + +export class MoveManager { + dragging = $state(undefined) + ghostScreenX = $state(0) + ghostScreenY = $state(0) + nearestDropZone = $state(undefined) + draggedNodeIds = $state>(new Set()) + + /** The module ID currently being moved via legacy click-to-move */ + movingModuleId = $state(undefined) + + toggleMoving(id: string) { + if (this.movingModuleId === id) { + this.movingModuleId = undefined + this.#updateDraggedNodeIds(undefined) + } else { + this.movingModuleId = id + this.#updateDraggedNodeIds(id) + } + } + + setMoving(id: string) { + this.movingModuleId = id + this.#updateDraggedNodeIds(id) + } + + clearMoving() { + this.movingModuleId = undefined + this.#updateDraggedNodeIds(undefined) + } + + #computeDraggedNodeIds: ((moduleId: string) => Set) | undefined + + setComputeDraggedNodeIds(fn: (moduleId: string) => Set) { + this.#computeDraggedNodeIds = fn + } + + #updateDraggedNodeIds(moduleId: string | undefined) { + this.draggedNodeIds = + moduleId && this.#computeDraggedNodeIds ? this.#computeDraggedNodeIds(moduleId) : new Set() + } + + #screenToFlowPosition: ((pos: { x: number; y: number }) => { x: number; y: number }) | undefined + #registeredDropZones = new Map() + + setScreenToFlowPosition(fn: (pos: { x: number; y: number }) => { x: number; y: number }) { + this.#screenToFlowPosition = fn + } + + registerDropZone(edgeId: string, zone: DropZoneRegistration) { + this.#registeredDropZones.set(edgeId, zone) + } + + unregisterDropZone(edgeId: string) { + this.#registeredDropZones.delete(edgeId) + } + + startDrag(moduleId: string, screenX: number, screenY: number) { + // Clear any active click-to-move so only drag mode is active + this.movingModuleId = undefined + this.dragging = { moduleId } + this.ghostScreenX = screenX + this.ghostScreenY = screenY + this.nearestDropZone = undefined + this.#updateDraggedNodeIds(moduleId) + } + + updateDrag(screenX: number, screenY: number) { + if (!this.dragging) return + + this.ghostScreenX = screenX + this.ghostScreenY = screenY + + if (!this.#screenToFlowPosition) return + + const flowPos = this.#screenToFlowPosition({ x: screenX, y: screenY }) + this.nearestDropZone = this.#findNearestDropZone(flowPos) + } + + endDrag(): DropZone | undefined { + if (!this.dragging) return undefined + const zone = this.nearestDropZone + this.dragging = undefined + this.nearestDropZone = undefined + this.#updateDraggedNodeIds(undefined) + return zone + } + + cancelDrag() { + this.dragging = undefined + this.nearestDropZone = undefined + this.#updateDraggedNodeIds(undefined) + } + + #findNearestDropZone(flowPos: { x: number; y: number }): DropZone | undefined { + if (!this.dragging) return undefined + + const draggedId = this.dragging.moduleId + let best: DropZone | undefined = undefined + let bestDist = Infinity + + // Box half-dimensions matching the visual drop zone + const halfW = NODE.width / 2 + const halfH = NODE.gap.vertical / 2 + + for (const [edgeId, zone] of this.#registeredDropZones) { + if (zone.disableMoveIds.includes(draggedId)) continue + + // Skip edges adjacent to the dragged node (no-op move) + if (zone.sourceId === draggedId || zone.targetId === draggedId) continue + + const dx = Math.abs(flowPos.x - zone.centerX) + const dy = Math.abs(flowPos.y - zone.centerY) + + // Axis-aligned bounding box test + if (dx <= halfW && dy <= halfH) { + // Manhattan-like distance for ranking overlapping boxes + const dist = dx + dy + if (dist < bestDist) { + bestDist = dist + best = { + edgeId, + sourceId: zone.sourceId, + targetId: zone.targetId, + branch: zone.branch, + index: zone.index, + flowPosition: { x: zone.centerX, y: zone.centerY }, + disableMoveIds: zone.disableMoveIds + } + } + } + } + + return best + } +} diff --git a/frontend/src/lib/components/graph/renderers/edges/BaseEdge.svelte b/frontend/src/lib/components/graph/renderers/edges/BaseEdge.svelte index 0818e3725c..de74cd22cd 100644 --- a/frontend/src/lib/components/graph/renderers/edges/BaseEdge.svelte +++ b/frontend/src/lib/components/graph/renderers/edges/BaseEdge.svelte @@ -1,7 +1,7 @@ - + {#if waitingForEvents && data.flowJob && data.flowJob.type === 'QueuedJob'}
{/if}
- {:else if data?.insertable && !$useDataflow && !data?.moving} + {:else if isDragging && isValidDropTarget} +
+
+ {#if isNearestDrop} +
+ {/if} + {@render dropTargetIndicator(isNearestDrop)} +
+
+ {:else if data?.insertable && !$useDataflow && !moveManager?.movingModuleId && !isDragging}
{/if} - {#if data?.moving} + {#if moveManager?.movingModuleId && data?.insertable}
- {#if data.moving && !data.disableMoveIds?.includes(data.moving)} + {#if !data.disableMoveIds?.includes(moveManager.movingModuleId)} {/if}
{/if} +{#snippet dropTargetIndicator(highlighted: boolean)} +
+ +
+{/snippet} + - + {#snippet children({ darkMode })}
('FlowGraphAssetContext') - let { data }: Props = $props() + let { data, id }: Props = $props() const isSelected = $derived(assetEq(flowGraphAssetsCtx?.val.selectedAsset, data.asset)) const cachedResourceMetadata = $derived.by(() => { @@ -267,7 +268,7 @@ ) - + {#snippet children({ darkMode })} diff --git a/frontend/src/lib/components/graph/renderers/nodes/AssetsOverflowedNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/AssetsOverflowedNode.svelte index 623a725b01..12e0b71673 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/AssetsOverflowedNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/AssetsOverflowedNode.svelte @@ -13,8 +13,9 @@ interface Props { data: AssetsOverflowedN['data'] + id?: string } - let { data }: Props = $props() + let { data, id }: Props = $props() const flowGraphAssetsCtx = getContext('FlowGraphAssetContext') let isOpen = $state(false) @@ -37,7 +38,7 @@ const colors = $derived(getNodeColorClasses(undefined, includesSelected)) - + {#snippet children({ darkMode })} - + {#snippet children({ darkMode })} - + {#snippet children({ darkMode })} - + {#snippet children({ darkMode })} - + {#snippet children({ darkMode })} {#if data.simplifiedTriggerView} - + {#snippet children({ darkMode })} +{#if !isMoving} {/snippet} +{/if} diff --git a/frontend/src/lib/components/graph/renderers/nodes/NoBranchNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/NoBranchNode.svelte index 356e1c272a..9a4558011c 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/NoBranchNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/NoBranchNode.svelte @@ -4,12 +4,13 @@ import type { NoBranchN } from '../../graphBuilder.svelte' interface Props { data: NoBranchN['data'] + id: string } - let { data }: Props = $props() + let { data, id }: Props = $props() - + {#snippet children({ darkMode })} } @@ -19,9 +22,16 @@ offset = 0, wrapperClass = '', contextMenuItems = undefined, + nodeId = undefined, children }: Props = $props() + const { moveManager } = getGraphContext() + + let faded = $derived( + nodeId != null && (moveManager?.draggedNodeIds?.has(nodeId) ?? false) + ) + let darkMode: boolean = $state(false) @@ -29,14 +39,14 @@ {#if contextMenuItems && contextMenuItems.length > 0} -
+
{@render children?.({ darkMode })}
{@render handles()} {:else} -
+
{@render children?.({ darkMode })}
diff --git a/frontend/src/lib/components/graph/renderers/nodes/branchOneEndNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/branchOneEndNode.svelte index fca971ec92..973009c7c8 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/branchOneEndNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/branchOneEndNode.svelte @@ -13,7 +13,7 @@ let { data, id }: Props = $props() - + {#snippet children({ darkMode })} (undefined) const history = initHistory(flowStore.val) const stepsInputArgs = new StepsInputArgs() @@ -93,7 +92,6 @@ previewArgs: previewArgsStore, scriptEditorDrawer, flowEditorDrawer: writable(undefined), - moving, history, pathStore: writable(''), flowStateStore,