From 03e20b7aaf5cebae57499b168a10e97230e90d61 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Mon, 2 Mar 2026 10:06:23 +0000 Subject: [PATCH] feat(frontend): add drag-and-drop node movement in flow editor (#8076) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add drag-and-drop node movement in flow editor Replace the 2-step click-based move with drag-and-drop: grab a node's Move icon, drag it near an insert point, see a visual drop indicator, and drop to move. Click-based move is preserved as fallback. Co-Authored-By: Claude Opus 4.6 * fix: hide insert buttons on edges during drag-and-drop Co-Authored-By: Claude Opus 4.6 * feat: unify drop zone and legacy move target styles Use consistent dot indicator for both drag-and-drop and click-based move targets. Use text-accent theming, hide insert buttons during drag. Co-Authored-By: Claude Opus 4.6 * feat: render real SvelteFlow graph in drag ghost for subflows Co-Authored-By: Claude Opus 4.6 * fix: center drag ghost on the dragged node instead of the whole subflow Co-Authored-By: Claude Opus 4.6 * feat: pass isSubflow prop through drag system and improve move UX Co-Authored-By: Claude Opus 4.6 * feat: fade entire subflow during legacy move and drag-and-drop Co-Authored-By: Claude Opus 4.5 * style: use text-secondary for move and drop target indicators Co-Authored-By: Claude Opus 4.5 * feat: improve drag-and-drop visual feedback with proximity cues Ghost opacity reacts to drop zone proximity (dims when far, brightens when near). Add move icon badge near cursor that highlights on valid drop target. Switch hit detection from circular radius to axis-aligned bounding box matching the node gap dimensions. Co-Authored-By: Claude Opus 4.6 * refactor: unify DragGhost to always use MiniFlowGraph Co-Authored-By: Claude Opus 4.5 * feat: scale drag ghost using flow viewport zoom instead of fixed width Co-Authored-By: Claude Opus 4.5 * refactor: register drop zone positions from BaseEdge instead of recomputing from node data Co-Authored-By: Claude Opus 4.6 * feat: hide node UI clutter during drag and polish drag ghost Co-Authored-By: Claude Opus 4.6 * fix: fade all deeply nested nodes when dragging a subflow Previously only immediate children of a dragged subflow would fade — deeply nested nodes (e.g. steps inside a forloop inside a branchall) stayed at full opacity. Store the full set of dragged node IDs on DragManager and check set membership instead of single-parent comparison. Co-Authored-By: Claude Opus 4.6 * refactor: rename DragManager to MoveManager and eliminate moving prop drilling Co-Authored-By: Claude Opus 4.5 * refactor: unify subflow node computation for both move modes Extract getSubflowNodeIds() to moveManager.svelte.ts and populate draggedNodeIds via a single $effect in DragCoordinator for both legacy click-to-move and drag-and-drop. Consumers (MapItem, NodeWrapper) now only check draggedNodeIds set membership instead of dual-checking. Co-Authored-By: Claude Opus 4.6 * refactor: clean up drag-and-drop code review issues Fix toggle risk in DragCoordinator by using forceSetMoving instead of the toggle-based setMoving. Remove dead code (DragInfo unused fields, parentSubflowId, GHOST_ZOOM_FACTOR, debug log), extract duplicated expressions to $derived variables, and add missing type annotations. Co-Authored-By: Claude Opus 4.6 * fix: clear click-to-move when drag starts to prevent dual mode activation Co-Authored-By: Claude Opus 4.6 * refactor: centralize draggedNodeIds cleanup in $effect Co-Authored-By: Claude Opus 4.5 * fix: adjust insertion index when moving node forward in same array Co-Authored-By: Claude Opus 4.5 * refactor: address PR review feedback for node move feature - Snapshot drag ghost once at drag start using untrack() to avoid recomputing on every nodes/edges change during drag - Rename setMoving/forceSetMoving to toggleMoving/setMoving for clarity - Add capture: true to DragCoordinator's Escape handler for consistency - Rename MOVE_BTN_OFFSET to DRAG_HANDLE_OFFSET with descriptive comment - Move misplaced import to top of moveManager.svelte.ts - Replace (n.data as any).offset with typed nodeOffset() helper Co-Authored-By: Claude Opus 4.6 * fix: register asset/AI node types in MiniFlowGraph for drag ghost MiniFlowGraph was missing asset, assetsOverflowed, aiTool, and newAiTool node types, so these nodes rendered as invisible elements that inflated the drag ghost bounding box. Register them so the ghost renders all node types correctly. Co-Authored-By: Claude Opus 4.6 * fix: resolve relative positions to absolute for xyflow child nodes in drag ghost Co-Authored-By: Claude Opus 4.5 * fix: use initialViewport instead of fitView so drag ghost matches flow zoom Co-Authored-By: Claude Opus 4.5 * style: format BaseEdge.svelte Co-Authored-By: Claude Opus 4.5 * fix: fade asset and AI tool nodes when their parent is being moved Co-Authored-By: Claude Opus 4.5 * fix: include child nodes of edge-matched nodes in subflow ID collection Co-Authored-By: Claude Opus 4.5 * fix: hide +Tool button when moving nodes Co-Authored-By: Claude Opus 4.5 * fix: address PR review feedback (listener cleanup, set iteration, dead code) Co-Authored-By: Claude Opus 4.6 * style: position cancel move button on top of node instead of above it Co-Authored-By: Claude Opus 4.6 * refactor: compute draggedNodeIds eagerly via callback instead of reactive effect Co-Authored-By: Claude Opus 4.6 * refactor: remove redundant parentModuleId from NodeWrapper Co-Authored-By: Claude Opus 4.6 * refactor: address PR review comments for drag ghost and move manager Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- frontend/src/lib/components/Dev.svelte | 2 - .../src/lib/components/FlowBuilder.svelte | 2 - .../flows/map/FlowModuleSchemaItem.svelte | 54 ++++- .../flows/map/FlowModuleSchemaMap.svelte | 42 ++-- .../lib/components/flows/map/MapItem.svelte | 31 ++- frontend/src/lib/components/flows/types.ts | 1 - .../components/graph/DragCoordinator.svelte | 68 ++++++ .../src/lib/components/graph/DragGhost.svelte | 151 ++++++++++++ .../lib/components/graph/FlowGraphV2.svelte | 11 +- .../lib/components/graph/MiniFlowGraph.svelte | 96 ++++++++ .../components/graph/graphBuilder.svelte.ts | 6 - .../src/lib/components/graph/graphContext.ts | 2 + .../components/graph/moveManager.svelte.ts | 215 ++++++++++++++++++ .../graph/renderers/edges/BaseEdge.svelte | 99 ++++++-- .../graph/renderers/nodes/AIToolNode.svelte | 5 +- .../graph/renderers/nodes/AssetNode.svelte | 5 +- .../nodes/AssetsOverflowedNode.svelte | 5 +- .../renderers/nodes/BranchAllEndNode.svelte | 2 +- .../renderers/nodes/BranchAllStart.svelte | 2 +- .../renderers/nodes/BranchOneStart.svelte | 2 +- .../renderers/nodes/ForLoopEndNode.svelte | 2 +- .../renderers/nodes/ForLoopStartNode.svelte | 2 +- .../graph/renderers/nodes/ModuleNode.svelte | 1 - .../renderers/nodes/NewAIToolNode.svelte | 6 + .../graph/renderers/nodes/NoBranchNode.svelte | 5 +- .../graph/renderers/nodes/NodeWrapper.svelte | 14 +- .../renderers/nodes/branchOneEndNode.svelte | 2 +- frontend/src/routes/flows/dev/+page.svelte | 2 - 28 files changed, 750 insertions(+), 85 deletions(-) create mode 100644 frontend/src/lib/components/graph/DragCoordinator.svelte create mode 100644 frontend/src/lib/components/graph/DragGhost.svelte create mode 100644 frontend/src/lib/components/graph/MiniFlowGraph.svelte create mode 100644 frontend/src/lib/components/graph/moveManager.svelte.ts 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,