mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-09-14 13:36:36 +00:00
feat: restore old toolchain button
This commit is contained in:
@@ -94,7 +94,6 @@
|
|||||||
repeating-conic-gradient(var(--check-a) 0% 25%, var(--check-b) 0% 50%);
|
repeating-conic-gradient(var(--check-a) 0% 25%, var(--check-b) 0% 50%);
|
||||||
background-size: 16px 16px;
|
background-size: 16px 16px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-m);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
canvas.pipette {
|
canvas.pipette {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
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, getTool, outputOf, 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 { loadStoredSteps, newStepId, saveSteps, type PipelineStep } from '$lib/tools/pipeline';
|
||||||
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';
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
import SourceCard from './tool/SourceCard.svelte';
|
import SourceCard from './tool/SourceCard.svelte';
|
||||||
import TextInputCard from './tool/TextInputCard.svelte';
|
import TextInputCard from './tool/TextInputCard.svelte';
|
||||||
|
|
||||||
let { tool }: { tool: ToolEntry } = $props();
|
let { tool, restoreChain = false }: { tool: ToolEntry; restoreChain?: boolean } = $props();
|
||||||
|
|
||||||
type Status = 'idle' | 'loaded' | 'processing' | 'error';
|
type Status = 'idle' | 'loaded' | 'processing' | 'error';
|
||||||
|
|
||||||
@@ -28,7 +28,8 @@
|
|||||||
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[]>([]);
|
// svelte-ignore state_referenced_locally
|
||||||
|
let chain = $state<PipelineStep[]>(restoreChain ? loadStoredSteps() : []);
|
||||||
let chainResults = $state<(PixelImage | null)[]>([]);
|
let chainResults = $state<(PixelImage | null)[]>([]);
|
||||||
let lastRunChainJson = '';
|
let lastRunChainJson = '';
|
||||||
|
|
||||||
@@ -204,6 +205,12 @@
|
|||||||
return runner.schedule(() => void runTool());
|
return runner.schedule(() => void runTool());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const filled = chain.filter((step) => step.toolId !== '');
|
||||||
|
if (filled.length === 0 && !hasLastRun) return;
|
||||||
|
saveSteps(filled);
|
||||||
|
});
|
||||||
|
|
||||||
function showError(e: unknown) {
|
function showError(e: unknown) {
|
||||||
status = source ? 'loaded' : 'idle';
|
status = source ? 'loaded' : 'idle';
|
||||||
errorText = e instanceof Error ? e.message : String(e);
|
errorText = e instanceof Error ? e.message : String(e);
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import ParamForm from '../ParamForm.svelte';
|
|
||||||
import type { PipelineStep } from '$lib/tools/pipeline';
|
|
||||||
import { getTool } from '$lib/registry';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
step: PipelineStep;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { step }: Props = $props();
|
|
||||||
|
|
||||||
const tool = $derived(getTool(step.toolId));
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if tool}
|
|
||||||
<div class="editor panel">
|
|
||||||
<h3 class="heading-section">{tool.title}</h3>
|
|
||||||
<p class="description text-caption text-muted">{tool.description}</p>
|
|
||||||
{#if tool.params.length > 0}
|
|
||||||
<ParamForm params={tool.params} bind:values={step.values} />
|
|
||||||
{:else}
|
|
||||||
<p class="hint text-caption text-muted">У этого шага нет параметров.</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.editor {
|
|
||||||
padding: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin-bottom: var(--space-1);
|
|
||||||
font-size: var(--text-m);
|
|
||||||
}
|
|
||||||
|
|
||||||
.description,
|
|
||||||
.hint {
|
|
||||||
margin: 0 0 var(--space-2);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { PipelineStep } from '$lib/tools/pipeline';
|
|
||||||
import { getTool } from '$lib/registry';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
steps: PipelineStep[];
|
|
||||||
selectedIndex: number;
|
|
||||||
onSelect: (index: number) => void;
|
|
||||||
onMoveUp: (index: number) => void;
|
|
||||||
onMoveDown: (index: number) => void;
|
|
||||||
onRemove: (index: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { steps, selectedIndex, onSelect, onMoveUp, onMoveDown, onRemove }: Props = $props();
|
|
||||||
|
|
||||||
function toolTitle(step: PipelineStep): string {
|
|
||||||
return getTool(step.toolId)?.title ?? step.toolId;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if steps.length === 0}
|
|
||||||
<p class="empty text-caption text-muted">Цепочка пуста — добавьте первый инструмент.</p>
|
|
||||||
{:else}
|
|
||||||
<ol>
|
|
||||||
{#each steps as step, index (step.id)}
|
|
||||||
<li>
|
|
||||||
<button type="button" class="label" class:selected={index === selectedIndex} onclick={() => onSelect(index)}>
|
|
||||||
<span class="num">{index + 1}</span>
|
|
||||||
{toolTitle(step)}
|
|
||||||
</button>
|
|
||||||
<span class="controls">
|
|
||||||
<button type="button" aria-label="Выше" disabled={index === 0} onclick={() => onMoveUp(index)}>↑</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="Ниже"
|
|
||||||
disabled={index === steps.length - 1}
|
|
||||||
onclick={() => onMoveDown(index)}
|
|
||||||
>↓</button>
|
|
||||||
<button type="button" class="remove" aria-label="Удалить шаг" onclick={() => onRemove(index)}>✕</button>
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ol>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
ol {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
li {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
text-align: left;
|
|
||||||
padding: var(--space-1) var(--space-2);
|
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: var(--radius-s);
|
|
||||||
background: none;
|
|
||||||
color: var(--text);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: var(--text-m);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label:hover,
|
|
||||||
.label.selected {
|
|
||||||
background: color-mix(in srgb, var(--accent) 8%, var(--surface));
|
|
||||||
border-color: var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.num {
|
|
||||||
display: inline-block;
|
|
||||||
min-width: 1.4ch;
|
|
||||||
margin-right: var(--space-1);
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls {
|
|
||||||
display: flex;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls button {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls button:hover:not(:disabled) {
|
|
||||||
border-color: var(--accent);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls .remove:hover:not(:disabled) {
|
|
||||||
border-color: var(--danger);
|
|
||||||
color: var(--danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls button:disabled {
|
|
||||||
opacity: 0.4;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Button from '../ui/Button.svelte';
|
|
||||||
import { CATEGORIES } from '$lib/categories';
|
|
||||||
import { TOOLS } from '$lib/registry';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
onAdd: (toolId: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { onAdd }: Props = $props();
|
|
||||||
|
|
||||||
const steppable = TOOLS.filter(
|
|
||||||
(tool) => (tool.resultType ?? 'image') === 'image' && (!tool.sourceMode || tool.sourceMode === 'file')
|
|
||||||
);
|
|
||||||
|
|
||||||
let selected = $state(steppable[0]?.id ?? '');
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="picker">
|
|
||||||
<select bind:value={selected} aria-label="Инструмент для нового шага">
|
|
||||||
{#each CATEGORIES as category (category.id)}
|
|
||||||
{@const categoryTools = steppable.filter((tool) => tool.category === category.id)}
|
|
||||||
{#if categoryTools.length > 0}
|
|
||||||
<optgroup label={category.label}>
|
|
||||||
{#each categoryTools as tool (tool.id)}
|
|
||||||
<option value={tool.id}>{tool.title}</option>
|
|
||||||
{/each}
|
|
||||||
</optgroup>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
<Button variant="secondary" onclick={() => onAdd(selected)}>Добавить шаг</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.picker {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--space-2);
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
padding: var(--space-1) var(--space-2);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-s);
|
|
||||||
background: var(--surface);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -2,8 +2,40 @@
|
|||||||
import { getTool } from '$lib/registry';
|
import { getTool } from '$lib/registry';
|
||||||
import ToolPage from '$lib/components/ToolPage.svelte';
|
import ToolPage from '$lib/components/ToolPage.svelte';
|
||||||
import ToolSearch from '$lib/components/search/ToolSearch.svelte';
|
import ToolSearch from '$lib/components/search/ToolSearch.svelte';
|
||||||
|
import Button from '$lib/components/ui/Button.svelte';
|
||||||
|
|
||||||
|
const LAST_TOOL_KEY = 'last-tool-id';
|
||||||
|
|
||||||
|
function loadLastToolId(): string | null {
|
||||||
|
if (typeof localStorage === 'undefined') return null;
|
||||||
|
try {
|
||||||
|
const id = localStorage.getItem(LAST_TOOL_KEY);
|
||||||
|
return id !== null && getTool(id) ? id : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let selectedId = $state<string | null>(null);
|
let selectedId = $state<string | null>(null);
|
||||||
|
let restoreOnOpen = $state(false);
|
||||||
|
let lastToolId = $state<string | null>(loadLastToolId());
|
||||||
|
|
||||||
|
function openTool(id: string, restore = false) {
|
||||||
|
if (!getTool(id)) return;
|
||||||
|
selectedId = id;
|
||||||
|
restoreOnOpen = restore;
|
||||||
|
lastToolId = id;
|
||||||
|
if (typeof localStorage === 'undefined') return;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LAST_TOOL_KEY, id);
|
||||||
|
} catch {
|
||||||
|
// приватный режим — просто не запоминаем
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeTool() {
|
||||||
|
selectedId = null;
|
||||||
|
}
|
||||||
|
|
||||||
let selected = $derived(selectedId !== null ? (getTool(selectedId) ?? null) : null);
|
let selected = $derived(selectedId !== null ? (getTool(selectedId) ?? null) : null);
|
||||||
</script>
|
</script>
|
||||||
@@ -18,15 +50,23 @@
|
|||||||
<section class="hero">
|
<section class="hero">
|
||||||
<h1>Что делаем с изображением?</h1>
|
<h1>Что делаем с изображением?</h1>
|
||||||
<p class="lead text-muted">Найдите инструмент — все операции выполняются локально в браузере.</p>
|
<p class="lead text-muted">Найдите инструмент — все операции выполняются локально в браузере.</p>
|
||||||
<ToolSearch onSelect={(id) => (selectedId = id)} />
|
<ToolSearch onSelect={(id) => openTool(id)} />
|
||||||
|
{#if lastToolId !== null && getTool(lastToolId)}
|
||||||
|
{@const restoreId = lastToolId}
|
||||||
|
<div class="restore-row">
|
||||||
|
<Button variant="secondary" fullWidth onclick={() => openTool(restoreId, true)}>
|
||||||
|
↩ Вернуть последний: {getTool(restoreId)?.title}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
{:else}
|
{:else}
|
||||||
<section class="workbench">
|
<section class="workbench">
|
||||||
<button type="button" class="back text-caption text-muted" onclick={() => (selectedId = null)}>
|
<button type="button" class="back text-caption text-muted" onclick={closeTool}>
|
||||||
← Сменить инструмент
|
← Сменить инструмент
|
||||||
</button>
|
</button>
|
||||||
{#key selectedId}
|
{#key selectedId}
|
||||||
<ToolPage tool={selected} />
|
<ToolPage tool={selected} restoreChain={restoreOnOpen} />
|
||||||
{/key}
|
{/key}
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -46,6 +86,11 @@
|
|||||||
margin: 0 auto var(--space-4);
|
margin: 0 auto var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.restore-row {
|
||||||
|
max-width: 36rem;
|
||||||
|
margin: var(--space-2) auto 0;
|
||||||
|
}
|
||||||
|
|
||||||
.back {
|
.back {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-bottom: var(--space-3);
|
margin-bottom: var(--space-3);
|
||||||
|
|||||||
@@ -1,281 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { clonePixelImage, type PixelImage } from '$lib/core/types';
|
|
||||||
import { decodeFile, isSupportedImage, unsupportedImageMessage } from '$lib/core/io';
|
|
||||||
import { getTool, PNG_OUTPUT, sanitizeParams } from '$lib/registry';
|
|
||||||
import { createAutoRunner } from '$lib/tools/auto-run';
|
|
||||||
import { createStep, type PipelineStep } from '$lib/tools/pipeline';
|
|
||||||
import DownloadButton from '$lib/components/DownloadButton.svelte';
|
|
||||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
|
||||||
import Preview from '$lib/components/Preview.svelte';
|
|
||||||
import SourceCard from '$lib/components/tool/SourceCard.svelte';
|
|
||||||
import StepEditor from '$lib/components/workspace/StepEditor.svelte';
|
|
||||||
import StepList from '$lib/components/workspace/StepList.svelte';
|
|
||||||
import StepPicker from '$lib/components/workspace/StepPicker.svelte';
|
|
||||||
|
|
||||||
type Status = 'idle' | 'loaded' | 'processing' | 'error';
|
|
||||||
|
|
||||||
const runner = createAutoRunner();
|
|
||||||
let hasLastRun = false;
|
|
||||||
let lastRunSource: PixelImage | null = null;
|
|
||||||
let lastRunStepsJson = '';
|
|
||||||
|
|
||||||
let status = $state<Status>('idle');
|
|
||||||
let source = $state<PixelImage | null>(null);
|
|
||||||
let steps = $state<PipelineStep[]>([]);
|
|
||||||
let selectedId = $state<string | null>(null);
|
|
||||||
let previews = $state<(PixelImage | null)[]>([]);
|
|
||||||
let errorText = $state('');
|
|
||||||
|
|
||||||
let selectedIndex = $derived.by(() => {
|
|
||||||
if (selectedId !== null) {
|
|
||||||
const index = steps.findIndex((step) => step.id === selectedId);
|
|
||||||
if (index >= 0) return index;
|
|
||||||
}
|
|
||||||
return steps.length - 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
let shownResult = $derived.by(() => {
|
|
||||||
if (!source) return null;
|
|
||||||
if (previews.length === 0) return steps.length === 0 ? source : null;
|
|
||||||
const idx = Math.min(Math.max(selectedIndex, 0), previews.length - 1);
|
|
||||||
return previews[idx];
|
|
||||||
});
|
|
||||||
|
|
||||||
function addStep(toolId: string) {
|
|
||||||
steps.push(createStep(toolId));
|
|
||||||
selectedId = steps[steps.length - 1].id;
|
|
||||||
}
|
|
||||||
|
|
||||||
function moveUp(index: number) {
|
|
||||||
if (index <= 0) return;
|
|
||||||
const [moved] = steps.splice(index, 1);
|
|
||||||
steps.splice(index - 1, 0, moved);
|
|
||||||
}
|
|
||||||
|
|
||||||
function moveDown(index: number) {
|
|
||||||
if (index >= steps.length - 1) return;
|
|
||||||
const [moved] = steps.splice(index, 1);
|
|
||||||
steps.splice(index + 1, 0, moved);
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeStep(index: number) {
|
|
||||||
if (index < 0 || index >= steps.length) return;
|
|
||||||
const removed = steps[index];
|
|
||||||
steps.splice(index, 1);
|
|
||||||
if (selectedId === removed.id) selectedId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runChain() {
|
|
||||||
if (!source) return;
|
|
||||||
const token = runner.next();
|
|
||||||
hasLastRun = true;
|
|
||||||
lastRunSource = source;
|
|
||||||
lastRunStepsJson = JSON.stringify(steps);
|
|
||||||
status = 'processing';
|
|
||||||
errorText = '';
|
|
||||||
try {
|
|
||||||
const collected: PixelImage[] = [];
|
|
||||||
let current: PixelImage = clonePixelImage(source);
|
|
||||||
for (let i = 0; i < steps.length; i++) {
|
|
||||||
const step = steps[i];
|
|
||||||
const tool = getTool(step.toolId);
|
|
||||||
if (!tool?.run) continue;
|
|
||||||
try {
|
|
||||||
current = await tool.run(current, sanitizeParams(tool, step.values));
|
|
||||||
} catch (e) {
|
|
||||||
const message = e instanceof Error ? e.message : String(e);
|
|
||||||
throw new Error(`Шаг ${i + 1} (${tool.title}): ${message}`);
|
|
||||||
}
|
|
||||||
if (!runner.isCurrent(token)) return;
|
|
||||||
collected.push(current);
|
|
||||||
}
|
|
||||||
previews = collected;
|
|
||||||
status = 'loaded';
|
|
||||||
} catch (e) {
|
|
||||||
if (!runner.isCurrent(token)) return;
|
|
||||||
errorText = e instanceof Error ? e.message : String(e);
|
|
||||||
status = 'loaded';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const stepsJson = JSON.stringify(steps);
|
|
||||||
if (hasLastRun && source === lastRunSource && stepsJson === lastRunStepsJson) return;
|
|
||||||
if (!source) return;
|
|
||||||
return runner.schedule(() => void runChain());
|
|
||||||
});
|
|
||||||
|
|
||||||
function showError(message: string) {
|
|
||||||
errorText = message;
|
|
||||||
}
|
|
||||||
|
|
||||||
function reset() {
|
|
||||||
source = null;
|
|
||||||
previews = [];
|
|
||||||
status = 'idle';
|
|
||||||
errorText = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleFile(file: File) {
|
|
||||||
if (!isSupportedImage(file)) {
|
|
||||||
showError(unsupportedImageMessage(file));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
source = await decodeFile(file);
|
|
||||||
} catch (e) {
|
|
||||||
showError(e instanceof Error ? e.message : String(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePaste(event: ClipboardEvent) {
|
|
||||||
for (const item of event.clipboardData?.items ?? []) {
|
|
||||||
if (!item.type.startsWith('image/')) continue;
|
|
||||||
const file = item.getAsFile();
|
|
||||||
if (!file) break;
|
|
||||||
event.preventDefault();
|
|
||||||
if (!isSupportedImage(file)) {
|
|
||||||
showError(unsupportedImageMessage(file));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
void handleFile(file);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:window onpaste={handlePaste} />
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h1>Рабочая область</h1>
|
|
||||||
<p class="description text-muted">
|
|
||||||
Цепочка инструментов: результат каждого шага становится входом следующего.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{#if errorText}
|
|
||||||
<div class="error-banner" role="alert">{errorText}</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="stage panel">
|
|
||||||
<div class="cell">
|
|
||||||
<SourceCard {source} onFile={handleFile} onError={showError} onReset={reset} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="cell middle">
|
|
||||||
<h2 class="heading-section">Шаги</h2>
|
|
||||||
<StepPicker onAdd={addStep} />
|
|
||||||
<StepList
|
|
||||||
{steps}
|
|
||||||
selectedIndex={selectedIndex}
|
|
||||||
onSelect={(index) => (selectedId = steps[index]?.id ?? null)}
|
|
||||||
onMoveUp={moveUp}
|
|
||||||
onMoveDown={moveDown}
|
|
||||||
onRemove={removeStep}
|
|
||||||
/>
|
|
||||||
{#if selectedIndex >= 0 && steps[selectedIndex]}
|
|
||||||
<StepEditor step={steps[selectedIndex]} />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="cell">
|
|
||||||
<h2 class="heading-section">Итог</h2>
|
|
||||||
{#if !source}
|
|
||||||
<div class="media">
|
|
||||||
<EmptyState
|
|
||||||
title="Итог появится здесь"
|
|
||||||
hint="Загрузите изображение и соберите цепочку шагов"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{:else if status === 'processing' && !shownResult}
|
|
||||||
<div class="media">
|
|
||||||
<EmptyState title="Обработка…" hint="Выполняется цепочка шагов" />
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="media">
|
|
||||||
<Preview image={shownResult} />
|
|
||||||
{#if status === 'processing'}
|
|
||||||
<span class="recalc" aria-live="polite">Пересчёт…</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<DownloadButton
|
|
||||||
image={shownResult}
|
|
||||||
format={PNG_OUTPUT}
|
|
||||||
baseName="workspace"
|
|
||||||
params={{}}
|
|
||||||
onError={(e) => showError(e instanceof Error ? e.message : String(e))}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
h1 {
|
|
||||||
margin-bottom: var(--space-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.description {
|
|
||||||
max-width: 48rem;
|
|
||||||
margin-bottom: var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-banner {
|
|
||||||
margin-bottom: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stage {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(16rem, 22rem) minmax(0, 1fr);
|
|
||||||
gap: var(--space-4);
|
|
||||||
padding: var(--space-4);
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cell {
|
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cell h2 {
|
|
||||||
margin-bottom: var(--space-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.middle {
|
|
||||||
position: sticky;
|
|
||||||
top: var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.media {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: 14rem;
|
|
||||||
position: relative;
|
|
||||||
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(--accent);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 64rem) {
|
|
||||||
.stage {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.middle {
|
|
||||||
position: static;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
Reference in New Issue
Block a user