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
+7 -1
View File
@@ -85,7 +85,6 @@
шрифты с кириллицей? сделать галочку "только с кириллицей"??? (решение о
наборе и лицензиях: `docs/plan-platform.md` §4 — только OFL/Apache с
паспортом, семейства с кириллицей)
- [ ] проверить настройки range slider на crop-png - слишком большие значения
- [ ] Свести convert-страницы (`convert-png-to-jpg`, `convert-png-to-webp`,
`png-to-bmp`) на один инструмент `convert` — зависит от S1f
(`docs/plan-seo.md` §4). Мешает не только `quality` (он живёт в схеме
@@ -159,6 +158,13 @@
- [ ] Рассмотреть необходимость всех этих пропсов в `SchemaPreview.svelte` они
дублируют друг друга `ontextsource`, `onrendertext`, `oncopytext`,
`ondownloadtxt`, `ondownload`,
- [ ] resize keep aspect - работает, но немного не очевидное поведение при
прямоугольной картинке и выборе размера через быстрые кнопки (например
256х256). Воспроизведение поведения: keep aspect включено, загружаем
прямоугольную картинку 832 × 1232, картинка рескейлится до 173 × 256,
выключаем keep aspect, картинка рескейлится до 256 × 256 (с искажением
пропорций), включаем keep aspect - картинка рескейлится до размера 256 ×
379
- [ ] Переделать внешний вид инструментов без настроек, например Remove alpha
channel PNG "remove-alpha-channel-png". Сейчас выглядит слишком пустым.
- [ ] Пересмотреть поля - Plate, Position, Font Style - сейчас второй label
@@ -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;
+7 -3
View File
@@ -76,6 +76,9 @@ export const en: Dict = {
result: "Result",
previewPanel: "Preview",
parts: "parts",
clamped: "clamped to image bounds",
aspectAdjusted: "size adjusted to keep aspect",
grown: "grew beyond requested size — image is larger",
alt: "Result image",
},
paramsCard: {
@@ -174,7 +177,8 @@ export const en: Dict = {
radiusInt: "Radius must be a non-negative integer",
kernelSize: "Kernel does not match the image dimensions",
sizeInt: "Width and height must be integers ≥ 1",
cropBounds: "Crop area does not intersect the image",
cropBounds:
"Crop area doesn't fit: offset plus size exceeds the image bounds",
noCanvasCtx: "Canvas 2D context is unavailable in this environment",
badBase64: "Expected a base64 string or a data-uri of an image",
qualityRange: "quality must be within 0..1",
@@ -197,9 +201,9 @@ export const en: Dict = {
paramString: 'Parameter "{id}" must be a string',
paramBool: 'Parameter "{id}" must be a checkbox value',
resizeSize: "Width and/or height must be positive",
cropSize: "Crop width and height must be positive",
cropSize: "Crop width and height must be at least 1 px",
sizePositive: "Dimensions must be positive and finite",
tooManyParts: "Too many parts ({count}). Maximum is 1000.",
tooManyParts: "Too many parts ({count}). The limit is {max}.",
pageUnknown: 'No page is registered under "{slug}".',
},
fields: {
+7 -3
View File
@@ -77,6 +77,9 @@ export const ru: Dict = {
result: "Результат",
previewPanel: "Предпросмотр",
parts: "частей",
clamped: "уменьшено до границ изображения",
aspectAdjusted: "размер скорректирован по пропорциям",
grown: "больше запрошенного — картинка крупнее",
alt: "Изображение-результат",
},
paramsCard: {
@@ -175,7 +178,8 @@ export const ru: Dict = {
radiusInt: "Радиус должен быть целым неотрицательным числом",
kernelSize: "Ядро не совпадает с изображением по размеру",
sizeInt: "Ширина и высота должны быть целыми числами ≥ 1",
cropBounds: "Область обрезки не пересекает изображение",
cropBounds:
"Область не помещается: смещение + размер превышают границы изображения",
noCanvasCtx: "Canvas 2D context недоступен в этом окружении",
badBase64: "Ожидается base64-строка или data-uri изображения",
qualityRange: "quality должно быть в диапазоне 0..1",
@@ -198,9 +202,9 @@ export const ru: Dict = {
paramString: 'Параметр "{id}" должен быть строкой',
paramBool: 'Параметр "{id}" должен быть значением флажка',
resizeSize: "Ширина и/или высота должны быть положительными",
cropSize: "Ширина и высота области обрезки должны быть положительными",
cropSize: "Ширина и высота области должны быть не меньше 1 px",
sizePositive: "Размеры должны быть положительными и конечными",
tooManyParts: "Слишком много частей ({count}). Ограничение — 1000.",
tooManyParts: "Слишком много частей ({count}). Ограничение — {max}.",
pageUnknown: "Страницы «{slug}» нет в каталоге.",
},
fields: {
+59
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
applySourceDefaults,
clampSourceAwareMaxes,
defaultSchemaParams,
field,
resolveLayoutGroups,
@@ -89,6 +90,64 @@ describe("registry-schema: withAspectLock", () => {
});
});
describe("registry-schema: maxFromSource", () => {
const cropLike = toolSchema<{
x: number;
size: Dimension;
}>({
x: field.number({ min: 0, max: 20000, default: 0, maxFromSource: "width" }),
size: field.dimension({
min: 1,
max: 20000,
width: 1,
height: 1,
maxFromSource: true,
}),
});
const img = { width: 832, height: 1216 };
it("number клампится к размеру исходника, а не к статическому max", () => {
const s = sanitizeSchemaParams(cropLike, { x: 5000 }, { source: img });
expect(s.x).toBe(832);
});
it("dimension клампится по осям исходника", () => {
const s = sanitizeSchemaParams(
cropLike,
{ size: { width: 5000, height: 5000 } },
{ source: img },
);
expect(s.size).toEqual({ width: 832, height: 1216 });
});
it("без исходника — статический max", () => {
const s = sanitizeSchemaParams(cropLike, {
x: 5000,
size: { width: 5000, height: 5000 },
});
expect(s.x).toBe(5000);
expect(s.size).toEqual({ width: 5000, height: 5000 });
});
it("maxMinus: потолок = исходник − константа (offset до size−1)", () => {
const offsetLike = toolSchema<{ x: number }>({
x: field.number({
min: 0,
max: 20000,
default: 0,
maxFromSource: "width",
maxMinus: 1,
}),
});
const s = sanitizeSchemaParams(
offsetLike,
{ x: 5000 },
{ source: { width: 832, height: 1216 } },
);
expect(s.x).toBe(831);
});
});
describe("registry-schema: source-aware dimension defaults", () => {
it("uses static fallback before a source exists", () => {
expect(defaultSchemaParams(sourceSchema)).toEqual({
+84 -7
View File
@@ -24,6 +24,9 @@ export interface NumberSpec extends FieldSpecBase {
min?: number;
max?: number;
step?: number;
/** Потолок из размеров исходника (ось); статический max — фолбэк. */
maxFromSource?: "width" | "height";
maxMinus?: number;
}
export interface SliderSpec extends FieldSpecBase {
@@ -73,10 +76,9 @@ export interface DimensionSpec extends FieldSpecBase {
width: number;
height: number;
defaultFromSource?: boolean;
/**
* Id checkbox-поля «сохранять пропорции»: пока оно включено, правка одной
* оси пересчитывает вторую по аспекту текущего исходника.
*/
/** Потолок каждой оси из соответствующего размера исходника. */
maxFromSource?: boolean;
/** Id checkbox-поля «сохранять пропорции». */
lockAspectWith?: string;
/** Показывать чипы быстрого выбора из SIZE_PRESETS. */
presets?: boolean;
@@ -286,6 +288,7 @@ export const field = {
width: number;
height: number;
defaultFromSource?: boolean;
maxFromSource?: boolean;
lockAspectWith?: string;
presets?: boolean;
}): Field<Dimension> => ({
@@ -394,6 +397,68 @@ function clamp(value: number, min?: number, max?: number): number {
return value;
}
/** Потолок поля: из размеров исходника при maxFromSource, иначе статический. */
export function effectiveMax(
spec: NumberSpec,
source?: Dimension,
): number | undefined {
if (spec.maxFromSource && source) {
return source[spec.maxFromSource] - (spec.maxMinus ?? 0);
}
return spec.max;
}
function dimensionAxisMax(
spec: DimensionSpec,
axis: "width" | "height",
source?: Dimension,
): number {
return spec.maxFromSource && source ? source[axis] : spec.max;
}
/** Поджимает maxFromSource-поля к актуальному потолку (сменился исходник). */
export function clampSourceAwareMaxes<P>(
schema: ToolSchema<P>,
params: Record<string, unknown>,
source?: Dimension,
): Record<string, unknown> {
if (!source) return params;
let changed = false;
const out = { ...params };
for (const key of Object.keys(schema.fields) as (keyof P)[]) {
const spec = schema.fields[key].spec;
if (spec.kind === "number" && spec.maxFromSource) {
const v = out[key as string];
if (typeof v !== "number" || !Number.isFinite(v)) continue;
const clamped = clamp(v, spec.min, effectiveMax(spec, source));
if (clamped !== v) {
out[key as string] = clamped;
changed = true;
}
} else if (spec.kind === "dimension" && spec.maxFromSource) {
const v = out[key as string] as Partial<Dimension> | undefined;
if (typeof v?.width !== "number" || typeof v?.height !== "number") {
continue;
}
const width = clamp(
v.width,
spec.min,
dimensionAxisMax(spec, "width", source),
);
const height = clamp(
v.height,
spec.min,
dimensionAxisMax(spec, "height", source),
);
if (width !== v.width || height !== v.height) {
out[key as string] = { width, height };
changed = true;
}
}
}
return changed ? out : params;
}
/**
* Пересчёт при включённом lockAspect: ведущая ось (последняя правка
* пользователя) сохраняется, вторая подгоняется под аспект исходника.
@@ -500,7 +565,11 @@ export function sanitizeSchemaParams<P>(
case "slider": {
const n =
typeof raw === "number" && Number.isFinite(raw) ? raw : spec.default;
out[key] = clamp(n, spec.min, spec.max);
const max =
spec.kind === "number"
? effectiveMax(spec, context?.source)
: spec.max;
out[key] = clamp(n, spec.min, max);
break;
}
case "select":
@@ -543,8 +612,16 @@ export function sanitizeSchemaParams<P>(
? defaults
: { width: w, height: h };
out[key] = {
width: clamp(sourceSize.width, spec.min, spec.max),
height: clamp(sourceSize.height, spec.min, spec.max),
width: clamp(
sourceSize.width,
spec.min,
dimensionAxisMax(spec, "width", context?.source),
),
height: clamp(
sourceSize.height,
spec.min,
dimensionAxisMax(spec, "height", context?.source),
),
};
break;
}
+1
View File
@@ -31,6 +31,7 @@ export type {
Page,
PageStep,
ResultKind,
ResultNote,
Tool,
ToolContext,
ToolImageFile,
+47
View File
@@ -346,6 +346,53 @@ describe("registry-new (переведённые инструменты)", () =>
expect(out.data[3]).toBe(0);
});
it("crop: resultNote предупреждает, когда результат ужался по границам", () => {
const tool = TOOLS.find((t) => t.id === "crop")!;
expect(
tool.resultNote?.(
{ x: 700, y: 0, size: { width: 256, height: 256 } },
solid(132, 256),
),
).toEqual({ key: "resultCard.clamped" });
expect(
tool.resultNote?.(
{ x: 0, y: 0, size: { width: 256, height: 256 } },
solid(256, 256),
),
).toBeNull();
});
it("resize: resultNote при keepAspect и скорректированной стороне", () => {
const tool = TOOLS.find((t) => t.id === "resize")!;
const p = { size: { width: 500, height: 300 }, keepAspect: true };
expect(tool.resultNote?.(p, solid(300, 300))).toEqual({
key: "resultCard.aspectAdjusted",
tone: "info",
});
expect(tool.resultNote?.(p, solid(500, 300))).toBeNull();
// без keepAspect результат всегда как запрошено
expect(
tool.resultNote?.(
{ size: { width: 500, height: 300 }, keepAspect: false },
solid(500, 300),
),
).toBeNull();
});
it("fit-on-background: resultNote, когда картинка крупнее канваса", () => {
const tool = TOOLS.find((t) => t.id === "fit-on-background")!;
const p = {
size: { width: 100, height: 100 },
opacity: 100,
color: "#ffffff",
};
expect(tool.resultNote?.(p, solid(200, 100))).toEqual({
key: "resultCard.grown",
tone: "info",
});
expect(tool.resultNote?.(p, solid(100, 100))).toBeNull();
});
it("change-canvas-size использует anchor для позиции", async () => {
const tool = TOOLS.find((t) => t.id === "change-canvas-size")!;
const img = solid(10, 10);
+28 -4
View File
@@ -120,6 +120,11 @@ const fitOnBackground: Tool<FitOnBackgroundParams> = {
Math.round((Math.trunc(p.opacity) / 100) * 255),
);
}),
resultNote: (p, result) =>
result.width !== Math.trunc(p.size.width) ||
result.height !== Math.trunc(p.size.height)
? { key: "resultCard.grown", tone: "info" }
: null,
};
interface ChangeCanvasSizeParams {
@@ -230,6 +235,12 @@ const resizeTool: Tool<ResizeParams> = {
}
return resize(img, w, h);
}),
resultNote: (p, result) =>
p.keepAspect &&
(result.width !== Math.trunc(p.size.width) ||
result.height !== Math.trunc(p.size.height))
? { key: "resultCard.aspectAdjusted", tone: "info" }
: null,
};
interface CropParams {
@@ -243,24 +254,29 @@ export const cropSchema = toolSchema<CropParams>(
x: field.number({
label: "fields.x",
min: 0,
max: 2000,
max: 20000,
step: 1,
default: 0,
maxFromSource: "width",
maxMinus: 1,
}),
y: field.number({
label: "fields.y",
min: 0,
max: 1200,
max: 20000,
step: 1,
default: 0,
maxFromSource: "height",
maxMinus: 1,
}),
size: field.dimension({
label: "fields.cropAreaSize",
min: 1,
max: 2000,
max: 20000,
width: 1,
height: 1,
defaultFromSource: true,
maxFromSource: true,
presets: true,
}),
},
@@ -286,6 +302,11 @@ const cropTool: Tool<CropParams> = {
}
return crop(img, Math.trunc(p.x), Math.trunc(p.y), w, h);
}),
resultNote: (p, result) =>
result.width < Math.trunc(p.size.width) ||
result.height < Math.trunc(p.size.height)
? { key: "resultCard.clamped" }
: null,
};
interface RotateParams {
@@ -449,7 +470,10 @@ const splitPartsTool: Tool<SplitPartsParams> = {
const rowsCount = Math.max(1, Math.trunc(params.rows));
const count = cols * rowsCount;
if (count > MAX_SPLIT_PARTS) {
throw new ToolError("errors.tooManyParts", { count });
throw new ToolError("errors.tooManyParts", {
count,
max: MAX_SPLIT_PARTS,
});
}
const parts = splitToParts(img, cols, rowsCount);
const rowLen = String(rowsCount).length;
+9
View File
@@ -62,6 +62,9 @@ export interface VerdictResult {
export type ToolResult = PixelImage | string | FileResult | VerdictResult;
/** Заметка у тайла результата: i18n-ключ и тон бейджа (warning по умолчанию). */
export type ResultNote = { key: string; tone?: "warning" | "info" };
/**
* Инструмент: уникальная реализация без адреса, текстов и категории — всё это
* у страницы. `id` уникален, но обслуживает сколько угодно страниц, поэтому
@@ -86,6 +89,12 @@ export type Tool<P = Record<string, unknown>> = {
domOnly?: boolean;
/** Формат/качество скачивания результата; по умолчанию — PNG. */
output?: OutputFormat;
/**
* Заметка у тайла результата, когда итог отличается от запрошенного:
* ужат по границам (crop), скорректирован по пропорциям (resize),
* вырос за канвас (fit-on-background).
*/
resultNote?: (params: P, result: PixelImage) => ResultNote | null;
};
/** Шаг страницы: инструмент и зафиксированные под него значения параметров. */