Compare commits

...
3 Commits
8 changed files with 678 additions and 100 deletions
+1 -2
View File
@@ -10,7 +10,7 @@
- Два взаимозаменяемых компонента этапа с одинаковым набором пропсов; `ToolPage` рендерит один тег. Попробовать новое — поменять компонент в этом теге; вернуть старое — поменять обратно. Никаких флагов и конфигов. - Два взаимозаменяемых компонента этапа с одинаковым набором пропсов; `ToolPage` рендерит один тег. Попробовать новое — поменять компонент в этом теге; вернуть старое — поменять обратно. Никаких флагов и конфигов.
- Общая типизация пропсов в одном файле рядом с компонентами; сами карточки (`SourceCard`, `ResultCard`, `TextInputCard`, `ParamsCard`) переиспользуются как есть — вся логика исполнения, маски, пипетки и автозапуска остаётся в `ToolPage` и приходит сверху. - Общая типизация пропсов в одном файле рядом с компонентами; сами карточки (`SourceCard`, `ResultCard`, `TextInputCard`, `ParamsCard`) переиспользуются как есть — вся логика исполнения, маски, пипетки и автозапуска остаётся в `ToolPage` и приходит сверху.
- Chain-звенья не затрагиваются: у них своя раскладка «Вход | Результат», эксперимент только про базовый этап. - Chain-звенья используют ту же панельную систему «Вход | Параметры | Результат» — общие классы `.pane` / `.pane-legend` / `.pane-params` вынесены в app.css, вертикальные поля в узкой колонке заданы один раз глобально.
- Один порог отзывчивости: три колонки от ~75rem, ниже — стек «исходник → параметры → результат». Промежуточные перестроения не придумываем до фидбека дизайнера. - Один порог отзывчивости: три колонки от ~75rem, ниже — стек «исходник → параметры → результат». Промежуточные перестроения не придумываем до фидбека дизайнера.
## 3. Компоненты ## 3. Компоненты
@@ -46,5 +46,4 @@ src/lib/components/stage/
## 6. Что сознательно не делаем ## 6. Что сознательно не делаем
- Редизайн визуального языка (цвета, типографика, формы) — материал для дизайнера, не для этого плана. - Редизайн визуального языка (цвета, типографика, формы) — материал для дизайнера, не для этого плана.
- Перестройка chain-звеньев под новую сетку.
- Новая логика: исполнение, автосохранение, пресеты — раскладка ничего не знает про исполнение. - Новая логика: исполнение, автосохранение, пресеты — раскладка ничего не знает про исполнение.
+14
View File
@@ -240,6 +240,20 @@ input.control[type='number'] {
padding-bottom: var(--space-3); padding-bottom: var(--space-3);
} }
/* Панель этапа для инлайн-раскладки: исходник | параметры | результат */
.pane {
position: relative;
display: flex;
flex-direction: column;
min-width: 0;
}
.pane-legend {
top: -0.65em;
left: 50%;
transform: translateX(-50%);
}
.actions-row { .actions-row {
display: flex; display: flex;
gap: var(--space-1); gap: var(--space-1);
+146 -96
View File
@@ -5,23 +5,43 @@
import type { PixelImage } from '$lib/core/types'; import type { PixelImage } from '$lib/core/types';
import { defaultParams, getTool, outputOf, sanitizeParams, type ToolEntry } from '$lib/registry'; import { defaultParams, getTool, outputOf, sanitizeParams, type ToolEntry } from '$lib/registry';
import { loadStoredSteps, newStepId, saveSteps, type PipelineStep } from '$lib/tools/pipeline'; import { loadStoredSteps, newStepId, saveSteps, type PipelineStep } from '$lib/tools/pipeline';
import { TOOL_ICONS } from '$lib/tools/tool-icons';
import DownloadButton from './DownloadButton.svelte'; import DownloadButton from './DownloadButton.svelte';
import ParamForm from './ParamForm.svelte'; import ParamForm from './ParamForm.svelte';
import Preview from './Preview.svelte'; import Preview from './Preview.svelte';
import ToolSearch from './search/ToolSearch.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 { executeStep } from '$lib/tools/executor'; import { executeStep } from '$lib/tools/executor';
import { t } from '$lib/i18n/t'; import { t } from '$lib/i18n/t';
import { toolDescription, toolTitle } from '$lib/i18n/tool-strings'; import { toolDescription, toolTitle } from '$lib/i18n/tool-strings';
import ParamsCard from './tool/ParamsCard.svelte'; import type { StageStatus } from './stage/stage-props';
import ResultCard from './tool/ResultCard.svelte'; import ChainToolBlock from './chain/ChainToolBlock.svelte';
import SourceCard from './tool/SourceCard.svelte'; import ToolStage from './stage/ToolStage.svelte';
import TextInputCard from './tool/TextInputCard.svelte'; import ToolStageClassic from './stage/ToolStageClassic.svelte';
let { tool, restoreChain = false }: { tool: ToolEntry; restoreChain?: boolean } = $props(); export type PresetStep = { toolId: string; values?: Record<string, unknown> };
type Status = 'idle' | 'loaded' | 'processing' | 'error'; let {
tool,
restoreChain = false,
stageVariant = 'inline',
presetBaseValues,
presetChain
}: {
tool: ToolEntry;
restoreChain?: boolean;
stageVariant?: 'classic' | 'inline';
presetBaseValues?: Record<string, unknown>;
presetChain?: PresetStep[];
} = $props();
const StageComponent = $derived(stageVariant === 'classic' ? ToolStageClassic : ToolStage);
type Status = StageStatus;
const isPreset = $derived(
(presetChain?.length ?? 0) > 0 || Object.keys(presetBaseValues ?? {}).length > 0
);
let status = $state<Status>('idle'); let status = $state<Status>('idle');
let source = $state<PixelImage | null>(null); let source = $state<PixelImage | null>(null);
@@ -31,9 +51,25 @@
let showMask = $state(false); let showMask = $state(false);
let info = $state<ImageInfo | null>(null); let info = $state<ImageInfo | null>(null);
let errorText = $state(''); let errorText = $state('');
let values = $state<Record<string, any>>({});
// svelte-ignore state_referenced_locally // svelte-ignore state_referenced_locally
let chain = $state<PipelineStep[]>(restoreChain ? loadStoredSteps() : []); let values = $state<Record<string, any>>({ ...defaultParams(tool), ...presetBaseValues });
// svelte-ignore state_referenced_locally
let chain = $state<PipelineStep[]>(
restoreChain
? loadStoredSteps()
: (presetChain ?? []).flatMap((preset) => {
const stepTool = getTool(preset.toolId);
return stepTool
? [
{
id: newStepId(),
toolId: preset.toolId,
values: { ...defaultParams(stepTool), ...preset.values }
}
]
: [];
})
);
let chainResults = $state<(PixelImage | null)[]>([]); let chainResults = $state<(PixelImage | null)[]>([]);
let lastRunChainJson = ''; let lastRunChainJson = '';
@@ -179,7 +215,7 @@
current = await executeStep(stepTool, current, sanitizeParams(stepTool, step.values)); current = await executeStep(stepTool, current, sanitizeParams(stepTool, step.values));
} catch (e) { } catch (e) {
throw new Error( throw new Error(
t('toolPage.stepError', { n: i + 1, title: toolTitle(stepTool), msg: errorMessage(e) }) t('toolPage.stepError', { n: i + 2, title: toolTitle(stepTool), msg: errorMessage(e) })
); );
} }
if (!runner.isCurrent(token)) return; if (!runner.isCurrent(token)) return;
@@ -210,6 +246,7 @@
}); });
$effect(() => { $effect(() => {
if (isPreset) return;
const filled = chain.filter((step) => step.toolId !== ''); const filled = chain.filter((step) => step.toolId !== '');
if (filled.length === 0 && !hasLastRun) return; if (filled.length === 0 && !hasLastRun) return;
saveSteps(filled); saveSteps(filled);
@@ -267,68 +304,42 @@
<div class="error-banner" role="alert">{errorText}</div> <div class="error-banner" role="alert">{errorText}</div>
{/if} {/if}
<div class="panel tool-block"> <StageComponent
<div class="tool-stage" class:single={isSourceless}> mode="base"
{#if !isSourceless} {tool}
<span class="edge-legend source-legend" aria-hidden="true">{t('toolPage.legendSource')}</span> source={source}
<div class="cell"> result={result}
{#if isTextSource && !source} displayImage={shownBase}
<TextInputCard onSubmit={handleTextSubmit} /> info={info}
{:else} status={status}
<SourceCard isInfo={isInfo}
{source} isSourceless={isSourceless}
onFile={handleFile} isTextSource={isTextSource}
onError={(e) => (errorText = errorMessage(e))} textResult={textResult}
onReset={reset} sanitized={sanitized}
pipetteActive={!!pipetteTargetId} canChainBase={canChainBase}
onPickColor={handlePickColor} hasChain={chain.length > 0}
/> hasMask={hasMask}
{/if} bind:showMask
</div> bind:values
{/if} pipetteTargetId={pipetteTargetId}
<span class="edge-legend result-legend" aria-hidden="true"> handleFile={handleFile}
{isInfo ? t('toolPage.legendSummary') : t('toolPage.legendResult')} onTextSubmit={handleTextSubmit}
</span> onSourceError={(e) => (errorText = errorMessage(e))}
<div class="cell"> reset={reset}
<ResultCard toggleChain={toggleChain}
{tool} handlePipetteToggle={handlePipetteToggle}
sourceLoaded={isSourceless ? true : !!source} handlePickColor={handlePickColor}
{status} showError={showError}
{result} errorMessage={errorMessage}
displayImage={shownBase} />
{info}
{isInfo}
params={sanitized}
textResult={textResult}
onChainToggle={canChainBase ? toggleChain : undefined}
hasChain={chain.length > 0}
onDownloadError={showError}
/>
</div>
</div>
{#if (source || isSourceless) && !isInfo && (tool.params.length > 0 || hasMask)}
<div class="params-sep">
<span class="edge-legend" aria-hidden="true">{t('toolPage.legendParams')}</span>
</div>
<ParamsCard
tool={tool}
params={tool.params}
bind:values
{pipetteTargetId}
onPipetteToggle={handlePipetteToggle}
hasMask={hasMask}
bind:showMask
/>
{/if}
</div>
<div class="chain-stack"> <div class="chain-stack">
{#each chain as step, index (step.id)} {#each chain as step, index (step.id)}
{#if step.toolId === ''} {#if step.toolId === ''}
<div class="panel empty-slot"> <div class="panel empty-slot">
<header> <header>
<h3 class="heading-section">{t('toolPage.stepHeading', { n: index + 1 })}</h3> <h3 class="heading-section">{t('toolPage.stepHeading', { n: index + 2 })}</h3>
<button <button
type="button" type="button"
class="remove-step" class="remove-step"
@@ -342,19 +353,57 @@
</div> </div>
{:else if getTool(step.toolId)} {:else if getTool(step.toolId)}
{@const stepTool = getTool(step.toolId)!} {@const stepTool = getTool(step.toolId)!}
<ChainToolBlock {#if stageVariant === 'inline'}
index={index} {@const StepIcon = TOOL_ICONS[stepTool.id]}
tool={stepTool} <ToolStage
bind:values={step.values} mode="chain"
input={index === 0 ? result : (chainResults[index - 1] ?? null)} index={index}
result={chainResults[index] ?? null} tool={stepTool}
busy={status === 'processing'} bind:values={step.values}
isLast={index === chain.length - 1} input={index === 0 ? result : (chainResults[index - 1] ?? null)}
onRemove={() => removeChainStep(index)} result={chainResults[index] ?? null}
onError={showError} busy={status === 'processing'}
onAddStep={addChainStep} isLast={index === chain.length - 1}
onRemoveChain={removeChain} onRemove={() => removeChainStep(index)}
/> onError={showError}
onAddStep={addChainStep}
onRemoveChain={removeChain}
>
{#snippet header()}
<span class="edge-legend step-legend">
{#if StepIcon}
<span class="step-icon" aria-hidden="true">
<StepIcon size={14} strokeWidth={2} />
</span>
{/if}
{t('chain.stepLabel', { n: index + 2, title: toolTitle(stepTool) })}
<button
type="button"
class="remove-step"
aria-label={t('toolPage.removeStepAria')}
title={t('toolPage.removeStepAria')}
onclick={() => removeChainStep(index)}
>
</button>
</span>
{/snippet}
</ToolStage>
{:else}
<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}
{/if} {/if}
{/each} {/each}
</div> </div>
@@ -374,25 +423,26 @@
margin-bottom: var(--space-3); margin-bottom: var(--space-3);
} }
.tool-stage { .step-legend {
position: relative; left: var(--space-3);
top: -0.75em;
display: inline-flex;
align-items: center;
gap: var(--space-2);
color: var(--text);
font-weight: 600;
z-index: 2;
} }
.source-legend { .step-icon {
left: 25%; display: inline-flex;
transform: translateX(-50%); align-items: center;
} justify-content: center;
width: 1.4rem;
.result-legend { height: 1.4rem;
left: 75%; border-radius: var(--radius-s);
transform: translateX(-50%); background: color-mix(in srgb, var(--accent) 10%, var(--surface));
} color: var(--link);
@media (max-width: 48rem) {
.source-legend,
.result-legend {
display: none;
}
} }
.empty-slot { .empty-slot {
@@ -49,7 +49,7 @@
{#if StepIcon} {#if StepIcon}
<span class="step-icon" aria-hidden="true"><StepIcon size={14} strokeWidth={2} /></span> <span class="step-icon" aria-hidden="true"><StepIcon size={14} strokeWidth={2} /></span>
{/if} {/if}
{t('chain.stepLabel', { n: index + 1, title: toolTitle(tool) })} {t('chain.stepLabel', { n: index + 2, title: toolTitle(tool) })}
<button <button
type="button" type="button"
class="remove" class="remove"
@@ -84,7 +84,7 @@
<DownloadButton <DownloadButton
image={result} image={result}
format={format} format={format}
baseName="{index + 1}-{tool.id}" baseName="{index + 2}-{tool.id}"
params={safeParams} params={safeParams}
{onError} {onError}
/> />
@@ -0,0 +1,311 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { outputOf, sanitizeParams, type ToolEntry } from '$lib/registry';
import type { ImageInfo } from '$lib/core/analyze';
import type { PixelImage } from '$lib/core/types';
import { t } from '$lib/i18n/t';
import { toolTitle } from '$lib/i18n/tool-strings';
import { TOOL_ICONS } from '$lib/tools/tool-icons';
import DownloadButton from '../DownloadButton.svelte';
import Button from '../ui/Button.svelte';
import EmptyState from '../ui/EmptyState.svelte';
import ParamsCard from '../tool/ParamsCard.svelte';
import ResultCard from '../tool/ResultCard.svelte';
import SourceCard from '../tool/SourceCard.svelte';
import TextInputCard from '../tool/TextInputCard.svelte';
import Preview from '../Preview.svelte';
import type { StageStatus } from './stage-props';
interface Props {
mode: 'base' | 'chain';
header?: Snippet;
tool: ToolEntry;
source?: PixelImage | null;
displayImage?: PixelImage | null;
info?: ImageInfo | null;
status?: StageStatus;
isInfo?: boolean;
isSourceless?: boolean;
isTextSource?: boolean;
textResult?: string | null;
sanitized?: Record<string, any>;
canChainBase?: boolean;
hasChain?: boolean;
hasMask?: boolean;
pipetteTargetId?: string | null;
values: Record<string, any>;
showMask?: boolean;
handleFile?: (file: File) => Promise<void> | void;
onTextSubmit?: (text: string) => Promise<void> | void;
onSourceError?: (e: unknown) => void;
reset?: () => void;
toggleChain?: () => void;
handlePipetteToggle?: (id: string) => void;
handlePickColor?: (hex: string) => void;
showError?: (e: unknown) => void;
index?: number;
input?: PixelImage | null;
result?: PixelImage | null;
busy?: boolean;
isLast?: boolean;
onRemove?: () => void;
onError?: (e: unknown) => void;
onAddStep?: () => void;
onRemoveChain?: () => void;
}
let {
mode,
header,
tool,
source = null,
displayImage = null,
info = null,
status = 'idle',
isInfo = false,
isSourceless = false,
isTextSource = false,
textResult = null,
sanitized = {},
canChainBase = false,
hasChain = false,
hasMask = false,
pipetteTargetId = null,
values = $bindable({}),
showMask = $bindable(false),
handleFile,
onTextSubmit,
onSourceError,
reset,
toggleChain,
handlePipetteToggle,
handlePickColor,
showError,
index = 0,
input = null,
result = null,
busy = false,
isLast = false,
onRemove,
onError,
onAddStep,
onRemoveChain
}: Props = $props();
const isChain = $derived(mode === 'chain');
const format = $derived(outputOf(tool));
const safeParams = $derived(sanitizeParams(tool, values ?? {}));
const StepIcon = $derived(TOOL_ICONS[tool.id]);
const showParams = $derived(
mode === 'chain'
? tool.params.length > 0
: (source !== null || isSourceless) &&
!isInfo &&
(tool.params.length > 0 || hasMask)
);
const leftLegend = $derived(isChain ? t('chain.inputLegend') : t('toolPage.legendSource'));
const midLegend = $derived(isChain ? t('chain.paramsLegend') : t('toolPage.legendParams'));
const rightLegend = $derived(
isChain ? t('chain.resultLegend') : isInfo ? t('toolPage.legendSummary') : t('toolPage.legendResult')
);
</script>
<div class="panel stage-grid" class:no-params={!showParams} class:single={!isChain && !!isSourceless}>
{#if header}
{@render header()}
{/if}
{#if isChain}
<section class="pane">
<span class="edge-legend pane-legend" aria-hidden="true">{leftLegend}</span>
<div class="pane-body">
<Preview image={input} />
</div>
</section>
{:else if !isSourceless}
<section class="pane">
<span class="edge-legend pane-legend" aria-hidden="true">{leftLegend}</span>
<div class="pane-body">
{#if isTextSource && !source}
<TextInputCard onSubmit={(text) => onTextSubmit?.(text)} />
{:else}
<SourceCard
{source}
onFile={(f) => handleFile?.(f)}
onError={(e) => onSourceError?.(e)}
onReset={() => reset?.()}
pipetteActive={!!pipetteTargetId}
onPickColor={(hex) => handlePickColor?.(hex)}
/>
{/if}
</div>
</section>
{/if}
{#if showParams}
<section class="pane pane-params">
<span class="edge-legend pane-legend" aria-hidden="true">{midLegend}</span>
<div class="pane-body">
<ParamsCard {tool} params={tool.params} bind:values {pipetteTargetId} onPipetteToggle={(id) => handlePipetteToggle?.(id)} {hasMask} bind:showMask />
</div>
</section>
{/if}
<section class="pane">
<span class="edge-legend pane-legend" aria-hidden="true">{rightLegend}</span>
<div class="pane-body">
{#if isChain}
{#if busy && !result}
<EmptyState title={t('chain.busyTitle')} hint={t('chain.busyHint')} />
{:else}
<Preview image={result} />
{#if busy}
<span class="recalc" aria-live="polite">{t('resultCard.recalc')}</span>
{/if}
<div class="actions-row">
<DownloadButton
image={result}
format={format}
baseName="{index + 2}-{tool.id}"
params={safeParams}
onError={(e) => showError?.(e)}
/>
<Button variant="secondary" fullWidth onclick={isLast ? onAddStep : onRemoveChain}>
{isLast ? t('resultCard.nextTool') : t('resultCard.breakChain')}
</Button>
</div>
{/if}
{:else}
<ResultCard
{tool}
sourceLoaded={isSourceless ? true : !!source}
{status}
{result}
displayImage={displayImage}
{info}
{isInfo}
params={sanitized}
textResult={textResult}
onChainToggle={canChainBase ? toggleChain : undefined}
{hasChain}
onDownloadError={(e) => showError?.(e)}
/>
{/if}
</div>
</section>
</div>
<style>
.stage-grid {
position: relative;
display: grid;
gap: var(--space-4);
padding: var(--space-4);
}
.pane {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 14rem;
}
.pane-body {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--space-2);
width: 100%;
}
.pane + .pane {
border-top: 1px solid var(--border);
padding-top: var(--space-4);
}
/* Параметры в узкой колонке: вертикальные поля с переносами.
:global под локальным якорем даёт специфичность выше scoped-правил полей. */
.pane-params :global(.root) {
padding: 0;
height: 100%;
}
.pane-params :global(.params-grid) {
grid-template-columns: 1fr;
}
.pane-params :global(.field) {
flex-direction: column;
align-items: stretch;
gap: var(--space-1);
}
.pane-params :global(.field > label) {
max-width: none;
}
.pane-params :global(.control-slot),
.pane-params :global(.row) {
align-items: stretch;
max-width: none;
width: 100%;
flex-wrap: wrap;
row-gap: var(--space-1);
}
.pane-params :global(.hint) {
align-self: auto;
text-align: left;
}
.pane-params :global(input[type='range']) {
order: -1;
flex: 1 1 100%;
min-width: 0;
width: 100%;
}
.pane-params :global(.row > output) {
margin-left: auto;
}
.pane-params :global(input.control),
.pane-params :global(select.control) {
max-width: none;
width: 100%;
}
.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(--link);
font-size: var(--text-xs);
}
@media (min-width: 75rem) {
.stage-grid:not(.no-params):not(.single) {
grid-template-columns: minmax(0, 1fr) clamp(13rem, 17vw, 18rem) minmax(0, 1fr);
}
.stage-grid.no-params:not(.single),
.stage-grid.single.no-params {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
.stage-grid.single:not(.no-params) {
grid-template-columns: clamp(13rem, 17vw, 18rem) minmax(0, 1fr);
}
.pane + .pane {
border-top: none;
padding-top: 0;
border-left: 1px solid var(--border);
padding-left: var(--space-4);
}
}
</style>
@@ -0,0 +1,116 @@
<script lang="ts">
import { t } from '$lib/i18n/t';
import ParamsCard from '../tool/ParamsCard.svelte';
import ResultCard from '../tool/ResultCard.svelte';
import SourceCard from '../tool/SourceCard.svelte';
import TextInputCard from '../tool/TextInputCard.svelte';
import type { StageProps } from './stage-props';
let {
tool,
source,
result,
displayImage,
info,
status,
isInfo,
isSourceless,
isTextSource,
textResult,
sanitized,
canChainBase,
hasChain,
hasMask,
showMask = $bindable(false),
values = $bindable(),
pipetteTargetId,
handleFile,
onTextSubmit,
onSourceError,
reset,
toggleChain,
handlePipetteToggle,
handlePickColor,
showError,
errorMessage
}: StageProps = $props();
</script>
<div class="panel tool-block">
<div class="tool-stage" class:single={isSourceless}>
{#if !isSourceless}
<span class="edge-legend source-legend" aria-hidden="true">{t('toolPage.legendSource')}</span>
<div class="cell">
{#if isTextSource && !source}
<TextInputCard onSubmit={onTextSubmit} />
{:else}
<SourceCard
{source}
onFile={handleFile}
onError={onSourceError}
onReset={reset}
pipetteActive={!!pipetteTargetId}
onPickColor={handlePickColor}
/>
{/if}
</div>
{/if}
<span class="edge-legend result-legend" aria-hidden="true">
{isInfo ? t('toolPage.legendSummary') : t('toolPage.legendResult')}
</span>
<div class="cell">
<ResultCard
{tool}
sourceLoaded={isSourceless ? true : !!source}
{status}
{result}
displayImage={displayImage}
{info}
{isInfo}
params={sanitized}
textResult={textResult}
onChainToggle={canChainBase ? toggleChain : undefined}
{hasChain}
onDownloadError={showError}
/>
</div>
</div>
{#if (source || isSourceless) && !isInfo && (tool.params.length > 0 || hasMask)}
<div class="params-sep">
<span class="edge-legend" aria-hidden="true">{t('toolPage.legendParams')}</span>
</div>
<ParamsCard
{tool}
params={tool.params}
bind:values
{pipetteTargetId}
onPipetteToggle={handlePipetteToggle}
{hasMask}
bind:showMask
/>
{/if}
</div>
<style>
.tool-stage {
position: relative;
}
.source-legend {
left: 25%;
transform: translateX(-50%);
}
.result-legend {
left: 75%;
transform: translateX(-50%);
}
@media (max-width: 48rem) {
.source-legend,
.result-legend {
display: none;
}
}
</style>
@@ -0,0 +1,48 @@
import type { ImageInfo } from '$lib/core/analyze';
import type { PixelImage } from '$lib/core/types';
import type { ToolEntry } from '$lib/registry';
export type StageStatus = 'idle' | 'loaded' | 'processing' | 'error';
export interface StageProps {
tool: ToolEntry;
source: PixelImage | null;
result: PixelImage | null;
displayImage: PixelImage | null;
info: ImageInfo | null;
status: StageStatus;
isInfo: boolean;
isSourceless: boolean;
isTextSource: boolean;
textResult: string | null;
sanitized: Record<string, any>;
canChainBase: boolean;
hasChain: boolean;
hasMask: boolean;
showMask: boolean;
values: Record<string, any>;
pipetteTargetId: string | null;
handleFile: (file: File) => Promise<void> | void;
onTextSubmit: (text: string) => Promise<void> | void;
onSourceError: (e: unknown) => void;
reset: () => void;
toggleChain: () => void;
handlePipetteToggle: (id: string) => void;
handlePickColor: (hex: string) => void;
showError: (e: unknown) => void;
errorMessage: (e: unknown) => string;
}
export interface ChainStageProps {
tool: ToolEntry;
index: number;
input: PixelImage | null;
result: PixelImage | null;
busy: boolean;
isLast: boolean;
values: Record<string, any>;
onRemove: () => void;
onError: (e: unknown) => void;
onAddStep: () => void;
onRemoveChain: () => void;
}
+40
View File
@@ -0,0 +1,40 @@
<script lang="ts">
import ToolPage from '$lib/components/ToolPage.svelte';
import { getTool } from '$lib/registry';
const base = getTool('linear-gradient-png')!;
</script>
<svelte:head>
<title>Mockup: цепочка инструментов — easy-png-tools</title>
<meta name="robots" content="noindex, nofollow" />
</svelte:head>
<section class="demo">
<ToolPage
tool={base}
stageVariant="inline"
presetBaseValues={{
width: 520,
height: 340,
fromColor: '#e5484d',
toColor: '#fbbf24',
direction: 'horizontal'
}}
presetChain={[
{
toolId: 'remove-background-png',
values: { color: '#fbbf24', tolerance: 10, outerOnly: true, smooth: 2 }
},
{ toolId: 'add-stroke-png', values: { color: '#2563eb', thickness: 8 } },
{ toolId: 'round-corners-png', values: { radius: 28 } }
]}
/>
</section>
<style>
.demo {
max-width: 100rem;
margin: 0 auto;
}
</style>