feat: add info\warning badge, update size range workflow

This commit is contained in:
2026-10-06 15:30:24 +05:00
parent ba533e67fd
commit 92cc590959
19 changed files with 402 additions and 194 deletions
@@ -13,11 +13,25 @@
label: string;
value: unknown;
spec: FieldSpec;
/** Размеры входа шага: потолок осей для maxFromSource-полей. */
sourceDims?: Dimension;
onchange?: (value: Dimension, axis?: "width" | "height" | "both") => void;
}
let { label, value, spec, onchange }: Props = $props();
let {
label,
value,
spec,
sourceDims = undefined,
onchange,
}: Props = $props();
const sp = $derived(spec as DimensionSpec);
const maxW = $derived(
sp.maxFromSource && sourceDims ? sourceDims.width : sp.max,
);
const maxH = $derived(
sp.maxFromSource && sourceDims ? sourceDims.height : sp.max,
);
const current = $derived.by(() => {
const v = value as Partial<Dimension> | undefined;
return {
@@ -49,7 +63,7 @@
<input
type="number"
min={sp.min}
max={sp.max}
max={maxW}
value={current.width}
oninput={(e) =>
setAxis("width", Number((e.target as HTMLInputElement).value))}
@@ -60,7 +74,7 @@
<input
type="number"
min={sp.min}
max={sp.max}
max={maxH}
value={current.height}
oninput={(e) =>
setAxis("height", Number((e.target as HTMLInputElement).value))}
@@ -2,6 +2,7 @@
import type { InputMode, ResultKind } from "$lib/registry";
import { RefreshCw } from "@lucide/svelte";
import { t } from "$lib/i18n/t";
import Badge from "$lib/components/ui/Badge.svelte";
import type { Snippet } from "svelte";
interface Props {
@@ -11,6 +12,9 @@
loading?: boolean;
parts?: number;
partsDims?: string;
dims?: string;
note?: string;
noteTone?: "warning" | "info";
}
let {
label,
@@ -19,24 +23,31 @@
loading = false,
parts = undefined,
partsDims = undefined,
dims = undefined,
note = undefined,
noteTone = "warning",
}: Props = $props();
</script>
<figure class="tile">
<figcaption>
<span>
{label}
{#if parts !== undefined}
<span class="count">
{parts}
{t("resultCard.parts")}{#if partsDims}
· {partsDims}{/if}
</span>
{/if}
<span class="tile-label">
<span
>{label}{#if parts !== undefined}<span class="dims"
>: {parts}
{t("resultCard.parts")}{#if partsDims}
· {partsDims}{/if}</span
>{:else if dims}<span class="dims">: {dims}</span>{/if}</span
>
{#if loading}
<RefreshCw class="rotating" size="12" />
{/if}
</span>
<span class="tile-note">
{#if note}
<Badge tone={noteTone} label={note} />
{/if}
</span>
</figcaption>
<div class="canvas" class:checker={mode === "image"}>
{@render children()}
@@ -47,7 +58,10 @@
.tile figcaption {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-m);
/* Резерв под бейдж: строка не прыгает, когда заметка появляется. */
min-height: var(--space-xxl);
margin-bottom: var(--space-m);
font: var(--font-size-s) var(--font-mono);
text-transform: uppercase;
@@ -56,8 +70,18 @@
animation: rotate var(--duration-l) linear infinite;
}
}
.count {
margin-left: var(--space-m);
.tile-label {
display: inline-flex;
align-items: center;
gap: var(--space-m);
}
/* Место под заметки результата: бейджи-предупреждения. */
.tile-note {
display: inline-flex;
align-items: center;
gap: var(--space-m);
}
.dims {
color: var(--color-main);
}
.canvas {
@@ -1,10 +1,11 @@
<script lang="ts">
import type {
Dimension,
FieldSpec,
FieldSpecKind,
ToolSchema,
} from "$lib/registry-schema";
import { resolveLayoutGroups } from "$lib/registry-schema";
import { effectiveMax, resolveLayoutGroups } from "$lib/registry-schema";
import { fieldLabel, groupLabel } from "$lib/i18n/schema-tool-strings";
import { t } from "$lib/i18n/t";
import { RotateCcw } from "@lucide/svelte";
@@ -30,6 +31,7 @@
spec: FieldSpec;
toolId: string;
fieldId: string;
sourceDims?: Dimension;
onchange?: (value: unknown, axis?: "width" | "height" | "both") => void;
}
@@ -54,6 +56,8 @@
schema: ToolSchema<Record<string, unknown>>;
values: Record<string, unknown>;
toolId: string;
/** Размеры входа шага: для maxFromSource-полей (crop и т.п.). */
sourceDims?: Dimension;
onchange: (
id: string,
value: unknown,
@@ -61,9 +65,23 @@
) => void;
onreset: () => void;
}
let { schema, values, toolId, onchange, onreset }: Props = $props();
let {
schema,
values,
toolId,
sourceDims = undefined,
onchange,
onreset,
}: Props = $props();
const layoutGroups = $derived(resolveLayoutGroups(schema));
function effectiveSpec(spec: FieldSpec): FieldSpec {
if (sourceDims && spec.kind === "number" && spec.maxFromSource) {
return { ...spec, max: effectiveMax(spec, sourceDims) };
}
return spec;
}
</script>
{#each layoutGroups as group (group.key)}
@@ -81,9 +99,10 @@
<Control
label={fieldLabel(schema.fields[id], id)}
value={values[id]}
spec={schema.fields[id].spec}
spec={effectiveSpec(schema.fields[id].spec)}
{toolId}
fieldId={id}
{sourceDims}
onchange={(v, axis) => onchange(id, v, axis)}
/>
{/each}
@@ -1,13 +1,17 @@
<script lang="ts">
import MetaList from "$lib/components/display/MetaList.svelte";
import { buildSchemaPreviewModel } from "./schema-preview-model";
import { hasPreviewResult } from "./schema-preview-model";
import SchemaActions from "./SchemaActions.svelte";
import SchemaResultTile from "./SchemaResultTile.svelte";
import SchemaSourceTile from "./SchemaSourceTile.svelte";
import type { PixelImage } from "$lib/core/types";
import type { OutputMime } from "$lib/core/io";
import { t } from "$lib/i18n/t";
import type { FileResult, InputMode, ResultKind } from "$lib/registry";
import type {
FileResult,
InputMode,
ResultKind,
ResultNote,
} from "$lib/registry";
interface Props {
inputMode: InputMode;
@@ -15,6 +19,7 @@
toolId: string;
source: PixelImage | null;
result: PixelImage | null;
resultNote?: ResultNote | null;
fileResult?: FileResult | null;
textSource?: string;
textResult?: string | null;
@@ -39,6 +44,7 @@
toolId,
source,
result,
resultNote = null,
fileResult = null,
textSource = "",
textResult = null,
@@ -58,11 +64,8 @@
onquality,
}: Props = $props();
const model = $derived(
buildSchemaPreviewModel(
{ inputMode, resultKind, source, result, fileResult, textResult },
t,
),
const hasResult = $derived(
hasPreviewResult({ inputMode, resultKind, result, fileResult, textResult }),
);
</script>
@@ -71,7 +74,7 @@
<SchemaActions
{inputMode}
{resultKind}
canDownload={model.hasResult}
canDownload={hasResult}
{running}
{format}
{quality}
@@ -100,6 +103,7 @@
<SchemaResultTile
{resultKind}
{result}
{resultNote}
{fileResult}
{textResult}
{textVars}
@@ -110,15 +114,6 @@
/>
</div>
<div class="meta">
<MetaList
items={[
{ caption: t("sourceCard.source"), value: model.sourceValue },
{ caption: t("resultCard.result"), value: model.resultValue },
]}
/>
</div>
<style>
.panel-head {
display: flex;
@@ -145,9 +140,6 @@
grid-template-columns: 1fr 1fr;
gap: var(--space-l);
}
.meta {
margin-top: var(--space-l);
}
@media (--bp-tablet) {
.pair {
grid-template-columns: 1fr;
@@ -2,7 +2,7 @@
import { toDataUrl } from "$lib/core/io";
import type { PixelImage } from "$lib/core/types";
import { t } from "$lib/i18n/t";
import type { FileResult, ResultKind } from "$lib/registry";
import type { FileResult, ResultKind, ResultNote } from "$lib/registry";
import { Check } from "@lucide/svelte";
import SchemaTextResult from "./SchemaTextResult.svelte";
import PreviewTile from "./PreviewTile.svelte";
@@ -10,6 +10,7 @@
interface Props {
resultKind?: ResultKind;
result: PixelImage | null;
resultNote?: ResultNote | null;
fileResult?: FileResult | null;
textResult?: string | null;
textVars?: Record<string, string | number>;
@@ -21,6 +22,7 @@
let {
resultKind = "image",
result,
resultNote = null,
fileResult = null,
textResult = null,
textVars = undefined,
@@ -31,6 +33,11 @@
}: Props = $props();
const resultUrl = $derived(result ? toDataUrl(result) : null);
const resultDims = $derived(
resultKind === "image" && result
? `${result.width} × ${result.height}`
: undefined,
);
const partUrls = $derived(
resultKind === "files" && fileResult
? fileResult.files.map((file) => ({
@@ -57,6 +64,9 @@
loading={running}
{parts}
{partsDims}
dims={resultDims}
note={resultNote ? t(resultNote.key) : undefined}
noteTone={resultNote?.tone ?? "warning"}
>
<div
class="canvas"
@@ -27,6 +27,9 @@
}: Props = $props();
const sourceUrl = $derived(source ? toDataUrl(source) : null);
const sourceDims = $derived(
source ? `${source.width} × ${source.height}` : undefined,
);
let dragging = $state(false);
let input = $state<HTMLInputElement | null>(null);
@@ -63,7 +66,7 @@
}
</script>
<PreviewTile label={t("sourceCard.source")} viewMode={mode}>
<PreviewTile label={t("sourceCard.source")} viewMode={mode} dims={sourceDims}>
{#if mode === "text"}
<div class="text-source-wrap">
<SchemaTextSource
@@ -40,6 +40,7 @@
} from "$lib/registry";
import {
applySourceDefaults,
clampSourceAwareMaxes,
defaultSchemaParams,
withAspectLock,
type Dimension,
@@ -105,6 +106,11 @@
// он — единый источник качества; dropdown редактирует его же.
const qualityParamId = $derived(lastTool.output?.qualityParamId);
const lastParams = $derived(steps.at(-1)?.params);
const resultNote = $derived(
result && lastTool.resultNote
? lastTool.resultNote(lastParams ?? {}, result)
: null,
);
const currentQuality = $derived.by(() => {
const setting = outputFormatByMime(format).settings?.quality;
if (!setting) return undefined;
@@ -205,7 +211,8 @@
}
}
}
steps = steps.with(index, { ...step, params: next });
const clamped = dims ? clampSourceAwareMaxes(stepSchema, next, dims) : next;
steps = steps.with(index, { ...step, params: clamped });
}
function resetStep(index: number) {
@@ -249,11 +256,15 @@
if (first && firstSchema) {
steps = steps.with(0, {
...first,
params: applySourceDefaults(
params: clampSourceAwareMaxes(
firstSchema,
first.params,
{ source: decoded },
touchedFor(first.key),
applySourceDefaults(
firstSchema,
first.params,
{ source: decoded },
touchedFor(first.key),
),
decoded,
),
});
}
@@ -309,7 +320,7 @@
const base = toDisplayError(e);
const msg = base.kind === "i18n" ? t(base.key, base.vars) : base.text;
throw new Error(
t("steps.stepError", {
t("toolPage.stepError", {
n: i + 1,
title: stepTitle(stepTool.id),
msg,
@@ -468,6 +479,7 @@
schema={toolSchemaOf(step)}
values={step.params}
toolId={step.id}
sourceDims={i === 0 ? (source ?? undefined) : stepDims[i - 1]}
onchange={(id, v, axis) => setStepValue(i, id, v, axis)}
onreset={() => resetStep(i)}
/>
@@ -483,6 +495,7 @@
toolId={lastTool.id}
{source}
{result}
{resultNote}
{fileResult}
{textSource}
{textResult}
@@ -1,107 +1,47 @@
import { describe, expect, it } from "vitest";
import { makeImage } from "../../core/test-helpers";
import {
buildSchemaPreviewModel,
type PreviewTranslate,
hasPreviewResult,
type SchemaPreviewModelInput,
} from "./schema-preview-model";
const image = makeImage(2, 3, [
[1, 2, 3, 255],
[4, 5, 6, 255],
[7, 8, 9, 255],
[10, 11, 12, 255],
[13, 14, 15, 255],
[16, 17, 18, 255],
]);
const image = { width: 2, height: 3, data: new Uint8ClampedArray(24) };
const translate: PreviewTranslate = (key) => {
if (key === "resultCard.parts") return "parts";
if (key === "textInput.heading") return "Text";
return key;
};
function input(
overrides: Partial<Parameters<typeof buildSchemaPreviewModel>[0]> = {},
) {
function input(over: Partial<SchemaPreviewModelInput> = {}) {
return {
inputMode: "image" as const,
resultKind: "image" as const,
source: null,
inputMode: "image",
resultKind: "image",
result: null,
fileResult: null,
textResult: null,
...overrides,
};
...over,
} satisfies SchemaPreviewModelInput;
}
describe("buildSchemaPreviewModel", () => {
it("formats source values for every input mode", () => {
expect(
buildSchemaPreviewModel(input({ inputMode: "none" }), translate),
).toMatchObject({ sourceValue: "—" });
expect(
buildSchemaPreviewModel(input({ inputMode: "text" }), translate),
).toMatchObject({ sourceValue: "Text" });
expect(
buildSchemaPreviewModel(
input({ inputMode: "image", source: image }),
translate,
),
).toMatchObject({ sourceValue: "2 × 3 px" });
describe("hasPreviewResult", () => {
it("image: результат только при наличии картинки", () => {
expect(hasPreviewResult(input({ result: image }))).toBe(true);
expect(hasPreviewResult(input())).toBe(false);
});
it("formats image results and enables download only with a result", () => {
expect(
buildSchemaPreviewModel(input({ result: image }), translate),
).toEqual({
sourceValue: "—",
resultValue: "2 × 3 px",
hasResult: true,
});
expect(buildSchemaPreviewModel(input(), translate)).toMatchObject({
resultValue: "—",
hasResult: false,
});
});
it("formats file results as ZIP and rejects empty file results", () => {
it("files: результат только при непустом наборе", () => {
const fileResult = { files: [{ name: "part.png", image }] };
expect(hasPreviewResult(input({ resultKind: "files", fileResult }))).toBe(
true,
);
expect(
buildSchemaPreviewModel(
input({ resultKind: "files", fileResult }),
translate,
),
).toEqual({
sourceValue: "—",
resultValue: "1 parts",
hasResult: true,
});
expect(
buildSchemaPreviewModel(
hasPreviewResult(
input({ resultKind: "files", fileResult: { files: [] } }),
translate,
),
).toMatchObject({ resultValue: "—", hasResult: false });
).toBe(false);
});
it.each(["text", "verdict"] as const)(
"uses text result presence for %s output",
"%s: результат по наличию текста",
(resultKind) => {
expect(
buildSchemaPreviewModel(
input({ resultKind, textResult: "done" }),
translate,
),
).toMatchObject({
resultValue: "Text",
hasResult: true,
});
expect(
buildSchemaPreviewModel(input({ resultKind }), translate),
).toMatchObject({
resultValue: "—",
hasResult: false,
});
expect(hasPreviewResult(input({ resultKind, textResult: "done" }))).toBe(
true,
);
expect(hasPreviewResult(input({ resultKind }))).toBe(false);
},
);
});
@@ -1,58 +1,20 @@
import type { PixelImage } from "$lib/core/types";
import type { FileResult, InputMode, ResultKind } from "$lib/registry";
export type PreviewTranslate = (key: string) => string;
export type SchemaPreviewModelInput = {
inputMode: InputMode;
resultKind: ResultKind;
source: PixelImage | null;
result: PixelImage | null;
fileResult: FileResult | null;
textResult: string | null;
};
export type SchemaPreviewModel = {
sourceValue: string;
resultValue: string;
hasResult: boolean;
};
export function buildSchemaPreviewModel(
input: SchemaPreviewModelInput,
translate: PreviewTranslate,
): SchemaPreviewModel {
const { inputMode, resultKind, source, result, fileResult, textResult } =
input;
const sourceValue =
inputMode === "none"
? "—"
: inputMode === "text"
? translate("textInput.heading")
: source
? `${source.width} × ${source.height} px`
: "—";
const hasFiles = Boolean(fileResult && fileResult.files.length > 0);
const resultValue =
resultKind === "image"
? result
? `${result.width} × ${result.height} px`
: "—"
: resultKind === "files"
? hasFiles
? `${fileResult?.files.length} ${translate("resultCard.parts")}`
: "—"
: textResult
? translate("textInput.heading")
: "—";
return {
sourceValue,
resultValue,
hasResult:
resultKind === "image"
? Boolean(result)
: resultKind === "files"
? hasFiles
: Boolean(textResult),
};
/** Можно ли скачать результат: он есть и он не пустой. */
export function hasPreviewResult(input: SchemaPreviewModelInput): boolean {
const { resultKind, result, fileResult, textResult } = input;
if (resultKind === "image") return Boolean(result);
if (resultKind === "files") {
return Boolean(fileResult && fileResult.files.length > 0);
}
return Boolean(textResult);
}
+1 -1
View File
@@ -61,7 +61,7 @@
display: flex;
align-items: center;
gap: var(--space-s);
line-height: normal;
line-height: 1;
font-family: var(--font-mono);
font-size: var(--font-size-s);
font-weight: bold;