fix: update dnd tools in pipeline

This commit is contained in:
2026-10-11 02:16:44 +05:00
parent 7bbcec5ff0
commit e866975045
4 changed files with 348 additions and 673 deletions
@@ -1,374 +0,0 @@
<script lang="ts">
import ChipButton from "$lib/components/ui/ChipButton.svelte";
import Toggle from "$lib/components/ui/Toggle.svelte";
import { toDataUrl, type OutputMime } from "$lib/core/io";
import type { PixelImage } from "$lib/core/types";
import { t } from "$lib/i18n/t";
import type { ChainStep } from "$lib/pipeline.svelte";
import type {
FileResult,
InputMode,
ResultKind,
ResultNote,
ToolImageFile,
} from "$lib/registry";
import type { Snippet } from "svelte";
import AddStepButton from "./AddStepButton.svelte";
import PartsGrid from "./PartsGrid.svelte";
import PreviewTile from "./PreviewTile.svelte";
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;
sourceFiles: ToolImageFile[];
textSource: string;
running: boolean;
resultKind: ResultKind;
result: PixelImage | null;
resultNote: ResultNote | null;
fileResult: FileResult | null;
textResult: string | null;
textVars: Record<string, string | number> | undefined;
toolId: string;
format: OutputMime;
quality: number | undefined;
limitKb: number | undefined;
alphaLoss: boolean;
errorText: string;
warnings: string[];
stepResults: (PixelImage | null)[];
stepFileSets: ToolImageFile[][];
stepMasks: (PixelImage | null)[];
stepMaskOn: boolean[];
stepMaskable: boolean[];
onaddstep: (index: number, toolId: string) => void;
ontogglestepmask: (index: number) => void;
ontextinput: (text: string) => void;
onrendertext: () => void;
onupload: (file: File) => void;
onuploadmany: (files: File[]) => void;
ondownload: () => void;
oncopy: () => void;
ondownloadtxt: () => void;
onformat: (mime: OutputMime) => void;
onquality: (value: number) => void;
onlimit: (kb: number | undefined) => void;
}
let {
steps,
stepCard,
canExtend,
stepDnd,
aligned = $bindable(false),
inputMode,
source,
sourceFiles,
textSource,
running,
resultKind,
result,
resultNote,
fileResult,
textResult,
textVars,
toolId,
format,
quality,
limitKb,
alphaLoss,
errorText,
warnings,
stepResults,
stepFileSets,
stepMasks,
stepMaskOn,
stepMaskable,
onaddstep,
ontogglestepmask,
ontextinput,
onrendertext,
onupload,
onuploadmany,
ondownload,
oncopy,
ondownloadtxt,
onformat,
onquality,
onlimit,
}: Props = $props();
</script>
{#snippet maskChip(i: number)}
{#if stepMaskable[i]}
<ChipButton
label={t("resultCard.maskToggle")}
active={stepMaskOn[i]}
onclick={() => ontogglestepmask(i)}
/>
{/if}
{/snippet}
<div class="aligned">
<div class="aligned-row">
<div></div>
<div class="panel-segment head-segment">
<span class="label">{t("resultCard.previewPanel")}</span>
<label class="align-toggle">
<Toggle bind:checked={aligned} label={t("chain.alignToggle")} />
<span>{t("chain.alignToggle")}</span>
</label>
<SchemaActions
{inputMode}
{resultKind}
canDownload={result !== null}
{running}
{format}
{quality}
{limitKb}
{alphaLoss}
{onupload}
{ondownload}
{onformat}
{onquality}
{onlimit}
/>
</div>
</div>
{#if errorText}
<p class="error" role="alert">{errorText}</p>
{/if}
{#each warnings as w (w)}
<p class="warn" role="status">{w}</p>
{/each}
{#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}
<div class="group-divider">
<div class="gd-cell"></div>
</div>
{/if}
<div class="aligned-row">
{@render stepCard(step, i)}
<div
class="panel-segment pair-segment"
class:last-segment={i === steps.length - 1 && !canExtend}
>
{#if i === 0}
<SchemaSourceTile
mode={inputMode}
{source}
sources={sourceFiles}
{textSource}
{running}
fileCount={sourceFiles.length}
{ontextinput}
{onrendertext}
{onupload}
{onuploadmany}
/>
{:else}
{@const inSet = stepFileSets[i - 1] ?? []}
<PreviewTile
label={t("chain.inputLegend")}
viewMode="image"
dims={input ? `${input.width} × ${input.height}` : undefined}
parts={inSet.length > 1 ? inSet.length : undefined}
>
{#if inSet.length > 1}
<PartsGrid files={inSet} />
{:else if input}
<img src={toDataUrl(input)} alt="" />
{:else}
<span class="empty">{t("resultCard.noResult")}</span>
{/if}
</PreviewTile>
{/if}
{#if i === steps.length - 1}
<SchemaResultTile
{resultKind}
result={out}
{resultNote}
{fileResult}
{textResult}
{textVars}
{toolId}
{running}
mask={stepMasks[i] ?? null}
maskOn={stepMaskOn[i] ?? false}
ontogglemask={stepMaskable[i]
? () => ontogglestepmask(i)
: undefined}
{oncopy}
{ondownloadtxt}
/>
{:else}
{@const outSet = stepFileSets[i] ?? []}
<PreviewTile
label={t("chain.stepResult", { n: i + 1 })}
viewMode="image"
dims={out ? `${out.width} × ${out.height}` : undefined}
parts={outSet.length > 1 ? outSet.length : undefined}
>
{#snippet actions()}
{@render maskChip(i)}
{/snippet}
{#if outSet.length > 1 && !stepMaskOn[i]}
<PartsGrid files={outSet} />
{:else if out}
<img
src={toDataUrl(
stepMaskOn[i] && stepMasks[i] ? stepMasks[i]! : out,
)}
alt=""
/>
{:else}
<span class="empty">{t("resultCard.noResult")}</span>
{/if}
</PreviewTile>
{/if}
</div>
</div>
{#if canExtend && !stepDnd?.active}
<div class="aligned-row">
<AddStepButton onadd={(id) => onaddstep(i + 1, id)} />
<div
class="panel-segment connect-segment"
class:add-last={i === steps.length - 1}
></div>
</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>
.aligned {
display: grid;
}
.aligned-row {
display: grid;
grid-template-columns: minmax(var(--size-workspace-min), 1fr) 2fr;
gap: var(--space-xxl);
align-items: start;
}
/* Сегменты справа образуют «одну панель»: общий фон, боковые границы,
верх у шапки и низ у последней строки. */
.panel-segment {
background: var(--color-panel);
border-left: var(--size-border) solid var(--color-border);
border-right: var(--size-border) solid var(--color-border);
padding: var(--space-xl);
}
.head-segment {
display: flex;
align-items: center;
gap: var(--space-xl);
flex-wrap: wrap;
border-top: var(--size-border) solid var(--color-border);
border-bottom: var(--size-border) solid var(--color-border);
border-radius: var(--radius-m) var(--radius-m) 0 0;
}
.head-segment .label {
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-l);
text-transform: uppercase;
color: var(--color-main);
}
.pair-segment {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-l);
}
/* Коннектор в строках «Add step»: продолжает боковые границы панели. */
.connect-segment {
align-self: stretch;
padding: 0;
}
.connect-segment.add-last {
border-bottom: var(--size-border) solid var(--color-border);
border-radius: 0 0 var(--radius-m) var(--radius-m);
}
/* Полоса-разделитель между группами: ячейки с фоном и боковыми границами
(как у карточки слева и панели справа), сверху — линия через всю ширину. */
.group-divider {
position: relative;
display: grid;
grid-template-columns: minmax(var(--size-workspace-min), 1fr) 2fr;
gap: var(--space-xxl);
}
.gd-cell {
grid-column: 2;
background: var(--color-panel);
border-left: var(--size-border) solid var(--color-border);
border-right: var(--size-border) solid var(--color-border);
padding: var(--space-s) 0;
}
.group-divider::after {
content: "";
position: absolute;
inset: 0 0 auto;
border-top: var(--size-border) solid var(--color-border);
}
.last-segment {
border-bottom: var(--size-border) solid var(--color-border);
border-radius: 0 0 var(--radius-m) var(--radius-m);
}
.align-toggle {
display: inline-flex;
align-items: center;
gap: var(--space-m);
margin-right: auto;
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
cursor: pointer;
}
.error {
margin: 0;
color: var(--color-danger);
font: var(--font-size-s) var(--font-mono);
}
.warn {
margin: 0;
color: var(--color-warning);
font: var(--font-size-s) var(--font-mono);
}
.empty {
font: var(--font-size-s) var(--font-mono);
}
@media (--bp-tablet) {
.aligned-row {
grid-template-columns: 1fr;
}
}
</style>
@@ -1,178 +0,0 @@
<script lang="ts">
import {
hasPreviewResult,
type PreviewHeadModel,
type PreviewResultModel,
type PreviewSourceModel,
type PreviewStepsModel,
} from "./schema-preview-model";
import SchemaActions from "./SchemaActions.svelte";
import PartsGrid from "./PartsGrid.svelte";
import SchemaResultTile from "./SchemaResultTile.svelte";
import SchemaSourceTile from "./SchemaSourceTile.svelte";
import PreviewTile from "./PreviewTile.svelte";
import Toggle from "$lib/components/ui/Toggle.svelte";
import ChipButton from "$lib/components/ui/ChipButton.svelte";
import { toDataUrl } from "$lib/core/io";
import type { PixelImage } from "$lib/core/types";
import { t } from "$lib/i18n/t";
import type { InputMode, ResultKind } from "$lib/registry";
interface Props {
inputMode: InputMode;
resultKind?: ResultKind;
toolId: string;
running?: boolean;
error?: string;
aligned?: boolean;
head: PreviewHeadModel;
source: PreviewSourceModel;
steps?: PreviewStepsModel;
result: PreviewResultModel;
}
let {
inputMode,
resultKind = "image",
toolId,
running = false,
error = "",
aligned = $bindable(false),
head,
source,
steps = {},
result,
}: Props = $props();
const stepResults = $derived(steps.results ?? []);
const stepFileSets = $derived(steps.fileSets ?? []);
const stepMaskable = $derived(steps.maskable ?? []);
const stepMaskOn = $derived(steps.maskOn ?? []);
const stepMasks = $derived(steps.masks ?? []);
const hasResult = $derived(
hasPreviewResult({
inputMode,
resultKind,
result: result.result,
fileResult: result.fileResult ?? null,
textResult: result.textResult ?? null,
}),
);
</script>
<div class="panel-head">
<span class="label">{t("resultCard.previewPanel")}</span>
{#if stepResults.length > 1}
<label class="align-toggle">
<Toggle bind:checked={aligned} label={t("chain.alignToggle")} />
<span>{t("chain.alignToggle")}</span>
</label>
{/if}
<SchemaActions
{inputMode}
{resultKind}
canDownload={hasResult}
{running}
{...head}
/>
</div>
{#if error}
<p class="error" role="alert">{error}</p>
{/if}
<div class="pair">
<SchemaSourceTile
mode={inputMode}
{running}
onupload={head.onupload}
{...source}
/>
{#each stepResults.slice(0, -1) as img, i (i)}
{@const set = stepFileSets[i] ?? []}
{#if set.length > 1 && !stepMaskOn[i]}
<PreviewTile
label={t("chain.stepResult", { n: i + 1 })}
viewMode="image"
parts={set.length}
partsDims={`${set[0].image.width} × ${set[0].image.height}`}
>
{#snippet actions()}
{#if stepMaskable[i]}
<ChipButton
label={t("resultCard.maskToggle")}
active={stepMaskOn[i] ?? false}
onclick={() => steps.ontogglestepmask?.(i)}
/>
{/if}
{/snippet}
<PartsGrid files={set} />
</PreviewTile>
{:else if img}
<PreviewTile
label={t("chain.stepResult", { n: i + 1 })}
viewMode="image"
dims={`${img.width} × ${img.height}`}
>
{#snippet actions()}
{#if stepMaskable[i]}
<ChipButton
label={t("resultCard.maskToggle")}
active={stepMaskOn[i] ?? false}
onclick={() => steps.ontogglestepmask?.(i)}
/>
{/if}
{/snippet}
<img
src={toDataUrl(
stepMaskOn[i] && stepMasks[i] ? (stepMasks[i] as PixelImage) : img,
)}
alt=""
/>
</PreviewTile>
{/if}
{/each}
<SchemaResultTile {resultKind} {toolId} {running} {...result} />
</div>
<style>
.panel-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-m);
padding-bottom: var(--space-l);
border-bottom: var(--size-border) solid var(--color-border);
margin-bottom: var(--space-xl);
}
.label {
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-l);
text-transform: uppercase;
color: var(--color-main);
}
.align-toggle {
display: inline-flex;
align-items: center;
gap: var(--space-m);
margin-right: auto;
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
cursor: pointer;
}
.error {
margin: 0 0 var(--space-l);
color: var(--color-danger);
font: var(--font-size-s) var(--font-mono);
}
.pair {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-l);
}
@media (--bp-tablet) {
.pair {
grid-template-columns: 1fr;
}
}
</style>
@@ -9,7 +9,7 @@
import { hasTransparency } from "$lib/core/analyze";
import { debounce } from "$lib/core/debounce";
import { ToolError } from "$lib/core/errors";
import { decodeFile, type OutputMime } from "$lib/core/io";
import { decodeFile, toDataUrl, type OutputMime } from "$lib/core/io";
import { outputFormatByMime } from "$lib/output-formats";
import type { PixelImage } from "$lib/core/types";
import { baseName, uniqueName, type ChainWarning } from "$lib/run-chain";
@@ -44,15 +44,21 @@
clampSourceAwareMaxes,
sanitizeSchemaParams,
} from "$lib/registry-schema";
import ChipButton from "$lib/components/ui/ChipButton.svelte";
import Toggle from "$lib/components/ui/Toggle.svelte";
import { onMount } from "svelte";
import { SvelteSet } from "svelte/reactivity";
import AddStepButton from "./AddStepButton.svelte";
import SchemaAlignedLayout from "./SchemaAlignedLayout.svelte";
import PartsGrid from "./PartsGrid.svelte";
import PreviewTile from "./PreviewTile.svelte";
import SchemaActions from "./SchemaActions.svelte";
import SchemaFields from "./SchemaFields.svelte";
import SchemaPreview from "./SchemaPreview.svelte";
import SchemaResultTile from "./SchemaResultTile.svelte";
import SchemaSourceTile from "./SchemaSourceTile.svelte";
import SortableStepCard from "./SortableStepCard.svelte";
import StepDropIndicator from "./StepDropIndicator.svelte";
import ToolPickerButton from "./ToolPickerButton.svelte";
import { hasPreviewResult } from "./schema-preview-model";
import { createSchemaToolRunner } from "./schema-tool-runner.svelte";
import { createStepDnd } from "./step-dnd.svelte";
import {
@@ -175,6 +181,15 @@
const alignedMode = $derived(
aligned && resultKind === "image" && steps.length > 1,
);
const hasResult = $derived(
hasPreviewResult({
inputMode,
resultKind: displayResultKind,
result,
fileResult,
textResult,
}),
);
const alphaLoss = $derived(
resultKind === "image" &&
@@ -477,132 +492,199 @@
</SortableStepCard>
{/snippet}
{#snippet maskChip(i: number)}
{#if stepMaskable[i]}
<ChipButton
label={t("resultCard.maskToggle")}
active={stepMaskOn[i] ?? false}
onclick={() => toggleStepMaskByIndex(i)}
/>
{/if}
{/snippet}
{#snippet inputTile(i: number)}
{#if i === 0}
<SchemaSourceTile
mode={inputMode}
{source}
sources={sourceFiles}
{textSource}
running={busy}
fileCount={sourceFiles.length}
ontextinput={(v) => (textSource = v)}
onrendertext={run}
onupload={handleFile}
onuploadmany={handleFiles}
/>
{:else}
{@const input = stepResults[i - 1]}
{@const inSet = stepFileSets[i - 1] ?? []}
<PreviewTile
label={t("chain.inputLegend")}
viewMode="image"
dims={input ? `${input.width} × ${input.height}` : undefined}
parts={inSet.length > 1 ? inSet.length : undefined}
>
{#if inSet.length > 1}
<PartsGrid files={inSet} />
{:else if input}
<img src={toDataUrl(input)} alt="" />
{:else}
<span class="empty">{t("resultCard.noResult")}</span>
{/if}
</PreviewTile>
{/if}
{/snippet}
{#snippet outputTile(i: number)}
{@const out = stepResults[i]}
{#if i === steps.length - 1}
<SchemaResultTile
resultKind={displayResultKind}
result={out}
{resultNote}
{fileResult}
{textResult}
textVars={verdictVars}
toolId={lastTool.id}
running={busy}
mask={stepMasks[i] ?? null}
maskOn={stepMaskOn[i] ?? false}
ontogglemask={stepMaskable[i]
? () => toggleStepMaskByIndex(i)
: undefined}
oncopy={copyText}
ondownloadtxt={downloadText}
/>
{:else}
{@const outSet = stepFileSets[i] ?? []}
<PreviewTile
label={t("chain.stepResult", { n: i + 1 })}
viewMode="image"
dims={out ? `${out.width} × ${out.height}` : undefined}
parts={outSet.length > 1 ? outSet.length : undefined}
>
{#snippet actions()}
{@render maskChip(i)}
{/snippet}
{#if outSet.length > 1 && !stepMaskOn[i]}
<PartsGrid files={outSet} />
{:else if out}
<img
src={toDataUrl(
stepMaskOn[i] && stepMasks[i]
? (stepMasks[i] as PixelImage)
: out,
)}
alt=""
/>
{:else}
<span class="empty">{t("resultCard.noResult")}</span>
{/if}
</PreviewTile>
{/if}
{/snippet}
{#snippet panelHead()}
<div class="panel-head">
<span class="label">{t("resultCard.previewPanel")}</span>
{#if resultKind === "image" && steps.length > 1}
<label class="align-toggle">
<Toggle bind:checked={aligned} label={t("chain.alignToggle")} />
<span>{t("chain.alignToggle")}</span>
</label>
{/if}
<SchemaActions
{inputMode}
resultKind={displayResultKind}
canDownload={hasResult}
running={busy}
{format}
quality={currentQuality}
{limitKb}
{alphaLoss}
onupload={handleFile}
ondownload={download}
onformat={(v) => (format = v)}
onquality={setFormatQuality}
onlimit={(v) => (limitKb = v)}
/>
</div>
{/snippet}
{#snippet addRow(index: number)}
{#if canExtend && !stepDnd.active}
<div class="add-row">
<AddStepButton onadd={(id) => addStepAt(index, id)} />
{#if alignedMode}
<div class="row-bridge" aria-hidden="true"></div>
{/if}
</div>
{/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}
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}
{#if alignedMode}
<div class="workspace aligned" class:dnd-active={stepDnd.active}>
<div class="head-spacer" aria-hidden="true"></div>
{@render panelHead()}
{@render addRow(0)}
{#each steps as step, i (step.key)}
{#if stepDnd.isIndicatorAt(i)}
<StepDropIndicator height={stepDnd.dragHeight} />
{/if}
{@render indicator(i)}
{@render stepCard(step, i)}
{#if canExtend && !stepDnd.active}
<AddStepButton onadd={(id) => addStepAt(i + 1, id)} />
{/if}
<div class="pair">
{@render inputTile(i)}
{@render outputTile(i)}
</div>
{@render addRow(i + 1)}
{/each}
{#if stepDnd.isIndicatorAt(steps.length)}
<StepDropIndicator height={stepDnd.dragHeight} />
{/if}
{@render indicator(steps.length)}
<div class="panel-foot" aria-hidden="true"></div>
</div>
{:else}
<div class="workspace" class:dnd-active={stepDnd.active}>
<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">
<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,
}}
/>
{@render panelHead()}
<div class="pair-grid">
<div class="cell">{@render inputTile(0)}</div>
{#each steps as step, i (step.key)}
<div class="cell">{@render outputTile(i)}</div>
{/each}
</div>
</section>
</div>
{/if}
{#if errorText}
<p class="error" role="alert">{errorText}</p>
{/if}
{#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"
@@ -678,6 +760,11 @@
font-size: var(--font-size-s);
line-height: 1.5;
}
/* Two scaffoldings over shared snippets: normal mode keeps two independent
columns (cards pack under each other, tiles pack two per row inside one
panel); aligned mode is a flat 3-column grid where each card shares its
grid row with the input/output pair and the right side reads as one
continuous panel. */
.workspace {
display: grid;
grid-template-columns: minmax(var(--size-workspace-min), 1fr) minmax(
@@ -687,23 +774,157 @@
gap: var(--space-xxl);
align-items: start;
}
.settings {
display: flex;
flex-direction: column;
}
.panel {
padding: var(--space-xl);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-m);
background: var(--color-panel);
}
.panel-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-m);
padding-bottom: var(--space-l);
border-bottom: var(--size-border) solid var(--color-border);
margin-bottom: var(--space-xl);
}
.label {
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-l);
text-transform: uppercase;
color: var(--color-main);
}
.align-toggle {
display: inline-flex;
align-items: center;
gap: var(--space-m);
margin-right: auto;
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
cursor: pointer;
}
.pair-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-l);
}
.cell {
min-width: 0;
}
/* Aligned mode: cards in column 1, each step's input/output pair spans
columns 2-3 on the card's row. The right side forms one panel: head and
foot carry the top/bottom borders, pairs and row bridges carry the side
borders, so rows have no vertical gaps. */
.workspace.aligned {
grid-template-columns: minmax(var(--size-workspace-min), 1fr) 1fr 1fr;
gap: 0 var(--space-xxl);
}
.aligned > :global(.step-card) {
grid-column: 1;
}
.aligned .head-spacer {
grid-column: 1;
}
.aligned .panel-head {
grid-column: 2 / -1;
padding: var(--space-l) var(--space-xl);
border: var(--size-border) solid var(--color-border);
border-bottom: none;
border-radius: var(--radius-m) var(--radius-m) 0 0;
background: var(--color-panel);
margin-bottom: 0;
}
.aligned .pair {
grid-column: 2 / -1;
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-l);
padding: var(--space-xl);
border-right: var(--size-border) solid var(--color-border);
border-left: var(--size-border) solid var(--color-border);
background: var(--color-panel);
}
/* The workspace has align-items: start; panel pieces must stretch to the
row height or the side borders break when a card is taller than its
pair. */
.aligned .panel-head,
.aligned .pair,
.aligned .row-bridge,
.aligned .panel-foot {
align-self: stretch;
}
.aligned .add-row,
.aligned .indicator-row {
grid-column: 1 / -1;
display: grid;
grid-template-columns: subgrid;
}
.aligned .row-bridge {
grid-column: 2 / -1;
border-right: var(--size-border) solid var(--color-border);
border-left: var(--size-border) solid var(--color-border);
background: var(--color-panel);
}
.aligned .panel-foot {
grid-column: 2 / -1;
min-height: var(--space-xl);
border: var(--size-border) solid var(--color-border);
border-top: none;
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);
font: var(--font-size-s) var(--font-mono);
}
.empty {
font: var(--font-size-s) var(--font-mono);
}
.no-schema {
font: var(--font-size-s) var(--font-mono);
}
.warn {
margin: 0;
margin: var(--space-s) 0 0;
color: var(--color-warning);
font: var(--font-size-s) var(--font-mono);
}
@media (--bp-tablet) {
.workspace {
.workspace,
.workspace.aligned {
grid-template-columns: 1fr;
}
.pair-grid,
.aligned .pair {
grid-template-columns: 1fr;
}
.aligned .head-spacer,
.aligned .row-bridge {
display: none;
}
.aligned .panel-head,
.aligned .pair,
.aligned .panel-foot {
grid-column: 1 / -1;
}
}
</style>
@@ -20,14 +20,17 @@ interface DndEvent {
}
// Count the slot the pointer is over: how many non-source cards have their
// vertical midpoint above the pointer.
// 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,
): number {
): { index: number; cardHeight: number } {
const manager = source.sortable.manager;
let index = 0;
if (!manager) return index;
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;
@@ -35,9 +38,10 @@ function indexAtPosition(
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;
return { index, cardHeight };
}
// Step-reorder state for SchemaToolView, single list. The dragged card is
@@ -65,10 +69,12 @@ export function createStepDnd({ onMove }: StepDndOptions) {
const position = event.operation?.position?.current;
if (!position || !sourceX) return null;
if (position.x < sourceX.left || position.x > sourceX.right) return null;
return indexAtPosition(
const { index, cardHeight } = indexAtPosition(
source as SortableDraggable<StepDragData>,
position.y,
);
if (cardHeight) dragHeight = cardHeight;
return index;
}
function onDragStart(event: DndEvent) {