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
+57 -8
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,8 +176,12 @@
<div class="error-banner" role="alert">{errorText}</div>
{/if}
<div class="stage panel">
<div class="stage panel" class:single={isSourceless}>
{#if !isSourceless}
<div class="cell">
{#if isTextSource && !source}
<TextInputCard onSubmit={handleTextSubmit} />
{:else}
<SourceCard
{source}
onFile={handleFile}
@@ -148,11 +190,13 @@
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>
+52
View File
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import { encodeBmpBytes } from './bmp';
import { makeImage } from './test-helpers';
describe('encodeBmpBytes', () => {
it('пишет заголовки BM и размеры для 2x1 без паддинга-неопределённости', () => {
const bytes = encodeBmpBytes(
makeImage(2, 1, [
[255, 0, 0, 255],
[0, 255, 0, 255]
])
);
expect([bytes[0], bytes[1]]).toEqual([0x42, 0x4d]);
const view = new DataView(bytes.buffer);
expect(view.getUint32(2, true)).toBe(54 + 8);
expect(view.getInt32(18, true)).toBe(2);
expect(view.getInt32(22, true)).toBe(1);
expect(view.getUint16(28, true)).toBe(24);
});
it('хранит пиксели в BGR снизу-вверх с паддингом строки', () => {
const bytes = encodeBmpBytes(
makeImage(2, 1, [
[255, 0, 0, 255],
[0, 255, 0, 255]
])
);
expect([...bytes.slice(54, 60)]).toEqual([
0, 0, 255,
0, 255, 0
]);
expect(bytes[60]).toBe(0);
expect(bytes[61]).toBe(0);
});
it('нижняя строка изображения идёт первой в файле', () => {
const bytes = encodeBmpBytes(
makeImage(4, 2, [
[10, 10, 10, 255],
[20, 20, 20, 255],
[30, 30, 30, 255],
[40, 40, 40, 255],
[50, 50, 50, 255],
[60, 60, 60, 255],
[70, 70, 70, 255],
[80, 80, 80, 255]
])
);
expect(bytes[54]).toBe(50);
expect(bytes[54 + 12]).toBe(10);
});
});
+37
View File
@@ -0,0 +1,37 @@
import type { PixelImage } from './types';
export function encodeBmpBytes(img: PixelImage): Uint8Array<ArrayBuffer> {
const rowSize = Math.ceil((img.width * 3) / 4) * 4;
const pixelArraySize = rowSize * img.height;
const fileSize = 54 + pixelArraySize;
const buf = new Uint8Array(fileSize);
const view = new DataView(buf.buffer);
buf[0] = 0x42;
buf[1] = 0x4d;
view.setUint32(2, fileSize, true);
view.setUint32(10, 54, true);
view.setUint32(14, 40, true);
view.setInt32(18, img.width, true);
view.setInt32(22, img.height, true);
view.setUint16(26, 1, true);
view.setUint16(28, 24, true);
view.setUint32(30, 0, true);
view.setUint32(34, pixelArraySize, true);
view.setInt32(38, 2835, true);
view.setInt32(42, 2835, true);
for (let y = 0; y < img.height; y++) {
const srcY = img.height - 1 - y;
let off = 54 + y * rowSize;
for (let x = 0; x < img.width; x++) {
const i = (srcY * img.width + x) * 4;
buf[off++] = img.data[i + 2];
buf[off++] = img.data[i + 1];
buf[off++] = img.data[i];
}
}
return buf;
}
+5 -1
View File
@@ -1,6 +1,7 @@
import { encodeBmpBytes } from './bmp';
import { type PixelImage } from './types';
export type OutputMime = 'image/png' | 'image/jpeg' | 'image/webp';
export type OutputMime = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/bmp';
export const ACCEPTED_IMAGE_TYPES =
'image/png,image/jpeg,image/webp,image/gif,image/bmp,image/x-icon';
@@ -38,6 +39,9 @@ export async function encode(
mime: OutputMime = 'image/png',
quality?: number
): Promise<Blob> {
if (mime === 'image/bmp') {
return new Blob([encodeBmpBytes(img)], { type: mime });
}
if (mime === 'image/jpeg' || mime === 'image/webp') {
if (quality !== undefined && (quality < 0 || quality > 1)) {
throw new RangeError('quality должен быть в диапазоне 0..1');
+7 -1
View File
@@ -40,18 +40,24 @@ export type OutputFormat = {
qualityParamId?: string;
};
export type SourceMode = 'file' | 'none' | 'text';
export type ToolEntry = {
id: string;
title: string;
description: string;
category: CategoryId;
sourceMode?: SourceMode;
params: ParamDef[];
run: (img: PixelImage, params: Record<string, unknown>) => Promise<PixelImage> | PixelImage;
generate?: (params: Record<string, unknown>) => Promise<PixelImage> | PixelImage;
toText?: (img: PixelImage, params: Record<string, unknown>) => Promise<string> | string;
runFromText?: (text: string, params: Record<string, unknown>) => Promise<PixelImage> | PixelImage;
preview?: (
img: PixelImage,
params: Record<string, unknown>
) => Promise<PixelImage> | PixelImage;
resultType?: 'image' | 'info';
resultType?: 'image' | 'info' | 'text';
output?: OutputFormat;
};