fix: update drag-n-drop tools - overlay, page scrim

This commit is contained in:
2026-10-11 03:02:45 +05:00
parent e866975045
commit dd9cb6d574
4 changed files with 195 additions and 185 deletions
@@ -4,7 +4,7 @@
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 { GripVertical, Star } from "@lucide/svelte"; import { Star } from "@lucide/svelte";
import { DragDropProvider } from "@dnd-kit/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";
@@ -56,7 +56,7 @@
import SchemaResultTile from "./SchemaResultTile.svelte"; import SchemaResultTile from "./SchemaResultTile.svelte";
import SchemaSourceTile from "./SchemaSourceTile.svelte"; import SchemaSourceTile from "./SchemaSourceTile.svelte";
import SortableStepCard from "./SortableStepCard.svelte"; import SortableStepCard from "./SortableStepCard.svelte";
import StepDropIndicator from "./StepDropIndicator.svelte"; import StepReorderOverlay from "./StepReorderOverlay.svelte";
import ToolPickerButton from "./ToolPickerButton.svelte"; import ToolPickerButton from "./ToolPickerButton.svelte";
import { hasPreviewResult } from "./schema-preview-model"; import { hasPreviewResult } from "./schema-preview-model";
import { createSchemaToolRunner } from "./schema-tool-runner.svelte"; import { createSchemaToolRunner } from "./schema-tool-runner.svelte";
@@ -613,7 +613,7 @@
{/snippet} {/snippet}
{#snippet addRow(index: number)} {#snippet addRow(index: number)}
{#if canExtend && !stepDnd.active} {#if canExtend}
<div class="add-row"> <div class="add-row">
<AddStepButton onadd={(id) => addStepAt(index, id)} /> <AddStepButton onadd={(id) => addStepAt(index, id)} />
{#if alignedMode} {#if alignedMode}
@@ -623,17 +623,6 @@
{/if} {/if}
{/snippet} {/snippet}
{#snippet indicator(slot: number)}
{#if stepDnd.isIndicatorAt(slot)}
<div class="indicator-row">
<StepDropIndicator height={stepDnd.dragHeight} />
{#if alignedMode}
<div class="row-bridge" aria-hidden="true"></div>
{/if}
</div>
{/if}
{/snippet}
<DragDropProvider <DragDropProvider
onDragStart={stepDnd.onDragStart} onDragStart={stepDnd.onDragStart}
onDragMove={stepDnd.onDragOver} onDragMove={stepDnd.onDragOver}
@@ -641,12 +630,11 @@
onDragEnd={stepDnd.onDragEnd} onDragEnd={stepDnd.onDragEnd}
> >
{#if alignedMode} {#if alignedMode}
<div class="workspace aligned" class:dnd-active={stepDnd.active}> <div class="workspace aligned">
<div class="head-spacer" aria-hidden="true"></div> <div class="head-spacer" aria-hidden="true"></div>
{@render panelHead()} {@render panelHead()}
{@render addRow(0)} {@render addRow(0)}
{#each steps as step, i (step.key)} {#each steps as step, i (step.key)}
{@render indicator(i)}
{@render stepCard(step, i)} {@render stepCard(step, i)}
<div class="pair"> <div class="pair">
{@render inputTile(i)} {@render inputTile(i)}
@@ -654,19 +642,16 @@
</div> </div>
{@render addRow(i + 1)} {@render addRow(i + 1)}
{/each} {/each}
{@render indicator(steps.length)}
<div class="panel-foot" aria-hidden="true"></div> <div class="panel-foot" aria-hidden="true"></div>
</div> </div>
{:else} {:else}
<div class="workspace" class:dnd-active={stepDnd.active}> <div class="workspace">
<section class="settings"> <section class="settings">
{@render addRow(0)} {@render addRow(0)}
{#each steps as step, i (step.key)} {#each steps as step, i (step.key)}
{@render indicator(i)}
{@render stepCard(step, i)} {@render stepCard(step, i)}
{@render addRow(i + 1)} {@render addRow(i + 1)}
{/each} {/each}
{@render indicator(steps.length)}
</section> </section>
<section class="panel"> <section class="panel">
{@render panelHead()} {@render panelHead()}
@@ -685,17 +670,7 @@
{#each allWarnings as w (warningText(w))} {#each allWarnings as w (warningText(w))}
<p class="warn" role="status">{warningText(w)}</p> <p class="warn" role="status">{warningText(w)}</p>
{/each} {/each}
{#if stepDnd.activeData && stepDnd.pointer} <StepReorderOverlay {steps} dnd={stepDnd} />
<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> </DragDropProvider>
</div> </div>
{:else} {:else}
@@ -708,23 +683,6 @@
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;
@@ -859,8 +817,7 @@
.aligned .panel-foot { .aligned .panel-foot {
align-self: stretch; align-self: stretch;
} }
.aligned .add-row, .aligned .add-row {
.aligned .indicator-row {
grid-column: 1 / -1; grid-column: 1 / -1;
display: grid; display: grid;
grid-template-columns: subgrid; grid-template-columns: subgrid;
@@ -879,19 +836,6 @@
border-radius: 0 0 var(--radius-m) var(--radius-m); border-radius: 0 0 var(--radius-m) var(--radius-m);
background: var(--color-panel); background: var(--color-panel);
} }
/* Compact drag: while a step is dragged, cards collapse to headers so the
reorder happens in a tight list; aligned additionally drops the preview
panel (its rows are too tall to drag across). */
.dnd-active :global(.step-body) {
display: none;
}
.aligned.dnd-active .panel-head,
.aligned.dnd-active .pair,
.aligned.dnd-active .row-bridge,
.aligned.dnd-active .panel-foot,
.aligned.dnd-active .head-spacer {
display: none;
}
.error { .error {
margin: var(--space-l) 0 0; margin: var(--space-l) 0 0;
color: var(--color-danger); color: var(--color-danger);
@@ -1,23 +0,0 @@
<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>
@@ -0,0 +1,125 @@
<script lang="ts">
import { chainStepTitle } from "$lib/i18n/schema-tool-strings";
import type { ChainStep } from "$lib/pipeline.svelte";
import { GripVertical } from "@lucide/svelte";
import type { StepDnd } from "./step-dnd.svelte";
interface Props {
steps: ChainStep[];
dnd: StepDnd;
}
let { steps, dnd }: Props = $props();
let listEl = $state<HTMLElement | null>(null);
$effect(() => {
dnd.registerList(listEl);
return () => dnd.registerList(null);
});
// Align the overlay so its source row sits exactly under the cursor (the
// dragged card's grab point); the page behind never moves.
const top = $derived.by(() => {
const anchor = dnd.anchor;
const el = listEl;
if (!anchor || !el || steps.length === 0) return null;
const rowH = el.scrollHeight / steps.length;
const offsetInSource = anchor.startY - anchor.sourceTop;
const raw = anchor.startY - (dnd.sourceIndex ?? 0) * rowH - offsetInSource;
const margin = 16;
const maxTop = window.innerHeight - el.offsetHeight - margin;
return Math.min(Math.max(raw, margin), Math.max(margin, maxTop));
});
const lineTop = $derived.by(() => {
const slot = dnd.indicatorSlot;
const el = listEl;
if (slot === null || !el || steps.length === 0) return null;
const rowH = el.scrollHeight / steps.length;
return slot * rowH - el.scrollTop;
});
</script>
{#if dnd.activeData && dnd.anchor}
<div class="reorder-scrim" aria-hidden="true"></div>
<div
class="reorder-overlay"
style:left="{dnd.anchor.left}px"
style:top="{top ?? dnd.anchor.sourceTop}px"
style:width="{dnd.anchor.width}px"
style:visibility={top === null ? "hidden" : "visible"}
>
<ol class="reorder-list" bind:this={listEl}>
{#each steps as step, i (step.key)}
<li class:dimmed={i === dnd.sourceIndex}>
<GripVertical size={14} aria-hidden="true" />
<span class="reorder-index">{String(i + 1).padStart(2, "0")}</span>
<span class="reorder-title">{chainStepTitle(step.id)}</span>
</li>
{/each}
</ol>
{#if lineTop !== null}
<div class="reorder-line" style:top="{lineTop}px"></div>
{/if}
</div>
{/if}
<style>
/* The scrim dims the page so the floating list does not blend into the
step cards underneath; it never intercepts the drag. */
.reorder-scrim {
position: fixed;
inset: 0;
z-index: var(--z-dropdown);
background: var(--color-scrim);
pointer-events: none;
}
.reorder-overlay {
position: fixed;
z-index: var(--z-drag);
display: flex;
flex-direction: column;
overflow: hidden;
max-height: 60vh;
border: var(--size-border-thick) solid var(--color-main);
background: var(--color-panel);
}
.reorder-list {
margin: 0;
padding: 0;
list-style: none;
overflow-y: auto;
}
.reorder-list li {
display: flex;
align-items: center;
gap: var(--space-m);
padding: var(--space-s) var(--space-l);
border-bottom: var(--size-border) solid var(--color-border);
color: var(--color-border);
white-space: nowrap;
}
.reorder-list li:last-child {
border-bottom: none;
}
.reorder-list li.dimmed {
opacity: 0.45;
}
.reorder-index {
font: var(--font-size-s) var(--font-mono);
color: var(--color-main);
}
.reorder-title {
overflow: hidden;
text-overflow: ellipsis;
font: 600 var(--font-size-m) var(--font-mono);
color: var(--color-text);
}
.reorder-line {
position: absolute;
right: 0;
left: 0;
height: var(--size-border-thick);
background: var(--color-main);
pointer-events: none;
}
</style>
@@ -1,5 +1,5 @@
import type { Draggable, Droppable } from "@dnd-kit/dom"; import type { Draggable } from "@dnd-kit/dom";
import { isSortable, type SortableDraggable } from "@dnd-kit/dom/sortable"; import { isSortable } from "@dnd-kit/dom/sortable";
export interface StepDragData { export interface StepDragData {
title: string; title: string;
@@ -19,139 +19,103 @@ interface DndEvent {
canceled?: boolean; canceled?: boolean;
} }
// Count the slot the pointer is over: how many non-source cards have their export interface StepDragAnchor {
// vertical midpoint above the pointer. Also reports the height of the first left: number;
// visible card: during drag all cards collapse to headers, so the indicator width: number;
// takes that uniform height rather than the hidden source's stale height. // Pointer Y and the source card's top at drag start: the overlay aligns
function indexAtPosition( // its source row under the cursor.
source: SortableDraggable<StepDragData>, startY: number;
pointerY: number, sourceTop: number;
): { index: number; cardHeight: number } {
const manager = source.sortable.manager;
let index = 0;
let cardHeight = 0;
if (!manager) return { index, cardHeight };
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 (!cardHeight) cardHeight = rect.height;
if (pointerY > rect.top + rect.height / 2) index++;
}
return { index, cardHeight };
} }
// Step-reorder state for SchemaToolView, single list. The dragged card is // Step-reorder state for SchemaToolView. Dragging a card handle opens a
// hidden (DragOverlay renders the ghost) and a dashed indicator of the // compact overlay list (StepReorderOverlay) instead of reshaping the page:
// dragged height marks the insertion slot between the remaining cards. // the page keeps its layout and scroll position, drop targeting is computed
// against the overlay rows.
export function createStepDnd({ onMove }: StepDndOptions) { export function createStepDnd({ onMove }: StepDndOptions) {
let activeData = $state<StepDragData | null>(null); let activeData = $state<StepDragData | null>(null);
let indicatorIndex = $state<number | null>(null); let indicatorSlot = $state<number | null>(null);
let dragHeight = $state(0);
let sourceIndex = $state<number | null>(null); let sourceIndex = $state<number | null>(null);
let sourceElement: HTMLElement | null = null; let anchor = $state<StepDragAnchor | null>(null);
// Ghost positioning: current pointer, grab offset inside the card and the let listEl: HTMLElement | null = null;
// 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 { // Slot in full-list coordinates: how many overlay rows have their midpoint
const source = event.operation?.source; // above the pointer. Rows are uniform single-line height.
if (!source || !isSortable(source)) return null; function slotAt(position: { x: number; y: number }): number | null {
const position = event.operation?.position?.current; const el = listEl;
if (!position || !sourceX) return null; if (!el || el.childElementCount === 0) return null;
if (position.x < sourceX.left || position.x > sourceX.right) return null; const rect = el.getBoundingClientRect();
const { index, cardHeight } = indexAtPosition( if (position.x < rect.left || position.x > rect.right) return null;
source as SortableDraggable<StepDragData>, const count = el.childElementCount;
position.y, const rowH = el.scrollHeight / count;
); if (rowH <= 0) return null;
if (cardHeight) dragHeight = cardHeight; const offsetY = position.y - rect.top + el.scrollTop;
return index; const slot = Math.floor(offsetY / rowH + 0.5);
return Math.min(Math.max(slot, 0), count);
} }
function onDragStart(event: DndEvent) { function onDragStart(event: DndEvent) {
const source = event.operation?.source; const source = event.operation?.source;
if (!source) return; if (!source) return;
activeData = (source.data as StepDragData | undefined) ?? null; 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 (isSortable(source)) sourceIndex = source.sortable.initialIndex;
if (sourceElement) sourceElement.style.display = "none"; const el = source.element as HTMLElement | undefined;
const rect = el?.getBoundingClientRect();
const position = event.operation?.position?.current;
if (rect) {
anchor = {
left: rect.left,
width: rect.width,
startY: position?.y ?? rect.top + rect.height / 2,
sourceTop: rect.top,
};
}
} }
function onDragOver(event: DndEvent) { function onDragOver(event: DndEvent) {
pointer = event.operation?.position?.current ?? pointer; const position = event.operation?.position?.current;
indicatorIndex = computeIndicator(event); indicatorSlot = position ? slotAt(position) : null;
} }
function onDragEnd(event: DndEvent) { function onDragEnd(event: DndEvent) {
const drop = event.canceled ? null : computeIndicator(event); const position = event.operation?.position?.current;
if (sourceElement) { const slot = event.canceled || !position ? null : slotAt(position);
sourceElement.style.display = "";
sourceElement = null;
}
const from = sourceIndex; const from = sourceIndex;
activeData = null; activeData = null;
indicatorIndex = null; indicatorSlot = null;
dragHeight = 0;
sourceIndex = null; sourceIndex = null;
sourceX = null; anchor = null;
pointer = null; if (from === null || slot === null) return;
if (from === null || drop === null || drop === from) return; // Slots include the source row; moveStep wants the compressed index.
onMove(from, drop); const to = slot > from ? slot - 1 : slot;
if (to === from) return;
onMove(from, to);
} }
// Slots render in the full list where the hidden source still occupies function registerList(el: HTMLElement | null) {
// its DOM position: a compressed index at/past the source shifts by 1. listEl = el;
function isIndicatorAt(slot: number): boolean {
if (indicatorIndex === null) return false;
let renderIndex = indicatorIndex;
if (sourceIndex !== null && indicatorIndex >= sourceIndex) {
renderIndex += 1;
}
return renderIndex === slot;
} }
return { return {
get activeData() { get activeData() {
return activeData; return activeData;
}, },
get dragHeight() {
return dragHeight;
},
get pointer() {
return pointer;
},
get grabOffset() {
return grabOffset;
},
get ghostWidth() {
return ghostWidth;
},
get active() { get active() {
return activeData !== null; return activeData !== null;
}, },
get indicatorSlot() {
return indicatorSlot;
},
get sourceIndex() {
return sourceIndex;
},
get anchor() {
return anchor;
},
onDragStart, onDragStart,
onDragOver, onDragOver,
onDragEnd, onDragEnd,
isIndicatorAt, registerList,
}; };
} }