Files
easy-png-tools2/web/src/lib/components/schema/SchemaToolView.svelte
T

669 lines
18 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import Button from "$lib/components/ui/Button.svelte";
import ChipButton from "$lib/components/ui/ChipButton.svelte";
import Toggle from "$lib/components/ui/Toggle.svelte";
import { ButtonVariantDefine } from "$lib/components/ui/define";
import { debounce } from "$lib/core/debounce";
import { cachedDataUrl } from "$lib/core/io";
import type { PixelImage } from "$lib/core/types";
import { isFav, toggleFav } from "$lib/favorites.svelte";
import {
chainStepTitle,
chainWarningNotice,
pageDescription,
pageTitle,
type WarningNotice,
} from "$lib/i18n/schema-tool-strings";
import { t } from "$lib/i18n/t";
import { moveStep, type ChainStep } from "$lib/pipeline.svelte";
import { getTool, type Page, type Tool } from "$lib/registry";
import { DragDropProvider } from "@dnd-kit/svelte";
import { Star } from "@lucide/svelte";
import AddStepButton from "./AddStepButton.svelte";
import PartsGrid from "./PartsGrid.svelte";
import PreviewTile from "./PreviewTile.svelte";
import SchemaActions from "./SchemaActions.svelte";
import SchemaFields from "./SchemaFields.svelte";
import SchemaResultTile from "./SchemaResultTile.svelte";
import SchemaSourceTile from "./SchemaSourceTile.svelte";
import SortableStepCard from "./SortableStepCard.svelte";
import StepReorderOverlay from "./StepReorderOverlay.svelte";
import ToolPickerButton from "./ToolPickerButton.svelte";
import { createChainHost } from "./chain-host.svelte";
import { createOutputControls } from "./output-controls.svelte";
import { hasPreviewResult } from "./schema-preview-model";
import type { DisplayError } from "./schema-tool-runner.svelte";
import { createSchemaToolRunner } from "./schema-tool-runner.svelte";
import {
createSchemaToolState,
toolSchemaOf,
} from "./schema-tool-state.svelte";
import { createSourceUpload } from "./source-upload.svelte";
import { createStepDnd } from "./step-dnd.svelte";
import { createStepMasks } from "./step-mask.svelte";
interface Props {
page: Page;
tool: Tool;
/** Named-chain mode: the chain id and display name; page is virtual. */
chainId?: string;
}
let { page, tool, chainId = undefined }: Props = $props();
const schema = $derived(tool.schema);
const inputMode = $derived(tool.input);
let aligned = $state(false);
// Composition root: feature state lives in sibling composables
// (source-upload, output-controls, step-mask, chain-host); execution in
// schema-tool-runner, step state in schema-tool-state.
const upload = createSourceUpload({
inputMode: () => inputMode,
steps: () => stepState.steps,
setSteps: (v) => (stepState.steps = v),
touchedFor: (key) => stepState.touchedFor(key),
reportError: (e) => runner.reportError(e),
clearError: () => runner.clearSourceError(),
});
const stepState = createSchemaToolState({
source: () => upload.source,
stepDims: () => runner.stepDims,
clearResults: () => runner.clearResults(),
});
const {
setStepValue,
resetStep,
addStepAt,
removeStepAt,
toggleStep,
replaceStepTool,
} = stepState;
const steps = $derived(stepState.steps);
const stepDnd = createStepDnd({
onMove: (from, to) => {
stepState.steps = moveStep(stepState.steps, from, to);
},
});
const lastTool = $derived(
(steps.length > 0 ? getTool(steps[steps.length - 1].id) : undefined) ??
tool,
);
const output = createOutputControls({
lastTool: () => lastTool,
steps: () => steps,
result: () => runner.result,
setStepValue,
});
const runner = createSchemaToolRunner({
pageSlug: () => page.slug,
schema: () => schema,
steps: () => steps,
source: () => upload.source,
sourceFiles: () => upload.sourceFiles,
textSource: () => upload.textSource,
format: () => output.format,
quality: () => output.quality,
limitKb: () => output.limitKb,
lastTool: () => lastTool,
});
const { run, download, copyText, downloadText } = runner;
const running = $derived(runner.running);
// Display-level busy: either a run or a download is in flight.
const busy = $derived(running || runner.downloading);
const result = $derived(runner.result);
const fileResult = $derived(runner.fileResult);
const textResult = $derived(runner.textResult);
const verdictVars = $derived(runner.verdictVars);
const stepDims = $derived(runner.stepDims);
const stepResults = $derived(runner.stepResults);
const stepFileSets = $derived(runner.stepFileSets);
const runWarnings = $derived(runner.runWarnings);
// Feedback zones: source tile / step tiles / download button area.
const sourceNotices = $derived(upload.sourceWarnings.map(chainWarningNotice));
const warningsByStep = $derived.by(() => {
const byStep: WarningNotice[][] = steps.map(() => []);
for (const w of runWarnings) {
if ("step" in w && w.step >= 1 && w.step <= byStep.length) {
byStep[w.step - 1].push(chainWarningNotice(w));
}
}
return byStep;
});
const downloadNotices = $derived(
runner.downloadWarnings.map(chainWarningNotice),
);
const stepError = $derived(runner.stepError);
const sourceError = $derived(runner.sourceError);
const actionError = $derived(runner.actionError);
function displayText(e: DisplayError | null): string {
if (!e) return "";
return e.kind === "i18n" ? t(e.key, e.vars) : e.text;
}
function errorBadge(e: DisplayError | null) {
return e
? [{ label: t("actions.actionFailed"), detail: displayText(e) }]
: [];
}
const masks = createStepMasks({
steps: () => steps,
source: () => upload.source,
stepResults: () => runner.stepResults,
});
const host = createChainHost({
page: () => page,
chainId: () => chainId,
schema: () => schema,
lastTool: () => lastTool,
steps: () => steps,
stepState,
source: upload,
output,
resetRun: () => runner.resetForHost(),
clearMasks: () => masks.clear(),
});
const resultKind = $derived(lastTool.result ?? "image");
// Display kind follows the actual output: a batch run of an image tool
// produces a file set, not a single image.
const displayResultKind = $derived(fileResult ? "files" : resultKind);
// Chain can be extended while the last step outputs images (one or many).
const canExtend = $derived(
["image", "files"].includes(lastTool.result ?? "image"),
);
const alignedMode = $derived(
aligned && resultKind === "image" && steps.length > 1,
);
const hasResult = $derived(
hasPreviewResult({
inputMode,
resultKind: displayResultKind,
result,
fileResult,
textResult,
}),
);
const lastParams = $derived(steps.at(-1)?.params);
const resultNote = $derived(
result && lastTool.resultNote
? lastTool.resultNote(lastParams ?? {}, result)
: null,
);
const debouncedRun = debounce(() => run(), 200);
$effect(() => {
if (!host.inited) return;
if (inputMode === "image" && !upload.source) return;
if (inputMode === "text" && !upload.textSource.trim()) return;
void steps;
void upload.source;
debouncedRun();
return () => debouncedRun.cancel();
});
function stepTitle(toolId: string): string {
return chainStepTitle(toolId);
}
</script>
{#if schema}
<div class="schema-tool">
<header class="header">
<div class="title-block">
<h1>
{pageTitle(page)}
{#if !chainId}
<button
type="button"
class="tool-fav"
class:active={isFav(page.slug)}
aria-label={isFav(page.slug) ? t("ui.removeFav") : t("ui.addFav")}
aria-pressed={isFav(page.slug)}
onclick={() => toggleFav(page.slug)}
>
<Star
size={18}
fill={isFav(page.slug) ? "currentColor" : "none"}
/>
</button>
{/if}
</h1>
{#if pageDescription(page)}
<p class="lede">{pageDescription(page)}</p>
{/if}
</div>
{#if !chainId}
<Button
label={t("savedChains.saveAs")}
variant={ButtonVariantDefine.OUTLINE}
size="s"
onclick={host.saveAsChain}
/>
{/if}
</header>
{#snippet stepCard(step: ChainStep, i: number)}
<SortableStepCard
{step}
index={i}
title={stepTitle(step.id)}
collapsed={step.collapsed ?? false}
ontoggle={() => toggleStep(i)}
onremove={steps.length > 1 ? () => removeStepAt(step.key) : undefined}
>
{#snippet tools()}
<ToolPickerButton
label={t("chain.changeTool")}
iconOnly
onadd={(id) => replaceStepTool(i, id)}
/>
{/snippet}
{@const stepSchema = toolSchemaOf(step)}
{#if stepSchema}
<SchemaFields
schema={stepSchema}
values={step.params}
toolId={step.id}
sourceDims={i === 0
? (upload.source ?? undefined)
: stepDims[i - 1]}
onchange={(id, v, axis) => setStepValue(i, id, v, axis)}
onreset={() => resetStep(i)}
/>
{/if}
</SortableStepCard>
{/snippet}
{#snippet maskChip(i: number)}
{#if masks.maskable[i]}
<ChipButton
label={t("resultCard.maskToggle")}
active={masks.maskOn[i] ?? false}
onclick={() => masks.toggleByIndex(i)}
/>
{/if}
{/snippet}
{#snippet inputTile(i: number)}
{#if i === 0}
<SchemaSourceTile
mode={inputMode}
source={upload.source}
sources={upload.sourceFiles}
textSource={upload.textSource}
running={busy}
fileCount={upload.sourceFiles.length}
warnings={sourceNotices}
errors={errorBadge(sourceError)}
ontextinput={(v) => (upload.textSource = v)}
onrendertext={run}
onupload={upload.handleFile}
onuploadmany={upload.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={cachedDataUrl(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}
warnings={warningsByStep[i] ?? []}
errors={stepError && stepError.step === i
? [{ label: stepError.label, detail: stepError.detail }]
: []}
{fileResult}
{textResult}
textVars={verdictVars}
toolId={lastTool.id}
running={busy}
mask={masks.masks[i] ?? null}
maskOn={masks.maskOn[i] ?? false}
ontogglemask={masks.maskable[i]
? () => masks.toggleByIndex(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}
warnings={warningsByStep[i] ?? []}
errors={stepError && stepError.step === i
? [{ label: stepError.label, detail: stepError.detail }]
: []}
>
{#snippet actions()}
{@render maskChip(i)}
{/snippet}
{#if outSet.length > 1 && !masks.maskOn[i]}
<PartsGrid files={outSet} />
{:else if out}
<img
src={cachedDataUrl(
masks.maskOn[i] && masks.masks[i]
? (masks.masks[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={output.format}
quality={output.quality}
limitKb={output.limitKb}
alphaLoss={output.alphaLoss}
warnings={downloadNotices}
error={actionError
? {
label: t("actions.actionFailed"),
detail: displayText(actionError),
}
: null}
onupload={upload.handleFile}
ondownload={download}
onformat={(v) => (output.format = v)}
onquality={output.setQuality}
onlimit={(v) => (output.limitKb = v)}
/>
</div>
{/snippet}
{#snippet addRow(index: number)}
{#if canExtend}
<div class="add-row">
<AddStepButton onadd={(id) => addStepAt(index, id)} />
{#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 aligned">
<div class="head-spacer" aria-hidden="true"></div>
{@render panelHead()}
{@render addRow(0)}
{#each steps as step, i (step.key)}
{@render stepCard(step, i)}
<div class="pair">
{@render inputTile(i)}
{@render outputTile(i)}
</div>
{@render addRow(i + 1)}
{/each}
<div class="panel-foot" aria-hidden="true"></div>
</div>
{:else}
<div class="workspace">
<section class="settings">
{@render addRow(0)}
{#each steps as step, i (step.key)}
{@render stepCard(step, i)}
{@render addRow(i + 1)}
{/each}
</section>
<section class="panel">
{@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}
<StepReorderOverlay {steps} dnd={stepDnd} />
</DragDropProvider>
</div>
{:else}
<p class="no-schema">{t("paramsCard.noSchema")}</p>
{/if}
<style>
.schema-tool {
padding: var(--space-l) clamp(var(--space-m), 4vw, var(--space-xxxl))
calc(var(--space-xxxl) + var(--space-l));
flex: 1;
}
.header {
display: flex;
justify-content: space-between;
gap: var(--space-l);
margin-bottom: var(--space-xxxl);
}
.title-block h1 {
display: inline-flex;
align-items: center;
gap: var(--space-m);
margin: var(--space-s) 0 0;
font-size: clamp(var(--font-size-xl), 4vw, var(--font-size-2xl));
line-height: 1.1;
color: var(--color-text);
}
.tool-fav {
display: inline-grid;
place-items: center;
padding: var(--space-s);
border: none;
background: none;
color: var(--color-text-muted);
cursor: pointer;
}
.tool-fav:hover,
.tool-fav.active {
color: var(--color-warning);
}
.lede {
max-width: 100%;
margin: var(--space-m) 0 0;
color: var(--color-text-muted);
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(
0,
2fr
);
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 {
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);
}
.empty {
font: var(--font-size-s) var(--font-mono);
}
.no-schema {
font: var(--font-size-s) var(--font-mono);
}
@media (--bp-tablet) {
.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>