mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-22 00:03:35 +00:00
feat(frontend): add drag-and-drop node movement in flow editor (#8076)
* 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 <noreply@anthropic.com> * fix: hide insert buttons on edges during drag-and-drop Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * feat: render real SvelteFlow graph in drag ghost for subflows Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: center drag ghost on the dragged node instead of the whole subflow Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: pass isSubflow prop through drag system and improve move UX Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: fade entire subflow during legacy move and drag-and-drop Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * style: use text-secondary for move and drop target indicators Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * refactor: unify DragGhost to always use MiniFlowGraph Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: scale drag ghost using flow viewport zoom instead of fixed width Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: register drop zone positions from BaseEdge instead of recomputing from node data Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: hide node UI clutter during drag and polish drag ghost Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * refactor: rename DragManager to MoveManager and eliminate moving prop drilling Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * fix: clear click-to-move when drag starts to prevent dual mode activation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: centralize draggedNodeIds cleanup in $effect Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: adjust insertion index when moving node forward in same array Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * fix: resolve relative positions to absolute for xyflow child nodes in drag ghost Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: use initialViewport instead of fitView so drag ghost matches flow zoom Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * style: format BaseEdge.svelte Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: fade asset and AI tool nodes when their parent is being moved Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: include child nodes of edge-matched nodes in subflow ID collection Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: hide +Tool button when moving nodes Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address PR review feedback (listener cleanup, set iteration, dead code) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style: position cancel move button on top of node instead of above it Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: compute draggedNodeIds eagerly via callback instead of reactive effect Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: remove redundant parentModuleId from NodeWrapper Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: address PR review comments for drag ghost and move manager Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
co-authored by
Claude Opus 4.6
parent
660928c43f
commit
03e20b7aaf
@@ -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,
|
||||
|
||||
@@ -613,7 +613,6 @@
|
||||
const previewArgsStore = $state({ val: initialArgs })
|
||||
const scriptEditorDrawer = writable<ScriptEditorDrawer | undefined>(undefined)
|
||||
const flowEditorDrawer = writable<FlowEditorDrawer | undefined>(undefined)
|
||||
const moving = writable<{ id: string } | undefined>(undefined)
|
||||
const history = initHistory(flowStore.val)
|
||||
const pathStore = writable<string>(pathStoreInit ?? initialPath)
|
||||
const captureOn = writable<boolean>(false)
|
||||
@@ -641,7 +640,6 @@
|
||||
previewArgs: previewArgsStore,
|
||||
scriptEditorDrawer,
|
||||
flowEditorDrawer,
|
||||
moving,
|
||||
history,
|
||||
flowStateStore,
|
||||
flowStore,
|
||||
|
||||
@@ -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<string, any> | 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}
|
||||
</div>
|
||||
|
||||
{#if deletable}
|
||||
{#if deletable && !isDragging}
|
||||
{#if maximizeSubflow !== undefined}
|
||||
{@render buttonMaximizeSubflow?.()}
|
||||
{/if}
|
||||
@@ -488,12 +527,13 @@
|
||||
>
|
||||
<button
|
||||
class={twMerge(
|
||||
'trash center-center p-1 text-secondary shadow-sm bg-surface duration-0 hover:bg-surface-tertiary',
|
||||
'trash center-center p-1 text-secondary shadow-sm bg-surface duration-0 hover:bg-surface-tertiary cursor-grab',
|
||||
hover || selected ? 'block' : '!hidden',
|
||||
'shadow-md rounded-md',
|
||||
'group-hover:block'
|
||||
'group-hover:block',
|
||||
'touch-none'
|
||||
)}
|
||||
onclick={stopPropagation(preventDefault((event) => dispatch('move')))}
|
||||
onpointerdown={stopPropagation(preventDefault(onMovePointerDown))}
|
||||
title="Move"
|
||||
>
|
||||
<Move size={12} />
|
||||
@@ -556,7 +596,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if editMode && enableTestRun && flowJob?.type !== 'QueuedJob'}
|
||||
{#if editMode && enableTestRun && flowJob?.type !== 'QueuedJob' && !isDragging}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="absolute top-1/2 -translate-y-1/2 -translate-x-[100%] -left-[0] flex items-center w-fit px-1 h-9 min-w-9"
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
import { JobService } from '$lib/gen'
|
||||
import { dfsByModule } from '../previousResults'
|
||||
import type { InlineScript, InsertKind } from '$lib/components/graph/graphBuilder.svelte'
|
||||
import { MoveManager } from '$lib/components/graph/moveManager.svelte'
|
||||
import { refreshStateStore } from '$lib/svelte5Utils.svelte'
|
||||
import type { GraphModuleState } from '$lib/components/graph'
|
||||
import FlowStickyNode from './FlowStickyNode.svelte'
|
||||
@@ -112,8 +113,10 @@
|
||||
flowHasChanged
|
||||
}: Props = $props()
|
||||
|
||||
const { customUi, selectionManager, moving, history, flowStateStore, flowStore, pathStore } =
|
||||
const { customUi, selectionManager, history, flowStateStore, flowStore, pathStore } =
|
||||
getContext<FlowEditorContext>('FlowEditorContext')
|
||||
|
||||
const moveManager = new MoveManager()
|
||||
const { triggersCount, triggersState } = getContext<TriggerContext>('TriggerContext')
|
||||
|
||||
const { flowPropPickerConfig } = getContext<PropPickerContext>('PropPickerContext')
|
||||
@@ -121,6 +124,19 @@
|
||||
// Get NoteEditor context for note position updates
|
||||
const noteEditorContext = getNoteEditorContext()
|
||||
|
||||
$effect(() => {
|
||||
if (!moveManager.movingModuleId) return
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
moveManager.clearMoving()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', onKeyDown, true)
|
||||
return () => document.removeEventListener('keydown', onKeyDown, true)
|
||||
})
|
||||
|
||||
export async function insertNewModuleAtIndex(
|
||||
modules: FlowModule[] | AgentTool[],
|
||||
index: number,
|
||||
@@ -444,7 +460,7 @@
|
||||
insertable
|
||||
scroll
|
||||
{minHeight}
|
||||
moving={$moving?.id}
|
||||
{moveManager}
|
||||
maxHeight={minHeight}
|
||||
modules={flowStore.val.value.modules}
|
||||
{noteMode}
|
||||
@@ -501,8 +517,7 @@
|
||||
}
|
||||
|
||||
dfs(flowStore.val.value.modules, (mod, modules, branches) => {
|
||||
// console.log('mod', mod.id, $moving?.id, detail, branches)
|
||||
if (mod.id == $moving?.id) {
|
||||
if (mod.id == moveManager.movingModuleId) {
|
||||
originalModules = modules
|
||||
}
|
||||
if (detail.branch) {
|
||||
@@ -517,15 +532,20 @@
|
||||
})
|
||||
if (flowStore.val.value.modules && Array.isArray(flowStore.val.value.modules)) {
|
||||
await tick()
|
||||
if ($moving) {
|
||||
if (moveManager.movingModuleId) {
|
||||
// console.log('modules', modules, movingModules, movingModule)
|
||||
push(history, flowStore.val)
|
||||
let indexToRemove = originalModules.findIndex((m) => $moving?.id == m.id)
|
||||
let indexToRemove = originalModules.findIndex((m) => moveManager.movingModuleId == m.id)
|
||||
|
||||
let [removedModule] = originalModules.splice(indexToRemove, 1)
|
||||
targetModules.splice(detail.index, 0, removedModule)
|
||||
// When moving within the same array, removal shifts subsequent indices down by 1
|
||||
let insertIndex = detail.index
|
||||
if (originalModules === targetModules && indexToRemove < detail.index) {
|
||||
insertIndex -= 1
|
||||
}
|
||||
targetModules.splice(insertIndex, 0, removedModule)
|
||||
selectionManager.selectId(removedModule.id)
|
||||
$moving = undefined
|
||||
moveManager.clearMoving()
|
||||
} else {
|
||||
if (detail.isPreprocessor) {
|
||||
await insertNewPreprocessorModule(
|
||||
@@ -656,11 +676,7 @@
|
||||
}
|
||||
}}
|
||||
onMove={(id) => {
|
||||
if (!$moving || $moving.id !== id) {
|
||||
$moving = { id }
|
||||
} else {
|
||||
$moving = undefined
|
||||
}
|
||||
moveManager.toggleMoving(id)
|
||||
}}
|
||||
onUpdateMock={(detail) => {
|
||||
let module = findModuleById(detail.id)
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
moduleAction: ModuleActionInfo | undefined
|
||||
annotation?: string | undefined
|
||||
nodeState?: FlowNodeState
|
||||
moving?: string | undefined
|
||||
duration_ms?: number | undefined
|
||||
retries?: number | undefined
|
||||
flowJobs:
|
||||
@@ -58,7 +57,6 @@
|
||||
moduleAction = undefined,
|
||||
annotation = undefined,
|
||||
nodeState,
|
||||
moving = undefined,
|
||||
duration_ms = undefined,
|
||||
retries = undefined,
|
||||
flowJobs,
|
||||
@@ -72,7 +70,7 @@
|
||||
maximizeSubflow
|
||||
}: Props = $props()
|
||||
|
||||
const { selectionManager } = getGraphContext()
|
||||
const { selectionManager, moveManager } = getGraphContext()
|
||||
|
||||
const flowEditorContext = getContext<FlowEditorContext | undefined>('FlowEditorContext')
|
||||
const { flowStore } = flowEditorContext || {}
|
||||
@@ -106,15 +104,30 @@
|
||||
onSelect(mod.id)
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether this module should be faded because it or its parent subflow is being moved/dragged */
|
||||
let isPartOfMovingSubflow = $derived(moveManager?.draggedNodeIds?.has(mod.id) ?? false)
|
||||
|
||||
let fadedClass = $derived(
|
||||
isPartOfMovingSubflow
|
||||
? moveManager?.movingModuleId
|
||||
? 'opacity-50'
|
||||
: moveManager?.dragging
|
||||
? 'opacity-30'
|
||||
: ''
|
||||
: ''
|
||||
)
|
||||
|
||||
</script>
|
||||
|
||||
{#if mod}
|
||||
<div class="relative">
|
||||
{#if moving == mod.id}
|
||||
<div class="absolute z-10 right-20 top-0.5 center-center">
|
||||
<Button variant="accent" on:click={() => dispatch('move')} size="xs" destructive
|
||||
>Cancel move</Button
|
||||
>
|
||||
{#if moveManager?.movingModuleId == mod.id}
|
||||
<div class="absolute z-10 inset-0 flex items-center justify-center">
|
||||
<Button variant="accent" on:click={() => dispatch('move')} size="xs" destructive>
|
||||
Cancel move
|
||||
<kbd class="ml-1 text-2xs opacity-60 font-mono">Esc</kbd>
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -152,7 +165,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class={moving == mod.id ? 'opacity-50' : ''}>
|
||||
<div class={fadedClass}>
|
||||
{#if mod.value.type === 'forloopflow' || mod.value.type === 'whileloopflow'}
|
||||
<FlowModuleSchemaItem
|
||||
deletable={insertable}
|
||||
|
||||
@@ -74,7 +74,6 @@ export type CurrentEditor =
|
||||
export type FlowEditorContext = {
|
||||
selectionManager: SelectionManager
|
||||
currentEditor: Writable<CurrentEditor>
|
||||
moving: Writable<{ id: string } | undefined>
|
||||
previewArgs: StateStore<Record<string, any>>
|
||||
scriptEditorDrawer: Writable<ScriptEditorDrawer | undefined>
|
||||
flowEditorDrawer: Writable<FlowEditorDrawer | undefined>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
import { useSvelteFlow, type Edge, type Node } from '@xyflow/svelte'
|
||||
import { onMount } from 'svelte'
|
||||
import { getSubflowNodeIds, type MoveManager } from './moveManager.svelte'
|
||||
import type { GraphEventHandlers } from './graphBuilder.svelte'
|
||||
import DragGhost from './DragGhost.svelte'
|
||||
|
||||
let {
|
||||
moveManager,
|
||||
eventHandlers,
|
||||
edges,
|
||||
nodes
|
||||
}: {
|
||||
moveManager: MoveManager
|
||||
eventHandlers: GraphEventHandlers
|
||||
edges: Edge[]
|
||||
nodes: Node[]
|
||||
} = $props()
|
||||
|
||||
const { screenToFlowPosition } = useSvelteFlow()
|
||||
|
||||
onMount(() => {
|
||||
moveManager.setScreenToFlowPosition(screenToFlowPosition)
|
||||
moveManager.setComputeDraggedNodeIds((moduleId) => getSubflowNodeIds(moduleId, nodes, edges))
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (!moveManager.dragging) return
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
moveManager.updateDrag(e.clientX, e.clientY)
|
||||
}
|
||||
|
||||
function onPointerUp(_e: PointerEvent) {
|
||||
const moduleId = moveManager.dragging?.moduleId
|
||||
const zone = moveManager.endDrag()
|
||||
if (zone && moduleId) {
|
||||
// Set movingModuleId directly (non-toggle) so the insert handler knows which module to relocate
|
||||
moveManager.setMoving(moduleId)
|
||||
// Then trigger the insert, which detects movingModuleId and performs the splice
|
||||
eventHandlers.insert({
|
||||
sourceId: zone.sourceId,
|
||||
targetId: zone.targetId,
|
||||
branch: zone.branch,
|
||||
index: zone.index
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
moveManager.cancelDrag()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('pointermove', onPointerMove)
|
||||
document.addEventListener('pointerup', onPointerUp)
|
||||
document.addEventListener('keydown', onKeyDown, true)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('pointermove', onPointerMove)
|
||||
document.removeEventListener('pointerup', onPointerUp)
|
||||
document.removeEventListener('keydown', onKeyDown, true)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<DragGhost {moveManager} {nodes} {edges} />
|
||||
@@ -0,0 +1,151 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte'
|
||||
import { useSvelteFlow, type Node, type Edge } from '@xyflow/svelte'
|
||||
import { getSubflowNodeIds, type MoveManager } from './moveManager.svelte'
|
||||
import { NODE } from './util'
|
||||
import MiniFlowGraph from './MiniFlowGraph.svelte'
|
||||
import { Move } from 'lucide-svelte'
|
||||
|
||||
let { moveManager, nodes, edges }: { moveManager: MoveManager; nodes: Node[]; edges: Edge[] } =
|
||||
$props()
|
||||
|
||||
const { getViewport } = useSvelteFlow()
|
||||
const PADDING = 10
|
||||
/** Approximate position of the drag handle (move button) on the node,
|
||||
* used to offset the ghost so the dragged node aligns with the cursor.
|
||||
* The handle is at the top-right of the 275px-wide node (right-4 = 16px inset). */
|
||||
const DRAG_HANDLE_OFFSET = { x: -90, y: 10 }
|
||||
/** Offset so the cursor indicator icon doesn't overlap the cursor tip */
|
||||
const CURSOR_INDICATOR_OFFSET = 8
|
||||
|
||||
function nodeOffset(n: Node): number {
|
||||
return ((n.data as Record<string, unknown>)?.offset as number) ?? 0
|
||||
}
|
||||
|
||||
function getSubflowNodesAndEdges(
|
||||
moduleId: string,
|
||||
allNodes: Node[],
|
||||
allEdges: Edge[]
|
||||
): { sfNodes: Node[]; sfEdges: Edge[] } {
|
||||
const nodeIds = getSubflowNodeIds(moduleId, allNodes, allEdges)
|
||||
const sfNodes = allNodes.filter((n) => nodeIds.has(n.id))
|
||||
const sfEdges = allEdges.filter((e) => nodeIds.has(e.source) && nodeIds.has(e.target))
|
||||
return { sfNodes, sfEdges }
|
||||
}
|
||||
|
||||
/** Resolve a node's position to absolute flow coordinates.
|
||||
* xyflow child nodes (with parentId) have positions relative to their parent. */
|
||||
function absolutePosition(n: Node, allNodes: Node[], depth = 0): { x: number; y: number } {
|
||||
if (n.parentId && depth < 20) {
|
||||
const parent = allNodes.find((p) => p.id === n.parentId)
|
||||
if (parent) {
|
||||
const parentAbs = absolutePosition(parent, allNodes, depth + 1)
|
||||
return { x: parentAbs.x + n.position.x, y: parentAbs.y + n.position.y }
|
||||
}
|
||||
}
|
||||
return { x: n.position.x, y: n.position.y }
|
||||
}
|
||||
|
||||
function computeGhost(moduleId: string, allNodes: Node[], allEdges: Edge[]) {
|
||||
const { sfNodes, sfEdges } = getSubflowNodesAndEdges(moduleId, allNodes, allEdges)
|
||||
if (sfNodes.length === 0) return undefined
|
||||
|
||||
// Compute bounding box using absolute positions
|
||||
let minX = Infinity,
|
||||
minY = Infinity,
|
||||
maxX = -Infinity,
|
||||
maxY = -Infinity
|
||||
for (const n of sfNodes) {
|
||||
const abs = absolutePosition(n, allNodes)
|
||||
const x = abs.x + nodeOffset(n)
|
||||
const y = abs.y
|
||||
const w = n.measured?.width ?? NODE.width
|
||||
const h = n.measured?.height ?? NODE.height
|
||||
minX = Math.min(minX, x)
|
||||
minY = Math.min(minY, y)
|
||||
maxX = Math.max(maxX, x + w)
|
||||
maxY = Math.max(maxY, y + h)
|
||||
}
|
||||
|
||||
const bbWidth = maxX - minX + PADDING * 2
|
||||
const bbHeight = maxY - minY + PADDING * 2
|
||||
const scale = getViewport().zoom
|
||||
const containerWidth = Math.round(bbWidth * scale)
|
||||
const containerHeight = Math.round(bbHeight * scale)
|
||||
|
||||
// Compute the offset so the dragged node's center aligns with the cursor
|
||||
const mainNode = sfNodes.find((n) => n.id === moduleId)
|
||||
let offsetX = containerWidth / 2
|
||||
let offsetY = containerHeight / 2
|
||||
if (mainNode) {
|
||||
const mainAbs = absolutePosition(mainNode, allNodes)
|
||||
const mx = mainAbs.x + nodeOffset(mainNode) - minX + PADDING
|
||||
const my = mainAbs.y - minY + PADDING
|
||||
const mw = mainNode.measured?.width ?? NODE.width
|
||||
const mh = mainNode.measured?.height ?? NODE.height
|
||||
offsetX = (mx + mw / 2) * scale
|
||||
offsetY = (my + mh / 2) * scale
|
||||
}
|
||||
|
||||
// Clone nodes/edges with interactive features disabled
|
||||
const ghostNodes = sfNodes.map((n) => ({
|
||||
...n,
|
||||
data: { ...n.data, insertable: false, editMode: false }
|
||||
}))
|
||||
const ghostEdges = sfEdges.map((e) => ({
|
||||
...e,
|
||||
data: { ...e.data, insertable: false, editMode: false }
|
||||
}))
|
||||
|
||||
// Viewport so the ghost renders at the same zoom as the main flow
|
||||
const initialViewport = {
|
||||
x: (PADDING - minX) * scale,
|
||||
y: (PADDING - minY) * scale,
|
||||
zoom: scale
|
||||
}
|
||||
|
||||
return { containerWidth, containerHeight, ghostNodes, ghostEdges, offsetX, offsetY, initialViewport }
|
||||
}
|
||||
|
||||
let isNearDrop = $derived(moveManager.nearestDropZone != null)
|
||||
|
||||
let ghost = $derived.by(() => {
|
||||
const moduleId = moveManager.dragging?.moduleId
|
||||
if (!moduleId) return undefined
|
||||
// Compute ghost once at drag start — don't react to node/edge changes during drag
|
||||
return untrack(() => computeGhost(moduleId, nodes, edges))
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if moveManager.dragging}
|
||||
<div
|
||||
class="fixed pointer-events-none z-[10001] flex items-center justify-center w-5 h-5 rounded-full shadow border border-border transition-colors duration-150 {isNearDrop
|
||||
? 'bg-surface-accent-primary text-white'
|
||||
: 'bg-surface text-secondary'}"
|
||||
style="left: {moveManager.ghostScreenX + CURSOR_INDICATOR_OFFSET}px; top: {moveManager.ghostScreenY + CURSOR_INDICATOR_OFFSET}px;"
|
||||
>
|
||||
<Move size={12} />
|
||||
</div>
|
||||
{#if ghost}
|
||||
<div
|
||||
class="fixed pointer-events-none z-[10000]"
|
||||
style="left: {moveManager.ghostScreenX +
|
||||
DRAG_HANDLE_OFFSET.x}px; top: {moveManager.ghostScreenY +
|
||||
DRAG_HANDLE_OFFSET.y}px; transform: translate({-ghost.offsetX}px, {-ghost.offsetY}px);"
|
||||
>
|
||||
<div
|
||||
style="opacity: {isNearDrop
|
||||
? 0.8
|
||||
: 0.25}; width: {ghost.containerWidth}px; height: {ghost.containerHeight}px; transition: opacity 150ms ease;"
|
||||
>
|
||||
<MiniFlowGraph
|
||||
nodes={ghost.ghostNodes}
|
||||
edges={ghost.ghostEdges}
|
||||
width={ghost.containerWidth}
|
||||
height={ghost.containerHeight}
|
||||
initialViewport={ghost.initialViewport}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -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}
|
||||
<SvelteFlowProvider>
|
||||
<ViewportResizer {height} {width} {nodes} bind:this={viewportResizer} />
|
||||
{#if moveManager}
|
||||
<DragCoordinator {moveManager} eventHandlers={eventHandler} {edges} nodes={nodesWithOffset} />
|
||||
{/if}
|
||||
{#if sharedViewport && onViewportChange}
|
||||
<ViewportSynchronizer
|
||||
{sharedViewport}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<script lang="ts">
|
||||
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 }
|
||||
</script>
|
||||
|
||||
<div style="width: {width}px; height: {height}px;">
|
||||
<SvelteFlowProvider>
|
||||
<SvelteFlow
|
||||
{nodes}
|
||||
{edges}
|
||||
{nodeTypes}
|
||||
{edgeTypes}
|
||||
fitView={!initialViewport}
|
||||
{fitViewOptions}
|
||||
{initialViewport}
|
||||
nodesDraggable={false}
|
||||
elementsSelectable={false}
|
||||
panOnDrag={false}
|
||||
zoomOnScroll={false}
|
||||
zoomOnPinch={false}
|
||||
zoomOnDoubleClick={false}
|
||||
preventScrolling={false}
|
||||
{proOptions}
|
||||
style="width: {width}px; height: {height}px; background: transparent;"
|
||||
/>
|
||||
</SvelteFlowProvider>
|
||||
</div>
|
||||
@@ -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<string, FlowModule[]>
|
||||
@@ -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,
|
||||
|
||||
@@ -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<boolean | undefined>
|
||||
showAssets: Writable<boolean | undefined>
|
||||
noteManager?: NoteManager
|
||||
moveManager?: MoveManager
|
||||
clearFlowSelection?: () => void
|
||||
yOffset?: number
|
||||
diffManager: FlowDiffManager
|
||||
|
||||
@@ -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<string> {
|
||||
const nodeIdPrefix = moduleId + '-'
|
||||
const nodeIds = new Set<string>()
|
||||
|
||||
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<DragInfo | undefined>(undefined)
|
||||
ghostScreenX = $state(0)
|
||||
ghostScreenY = $state(0)
|
||||
nearestDropZone = $state<DropZone | undefined>(undefined)
|
||||
draggedNodeIds = $state<Set<string>>(new Set())
|
||||
|
||||
/** The module ID currently being moved via legacy click-to-move */
|
||||
movingModuleId = $state<string | undefined>(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<string>) | undefined
|
||||
|
||||
setComputeDraggedNodeIds(fn: (moduleId: string) => Set<string>) {
|
||||
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<string, DropZoneRegistration>()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import InsertModulePopover from '$lib/components/flows/map/InsertModulePopover.svelte'
|
||||
import { getBezierPath, BaseEdge, type EdgeProps, EdgeLabel } from '@xyflow/svelte'
|
||||
import { ClipboardCopy, Hourglass } from 'lucide-svelte'
|
||||
import { CircleDot, Hourglass } from 'lucide-svelte'
|
||||
import type { GraphEventHandlers } from '../../graphBuilder.svelte'
|
||||
import { getStraightLinePath } from '../utils'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
@@ -13,10 +13,10 @@
|
||||
import InsertModuleButton from '$lib/components/flows/map/InsertModuleButton.svelte'
|
||||
import { getGraphContext } from '../../graphContext'
|
||||
|
||||
const { useDataflow, showAssets } = getGraphContext()
|
||||
const { useDataflow, showAssets, moveManager } = getGraphContext()
|
||||
|
||||
let {
|
||||
// id,
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourcePosition,
|
||||
@@ -32,7 +32,6 @@
|
||||
sourceId: string
|
||||
branch: { rootId: string; branch: number } | undefined
|
||||
targetId: string
|
||||
moving: string | undefined
|
||||
eventHandlers: GraphEventHandlers
|
||||
index: number
|
||||
enableTrigger: boolean
|
||||
@@ -74,16 +73,50 @@
|
||||
let suspendStatus: Record<string, { job: Job; nb: number }> | undefined = $derived(
|
||||
data?.suspendStatus
|
||||
)
|
||||
|
||||
let centerY = $derived(
|
||||
sourceY +
|
||||
32 +
|
||||
(data.shouldOffsetInsertBtnDueToAssetNode && $showAssets ? NODE_WITH_WRITE_ASSET_Y_OFFSET : 0)
|
||||
)
|
||||
|
||||
let isDragging = $derived(!!moveManager?.dragging)
|
||||
let draggedId = $derived(moveManager?.dragging?.moduleId)
|
||||
let isValidDropTarget = $derived(
|
||||
isDragging &&
|
||||
data?.insertable &&
|
||||
draggedId !== undefined &&
|
||||
!data.disableMoveIds?.includes(draggedId) &&
|
||||
data.sourceId !== draggedId &&
|
||||
data.targetId !== draggedId
|
||||
)
|
||||
let isNearestDrop = $derived(isValidDropTarget && moveManager?.nearestDropZone?.edgeId === id)
|
||||
let isAdjacentToDragged = $derived(
|
||||
isDragging && (data?.sourceId === draggedId || data?.targetId === draggedId)
|
||||
)
|
||||
|
||||
// Register this edge's drop zone position with the drag manager so proximity
|
||||
// detection uses the actual xyflow-computed position rather than re-deriving it.
|
||||
$effect(() => {
|
||||
if (!data?.insertable || !moveManager) return
|
||||
|
||||
const centerX = sourceX
|
||||
|
||||
moveManager.registerDropZone(id, {
|
||||
sourceId: data.sourceId,
|
||||
targetId: data.targetId,
|
||||
branch: data.branch,
|
||||
index: data.index,
|
||||
disableMoveIds: data.disableMoveIds ?? [],
|
||||
centerX,
|
||||
centerY
|
||||
})
|
||||
|
||||
return () => moveManager.unregisterDropZone(id)
|
||||
})
|
||||
</script>
|
||||
|
||||
<EdgeLabel
|
||||
x={sourceX}
|
||||
y={sourceY +
|
||||
32 +
|
||||
(data.shouldOffsetInsertBtnDueToAssetNode && $showAssets ? NODE_WITH_WRITE_ASSET_Y_OFFSET : 0)}
|
||||
class="base-edge"
|
||||
style=""
|
||||
>
|
||||
<EdgeLabel x={sourceX} y={centerY} class="base-edge" style="">
|
||||
{#if waitingForEvents && data.flowJob && data.flowJob.type === 'QueuedJob'}
|
||||
<div
|
||||
class="px-2 py-0.5 rounded-md bg-surface shadow-md text-violet-700 dark:text-violet-400 text-xs flex items-center gap-1"
|
||||
@@ -118,7 +151,17 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if data?.insertable && !$useDataflow && !data?.moving}
|
||||
{:else if isDragging && isValidDropTarget}
|
||||
<div class="edgeButtonContainer nodrag nopan" style:transform="translate(-50%, -50%)">
|
||||
<div class="relative flex items-center justify-center" style="width: 275px; height: 20px;">
|
||||
{#if isNearestDrop}
|
||||
<div class="absolute inset-0 rounded-md bg-accent/5 transition-opacity duration-150"
|
||||
></div>
|
||||
{/if}
|
||||
{@render dropTargetIndicator(isNearestDrop)}
|
||||
</div>
|
||||
</div>
|
||||
{:else if data?.insertable && !$useDataflow && !moveManager?.movingModuleId && !isDragging}
|
||||
<div
|
||||
class={twMerge('edgeButtonContainer nodrag nopan top-0')}
|
||||
style:transform="translate(-50%, -50%)"
|
||||
@@ -168,9 +211,9 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if data?.moving}
|
||||
{#if moveManager?.movingModuleId && data?.insertable}
|
||||
<div class="edgeButtonContainer nodrag nopan" style:transform="translate(-50%, -50%)">
|
||||
{#if data.moving && !data.disableMoveIds?.includes(data.moving)}
|
||||
{#if !data.disableMoveIds?.includes(moveManager.movingModuleId)}
|
||||
<button
|
||||
title="Paste module"
|
||||
onclick={() => {
|
||||
@@ -182,24 +225,34 @@
|
||||
})
|
||||
}}
|
||||
type="button"
|
||||
class={twMerge(
|
||||
'w-6 h-6 flex items-center justify-center',
|
||||
'border border-gray-300 dark:border-gray-500',
|
||||
'text-primary text-sm',
|
||||
'bg-surface focus:outline-none hover:bg-surface-hover focus:ring-4 focus:ring-surface-selected rounded-full '
|
||||
)}
|
||||
class="group relative flex items-center justify-center"
|
||||
style="width: 275px; height: 20px;"
|
||||
>
|
||||
<ClipboardCopy size={14} />
|
||||
<div
|
||||
class="absolute inset-0 rounded-md bg-accent/5 opacity-0 group-hover:opacity-100 transition-opacity duration-150"
|
||||
></div>
|
||||
{@render dropTargetIndicator(false)}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</EdgeLabel>
|
||||
|
||||
{#snippet dropTargetIndicator(highlighted: boolean)}
|
||||
<div
|
||||
class={twMerge(
|
||||
'w-[20px] h-[20px] flex items-center justify-center rounded-md bg-surface-secondary transition-all duration-150 group-hover:text-accent',
|
||||
highlighted ? 'text-accent' : 'text-primary'
|
||||
)}
|
||||
>
|
||||
<CircleDot size={12} />
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<BaseEdge
|
||||
path={completeEdge}
|
||||
{markerEnd}
|
||||
class={$useDataflow ? 'hidden' : ''}
|
||||
class={$useDataflow ? 'hidden' : isAdjacentToDragged ? 'opacity-30' : ''}
|
||||
interactionWidth={0}
|
||||
style={undefined}
|
||||
label={undefined}
|
||||
|
||||
@@ -277,9 +277,10 @@
|
||||
|
||||
interface Props {
|
||||
data: AiToolN['data']
|
||||
id?: string
|
||||
}
|
||||
|
||||
let { data }: Props = $props()
|
||||
let { data, id }: Props = $props()
|
||||
|
||||
const { selectionManager } = getGraphContext()
|
||||
|
||||
@@ -292,7 +293,7 @@
|
||||
)
|
||||
</script>
|
||||
|
||||
<NodeWrapper>
|
||||
<NodeWrapper nodeId={id}>
|
||||
{#snippet children({ darkMode })}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
|
||||
@@ -242,11 +242,12 @@
|
||||
|
||||
interface Props {
|
||||
data: AssetN['data']
|
||||
id?: string
|
||||
}
|
||||
|
||||
const flowGraphAssetsCtx = getContext<any | undefined>('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 @@
|
||||
)
|
||||
</script>
|
||||
|
||||
<NodeWrapper wrapperClass="bg-surface-secondary rounded-md">
|
||||
<NodeWrapper wrapperClass="bg-surface-secondary rounded-md" nodeId={id}>
|
||||
{#snippet children({ darkMode })}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<Tooltip customBgClass="bg-surface-tertiary">
|
||||
|
||||
@@ -13,8 +13,9 @@
|
||||
|
||||
interface Props {
|
||||
data: AssetsOverflowedN['data']
|
||||
id?: string
|
||||
}
|
||||
let { data }: Props = $props()
|
||||
let { data, id }: Props = $props()
|
||||
const flowGraphAssetsCtx = getContext<FlowGraphAssetContext | undefined>('FlowGraphAssetContext')
|
||||
|
||||
let isOpen = $state(false)
|
||||
@@ -37,7 +38,7 @@
|
||||
const colors = $derived(getNodeColorClasses(undefined, includesSelected))
|
||||
</script>
|
||||
|
||||
<NodeWrapper>
|
||||
<NodeWrapper nodeId={id}>
|
||||
{#snippet children({ darkMode })}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<Popover
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
const { selectionManager } = getGraphContext()
|
||||
</script>
|
||||
|
||||
<NodeWrapper offset={data.offset} enableSourceHandle enableTargetHandle>
|
||||
<NodeWrapper offset={data.offset} enableSourceHandle enableTargetHandle nodeId={id}>
|
||||
{#snippet children({ darkMode })}
|
||||
<VirtualItem
|
||||
label={'Collect result from all branches'}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
const { selectionManager } = getGraphContext()
|
||||
</script>
|
||||
|
||||
<NodeWrapper offset={data.offset}>
|
||||
<NodeWrapper offset={data.offset} nodeId={id}>
|
||||
{#snippet children({ darkMode })}
|
||||
<VirtualItem
|
||||
label={data.label}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
let { data, id }: Props = $props()
|
||||
</script>
|
||||
|
||||
<NodeWrapper offset={data.offset}>
|
||||
<NodeWrapper offset={data.offset} nodeId={id}>
|
||||
{#snippet children({ darkMode })}
|
||||
<VirtualItem
|
||||
label={data.label}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
const { selectionManager } = getGraphContext()
|
||||
</script>
|
||||
|
||||
<NodeWrapper offset={data.offset}>
|
||||
<NodeWrapper offset={data.offset} nodeId={id}>
|
||||
{#snippet children({ darkMode })}
|
||||
{#if data.simplifiedTriggerView}
|
||||
<VirtualItem
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
let filteredInput = $derived(filterIterFromInput($pickablePropertiesFiltered?.flow_input))
|
||||
</script>
|
||||
|
||||
<NodeWrapper offset={data.offset}>
|
||||
<NodeWrapper offset={data.offset} nodeId={id}>
|
||||
{#snippet children({ darkMode })}
|
||||
<VirtualItem
|
||||
label={data.simplifiedTriggerView ? 'For each new event' : 'Do one iteration'}
|
||||
|
||||
@@ -66,7 +66,6 @@
|
||||
(state?.iteration_total ?? '?')
|
||||
: ''}
|
||||
nodeState={state?.skipped ? '_Skipped' : type}
|
||||
moving={data.moving}
|
||||
duration_ms={state?.duration_ms}
|
||||
retries={state?.retries}
|
||||
{flowJobs}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { Plus } from 'lucide-svelte'
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import { getGraphContext } from '../../graphContext'
|
||||
|
||||
let funcDesc = $state('')
|
||||
interface Props {
|
||||
@@ -11,9 +12,13 @@
|
||||
}
|
||||
let { data }: Props = $props()
|
||||
|
||||
const { moveManager } = getGraphContext()
|
||||
let isMoving = $derived(!!moveManager?.dragging || !!moveManager?.movingModuleId)
|
||||
|
||||
let open = $state(false)
|
||||
</script>
|
||||
|
||||
{#if !isMoving}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<Popover
|
||||
bind:isOpen={open}
|
||||
@@ -110,3 +115,4 @@
|
||||
/>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
|
||||
@@ -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()
|
||||
</script>
|
||||
|
||||
<NodeWrapper offset={data.offset} enableSourceHandle enableTargetHandle>
|
||||
<NodeWrapper offset={data.offset} enableSourceHandle enableTargetHandle nodeId={id}>
|
||||
{#snippet children({ darkMode })}
|
||||
<VirtualItem
|
||||
label={data.label ?? 'No branches'}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Handle, Position } from '@xyflow/svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import ContextMenu, { type ContextMenuItem } from '../../../common/contextmenu/ContextMenu.svelte'
|
||||
import { getGraphContext } from '../../graphContext'
|
||||
|
||||
interface Props {
|
||||
enableSourceHandle?: boolean
|
||||
@@ -10,6 +11,8 @@
|
||||
offset?: number
|
||||
wrapperClass?: string
|
||||
contextMenuItems?: ContextMenuItem[]
|
||||
/** xyflow node ID — used to fade nodes that are part of a moving subflow */
|
||||
nodeId?: string
|
||||
children?: import('svelte').Snippet<[any]>
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
</script>
|
||||
|
||||
@@ -29,14 +39,14 @@
|
||||
|
||||
{#if contextMenuItems && contextMenuItems.length > 0}
|
||||
<ContextMenu items={contextMenuItems}>
|
||||
<div class={twMerge('relative rounded-md', wrapperClass)} style={`margin-left: ${offset}px;`}>
|
||||
<div class={twMerge('relative rounded-md', faded ? 'opacity-30' : '', wrapperClass)} style={`margin-left: ${offset}px;`}>
|
||||
{@render children?.({ darkMode })}
|
||||
</div>
|
||||
|
||||
{@render handles()}
|
||||
</ContextMenu>
|
||||
{:else}
|
||||
<div class={twMerge('relative rounded-md', wrapperClass)} style={`margin-left: ${offset}px;`}>
|
||||
<div class={twMerge('relative rounded-md', faded ? 'opacity-30' : '', wrapperClass)} style={`margin-left: ${offset}px;`}>
|
||||
{@render children?.({ darkMode })}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
let { data, id }: Props = $props()
|
||||
</script>
|
||||
|
||||
<NodeWrapper offset={data.offset}>
|
||||
<NodeWrapper offset={data.offset} nodeId={id}>
|
||||
{#snippet children({ darkMode })}
|
||||
<VirtualItem
|
||||
label={'Collect result from chosen branch'}
|
||||
|
||||
@@ -74,7 +74,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()
|
||||
@@ -93,7 +92,6 @@
|
||||
previewArgs: previewArgsStore,
|
||||
scriptEditorDrawer,
|
||||
flowEditorDrawer: writable(undefined),
|
||||
moving,
|
||||
history,
|
||||
pathStore: writable(''),
|
||||
flowStateStore,
|
||||
|
||||
Reference in New Issue
Block a user