mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-09-14 21:46:35 +00:00
feat: add chain tools workflow
This commit is contained in:
@@ -154,3 +154,71 @@ input.control[type='number'] {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tool-stage {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding: var(--space-4);
|
||||||
|
align-items: start;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-stage.single {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-stage .cell {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-stage .cell + .cell {
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
padding-left: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-media {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 14rem;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-row {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-1);
|
||||||
|
align-items: stretch;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-row > button {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 48rem) {
|
||||||
|
.tool-stage {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-stage .cell + .cell {
|
||||||
|
border-left: none;
|
||||||
|
padding-left: 0;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding-top: var(--space-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,13 @@
|
|||||||
import { imageInfo, type ImageInfo } from '$lib/core/analyze';
|
import { imageInfo, type ImageInfo } from '$lib/core/analyze';
|
||||||
import { decodeFile, isSupportedImage, unsupportedImageMessage } from '$lib/core/io';
|
import { decodeFile, isSupportedImage, unsupportedImageMessage } from '$lib/core/io';
|
||||||
import type { PixelImage } from '$lib/core/types';
|
import type { PixelImage } from '$lib/core/types';
|
||||||
import { defaultParams, sanitizeParams, type ToolEntry } from '$lib/registry';
|
import { defaultParams, getTool, outputOf, sanitizeParams, type ToolEntry } from '$lib/registry';
|
||||||
|
import { newStepId, type PipelineStep } from '$lib/tools/pipeline';
|
||||||
|
import DownloadButton from './DownloadButton.svelte';
|
||||||
|
import ParamForm from './ParamForm.svelte';
|
||||||
|
import Preview from './Preview.svelte';
|
||||||
|
import ToolSearch from './search/ToolSearch.svelte';
|
||||||
|
import ChainToolBlock from './chain/ChainToolBlock.svelte';
|
||||||
import { createAutoRunner } from '$lib/tools/auto-run';
|
import { createAutoRunner } from '$lib/tools/auto-run';
|
||||||
import ParamsCard from './tool/ParamsCard.svelte';
|
import ParamsCard from './tool/ParamsCard.svelte';
|
||||||
import ResultCard from './tool/ResultCard.svelte';
|
import ResultCard from './tool/ResultCard.svelte';
|
||||||
@@ -22,11 +28,47 @@
|
|||||||
let info = $state<ImageInfo | null>(null);
|
let info = $state<ImageInfo | null>(null);
|
||||||
let errorText = $state('');
|
let errorText = $state('');
|
||||||
let values = $state<Record<string, any>>({});
|
let values = $state<Record<string, any>>({});
|
||||||
|
let chain = $state<PipelineStep[]>([]);
|
||||||
|
let chainResults = $state<(PixelImage | null)[]>([]);
|
||||||
|
let lastRunChainJson = '';
|
||||||
|
|
||||||
const isInfo = $derived(tool.resultType === 'info');
|
const isInfo = $derived(tool.resultType === 'info');
|
||||||
const isSourceless = $derived(tool.sourceMode === 'none');
|
const isSourceless = $derived(tool.sourceMode === 'none');
|
||||||
const isTextSource = $derived(tool.sourceMode === 'text');
|
const isTextSource = $derived(tool.sourceMode === 'text');
|
||||||
const sanitized = $derived(sanitizeParams(tool, values));
|
const sanitized = $derived(sanitizeParams(tool, values));
|
||||||
|
const canChainBase = $derived(
|
||||||
|
(tool.resultType ?? 'image') === 'image' && tool.sourceMode !== 'text'
|
||||||
|
);
|
||||||
|
const hasFilledSteps = $derived(chain.some((step) => step.toolId !== ''));
|
||||||
|
|
||||||
|
function addChainStep() {
|
||||||
|
chain.push({ id: newStepId(), toolId: '', values: {} });
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeChainStep(index: number) {
|
||||||
|
chain.splice(index, 1);
|
||||||
|
chainResults = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeChain() {
|
||||||
|
chain.length = 0;
|
||||||
|
chainResults = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleChain() {
|
||||||
|
if (chain.length > 0) {
|
||||||
|
removeChain();
|
||||||
|
} else {
|
||||||
|
addChainStep();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyChainTool(index: number, toolId: string) {
|
||||||
|
const stepTool = getTool(toolId);
|
||||||
|
if (!stepTool || !chain[index]) return;
|
||||||
|
chain[index].toolId = toolId;
|
||||||
|
chain[index].values = defaultParams(stepTool);
|
||||||
|
}
|
||||||
|
|
||||||
const runner = createAutoRunner();
|
const runner = createAutoRunner();
|
||||||
let hasLastRun = false;
|
let hasLastRun = false;
|
||||||
@@ -91,6 +133,7 @@
|
|||||||
hasLastRun = true;
|
hasLastRun = true;
|
||||||
lastRunSource = source;
|
lastRunSource = source;
|
||||||
lastRunValuesJson = JSON.stringify(sanitized);
|
lastRunValuesJson = JSON.stringify(sanitized);
|
||||||
|
lastRunChainJson = JSON.stringify(chain);
|
||||||
try {
|
try {
|
||||||
let next: PixelImage | null = null;
|
let next: PixelImage | null = null;
|
||||||
let nextText: string | null = null;
|
let nextText: string | null = null;
|
||||||
@@ -116,6 +159,26 @@
|
|||||||
result = next;
|
result = next;
|
||||||
previewResult = nextPreview;
|
previewResult = nextPreview;
|
||||||
textResult = nextText;
|
textResult = nextText;
|
||||||
|
|
||||||
|
const collected: (PixelImage | null)[] = [];
|
||||||
|
let current: PixelImage | null = next ?? source;
|
||||||
|
for (let i = 0; i < chain.length; i++) {
|
||||||
|
const step = chain[i];
|
||||||
|
const stepTool = step.toolId === '' ? undefined : getTool(step.toolId);
|
||||||
|
if (!current || !stepTool?.run) {
|
||||||
|
collected.push(null);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
current = await stepTool.run(current, sanitizeParams(stepTool, step.values));
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
throw new Error(`Шаг ${i + 1} (${stepTool.title}): ${message}`);
|
||||||
|
}
|
||||||
|
if (!runner.isCurrent(token)) return;
|
||||||
|
collected.push(current);
|
||||||
|
}
|
||||||
|
chainResults = collected;
|
||||||
status = 'loaded';
|
status = 'loaded';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!runner.isCurrent(token)) return;
|
if (!runner.isCurrent(token)) return;
|
||||||
@@ -125,7 +188,15 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const valuesJson = JSON.stringify(sanitized);
|
const valuesJson = JSON.stringify(sanitized);
|
||||||
if (hasLastRun && source === lastRunSource && valuesJson === lastRunValuesJson) return;
|
const chainJson = JSON.stringify(chain);
|
||||||
|
if (
|
||||||
|
hasLastRun &&
|
||||||
|
source === lastRunSource &&
|
||||||
|
valuesJson === lastRunValuesJson &&
|
||||||
|
chainJson === lastRunChainJson
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!source && !isSourceless) return;
|
if (!source && !isSourceless) return;
|
||||||
if (isInfo) return;
|
if (isInfo) return;
|
||||||
return runner.schedule(() => void runTool());
|
return runner.schedule(() => void runTool());
|
||||||
@@ -178,7 +249,7 @@
|
|||||||
<div class="error-banner" role="alert">{errorText}</div>
|
<div class="error-banner" role="alert">{errorText}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="stage panel" class:single={isSourceless}>
|
<div class="panel tool-stage" class:single={isSourceless}>
|
||||||
{#if !isSourceless}
|
{#if !isSourceless}
|
||||||
<div class="cell">
|
<div class="cell">
|
||||||
{#if isTextSource && !source}
|
{#if isTextSource && !source}
|
||||||
@@ -206,20 +277,60 @@
|
|||||||
{info}
|
{info}
|
||||||
{isInfo}
|
{isInfo}
|
||||||
params={sanitized}
|
params={sanitized}
|
||||||
{textResult}
|
textResult={textResult}
|
||||||
onDownloadError={showError}
|
onDownloadError={showError}
|
||||||
|
onChainToggle={canChainBase ? toggleChain : undefined}
|
||||||
|
hasChain={chain.length > 0}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if (source || isSourceless) && !isInfo}
|
{#if (source || isSourceless) && !isInfo}
|
||||||
|
<div class="base-params">
|
||||||
<ParamsCard
|
<ParamsCard
|
||||||
params={tool.params}
|
params={tool.params}
|
||||||
bind:values
|
bind:values
|
||||||
{pipetteTargetId}
|
{pipetteTargetId}
|
||||||
onPipetteToggle={handlePipetteToggle}
|
onPipetteToggle={handlePipetteToggle}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<div class="chain-stack">
|
||||||
|
{#each chain as step, index (step.id)}
|
||||||
|
{#if step.toolId === ''}
|
||||||
|
<div class="panel empty-slot">
|
||||||
|
<header>
|
||||||
|
<h3 class="heading-section">Шаг {index + 1}</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="remove-step"
|
||||||
|
aria-label="Убрать шаг"
|
||||||
|
onclick={() => removeChainStep(index)}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<ToolSearch onSelect={(id) => applyChainTool(index, id)} />
|
||||||
|
</div>
|
||||||
|
{:else if getTool(step.toolId)}
|
||||||
|
{@const stepTool = getTool(step.toolId)!}
|
||||||
|
<ChainToolBlock
|
||||||
|
index={index}
|
||||||
|
tool={stepTool}
|
||||||
|
bind:values={step.values}
|
||||||
|
input={index === 0 ? result : (chainResults[index - 1] ?? null)}
|
||||||
|
result={chainResults[index] ?? null}
|
||||||
|
busy={status === 'processing'}
|
||||||
|
isLast={index === chain.length - 1}
|
||||||
|
onRemove={() => removeChainStep(index)}
|
||||||
|
onError={showError}
|
||||||
|
onAddStep={addChainStep}
|
||||||
|
onRemoveChain={removeChain}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -236,37 +347,40 @@
|
|||||||
margin-bottom: var(--space-3);
|
margin-bottom: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stage {
|
.base-params {
|
||||||
display: grid;
|
margin-top: var(--space-4);
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: var(--space-4);
|
|
||||||
padding: var(--space-4);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stage.single {
|
.empty-slot {
|
||||||
grid-template-columns: 1fr;
|
padding: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cell {
|
.empty-slot header {
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cell + .cell {
|
.empty-slot h3 {
|
||||||
border-left: 1px solid var(--border);
|
margin: 0;
|
||||||
padding-left: var(--space-4);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 48rem) {
|
.remove-step {
|
||||||
.stage {
|
width: 1.6rem;
|
||||||
grid-template-columns: 1fr;
|
height: 1.6rem;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cell + .cell {
|
.remove-step:hover {
|
||||||
border-left: none;
|
border-color: var(--danger);
|
||||||
padding-left: 0;
|
color: var(--danger);
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
padding-top: var(--space-4);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { outputOf, sanitizeParams, type ToolEntry } from '$lib/registry';
|
||||||
|
import type { PixelImage } from '$lib/core/types';
|
||||||
|
import DownloadButton from '../DownloadButton.svelte';
|
||||||
|
import Button from '../ui/Button.svelte';
|
||||||
|
import EmptyState from '../ui/EmptyState.svelte';
|
||||||
|
import ParamsCard from '../tool/ParamsCard.svelte';
|
||||||
|
import Preview from '../Preview.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
index: number;
|
||||||
|
tool: ToolEntry;
|
||||||
|
values: Record<string, any>;
|
||||||
|
input: PixelImage | null;
|
||||||
|
result: PixelImage | null;
|
||||||
|
busy: boolean;
|
||||||
|
isLast: boolean;
|
||||||
|
onRemove: () => void;
|
||||||
|
onError: (e: unknown) => void;
|
||||||
|
onAddStep: () => void;
|
||||||
|
onRemoveChain: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
index,
|
||||||
|
tool,
|
||||||
|
values = $bindable(),
|
||||||
|
input,
|
||||||
|
result,
|
||||||
|
busy,
|
||||||
|
isLast,
|
||||||
|
onRemove,
|
||||||
|
onError,
|
||||||
|
onAddStep,
|
||||||
|
onRemoveChain
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const format = $derived(outputOf(tool));
|
||||||
|
const safeParams = $derived(sanitizeParams(tool, values));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="block">
|
||||||
|
<header>
|
||||||
|
<h3 class="heading-section">Шаг {index + 1}: {tool.title}</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="remove"
|
||||||
|
aria-label="Убрать шаг"
|
||||||
|
title="Убрать шаг"
|
||||||
|
onclick={onRemove}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="tool-stage panel">
|
||||||
|
<div class="cell">
|
||||||
|
<h4 class="heading-section">Вход</h4>
|
||||||
|
<div class="cell-media">
|
||||||
|
<Preview image={input} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cell">
|
||||||
|
<h4 class="heading-section">Результат</h4>
|
||||||
|
{#if busy && !result}
|
||||||
|
<div class="cell-media">
|
||||||
|
<EmptyState title="Обработка…" hint="Выполняется шаг цепочки" />
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="cell-media">
|
||||||
|
<Preview image={result} />
|
||||||
|
{#if busy}
|
||||||
|
<span class="recalc" aria-live="polite">Пересчёт…</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="actions-row">
|
||||||
|
<DownloadButton
|
||||||
|
image={result}
|
||||||
|
format={format}
|
||||||
|
baseName="{index + 1}-{tool.id}"
|
||||||
|
params={safeParams}
|
||||||
|
{onError}
|
||||||
|
/>
|
||||||
|
<Button variant="secondary" fullWidth onclick={isLast ? onAddStep : onRemoveChain}>
|
||||||
|
{isLast ? '⛓ Следующий инструмент' : '✂ Оборвать цепочку'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ParamsCard params={tool.params} bind:values />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove {
|
||||||
|
width: 1.6rem;
|
||||||
|
height: 1.6rem;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color var(--transition-fast),
|
||||||
|
color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove:hover {
|
||||||
|
border-color: var(--danger);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recalc {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--space-2);
|
||||||
|
right: var(--space-2);
|
||||||
|
padding: 2px var(--space-2);
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
background: color-mix(in srgb, var(--accent) 12%, var(--surface));
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,11 +2,10 @@
|
|||||||
import { isChainable, TOOLS } from '$lib/registry';
|
import { isChainable, TOOLS } from '$lib/registry';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
size?: 'hero' | 'compact';
|
|
||||||
onSelect: (toolId: string) => void;
|
onSelect: (toolId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { size = 'hero', onSelect }: Props = $props();
|
let { onSelect }: Props = $props();
|
||||||
|
|
||||||
const candidates = TOOLS.filter(isChainable);
|
const candidates = TOOLS.filter(isChainable);
|
||||||
|
|
||||||
@@ -51,7 +50,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
found.sort((a, b) => b.score - a.score || a.title.localeCompare(b.title));
|
found.sort((a, b) => b.score - a.score || a.title.localeCompare(b.title));
|
||||||
return found.slice(0, size === 'hero' ? 12 : 8);
|
return found.slice(0, 12);
|
||||||
});
|
});
|
||||||
|
|
||||||
function choose(id: string) {
|
function choose(id: string) {
|
||||||
@@ -79,7 +78,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="search {size}">
|
<div class="search">
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
bind:value={query}
|
bind:value={query}
|
||||||
@@ -120,25 +119,16 @@
|
|||||||
.search {
|
.search {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: var(--control-max-width);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero {
|
|
||||||
max-width: 36rem;
|
max-width: 36rem;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
input {
|
input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-3) var(--space-4);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-m);
|
border-radius: var(--radius-m);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
font-size: var(--text-l);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero input {
|
|
||||||
padding: var(--space-3) var(--space-4);
|
|
||||||
font-size: var(--text-xl);
|
font-size: var(--text-xl);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@@ -163,6 +153,7 @@
|
|||||||
box-shadow: var(--shadow-card);
|
box-shadow: var(--shadow-card);
|
||||||
max-height: 24rem;
|
max-height: 24rem;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
li button {
|
li button {
|
||||||
|
|||||||
@@ -25,7 +25,6 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.root {
|
.root {
|
||||||
margin-top: var(--space-4);
|
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import CheckboxField from '../ui/CheckboxField.svelte';
|
import CheckboxField from '../ui/CheckboxField.svelte';
|
||||||
|
import Button from '../ui/Button.svelte';
|
||||||
import DownloadButton from '../DownloadButton.svelte';
|
import DownloadButton from '../DownloadButton.svelte';
|
||||||
import EmptyState from '../ui/EmptyState.svelte';
|
import EmptyState from '../ui/EmptyState.svelte';
|
||||||
import InfoPanel from '../InfoPanel.svelte';
|
import InfoPanel from '../InfoPanel.svelte';
|
||||||
@@ -22,6 +23,8 @@
|
|||||||
isInfo: boolean;
|
isInfo: boolean;
|
||||||
params: Record<string, unknown>;
|
params: Record<string, unknown>;
|
||||||
textResult: string | null;
|
textResult: string | null;
|
||||||
|
onChainToggle?: () => void;
|
||||||
|
hasChain?: boolean;
|
||||||
onDownloadError: (e: unknown) => void;
|
onDownloadError: (e: unknown) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +39,8 @@
|
|||||||
isInfo,
|
isInfo,
|
||||||
params,
|
params,
|
||||||
textResult,
|
textResult,
|
||||||
|
onChainToggle,
|
||||||
|
hasChain = false,
|
||||||
onDownloadError
|
onDownloadError
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
@@ -81,6 +86,7 @@
|
|||||||
<span class="recalc" aria-live="polite">Пересчёт…</span>
|
<span class="recalc" aria-live="polite">Пересчёт…</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="actions-row">
|
||||||
<DownloadButton
|
<DownloadButton
|
||||||
image={result}
|
image={result}
|
||||||
format={outputOf(tool)}
|
format={outputOf(tool)}
|
||||||
@@ -88,6 +94,12 @@
|
|||||||
{params}
|
{params}
|
||||||
onError={onDownloadError}
|
onError={onDownloadError}
|
||||||
/>
|
/>
|
||||||
|
{#if onChainToggle}
|
||||||
|
<Button variant="secondary" fullWidth onclick={onChainToggle}>
|
||||||
|
{hasChain ? '✂ Оборвать цепочку' : '⛓ Следующий инструмент'}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
<section class="hero">
|
<section class="hero">
|
||||||
<h1>Что делаем с изображением?</h1>
|
<h1>Что делаем с изображением?</h1>
|
||||||
<p class="lead text-muted">Найдите инструмент — все операции выполняются локально в браузере.</p>
|
<p class="lead text-muted">Найдите инструмент — все операции выполняются локально в браузере.</p>
|
||||||
<ToolSearch size="hero" onSelect={(id) => (selectedId = id)} />
|
<ToolSearch onSelect={(id) => (selectedId = id)} />
|
||||||
</section>
|
</section>
|
||||||
{:else}
|
{:else}
|
||||||
<section class="workbench">
|
<section class="workbench">
|
||||||
|
|||||||
Reference in New Issue
Block a user