mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-11-13 17:08:20 +00:00
feat: add info\warning badge, update size range workflow
This commit is contained in:
+7
-1
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ export type {
|
||||
Page,
|
||||
PageStep,
|
||||
ResultKind,
|
||||
ResultNote,
|
||||
Tool,
|
||||
ToolContext,
|
||||
ToolImageFile,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
/** Шаг страницы: инструмент и зафиксированные под него значения параметров. */
|
||||
|
||||
Reference in New Issue
Block a user