Compare commits
3
Commits
365af6ef95
...
6e0647dfbf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e0647dfbf | ||
|
|
148e4b1957 | ||
|
|
d70cbad0d2 |
@@ -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-звеньев под новую сетку.
|
|
||||||
- Новая логика: исполнение, автосохранение, пресеты — раскладка ничего не знает про исполнение.
|
- Новая логика: исполнение, автосохранение, пресеты — раскладка ничего не знает про исполнение.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user