feat: workflows drag and drop enhancements

feat/workflows-drag-and-drop-enhancements
Daniel Dietzler 2026-05-29 20:58:41 +02:00
parent 84c75d95c7
commit 9771f56a8b
No known key found for this signature in database
GPG Key ID: A1C0B97CD8E18DFF
2 changed files with 78 additions and 45 deletions

View File

@ -6,6 +6,7 @@
import WorkflowTriggerPicker from '$lib/modals/WorkflowTriggerPicker.svelte';
import { Route } from '$lib/route';
import { getWorkflowActions, handleUpdateWorkflow } from '$lib/services/workflow.service';
import { generateId } from '$lib/utils/generate-id';
import { getTriggerDescription, getTriggerName } from '$lib/utils/workflow';
import type { WorkflowResponseDto, WorkflowUpdateDto } from '@immich/sdk';
import {
@ -44,6 +45,7 @@
} from '@mdi/js';
import { cloneDeep, isEqual } from 'lodash-es';
import { t } from 'svelte-i18n';
import { flip } from 'svelte/animate';
import type { PageData } from './$types';
import WorkflowJsonEditor from './WorkflowJsonEditor.svelte';
import WorkflowStepCard from './WorkflowStepCard.svelte';
@ -62,12 +64,13 @@
let { data }: Props = $props();
let { id, enabled, name, description, trigger } = $derived(data.workflow);
let steps = $state(data.workflow.steps);
let steps = $state(data.workflow.steps.map((step) => ({ ...step, id: generateId() })));
let savedWorkflow = $state(cloneDeep(data.workflow));
let allowNavigation = $state(false);
let isShowingNavigationDialog = $state(false);
let isSaving = $state(false);
let editMode = $state<EditMode>('visual');
let dragSourceId: string | undefined;
const workflowSummary = $derived({ name, description, trigger, steps });
const workflowJsonContent = $derived<WorkflowJsonContent>({ name, description, enabled, trigger, steps });
@ -77,20 +80,23 @@
name !== savedWorkflow.name ||
description !== savedWorkflow.description ||
!isEqual(trigger, savedWorkflow.trigger) ||
!isEqual(steps, savedWorkflow.steps),
!isEqual(
steps.map(({ id: _, ...step }) => step),
savedWorkflow.steps,
),
);
const handleAddStep = async () => {
const step = await modalManager.show(WorkflowAddStepModal, { trigger });
if (step) {
steps.push(step);
steps.push({ ...step, id: generateId() });
}
};
const handleInsertStep = async (index: number) => {
const step = await modalManager.show(WorkflowAddStepModal, { trigger });
if (step) {
steps = [...steps.slice(0, index), step, ...steps.slice(index)];
steps = [...steps.slice(0, index), { ...step, id: generateId() }, ...steps.slice(index)];
}
};
@ -102,20 +108,53 @@
const result = await modalManager.show(WorkflowEditStepModal, { trigger, step: cloneDeep(step) });
if (result) {
steps[index] = result;
steps[index] = { ...result, id: generateId() };
}
};
const handleDrop = (index: number, event: DragEvent) => {
if (!event.dataTransfer) {
const handleDragEnd = (event: DragEvent) => {
if (!event.dataTransfer || !dragSourceId) {
return;
}
const from = Number(event.dataTransfer.getData('text/plain'));
const ghostIndex = steps.findIndex(({ id }) => id === 'ghost');
if (ghostIndex === -1) {
return;
}
const from = steps.findIndex(({ id }) => id === dragSourceId);
const next = [...steps];
const [moved] = next.splice(from, 1);
next.splice(index, 0, moved);
const [step] = next.splice(from, 1);
next[ghostIndex > from ? ghostIndex - 1 : ghostIndex] = step;
steps = next;
dragSourceId = undefined;
};
const handleDragOver = (index: number, event: DragEvent, boundingRect: DOMRect) => {
if (!event.dataTransfer || !dragSourceId) {
return;
}
const fromIndex = steps.findIndex(({ id }) => dragSourceId === id);
const ghostIndex = steps.findIndex(({ id }) => id === 'ghost');
const shiftedIndex = event.clientY > boundingRect.top + boundingRect.height / 2 ? index + 1 : index;
if (index === fromIndex || shiftedIndex === fromIndex) {
if (ghostIndex !== -1) {
steps.splice(ghostIndex, 1);
}
return;
}
if (
(ghostIndex !== -1 && Math.abs(shiftedIndex - ghostIndex) <= (ghostIndex > shiftedIndex ? 0 : 1)) ||
Math.abs(shiftedIndex - fromIndex) <= (fromIndex > shiftedIndex ? 0 : 1)
) {
return;
}
const next = steps.filter(({ id }) => id !== 'ghost');
next.splice(shiftedIndex, 0, { ...steps[fromIndex], id: 'ghost' });
steps = next;
};
@ -131,7 +170,7 @@
name = content.name;
description = content.description;
trigger = content.trigger;
steps = cloneDeep(content.steps);
steps = cloneDeep(content.steps).map((step) => ({ ...step, id: generateId() }));
};
const onClose = () => goto(Route.workflows());
@ -344,15 +383,19 @@
</CardHeader>
</Card>
{#each steps as step, index (step.method + index)}
<WorkflowStepCard
{step}
{index}
onEdit={handleEditStep}
onDelete={handleDeleteStep}
onInsertBefore={handleInsertStep}
onDrop={handleDrop}
/>
{#each steps as step, index (step.id)}
<div class="w-full" animate:flip={{ duration: 120 }}>
<WorkflowStepCard
{step}
{index}
onEdit={handleEditStep}
onDelete={handleDeleteStep}
onInsertBefore={handleInsertStep}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
onDragStart={(event) => (dragSourceId = event.dataTransfer?.getData('text/plain'))}
/>
</div>
{/each}
<Button

View File

@ -4,7 +4,6 @@
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const albumNameCache = new Map<string, Promise<string>>();
const getAlbumName = (id: string): Promise<string> => {
let albumName = albumNameCache.get(id);
if (!albumName) {
@ -20,7 +19,7 @@
<script lang="ts">
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
import type { JSONSchemaProperty } from '$lib/types';
import type { WorkflowStepDto } from '@immich/sdk';
import { type WorkflowStepDto } from '@immich/sdk';
import { Badge, Card, CardBody, CardDescription, CardHeader, CardTitle, Icon, IconButton } from '@immich/ui';
import {
mdiAutoFix,
@ -35,15 +34,17 @@
import WorkflowStepDragImage from './WorkflowStepDragImage.svelte';
type Props = {
step: WorkflowStepDto;
step: WorkflowStepDto & { id: string };
index: number;
onEdit: (index: number) => void;
onDelete: (index: number) => void;
onInsertBefore: (index: number) => void;
onDrop: (index: number, event: DragEvent) => void;
onDragOver: (index: number, event: DragEvent, boundingRect: DOMRect) => void;
onDragEnd: (event: DragEvent) => void;
onDragStart: (event: DragEvent) => void;
};
let { step, index, onEdit, onDelete, onInsertBefore, onDrop }: Props = $props();
let { step, index, onEdit, onDelete, onInsertBefore, onDragOver, onDragEnd, onDragStart }: Props = $props();
const method = $derived(pluginManager.getMethod(step.method));
const isFilter = $derived(method?.uiHints?.includes('Filter') ?? false);
@ -51,12 +52,12 @@
const configEntries = $derived(
Object.entries(step.config ?? {}).filter(([, value]) => value !== null && value !== undefined && value !== ''),
);
const isGhost = $derived(step.id === 'ghost');
const getUiHint = (key: string) => schema?.properties?.[key]?.uiHint;
const toIds = (value: unknown): string[] => (Array.isArray(value) ? value.map(String) : [String(value)]);
let dragImage = $state<Element>();
let isDropTarget = $state(false);
let hoverDrag = $state(false);
const truncate = (input: string, max = 24) => (input.length > max ? input.slice(0, max - 1) + '…' : input);
@ -93,7 +94,7 @@
}
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', String(index));
event.dataTransfer.setData('text/plain', step.id);
mount(WorkflowStepDragImage, {
target: document.body,
@ -107,31 +108,23 @@
dragImage = document.body.querySelector('#workflow-step-drag-image')!;
event.dataTransfer.setDragImage(dragImage, 16, 22);
onDragStart(event);
};
const handleDrop = (index: number, event: DragEvent) => {
if (!event.dataTransfer) {
const handleDragOver = (event: DragEvent & { currentTarget: HTMLElement }) => {
if (isGhost) {
return;
}
event.preventDefault();
const from = Number(event.dataTransfer.getData('text/plain'));
if (from === index) {
return;
}
onDrop(index, event);
};
const handleDragOver = (event: DragEvent) => {
event.preventDefault();
isDropTarget = true;
onDragOver(index, event, event.currentTarget.getBoundingClientRect());
};
const handleDragEnd = () => {
const handleDragEnd = (event: DragEvent) => {
dragImage?.remove();
dragImage = undefined;
isDropTarget = false;
onDragEnd(event);
};
</script>
@ -157,13 +150,12 @@
class:scale-[0.99]={!!dragImage}
ondragover={handleDragOver}
ondragleave={() => (isDropTarget = false)}
ondrop={(event) => handleDrop(index, event)}
role="listitem"
>
<Card
class="shadow-none transition-colors {isDropTarget
? 'border-primary ring-2 ring-primary-200'
: hoverDrag
: isGhost
? 'border-dashed border-primary'
: ''}"
>
@ -174,8 +166,6 @@
class="flex shrink-0 cursor-grab items-center justify-center rounded-md border border-transparent p-1 text-light-400 select-none hover:border-primary-200 hover:bg-primary-50 hover:text-primary active:cursor-grabbing"
aria-label={$t('drag_to_reorder')}
draggable="true"
onmouseenter={() => (hoverDrag = true)}
onmouseleave={() => (hoverDrag = false)}
ondragstart={(event) => handleDragStart(index, event)}
ondragend={handleDragEnd}
title={$t('drag_to_reorder')}