docs: add common tools and components
This commit is contained in:
@@ -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>
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user