docs: add common tools and components

This commit is contained in:
2026-08-23 08:20:14 +05:00
parent b48a8adfa7
commit 3b18d0c6f8
8 changed files with 322 additions and 20 deletions
+67 -18
View File
@@ -6,6 +6,7 @@
import ParamsCard from './tool/ParamsCard.svelte';
import ResultCard from './tool/ResultCard.svelte';
import SourceCard from './tool/SourceCard.svelte';
import TextInputCard from './tool/TextInputCard.svelte';
let { tool }: { tool: ToolEntry } = $props();
@@ -15,15 +16,19 @@
let source = $state<PixelImage | null>(null);
let result = $state<PixelImage | null>(null);
let previewResult = $state<PixelImage | null>(null);
let textResult = $state<string | null>(null);
let showMask = $state(false);
let info = $state<ImageInfo | null>(null);
let errorText = $state('');
let values = $state<Record<string, any>>({});
const isInfo = $derived(tool.resultType === 'info');
const isSourceless = $derived(tool.sourceMode === 'none');
const isTextSource = $derived(tool.sourceMode === 'text');
const sanitized = $derived(sanitizeParams(tool, values));
let runToken = 0;
let hasLastRun = false;
let lastRunSource: PixelImage | null = null;
let lastRunValuesJson = '';
let pipetteTargetId = $state<string | null>(null);
@@ -59,24 +64,55 @@
}
}
async function handleTextSubmit(text: string) {
if (!isTextSource || !tool.runFromText) return;
errorText = '';
status = 'processing';
try {
source = await tool.runFromText(text, defaultParams(tool));
values = defaultParams(tool);
result = null;
textResult = null;
previewResult = null;
showMask = false;
pipetteTargetId = null;
info = null;
await runTool();
} catch (e) {
showError(e);
}
}
async function runTool() {
if (!source || isInfo) return;
if (isInfo) return;
if (!source && !isSourceless) return;
const token = ++runToken;
hasLastRun = true;
lastRunSource = source;
lastRunValuesJson = JSON.stringify(sanitized);
try {
const next = await tool.run(source, sanitized);
let next: PixelImage;
if (isSourceless) {
next = await tool.generate!(sanitized);
} else {
next = await tool.run(source!, sanitized);
}
let nextPreview: PixelImage | null = null;
if (tool.preview) {
if (tool.preview && source) {
try {
nextPreview = await tool.preview(source, sanitized);
} catch {
nextPreview = null;
}
}
let nextText: string | null = null;
if (tool.toText && source) {
nextText = await tool.toText(source, sanitized);
}
if (token !== runToken) return;
result = next;
previewResult = nextPreview;
textResult = nextText;
status = 'loaded';
} catch (e) {
if (token !== runToken) return;
@@ -86,8 +122,9 @@
$effect(() => {
const valuesJson = JSON.stringify(sanitized);
if (source === lastRunSource && valuesJson === lastRunValuesJson) return;
if (!source || isInfo) return;
if (hasLastRun && source === lastRunSource && valuesJson === lastRunValuesJson) return;
if (!source && !isSourceless) return;
if (isInfo) return;
const timer = setTimeout(() => void runTool(), 300);
return () => clearTimeout(timer);
});
@@ -101,6 +138,7 @@
source = null;
result = null;
previewResult = null;
textResult = null;
showMask = false;
pipetteTargetId = null;
info = null;
@@ -138,21 +176,27 @@
<div class="error-banner" role="alert">{errorText}</div>
{/if}
<div class="stage panel">
<div class="cell">
<SourceCard
{source}
onFile={handleFile}
onError={(message) => (errorText = message)}
onReset={reset}
pipetteActive={!!pipetteTargetId}
onPickColor={handlePickColor}
/>
</div>
<div class="stage panel" class:single={isSourceless}>
{#if !isSourceless}
<div class="cell">
{#if isTextSource && !source}
<TextInputCard onSubmit={handleTextSubmit} />
{:else}
<SourceCard
{source}
onFile={handleFile}
onError={(message) => (errorText = message)}
onReset={reset}
pipetteActive={!!pipetteTargetId}
onPickColor={handlePickColor}
/>
{/if}
</div>
{/if}
<div class="cell">
<ResultCard
{tool}
sourceLoaded={!!source}
sourceLoaded={isSourceless ? true : !!source}
{status}
{result}
{previewResult}
@@ -160,12 +204,13 @@
{info}
{isInfo}
params={sanitized}
{textResult}
onDownloadError={showError}
/>
</div>
</div>
{#if source && !isInfo}
{#if (source || isSourceless) && !isInfo}
<ParamsCard
params={tool.params}
bind:values
@@ -196,6 +241,10 @@
padding: var(--space-4);
}
.stage.single {
grid-template-columns: 1fr;
}
.cell {
min-width: 0;
display: flex;
@@ -4,6 +4,7 @@
import EmptyState from '../ui/EmptyState.svelte';
import InfoPanel from '../InfoPanel.svelte';
import Preview from '../Preview.svelte';
import TextResult from './TextResult.svelte';
import type { ImageInfo } from '$lib/core/analyze';
import type { PixelImage } from '$lib/core/types';
import { outputOf, type ToolEntry } from '$lib/registry';
@@ -20,6 +21,7 @@
info: ImageInfo | null;
isInfo: boolean;
params: Record<string, unknown>;
textResult: string | null;
onDownloadError: (e: unknown) => void;
}
@@ -33,6 +35,7 @@
info,
isInfo,
params,
textResult,
onDownloadError
}: Props = $props();
@@ -62,6 +65,12 @@
<InfoPanel {info} />
{/if}
</div>
{:else if tool.resultType === 'text'}
<div class="media">
{#if textResult !== null}
<TextResult text={textResult} filename={tool.id} />
{/if}
</div>
{:else}
{#if hasPreview}
<CheckboxField id="show-mask" label="Показать маску" bind:checked={showMask} />
@@ -0,0 +1,57 @@
<script lang="ts">
import Button from '../ui/Button.svelte';
interface Props {
onSubmit: (text: string) => void;
}
let { onSubmit }: Props = $props();
let text = $state('');
function submit() {
if (text.trim().length === 0) return;
onSubmit(text);
}
</script>
<div class="container">
<h2 class="heading-section">Текст</h2>
<textarea
class="input"
rows="8"
bind:value={text}
placeholder="Вставьте данные сюда"
aria-label="Текстовые данные"
></textarea>
<Button variant="secondary" onclick={submit} disabled={text.trim().length === 0}>
Декодировать
</Button>
</div>
<style>
.container {
display: flex;
flex-direction: column;
gap: var(--space-2);
min-width: 0;
height: 100%;
}
h2 {
margin-bottom: var(--space-1);
}
.input {
flex: 1;
width: 100%;
resize: vertical;
padding: var(--space-2);
border: 1px solid var(--border);
border-radius: var(--radius-s);
background: var(--surface);
font-family: var(--font-mono);
font-size: var(--text-s);
min-height: 12rem;
}
</style>
@@ -0,0 +1,88 @@
<script lang="ts">
import { downloadBlob } from '$lib/core/io';
interface Props {
text: string;
filename: string;
}
let { text, filename }: Props = $props();
let copied = $state(false);
async function copy() {
await navigator.clipboard.writeText(text);
copied = true;
setTimeout(() => (copied = false), 1500);
}
function download() {
downloadBlob(new Blob([text], { type: 'text/plain' }), `${filename}.txt`);
}
</script>
<div class="panel text-result">
<textarea class="output" rows="10" readonly value={text} aria-label="Текстовый результат"></textarea>
<div class="actions">
<button type="button" class="secondary" onclick={copy}>
{copied ? 'Скопировано' : 'Копировать'}
</button>
<button type="button" class="primary" onclick={download}>Скачать .txt</button>
</div>
</div>
<style>
.text-result {
padding: var(--space-3);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.output {
width: 100%;
resize: vertical;
padding: var(--space-2);
border: 1px solid var(--border);
border-radius: var(--radius-s);
background: var(--bg);
font-family: var(--font-mono);
font-size: var(--text-xs);
word-break: break-all;
}
.actions {
display: flex;
gap: var(--space-2);
}
.actions button {
flex: 1;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-s);
border: 1px solid transparent;
font-weight: 600;
font-size: var(--text-m);
cursor: pointer;
}
.actions .primary {
background: var(--accent);
color: var(--accent-contrast);
}
.actions .primary:hover {
background: var(--accent-hover);
}
.actions .secondary {
background: var(--surface);
color: var(--text);
border-color: var(--border);
}
.actions .secondary:hover {
border-color: var(--accent);
color: var(--accent);
}
</style>