From 9ea4a03f21b415fa9cf4ec2f2e87a59390027d67 Mon Sep 17 00:00:00 2001 From: Alex Tran Date: Wed, 20 May 2026 14:57:34 -0500 Subject: [PATCH] drag and drop --- i18n/en.json | 1 + .../workflows/[workflowId]/+page.svelte | 233 +++++++++++++----- .../[workflowId]/WorkflowStepDragImage.svelte | 43 ++++ 3 files changed, 217 insertions(+), 60 deletions(-) create mode 100644 web/src/routes/(user)/workflows/[workflowId]/WorkflowStepDragImage.svelte diff --git a/i18n/en.json b/i18n/en.json index 054408dff9..05fb92fc8c 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -976,6 +976,7 @@ "downloading_asset_filename": "Downloading asset {filename}", "downloading_from_icloud": "Downloading from iCloud", "downloading_media": "Downloading media", + "drag_to_reorder": "Drag to reorder", "drop_files_to_upload": "Drop files anywhere to upload", "duplicates": "Duplicates", "duplicates_description": "Resolve each group by indicating which, if any, are duplicates.", diff --git a/web/src/routes/(user)/workflows/[workflowId]/+page.svelte b/web/src/routes/(user)/workflows/[workflowId]/+page.svelte index 227ec46d49..e80c564f5a 100644 --- a/web/src/routes/(user)/workflows/[workflowId]/+page.svelte +++ b/web/src/routes/(user)/workflows/[workflowId]/+page.svelte @@ -39,6 +39,7 @@ mdiAutoFix, mdiCodeJson, mdiContentSave, + mdiDragVertical, mdiFilterVariant, mdiFlashOutline, mdiFormatListBulletedSquare, @@ -48,9 +49,11 @@ mdiTrashCanOutline, } from '@mdi/js'; import { cloneDeep, isEqual } from 'lodash-es'; + import { flushSync } from 'svelte'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; import WorkflowJsonEditor from './WorkflowJsonEditor.svelte'; + import WorkflowStepDragImage from './WorkflowStepDragImage.svelte'; import WorkflowSummary from './WorkflowSummary.svelte'; type WorkflowJsonContent = Required< @@ -58,6 +61,12 @@ >; type EditMode = 'visual' | 'json'; + type StepDragImage = { + description?: string; + isFilter: boolean; + label: string; + stepNumber: number; + }; type Props = { data: PageData; @@ -71,6 +80,11 @@ let isShowingNavigationDialog = $state(false); let isSaving = $state(false); let editMode = $state('visual'); + let draggedIndex = $state(null); + let dragHandleHoverIndex = $state(null); + let dragImageElement = $state(null); + let dragImage = $state({ isFilter: false, label: '', stepNumber: 1 }); + let dropTargetIndex = $state(null); const workflowSummary = $derived({ name, description, trigger, steps }); const workflowJsonContent = $derived({ description, enabled, name, steps, trigger }); @@ -112,6 +126,66 @@ } }; + const handleDragStart = (index: number) => (event: DragEvent) => { + draggedIndex = index; + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('text/plain', String(index)); + + const step = steps[index]; + const method = step ? pluginManager.getMethod(step.method) : undefined; + dragImage = { + description: method?.description, + isFilter: method?.uiHints?.includes('filter') ?? false, + label: step ? pluginManager.getMethodLabel(step.method) : '', + stepNumber: index + 1, + }; + flushSync(); + + if (dragImageElement) { + event.dataTransfer.setDragImage(dragImageElement, 16, 22); + } + } + }; + + const handleDragOver = (index: number) => (event: DragEvent) => { + if (draggedIndex === null) { + return; + } + event.preventDefault(); + if (event.dataTransfer) { + event.dataTransfer.dropEffect = 'move'; + } + if (dropTargetIndex !== index) { + dropTargetIndex = index; + } + }; + + const handleDragLeave = (index: number) => () => { + if (dropTargetIndex === index) { + dropTargetIndex = null; + } + }; + + const handleDrop = (index: number) => (event: DragEvent) => { + event.preventDefault(); + const from = draggedIndex; + draggedIndex = null; + dropTargetIndex = null; + if (from === null || from === index) { + return; + } + const next = [...steps]; + [next[from], next[index]] = [next[index], next[from]]; + steps = next; + }; + + const handleDragEnd = () => { + draggedIndex = null; + dragHandleHoverIndex = null; + dropTargetIndex = null; + }; + const handleDeleteStep = async (index: number) => { const confirmed = await modalManager.showDialog({ title: $t('step_delete'), prompt: $t('step_delete_confirm') }); if (confirmed) { @@ -364,70 +438,102 @@ {#each stepsWithConfigEntries as { step, configEntries }, index (index)} {@const method = pluginManager.getMethod(step.method)} {@const isFilter = method?.uiHints?.includes('filter') ?? false} + {@const isDragging = draggedIndex === index} + {@const isDragHandleHovered = dragHandleHoverIndex === index} + {@const isDropTarget = dropTargetIndex === index && draggedIndex !== null && draggedIndex !== index} {@render sequenceConnector()} - - -
-
- -
-
- - {index + 1} - {pluginManager.getMethodLabel(step.method)} - - {#if method?.description} - {method.description} - {/if} -
-
- handleEditStep(index)} - /> - handleDeleteStep(index)} - /> -
-
-
- - {#if configEntries.length > 0} - -
- {#each configEntries as [key, value] (key)} - + + +
+ +
(dragHandleHoverIndex = index)} + onmouseleave={() => (dragHandleHoverIndex = null)} + ondragstart={handleDragStart(index)} + ondragend={handleDragEnd} + title={$t('drag_to_reorder')} + > + +
+
+ +
+
+ + {index + 1} + {pluginManager.getMethodLabel(step.method)} + + {#if method?.description} + {method.description} + {/if} +
+
+ - {key}{formatConfigValue(value)} - - {/each} + onclick={() => handleEditStep(index)} + /> + handleDeleteStep(index)} + /> +
- - {/if} -
+ + + {#if configEntries.length > 0} + +
+ {#each configEntries as [key, value] (key)} + + {key}{formatConfigValue(value)} + + {/each} +
+
+ {/if} + +
{/each}