feat: update dnd tools in pipeline

This commit is contained in:
2026-10-10 17:15:16 +05:00
parent 8b94feee79
commit 7bbcec5ff0
10 changed files with 519 additions and 172 deletions
+1
View File
@@ -177,6 +177,7 @@
--duration-l: 0.8s;
--z-dropdown: 10;
--z-drag: 20;
}
[data-theme="dark"] {
+19 -25
View File
@@ -3,55 +3,44 @@
import IconButton from "$lib/components/ui/IconButton.svelte";
import { t } from "$lib/i18n/t";
import type { Snippet } from "svelte";
import type { Attachment } from "svelte/attachments";
interface Props {
index: number;
title?: string;
type?: string;
draggable?: boolean;
collapsed?: boolean;
tools?: Snippet;
children?: Snippet;
onremove?: () => void;
ontoggle?: () => void;
ondragstart?: (e: DragEvent) => void;
ondragover?: (e: DragEvent) => void;
ondrop?: (e: DragEvent) => void;
ondragend?: () => void;
// dnd-kit attachments wired by SortableStepCard; noop on static usage.
elementAttachment?: Attachment<HTMLElement>;
handleAttachment?: Attachment<HTMLElement>;
}
const noopAttach: Attachment<HTMLElement> = () => {};
let {
index,
title,
type,
draggable = false,
collapsed = false,
tools,
children,
onremove,
ontoggle,
ondragstart,
ondragover,
ondrop,
ondragend,
elementAttachment = noopAttach,
handleAttachment = noopAttach,
}: Props = $props();
</script>
<article
class="step-card"
ondragover={(e) => ondragover?.(e)}
ondrop={(e) => ondrop?.(e)}
>
<header
class="step-head"
role="button"
tabindex="0"
{draggable}
ondragstart={(e) => ondragstart?.(e)}
ondragend={() => ondragend?.()}
>
<article class="step-card" {@attach elementAttachment}>
<header class="step-head" role="button" tabindex="0">
<span class="step-index">{index.toString().padStart(2, "0")}</span>
<GripVertical size={16} class="drag" aria-hidden="true" />
<span class="drag-handle" {@attach handleAttachment}>
<GripVertical size={16} class="drag" aria-hidden="true" />
</span>
<div class="step-heading-text">
{#if type}<span class="step-type">{type}</span>{/if}
{#if title}<h2 class="step-title">{title}</h2>{/if}
@@ -101,6 +90,12 @@
font: var(--font-size-s) var(--font-mono);
color: var(--color-main);
}
.drag-handle {
display: flex;
align-items: center;
cursor: grab;
touch-action: none;
}
.step-heading-text {
flex: 1;
display: flex;
@@ -127,6 +122,5 @@
}
.step-card :global(.drag) {
color: var(--color-border);
cursor: grab;
}
</style>
@@ -19,11 +19,14 @@
import SchemaActions from "./SchemaActions.svelte";
import SchemaResultTile from "./SchemaResultTile.svelte";
import SchemaSourceTile from "./SchemaSourceTile.svelte";
import StepDropIndicator from "./StepDropIndicator.svelte";
import type { StepDnd } from "./step-dnd.svelte";
interface Props {
steps: ChainStep[];
stepCard: Snippet<[ChainStep, number]>;
canExtend: boolean;
stepDnd?: StepDnd;
aligned: boolean;
inputMode: InputMode;
source: PixelImage | null;
@@ -65,6 +68,7 @@
steps,
stepCard,
canExtend,
stepDnd,
aligned = $bindable(false),
inputMode,
source,
@@ -146,13 +150,19 @@
{#each warnings as w (w)}
<p class="warn" role="status">{w}</p>
{/each}
{#if canExtend}
{#if canExtend && !stepDnd?.active}
<div class="aligned-row">
<AddStepButton onadd={(id) => onaddstep(0, id)} />
<div class="panel-segment connect-segment"></div>
</div>
{/if}
{#each steps as step, i (step.key)}
{#if stepDnd?.isIndicatorAt(i)}
<div class="aligned-row">
<StepDropIndicator height={stepDnd.dragHeight} />
<div class="panel-segment connect-segment"></div>
</div>
{/if}
{@const input = i === 0 ? null : stepResults[i - 1]}
{@const out = stepResults[i]}
{#if i > 0}
@@ -241,7 +251,7 @@
{/if}
</div>
</div>
{#if canExtend}
{#if canExtend && !stepDnd?.active}
<div class="aligned-row">
<AddStepButton onadd={(id) => onaddstep(i + 1, id)} />
<div
@@ -251,6 +261,15 @@
</div>
{/if}
{/each}
{#if stepDnd?.isIndicatorAt(steps.length)}
<div class="aligned-row">
<StepDropIndicator height={stepDnd.dragHeight} />
<div
class="panel-segment connect-segment"
class:add-last={!canExtend || stepDnd.active}
></div>
</div>
{/if}
</div>
<style>
@@ -1,11 +1,11 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import StepCard from "$lib/components/display/StepCard.svelte";
import Button from "$lib/components/ui/Button.svelte";
import { ButtonVariantDefine } from "$lib/components/ui/define";
import { isFav, toggleFav } from "$lib/favorites.svelte";
import { Star } from "@lucide/svelte";
import { GripVertical, Star } from "@lucide/svelte";
import { DragDropProvider } from "@dnd-kit/svelte";
import { hasTransparency } from "$lib/core/analyze";
import { debounce } from "$lib/core/debounce";
import { ToolError } from "$lib/core/errors";
@@ -30,6 +30,7 @@
import {
createChain,
isStructuralDefault,
moveStep,
type ChainStep,
} from "$lib/pipeline.svelte";
import {
@@ -49,8 +50,11 @@
import SchemaAlignedLayout from "./SchemaAlignedLayout.svelte";
import SchemaFields from "./SchemaFields.svelte";
import SchemaPreview from "./SchemaPreview.svelte";
import SortableStepCard from "./SortableStepCard.svelte";
import StepDropIndicator from "./StepDropIndicator.svelte";
import ToolPickerButton from "./ToolPickerButton.svelte";
import { createSchemaToolRunner } from "./schema-tool-runner.svelte";
import { createStepDnd } from "./step-dnd.svelte";
import {
createSchemaToolState,
toolSchemaOf,
@@ -121,9 +125,13 @@
removeStepAt,
toggleStep,
replaceStepTool,
onStepDrop,
} = stepState;
const steps = $derived(stepState.steps);
const stepDnd = createStepDnd({
onMove: (from, to) => {
stepState.steps = moveStep(stepState.steps, from, to);
},
});
const runner = createSchemaToolRunner({
pageSlug: () => page.slug,
@@ -440,26 +448,13 @@
</header>
{#snippet stepCard(step: ChainStep, i: number)}
<StepCard
index={i + 1}
<SortableStepCard
{step}
index={i}
title={stepTitle(step.id)}
draggable
collapsed={step.collapsed ?? false}
ontoggle={() => toggleStep(i)}
onremove={steps.length > 1 ? () => removeStepAt(step.key) : undefined}
ondragstart={(e) => {
stepState.dragFrom = i;
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", String(i));
}
}}
ondragover={(e) => {
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
}}
ondrop={(e) => onStepDrop(e, i)}
ondragend={() => (stepState.dragFrom = null)}
>
{#snippet tools()}
<ToolPickerButton
@@ -479,122 +474,147 @@
onreset={() => resetStep(i)}
/>
{/if}
</StepCard>
</SortableStepCard>
{/snippet}
{#if !alignedMode}
<div class="workspace">
<section class="settings">
{#if canExtend}
<AddStepButton onadd={(id) => addStepAt(0, id)} />
{/if}
{#each steps as step, i (step.key)}
{@render stepCard(step, i)}
{#if canExtend}
<AddStepButton onadd={(id) => addStepAt(i + 1, id)} />
<DragDropProvider
onDragStart={stepDnd.onDragStart}
onDragMove={stepDnd.onDragOver}
onDragOver={stepDnd.onDragOver}
onDragEnd={stepDnd.onDragEnd}
>
{#if !alignedMode}
<div class="workspace">
<section class="settings">
{#if canExtend && !stepDnd.active}
<AddStepButton onadd={(id) => addStepAt(0, id)} />
{/if}
{/each}
</section>
{#each steps as step, i (step.key)}
{#if stepDnd.isIndicatorAt(i)}
<StepDropIndicator height={stepDnd.dragHeight} />
{/if}
{@render stepCard(step, i)}
{#if canExtend && !stepDnd.active}
<AddStepButton onadd={(id) => addStepAt(i + 1, id)} />
{/if}
{/each}
{#if stepDnd.isIndicatorAt(steps.length)}
<StepDropIndicator height={stepDnd.dragHeight} />
{/if}
</section>
<section class="panel">
<SchemaPreview
toolId={lastTool.id}
{inputMode}
resultKind={displayResultKind}
running={busy}
error={errorText}
bind:aligned
head={{
format,
quality: currentQuality,
limitKb,
alphaLoss,
onupload: handleFile,
ondownload: download,
onformat: (v) => (format = v),
onquality: setFormatQuality,
onlimit: (v) => (limitKb = v),
}}
source={{
source,
sources: sourceFiles,
fileCount: sourceFiles.length,
textSource,
ontextinput: (v) => (textSource = v),
onrendertext: run,
onuploadmany: handleFiles,
}}
steps={{
results: stepResults,
fileSets: stepFileSets,
maskable: stepMaskable,
maskOn: stepMaskOn,
masks: stepMasks,
ontogglestepmask: toggleStepMaskByIndex,
}}
result={{
result,
resultNote,
fileResult,
textResult,
textVars: verdictVars,
mask: stepMasks.at(-1) ?? null,
maskOn: stepMaskOn.at(-1) ?? false,
ontogglemask: stepMaskable.at(-1)
? () => toggleStepMask(steps[steps.length - 1].key)
: undefined,
oncopy: copyText,
ondownloadtxt: downloadText,
}}
/>
</section>
</div>
{#each allWarnings as w (warningText(w))}
<p class="warn" role="status">{warningText(w)}</p>
{/each}
{:else}
<SchemaAlignedLayout
{steps}
{stepCard}
{canExtend}
bind:aligned
{inputMode}
{source}
{sourceFiles}
{textSource}
running={busy}
resultKind={displayResultKind}
{result}
{resultNote}
{fileResult}
{textResult}
textVars={verdictVars}
toolId={lastTool.id}
{format}
quality={currentQuality}
{limitKb}
{alphaLoss}
{errorText}
warnings={allWarnings.map(warningText)}
{stepResults}
{stepFileSets}
{stepMasks}
{stepMaskOn}
{stepMaskable}
onaddstep={addStepAt}
ontogglestepmask={toggleStepMaskByIndex}
ontextinput={(v) => (textSource = v)}
onrendertext={run}
onupload={handleFile}
onuploadmany={handleFiles}
ondownload={download}
oncopy={copyText}
ondownloadtxt={downloadText}
onformat={(v) => (format = v)}
onquality={setFormatQuality}
onlimit={(v) => (limitKb = v)}
/>
{/if}
<section class="panel">
<SchemaPreview
toolId={lastTool.id}
{inputMode}
resultKind={displayResultKind}
running={busy}
error={errorText}
bind:aligned
head={{
format,
quality: currentQuality,
limitKb,
alphaLoss,
onupload: handleFile,
ondownload: download,
onformat: (v) => (format = v),
onquality: setFormatQuality,
onlimit: (v) => (limitKb = v),
}}
source={{
source,
sources: sourceFiles,
fileCount: sourceFiles.length,
textSource,
ontextinput: (v) => (textSource = v),
onrendertext: run,
onuploadmany: handleFiles,
}}
steps={{
results: stepResults,
fileSets: stepFileSets,
maskable: stepMaskable,
maskOn: stepMaskOn,
masks: stepMasks,
ontogglestepmask: toggleStepMaskByIndex,
}}
result={{
result,
resultNote,
fileResult,
textResult,
textVars: verdictVars,
mask: stepMasks.at(-1) ?? null,
maskOn: stepMaskOn.at(-1) ?? false,
ontogglemask: stepMaskable.at(-1)
? () => toggleStepMask(steps[steps.length - 1].key)
: undefined,
oncopy: copyText,
ondownloadtxt: downloadText,
}}
/>
</section>
</div>
{#each allWarnings as w (warningText(w))}
<p class="warn" role="status">{warningText(w)}</p>
{/each}
{:else}
<SchemaAlignedLayout
{steps}
{stepCard}
{canExtend}
{stepDnd}
bind:aligned
{inputMode}
{source}
{sourceFiles}
{textSource}
running={busy}
resultKind={displayResultKind}
{result}
{resultNote}
{fileResult}
{textResult}
textVars={verdictVars}
toolId={lastTool.id}
{format}
quality={currentQuality}
{limitKb}
{alphaLoss}
{errorText}
warnings={allWarnings.map(warningText)}
{stepResults}
{stepFileSets}
{stepMasks}
{stepMaskOn}
{stepMaskable}
onaddstep={addStepAt}
ontogglestepmask={toggleStepMaskByIndex}
ontextinput={(v) => (textSource = v)}
onrendertext={run}
onupload={handleFile}
onuploadmany={handleFiles}
ondownload={download}
oncopy={copyText}
ondownloadtxt={downloadText}
onformat={(v) => (format = v)}
onquality={setFormatQuality}
onlimit={(v) => (limitKb = v)}
/>
{/if}
{#if stepDnd.activeData && stepDnd.pointer}
<div
class="step-ghost"
style:width="{stepDnd.ghostWidth}px"
style:left="{stepDnd.pointer.x - stepDnd.grabOffset.x}px"
style:top="{stepDnd.pointer.y - stepDnd.grabOffset.y}px"
>
<GripVertical size={16} aria-hidden="true" />
<span class="step-ghost-title">{stepDnd.activeData.title}</span>
</div>
{/if}
</DragDropProvider>
</div>
{:else}
<p class="no-schema">{t("paramsCard.noSchema")}</p>
@@ -606,6 +626,23 @@
clamp(var(--space-m), 4vw, var(--space-xxxl));
flex: 1;
}
.step-ghost {
position: fixed;
z-index: var(--z-drag);
display: flex;
align-items: center;
gap: var(--space-m);
padding: var(--space-m) var(--space-xl);
border: var(--size-border-thick) dashed var(--color-main);
background: var(--color-panel);
color: var(--color-border);
opacity: 0.85;
pointer-events: none;
}
.step-ghost-title {
font: 600 var(--font-size-m) var(--font-mono);
color: var(--color-text);
}
.header {
display: flex;
justify-content: space-between;
@@ -0,0 +1,58 @@
<script lang="ts">
import StepCard from "$lib/components/display/StepCard.svelte";
import type { ChainStep } from "$lib/pipeline.svelte";
import { createSortable } from "@dnd-kit/svelte/sortable";
import type { Snippet } from "svelte";
interface Props {
step: ChainStep;
index: number;
title: string;
collapsed?: boolean;
tools?: Snippet;
children?: Snippet;
onremove?: () => void;
ontoggle?: () => void;
}
let {
step,
index,
title,
collapsed = false,
tools,
children,
onremove,
ontoggle,
}: Props = $props();
// feedback: "none" -- no transform shifting; step-dnd.svelte.ts hides the
// source and renders a dashed drop indicator instead.
const sortable = createSortable({
get id() {
return step.key;
},
get index() {
return index;
},
group: "steps",
type: "step",
accept: "step",
feedback: "none",
get data() {
return { title };
},
});
</script>
<StepCard
index={index + 1}
{title}
{collapsed}
{tools}
{children}
{onremove}
{ontoggle}
elementAttachment={sortable.attach}
handleAttachment={sortable.attachHandle}
/>
@@ -0,0 +1,23 @@
<script lang="ts">
interface Props {
height?: number;
}
let { height = 0 }: Props = $props();
</script>
<div
class="step-drop-indicator"
style:height={height > 0 ? `${height}px` : null}
></div>
<style>
.step-drop-indicator {
border: var(--size-border-thick) dashed var(--color-main);
background: var(--color-panel);
opacity: 0.6;
min-height: var(--space-xxl);
pointer-events: none;
flex-shrink: 0;
}
</style>
@@ -2,7 +2,6 @@ import type { PixelImage } from "$lib/core/types";
import {
createStep,
insertStep,
moveStep,
removeStep,
type ChainStep,
} from "$lib/pipeline.svelte";
@@ -23,7 +22,6 @@ export interface SchemaToolStateCtx {
interface StepsState {
steps: ChainStep[];
dragFrom: number | null;
}
export function toolSchemaOf(step: ChainStep) {
@@ -122,7 +120,7 @@ function touchedFor(marks: Marks, key: string): SvelteSet<string> {
// Chain-step state of SchemaToolView: the steps array, per-step edit marks
// and all mutations. Results/execution live in schema-tool-runner.svelte.ts.
export function createSchemaToolState(ctx: SchemaToolStateCtx) {
const s = $state<StepsState>({ steps: [], dragFrom: null });
const s = $state<StepsState>({ steps: [] });
const marks: Marks = {
touchedByStep: new SvelteMap(),
lastAxis: {},
@@ -134,12 +132,6 @@ export function createSchemaToolState(ctx: SchemaToolStateCtx) {
set steps(v: ChainStep[]) {
s.steps = v;
},
get dragFrom() {
return s.dragFrom;
},
set dragFrom(v: number | null) {
s.dragFrom = v;
},
touchedFor: (key: string) => touchedFor(marks, key),
// Marks are keyed by step.key from the previous host; keep them and
// stale keys leak into the new chain's fields.
@@ -181,10 +173,5 @@ export function createSchemaToolState(ctx: SchemaToolStateCtx) {
collapsed: step.collapsed,
});
},
onStepDrop: (e: DragEvent, to: number) => {
e.preventDefault();
if (s.dragFrom !== null) s.steps = moveStep(s.steps, s.dragFrom, to);
s.dragFrom = null;
},
};
}
@@ -0,0 +1,152 @@
import type { Draggable, Droppable } from "@dnd-kit/dom";
import { isSortable, type SortableDraggable } from "@dnd-kit/dom/sortable";
export interface StepDragData {
title: string;
}
interface StepDndOptions {
// Compressed-index semantics: `to` counts items with the source excluded,
// matching moveStep's remove-then-insert.
onMove: (from: number, to: number) => void;
}
interface DndEvent {
operation?: {
source?: Draggable | null;
position?: { current?: { x: number; y: number } } | null;
};
canceled?: boolean;
}
// Count the slot the pointer is over: how many non-source cards have their
// vertical midpoint above the pointer.
function indexAtPosition(
source: SortableDraggable<StepDragData>,
pointerY: number,
): number {
const manager = source.sortable.manager;
let index = 0;
if (!manager) return index;
for (const droppable of manager.registry.droppables) {
if (!isSortable(droppable)) continue;
if (droppable.sortable.id === source.id) continue;
const el = (droppable as Droppable).element;
if (!el) continue;
const rect = el.getBoundingClientRect();
if (rect.height === 0) continue;
if (pointerY > rect.top + rect.height / 2) index++;
}
return index;
}
// Step-reorder state for SchemaToolView, single list. The dragged card is
// hidden (DragOverlay renders the ghost) and a dashed indicator of the
// dragged height marks the insertion slot between the remaining cards.
export function createStepDnd({ onMove }: StepDndOptions) {
let activeData = $state<StepDragData | null>(null);
let indicatorIndex = $state<number | null>(null);
let dragHeight = $state(0);
let sourceIndex = $state<number | null>(null);
let sourceElement: HTMLElement | null = null;
// Ghost positioning: current pointer, grab offset inside the card and the
// card width. DragOverlay is not used -- the Feedback plugin skips
// draggables with feedback: "none", so the ghost follows the pointer here.
let pointer = $state<{ x: number; y: number } | null>(null);
let grabOffset = $state<{ x: number; y: number }>({ x: 0, y: 0 });
let ghostWidth = $state(0);
// X-range of the source card: dragging sideways over the preview panel
// must not light up the indicator.
let sourceX: { left: number; right: number } | null = null;
function computeIndicator(event: DndEvent): number | null {
const source = event.operation?.source;
if (!source || !isSortable(source)) return null;
const position = event.operation?.position?.current;
if (!position || !sourceX) return null;
if (position.x < sourceX.left || position.x > sourceX.right) return null;
return indexAtPosition(
source as SortableDraggable<StepDragData>,
position.y,
);
}
function onDragStart(event: DndEvent) {
const source = event.operation?.source;
if (!source) return;
activeData = (source.data as StepDragData | undefined) ?? null;
sourceElement = (source.element as HTMLElement | undefined) ?? null;
const rect = sourceElement?.getBoundingClientRect();
dragHeight = rect?.height ?? 0;
ghostWidth = rect?.width ?? 0;
sourceX = rect ? { left: rect.left, right: rect.right } : null;
const start = event.operation?.position?.current;
grabOffset =
start && rect
? { x: start.x - rect.left, y: start.y - rect.top }
: { x: 0, y: 0 };
pointer = start ?? null;
if (isSortable(source)) sourceIndex = source.sortable.initialIndex;
if (sourceElement) sourceElement.style.display = "none";
}
function onDragOver(event: DndEvent) {
pointer = event.operation?.position?.current ?? pointer;
indicatorIndex = computeIndicator(event);
}
function onDragEnd(event: DndEvent) {
const drop = event.canceled ? null : computeIndicator(event);
if (sourceElement) {
sourceElement.style.display = "";
sourceElement = null;
}
const from = sourceIndex;
activeData = null;
indicatorIndex = null;
dragHeight = 0;
sourceIndex = null;
sourceX = null;
pointer = null;
if (from === null || drop === null || drop === from) return;
onMove(from, drop);
}
// Slots render in the full list where the hidden source still occupies
// its DOM position: a compressed index at/past the source shifts by 1.
function isIndicatorAt(slot: number): boolean {
if (indicatorIndex === null) return false;
let renderIndex = indicatorIndex;
if (sourceIndex !== null && indicatorIndex >= sourceIndex) {
renderIndex += 1;
}
return renderIndex === slot;
}
return {
get activeData() {
return activeData;
},
get dragHeight() {
return dragHeight;
},
get pointer() {
return pointer;
},
get grabOffset() {
return grabOffset;
},
get ghostWidth() {
return ghostWidth;
},
get active() {
return activeData !== null;
},
onDragStart,
onDragOver,
onDragEnd,
isIndicatorAt,
};
}
export type StepDnd = ReturnType<typeof createStepDnd>;