mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-11-13 17:08:20 +00:00
feat: add select format to download button
This commit is contained in:
+5
-5
@@ -6,12 +6,8 @@
|
|||||||
|
|
||||||
## UX — приоритетная очередь
|
## UX — приоритетная очередь
|
||||||
|
|
||||||
Порядок согласован: E (download) → D (pipeline). Делать по одной, сверху вниз.
|
Порядок согласован: D (pipeline). Делать по одной, сверху вниз.
|
||||||
|
|
||||||
- [ ] **E. Download** — кнопка сразу пишет текущий формат, селектор
|
|
||||||
PNG/JPG/WebP/BMP для всех инструментов, предупреждение при несовместимости
|
|
||||||
формата (альфа-канал); описание формата из блока исходник/результат
|
|
||||||
переезжает в кнопку. Райз «Download» → S1f (`docs/plan-seo.md` §4)
|
|
||||||
- [ ] **D. Pipeline** — процесс редактирования и настройки: drag-n-drop,
|
- [ ] **D. Pipeline** — процесс редактирования и настройки: drag-n-drop,
|
||||||
удаление, добавление инструментов; переход «один инструмент ↔ pipeline»
|
удаление, добавление инструментов; переход «один инструмент ↔ pipeline»
|
||||||
без перезагрузки. Следом — идеи 1–3 (смена первого инструмента,
|
без перезагрузки. Следом — идеи 1–3 (смена первого инструмента,
|
||||||
@@ -38,6 +34,10 @@
|
|||||||
- [ ] e2e: `i18n.spec.ts` и `text-and-verdicts.spec.ts` дублируют локаторы из
|
- [ ] e2e: `i18n.spec.ts` и `text-and-verdicts.spec.ts` дублируют локаторы из
|
||||||
`helpers/page.ts` вместо вызова — правило «спека зовёт helpers»
|
`helpers/page.ts` вместо вызова — правило «спека зовёт helpers»
|
||||||
(`docs/testing-strategy.md`, раздел 5)
|
(`docs/testing-strategy.md`, раздел 5)
|
||||||
|
- [ ] e2e webkit: случайные падения по таймауту 5с (элемент не появляется),
|
||||||
|
каждый прогон падают разные тесты — webkit под Windows слишком медленный.
|
||||||
|
Рассмотреть увеличение timeout/expectTimeout для webkit-проекта в
|
||||||
|
`playwright.config.ts`
|
||||||
- [ ] мёртвый i18n-ключ `noParams` («нет параметров — результат готов»): его
|
- [ ] мёртвый i18n-ключ `noParams` («нет параметров — результат готов»): его
|
||||||
ничем не читают. Либо показывать подсказку для инструментов с пустой
|
ничем не читают. Либо показывать подсказку для инструментов с пустой
|
||||||
схемой (FA-E1), либо удалить ключ из обеих локалей
|
схемой (FA-E1), либо удалить ключ из обеих локалей
|
||||||
|
|||||||
+10
-1
@@ -117,7 +117,8 @@ export async function uploadViaDrop(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadResultFile(page: Page): Promise<Download> {
|
export async function downloadResultFile(page: Page): Promise<Download> {
|
||||||
const button = page.getByRole("button", { name: "Download result" });
|
// Split-кнопка: основная часть "Download" (image) или "Download ZIP" (files).
|
||||||
|
const button = page.getByRole("button", { name: /^Download( ZIP)?$/ });
|
||||||
await expect(button).toBeVisible();
|
await expect(button).toBeVisible();
|
||||||
const [download] = await Promise.all([
|
const [download] = await Promise.all([
|
||||||
page.waitForEvent("download"),
|
page.waitForEvent("download"),
|
||||||
@@ -126,6 +127,14 @@ export async function downloadResultFile(page: Page): Promise<Download> {
|
|||||||
return download;
|
return download;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function chooseDownloadFormat(
|
||||||
|
page: Page,
|
||||||
|
format: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await page.getByRole("button", { name: "Choose download format" }).click();
|
||||||
|
await page.getByRole("menuitemradio", { name: format, exact: true }).click();
|
||||||
|
}
|
||||||
|
|
||||||
export async function downloadResultBytes(
|
export async function downloadResultBytes(
|
||||||
page: Page,
|
page: Page,
|
||||||
): Promise<{ name: string; bytes: Uint8Array }> {
|
): Promise<{ name: string; bytes: Uint8Array }> {
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import {
|
|||||||
largePng,
|
largePng,
|
||||||
onePixelPng,
|
onePixelPng,
|
||||||
opaquePng,
|
opaquePng,
|
||||||
|
transparentPng,
|
||||||
} from "./helpers/fixtures";
|
} from "./helpers/fixtures";
|
||||||
import {
|
import {
|
||||||
|
chooseDownloadFormat,
|
||||||
downloadResultBytes,
|
downloadResultBytes,
|
||||||
errorAlert,
|
errorAlert,
|
||||||
expectNoErrorAlert,
|
expectNoErrorAlert,
|
||||||
@@ -144,6 +146,26 @@ test("convert-png-to-webp: produces WebP and quality affects size", async ({
|
|||||||
expectNoErrors(sink);
|
expectNoErrors(sink);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("download format selector: JPG warns about alpha and produces JPEG", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const sink = trackErrors(page);
|
||||||
|
await openTool(page, "flip-png");
|
||||||
|
await uploadImage(page, transparentPng);
|
||||||
|
await expect(resultImage(page)).toBeVisible();
|
||||||
|
await chooseDownloadFormat(page, "JPG");
|
||||||
|
await expect(
|
||||||
|
page.getByRole("status").filter({ hasText: /transparency/ }),
|
||||||
|
).toBeVisible();
|
||||||
|
// у JPG в dropdown есть настройка качества
|
||||||
|
await rangeInput(page, "Quality").fill("10");
|
||||||
|
const { name, bytes } = await downloadResultBytes(page);
|
||||||
|
expect(name).toBe("flip-png.jpg");
|
||||||
|
expect(Array.from(bytes.subarray(0, 3))).toEqual([0xff, 0xd8, 0xff]);
|
||||||
|
await expectNoErrorAlert(page);
|
||||||
|
expectNoErrors(sink);
|
||||||
|
});
|
||||||
|
|
||||||
test("png-to-bmp: produces a BMP download", async ({ page }) => {
|
test("png-to-bmp: produces a BMP download", async ({ page }) => {
|
||||||
const sink = trackErrors(page);
|
const sink = trackErrors(page);
|
||||||
await openTool(page, "png-to-bmp");
|
await openTool(page, "png-to-bmp");
|
||||||
|
|||||||
@@ -176,6 +176,8 @@
|
|||||||
|
|
||||||
--duration-s: 0.15s;
|
--duration-s: 0.15s;
|
||||||
--duration-l: 0.8s;
|
--duration-l: 0.8s;
|
||||||
|
|
||||||
|
--z-dropdown: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] {
|
[data-theme="dark"] {
|
||||||
|
|||||||
@@ -1,31 +1,66 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import DownloadButton from "$lib/components/ui/DownloadButton.svelte";
|
import DownloadButton from "$lib/components/ui/DownloadButton.svelte";
|
||||||
import UploadButton from "$lib/components/ui/UploadButton.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 { t } from "$lib/i18n/t";
|
||||||
|
import type { ResultKind } from "$lib/registry";
|
||||||
|
import SchemaDownload from "./SchemaDownload.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
inputMode: "image" | "text" | "none";
|
inputMode: "image" | "text" | "none";
|
||||||
|
resultKind?: ResultKind;
|
||||||
canDownload: boolean;
|
canDownload: boolean;
|
||||||
running: boolean;
|
running: boolean;
|
||||||
|
format?: OutputMime;
|
||||||
|
quality?: number;
|
||||||
|
alphaLoss?: boolean;
|
||||||
onupload: (file: File) => void;
|
onupload: (file: File) => void;
|
||||||
ondownload: () => void;
|
ondownload: () => void;
|
||||||
|
onformat?: (mime: OutputMime) => void;
|
||||||
|
onquality?: (value: number) => void;
|
||||||
}
|
}
|
||||||
let { inputMode, canDownload, running, onupload, ondownload }: Props =
|
let {
|
||||||
$props();
|
inputMode,
|
||||||
|
resultKind = "image",
|
||||||
|
canDownload,
|
||||||
|
running,
|
||||||
|
format = "image/png",
|
||||||
|
quality = undefined,
|
||||||
|
alphaLoss = false,
|
||||||
|
onupload,
|
||||||
|
ondownload,
|
||||||
|
onformat,
|
||||||
|
onquality,
|
||||||
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
{#if inputMode === "image"}
|
{#if inputMode === "image"}
|
||||||
<UploadButton label={t("actions.openImage")} onfile={onupload} />
|
<UploadButton label={t("actions.openImage")} onfile={onupload} />
|
||||||
{/if}
|
{/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
|
<DownloadButton
|
||||||
label={t("actions.downloadResult")}
|
label={t("actions.downloadFormat", { format: "ZIP" })}
|
||||||
onclick={ondownload}
|
onclick={ondownload}
|
||||||
disabled={running}
|
disabled={running}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{#if canDownload && alphaLoss}
|
||||||
|
<p class="alpha-warning" role="status">
|
||||||
|
{t("actions.alphaLoss", { format: outputFormatByMime(format).label })}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.actions {
|
.actions {
|
||||||
@@ -34,4 +69,9 @@
|
|||||||
gap: var(--space-l);
|
gap: var(--space-l);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
.alpha-warning {
|
||||||
|
margin: var(--space-m) 0 0;
|
||||||
|
color: var(--color-warning);
|
||||||
|
font: var(--font-size-s) var(--font-mono);
|
||||||
|
}
|
||||||
</style>
|
</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 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 { t } from "$lib/i18n/t";
|
import { t } from "$lib/i18n/t";
|
||||||
import type { FileResult, InputMode, ResultKind } from "$lib/registry";
|
import type { FileResult, InputMode, ResultKind } from "$lib/registry";
|
||||||
|
|
||||||
@@ -20,12 +21,17 @@
|
|||||||
textVars?: Record<string, string | number>;
|
textVars?: Record<string, string | number>;
|
||||||
running?: boolean;
|
running?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
format?: OutputMime;
|
||||||
|
quality?: number;
|
||||||
|
alphaLoss?: boolean;
|
||||||
onupload: (file: File) => void;
|
onupload: (file: File) => void;
|
||||||
ontextsource?: (text: string) => void;
|
ontextsource?: (text: string) => void;
|
||||||
onrendertext?: () => void;
|
onrendertext?: () => void;
|
||||||
oncopytext?: () => void;
|
oncopytext?: () => void;
|
||||||
ondownloadtxt?: () => void;
|
ondownloadtxt?: () => void;
|
||||||
ondownload: () => void;
|
ondownload: () => void;
|
||||||
|
onformat?: (mime: OutputMime) => void;
|
||||||
|
onquality?: (value: number) => void;
|
||||||
}
|
}
|
||||||
let {
|
let {
|
||||||
inputMode,
|
inputMode,
|
||||||
@@ -39,12 +45,17 @@
|
|||||||
textVars = undefined,
|
textVars = undefined,
|
||||||
running = false,
|
running = false,
|
||||||
error = "",
|
error = "",
|
||||||
|
format = "image/png",
|
||||||
|
quality = undefined,
|
||||||
|
alphaLoss = false,
|
||||||
onupload,
|
onupload,
|
||||||
ontextsource,
|
ontextsource,
|
||||||
onrendertext,
|
onrendertext,
|
||||||
oncopytext,
|
oncopytext,
|
||||||
ondownloadtxt,
|
ondownloadtxt,
|
||||||
ondownload,
|
ondownload,
|
||||||
|
onformat,
|
||||||
|
onquality,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const model = $derived(
|
const model = $derived(
|
||||||
@@ -59,10 +70,16 @@
|
|||||||
<span class="label">{t("resultCard.previewPanel")}</span>
|
<span class="label">{t("resultCard.previewPanel")}</span>
|
||||||
<SchemaActions
|
<SchemaActions
|
||||||
{inputMode}
|
{inputMode}
|
||||||
|
{resultKind}
|
||||||
canDownload={model.hasResult}
|
canDownload={model.hasResult}
|
||||||
{running}
|
{running}
|
||||||
|
{format}
|
||||||
|
{quality}
|
||||||
|
{alphaLoss}
|
||||||
{onupload}
|
{onupload}
|
||||||
{ondownload}
|
{ondownload}
|
||||||
|
{onformat}
|
||||||
|
{onquality}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -98,7 +115,6 @@
|
|||||||
items={[
|
items={[
|
||||||
{ caption: t("sourceCard.source"), value: model.sourceValue },
|
{ caption: t("sourceCard.source"), value: model.sourceValue },
|
||||||
{ caption: t("resultCard.result"), value: model.resultValue },
|
{ caption: t("resultCard.result"), value: model.resultValue },
|
||||||
{ caption: t("resultCard.format"), value: model.formatValue },
|
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,8 +2,15 @@
|
|||||||
import SchemaFields from "./SchemaFields.svelte";
|
import SchemaFields from "./SchemaFields.svelte";
|
||||||
import SchemaPreview from "./SchemaPreview.svelte";
|
import SchemaPreview from "./SchemaPreview.svelte";
|
||||||
import { debounce } from "$lib/core/debounce";
|
import { debounce } from "$lib/core/debounce";
|
||||||
|
import { hasTransparency } from "$lib/core/analyze";
|
||||||
import { ToolError, type ErrorVars } from "$lib/core/errors";
|
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 type { PixelImage } from "$lib/core/types";
|
||||||
import { execute } from "$lib/executor";
|
import { execute } from "$lib/executor";
|
||||||
import {
|
import {
|
||||||
@@ -58,6 +65,33 @@
|
|||||||
let running = $state(false);
|
let running = $state(false);
|
||||||
let displayError = $state<DisplayError | null>(null);
|
let displayError = $state<DisplayError | null>(null);
|
||||||
let started = $state(false);
|
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 errorText = $derived.by(() => {
|
||||||
const current = displayError;
|
const current = displayError;
|
||||||
@@ -73,6 +107,7 @@
|
|||||||
if (!schema || started) return;
|
if (!schema || started) return;
|
||||||
started = true;
|
started = true;
|
||||||
values = defaultSchemaParams(schema);
|
values = defaultSchemaParams(schema);
|
||||||
|
format = tool.output?.mime ?? "image/png";
|
||||||
}
|
}
|
||||||
|
|
||||||
function setValue(
|
function setValue(
|
||||||
@@ -196,12 +231,11 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
const out = tool.output;
|
const out = outputFormatByMime(format);
|
||||||
const quality = out?.qualityParamId
|
const quality =
|
||||||
? Number(values[out.qualityParamId]) / 100
|
currentQuality !== undefined ? currentQuality / 100 : undefined;
|
||||||
: undefined;
|
const blob = await encode(result, out.mime, quality);
|
||||||
const blob = await encode(result, out?.mime ?? "image/png", quality);
|
downloadBlob(blob, `${page.slug}.${out.ext}`);
|
||||||
downloadBlob(blob, `${page.slug}.${out?.ext ?? "png"}`);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
displayError = toDisplayError(e);
|
displayError = toDisplayError(e);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -311,6 +345,11 @@
|
|||||||
{resultKind}
|
{resultKind}
|
||||||
{running}
|
{running}
|
||||||
error={errorText}
|
error={errorText}
|
||||||
|
{format}
|
||||||
|
quality={currentQuality}
|
||||||
|
{alphaLoss}
|
||||||
|
onformat={(v) => (format = v)}
|
||||||
|
onquality={setFormatQuality}
|
||||||
onupload={handleFile}
|
onupload={handleFile}
|
||||||
ontextsource={(textValue) => {
|
ontextsource={(textValue) => {
|
||||||
textSource = textValue;
|
textSource = textValue;
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ describe("buildSchemaPreviewModel", () => {
|
|||||||
).toEqual({
|
).toEqual({
|
||||||
sourceValue: "—",
|
sourceValue: "—",
|
||||||
resultValue: "2 × 3 px",
|
resultValue: "2 × 3 px",
|
||||||
formatValue: "PNG",
|
|
||||||
hasResult: true,
|
hasResult: true,
|
||||||
});
|
});
|
||||||
expect(buildSchemaPreviewModel(input(), translate)).toMatchObject({
|
expect(buildSchemaPreviewModel(input(), translate)).toMatchObject({
|
||||||
@@ -75,7 +74,6 @@ describe("buildSchemaPreviewModel", () => {
|
|||||||
).toEqual({
|
).toEqual({
|
||||||
sourceValue: "—",
|
sourceValue: "—",
|
||||||
resultValue: "1 parts",
|
resultValue: "1 parts",
|
||||||
formatValue: "ZIP (PNG)",
|
|
||||||
hasResult: true,
|
hasResult: true,
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
@@ -96,7 +94,6 @@ describe("buildSchemaPreviewModel", () => {
|
|||||||
),
|
),
|
||||||
).toMatchObject({
|
).toMatchObject({
|
||||||
resultValue: "Text",
|
resultValue: "Text",
|
||||||
formatValue: "PNG",
|
|
||||||
hasResult: true,
|
hasResult: true,
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ export type SchemaPreviewModelInput = {
|
|||||||
export type SchemaPreviewModel = {
|
export type SchemaPreviewModel = {
|
||||||
sourceValue: string;
|
sourceValue: string;
|
||||||
resultValue: string;
|
resultValue: string;
|
||||||
formatValue: string;
|
|
||||||
hasResult: boolean;
|
hasResult: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -49,7 +48,6 @@ export function buildSchemaPreviewModel(
|
|||||||
return {
|
return {
|
||||||
sourceValue,
|
sourceValue,
|
||||||
resultValue,
|
resultValue,
|
||||||
formatValue: resultKind === "files" ? "ZIP (PNG)" : "PNG",
|
|
||||||
hasResult:
|
hasResult:
|
||||||
resultKind === "image"
|
resultKind === "image"
|
||||||
? Boolean(result)
|
? Boolean(result)
|
||||||
|
|||||||
@@ -5,6 +5,45 @@ import { type PixelImage } from "./types";
|
|||||||
export type OutputMime =
|
export type OutputMime =
|
||||||
"image/png" | "image/jpeg" | "image/webp" | "image/bmp";
|
"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 =
|
export const ACCEPTED_IMAGE_TYPES =
|
||||||
"image/png,image/jpeg,image/webp,image/gif,image/bmp,image/x-icon";
|
"image/png,image/jpeg,image/webp,image/gif,image/bmp,image/x-icon";
|
||||||
|
|
||||||
|
|||||||
@@ -75,7 +75,6 @@ export const en: Dict = {
|
|||||||
result: "Result",
|
result: "Result",
|
||||||
previewPanel: "Preview",
|
previewPanel: "Preview",
|
||||||
parts: "parts",
|
parts: "parts",
|
||||||
format: "Format",
|
|
||||||
alt: "Result image",
|
alt: "Result image",
|
||||||
},
|
},
|
||||||
paramsCard: {
|
paramsCard: {
|
||||||
@@ -100,7 +99,10 @@ export const en: Dict = {
|
|||||||
generate: "Generate",
|
generate: "Generate",
|
||||||
generating: "Generating…",
|
generating: "Generating…",
|
||||||
openImage: "Open image",
|
openImage: "Open image",
|
||||||
downloadResult: "Download result",
|
download: "Download",
|
||||||
|
downloadFormat: "Download {format}",
|
||||||
|
formatAria: "Choose download format",
|
||||||
|
alphaLoss: "Result has transparency — {format} will flatten it",
|
||||||
},
|
},
|
||||||
textSource: {
|
textSource: {
|
||||||
placeholder: "Paste base64 / hex / bytes…",
|
placeholder: "Paste base64 / hex / bytes…",
|
||||||
|
|||||||
@@ -76,7 +76,6 @@ export const ru: Dict = {
|
|||||||
result: "Результат",
|
result: "Результат",
|
||||||
previewPanel: "Предпросмотр",
|
previewPanel: "Предпросмотр",
|
||||||
parts: "частей",
|
parts: "частей",
|
||||||
format: "Формат",
|
|
||||||
alt: "Изображение-результат",
|
alt: "Изображение-результат",
|
||||||
},
|
},
|
||||||
paramsCard: {
|
paramsCard: {
|
||||||
@@ -101,7 +100,10 @@ export const ru: Dict = {
|
|||||||
generate: "Сгенерировать",
|
generate: "Сгенерировать",
|
||||||
generating: "Генерация…",
|
generating: "Генерация…",
|
||||||
openImage: "Открыть изображение",
|
openImage: "Открыть изображение",
|
||||||
downloadResult: "Скачать результат",
|
download: "Скачать",
|
||||||
|
downloadFormat: "Скачать {format}",
|
||||||
|
formatAria: "Выбор формата скачивания",
|
||||||
|
alphaLoss: "В результате есть прозрачность — {format} её уберёт",
|
||||||
},
|
},
|
||||||
textSource: {
|
textSource: {
|
||||||
placeholder: "Вставьте base64 / hex / байты…",
|
placeholder: "Вставьте base64 / hex / байты…",
|
||||||
|
|||||||
Reference in New Issue
Block a user