mirror of
https://github.com/immich-app/immich.git
synced 2026-05-21 07:06:31 -04:00
drag and drop
This commit is contained in:
@@ -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.",
|
||||
|
||||
@@ -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<EditMode>('visual');
|
||||
let draggedIndex = $state<number | null>(null);
|
||||
let dragHandleHoverIndex = $state<number | null>(null);
|
||||
let dragImageElement = $state<HTMLElement | null>(null);
|
||||
let dragImage = $state<StepDragImage>({ isFilter: false, label: '', stepNumber: 1 });
|
||||
let dropTargetIndex = $state<number | null>(null);
|
||||
|
||||
const workflowSummary = $derived({ name, description, trigger, steps });
|
||||
const workflowJsonContent = $derived<WorkflowJsonContent>({ 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()}
|
||||
<Card class="{isFilter ? '' : ''} shadow-none">
|
||||
<CardHeader>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex size-10 shrink-0 items-center justify-center rounded-lg"
|
||||
class:bg-primary-50={isFilter}
|
||||
class:bg-warning-50={!isFilter}
|
||||
>
|
||||
<Icon
|
||||
icon={isFilter ? mdiFilterVariant : mdiAutoFix}
|
||||
size="20"
|
||||
class={isFilter ? 'text-primary' : 'text-warning'}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<CardTitle class="truncate">
|
||||
<span class="mr-1 font-bold text-light-500">{index + 1}</span>
|
||||
{pluginManager.getMethodLabel(step.method)}
|
||||
</CardTitle>
|
||||
{#if method?.description}
|
||||
<CardDescription class="truncate">{method.description}</CardDescription>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<IconButton
|
||||
icon={mdiPencilOutline}
|
||||
aria-label={$t('edit')}
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
color="secondary"
|
||||
size="small"
|
||||
onclick={() => handleEditStep(index)}
|
||||
/>
|
||||
<IconButton
|
||||
icon={mdiTrashCanOutline}
|
||||
aria-label={$t('delete')}
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
color="danger"
|
||||
size="small"
|
||||
onclick={() => handleDeleteStep(index)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{#if configEntries.length > 0}
|
||||
<CardBody class="py-3">
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
{#each configEntries as [key, value] (key)}
|
||||
<Badge
|
||||
color={isFilter ? 'info' : 'warning'}
|
||||
<div
|
||||
class="w-full transition-all"
|
||||
class:opacity-40={isDragging}
|
||||
class:scale-[0.99]={isDragging}
|
||||
ondragover={handleDragOver(index)}
|
||||
ondragleave={handleDragLeave(index)}
|
||||
ondrop={handleDrop(index)}
|
||||
role="listitem"
|
||||
>
|
||||
<Card
|
||||
class="shadow-none transition-colors {isDropTarget
|
||||
? 'border-primary ring-2 ring-primary-200'
|
||||
: isDragHandleHovered
|
||||
? 'border-dashed border-primary'
|
||||
: ''}"
|
||||
>
|
||||
<CardHeader>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
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={() => (dragHandleHoverIndex = index)}
|
||||
onmouseleave={() => (dragHandleHoverIndex = null)}
|
||||
ondragstart={handleDragStart(index)}
|
||||
ondragend={handleDragEnd}
|
||||
title={$t('drag_to_reorder')}
|
||||
>
|
||||
<Icon icon={mdiDragVertical} size="20" />
|
||||
</div>
|
||||
<div
|
||||
class="flex size-10 shrink-0 items-center justify-center rounded-lg"
|
||||
class:bg-primary-50={isFilter}
|
||||
class:bg-warning-50={!isFilter}
|
||||
>
|
||||
<Icon
|
||||
icon={isFilter ? mdiFilterVariant : mdiAutoFix}
|
||||
size="20"
|
||||
class={isFilter ? 'text-primary' : 'text-warning'}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<CardTitle class="truncate">
|
||||
<span class="mr-1 font-bold text-light-500">{index + 1}</span>
|
||||
{pluginManager.getMethodLabel(step.method)}
|
||||
</CardTitle>
|
||||
{#if method?.description}
|
||||
<CardDescription class="truncate">{method.description}</CardDescription>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<IconButton
|
||||
icon={mdiPencilOutline}
|
||||
aria-label={$t('edit')}
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
color="secondary"
|
||||
size="small"
|
||||
class="border font-mono {isFilter ? 'border-primary-200' : 'border-warning-200'}"
|
||||
>
|
||||
<span class="opacity-60">{key}</span>{formatConfigValue(value)}
|
||||
</Badge>
|
||||
{/each}
|
||||
onclick={() => handleEditStep(index)}
|
||||
/>
|
||||
<IconButton
|
||||
icon={mdiTrashCanOutline}
|
||||
aria-label={$t('delete')}
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
color="danger"
|
||||
size="small"
|
||||
onclick={() => handleDeleteStep(index)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
{/if}
|
||||
</Card>
|
||||
</CardHeader>
|
||||
|
||||
{#if configEntries.length > 0}
|
||||
<CardBody class="py-3">
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
{#each configEntries as [key, value] (key)}
|
||||
<Badge
|
||||
color={isFilter ? 'info' : 'warning'}
|
||||
shape="round"
|
||||
size="small"
|
||||
class="border font-mono {isFilter ? 'border-primary-200' : 'border-warning-200'}"
|
||||
>
|
||||
<span class="opacity-60">{key}</span>{formatConfigValue(value)}
|
||||
</Badge>
|
||||
{/each}
|
||||
</div>
|
||||
</CardBody>
|
||||
{/if}
|
||||
</Card>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<Button
|
||||
@@ -446,5 +552,12 @@
|
||||
</VStack>
|
||||
</Container>
|
||||
|
||||
<WorkflowStepDragImage
|
||||
bind:ref={dragImageElement}
|
||||
description={dragImage.description}
|
||||
isFilter={dragImage.isFilter}
|
||||
label={dragImage.label}
|
||||
stepNumber={dragImage.stepNumber}
|
||||
/>
|
||||
<WorkflowSummary workflow={workflowSummary} />
|
||||
</AppShell>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import { Icon } from '@immich/ui';
|
||||
import { mdiAutoFix, mdiFilterVariant } from '@mdi/js';
|
||||
|
||||
type Props = {
|
||||
ref?: HTMLElement | null;
|
||||
description?: string;
|
||||
isFilter: boolean;
|
||||
label: string;
|
||||
stepNumber: number;
|
||||
};
|
||||
|
||||
let { ref = $bindable(null), description, isFilter, label, stepNumber }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none fixed top-[-1000px] left-0 flex w-80 items-center gap-2.5 rounded-lg border border-light-200 bg-light px-3 py-2.5 text-sm/5 text-dark shadow-2xl"
|
||||
>
|
||||
<div
|
||||
class="flex size-8 shrink-0 items-center justify-center rounded-lg"
|
||||
class:bg-primary-50={isFilter}
|
||||
class:bg-warning-50={!isFilter}
|
||||
>
|
||||
<Icon
|
||||
icon={isFilter ? mdiFilterVariant : mdiAutoFix}
|
||||
size="18"
|
||||
class={isFilter ? 'text-primary' : 'text-warning'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="shrink-0 font-bold text-light-500">#{stepNumber}</span>
|
||||
<span class="truncate font-bold">{label}</span>
|
||||
</div>
|
||||
|
||||
{#if description}
|
||||
<div class="mt-0.5 truncate text-xs/4 text-light-500">{description}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user