mirror of
https://github.com/immich-app/immich.git
synced 2026-05-29 11:02:38 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b31ca6e75 |
@@ -0,0 +1,65 @@
|
|||||||
|
import type { Attachment } from 'svelte/attachments';
|
||||||
|
|
||||||
|
const EDGE_ZONE = 72;
|
||||||
|
const MAX_SCROLL_SPEED = 22;
|
||||||
|
|
||||||
|
const findScrollContainer = (element: HTMLElement): HTMLElement | null => {
|
||||||
|
let node = element.parentElement;
|
||||||
|
while (node) {
|
||||||
|
const overflowY = getComputedStyle(node).overflowY;
|
||||||
|
if (/(auto|scroll|overlay)/.test(overflowY) && node.scrollHeight > node.clientHeight) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
node = node.parentElement;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function dragAutoScroll(isActive: () => boolean): Attachment {
|
||||||
|
return (node) => {
|
||||||
|
const element = node as HTMLElement;
|
||||||
|
let scrollContainer: HTMLElement | null = null;
|
||||||
|
let pointerY = -1;
|
||||||
|
let frame: number | null = null;
|
||||||
|
|
||||||
|
const trackPointer = (event: DragEvent) => {
|
||||||
|
pointerY = event.clientY;
|
||||||
|
};
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
if (scrollContainer && pointerY >= 0) {
|
||||||
|
const { top, bottom } = scrollContainer.getBoundingClientRect();
|
||||||
|
let delta = 0;
|
||||||
|
if (pointerY < top + EDGE_ZONE) {
|
||||||
|
delta = -MAX_SCROLL_SPEED * Math.min(1, (top + EDGE_ZONE - pointerY) / EDGE_ZONE);
|
||||||
|
} else if (pointerY > bottom - EDGE_ZONE) {
|
||||||
|
delta = MAX_SCROLL_SPEED * Math.min(1, (pointerY - (bottom - EDGE_ZONE)) / EDGE_ZONE);
|
||||||
|
}
|
||||||
|
if (delta !== 0) {
|
||||||
|
scrollContainer.scrollBy(0, delta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
frame = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!isActive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollContainer = findScrollContainer(element);
|
||||||
|
pointerY = -1;
|
||||||
|
globalThis.addEventListener('dragover', trackPointer);
|
||||||
|
frame = requestAnimationFrame(tick);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
globalThis.removeEventListener('dragover', trackPointer);
|
||||||
|
if (frame !== null) {
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
frame = null;
|
||||||
|
}
|
||||||
|
scrollContainer = null;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { beforeNavigate, goto, invalidate } from '$app/navigation';
|
import { beforeNavigate, goto, invalidate } from '$app/navigation';
|
||||||
|
import { dragAutoScroll } from '$lib/attachments/drag-auto-scroll.svelte';
|
||||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||||
import WorkflowAddStepModal from '$lib/modals/WorkflowAddStepModal.svelte';
|
import WorkflowAddStepModal from '$lib/modals/WorkflowAddStepModal.svelte';
|
||||||
import WorkflowEditStepModal from '$lib/modals/WorkflowEditStepModal.svelte';
|
import WorkflowEditStepModal from '$lib/modals/WorkflowEditStepModal.svelte';
|
||||||
@@ -7,7 +8,7 @@
|
|||||||
import { Route } from '$lib/route';
|
import { Route } from '$lib/route';
|
||||||
import { getWorkflowActions, handleUpdateWorkflow } from '$lib/services/workflow.service';
|
import { getWorkflowActions, handleUpdateWorkflow } from '$lib/services/workflow.service';
|
||||||
import { getTriggerDescription, getTriggerName } from '$lib/utils/workflow';
|
import { getTriggerDescription, getTriggerName } from '$lib/utils/workflow';
|
||||||
import type { WorkflowResponseDto, WorkflowUpdateDto } from '@immich/sdk';
|
import type { WorkflowResponseDto, WorkflowStepDto, WorkflowUpdateDto } from '@immich/sdk';
|
||||||
import {
|
import {
|
||||||
ActionBar,
|
ActionBar,
|
||||||
AppShell,
|
AppShell,
|
||||||
@@ -43,7 +44,10 @@
|
|||||||
mdiPlus,
|
mdiPlus,
|
||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
import { cloneDeep, isEqual } from 'lodash-es';
|
import { cloneDeep, isEqual } from 'lodash-es';
|
||||||
|
import { flip } from 'svelte/animate';
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
import { createListReorder, GHOST_KEY, type ReorderEntry } from './list-reorder.svelte';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import WorkflowJsonEditor from './WorkflowJsonEditor.svelte';
|
import WorkflowJsonEditor from './WorkflowJsonEditor.svelte';
|
||||||
import WorkflowStepCard from './WorkflowStepCard.svelte';
|
import WorkflowStepCard from './WorkflowStepCard.svelte';
|
||||||
@@ -69,6 +73,11 @@
|
|||||||
let isSaving = $state(false);
|
let isSaving = $state(false);
|
||||||
let editMode = $state<EditMode>('visual');
|
let editMode = $state<EditMode>('visual');
|
||||||
|
|
||||||
|
const reorder = createListReorder(
|
||||||
|
() => steps,
|
||||||
|
(next) => (steps = next),
|
||||||
|
);
|
||||||
|
|
||||||
const workflowSummary = $derived({ name, description, trigger, steps });
|
const workflowSummary = $derived({ name, description, trigger, steps });
|
||||||
const workflowJsonContent = $derived<WorkflowJsonContent>({ name, description, enabled, trigger, steps });
|
const workflowJsonContent = $derived<WorkflowJsonContent>({ name, description, enabled, trigger, steps });
|
||||||
|
|
||||||
@@ -106,19 +115,6 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDrop = (index: number, event: DragEvent) => {
|
|
||||||
if (!event.dataTransfer) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const from = Number(event.dataTransfer.getData('text/plain'));
|
|
||||||
|
|
||||||
const next = [...steps];
|
|
||||||
const [moved] = next.splice(from, 1);
|
|
||||||
next.splice(index, 0, moved);
|
|
||||||
steps = next;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteStep = async (index: number) => {
|
const handleDeleteStep = async (index: number) => {
|
||||||
const confirmed = await modalManager.showDialog({ title: $t('step_delete'), prompt: $t('step_delete_confirm') });
|
const confirmed = await modalManager.showDialog({ title: $t('step_delete'), prompt: $t('step_delete_confirm') });
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
@@ -344,17 +340,51 @@
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{#each steps as step, index (step.method + index)}
|
<div class="hidden" aria-hidden="true" {@attach dragAutoScroll(() => reorder.isDragging)}></div>
|
||||||
|
|
||||||
|
{#snippet stepCard(entry: ReorderEntry<WorkflowStepDto>)}
|
||||||
<WorkflowStepCard
|
<WorkflowStepCard
|
||||||
{step}
|
step={entry.item}
|
||||||
{index}
|
index={entry.index}
|
||||||
|
position={entry.index + 1}
|
||||||
|
isGhost={entry.isGhost}
|
||||||
|
isSource={entry.isSource}
|
||||||
|
isDragging={reorder.isDragging}
|
||||||
onEdit={handleEditStep}
|
onEdit={handleEditStep}
|
||||||
onDelete={handleDeleteStep}
|
onDelete={handleDeleteStep}
|
||||||
onInsertBefore={handleInsertStep}
|
onInsertBefore={handleInsertStep}
|
||||||
onDrop={handleDrop}
|
onDragStart={reorder.start}
|
||||||
|
onDragOver={reorder.over}
|
||||||
|
onDragEnd={reorder.end}
|
||||||
|
onDrop={reorder.drop}
|
||||||
/>
|
/>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#each reorder.entries as entry (entry.isGhost ? GHOST_KEY : entry.item)}
|
||||||
|
<div class="w-full" animate:flip={{ duration: 200 }}>
|
||||||
|
{#if entry.isGhost}
|
||||||
|
<div transition:fade={{ duration: 120 }}>{@render stepCard(entry)}</div>
|
||||||
|
{:else}
|
||||||
|
{@render stepCard(entry)}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
|
{#if reorder.isDragging}
|
||||||
|
<div
|
||||||
|
class="-mt-4 min-h-12 w-full"
|
||||||
|
role="listitem"
|
||||||
|
ondragover={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
reorder.toEnd();
|
||||||
|
}}
|
||||||
|
ondrop={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
reorder.drop();
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
fullWidth
|
fullWidth
|
||||||
|
|||||||
@@ -17,13 +17,34 @@
|
|||||||
type Props = {
|
type Props = {
|
||||||
step: WorkflowStepDto;
|
step: WorkflowStepDto;
|
||||||
index: number;
|
index: number;
|
||||||
|
position: number;
|
||||||
|
isGhost: boolean;
|
||||||
|
isSource: boolean;
|
||||||
|
isDragging: boolean;
|
||||||
onEdit: (index: number) => void;
|
onEdit: (index: number) => void;
|
||||||
onDelete: (index: number) => void;
|
onDelete: (index: number) => void;
|
||||||
onInsertBefore: (index: number) => void;
|
onInsertBefore: (index: number) => void;
|
||||||
onDrop: (index: number, event: DragEvent) => void;
|
onDragStart: (index: number) => void;
|
||||||
|
onDragOver: (index: number, after: boolean) => void;
|
||||||
|
onDragEnd: () => void;
|
||||||
|
onDrop: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { step, index, onEdit, onDelete, onInsertBefore, onDrop }: Props = $props();
|
let {
|
||||||
|
step,
|
||||||
|
index,
|
||||||
|
position,
|
||||||
|
isGhost,
|
||||||
|
isSource,
|
||||||
|
isDragging,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onInsertBefore,
|
||||||
|
onDragStart,
|
||||||
|
onDragOver,
|
||||||
|
onDragEnd,
|
||||||
|
onDrop,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
const method = $derived(pluginManager.getMethod(step.method));
|
const method = $derived(pluginManager.getMethod(step.method));
|
||||||
const isFilter = $derived(method?.uiHints?.includes('Filter') ?? false);
|
const isFilter = $derived(method?.uiHints?.includes('Filter') ?? false);
|
||||||
@@ -31,9 +52,24 @@
|
|||||||
Object.entries(step.config ?? {}).filter(([, value]) => value !== null && value !== undefined && value !== ''),
|
Object.entries(step.config ?? {}).filter(([, value]) => value !== null && value !== undefined && value !== ''),
|
||||||
);
|
);
|
||||||
let dragImage = $state<Element>();
|
let dragImage = $state<Element>();
|
||||||
let isDropTarget = $state(false);
|
|
||||||
let hoverDrag = $state(false);
|
let hoverDrag = $state(false);
|
||||||
|
|
||||||
|
const cardStateClass = $derived.by(() => {
|
||||||
|
if (isGhost) {
|
||||||
|
return 'pointer-events-none border-2 border-dashed border-primary bg-primary-50/40 shadow-lg';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSource) {
|
||||||
|
return 'border-dashed border-primary-300 bg-primary-50/20';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hoverDrag) {
|
||||||
|
return 'border-dashed border-primary';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
const truncate = (input: string, max = 24) => (input.length > max ? input.slice(0, max - 1) + '…' : input);
|
const truncate = (input: string, max = 24) => (input.length > max ? input.slice(0, max - 1) + '…' : input);
|
||||||
|
|
||||||
const formatConfigValue = (value: unknown): string => {
|
const formatConfigValue = (value: unknown): string => {
|
||||||
@@ -83,31 +119,31 @@
|
|||||||
|
|
||||||
dragImage = document.body.querySelector('#workflow-step-drag-image')!;
|
dragImage = document.body.querySelector('#workflow-step-drag-image')!;
|
||||||
event.dataTransfer.setDragImage(dragImage, 16, 22);
|
event.dataTransfer.setDragImage(dragImage, 16, 22);
|
||||||
|
|
||||||
|
onDragStart(index);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDrop = (index: number, event: DragEvent) => {
|
const handleDrop = (event: DragEvent) => {
|
||||||
if (!event.dataTransfer) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
onDrop();
|
||||||
|
};
|
||||||
|
|
||||||
const from = Number(event.dataTransfer.getData('text/plain'));
|
const handleDragOver = (event: DragEvent & { currentTarget: HTMLElement }) => {
|
||||||
if (from === index) {
|
event.preventDefault();
|
||||||
|
if (isGhost) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
onDrop(index, event);
|
const rect = event.currentTarget.getBoundingClientRect();
|
||||||
};
|
const after = event.clientY > rect.top + rect.height / 2;
|
||||||
|
onDragOver(index, after);
|
||||||
const handleDragOver = (event: DragEvent) => {
|
|
||||||
event.preventDefault();
|
|
||||||
isDropTarget = true;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragEnd = () => {
|
const handleDragEnd = () => {
|
||||||
dragImage?.remove();
|
dragImage?.remove();
|
||||||
dragImage = undefined;
|
dragImage = undefined;
|
||||||
isDropTarget = false;
|
hoverDrag = false;
|
||||||
|
onDragEnd();
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -118,6 +154,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="absolute top-1/2 left-1/2 z-10 -translate-1/2 cursor-pointer rounded-full border border-dashed border-primary-200 bg-light p-0.5 text-primary opacity-0 transition-opacity group-hover/step-row:opacity-100 hover:bg-primary-50"
|
class="absolute top-1/2 left-1/2 z-10 -translate-1/2 cursor-pointer rounded-full border border-dashed border-primary-200 bg-light p-0.5 text-primary opacity-0 transition-opacity group-hover/step-row:opacity-100 hover:bg-primary-50"
|
||||||
|
class:hidden={isDragging}
|
||||||
aria-label={$t('add_step')}
|
aria-label={$t('add_step')}
|
||||||
title={$t('add_step')}
|
title={$t('add_step')}
|
||||||
onclick={() => onInsertBefore(index)}
|
onclick={() => onInsertBefore(index)}
|
||||||
@@ -129,20 +166,12 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
class="w-full transition-all"
|
class="w-full transition-all"
|
||||||
class:opacity-40={!!dragImage}
|
class:opacity-50={isSource}
|
||||||
class:scale-[0.99]={!!dragImage}
|
|
||||||
ondragover={handleDragOver}
|
ondragover={handleDragOver}
|
||||||
ondragleave={() => (isDropTarget = false)}
|
ondrop={handleDrop}
|
||||||
ondrop={(event) => handleDrop(index, event)}
|
|
||||||
role="listitem"
|
role="listitem"
|
||||||
>
|
>
|
||||||
<Card
|
<Card class="shadow-none transition-colors {cardStateClass}">
|
||||||
class="shadow-none transition-colors {isDropTarget
|
|
||||||
? 'border-primary ring-2 ring-primary-200'
|
|
||||||
: hoverDrag
|
|
||||||
? 'border-dashed border-primary'
|
|
||||||
: ''}"
|
|
||||||
>
|
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
@@ -171,7 +200,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex min-w-0 flex-1 flex-col">
|
<div class="flex min-w-0 flex-1 flex-col">
|
||||||
<CardTitle class="truncate">
|
<CardTitle class="truncate">
|
||||||
<span class="mr-1 font-bold text-light-500">{index + 1}</span>
|
{#if !isGhost}
|
||||||
|
<span class="mr-1 font-bold text-light-500">{position}</span>
|
||||||
|
{/if}
|
||||||
{pluginManager.getMethodLabel(step.method)}
|
{pluginManager.getMethodLabel(step.method)}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
{#if method?.description}
|
{#if method?.description}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
export const GHOST_KEY = 'reorder-ghost';
|
||||||
|
|
||||||
|
export type ReorderEntry<T> = {
|
||||||
|
item: T;
|
||||||
|
index: number;
|
||||||
|
isGhost: boolean;
|
||||||
|
isSource: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createListReorder<T>(getItems: () => T[], setItems: (items: T[]) => void) {
|
||||||
|
let draggingIndex = $state<number | null>(null);
|
||||||
|
let dropIndex = $state<number | null>(null);
|
||||||
|
|
||||||
|
const entries = $derived.by<ReorderEntry<T>[]>(() => {
|
||||||
|
const items = getItems();
|
||||||
|
const list: ReorderEntry<T>[] = items.map((item, index) => ({
|
||||||
|
item,
|
||||||
|
index,
|
||||||
|
isGhost: false,
|
||||||
|
isSource: index === draggingIndex,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (
|
||||||
|
draggingIndex !== null &&
|
||||||
|
dropIndex !== null &&
|
||||||
|
dropIndex !== draggingIndex &&
|
||||||
|
dropIndex !== draggingIndex + 1
|
||||||
|
) {
|
||||||
|
list.splice(dropIndex, 0, { item: items[draggingIndex], index: draggingIndex, isGhost: true, isSource: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
get isDragging() {
|
||||||
|
return draggingIndex !== null;
|
||||||
|
},
|
||||||
|
get entries() {
|
||||||
|
return entries;
|
||||||
|
},
|
||||||
|
start(index: number) {
|
||||||
|
draggingIndex = index;
|
||||||
|
dropIndex = index;
|
||||||
|
},
|
||||||
|
over(index: number, after: boolean) {
|
||||||
|
if (draggingIndex === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dropIndex = Math.max(0, Math.min(index + (after ? 1 : 0), getItems().length));
|
||||||
|
},
|
||||||
|
toEnd() {
|
||||||
|
if (draggingIndex !== null) {
|
||||||
|
dropIndex = getItems().length;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
end() {
|
||||||
|
draggingIndex = null;
|
||||||
|
dropIndex = null;
|
||||||
|
},
|
||||||
|
drop() {
|
||||||
|
if (draggingIndex === null || dropIndex === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = dropIndex > draggingIndex ? dropIndex - 1 : dropIndex;
|
||||||
|
if (target !== draggingIndex) {
|
||||||
|
const next = [...getItems()];
|
||||||
|
const [moved] = next.splice(draggingIndex, 1);
|
||||||
|
next.splice(target, 0, moved);
|
||||||
|
setItems(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
draggingIndex = null;
|
||||||
|
dropIndex = null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user