feat: add source aware resolver, update reset, update generator

This commit is contained in:
2026-09-25 19:01:43 +05:00
parent 5313567e70
commit 5f77c097e2
19 changed files with 361 additions and 179 deletions
+40 -17
View File
@@ -2,7 +2,7 @@
import SchemaFields from "$lib/components/SchemaFields.svelte";
import SchemaPreview from "$lib/components/SchemaPreview.svelte";
import { debounce } from "$lib/core/debounce";
import { ToolError } from "$lib/core/errors";
import { ToolError, type ErrorVars } from "$lib/core/errors";
import { decodeFile, encode } from "$lib/core/io";
import type { PixelImage } from "$lib/core/types";
import { execute } from "$lib/executor";
@@ -14,6 +14,7 @@
import { t } from "$lib/i18n/t";
import type { FileResult, ToolEntry } from "$lib/registry";
import {
applySourceDefaults,
defaultSchemaParams,
sanitizeSchemaParams,
type ToolSchema,
@@ -23,6 +24,9 @@
interface Props {
tool: ToolEntry;
}
type DisplayError =
| { kind: "i18n"; key: string; vars?: ErrorVars }
| { kind: "plain"; text: string };
let { tool }: Props = $props();
const schema = $derived(
@@ -42,9 +46,17 @@
undefined,
);
let running = $state(false);
let error = $state("");
let displayError = $state<DisplayError | null>(null);
let started = $state(false);
const errorText = $derived.by(() => {
const current = displayError;
if (!current) return "";
return current.kind === "i18n"
? t(current.key, current.vars)
: current.text;
});
const debouncedRun = debounce(() => run(), 200);
function init() {
@@ -58,30 +70,38 @@
}
function reset() {
values = schema ? defaultSchemaParams(schema) : {};
values = schema
? defaultSchemaParams(schema, { source: source ?? undefined })
: {};
result = null;
fileResult = null;
textResult = null;
verdictVars = undefined;
error = "";
displayError = null;
}
async function handleFile(file: File) {
error = "";
displayError = null;
try {
source = await decodeFile(file);
const decoded = await decodeFile(file);
source = decoded;
if (schema) {
values = applySourceDefaults(schema, values, { source: decoded });
}
} catch (e) {
error = errorText(e);
displayError = toDisplayError(e);
}
}
async function run() {
if (!schema) return;
error = "";
displayError = null;
running = true;
try {
const out = await execute(tool, {
params: sanitizeSchemaParams(schema, values),
params: sanitizeSchemaParams(schema, values, {
source: source ?? undefined,
}),
source: source ?? undefined,
text: textSource || undefined,
});
@@ -108,7 +128,7 @@
fileResult = null;
}
} catch (e) {
error = errorText(e);
displayError = toDisplayError(e);
} finally {
running = false;
}
@@ -144,7 +164,7 @@
try {
await navigator.clipboard.writeText(copyValue);
} catch (e) {
error = errorText(e);
displayError = toDisplayError(e);
}
}
@@ -164,18 +184,21 @@
});
$effect(() => {
// TODO: can any edge case start infinite loop?
if (!started) return;
if (inputMode === "image" && !source) return;
if (inputMode === "text" && !textSource.trim()) return;
void values;
void source;
debouncedRun();
return () => debouncedRun.cancel();
});
function errorText(e: unknown): string {
if (e instanceof ToolError) return t(e.key, e.vars);
if (e instanceof Error) return e.message;
return String(e);
function toDisplayError(e: unknown): DisplayError {
if (e instanceof ToolError) {
return { kind: "i18n", key: e.key, vars: e.vars };
}
if (e instanceof Error) return { kind: "plain", text: e.message };
return { kind: "plain", text: String(e) };
}
</script>
@@ -215,7 +238,7 @@
{inputMode}
{resultKind}
{running}
{error}
error={errorText}
onupload={handleFile}
ontextsource={(textValue) => {
textSource = textValue;
+9 -3
View File
@@ -20,7 +20,9 @@ export async function execute(
tool: ToolEntry,
ctx: ExecuteContext,
): Promise<ToolResult> {
const params = sanitizeSchemaParams(tool.schema, ctx.params);
const params = sanitizeSchemaParams(tool.schema, ctx.params, {
source: ctx.source,
});
if (tool.input === "image" && !ctx.source) {
throw new ToolError("errors.sourceRequired");
}
@@ -129,13 +131,17 @@ function ensureWorker(): Worker | null {
} else if (payload.errorKey) {
entry.reject(new ToolError(payload.errorKey, payload.errorVars));
} else {
entry.reject(new Error(payload.error ?? "errors.workerFailed"));
entry.reject(
payload.error
? new Error(payload.error)
: new ToolError("errors.workerFailed"),
);
}
};
candidate.onerror = () => {
worker = null;
for (const entry of pending.values()) {
entry.reject(new Error("errors.workerUnavailable"));
entry.reject(new ToolError("errors.workerUnavailable"));
}
pending.clear();
};
+2 -2
View File
@@ -46,7 +46,7 @@ async function handle(request: WorkerRequest): Promise<void> {
try {
const tool = getTool(request.toolId);
if (!tool?.run) {
throw new Error("errors.noImageRun");
throw new ToolError("errors.noImageRun");
}
const source: PixelImage | undefined = request.source
? {
@@ -56,7 +56,7 @@ async function handle(request: WorkerRequest): Promise<void> {
}
: undefined;
const output = await tool.run({
params: sanitizeSchemaParams(tool.schema, request.params),
params: sanitizeSchemaParams(tool.schema, request.params, { source }),
source,
text: request.text,
});
+11
View File
@@ -36,6 +36,17 @@ describe("t", () => {
expect(t("home.onlyEnKey")).toBe("Only English string");
});
it("переводит ошибки с vars в активной локали", () => {
setLocale("ru");
expect(t("errors.pixelCountMismatch", { count: 33, width: 32 })).toContain(
"33",
);
setLocale("en");
expect(t("errors.pixelCountMismatch", { count: 33, width: 32 })).toContain(
"33",
);
});
it("неизвестный путь возвращает сам путь", () => {
expect(t("no.such.key")).toBe("no.such.key");
});
+65
View File
@@ -1,9 +1,11 @@
import { describe, expect, it } from "vitest";
import {
applySourceDefaults,
defaultSchemaParams,
field,
sanitizeSchemaParams,
toolSchema,
type Dimension,
} from "./registry-schema";
interface FrameParams {
@@ -32,6 +34,24 @@ const frameSchema = toolSchema<FrameParams>(
},
);
interface SourceParams {
size: Dimension;
fixed: Dimension;
}
const sourceSchema = toolSchema<SourceParams>({
size: field.dimension({
min: 1,
max: 100,
width: 1,
height: 1,
defaultFromSource: true,
}),
fixed: field.dimension({ min: 1, max: 100, width: 10, height: 20 }),
});
const source = { width: 64, height: 48 };
describe("registry-schema: дефолты из схемы", () => {
it("собирает дефолты всех полей", () => {
expect(defaultSchemaParams(frameSchema)).toEqual({
@@ -43,6 +63,51 @@ describe("registry-schema: дефолты из схемы", () => {
});
});
describe("registry-schema: source-aware dimension defaults", () => {
it("uses static fallback before a source exists", () => {
expect(defaultSchemaParams(sourceSchema)).toEqual({
size: { width: 1, height: 1 },
fixed: { width: 10, height: 20 },
});
});
it("uses source dimensions for opted-in fields only", () => {
expect(defaultSchemaParams(sourceSchema, { source })).toEqual({
size: source,
fixed: { width: 10, height: 20 },
});
expect(
applySourceDefaults(
sourceSchema,
{ size: { width: 2, height: 3 }, fixed: { width: 30, height: 40 } },
{ source },
),
).toEqual({
size: source,
fixed: { width: 30, height: 40 },
});
});
it("uses source dimensions when sanitizing missing values", () => {
expect(sanitizeSchemaParams(sourceSchema, {}, { source })).toEqual({
size: source,
fixed: { width: 10, height: 20 },
});
const manual = sanitizeSchemaParams(
sourceSchema,
{ size: { width: 12, height: 13 } },
{ source },
);
expect(manual.size).toEqual({ width: 12, height: 13 });
const sentinel = sanitizeSchemaParams(
sourceSchema,
{ size: { width: 0, height: 0 } },
{ source },
);
expect(sentinel.size).toEqual(source);
});
});
describe("registry-schema: раскладка (schema.layout)", () => {
it("toolSchema сохраняет группы полей", () => {
expect(frameSchema.layout).toEqual({
+45 -5
View File
@@ -69,6 +69,11 @@ export interface DimensionSpec extends FieldSpecBase {
max: number;
width: number;
height: number;
defaultFromSource?: boolean;
}
export interface SchemaContext {
source?: Dimension;
}
/** Пара цветов «от → к» (градиенты, сведение к двум цветам и т.п.). */
@@ -270,6 +275,7 @@ export const field = {
max: number;
width: number;
height: number;
defaultFromSource?: boolean;
}): Field<Dimension> => ({
spec: { kind: "dimension", ...s },
}),
@@ -341,15 +347,27 @@ function clamp(value: number, min?: number, max?: number): number {
return value;
}
function resolveDimensionDefaults(
spec: DimensionSpec,
context?: SchemaContext,
): Dimension {
const source = spec.defaultFromSource ? context?.source : undefined;
return {
width: clamp(source?.width ?? spec.width, spec.min, spec.max),
height: clamp(source?.height ?? spec.height, spec.min, spec.max),
};
}
/** Дефолты из схемы — единый источник значений по умолчанию для нового UI. */
export function defaultSchemaParams<P>(
schema: ToolSchema<P>,
context?: SchemaContext,
): Record<keyof P, unknown> {
const out = {} as Record<keyof P, unknown>;
for (const key of Object.keys(schema.fields) as (keyof P)[]) {
const spec = schema.fields[key].spec;
if (spec.kind === "dimension") {
out[key] = { width: spec.width, height: spec.height };
out[key] = resolveDimensionDefaults(spec, context);
} else if (spec.kind === "color-pair") {
out[key] = { from: spec.from, to: spec.to };
} else if (spec.kind === "colors") {
@@ -382,10 +400,27 @@ export function defaultSchemaParams<P>(
return out;
}
export function applySourceDefaults<P>(
schema: ToolSchema<P>,
values: Record<string, unknown>,
context: SchemaContext,
): Record<keyof P, unknown> {
const defaults = defaultSchemaParams(schema, context);
for (const key of Object.keys(schema.fields) as (keyof P)[]) {
const spec = schema.fields[key].spec;
if (spec.kind === "dimension" && spec.defaultFromSource) continue;
if (Object.prototype.hasOwnProperty.call(values, key)) {
defaults[key] = values[key as string];
}
}
return defaults;
}
/** Валидация/нормализация значений по схеме. Аналог старого `sanitizeParams`. */
export function sanitizeSchemaParams<P>(
schema: ToolSchema<P>,
values: Record<string, unknown>,
context?: SchemaContext,
): Record<keyof P, unknown> {
const out = {} as Record<keyof P, unknown>;
for (const key of Object.keys(schema.fields) as (keyof P)[]) {
@@ -425,17 +460,22 @@ export function sanitizeSchemaParams<P>(
"height" in raw
? (raw as Record<string, unknown>)
: undefined;
const defaults = resolveDimensionDefaults(spec, context);
const w =
typeof r?.width === "number" && Number.isFinite(r.width)
? r.width
: spec.width;
: defaults.width;
const h =
typeof r?.height === "number" && Number.isFinite(r.height)
? r.height
: spec.height;
: defaults.height;
const sourceSize =
spec.defaultFromSource && context?.source && w === 0 && h === 0
? defaults
: { width: w, height: h };
out[key] = {
width: clamp(w, spec.min, spec.max),
height: clamp(h, spec.min, spec.max),
width: clamp(sourceSize.width, spec.min, spec.max),
height: clamp(sourceSize.height, spec.min, spec.max),
};
break;
}
+7 -5
View File
@@ -194,8 +194,9 @@ export const resizeSchema = toolSchema<ResizeParams>(
label: "fields.canvasSize",
min: 0,
max: 20000,
width: 0,
height: 0,
width: 1,
height: 1,
defaultFromSource: true,
}),
keepAspect: field.checkbox({ label: "fields.keepAspect", default: true }),
},
@@ -263,10 +264,11 @@ export const cropSchema = toolSchema<CropParams>(
}),
size: field.dimension({
label: "fields.cropAreaSize",
min: 0,
min: 1,
max: 2000,
width: 0,
height: 0,
width: 1,
height: 1,
defaultFromSource: true,
}),
},
{
+39
View File
@@ -65,6 +65,45 @@ describe("registry-new (переведённые инструменты)", () =>
}
});
it("source-aware dimension defaults применяются только к image tools", () => {
for (const tool of TOOLS) {
for (const field of Object.values(tool.schema.fields)) {
if (field.spec.kind === "dimension" && field.spec.defaultFromSource) {
expect(tool.input, tool.id).toBe("image");
}
}
}
});
it("resize и crop получают размеры текущего source", async () => {
const source = solid(64, 48);
const resize = TOOLS.find((t) => t.id === "resize-png")!;
const resizeDefaults = defaultSchemaParams(resize.schema, { source });
expect(resizeDefaults.size).toEqual({ width: 64, height: 48 });
const resized = asImage(
await resize.run({
source,
params: sanitizeSchemaParams(resize.schema, resizeDefaults, { source }),
}),
);
expect([resized.width, resized.height]).toEqual([64, 48]);
const crop = TOOLS.find((t) => t.id === "crop-png")!;
const cropDefaults = defaultSchemaParams(crop.schema, { source });
expect(cropDefaults).toMatchObject({
x: 0,
y: 0,
size: { width: 64, height: 48 },
});
const cropped = asImage(
await crop.run({
source,
params: sanitizeSchemaParams(crop.schema, cropDefaults, { source }),
}),
);
expect([cropped.width, cropped.height]).toEqual([64, 48]);
});
it("create-empty: dimension-дефолты корректны", () => {
const tool = TOOLS.find((t) => t.id === "create-empty-png")!;
const d = defaultSchemaParams(tool.schema);