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 с
|
наборе и лицензиях: `docs/plan-platform.md` §4 — только OFL/Apache с
|
||||||
паспортом, семейства с кириллицей)
|
паспортом, семейства с кириллицей)
|
||||||
- [ ] проверить настройки range slider на crop-png - слишком большие значения
|
|
||||||
- [ ] Свести convert-страницы (`convert-png-to-jpg`, `convert-png-to-webp`,
|
- [ ] Свести convert-страницы (`convert-png-to-jpg`, `convert-png-to-webp`,
|
||||||
`png-to-bmp`) на один инструмент `convert` — зависит от S1f
|
`png-to-bmp`) на один инструмент `convert` — зависит от S1f
|
||||||
(`docs/plan-seo.md` §4). Мешает не только `quality` (он живёт в схеме
|
(`docs/plan-seo.md` §4). Мешает не только `quality` (он живёт в схеме
|
||||||
@@ -159,6 +158,13 @@
|
|||||||
- [ ] Рассмотреть необходимость всех этих пропсов в `SchemaPreview.svelte` они
|
- [ ] Рассмотреть необходимость всех этих пропсов в `SchemaPreview.svelte` они
|
||||||
дублируют друг друга `ontextsource`, `onrendertext`, `oncopytext`,
|
дублируют друг друга `ontextsource`, `onrendertext`, `oncopytext`,
|
||||||
`ondownloadtxt`, `ondownload`,
|
`ondownloadtxt`, `ondownload`,
|
||||||
|
- [ ] resize keep aspect - работает, но немного не очевидное поведение при
|
||||||
|
прямоугольной картинке и выборе размера через быстрые кнопки (например
|
||||||
|
256х256). Воспроизведение поведения: keep aspect включено, загружаем
|
||||||
|
прямоугольную картинку 832 × 1232, картинка рескейлится до 173 × 256,
|
||||||
|
выключаем keep aspect, картинка рескейлится до 256 × 256 (с искажением
|
||||||
|
пропорций), включаем keep aspect - картинка рескейлится до размера 256 ×
|
||||||
|
379
|
||||||
- [ ] Переделать внешний вид инструментов без настроек, например Remove alpha
|
- [ ] Переделать внешний вид инструментов без настроек, например Remove alpha
|
||||||
channel PNG "remove-alpha-channel-png". Сейчас выглядит слишком пустым.
|
channel PNG "remove-alpha-channel-png". Сейчас выглядит слишком пустым.
|
||||||
- [ ] Пересмотреть поля - Plate, Position, Font Style - сейчас второй label
|
- [ ] Пересмотреть поля - Plate, Position, Font Style - сейчас второй label
|
||||||
|
|||||||
@@ -13,11 +13,25 @@
|
|||||||
label: string;
|
label: string;
|
||||||
value: unknown;
|
value: unknown;
|
||||||
spec: FieldSpec;
|
spec: FieldSpec;
|
||||||
|
/** Размеры входа шага: потолок осей для maxFromSource-полей. */
|
||||||
|
sourceDims?: Dimension;
|
||||||
onchange?: (value: Dimension, axis?: "width" | "height" | "both") => void;
|
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 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 current = $derived.by(() => {
|
||||||
const v = value as Partial<Dimension> | undefined;
|
const v = value as Partial<Dimension> | undefined;
|
||||||
return {
|
return {
|
||||||
@@ -49,7 +63,7 @@
|
|||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={sp.min}
|
min={sp.min}
|
||||||
max={sp.max}
|
max={maxW}
|
||||||
value={current.width}
|
value={current.width}
|
||||||
oninput={(e) =>
|
oninput={(e) =>
|
||||||
setAxis("width", Number((e.target as HTMLInputElement).value))}
|
setAxis("width", Number((e.target as HTMLInputElement).value))}
|
||||||
@@ -60,7 +74,7 @@
|
|||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={sp.min}
|
min={sp.min}
|
||||||
max={sp.max}
|
max={maxH}
|
||||||
value={current.height}
|
value={current.height}
|
||||||
oninput={(e) =>
|
oninput={(e) =>
|
||||||
setAxis("height", Number((e.target as HTMLInputElement).value))}
|
setAxis("height", Number((e.target as HTMLInputElement).value))}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import type { InputMode, ResultKind } from "$lib/registry";
|
import type { InputMode, ResultKind } from "$lib/registry";
|
||||||
import { RefreshCw } from "@lucide/svelte";
|
import { RefreshCw } from "@lucide/svelte";
|
||||||
import { t } from "$lib/i18n/t";
|
import { t } from "$lib/i18n/t";
|
||||||
|
import Badge from "$lib/components/ui/Badge.svelte";
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -11,6 +12,9 @@
|
|||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
parts?: number;
|
parts?: number;
|
||||||
partsDims?: string;
|
partsDims?: string;
|
||||||
|
dims?: string;
|
||||||
|
note?: string;
|
||||||
|
noteTone?: "warning" | "info";
|
||||||
}
|
}
|
||||||
let {
|
let {
|
||||||
label,
|
label,
|
||||||
@@ -19,24 +23,31 @@
|
|||||||
loading = false,
|
loading = false,
|
||||||
parts = undefined,
|
parts = undefined,
|
||||||
partsDims = undefined,
|
partsDims = undefined,
|
||||||
|
dims = undefined,
|
||||||
|
note = undefined,
|
||||||
|
noteTone = "warning",
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<figure class="tile">
|
<figure class="tile">
|
||||||
<figcaption>
|
<figcaption>
|
||||||
<span>
|
<span class="tile-label">
|
||||||
{label}
|
<span
|
||||||
{#if parts !== undefined}
|
>{label}{#if parts !== undefined}<span class="dims"
|
||||||
<span class="count">
|
>: {parts}
|
||||||
{parts}
|
{t("resultCard.parts")}{#if partsDims}
|
||||||
{t("resultCard.parts")}{#if partsDims}
|
· {partsDims}{/if}</span
|
||||||
· {partsDims}{/if}
|
>{:else if dims}<span class="dims">: {dims}</span>{/if}</span
|
||||||
</span>
|
>
|
||||||
{/if}
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<RefreshCw class="rotating" size="12" />
|
<RefreshCw class="rotating" size="12" />
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
|
<span class="tile-note">
|
||||||
|
{#if note}
|
||||||
|
<Badge tone={noteTone} label={note} />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
</figcaption>
|
</figcaption>
|
||||||
<div class="canvas" class:checker={mode === "image"}>
|
<div class="canvas" class:checker={mode === "image"}>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
@@ -47,7 +58,10 @@
|
|||||||
.tile figcaption {
|
.tile figcaption {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
gap: var(--space-m);
|
gap: var(--space-m);
|
||||||
|
/* Резерв под бейдж: строка не прыгает, когда заметка появляется. */
|
||||||
|
min-height: var(--space-xxl);
|
||||||
margin-bottom: var(--space-m);
|
margin-bottom: var(--space-m);
|
||||||
font: var(--font-size-s) var(--font-mono);
|
font: var(--font-size-s) var(--font-mono);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -56,8 +70,18 @@
|
|||||||
animation: rotate var(--duration-l) linear infinite;
|
animation: rotate var(--duration-l) linear infinite;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.count {
|
.tile-label {
|
||||||
margin-left: var(--space-m);
|
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);
|
color: var(--color-main);
|
||||||
}
|
}
|
||||||
.canvas {
|
.canvas {
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {
|
import type {
|
||||||
|
Dimension,
|
||||||
FieldSpec,
|
FieldSpec,
|
||||||
FieldSpecKind,
|
FieldSpecKind,
|
||||||
ToolSchema,
|
ToolSchema,
|
||||||
} from "$lib/registry-schema";
|
} 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 { fieldLabel, groupLabel } from "$lib/i18n/schema-tool-strings";
|
||||||
import { t } from "$lib/i18n/t";
|
import { t } from "$lib/i18n/t";
|
||||||
import { RotateCcw } from "@lucide/svelte";
|
import { RotateCcw } from "@lucide/svelte";
|
||||||
@@ -30,6 +31,7 @@
|
|||||||
spec: FieldSpec;
|
spec: FieldSpec;
|
||||||
toolId: string;
|
toolId: string;
|
||||||
fieldId: string;
|
fieldId: string;
|
||||||
|
sourceDims?: Dimension;
|
||||||
onchange?: (value: unknown, axis?: "width" | "height" | "both") => void;
|
onchange?: (value: unknown, axis?: "width" | "height" | "both") => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +56,8 @@
|
|||||||
schema: ToolSchema<Record<string, unknown>>;
|
schema: ToolSchema<Record<string, unknown>>;
|
||||||
values: Record<string, unknown>;
|
values: Record<string, unknown>;
|
||||||
toolId: string;
|
toolId: string;
|
||||||
|
/** Размеры входа шага: для maxFromSource-полей (crop и т.п.). */
|
||||||
|
sourceDims?: Dimension;
|
||||||
onchange: (
|
onchange: (
|
||||||
id: string,
|
id: string,
|
||||||
value: unknown,
|
value: unknown,
|
||||||
@@ -61,9 +65,23 @@
|
|||||||
) => void;
|
) => void;
|
||||||
onreset: () => 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));
|
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>
|
</script>
|
||||||
|
|
||||||
{#each layoutGroups as group (group.key)}
|
{#each layoutGroups as group (group.key)}
|
||||||
@@ -81,9 +99,10 @@
|
|||||||
<Control
|
<Control
|
||||||
label={fieldLabel(schema.fields[id], id)}
|
label={fieldLabel(schema.fields[id], id)}
|
||||||
value={values[id]}
|
value={values[id]}
|
||||||
spec={schema.fields[id].spec}
|
spec={effectiveSpec(schema.fields[id].spec)}
|
||||||
{toolId}
|
{toolId}
|
||||||
fieldId={id}
|
fieldId={id}
|
||||||
|
{sourceDims}
|
||||||
onchange={(v, axis) => onchange(id, v, axis)}
|
onchange={(v, axis) => onchange(id, v, axis)}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import MetaList from "$lib/components/display/MetaList.svelte";
|
import { hasPreviewResult } from "./schema-preview-model";
|
||||||
import { buildSchemaPreviewModel } from "./schema-preview-model";
|
|
||||||
import SchemaActions from "./SchemaActions.svelte";
|
import SchemaActions from "./SchemaActions.svelte";
|
||||||
import SchemaResultTile from "./SchemaResultTile.svelte";
|
import SchemaResultTile from "./SchemaResultTile.svelte";
|
||||||
import SchemaSourceTile from "./SchemaSourceTile.svelte";
|
import SchemaSourceTile from "./SchemaSourceTile.svelte";
|
||||||
import type { PixelImage } from "$lib/core/types";
|
import type { PixelImage } from "$lib/core/types";
|
||||||
import type { OutputMime } from "$lib/core/io";
|
import type { OutputMime } from "$lib/core/io";
|
||||||
import { t } from "$lib/i18n/t";
|
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 {
|
interface Props {
|
||||||
inputMode: InputMode;
|
inputMode: InputMode;
|
||||||
@@ -15,6 +19,7 @@
|
|||||||
toolId: string;
|
toolId: string;
|
||||||
source: PixelImage | null;
|
source: PixelImage | null;
|
||||||
result: PixelImage | null;
|
result: PixelImage | null;
|
||||||
|
resultNote?: ResultNote | null;
|
||||||
fileResult?: FileResult | null;
|
fileResult?: FileResult | null;
|
||||||
textSource?: string;
|
textSource?: string;
|
||||||
textResult?: string | null;
|
textResult?: string | null;
|
||||||
@@ -39,6 +44,7 @@
|
|||||||
toolId,
|
toolId,
|
||||||
source,
|
source,
|
||||||
result,
|
result,
|
||||||
|
resultNote = null,
|
||||||
fileResult = null,
|
fileResult = null,
|
||||||
textSource = "",
|
textSource = "",
|
||||||
textResult = null,
|
textResult = null,
|
||||||
@@ -58,11 +64,8 @@
|
|||||||
onquality,
|
onquality,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const model = $derived(
|
const hasResult = $derived(
|
||||||
buildSchemaPreviewModel(
|
hasPreviewResult({ inputMode, resultKind, result, fileResult, textResult }),
|
||||||
{ inputMode, resultKind, source, result, fileResult, textResult },
|
|
||||||
t,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -71,7 +74,7 @@
|
|||||||
<SchemaActions
|
<SchemaActions
|
||||||
{inputMode}
|
{inputMode}
|
||||||
{resultKind}
|
{resultKind}
|
||||||
canDownload={model.hasResult}
|
canDownload={hasResult}
|
||||||
{running}
|
{running}
|
||||||
{format}
|
{format}
|
||||||
{quality}
|
{quality}
|
||||||
@@ -100,6 +103,7 @@
|
|||||||
<SchemaResultTile
|
<SchemaResultTile
|
||||||
{resultKind}
|
{resultKind}
|
||||||
{result}
|
{result}
|
||||||
|
{resultNote}
|
||||||
{fileResult}
|
{fileResult}
|
||||||
{textResult}
|
{textResult}
|
||||||
{textVars}
|
{textVars}
|
||||||
@@ -110,15 +114,6 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="meta">
|
|
||||||
<MetaList
|
|
||||||
items={[
|
|
||||||
{ caption: t("sourceCard.source"), value: model.sourceValue },
|
|
||||||
{ caption: t("resultCard.result"), value: model.resultValue },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.panel-head {
|
.panel-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -145,9 +140,6 @@
|
|||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: var(--space-l);
|
gap: var(--space-l);
|
||||||
}
|
}
|
||||||
.meta {
|
|
||||||
margin-top: var(--space-l);
|
|
||||||
}
|
|
||||||
@media (--bp-tablet) {
|
@media (--bp-tablet) {
|
||||||
.pair {
|
.pair {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { toDataUrl } from "$lib/core/io";
|
import { toDataUrl } from "$lib/core/io";
|
||||||
import type { PixelImage } from "$lib/core/types";
|
import type { PixelImage } from "$lib/core/types";
|
||||||
import { t } from "$lib/i18n/t";
|
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 { Check } from "@lucide/svelte";
|
||||||
import SchemaTextResult from "./SchemaTextResult.svelte";
|
import SchemaTextResult from "./SchemaTextResult.svelte";
|
||||||
import PreviewTile from "./PreviewTile.svelte";
|
import PreviewTile from "./PreviewTile.svelte";
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
resultKind?: ResultKind;
|
resultKind?: ResultKind;
|
||||||
result: PixelImage | null;
|
result: PixelImage | null;
|
||||||
|
resultNote?: ResultNote | null;
|
||||||
fileResult?: FileResult | null;
|
fileResult?: FileResult | null;
|
||||||
textResult?: string | null;
|
textResult?: string | null;
|
||||||
textVars?: Record<string, string | number>;
|
textVars?: Record<string, string | number>;
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
let {
|
let {
|
||||||
resultKind = "image",
|
resultKind = "image",
|
||||||
result,
|
result,
|
||||||
|
resultNote = null,
|
||||||
fileResult = null,
|
fileResult = null,
|
||||||
textResult = null,
|
textResult = null,
|
||||||
textVars = undefined,
|
textVars = undefined,
|
||||||
@@ -31,6 +33,11 @@
|
|||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const resultUrl = $derived(result ? toDataUrl(result) : null);
|
const resultUrl = $derived(result ? toDataUrl(result) : null);
|
||||||
|
const resultDims = $derived(
|
||||||
|
resultKind === "image" && result
|
||||||
|
? `${result.width} × ${result.height}`
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
const partUrls = $derived(
|
const partUrls = $derived(
|
||||||
resultKind === "files" && fileResult
|
resultKind === "files" && fileResult
|
||||||
? fileResult.files.map((file) => ({
|
? fileResult.files.map((file) => ({
|
||||||
@@ -57,6 +64,9 @@
|
|||||||
loading={running}
|
loading={running}
|
||||||
{parts}
|
{parts}
|
||||||
{partsDims}
|
{partsDims}
|
||||||
|
dims={resultDims}
|
||||||
|
note={resultNote ? t(resultNote.key) : undefined}
|
||||||
|
noteTone={resultNote?.tone ?? "warning"}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="canvas"
|
class="canvas"
|
||||||
|
|||||||
@@ -27,6 +27,9 @@
|
|||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const sourceUrl = $derived(source ? toDataUrl(source) : null);
|
const sourceUrl = $derived(source ? toDataUrl(source) : null);
|
||||||
|
const sourceDims = $derived(
|
||||||
|
source ? `${source.width} × ${source.height}` : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
let dragging = $state(false);
|
let dragging = $state(false);
|
||||||
let input = $state<HTMLInputElement | null>(null);
|
let input = $state<HTMLInputElement | null>(null);
|
||||||
@@ -63,7 +66,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PreviewTile label={t("sourceCard.source")} viewMode={mode}>
|
<PreviewTile label={t("sourceCard.source")} viewMode={mode} dims={sourceDims}>
|
||||||
{#if mode === "text"}
|
{#if mode === "text"}
|
||||||
<div class="text-source-wrap">
|
<div class="text-source-wrap">
|
||||||
<SchemaTextSource
|
<SchemaTextSource
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
} from "$lib/registry";
|
} from "$lib/registry";
|
||||||
import {
|
import {
|
||||||
applySourceDefaults,
|
applySourceDefaults,
|
||||||
|
clampSourceAwareMaxes,
|
||||||
defaultSchemaParams,
|
defaultSchemaParams,
|
||||||
withAspectLock,
|
withAspectLock,
|
||||||
type Dimension,
|
type Dimension,
|
||||||
@@ -105,6 +106,11 @@
|
|||||||
// он — единый источник качества; dropdown редактирует его же.
|
// он — единый источник качества; dropdown редактирует его же.
|
||||||
const qualityParamId = $derived(lastTool.output?.qualityParamId);
|
const qualityParamId = $derived(lastTool.output?.qualityParamId);
|
||||||
const lastParams = $derived(steps.at(-1)?.params);
|
const lastParams = $derived(steps.at(-1)?.params);
|
||||||
|
const resultNote = $derived(
|
||||||
|
result && lastTool.resultNote
|
||||||
|
? lastTool.resultNote(lastParams ?? {}, result)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
const currentQuality = $derived.by(() => {
|
const currentQuality = $derived.by(() => {
|
||||||
const setting = outputFormatByMime(format).settings?.quality;
|
const setting = outputFormatByMime(format).settings?.quality;
|
||||||
if (!setting) return undefined;
|
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) {
|
function resetStep(index: number) {
|
||||||
@@ -249,11 +256,15 @@
|
|||||||
if (first && firstSchema) {
|
if (first && firstSchema) {
|
||||||
steps = steps.with(0, {
|
steps = steps.with(0, {
|
||||||
...first,
|
...first,
|
||||||
params: applySourceDefaults(
|
params: clampSourceAwareMaxes(
|
||||||
firstSchema,
|
firstSchema,
|
||||||
first.params,
|
applySourceDefaults(
|
||||||
{ source: decoded },
|
firstSchema,
|
||||||
touchedFor(first.key),
|
first.params,
|
||||||
|
{ source: decoded },
|
||||||
|
touchedFor(first.key),
|
||||||
|
),
|
||||||
|
decoded,
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -309,7 +320,7 @@
|
|||||||
const base = toDisplayError(e);
|
const base = toDisplayError(e);
|
||||||
const msg = base.kind === "i18n" ? t(base.key, base.vars) : base.text;
|
const msg = base.kind === "i18n" ? t(base.key, base.vars) : base.text;
|
||||||
throw new Error(
|
throw new Error(
|
||||||
t("steps.stepError", {
|
t("toolPage.stepError", {
|
||||||
n: i + 1,
|
n: i + 1,
|
||||||
title: stepTitle(stepTool.id),
|
title: stepTitle(stepTool.id),
|
||||||
msg,
|
msg,
|
||||||
@@ -468,6 +479,7 @@
|
|||||||
schema={toolSchemaOf(step)}
|
schema={toolSchemaOf(step)}
|
||||||
values={step.params}
|
values={step.params}
|
||||||
toolId={step.id}
|
toolId={step.id}
|
||||||
|
sourceDims={i === 0 ? (source ?? undefined) : stepDims[i - 1]}
|
||||||
onchange={(id, v, axis) => setStepValue(i, id, v, axis)}
|
onchange={(id, v, axis) => setStepValue(i, id, v, axis)}
|
||||||
onreset={() => resetStep(i)}
|
onreset={() => resetStep(i)}
|
||||||
/>
|
/>
|
||||||
@@ -483,6 +495,7 @@
|
|||||||
toolId={lastTool.id}
|
toolId={lastTool.id}
|
||||||
{source}
|
{source}
|
||||||
{result}
|
{result}
|
||||||
|
{resultNote}
|
||||||
{fileResult}
|
{fileResult}
|
||||||
{textSource}
|
{textSource}
|
||||||
{textResult}
|
{textResult}
|
||||||
|
|||||||
@@ -1,107 +1,47 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { makeImage } from "../../core/test-helpers";
|
|
||||||
import {
|
import {
|
||||||
buildSchemaPreviewModel,
|
hasPreviewResult,
|
||||||
type PreviewTranslate,
|
type SchemaPreviewModelInput,
|
||||||
} from "./schema-preview-model";
|
} from "./schema-preview-model";
|
||||||
|
|
||||||
const image = makeImage(2, 3, [
|
const image = { width: 2, height: 3, data: new Uint8ClampedArray(24) };
|
||||||
[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 translate: PreviewTranslate = (key) => {
|
function input(over: Partial<SchemaPreviewModelInput> = {}) {
|
||||||
if (key === "resultCard.parts") return "parts";
|
|
||||||
if (key === "textInput.heading") return "Text";
|
|
||||||
return key;
|
|
||||||
};
|
|
||||||
|
|
||||||
function input(
|
|
||||||
overrides: Partial<Parameters<typeof buildSchemaPreviewModel>[0]> = {},
|
|
||||||
) {
|
|
||||||
return {
|
return {
|
||||||
inputMode: "image" as const,
|
inputMode: "image",
|
||||||
resultKind: "image" as const,
|
resultKind: "image",
|
||||||
source: null,
|
|
||||||
result: null,
|
result: null,
|
||||||
fileResult: null,
|
fileResult: null,
|
||||||
textResult: null,
|
textResult: null,
|
||||||
...overrides,
|
...over,
|
||||||
};
|
} satisfies SchemaPreviewModelInput;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("buildSchemaPreviewModel", () => {
|
describe("hasPreviewResult", () => {
|
||||||
it("formats source values for every input mode", () => {
|
it("image: результат только при наличии картинки", () => {
|
||||||
expect(
|
expect(hasPreviewResult(input({ result: image }))).toBe(true);
|
||||||
buildSchemaPreviewModel(input({ inputMode: "none" }), translate),
|
expect(hasPreviewResult(input())).toBe(false);
|
||||||
).toMatchObject({ sourceValue: "—" });
|
|
||||||
expect(
|
|
||||||
buildSchemaPreviewModel(input({ inputMode: "text" }), translate),
|
|
||||||
).toMatchObject({ sourceValue: "Text" });
|
|
||||||
expect(
|
|
||||||
buildSchemaPreviewModel(
|
|
||||||
input({ inputMode: "image", source: image }),
|
|
||||||
translate,
|
|
||||||
),
|
|
||||||
).toMatchObject({ sourceValue: "2 × 3 px" });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("formats image results and enables download only with a result", () => {
|
it("files: результат только при непустом наборе", () => {
|
||||||
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", () => {
|
|
||||||
const fileResult = { files: [{ name: "part.png", image }] };
|
const fileResult = { files: [{ name: "part.png", image }] };
|
||||||
|
expect(hasPreviewResult(input({ resultKind: "files", fileResult }))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
buildSchemaPreviewModel(
|
hasPreviewResult(
|
||||||
input({ resultKind: "files", fileResult }),
|
|
||||||
translate,
|
|
||||||
),
|
|
||||||
).toEqual({
|
|
||||||
sourceValue: "—",
|
|
||||||
resultValue: "1 parts",
|
|
||||||
hasResult: true,
|
|
||||||
});
|
|
||||||
expect(
|
|
||||||
buildSchemaPreviewModel(
|
|
||||||
input({ resultKind: "files", fileResult: { files: [] } }),
|
input({ resultKind: "files", fileResult: { files: [] } }),
|
||||||
translate,
|
|
||||||
),
|
),
|
||||||
).toMatchObject({ resultValue: "—", hasResult: false });
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(["text", "verdict"] as const)(
|
it.each(["text", "verdict"] as const)(
|
||||||
"uses text result presence for %s output",
|
"%s: результат по наличию текста",
|
||||||
(resultKind) => {
|
(resultKind) => {
|
||||||
expect(
|
expect(hasPreviewResult(input({ resultKind, textResult: "done" }))).toBe(
|
||||||
buildSchemaPreviewModel(
|
true,
|
||||||
input({ resultKind, textResult: "done" }),
|
);
|
||||||
translate,
|
expect(hasPreviewResult(input({ resultKind }))).toBe(false);
|
||||||
),
|
|
||||||
).toMatchObject({
|
|
||||||
resultValue: "Text",
|
|
||||||
hasResult: true,
|
|
||||||
});
|
|
||||||
expect(
|
|
||||||
buildSchemaPreviewModel(input({ resultKind }), translate),
|
|
||||||
).toMatchObject({
|
|
||||||
resultValue: "—",
|
|
||||||
hasResult: false,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,58 +1,20 @@
|
|||||||
import type { PixelImage } from "$lib/core/types";
|
import type { PixelImage } from "$lib/core/types";
|
||||||
import type { FileResult, InputMode, ResultKind } from "$lib/registry";
|
import type { FileResult, InputMode, ResultKind } from "$lib/registry";
|
||||||
|
|
||||||
export type PreviewTranslate = (key: string) => string;
|
|
||||||
|
|
||||||
export type SchemaPreviewModelInput = {
|
export type SchemaPreviewModelInput = {
|
||||||
inputMode: InputMode;
|
inputMode: InputMode;
|
||||||
resultKind: ResultKind;
|
resultKind: ResultKind;
|
||||||
source: PixelImage | null;
|
|
||||||
result: PixelImage | null;
|
result: PixelImage | null;
|
||||||
fileResult: FileResult | null;
|
fileResult: FileResult | null;
|
||||||
textResult: string | null;
|
textResult: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SchemaPreviewModel = {
|
/** Можно ли скачать результат: он есть и он не пустой. */
|
||||||
sourceValue: string;
|
export function hasPreviewResult(input: SchemaPreviewModelInput): boolean {
|
||||||
resultValue: string;
|
const { resultKind, result, fileResult, textResult } = input;
|
||||||
hasResult: boolean;
|
if (resultKind === "image") return Boolean(result);
|
||||||
};
|
if (resultKind === "files") {
|
||||||
|
return Boolean(fileResult && fileResult.files.length > 0);
|
||||||
export function buildSchemaPreviewModel(
|
}
|
||||||
input: SchemaPreviewModelInput,
|
return Boolean(textResult);
|
||||||
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),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-s);
|
gap: var(--space-s);
|
||||||
line-height: normal;
|
line-height: 1;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: var(--font-size-s);
|
font-size: var(--font-size-s);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
|||||||
@@ -76,6 +76,9 @@ export const en: Dict = {
|
|||||||
result: "Result",
|
result: "Result",
|
||||||
previewPanel: "Preview",
|
previewPanel: "Preview",
|
||||||
parts: "parts",
|
parts: "parts",
|
||||||
|
clamped: "clamped to image bounds",
|
||||||
|
aspectAdjusted: "size adjusted to keep aspect",
|
||||||
|
grown: "grew beyond requested size — image is larger",
|
||||||
alt: "Result image",
|
alt: "Result image",
|
||||||
},
|
},
|
||||||
paramsCard: {
|
paramsCard: {
|
||||||
@@ -174,7 +177,8 @@ export const en: Dict = {
|
|||||||
radiusInt: "Radius must be a non-negative integer",
|
radiusInt: "Radius must be a non-negative integer",
|
||||||
kernelSize: "Kernel does not match the image dimensions",
|
kernelSize: "Kernel does not match the image dimensions",
|
||||||
sizeInt: "Width and height must be integers ≥ 1",
|
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",
|
noCanvasCtx: "Canvas 2D context is unavailable in this environment",
|
||||||
badBase64: "Expected a base64 string or a data-uri of an image",
|
badBase64: "Expected a base64 string or a data-uri of an image",
|
||||||
qualityRange: "quality must be within 0..1",
|
qualityRange: "quality must be within 0..1",
|
||||||
@@ -197,9 +201,9 @@ export const en: Dict = {
|
|||||||
paramString: 'Parameter "{id}" must be a string',
|
paramString: 'Parameter "{id}" must be a string',
|
||||||
paramBool: 'Parameter "{id}" must be a checkbox value',
|
paramBool: 'Parameter "{id}" must be a checkbox value',
|
||||||
resizeSize: "Width and/or height must be positive",
|
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",
|
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}".',
|
pageUnknown: 'No page is registered under "{slug}".',
|
||||||
},
|
},
|
||||||
fields: {
|
fields: {
|
||||||
|
|||||||
@@ -77,6 +77,9 @@ export const ru: Dict = {
|
|||||||
result: "Результат",
|
result: "Результат",
|
||||||
previewPanel: "Предпросмотр",
|
previewPanel: "Предпросмотр",
|
||||||
parts: "частей",
|
parts: "частей",
|
||||||
|
clamped: "уменьшено до границ изображения",
|
||||||
|
aspectAdjusted: "размер скорректирован по пропорциям",
|
||||||
|
grown: "больше запрошенного — картинка крупнее",
|
||||||
alt: "Изображение-результат",
|
alt: "Изображение-результат",
|
||||||
},
|
},
|
||||||
paramsCard: {
|
paramsCard: {
|
||||||
@@ -175,7 +178,8 @@ export const ru: Dict = {
|
|||||||
radiusInt: "Радиус должен быть целым неотрицательным числом",
|
radiusInt: "Радиус должен быть целым неотрицательным числом",
|
||||||
kernelSize: "Ядро не совпадает с изображением по размеру",
|
kernelSize: "Ядро не совпадает с изображением по размеру",
|
||||||
sizeInt: "Ширина и высота должны быть целыми числами ≥ 1",
|
sizeInt: "Ширина и высота должны быть целыми числами ≥ 1",
|
||||||
cropBounds: "Область обрезки не пересекает изображение",
|
cropBounds:
|
||||||
|
"Область не помещается: смещение + размер превышают границы изображения",
|
||||||
noCanvasCtx: "Canvas 2D context недоступен в этом окружении",
|
noCanvasCtx: "Canvas 2D context недоступен в этом окружении",
|
||||||
badBase64: "Ожидается base64-строка или data-uri изображения",
|
badBase64: "Ожидается base64-строка или data-uri изображения",
|
||||||
qualityRange: "quality должно быть в диапазоне 0..1",
|
qualityRange: "quality должно быть в диапазоне 0..1",
|
||||||
@@ -198,9 +202,9 @@ export const ru: Dict = {
|
|||||||
paramString: 'Параметр "{id}" должен быть строкой',
|
paramString: 'Параметр "{id}" должен быть строкой',
|
||||||
paramBool: 'Параметр "{id}" должен быть значением флажка',
|
paramBool: 'Параметр "{id}" должен быть значением флажка',
|
||||||
resizeSize: "Ширина и/или высота должны быть положительными",
|
resizeSize: "Ширина и/или высота должны быть положительными",
|
||||||
cropSize: "Ширина и высота области обрезки должны быть положительными",
|
cropSize: "Ширина и высота области должны быть не меньше 1 px",
|
||||||
sizePositive: "Размеры должны быть положительными и конечными",
|
sizePositive: "Размеры должны быть положительными и конечными",
|
||||||
tooManyParts: "Слишком много частей ({count}). Ограничение — 1000.",
|
tooManyParts: "Слишком много частей ({count}). Ограничение — {max}.",
|
||||||
pageUnknown: "Страницы «{slug}» нет в каталоге.",
|
pageUnknown: "Страницы «{slug}» нет в каталоге.",
|
||||||
},
|
},
|
||||||
fields: {
|
fields: {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
applySourceDefaults,
|
applySourceDefaults,
|
||||||
|
clampSourceAwareMaxes,
|
||||||
defaultSchemaParams,
|
defaultSchemaParams,
|
||||||
field,
|
field,
|
||||||
resolveLayoutGroups,
|
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", () => {
|
describe("registry-schema: source-aware dimension defaults", () => {
|
||||||
it("uses static fallback before a source exists", () => {
|
it("uses static fallback before a source exists", () => {
|
||||||
expect(defaultSchemaParams(sourceSchema)).toEqual({
|
expect(defaultSchemaParams(sourceSchema)).toEqual({
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ export interface NumberSpec extends FieldSpecBase {
|
|||||||
min?: number;
|
min?: number;
|
||||||
max?: number;
|
max?: number;
|
||||||
step?: number;
|
step?: number;
|
||||||
|
/** Потолок из размеров исходника (ось); статический max — фолбэк. */
|
||||||
|
maxFromSource?: "width" | "height";
|
||||||
|
maxMinus?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SliderSpec extends FieldSpecBase {
|
export interface SliderSpec extends FieldSpecBase {
|
||||||
@@ -73,10 +76,9 @@ export interface DimensionSpec extends FieldSpecBase {
|
|||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
defaultFromSource?: boolean;
|
defaultFromSource?: boolean;
|
||||||
/**
|
/** Потолок каждой оси из соответствующего размера исходника. */
|
||||||
* Id checkbox-поля «сохранять пропорции»: пока оно включено, правка одной
|
maxFromSource?: boolean;
|
||||||
* оси пересчитывает вторую по аспекту текущего исходника.
|
/** Id checkbox-поля «сохранять пропорции». */
|
||||||
*/
|
|
||||||
lockAspectWith?: string;
|
lockAspectWith?: string;
|
||||||
/** Показывать чипы быстрого выбора из SIZE_PRESETS. */
|
/** Показывать чипы быстрого выбора из SIZE_PRESETS. */
|
||||||
presets?: boolean;
|
presets?: boolean;
|
||||||
@@ -286,6 +288,7 @@ export const field = {
|
|||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
defaultFromSource?: boolean;
|
defaultFromSource?: boolean;
|
||||||
|
maxFromSource?: boolean;
|
||||||
lockAspectWith?: string;
|
lockAspectWith?: string;
|
||||||
presets?: boolean;
|
presets?: boolean;
|
||||||
}): Field<Dimension> => ({
|
}): Field<Dimension> => ({
|
||||||
@@ -394,6 +397,68 @@ function clamp(value: number, min?: number, max?: number): number {
|
|||||||
return value;
|
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: ведущая ось (последняя правка
|
* Пересчёт при включённом lockAspect: ведущая ось (последняя правка
|
||||||
* пользователя) сохраняется, вторая подгоняется под аспект исходника.
|
* пользователя) сохраняется, вторая подгоняется под аспект исходника.
|
||||||
@@ -500,7 +565,11 @@ export function sanitizeSchemaParams<P>(
|
|||||||
case "slider": {
|
case "slider": {
|
||||||
const n =
|
const n =
|
||||||
typeof raw === "number" && Number.isFinite(raw) ? raw : spec.default;
|
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;
|
break;
|
||||||
}
|
}
|
||||||
case "select":
|
case "select":
|
||||||
@@ -543,8 +612,16 @@ export function sanitizeSchemaParams<P>(
|
|||||||
? defaults
|
? defaults
|
||||||
: { width: w, height: h };
|
: { width: w, height: h };
|
||||||
out[key] = {
|
out[key] = {
|
||||||
width: clamp(sourceSize.width, spec.min, spec.max),
|
width: clamp(
|
||||||
height: clamp(sourceSize.height, spec.min, spec.max),
|
sourceSize.width,
|
||||||
|
spec.min,
|
||||||
|
dimensionAxisMax(spec, "width", context?.source),
|
||||||
|
),
|
||||||
|
height: clamp(
|
||||||
|
sourceSize.height,
|
||||||
|
spec.min,
|
||||||
|
dimensionAxisMax(spec, "height", context?.source),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export type {
|
|||||||
Page,
|
Page,
|
||||||
PageStep,
|
PageStep,
|
||||||
ResultKind,
|
ResultKind,
|
||||||
|
ResultNote,
|
||||||
Tool,
|
Tool,
|
||||||
ToolContext,
|
ToolContext,
|
||||||
ToolImageFile,
|
ToolImageFile,
|
||||||
|
|||||||
@@ -346,6 +346,53 @@ describe("registry-new (переведённые инструменты)", () =>
|
|||||||
expect(out.data[3]).toBe(0);
|
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 () => {
|
it("change-canvas-size использует anchor для позиции", async () => {
|
||||||
const tool = TOOLS.find((t) => t.id === "change-canvas-size")!;
|
const tool = TOOLS.find((t) => t.id === "change-canvas-size")!;
|
||||||
const img = solid(10, 10);
|
const img = solid(10, 10);
|
||||||
|
|||||||
@@ -120,6 +120,11 @@ const fitOnBackground: Tool<FitOnBackgroundParams> = {
|
|||||||
Math.round((Math.trunc(p.opacity) / 100) * 255),
|
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 {
|
interface ChangeCanvasSizeParams {
|
||||||
@@ -230,6 +235,12 @@ const resizeTool: Tool<ResizeParams> = {
|
|||||||
}
|
}
|
||||||
return resize(img, w, h);
|
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 {
|
interface CropParams {
|
||||||
@@ -243,24 +254,29 @@ export const cropSchema = toolSchema<CropParams>(
|
|||||||
x: field.number({
|
x: field.number({
|
||||||
label: "fields.x",
|
label: "fields.x",
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 2000,
|
max: 20000,
|
||||||
step: 1,
|
step: 1,
|
||||||
default: 0,
|
default: 0,
|
||||||
|
maxFromSource: "width",
|
||||||
|
maxMinus: 1,
|
||||||
}),
|
}),
|
||||||
y: field.number({
|
y: field.number({
|
||||||
label: "fields.y",
|
label: "fields.y",
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 1200,
|
max: 20000,
|
||||||
step: 1,
|
step: 1,
|
||||||
default: 0,
|
default: 0,
|
||||||
|
maxFromSource: "height",
|
||||||
|
maxMinus: 1,
|
||||||
}),
|
}),
|
||||||
size: field.dimension({
|
size: field.dimension({
|
||||||
label: "fields.cropAreaSize",
|
label: "fields.cropAreaSize",
|
||||||
min: 1,
|
min: 1,
|
||||||
max: 2000,
|
max: 20000,
|
||||||
width: 1,
|
width: 1,
|
||||||
height: 1,
|
height: 1,
|
||||||
defaultFromSource: true,
|
defaultFromSource: true,
|
||||||
|
maxFromSource: true,
|
||||||
presets: true,
|
presets: true,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -286,6 +302,11 @@ const cropTool: Tool<CropParams> = {
|
|||||||
}
|
}
|
||||||
return crop(img, Math.trunc(p.x), Math.trunc(p.y), w, h);
|
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 {
|
interface RotateParams {
|
||||||
@@ -449,7 +470,10 @@ const splitPartsTool: Tool<SplitPartsParams> = {
|
|||||||
const rowsCount = Math.max(1, Math.trunc(params.rows));
|
const rowsCount = Math.max(1, Math.trunc(params.rows));
|
||||||
const count = cols * rowsCount;
|
const count = cols * rowsCount;
|
||||||
if (count > MAX_SPLIT_PARTS) {
|
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 parts = splitToParts(img, cols, rowsCount);
|
||||||
const rowLen = String(rowsCount).length;
|
const rowLen = String(rowsCount).length;
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ export interface VerdictResult {
|
|||||||
|
|
||||||
export type ToolResult = PixelImage | string | FileResult | VerdictResult;
|
export type ToolResult = PixelImage | string | FileResult | VerdictResult;
|
||||||
|
|
||||||
|
/** Заметка у тайла результата: i18n-ключ и тон бейджа (warning по умолчанию). */
|
||||||
|
export type ResultNote = { key: string; tone?: "warning" | "info" };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Инструмент: уникальная реализация без адреса, текстов и категории — всё это
|
* Инструмент: уникальная реализация без адреса, текстов и категории — всё это
|
||||||
* у страницы. `id` уникален, но обслуживает сколько угодно страниц, поэтому
|
* у страницы. `id` уникален, но обслуживает сколько угодно страниц, поэтому
|
||||||
@@ -86,6 +89,12 @@ export type Tool<P = Record<string, unknown>> = {
|
|||||||
domOnly?: boolean;
|
domOnly?: boolean;
|
||||||
/** Формат/качество скачивания результата; по умолчанию — PNG. */
|
/** Формат/качество скачивания результата; по умолчанию — PNG. */
|
||||||
output?: OutputFormat;
|
output?: OutputFormat;
|
||||||
|
/**
|
||||||
|
* Заметка у тайла результата, когда итог отличается от запрошенного:
|
||||||
|
* ужат по границам (crop), скорректирован по пропорциям (resize),
|
||||||
|
* вырос за канвас (fit-on-background).
|
||||||
|
*/
|
||||||
|
resultNote?: (params: P, result: PixelImage) => ResultNote | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Шаг страницы: инструмент и зафиксированные под него значения параметров. */
|
/** Шаг страницы: инструмент и зафиксированные под него значения параметров. */
|
||||||
|
|||||||
Reference in New Issue
Block a user