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 { ButtonVariantDefine } from "$lib/components/ui/define";
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 { hasTransparency } from "$lib/core/analyze";
import { debounce } from "$lib/core/debounce";
@@ -56,7 +56,7 @@
import SchemaResultTile from "./SchemaResultTile.svelte";
import SchemaSourceTile from "./SchemaSourceTile.svelte";
import SortableStepCard from "./SortableStepCard.svelte";
import StepDropIndicator from "./StepDropIndicator.svelte";
import StepReorderOverlay from "./StepReorderOverlay.svelte";
import ToolPickerButton from "./ToolPickerButton.svelte";
import { hasPreviewResult } from "./schema-preview-model";
import { createSchemaToolRunner } from "./schema-tool-runner.svelte";
@@ -613,7 +613,7 @@
{/snippet}
{#snippet addRow(index: number)}
{#if canExtend && !stepDnd.active}
{#if canExtend}
<div class="add-row">
<AddStepButton onadd={(id) => addStepAt(index, id)} />
{#if alignedMode}
@@ -623,17 +623,6 @@
{/if}
{/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
onDragStart={stepDnd.onDragStart}
onDragMove={stepDnd.onDragOver}
@@ -641,12 +630,11 @@
onDragEnd={stepDnd.onDragEnd}
>
{#if alignedMode}
<div class="workspace aligned" class:dnd-active={stepDnd.active}>
<div class="workspace aligned">
<div class="head-spacer" aria-hidden="true"></div>
{@render panelHead()}
{@render addRow(0)}
{#each steps as step, i (step.key)}
{@render indicator(i)}
{@render stepCard(step, i)}
<div class="pair">
{@render inputTile(i)}
@@ -654,19 +642,16 @@
</div>
{@render addRow(i + 1)}
{/each}
{@render indicator(steps.length)}
<div class="panel-foot" aria-hidden="true"></div>
</div>
{:else}
<div class="workspace" class:dnd-active={stepDnd.active}>
<div class="workspace">
<section class="settings">
{@render addRow(0)}
{#each steps as step, i (step.key)}
{@render indicator(i)}
{@render stepCard(step, i)}
{@render addRow(i + 1)}
{/each}
{@render indicator(steps.length)}
</section>
<section class="panel">
{@render panelHead()}
@@ -685,17 +670,7 @@
{#each allWarnings as w (warningText(w))}
<p class="warn" role="status">{warningText(w)}</p>
{/each}
{#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}
<StepReorderOverlay {steps} dnd={stepDnd} />
</DragDropProvider>
</div>
{:else}
@@ -708,23 +683,6 @@
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;
@@ -859,8 +817,7 @@
.aligned .panel-foot {
align-self: stretch;
}
.aligned .add-row,
.aligned .indicator-row {
.aligned .add-row {
grid-column: 1 / -1;
display: grid;
grid-template-columns: subgrid;
@@ -879,19 +836,6 @@
border-radius: 0 0 var(--radius-m) var(--radius-m);
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 {
margin: var(--space-l) 0 0;
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 { isSortable, type SortableDraggable } from "@dnd-kit/dom/sortable";
import type { Draggable } from "@dnd-kit/dom";
import { isSortable } from "@dnd-kit/dom/sortable";
export interface StepDragData {
title: string;
@@ -19,139 +19,103 @@ interface DndEvent {
canceled?: boolean;
}
// Count the slot the pointer is over: how many non-source cards have their
// vertical midpoint above the pointer. Also reports the height of the first
// visible card: during drag all cards collapse to headers, so the indicator
// takes that uniform height rather than the hidden source's stale height.
function indexAtPosition(
source: SortableDraggable<StepDragData>,
pointerY: 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 };
export interface StepDragAnchor {
left: number;
width: number;
// Pointer Y and the source card's top at drag start: the overlay aligns
// its source row under the cursor.
startY: number;
sourceTop: number;
}
// 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.
// Step-reorder state for SchemaToolView. Dragging a card handle opens a
// compact overlay list (StepReorderOverlay) instead of reshaping the page:
// the page keeps its layout and scroll position, drop targeting is computed
// against the overlay rows.
export function createStepDnd({ onMove }: StepDndOptions) {
let activeData = $state<StepDragData | null>(null);
let indicatorIndex = $state<number | null>(null);
let dragHeight = $state(0);
let indicatorSlot = $state<number | null>(null);
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;
let anchor = $state<StepDragAnchor | null>(null);
let listEl: HTMLElement | 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;
const { index, cardHeight } = indexAtPosition(
source as SortableDraggable<StepDragData>,
position.y,
);
if (cardHeight) dragHeight = cardHeight;
return index;
// Slot in full-list coordinates: how many overlay rows have their midpoint
// above the pointer. Rows are uniform single-line height.
function slotAt(position: { x: number; y: number }): number | null {
const el = listEl;
if (!el || el.childElementCount === 0) return null;
const rect = el.getBoundingClientRect();
if (position.x < rect.left || position.x > rect.right) return null;
const count = el.childElementCount;
const rowH = el.scrollHeight / count;
if (rowH <= 0) return null;
const offsetY = position.y - rect.top + el.scrollTop;
const slot = Math.floor(offsetY / rowH + 0.5);
return Math.min(Math.max(slot, 0), count);
}
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";
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) {
pointer = event.operation?.position?.current ?? pointer;
indicatorIndex = computeIndicator(event);
const position = event.operation?.position?.current;
indicatorSlot = position ? slotAt(position) : null;
}
function onDragEnd(event: DndEvent) {
const drop = event.canceled ? null : computeIndicator(event);
if (sourceElement) {
sourceElement.style.display = "";
sourceElement = null;
}
const position = event.operation?.position?.current;
const slot = event.canceled || !position ? null : slotAt(position);
const from = sourceIndex;
activeData = null;
indicatorIndex = null;
dragHeight = 0;
indicatorSlot = null;
sourceIndex = null;
sourceX = null;
pointer = null;
if (from === null || drop === null || drop === from) return;
onMove(from, drop);
anchor = null;
if (from === null || slot === null) return;
// Slots include the source row; moveStep wants the compressed index.
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
// 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;
function registerList(el: HTMLElement | null) {
listEl = el;
}
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;
},
get indicatorSlot() {
return indicatorSlot;
},
get sourceIndex() {
return sourceIndex;
},
get anchor() {
return anchor;
},
onDragStart,
onDragOver,
onDragEnd,
isIndicatorAt,
registerList,
};
}