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
+2
View File
@@ -52,6 +52,8 @@
"vitest": "^4.1.11" "vitest": "^4.1.11"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/dom": "^0.3.2",
"@dnd-kit/svelte": "^0.3.2",
"@fontsource/ibm-plex-mono": "^5.3.0", "@fontsource/ibm-plex-mono": "^5.3.0",
"@fontsource/ibm-plex-sans": "^5.3.0", "@fontsource/ibm-plex-sans": "^5.3.0",
"@lucide/svelte": "^1.33.0", "@lucide/svelte": "^1.33.0",
+74
View File
@@ -8,6 +8,12 @@ importers:
.: .:
dependencies: dependencies:
'@dnd-kit/dom':
specifier: ^0.3.2
version: 0.3.2
'@dnd-kit/svelte':
specifier: ^0.3.2
version: 0.3.2(svelte@5.56.9(@typescript-eslint/types@8.67.0))
'@fontsource/ibm-plex-mono': '@fontsource/ibm-plex-mono':
specifier: ^5.3.0 specifier: ^5.3.0
version: 5.3.0 version: 5.3.0
@@ -196,6 +202,26 @@ packages:
peerDependencies: peerDependencies:
postcss-selector-parser: ^7.1.1 postcss-selector-parser: ^7.1.1
'@dnd-kit/abstract@0.3.2':
resolution: {integrity: sha512-uvPVK+SZYD6Viddn9M0K0JQdXknuVSxA/EbMlFRanve3P/XTc18oLa5zGftKSGjfQGmuzkZ34E26DSbly1zi3Q==}
'@dnd-kit/collision@0.3.2':
resolution: {integrity: sha512-pNmNSLCI8S9fNQ7QJ3fBCDjiT0sqBhUFcKgmyYaGvGCAU+kq0AP8OWlh0JSisc9k5mFyxmRpmFQcnJpILz/RPA==}
'@dnd-kit/dom@0.3.2':
resolution: {integrity: sha512-cIUAVgt2szQyz6JRy7I+0r+xeyOAGH21Y15hb5bIyHoDEaZBvIDH+OOlD9eoLjCbsxDLN9WloU2CBi3OE6LYDg==}
'@dnd-kit/geometry@0.3.2':
resolution: {integrity: sha512-3UBPuIS7E3oGiHxOE8h810QA+0pnrnCtGxl4Os1z3yy5YkC/BEYGY+TxWPTQaY1/OMV7GCX7ZNMlama2QN3n3w==}
'@dnd-kit/state@0.3.2':
resolution: {integrity: sha512-dLUIkoYrIJhGXfF2wGLTfb46vUokEsO/OoE21TSfmahYrx7ysTmnwbePsznFaHlwgZhQEh6AlLvthLCeY21b1A==}
'@dnd-kit/svelte@0.3.2':
resolution: {integrity: sha512-HkTnx/3GnxfVNqHSojxhxp5r+KymL9Q7UI2gonXN2+bmdObw03FSQOjicOoRzlV2u9o5DtK1sU4YUQAfkLCGGQ==}
peerDependencies:
svelte: ^5.29.0
'@eslint-community/eslint-utils@4.10.1': '@eslint-community/eslint-utils@4.10.1':
resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@@ -313,6 +339,9 @@ packages:
'@polka/url@1.0.0-next.29': '@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
'@preact/signals-core@1.14.4':
resolution: {integrity: sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==}
'@rolldown/binding-android-arm64@1.2.4': '@rolldown/binding-android-arm64@1.2.4':
resolution: {integrity: sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==} resolution: {integrity: sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@@ -1518,6 +1547,9 @@ packages:
peerDependencies: peerDependencies:
typescript: '>=4.8.4' typescript: '>=4.8.4'
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
type-check@0.4.0: type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@@ -1744,6 +1776,44 @@ snapshots:
dependencies: dependencies:
postcss-selector-parser: 7.1.5 postcss-selector-parser: 7.1.5
'@dnd-kit/abstract@0.3.2':
dependencies:
'@dnd-kit/geometry': 0.3.2
'@dnd-kit/state': 0.3.2
tslib: 2.8.1
'@dnd-kit/collision@0.3.2':
dependencies:
'@dnd-kit/abstract': 0.3.2
'@dnd-kit/geometry': 0.3.2
tslib: 2.8.1
'@dnd-kit/dom@0.3.2':
dependencies:
'@dnd-kit/abstract': 0.3.2
'@dnd-kit/collision': 0.3.2
'@dnd-kit/geometry': 0.3.2
'@dnd-kit/state': 0.3.2
tslib: 2.8.1
'@dnd-kit/geometry@0.3.2':
dependencies:
'@dnd-kit/state': 0.3.2
tslib: 2.8.1
'@dnd-kit/state@0.3.2':
dependencies:
'@preact/signals-core': 1.14.4
tslib: 2.8.1
'@dnd-kit/svelte@0.3.2(svelte@5.56.9(@typescript-eslint/types@8.67.0))':
dependencies:
'@dnd-kit/abstract': 0.3.2
'@dnd-kit/dom': 0.3.2
'@dnd-kit/state': 0.3.2
svelte: 5.56.9(@typescript-eslint/types@8.67.0)
tslib: 2.8.1
'@eslint-community/eslint-utils@4.10.1(eslint@10.9.0(supports-color@10.2.2))': '@eslint-community/eslint-utils@4.10.1(eslint@10.9.0(supports-color@10.2.2))':
dependencies: dependencies:
eslint: 10.9.0(supports-color@10.2.2) eslint: 10.9.0(supports-color@10.2.2)
@@ -1847,6 +1917,8 @@ snapshots:
'@polka/url@1.0.0-next.29': {} '@polka/url@1.0.0-next.29': {}
'@preact/signals-core@1.14.4': {}
'@rolldown/binding-android-arm64@1.2.4': '@rolldown/binding-android-arm64@1.2.4':
optional: true optional: true
@@ -2991,6 +3063,8 @@ snapshots:
dependencies: dependencies:
typescript: 6.0.3 typescript: 6.0.3
tslib@2.8.1: {}
type-check@0.4.0: type-check@0.4.0:
dependencies: dependencies:
prelude-ls: 1.2.1 prelude-ls: 1.2.1
+1
View File
@@ -177,6 +177,7 @@
--duration-l: 0.8s; --duration-l: 0.8s;
--z-dropdown: 10; --z-dropdown: 10;
--z-drag: 20;
} }
[data-theme="dark"] { [data-theme="dark"] {
+18 -24
View File
@@ -3,55 +3,44 @@
import IconButton from "$lib/components/ui/IconButton.svelte"; import IconButton from "$lib/components/ui/IconButton.svelte";
import { t } from "$lib/i18n/t"; import { t } from "$lib/i18n/t";
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
import type { Attachment } from "svelte/attachments";
interface Props { interface Props {
index: number; index: number;
title?: string; title?: string;
type?: string; type?: string;
draggable?: boolean;
collapsed?: boolean; collapsed?: boolean;
tools?: Snippet; tools?: Snippet;
children?: Snippet; children?: Snippet;
onremove?: () => void; onremove?: () => void;
ontoggle?: () => void; ontoggle?: () => void;
ondragstart?: (e: DragEvent) => void; // dnd-kit attachments wired by SortableStepCard; noop on static usage.
ondragover?: (e: DragEvent) => void; elementAttachment?: Attachment<HTMLElement>;
ondrop?: (e: DragEvent) => void; handleAttachment?: Attachment<HTMLElement>;
ondragend?: () => void;
} }
const noopAttach: Attachment<HTMLElement> = () => {};
let { let {
index, index,
title, title,
type, type,
draggable = false,
collapsed = false, collapsed = false,
tools, tools,
children, children,
onremove, onremove,
ontoggle, ontoggle,
ondragstart, elementAttachment = noopAttach,
ondragover, handleAttachment = noopAttach,
ondrop,
ondragend,
}: Props = $props(); }: Props = $props();
</script> </script>
<article <article class="step-card" {@attach elementAttachment}>
class="step-card" <header class="step-head" role="button" tabindex="0">
ondragover={(e) => ondragover?.(e)}
ondrop={(e) => ondrop?.(e)}
>
<header
class="step-head"
role="button"
tabindex="0"
{draggable}
ondragstart={(e) => ondragstart?.(e)}
ondragend={() => ondragend?.()}
>
<span class="step-index">{index.toString().padStart(2, "0")}</span> <span class="step-index">{index.toString().padStart(2, "0")}</span>
<span class="drag-handle" {@attach handleAttachment}>
<GripVertical size={16} class="drag" aria-hidden="true" /> <GripVertical size={16} class="drag" aria-hidden="true" />
</span>
<div class="step-heading-text"> <div class="step-heading-text">
{#if type}<span class="step-type">{type}</span>{/if} {#if type}<span class="step-type">{type}</span>{/if}
{#if title}<h2 class="step-title">{title}</h2>{/if} {#if title}<h2 class="step-title">{title}</h2>{/if}
@@ -101,6 +90,12 @@
font: var(--font-size-s) var(--font-mono); font: var(--font-size-s) var(--font-mono);
color: var(--color-main); color: var(--color-main);
} }
.drag-handle {
display: flex;
align-items: center;
cursor: grab;
touch-action: none;
}
.step-heading-text { .step-heading-text {
flex: 1; flex: 1;
display: flex; display: flex;
@@ -127,6 +122,5 @@
} }
.step-card :global(.drag) { .step-card :global(.drag) {
color: var(--color-border); color: var(--color-border);
cursor: grab;
} }
</style> </style>
@@ -19,11 +19,14 @@
import SchemaActions from "./SchemaActions.svelte"; import SchemaActions from "./SchemaActions.svelte";
import SchemaResultTile from "./SchemaResultTile.svelte"; import SchemaResultTile from "./SchemaResultTile.svelte";
import SchemaSourceTile from "./SchemaSourceTile.svelte"; import SchemaSourceTile from "./SchemaSourceTile.svelte";
import StepDropIndicator from "./StepDropIndicator.svelte";
import type { StepDnd } from "./step-dnd.svelte";
interface Props { interface Props {
steps: ChainStep[]; steps: ChainStep[];
stepCard: Snippet<[ChainStep, number]>; stepCard: Snippet<[ChainStep, number]>;
canExtend: boolean; canExtend: boolean;
stepDnd?: StepDnd;
aligned: boolean; aligned: boolean;
inputMode: InputMode; inputMode: InputMode;
source: PixelImage | null; source: PixelImage | null;
@@ -65,6 +68,7 @@
steps, steps,
stepCard, stepCard,
canExtend, canExtend,
stepDnd,
aligned = $bindable(false), aligned = $bindable(false),
inputMode, inputMode,
source, source,
@@ -146,13 +150,19 @@
{#each warnings as w (w)} {#each warnings as w (w)}
<p class="warn" role="status">{w}</p> <p class="warn" role="status">{w}</p>
{/each} {/each}
{#if canExtend} {#if canExtend && !stepDnd?.active}
<div class="aligned-row"> <div class="aligned-row">
<AddStepButton onadd={(id) => onaddstep(0, id)} /> <AddStepButton onadd={(id) => onaddstep(0, id)} />
<div class="panel-segment connect-segment"></div> <div class="panel-segment connect-segment"></div>
</div> </div>
{/if} {/if}
{#each steps as step, i (step.key)} {#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 input = i === 0 ? null : stepResults[i - 1]}
{@const out = stepResults[i]} {@const out = stepResults[i]}
{#if i > 0} {#if i > 0}
@@ -241,7 +251,7 @@
{/if} {/if}
</div> </div>
</div> </div>
{#if canExtend} {#if canExtend && !stepDnd?.active}
<div class="aligned-row"> <div class="aligned-row">
<AddStepButton onadd={(id) => onaddstep(i + 1, id)} /> <AddStepButton onadd={(id) => onaddstep(i + 1, id)} />
<div <div
@@ -251,6 +261,15 @@
</div> </div>
{/if} {/if}
{/each} {/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> </div>
<style> <style>
@@ -1,11 +1,11 @@
<script lang="ts"> <script lang="ts">
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
import StepCard from "$lib/components/display/StepCard.svelte";
import Button from "$lib/components/ui/Button.svelte"; import Button from "$lib/components/ui/Button.svelte";
import { ButtonVariantDefine } from "$lib/components/ui/define"; import { ButtonVariantDefine } from "$lib/components/ui/define";
import { isFav, toggleFav } from "$lib/favorites.svelte"; 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 { hasTransparency } from "$lib/core/analyze";
import { debounce } from "$lib/core/debounce"; import { debounce } from "$lib/core/debounce";
import { ToolError } from "$lib/core/errors"; import { ToolError } from "$lib/core/errors";
@@ -30,6 +30,7 @@
import { import {
createChain, createChain,
isStructuralDefault, isStructuralDefault,
moveStep,
type ChainStep, type ChainStep,
} from "$lib/pipeline.svelte"; } from "$lib/pipeline.svelte";
import { import {
@@ -49,8 +50,11 @@
import SchemaAlignedLayout from "./SchemaAlignedLayout.svelte"; import SchemaAlignedLayout from "./SchemaAlignedLayout.svelte";
import SchemaFields from "./SchemaFields.svelte"; import SchemaFields from "./SchemaFields.svelte";
import SchemaPreview from "./SchemaPreview.svelte"; import SchemaPreview from "./SchemaPreview.svelte";
import SortableStepCard from "./SortableStepCard.svelte";
import StepDropIndicator from "./StepDropIndicator.svelte";
import ToolPickerButton from "./ToolPickerButton.svelte"; import ToolPickerButton from "./ToolPickerButton.svelte";
import { createSchemaToolRunner } from "./schema-tool-runner.svelte"; import { createSchemaToolRunner } from "./schema-tool-runner.svelte";
import { createStepDnd } from "./step-dnd.svelte";
import { import {
createSchemaToolState, createSchemaToolState,
toolSchemaOf, toolSchemaOf,
@@ -121,9 +125,13 @@
removeStepAt, removeStepAt,
toggleStep, toggleStep,
replaceStepTool, replaceStepTool,
onStepDrop,
} = stepState; } = stepState;
const steps = $derived(stepState.steps); const steps = $derived(stepState.steps);
const stepDnd = createStepDnd({
onMove: (from, to) => {
stepState.steps = moveStep(stepState.steps, from, to);
},
});
const runner = createSchemaToolRunner({ const runner = createSchemaToolRunner({
pageSlug: () => page.slug, pageSlug: () => page.slug,
@@ -440,26 +448,13 @@
</header> </header>
{#snippet stepCard(step: ChainStep, i: number)} {#snippet stepCard(step: ChainStep, i: number)}
<StepCard <SortableStepCard
index={i + 1} {step}
index={i}
title={stepTitle(step.id)} title={stepTitle(step.id)}
draggable
collapsed={step.collapsed ?? false} collapsed={step.collapsed ?? false}
ontoggle={() => toggleStep(i)} ontoggle={() => toggleStep(i)}
onremove={steps.length > 1 ? () => removeStepAt(step.key) : undefined} 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()} {#snippet tools()}
<ToolPickerButton <ToolPickerButton
@@ -479,21 +474,33 @@
onreset={() => resetStep(i)} onreset={() => resetStep(i)}
/> />
{/if} {/if}
</StepCard> </SortableStepCard>
{/snippet} {/snippet}
<DragDropProvider
onDragStart={stepDnd.onDragStart}
onDragMove={stepDnd.onDragOver}
onDragOver={stepDnd.onDragOver}
onDragEnd={stepDnd.onDragEnd}
>
{#if !alignedMode} {#if !alignedMode}
<div class="workspace"> <div class="workspace">
<section class="settings"> <section class="settings">
{#if canExtend} {#if canExtend && !stepDnd.active}
<AddStepButton onadd={(id) => addStepAt(0, id)} /> <AddStepButton onadd={(id) => addStepAt(0, id)} />
{/if} {/if}
{#each steps as step, i (step.key)} {#each steps as step, i (step.key)}
{#if stepDnd.isIndicatorAt(i)}
<StepDropIndicator height={stepDnd.dragHeight} />
{/if}
{@render stepCard(step, i)} {@render stepCard(step, i)}
{#if canExtend} {#if canExtend && !stepDnd.active}
<AddStepButton onadd={(id) => addStepAt(i + 1, id)} /> <AddStepButton onadd={(id) => addStepAt(i + 1, id)} />
{/if} {/if}
{/each} {/each}
{#if stepDnd.isIndicatorAt(steps.length)}
<StepDropIndicator height={stepDnd.dragHeight} />
{/if}
</section> </section>
<section class="panel"> <section class="panel">
@@ -557,6 +564,7 @@
{steps} {steps}
{stepCard} {stepCard}
{canExtend} {canExtend}
{stepDnd}
bind:aligned bind:aligned
{inputMode} {inputMode}
{source} {source}
@@ -595,6 +603,18 @@
onlimit={(v) => (limitKb = v)} onlimit={(v) => (limitKb = v)}
/> />
{/if} {/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> </div>
{:else} {:else}
<p class="no-schema">{t("paramsCard.noSchema")}</p> <p class="no-schema">{t("paramsCard.noSchema")}</p>
@@ -606,6 +626,23 @@
clamp(var(--space-m), 4vw, var(--space-xxxl)); clamp(var(--space-m), 4vw, var(--space-xxxl));
flex: 1; 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 { .header {
display: flex; display: flex;
justify-content: space-between; 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 { import {
createStep, createStep,
insertStep, insertStep,
moveStep,
removeStep, removeStep,
type ChainStep, type ChainStep,
} from "$lib/pipeline.svelte"; } from "$lib/pipeline.svelte";
@@ -23,7 +22,6 @@ export interface SchemaToolStateCtx {
interface StepsState { interface StepsState {
steps: ChainStep[]; steps: ChainStep[];
dragFrom: number | null;
} }
export function toolSchemaOf(step: ChainStep) { 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 // Chain-step state of SchemaToolView: the steps array, per-step edit marks
// and all mutations. Results/execution live in schema-tool-runner.svelte.ts. // and all mutations. Results/execution live in schema-tool-runner.svelte.ts.
export function createSchemaToolState(ctx: SchemaToolStateCtx) { export function createSchemaToolState(ctx: SchemaToolStateCtx) {
const s = $state<StepsState>({ steps: [], dragFrom: null }); const s = $state<StepsState>({ steps: [] });
const marks: Marks = { const marks: Marks = {
touchedByStep: new SvelteMap(), touchedByStep: new SvelteMap(),
lastAxis: {}, lastAxis: {},
@@ -134,12 +132,6 @@ export function createSchemaToolState(ctx: SchemaToolStateCtx) {
set steps(v: ChainStep[]) { set steps(v: ChainStep[]) {
s.steps = v; s.steps = v;
}, },
get dragFrom() {
return s.dragFrom;
},
set dragFrom(v: number | null) {
s.dragFrom = v;
},
touchedFor: (key: string) => touchedFor(marks, key), touchedFor: (key: string) => touchedFor(marks, key),
// Marks are keyed by step.key from the previous host; keep them and // Marks are keyed by step.key from the previous host; keep them and
// stale keys leak into the new chain's fields. // stale keys leak into the new chain's fields.
@@ -181,10 +173,5 @@ export function createSchemaToolState(ctx: SchemaToolStateCtx) {
collapsed: step.collapsed, 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>;