feat: add select format to download button

This commit is contained in:
2026-10-04 23:43:06 +05:00
parent 864f9cdc9f
commit e87bc1d715
13 changed files with 379 additions and 27 deletions
@@ -1,31 +1,66 @@
<script lang="ts">
import DownloadButton from "$lib/components/ui/DownloadButton.svelte";
import UploadButton from "$lib/components/ui/UploadButton.svelte";
import type { OutputMime } from "$lib/core/io";
import { outputFormatByMime } from "$lib/core/io";
import { t } from "$lib/i18n/t";
import type { ResultKind } from "$lib/registry";
import SchemaDownload from "./SchemaDownload.svelte";
interface Props {
inputMode: "image" | "text" | "none";
resultKind?: ResultKind;
canDownload: boolean;
running: boolean;
format?: OutputMime;
quality?: number;
alphaLoss?: boolean;
onupload: (file: File) => void;
ondownload: () => void;
onformat?: (mime: OutputMime) => void;
onquality?: (value: number) => void;
}
let { inputMode, canDownload, running, onupload, ondownload }: Props =
$props();
let {
inputMode,
resultKind = "image",
canDownload,
running,
format = "image/png",
quality = undefined,
alphaLoss = false,
onupload,
ondownload,
onformat,
onquality,
}: Props = $props();
</script>
<div class="actions">
{#if inputMode === "image"}
<UploadButton label={t("actions.openImage")} onfile={onupload} />
{/if}
{#if canDownload}
{#if canDownload && resultKind === "image"}
<SchemaDownload
{format}
{quality}
{running}
{ondownload}
onformat={(v) => onformat?.(v)}
onquality={(v) => onquality?.(v)}
/>
{:else if canDownload && resultKind === "files"}
<DownloadButton
label={t("actions.downloadResult")}
label={t("actions.downloadFormat", { format: "ZIP" })}
onclick={ondownload}
disabled={running}
/>
{/if}
</div>
{#if canDownload && alphaLoss}
<p class="alpha-warning" role="status">
{t("actions.alphaLoss", { format: outputFormatByMime(format).label })}
</p>
{/if}
<style>
.actions {
@@ -34,4 +69,9 @@
gap: var(--space-l);
flex-wrap: wrap;
}
.alpha-warning {
margin: var(--space-m) 0 0;
color: var(--color-warning);
font: var(--font-size-s) var(--font-mono);
}
</style>
@@ -0,0 +1,186 @@
<script lang="ts">
import RangeControl from "$lib/components/fields/schema/RangeControl.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import {
OUTPUT_FORMATS,
outputFormatByMime,
type OutputMime,
} from "$lib/core/io";
import type { SliderSpec } from "$lib/registry-schema";
import { t } from "$lib/i18n/t";
import { ChevronDown, Download } from "@lucide/svelte";
interface Props {
format: OutputMime;
quality?: number;
running: boolean;
ondownload: () => void;
onformat: (mime: OutputMime) => void;
onquality: (value: number) => void;
}
let { format, quality, running, ondownload, onformat, onquality }: Props =
$props();
const current = $derived(outputFormatByMime(format));
const qualitySetting = $derived(current.settings?.quality);
const qualitySpec = $derived(
qualitySetting
? ({
kind: "slider",
min: qualitySetting.min,
max: qualitySetting.max,
step: qualitySetting.step,
default: qualitySetting.default,
} satisfies SliderSpec)
: undefined,
);
let open = $state(false);
let root = $state<HTMLDivElement | null>(null);
function choose(mime: OutputMime) {
// Меню — ещё и панель настроек формата: не закрываем при выборе,
// чтобы можно было сразу настроить качество.
onformat(mime);
}
$effect(() => {
if (!open) return;
function onPointerDown(e: PointerEvent) {
if (!root?.contains(e.target as Node)) open = false;
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") open = false;
}
window.addEventListener("pointerdown", onPointerDown);
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("keydown", onKey);
};
});
</script>
<div class="download-split" bind:this={root}>
<button
type="button"
class="main"
disabled={running}
onclick={() => {
open = false;
ondownload();
}}
>
<Icon icon={Download} size={14} />
<span>{t("actions.download")}</span>
</button>
<button
type="button"
class="toggle"
aria-haspopup="menu"
aria-expanded={open}
aria-label={t("actions.formatAria")}
disabled={running}
onclick={() => (open = !open)}
>
<span>{current.label}</span>
<Icon icon={ChevronDown} size={14} />
</button>
{#if open}
<div class="menu" role="menu">
{#each OUTPUT_FORMATS as f (f.mime)}
<button
type="button"
class="item"
class:selected={f.mime === format}
role="menuitemradio"
aria-checked={f.mime === format}
onclick={() => choose(f.mime)}
>
{f.label}
</button>
{/each}
{#if qualitySpec && quality !== undefined}
<div class="quality">
<RangeControl
label={t("fields.quality")}
value={quality}
spec={qualitySpec}
onchange={(q) => onquality(q)}
/>
</div>
{/if}
</div>
{/if}
</div>
<style>
.download-split {
position: relative;
display: inline-flex;
}
.main,
.toggle {
display: inline-flex;
align-items: center;
gap: var(--space-m);
padding: var(--space-m) var(--space-xl);
border: var(--size-border-thick) solid var(--color-main);
background: var(--color-main);
color: var(--color-background);
font-size: var(--font-size-l);
font-weight: bold;
cursor: pointer;
}
.main {
border-right: none;
border-radius: var(--radius-s) 0 0 var(--radius-s);
}
.toggle {
border-radius: 0 var(--radius-s) var(--radius-s) 0;
font-family: var(--font-mono);
font-size: var(--font-size-s);
font-weight: normal;
}
.main:disabled,
.toggle:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.menu {
position: absolute;
top: calc(100% + var(--space-s));
right: 0;
z-index: var(--z-dropdown);
/* Ширина под строку качества: числовое поле + степперы + сброс. */
min-width: calc(var(--space-xxxl) * 9);
display: grid;
gap: var(--space-s);
padding: var(--space-m);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-m);
background: var(--color-panel);
}
.item {
padding: var(--space-m) var(--space-l);
border: var(--size-border) solid transparent;
border-radius: var(--radius-s);
background: transparent;
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
text-align: left;
cursor: pointer;
}
.item:hover {
color: var(--color-text);
border-color: var(--color-border);
}
.item.selected {
color: var(--color-main);
border-color: var(--color-main);
}
.quality {
padding-top: var(--space-m);
border-top: var(--size-border) solid var(--color-border);
}
</style>
@@ -5,6 +5,7 @@
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";
@@ -20,12 +21,17 @@
textVars?: Record<string, string | number>;
running?: boolean;
error?: string;
format?: OutputMime;
quality?: number;
alphaLoss?: boolean;
onupload: (file: File) => void;
ontextsource?: (text: string) => void;
onrendertext?: () => void;
oncopytext?: () => void;
ondownloadtxt?: () => void;
ondownload: () => void;
onformat?: (mime: OutputMime) => void;
onquality?: (value: number) => void;
}
let {
inputMode,
@@ -39,12 +45,17 @@
textVars = undefined,
running = false,
error = "",
format = "image/png",
quality = undefined,
alphaLoss = false,
onupload,
ontextsource,
onrendertext,
oncopytext,
ondownloadtxt,
ondownload,
onformat,
onquality,
}: Props = $props();
const model = $derived(
@@ -59,10 +70,16 @@
<span class="label">{t("resultCard.previewPanel")}</span>
<SchemaActions
{inputMode}
{resultKind}
canDownload={model.hasResult}
{running}
{format}
{quality}
{alphaLoss}
{onupload}
{ondownload}
{onformat}
{onquality}
/>
</div>
@@ -98,7 +115,6 @@
items={[
{ caption: t("sourceCard.source"), value: model.sourceValue },
{ caption: t("resultCard.result"), value: model.resultValue },
{ caption: t("resultCard.format"), value: model.formatValue },
]}
/>
</div>
@@ -2,8 +2,15 @@
import SchemaFields from "./SchemaFields.svelte";
import SchemaPreview from "./SchemaPreview.svelte";
import { debounce } from "$lib/core/debounce";
import { hasTransparency } from "$lib/core/analyze";
import { ToolError, type ErrorVars } from "$lib/core/errors";
import { decodeFile, downloadBlob, encode } from "$lib/core/io";
import {
decodeFile,
downloadBlob,
encode,
outputFormatByMime,
type OutputMime,
} from "$lib/core/io";
import type { PixelImage } from "$lib/core/types";
import { execute } from "$lib/executor";
import {
@@ -58,6 +65,33 @@
let running = $state(false);
let displayError = $state<DisplayError | null>(null);
let started = $state(false);
let format = $state<OutputMime>("image/png");
// Качество lossy-форматов для инструментов без quality-параметра в схеме.
let formatQuality = $state<Record<string, number>>({});
const alphaLoss = $derived(
resultKind === "image" &&
result !== null &&
!outputFormatByMime(format).supportsAlpha &&
hasTransparency(result),
);
// Если в схеме есть quality-параметр (convert-инструменты), он — единый
// источник качества; dropdown редактирует его же.
const qualityParamId = $derived(tool.output?.qualityParamId);
const currentQuality = $derived.by(() => {
const setting = outputFormatByMime(format).settings?.quality;
if (!setting) return undefined;
if (qualityParamId) return Number(values[qualityParamId]);
return formatQuality[format] ?? setting.default;
});
function setFormatQuality(q: number) {
if (qualityParamId) {
setValue(qualityParamId, q);
} else {
formatQuality = { ...formatQuality, [format]: q };
}
}
const errorText = $derived.by(() => {
const current = displayError;
@@ -73,6 +107,7 @@
if (!schema || started) return;
started = true;
values = defaultSchemaParams(schema);
format = tool.output?.mime ?? "image/png";
}
function setValue(
@@ -196,12 +231,11 @@
return;
}
if (!result) return;
const out = tool.output;
const quality = out?.qualityParamId
? Number(values[out.qualityParamId]) / 100
: undefined;
const blob = await encode(result, out?.mime ?? "image/png", quality);
downloadBlob(blob, `${page.slug}.${out?.ext ?? "png"}`);
const out = outputFormatByMime(format);
const quality =
currentQuality !== undefined ? currentQuality / 100 : undefined;
const blob = await encode(result, out.mime, quality);
downloadBlob(blob, `${page.slug}.${out.ext}`);
} catch (e) {
displayError = toDisplayError(e);
} finally {
@@ -311,6 +345,11 @@
{resultKind}
{running}
error={errorText}
{format}
quality={currentQuality}
{alphaLoss}
onformat={(v) => (format = v)}
onquality={setFormatQuality}
onupload={handleFile}
ontextsource={(textValue) => {
textSource = textValue;
@@ -56,7 +56,6 @@ describe("buildSchemaPreviewModel", () => {
).toEqual({
sourceValue: "—",
resultValue: "2 × 3 px",
formatValue: "PNG",
hasResult: true,
});
expect(buildSchemaPreviewModel(input(), translate)).toMatchObject({
@@ -75,7 +74,6 @@ describe("buildSchemaPreviewModel", () => {
).toEqual({
sourceValue: "—",
resultValue: "1 parts",
formatValue: "ZIP (PNG)",
hasResult: true,
});
expect(
@@ -96,7 +94,6 @@ describe("buildSchemaPreviewModel", () => {
),
).toMatchObject({
resultValue: "Text",
formatValue: "PNG",
hasResult: true,
});
expect(
@@ -15,7 +15,6 @@ export type SchemaPreviewModelInput = {
export type SchemaPreviewModel = {
sourceValue: string;
resultValue: string;
formatValue: string;
hasResult: boolean;
};
@@ -49,7 +48,6 @@ export function buildSchemaPreviewModel(
return {
sourceValue,
resultValue,
formatValue: resultKind === "files" ? "ZIP (PNG)" : "PNG",
hasResult:
resultKind === "image"
? Boolean(result)
+39
View File
@@ -5,6 +5,45 @@ import { type PixelImage } from "./types";
export type OutputMime =
"image/png" | "image/jpeg" | "image/webp" | "image/bmp";
export interface OutputFormatOption {
mime: OutputMime;
ext: string;
label: string;
supportsAlpha: boolean;
/** Точные настройки формата в dropdown кнопки Download (расширяемо). */
settings?: {
quality?: { min: number; max: number; step: number; default: number };
};
}
const QUALITY_SETTING = { min: 1, max: 100, step: 1, default: 90 } as const;
/** Варианты формата в кнопке Download (селектор рядом с ней). */
export const OUTPUT_FORMATS: readonly OutputFormatOption[] = [
{ mime: "image/png", ext: "png", label: "PNG", supportsAlpha: true },
{
mime: "image/jpeg",
ext: "jpg",
label: "JPG",
supportsAlpha: false,
settings: { quality: QUALITY_SETTING },
},
{
mime: "image/webp",
ext: "webp",
label: "WebP",
supportsAlpha: true,
settings: { quality: QUALITY_SETTING },
},
{ mime: "image/bmp", ext: "bmp", label: "BMP", supportsAlpha: false },
];
export function outputFormatByMime(mime: OutputMime): OutputFormatOption {
const found = OUTPUT_FORMATS.find((f) => f.mime === mime);
if (!found) throw new ToolError("errors.encodeUnsupported", { mime });
return found;
}
export const ACCEPTED_IMAGE_TYPES =
"image/png,image/jpeg,image/webp,image/gif,image/bmp,image/x-icon";
+4 -2
View File
@@ -75,7 +75,6 @@ export const en: Dict = {
result: "Result",
previewPanel: "Preview",
parts: "parts",
format: "Format",
alt: "Result image",
},
paramsCard: {
@@ -100,7 +99,10 @@ export const en: Dict = {
generate: "Generate",
generating: "Generating…",
openImage: "Open image",
downloadResult: "Download result",
download: "Download",
downloadFormat: "Download {format}",
formatAria: "Choose download format",
alphaLoss: "Result has transparency — {format} will flatten it",
},
textSource: {
placeholder: "Paste base64 / hex / bytes…",
+4 -2
View File
@@ -76,7 +76,6 @@ export const ru: Dict = {
result: "Результат",
previewPanel: "Предпросмотр",
parts: "частей",
format: "Формат",
alt: "Изображение-результат",
},
paramsCard: {
@@ -101,7 +100,10 @@ export const ru: Dict = {
generate: "Сгенерировать",
generating: "Генерация…",
openImage: "Открыть изображение",
downloadResult: "Скачать результат",
download: "Скачать",
downloadFormat: "Скачать {format}",
formatAria: "Выбор формата скачивания",
alphaLoss: "В результате есть прозрачность — {format} её уберёт",
},
textSource: {
placeholder: "Вставьте base64 / hex / байты…",