mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-11-14 17:38:19 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a7f598d50 | ||
|
|
6166960c4b | ||
|
|
2f4d52a9e0 | ||
|
|
be9ecd0e8e | ||
|
|
3c9175f8a3 | ||
|
|
f67ddb3b0a |
@@ -2,6 +2,16 @@
|
|||||||
All notable changes to this project will be documented in this file. See [conventional commits](https://www.conventionalcommits.org/) for commit guidelines.
|
All notable changes to this project will be documented in this file. See [conventional commits](https://www.conventionalcommits.org/) for commit guidelines.
|
||||||
|
|
||||||
- - -
|
- - -
|
||||||
|
## [v0.8.0](https://github.com/Ku6epXBOCTuK/easy-png-tools/compare/f67ddb3b0a7c69316caa3bed30d976f41d7a1219..v0.8.0) - 2026-10-11
|
||||||
|
#### Features
|
||||||
|
- add tooltip for errors\warnings - ([be9ecd0](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/be9ecd0e8edfbe83a6c148cad5c61cce22687ccb)) - Ku6epXBOCTuK
|
||||||
|
- add download size limit warning - ([f67ddb3](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f67ddb3b0a7c69316caa3bed30d976f41d7a1219)) - Ku6epXBOCTuK
|
||||||
|
#### Bug Fixes
|
||||||
|
- move to popover realisation - ([6166960](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6166960c4b3a893e9a5be281a688a846b8a6e09f)) - Ku6epXBOCTuK
|
||||||
|
- update error\warning flow - ([3c9175f](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/3c9175f8a39fa156009733a775efefb85ee0208d)) - Ku6epXBOCTuK
|
||||||
|
|
||||||
|
- - -
|
||||||
|
|
||||||
## [v0.7.0](https://github.com/Ku6epXBOCTuK/easy-png-tools/compare/7bbcec5ff0c72044dcec70d5ba41c513a0eac16b..v0.7.0) - 2026-10-10
|
## [v0.7.0](https://github.com/Ku6epXBOCTuK/easy-png-tools/compare/7bbcec5ff0c72044dcec70d5ba41c513a0eac16b..v0.7.0) - 2026-10-10
|
||||||
#### Features
|
#### Features
|
||||||
- update dnd tools in pipeline - ([7bbcec5](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/7bbcec5ff0c72044dcec70d5ba41c513a0eac16b)) - Ku6epXBOCTuK
|
- update dnd tools in pipeline - ([7bbcec5](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/7bbcec5ff0c72044dcec70d5ba41c513a0eac16b)) - Ku6epXBOCTuK
|
||||||
|
|||||||
+37
-29
@@ -8,14 +8,35 @@
|
|||||||
> сохраняется ради перекрёстных ссылок («идея 4 зависит от идеи 3»). Оценки
|
> сохраняется ради перекрёстных ссылок («идея 4 зависит от идеи 3»). Оценки
|
||||||
> (S/M/L) — оценка труда, не приоритет.
|
> (S/M/L) — оценка труда, не приоритет.
|
||||||
|
|
||||||
|
## Тесты и lint
|
||||||
|
|
||||||
|
- [ ] правило - только английские комментарии не работает на svelte файлы
|
||||||
|
- [ ] правило - ограницить комментарии вроде уменьшило простыни из комментариев,
|
||||||
|
но всё равно они есть и часто пишет очевидные вещи. надо более строгое
|
||||||
|
правило. может такой вариант: разрешено писать комментарии в коде, но
|
||||||
|
только внутри функций, название которых начинается с `complex_` или
|
||||||
|
`tricky_` (или помеченных специальным декоратором/аннотацией).
|
||||||
|
- [ ] линтер на deprecated иконки возможен? 'FilePlus2' is deprecated.
|
||||||
|
- [ ] `tseslint.config(...)` deprecated: сигнатура
|
||||||
|
`(...configs: InfiniteDepthConfigWithExtends[]): ConfigArray` помечена
|
||||||
|
устаревшей, нужен переход на `defineConfig` из `eslint/config` (ESLint
|
||||||
|
core, а не `typescript-eslint`) — `web/eslint.config.js`.
|
||||||
|
- [ ] удалить мёртвые `tools.*.params` из root i18n после проверки потребителей;
|
||||||
|
до этого действует pattern-based policy-исключение
|
||||||
|
(`web/eslint-plugins/README.md`, раздел `dict-consistency`)
|
||||||
|
- [ ] e2e: `i18n.spec.ts` и `text-and-verdicts.spec.ts` дублируют локаторы из
|
||||||
|
`helpers/page.ts` вместо вызова — правило «спека зовёт helpers»
|
||||||
|
(`docs/testing-strategy.md`, раздел 5)
|
||||||
|
- [ ] Пересмотреть тесты — убрать точные совпадения текстов и любые селекторы,
|
||||||
|
привязанные к разметке или svelte-классам (сейчас остались:
|
||||||
|
`tool-flows.spec.ts`, `schema-tool-strings.test.ts`)
|
||||||
|
- [ ] можно ли в тестах получать i18n ключ вместо текста? можно ли написать lint
|
||||||
|
правило такое (запретить toContain без i18n)
|
||||||
|
- [ ] посмотреть ошибки e2e, разрешить запускать е2е без ручного подтверждения,
|
||||||
|
и рассмотреть - может уже пора включить их в ci\cd гейт
|
||||||
|
|
||||||
## Pipeline
|
## Pipeline
|
||||||
|
|
||||||
- [ ] **баг: drag-n-drop перестановки шагов не работает** — обработчики на месте
|
|
||||||
(`StepCard` draggable в сниппете `stepCard` `SchemaToolView.svelte`,
|
|
||||||
`onStepDrop` → `moveStep` в `schema-tool-state.svelte.ts`), но
|
|
||||||
перетаскивание не срабатывает. Замечено до рефактора H1 (2026-10), не
|
|
||||||
регрессия от него. Начать с проверки `draggable`/`dragstart` на `StepCard`
|
|
||||||
и того, что drop не глушится дочерними элементами карточки
|
|
||||||
- [ ] названия инструментов — в pipeline инструмент называется «обрезать png»,
|
- [ ] названия инструментов — в pipeline инструмент называется «обрезать png»,
|
||||||
но он работает с любыми картинками, наверное надо убрать; в заголовке
|
но он работает с любыми картинками, наверное надо убрать; в заголовке
|
||||||
страницы — возможно, надо указать все форматы (связано с SEO)
|
страницы — возможно, надо указать все форматы (связано с SEO)
|
||||||
@@ -74,10 +95,6 @@
|
|||||||
захотеть сохранить обработанный PNG или JPEG с другим качеством — блок
|
захотеть сохранить обработанный PNG или JPEG с другим качеством — блок
|
||||||
мешает легальному сценарию. Решение не принято; перед реализацией
|
мешает легальному сценарию. Решение не принято; перед реализацией
|
||||||
переобдумать.
|
переобдумать.
|
||||||
- [ ] **Лимит КБ: сигнал о недостижимости** — если даже минимальное качество /
|
|
||||||
k=2 не влезает в лимит, файл скачивается молча с превышением. Нужно:
|
|
||||||
`fitWithinBytes` (`web/src/lib/core/io.ts`) возвращает также достигнутый
|
|
||||||
размер, UI показывает warning «не удалось ужаться до N КБ, получилось M».
|
|
||||||
- [ ] **Лимит КБ: индикация активного сжатия** — сейчас заданный лимит никак не
|
- [ ] **Лимит КБ: индикация активного сжатия** — сейчас заданный лимит никак не
|
||||||
виден, пока меню Download закрыто: пользователь может забыть про него и
|
виден, пока меню Download закрыто: пользователь может забыть про него и
|
||||||
случайно сжать следующие файлы. Нужен постоянный индикатор (значок/
|
случайно сжать следующие файлы. Нужен постоянный индикатор (значок/
|
||||||
@@ -124,6 +141,16 @@
|
|||||||
|
|
||||||
## UI и качество
|
## UI и качество
|
||||||
|
|
||||||
|
- [ ] **обдумать агрегацию предупреждений на тайле** — сейчас разрешено два
|
||||||
|
бейджа рядом (derived `resultNote` + runtime-warnings); после
|
||||||
|
использования решить, нужен ли один сводный бейдж со счётчиком и списком
|
||||||
|
- [ ] **лимит КБ/качество как авто-шаг пайплайна** — вынести size limit/quality
|
||||||
|
из меню Download в автоматический инструмент цепочки, который применяет
|
||||||
|
выбранные настройки (сжатие видно в превью и в цепочке, ошибки
|
||||||
|
недостижимости ложатся на тайл шага). Нужен флоу передачи индикатора из
|
||||||
|
такого шага в кнопку Download (ошибка/предупреждение/итоговый размер).
|
||||||
|
Связано с пунктами про индикацию активного сжатия и превью итогового
|
||||||
|
результата
|
||||||
- [ ] кнопку редактирования названия пайплайна на этой же странице
|
- [ ] кнопку редактирования названия пайплайна на этой же странице
|
||||||
- [ ] при загрузке нескольких файлов, показывает source не той же высоты, как
|
- [ ] при загрузке нескольких файлов, показывает source не той же высоты, как
|
||||||
result
|
result
|
||||||
@@ -201,25 +228,6 @@
|
|||||||
сознательно не вводим — `docs/decisions.md`, раздел 1, поэтому расхождение
|
сознательно не вводим — `docs/decisions.md`, раздел 1, поэтому расхождение
|
||||||
ловится на ревью.
|
ловится на ревью.
|
||||||
|
|
||||||
## Тесты и lint
|
|
||||||
|
|
||||||
- [ ] линтер на deprecated иконки возможен? 'FilePlus2' is deprecated.
|
|
||||||
- [ ] `tseslint.config(...)` deprecated: сигнатура
|
|
||||||
`(...configs: InfiniteDepthConfigWithExtends[]): ConfigArray` помечена
|
|
||||||
устаревшей, нужен переход на `defineConfig` из `eslint/config` (ESLint
|
|
||||||
core, а не `typescript-eslint`) — `web/eslint.config.js`.
|
|
||||||
- [ ] удалить мёртвые `tools.*.params` из root i18n после проверки потребителей;
|
|
||||||
до этого действует pattern-based policy-исключение
|
|
||||||
(`web/eslint-plugins/README.md`, раздел `dict-consistency`)
|
|
||||||
- [ ] e2e: `i18n.spec.ts` и `text-and-verdicts.spec.ts` дублируют локаторы из
|
|
||||||
`helpers/page.ts` вместо вызова — правило «спека зовёт helpers»
|
|
||||||
(`docs/testing-strategy.md`, раздел 5)
|
|
||||||
- [ ] Пересмотреть тесты — убрать точные совпадения текстов и любые селекторы,
|
|
||||||
привязанные к разметке или svelte-классам (сейчас остались:
|
|
||||||
`tool-flows.spec.ts`, `schema-tool-strings.test.ts`)
|
|
||||||
- [ ] можно ли в тестах получать i18n ключ вместо текста? можно ли написать lint
|
|
||||||
правило такое (запретить toContain без i18n)
|
|
||||||
|
|
||||||
## SEO/GEO
|
## SEO/GEO
|
||||||
|
|
||||||
- [ ] переименование бренда + домен: чек-лист в локальном файле
|
- [ ] переименование бренда + домен: чек-лист в локальном файле
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "easy-png-tools",
|
"name": "easy-png-tools",
|
||||||
"version": "0.7.0",
|
"version": "0.8.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "web",
|
"name": "web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.7.0",
|
"version": "0.8.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
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 Badge from "$lib/components/ui/Badge.svelte";
|
||||||
|
import DetailBadge from "$lib/components/ui/DetailBadge.svelte";
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -15,6 +16,12 @@
|
|||||||
dims?: string;
|
dims?: string;
|
||||||
note?: string;
|
note?: string;
|
||||||
noteTone?: "warning" | "info";
|
noteTone?: "warning" | "info";
|
||||||
|
// Runtime warnings (chain/source/download events): shown as badges next
|
||||||
|
// to the derived result note, full message in the "?" popover.
|
||||||
|
warnings?: { label: string; detail?: string }[];
|
||||||
|
// Runtime errors: danger badges; the full message lives in the
|
||||||
|
// DetailBadge popover behind the "?" button.
|
||||||
|
errors?: { label: string; detail?: string }[];
|
||||||
actions?: Snippet;
|
actions?: Snippet;
|
||||||
}
|
}
|
||||||
let {
|
let {
|
||||||
@@ -27,6 +34,8 @@
|
|||||||
dims = undefined,
|
dims = undefined,
|
||||||
note = undefined,
|
note = undefined,
|
||||||
noteTone = "warning",
|
noteTone = "warning",
|
||||||
|
warnings = [],
|
||||||
|
errors = [],
|
||||||
actions = undefined,
|
actions = undefined,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
@@ -52,6 +61,22 @@
|
|||||||
{#if note}
|
{#if note}
|
||||||
<Badge tone={noteTone} label={note} />
|
<Badge tone={noteTone} label={note} />
|
||||||
{/if}
|
{/if}
|
||||||
|
{#each errors as error (error.label + (error.detail ?? ""))}
|
||||||
|
<DetailBadge
|
||||||
|
tone="danger"
|
||||||
|
label={error.label}
|
||||||
|
detail={error.detail}
|
||||||
|
role="alert"
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
{#each warnings as warning (warning.label + (warning.detail ?? ""))}
|
||||||
|
<DetailBadge
|
||||||
|
tone="warning"
|
||||||
|
label={warning.label}
|
||||||
|
detail={warning.detail}
|
||||||
|
role="status"
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
</span>
|
</span>
|
||||||
</figcaption>
|
</figcaption>
|
||||||
<div class="canvas" class:checker={mode === "image"}>
|
<div class="canvas" class:checker={mode === "image"}>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<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 DetailBadge from "$lib/components/ui/DetailBadge.svelte";
|
||||||
import type { OutputMime } from "$lib/core/io";
|
import type { OutputMime } from "$lib/core/io";
|
||||||
import { outputFormatByMime } from "$lib/output-formats";
|
import { outputFormatByMime } from "$lib/output-formats";
|
||||||
import { t } from "$lib/i18n/t";
|
import { t } from "$lib/i18n/t";
|
||||||
@@ -16,6 +17,9 @@
|
|||||||
quality?: number;
|
quality?: number;
|
||||||
limitKb?: number;
|
limitKb?: number;
|
||||||
alphaLoss?: boolean;
|
alphaLoss?: boolean;
|
||||||
|
// Download-zone feedback: size-limit misses and action failures.
|
||||||
|
warnings?: { label: string; detail?: string }[];
|
||||||
|
error?: { label: string; detail?: string } | null;
|
||||||
onupload: (file: File) => void;
|
onupload: (file: File) => void;
|
||||||
ondownload: () => void;
|
ondownload: () => void;
|
||||||
onformat?: (mime: OutputMime) => void;
|
onformat?: (mime: OutputMime) => void;
|
||||||
@@ -31,6 +35,8 @@
|
|||||||
quality = undefined,
|
quality = undefined,
|
||||||
limitKb = undefined,
|
limitKb = undefined,
|
||||||
alphaLoss = false,
|
alphaLoss = false,
|
||||||
|
warnings = [],
|
||||||
|
error = null,
|
||||||
onupload,
|
onupload,
|
||||||
ondownload,
|
ondownload,
|
||||||
onformat,
|
onformat,
|
||||||
@@ -61,6 +67,22 @@
|
|||||||
disabled={running}
|
disabled={running}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#each warnings as warning (warning.label + (warning.detail ?? ""))}
|
||||||
|
<DetailBadge
|
||||||
|
tone="warning"
|
||||||
|
label={warning.label}
|
||||||
|
detail={warning.detail}
|
||||||
|
role="status"
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
{#if error}
|
||||||
|
<DetailBadge
|
||||||
|
tone="danger"
|
||||||
|
label={error.label}
|
||||||
|
detail={error.detail}
|
||||||
|
role="alert"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if canDownload && alphaLoss}
|
{#if canDownload && alphaLoss}
|
||||||
<p class="alpha-warning" role="status">
|
<p class="alpha-warning" role="status">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import RangeControl from "$lib/components/fields/schema/RangeControl.svelte";
|
import RangeControl from "$lib/components/fields/schema/RangeControl.svelte";
|
||||||
import Icon from "$lib/components/ui/Icon.svelte";
|
import Icon from "$lib/components/ui/Icon.svelte";
|
||||||
import { onDismiss } from "$lib/components/ui/dismiss";
|
import { trackPopover } from "$lib/components/ui/popover-position";
|
||||||
import type { OutputMime } from "$lib/core/io";
|
import type { OutputMime } from "$lib/core/io";
|
||||||
import { OUTPUT_FORMATS, outputFormatByMime } from "$lib/output-formats";
|
import { OUTPUT_FORMATS, outputFormatByMime } from "$lib/output-formats";
|
||||||
import type { SliderSpec } from "$lib/registry-schema";
|
import type { SliderSpec } from "$lib/registry-schema";
|
||||||
@@ -43,8 +43,16 @@
|
|||||||
: undefined,
|
: undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const menuId = $props.id();
|
||||||
let open = $state(false);
|
let open = $state(false);
|
||||||
let root = $state<HTMLDivElement | null>(null);
|
let btn = $state<HTMLButtonElement | null>(null);
|
||||||
|
let menu = $state<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
// While open, keep the popover placed next to the button (scroll/resize).
|
||||||
|
$effect(() => {
|
||||||
|
if (!open || !menu || !btn) return;
|
||||||
|
return trackPopover(menu, btn, "end");
|
||||||
|
});
|
||||||
|
|
||||||
function choose(mime: OutputMime) {
|
function choose(mime: OutputMime) {
|
||||||
// Menu doubles as a format-settings panel: keep it open on select
|
// Menu doubles as a format-settings panel: keep it open on select
|
||||||
@@ -56,23 +64,15 @@
|
|||||||
const raw = (e.target as HTMLInputElement).valueAsNumber;
|
const raw = (e.target as HTMLInputElement).valueAsNumber;
|
||||||
onlimit(Number.isFinite(raw) && raw >= 1 ? Math.round(raw) : undefined);
|
onlimit(Number.isFinite(raw) && raw >= 1 ? Math.round(raw) : undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
return onDismiss(
|
|
||||||
() => root,
|
|
||||||
() => (open = false),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="download-split" bind:this={root}>
|
<div class="download-split">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="main"
|
class="main"
|
||||||
disabled={running}
|
disabled={running}
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
open = false;
|
menu?.hidePopover();
|
||||||
ondownload();
|
ondownload();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -80,66 +80,72 @@
|
|||||||
<span>{t("actions.download")}</span>
|
<span>{t("actions.download")}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
bind:this={btn}
|
||||||
type="button"
|
type="button"
|
||||||
class="toggle"
|
class="toggle"
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
|
aria-controls={menuId}
|
||||||
aria-label={t("actions.formatAria")}
|
aria-label={t("actions.formatAria")}
|
||||||
disabled={running}
|
disabled={running}
|
||||||
onclick={() => (open = !open)}
|
popovertarget={menuId}
|
||||||
>
|
>
|
||||||
<span>{current.label}</span>
|
<span>{current.label}</span>
|
||||||
<Icon icon={ChevronDown} size={14} />
|
<Icon icon={ChevronDown} size={14} />
|
||||||
</button>
|
</button>
|
||||||
{#if open}
|
<div
|
||||||
<div class="menu" role="menu">
|
bind:this={menu}
|
||||||
{#each OUTPUT_FORMATS as f (f.mime)}
|
id={menuId}
|
||||||
<button
|
class="menu"
|
||||||
type="button"
|
role="menu"
|
||||||
class="item"
|
popover="auto"
|
||||||
class:selected={f.mime === format}
|
ontoggle={(e) => (open = e.newState === "open")}
|
||||||
role="menuitemradio"
|
>
|
||||||
aria-checked={f.mime === format}
|
{#each OUTPUT_FORMATS as f (f.mime)}
|
||||||
onclick={() => choose(f.mime)}
|
<button
|
||||||
>
|
type="button"
|
||||||
{f.label}
|
class="item"
|
||||||
</button>
|
class:selected={f.mime === format}
|
||||||
{/each}
|
role="menuitemradio"
|
||||||
{#if qualitySpec && quality !== undefined}
|
aria-checked={f.mime === format}
|
||||||
<div class="quality">
|
onclick={() => choose(f.mime)}
|
||||||
<RangeControl
|
>
|
||||||
label={t("fields.quality")}
|
{f.label}
|
||||||
value={quality}
|
</button>
|
||||||
spec={qualitySpec}
|
{/each}
|
||||||
onchange={(q) => onquality(q)}
|
{#if qualitySpec && quality !== undefined}
|
||||||
/>
|
<div class="quality">
|
||||||
</div>
|
<RangeControl
|
||||||
{/if}
|
label={t("fields.quality")}
|
||||||
<div class="limit">
|
value={quality}
|
||||||
<label class="limit-field">
|
spec={qualitySpec}
|
||||||
<span>{t("actions.sizeLimitKb")}</span>
|
onchange={(q) => onquality(q)}
|
||||||
<input
|
/>
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
step="1"
|
|
||||||
placeholder="∞"
|
|
||||||
value={limitKb ?? ""}
|
|
||||||
oninput={handleLimit}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{#if limitKb !== undefined && format === "image/bmp"}
|
|
||||||
<p class="limit-warn" role="status">
|
|
||||||
{t("actions.sizeLimitBmp")}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="limit">
|
||||||
|
<label class="limit-field">
|
||||||
|
<span>{t("actions.sizeLimitKb")}</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
step="1"
|
||||||
|
placeholder="∞"
|
||||||
|
value={limitKb ?? ""}
|
||||||
|
oninput={handleLimit}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{#if limitKb !== undefined && format === "image/bmp"}
|
||||||
|
<p class="limit-warn" role="status">
|
||||||
|
{t("actions.sizeLimitBmp")}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.download-split {
|
.download-split {
|
||||||
position: relative;
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
.main,
|
.main,
|
||||||
@@ -171,19 +177,24 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
.menu {
|
.menu {
|
||||||
position: absolute;
|
/* Native popover: top layer + light dismiss + Escape из коробки;
|
||||||
top: calc(100% + var(--space-s));
|
позиционирование — trackPopover (anchor() поддержан не везде). */
|
||||||
right: 0;
|
position: fixed;
|
||||||
z-index: var(--z-dropdown);
|
margin: 0;
|
||||||
|
translate: 0 var(--space-s);
|
||||||
/* Ширина под строку качества: числовое поле + степперы + сброс. */
|
/* Ширина под строку качества: числовое поле + степперы + сброс. */
|
||||||
min-width: calc(var(--space-xxxl) * 9);
|
min-width: calc(var(--space-xxxl) * 9);
|
||||||
display: grid;
|
|
||||||
gap: var(--space-s);
|
gap: var(--space-s);
|
||||||
padding: var(--space-m);
|
padding: var(--space-m);
|
||||||
border: var(--size-border) solid var(--color-border);
|
border: var(--size-border) solid var(--color-border);
|
||||||
border-radius: var(--radius-m);
|
border-radius: var(--radius-m);
|
||||||
background: var(--color-panel);
|
background: var(--color-panel);
|
||||||
}
|
}
|
||||||
|
/* display задаётся только в открытом состоянии: авторский display
|
||||||
|
перебивает UA-правило [popover]:not(:popover-open) { display: none }. */
|
||||||
|
.menu:popover-open {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
.item {
|
.item {
|
||||||
padding: var(--space-m) var(--space-l);
|
padding: var(--space-m) var(--space-l);
|
||||||
border: var(--size-border) solid transparent;
|
border: var(--size-border) solid transparent;
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
resultKind?: ResultKind;
|
resultKind?: ResultKind;
|
||||||
result: PixelImage | null;
|
result: PixelImage | null;
|
||||||
resultNote?: ResultNote | null;
|
resultNote?: ResultNote | null;
|
||||||
|
warnings?: { label: string; detail?: string }[];
|
||||||
|
errors?: { label: string; detail?: string }[];
|
||||||
fileResult?: FileResult | null;
|
fileResult?: FileResult | null;
|
||||||
textResult?: string | null;
|
textResult?: string | null;
|
||||||
textVars?: Record<string, string | number>;
|
textVars?: Record<string, string | number>;
|
||||||
@@ -28,6 +30,8 @@
|
|||||||
resultKind = "image",
|
resultKind = "image",
|
||||||
result,
|
result,
|
||||||
resultNote = null,
|
resultNote = null,
|
||||||
|
warnings = [],
|
||||||
|
errors = [],
|
||||||
fileResult = null,
|
fileResult = null,
|
||||||
textResult = null,
|
textResult = null,
|
||||||
textVars = undefined,
|
textVars = undefined,
|
||||||
@@ -68,6 +72,8 @@
|
|||||||
dims={resultDims}
|
dims={resultDims}
|
||||||
note={resultNote ? t(resultNote.key) : undefined}
|
note={resultNote ? t(resultNote.key) : undefined}
|
||||||
noteTone={resultNote?.tone ?? "warning"}
|
noteTone={resultNote?.tone ?? "warning"}
|
||||||
|
{warnings}
|
||||||
|
{errors}
|
||||||
>
|
>
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
{#if ontogglemask}
|
{#if ontogglemask}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
textSource?: string;
|
textSource?: string;
|
||||||
running?: boolean;
|
running?: boolean;
|
||||||
fileCount?: number;
|
fileCount?: number;
|
||||||
|
warnings?: { label: string; detail?: string }[];
|
||||||
|
errors?: { label: string; detail?: string }[];
|
||||||
ontextinput?: (text: string) => void;
|
ontextinput?: (text: string) => void;
|
||||||
onrendertext?: () => void;
|
onrendertext?: () => void;
|
||||||
onupload?: (file: File) => void;
|
onupload?: (file: File) => void;
|
||||||
@@ -27,6 +29,8 @@
|
|||||||
textSource = "",
|
textSource = "",
|
||||||
running = false,
|
running = false,
|
||||||
fileCount = 0,
|
fileCount = 0,
|
||||||
|
warnings = [],
|
||||||
|
errors = [],
|
||||||
ontextinput,
|
ontextinput,
|
||||||
onrendertext,
|
onrendertext,
|
||||||
onupload,
|
onupload,
|
||||||
@@ -83,7 +87,13 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PreviewTile label={t("sourceCard.source")} viewMode={mode} dims={sourceDims}>
|
<PreviewTile
|
||||||
|
label={t("sourceCard.source")}
|
||||||
|
viewMode={mode}
|
||||||
|
dims={sourceDims}
|
||||||
|
{warnings}
|
||||||
|
{errors}
|
||||||
|
>
|
||||||
{#if mode === "text"}
|
{#if mode === "text"}
|
||||||
<div class="text-source-wrap">
|
<div class="text-source-wrap">
|
||||||
<SchemaTextSource
|
<SchemaTextSource
|
||||||
|
|||||||
@@ -9,9 +9,10 @@
|
|||||||
import type { PixelImage } from "$lib/core/types";
|
import type { PixelImage } from "$lib/core/types";
|
||||||
import {
|
import {
|
||||||
chainStepTitle,
|
chainStepTitle,
|
||||||
chainWarningText,
|
chainWarningNotice,
|
||||||
pageDescription,
|
pageDescription,
|
||||||
pageTitle,
|
pageTitle,
|
||||||
|
type WarningNotice,
|
||||||
} from "$lib/i18n/schema-tool-strings";
|
} from "$lib/i18n/schema-tool-strings";
|
||||||
import { t } from "$lib/i18n/t";
|
import { t } from "$lib/i18n/t";
|
||||||
import { moveStep, type ChainStep } from "$lib/pipeline.svelte";
|
import { moveStep, type ChainStep } from "$lib/pipeline.svelte";
|
||||||
@@ -32,6 +33,7 @@
|
|||||||
import { createOutputControls } from "./output-controls.svelte";
|
import { createOutputControls } from "./output-controls.svelte";
|
||||||
import { hasPreviewResult } from "./schema-preview-model";
|
import { hasPreviewResult } from "./schema-preview-model";
|
||||||
import { createSchemaToolRunner } from "./schema-tool-runner.svelte";
|
import { createSchemaToolRunner } from "./schema-tool-runner.svelte";
|
||||||
|
import type { DisplayError } from "./schema-tool-runner.svelte";
|
||||||
import {
|
import {
|
||||||
createSchemaToolState,
|
createSchemaToolState,
|
||||||
toolSchemaOf,
|
toolSchemaOf,
|
||||||
@@ -63,7 +65,7 @@
|
|||||||
setSteps: (v) => (stepState.steps = v),
|
setSteps: (v) => (stepState.steps = v),
|
||||||
touchedFor: (key) => stepState.touchedFor(key),
|
touchedFor: (key) => stepState.touchedFor(key),
|
||||||
reportError: (e) => runner.reportError(e),
|
reportError: (e) => runner.reportError(e),
|
||||||
clearError: () => (runner.displayError = null),
|
clearError: () => runner.clearSourceError(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const stepState = createSchemaToolState({
|
const stepState = createSchemaToolState({
|
||||||
@@ -122,8 +124,34 @@
|
|||||||
const stepResults = $derived(runner.stepResults);
|
const stepResults = $derived(runner.stepResults);
|
||||||
const stepFileSets = $derived(runner.stepFileSets);
|
const stepFileSets = $derived(runner.stepFileSets);
|
||||||
const runWarnings = $derived(runner.runWarnings);
|
const runWarnings = $derived(runner.runWarnings);
|
||||||
const displayError = $derived(runner.displayError);
|
// Feedback zones: source tile / step tiles / download button area.
|
||||||
const allWarnings = $derived([...upload.sourceWarnings, ...runWarnings]);
|
const sourceNotices = $derived(upload.sourceWarnings.map(chainWarningNotice));
|
||||||
|
const warningsByStep = $derived.by(() => {
|
||||||
|
const byStep: WarningNotice[][] = steps.map(() => []);
|
||||||
|
for (const w of runWarnings) {
|
||||||
|
if ("step" in w && w.step >= 1 && w.step <= byStep.length) {
|
||||||
|
byStep[w.step - 1].push(chainWarningNotice(w));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return byStep;
|
||||||
|
});
|
||||||
|
const downloadNotices = $derived(
|
||||||
|
runner.downloadWarnings.map(chainWarningNotice),
|
||||||
|
);
|
||||||
|
const stepError = $derived(runner.stepError);
|
||||||
|
const sourceError = $derived(runner.sourceError);
|
||||||
|
const actionError = $derived(runner.actionError);
|
||||||
|
|
||||||
|
function displayText(e: DisplayError | null): string {
|
||||||
|
if (!e) return "";
|
||||||
|
return e.kind === "i18n" ? t(e.key, e.vars) : e.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorBadge(e: DisplayError | null) {
|
||||||
|
return e
|
||||||
|
? [{ label: t("actions.actionFailed"), detail: displayText(e) }]
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
const masks = createStepMasks({
|
const masks = createStepMasks({
|
||||||
steps: () => steps,
|
steps: () => steps,
|
||||||
@@ -172,14 +200,6 @@
|
|||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
|
|
||||||
const errorText = $derived.by(() => {
|
|
||||||
const current = displayError;
|
|
||||||
if (!current) return "";
|
|
||||||
return current.kind === "i18n"
|
|
||||||
? t(current.key, current.vars)
|
|
||||||
: current.text;
|
|
||||||
});
|
|
||||||
|
|
||||||
const debouncedRun = debounce(() => run(), 200);
|
const debouncedRun = debounce(() => run(), 200);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -284,6 +304,8 @@
|
|||||||
textSource={upload.textSource}
|
textSource={upload.textSource}
|
||||||
running={busy}
|
running={busy}
|
||||||
fileCount={upload.sourceFiles.length}
|
fileCount={upload.sourceFiles.length}
|
||||||
|
warnings={sourceNotices}
|
||||||
|
errors={errorBadge(sourceError)}
|
||||||
ontextinput={(v) => (upload.textSource = v)}
|
ontextinput={(v) => (upload.textSource = v)}
|
||||||
onrendertext={run}
|
onrendertext={run}
|
||||||
onupload={upload.handleFile}
|
onupload={upload.handleFile}
|
||||||
@@ -316,6 +338,10 @@
|
|||||||
resultKind={displayResultKind}
|
resultKind={displayResultKind}
|
||||||
result={out}
|
result={out}
|
||||||
{resultNote}
|
{resultNote}
|
||||||
|
warnings={warningsByStep[i] ?? []}
|
||||||
|
errors={stepError && stepError.step === i
|
||||||
|
? [{ label: stepError.label, detail: stepError.detail }]
|
||||||
|
: []}
|
||||||
{fileResult}
|
{fileResult}
|
||||||
{textResult}
|
{textResult}
|
||||||
textVars={verdictVars}
|
textVars={verdictVars}
|
||||||
@@ -336,6 +362,10 @@
|
|||||||
viewMode="image"
|
viewMode="image"
|
||||||
dims={out ? `${out.width} × ${out.height}` : undefined}
|
dims={out ? `${out.width} × ${out.height}` : undefined}
|
||||||
parts={outSet.length > 1 ? outSet.length : undefined}
|
parts={outSet.length > 1 ? outSet.length : undefined}
|
||||||
|
warnings={warningsByStep[i] ?? []}
|
||||||
|
errors={stepError && stepError.step === i
|
||||||
|
? [{ label: stepError.label, detail: stepError.detail }]
|
||||||
|
: []}
|
||||||
>
|
>
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
{@render maskChip(i)}
|
{@render maskChip(i)}
|
||||||
@@ -376,6 +406,13 @@
|
|||||||
quality={output.quality}
|
quality={output.quality}
|
||||||
limitKb={output.limitKb}
|
limitKb={output.limitKb}
|
||||||
alphaLoss={output.alphaLoss}
|
alphaLoss={output.alphaLoss}
|
||||||
|
warnings={downloadNotices}
|
||||||
|
error={actionError
|
||||||
|
? {
|
||||||
|
label: t("actions.actionFailed"),
|
||||||
|
detail: displayText(actionError),
|
||||||
|
}
|
||||||
|
: null}
|
||||||
onupload={upload.handleFile}
|
onupload={upload.handleFile}
|
||||||
ondownload={download}
|
ondownload={download}
|
||||||
onformat={(v) => (output.format = v)}
|
onformat={(v) => (output.format = v)}
|
||||||
@@ -437,12 +474,6 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if errorText}
|
|
||||||
<p class="error" role="alert">{errorText}</p>
|
|
||||||
{/if}
|
|
||||||
{#each allWarnings as w (chainWarningText(w))}
|
|
||||||
<p class="warn" role="status">{chainWarningText(w)}</p>
|
|
||||||
{/each}
|
|
||||||
<StepReorderOverlay {steps} dnd={stepDnd} />
|
<StepReorderOverlay {steps} dnd={stepDnd} />
|
||||||
</DragDropProvider>
|
</DragDropProvider>
|
||||||
</div>
|
</div>
|
||||||
@@ -609,22 +640,12 @@
|
|||||||
border-radius: 0 0 var(--radius-m) var(--radius-m);
|
border-radius: 0 0 var(--radius-m) var(--radius-m);
|
||||||
background: var(--color-panel);
|
background: var(--color-panel);
|
||||||
}
|
}
|
||||||
.error {
|
|
||||||
margin: var(--space-l) 0 0;
|
|
||||||
color: var(--color-danger);
|
|
||||||
font: var(--font-size-s) var(--font-mono);
|
|
||||||
}
|
|
||||||
.empty {
|
.empty {
|
||||||
font: var(--font-size-s) var(--font-mono);
|
font: var(--font-size-s) var(--font-mono);
|
||||||
}
|
}
|
||||||
.no-schema {
|
.no-schema {
|
||||||
font: var(--font-size-s) var(--font-mono);
|
font: var(--font-size-s) var(--font-mono);
|
||||||
}
|
}
|
||||||
.warn {
|
|
||||||
margin: var(--space-s) 0 0;
|
|
||||||
color: var(--color-warning);
|
|
||||||
font: var(--font-size-s) var(--font-mono);
|
|
||||||
}
|
|
||||||
@media (--bp-tablet) {
|
@media (--bp-tablet) {
|
||||||
.workspace,
|
.workspace,
|
||||||
.workspace.aligned {
|
.workspace.aligned {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Icon from "$lib/components/ui/Icon.svelte";
|
import Icon from "$lib/components/ui/Icon.svelte";
|
||||||
import { onDismiss } from "$lib/components/ui/dismiss";
|
import { trackPopover } from "$lib/components/ui/popover-position";
|
||||||
import { pageTitle } from "$lib/i18n/schema-tool-strings";
|
import { pageTitle } from "$lib/i18n/schema-tool-strings";
|
||||||
import { chainablePages } from "$lib/pipeline.svelte";
|
import { chainablePages } from "$lib/pipeline.svelte";
|
||||||
import type { Page } from "$lib/registry";
|
import type { Page } from "$lib/registry";
|
||||||
@@ -15,31 +15,33 @@
|
|||||||
|
|
||||||
const pages = chainablePages();
|
const pages = chainablePages();
|
||||||
|
|
||||||
|
const menuId = $props.id();
|
||||||
let open = $state(false);
|
let open = $state(false);
|
||||||
let root = $state<HTMLDivElement | null>(null);
|
let btn = $state<HTMLButtonElement | null>(null);
|
||||||
|
let menu = $state<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
// While open, keep the popover placed next to the button (scroll/resize).
|
||||||
|
$effect(() => {
|
||||||
|
if (!open || !menu || !btn) return;
|
||||||
|
return trackPopover(menu, btn, "end");
|
||||||
|
});
|
||||||
|
|
||||||
function choose(page: Page) {
|
function choose(page: Page) {
|
||||||
onadd(page.steps[0].id);
|
onadd(page.steps[0].id);
|
||||||
open = false;
|
menu?.hidePopover();
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
return onDismiss(
|
|
||||||
() => root,
|
|
||||||
() => (open = false),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="tool-picker" bind:this={root}>
|
<div class="tool-picker">
|
||||||
<button
|
<button
|
||||||
|
bind:this={btn}
|
||||||
type="button"
|
type="button"
|
||||||
class={iconOnly ? "icon-btn" : "add-btn"}
|
class={iconOnly ? "icon-btn" : "add-btn"}
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
|
aria-controls={menuId}
|
||||||
aria-label={iconOnly ? label : undefined}
|
aria-label={iconOnly ? label : undefined}
|
||||||
onclick={() => (open = !open)}
|
popovertarget={menuId}
|
||||||
>
|
>
|
||||||
{#if iconOnly}
|
{#if iconOnly}
|
||||||
<Icon icon={Repeat} size={14} />
|
<Icon icon={Repeat} size={14} />
|
||||||
@@ -48,25 +50,29 @@
|
|||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{#if open}
|
<div
|
||||||
<div class="menu" role="menu">
|
bind:this={menu}
|
||||||
{#each pages as page (page.slug)}
|
id={menuId}
|
||||||
<button
|
class="menu"
|
||||||
type="button"
|
role="menu"
|
||||||
class="item"
|
popover="auto"
|
||||||
role="menuitem"
|
ontoggle={(e) => (open = e.newState === "open")}
|
||||||
onclick={() => choose(page)}
|
>
|
||||||
>
|
{#each pages as page (page.slug)}
|
||||||
{pageTitle(page)}
|
<button
|
||||||
</button>
|
type="button"
|
||||||
{/each}
|
class="item"
|
||||||
</div>
|
role="menuitem"
|
||||||
{/if}
|
onclick={() => choose(page)}
|
||||||
|
>
|
||||||
|
{pageTitle(page)}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.tool-picker {
|
.tool-picker {
|
||||||
position: relative;
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
.add-btn {
|
.add-btn {
|
||||||
@@ -99,20 +105,25 @@
|
|||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
.menu {
|
.menu {
|
||||||
position: absolute;
|
/* Native popover: top layer + light dismiss + Escape из коробки;
|
||||||
top: calc(100% + var(--space-s));
|
позиционирование — trackPopover (anchor() поддержан не везде). */
|
||||||
right: 0;
|
position: fixed;
|
||||||
z-index: var(--z-dropdown);
|
margin: 0;
|
||||||
|
translate: 0 var(--space-s);
|
||||||
min-width: calc(var(--space-xxxl) * 8);
|
min-width: calc(var(--space-xxxl) * 8);
|
||||||
max-height: calc(var(--space-xxxl) * 8);
|
max-height: calc(var(--space-xxxl) * 8);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
display: grid;
|
|
||||||
gap: var(--space-s);
|
gap: var(--space-s);
|
||||||
padding: var(--space-m);
|
padding: var(--space-m);
|
||||||
border: var(--size-border) solid var(--color-border);
|
border: var(--size-border) solid var(--color-border);
|
||||||
border-radius: var(--radius-m);
|
border-radius: var(--radius-m);
|
||||||
background: var(--color-panel);
|
background: var(--color-panel);
|
||||||
}
|
}
|
||||||
|
/* display задаётся только в открытом состоянии: авторский display
|
||||||
|
перебивает UA-правило [popover]:not(:popover-open) { display: none }. */
|
||||||
|
.menu:popover-open {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
.item {
|
.item {
|
||||||
padding: var(--space-m) var(--space-l);
|
padding: var(--space-m) var(--space-l);
|
||||||
border: var(--size-border) solid transparent;
|
border: var(--size-border) solid transparent;
|
||||||
|
|||||||
@@ -56,7 +56,14 @@ interface RunnerState {
|
|||||||
// Full named set per step (batch/fan-out); intermediate tiles show a grid.
|
// Full named set per step (batch/fan-out); intermediate tiles show a grid.
|
||||||
stepFileSets: ToolImageFile[][];
|
stepFileSets: ToolImageFile[][];
|
||||||
runWarnings: ChainWarning[];
|
runWarnings: ChainWarning[];
|
||||||
displayError: DisplayError | null;
|
// Size-limit feedback from the last download (limit unreachable etc.).
|
||||||
|
downloadWarnings: ChainWarning[];
|
||||||
|
// Chain step failure: badge lands on that step's tile (0-based index).
|
||||||
|
stepError: { step: number; label: string; detail: string } | null;
|
||||||
|
// Source decode/upload failures (via reportError): badge on the source tile.
|
||||||
|
sourceError: DisplayError | null;
|
||||||
|
// Download/copy and non-chain run failures: badge near the download button.
|
||||||
|
actionError: DisplayError | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toDisplayError(e: unknown): DisplayError {
|
function toDisplayError(e: unknown): DisplayError {
|
||||||
@@ -99,7 +106,8 @@ async function run(s: RunnerState, ctx: SchemaToolRunnerCtx) {
|
|||||||
const steps = ctx.steps();
|
const steps = ctx.steps();
|
||||||
if (!schema || steps.length === 0) return;
|
if (!schema || steps.length === 0) return;
|
||||||
const gen = ++s.runGen;
|
const gen = ++s.runGen;
|
||||||
s.displayError = null;
|
s.stepError = null;
|
||||||
|
s.actionError = null;
|
||||||
s.running = true;
|
s.running = true;
|
||||||
try {
|
try {
|
||||||
const source = ctx.source();
|
const source = ctx.source();
|
||||||
@@ -123,16 +131,17 @@ async function run(s: RunnerState, ctx: SchemaToolRunnerCtx) {
|
|||||||
if (e instanceof ChainStepError) {
|
if (e instanceof ChainStepError) {
|
||||||
const base = toDisplayError(e.cause);
|
const base = toDisplayError(e.cause);
|
||||||
const msg = base.kind === "i18n" ? t(base.key, base.vars) : base.text;
|
const msg = base.kind === "i18n" ? t(base.key, base.vars) : base.text;
|
||||||
s.displayError = {
|
s.stepError = {
|
||||||
kind: "plain",
|
step: e.stepIndex,
|
||||||
text: t("toolPage.stepError", {
|
label: t("chain.stepFailed", { n: e.stepIndex + 1 }),
|
||||||
|
detail: t("toolPage.stepError", {
|
||||||
n: e.stepIndex + 1,
|
n: e.stepIndex + 1,
|
||||||
title: chainStepTitle(e.toolId),
|
title: chainStepTitle(e.toolId),
|
||||||
msg,
|
msg,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
s.displayError = toDisplayError(e);
|
s.actionError = toDisplayError(e);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (gen === s.runGen) s.running = false;
|
if (gen === s.runGen) s.running = false;
|
||||||
@@ -142,7 +151,7 @@ async function run(s: RunnerState, ctx: SchemaToolRunnerCtx) {
|
|||||||
async function download(s: RunnerState, ctx: SchemaToolRunnerCtx) {
|
async function download(s: RunnerState, ctx: SchemaToolRunnerCtx) {
|
||||||
if (!ctx.schema()) return;
|
if (!ctx.schema()) return;
|
||||||
s.downloading = true;
|
s.downloading = true;
|
||||||
s.displayError = null;
|
s.actionError = null;
|
||||||
try {
|
try {
|
||||||
if (s.fileResult) {
|
if (s.fileResult) {
|
||||||
await downloadZip(s.fileResult.files, `${ctx.pageSlug()}.zip`);
|
await downloadZip(s.fileResult.files, `${ctx.pageSlug()}.zip`);
|
||||||
@@ -153,12 +162,31 @@ async function download(s: RunnerState, ctx: SchemaToolRunnerCtx) {
|
|||||||
const q = ctx.quality();
|
const q = ctx.quality();
|
||||||
const limit = ctx.limitKb();
|
const limit = ctx.limitKb();
|
||||||
const quality = q !== undefined ? q / 100 : undefined;
|
const quality = q !== undefined ? q / 100 : undefined;
|
||||||
const blob = limit
|
s.downloadWarnings = [];
|
||||||
? await fitWithinBytes(s.result, out.mime, limit * 1024, quality, encode)
|
if (limit) {
|
||||||
: await encode(s.result, out.mime, quality);
|
const fitted = await fitWithinBytes(
|
||||||
downloadBlob(blob, `${ctx.pageSlug()}.${out.ext}`);
|
s.result,
|
||||||
|
out.mime,
|
||||||
|
limit * 1024,
|
||||||
|
quality,
|
||||||
|
encode,
|
||||||
|
);
|
||||||
|
if (fitted.achievedBytes > limit * 1024) {
|
||||||
|
s.downloadWarnings = [
|
||||||
|
{
|
||||||
|
kind: "sizeLimitMiss",
|
||||||
|
limitKb: limit,
|
||||||
|
actualKb: Math.ceil(fitted.achievedBytes / 1024),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
downloadBlob(fitted.blob, `${ctx.pageSlug()}.${out.ext}`);
|
||||||
|
} else {
|
||||||
|
const blob = await encode(s.result, out.mime, quality);
|
||||||
|
downloadBlob(blob, `${ctx.pageSlug()}.${out.ext}`);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
s.displayError = toDisplayError(e);
|
s.actionError = toDisplayError(e);
|
||||||
} finally {
|
} finally {
|
||||||
s.downloading = false;
|
s.downloading = false;
|
||||||
}
|
}
|
||||||
@@ -174,7 +202,7 @@ async function copyText(s: RunnerState, ctx: SchemaToolRunnerCtx) {
|
|||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(copyValue);
|
await navigator.clipboard.writeText(copyValue);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
s.displayError = toDisplayError(e);
|
s.actionError = toDisplayError(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,7 +212,7 @@ async function downloadText(s: RunnerState, ctx: SchemaToolRunnerCtx) {
|
|||||||
const blob = new Blob([s.textResult], { type: "text/plain" });
|
const blob = new Blob([s.textResult], { type: "text/plain" });
|
||||||
downloadBlob(blob, `${ctx.pageSlug()}.txt`);
|
downloadBlob(blob, `${ctx.pageSlug()}.txt`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
s.displayError = toDisplayError(e);
|
s.actionError = toDisplayError(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,7 +221,9 @@ function clearResults(s: RunnerState) {
|
|||||||
s.fileResult = null;
|
s.fileResult = null;
|
||||||
s.textResult = null;
|
s.textResult = null;
|
||||||
s.verdictVars = undefined;
|
s.verdictVars = undefined;
|
||||||
s.displayError = null;
|
s.downloadWarnings = [];
|
||||||
|
s.stepError = null;
|
||||||
|
s.actionError = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execution state and actions of SchemaToolView: run/download/clipboard and
|
// Execution state and actions of SchemaToolView: run/download/clipboard and
|
||||||
@@ -211,7 +241,10 @@ export function createSchemaToolRunner(ctx: SchemaToolRunnerCtx) {
|
|||||||
stepResults: [],
|
stepResults: [],
|
||||||
stepFileSets: [],
|
stepFileSets: [],
|
||||||
runWarnings: [],
|
runWarnings: [],
|
||||||
displayError: null,
|
downloadWarnings: [],
|
||||||
|
stepError: null,
|
||||||
|
sourceError: null,
|
||||||
|
actionError: null,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
get running() {
|
get running() {
|
||||||
@@ -244,11 +277,20 @@ export function createSchemaToolRunner(ctx: SchemaToolRunnerCtx) {
|
|||||||
get runWarnings() {
|
get runWarnings() {
|
||||||
return s.runWarnings;
|
return s.runWarnings;
|
||||||
},
|
},
|
||||||
get displayError() {
|
get downloadWarnings() {
|
||||||
return s.displayError;
|
return s.downloadWarnings;
|
||||||
},
|
},
|
||||||
set displayError(v: DisplayError | null) {
|
get stepError() {
|
||||||
s.displayError = v;
|
return s.stepError;
|
||||||
|
},
|
||||||
|
get sourceError() {
|
||||||
|
return s.sourceError;
|
||||||
|
},
|
||||||
|
get actionError() {
|
||||||
|
return s.actionError;
|
||||||
|
},
|
||||||
|
clearSourceError: () => {
|
||||||
|
s.sourceError = null;
|
||||||
},
|
},
|
||||||
run: () => run(s, ctx),
|
run: () => run(s, ctx),
|
||||||
download: () => download(s, ctx),
|
download: () => download(s, ctx),
|
||||||
@@ -264,7 +306,7 @@ export function createSchemaToolRunner(ctx: SchemaToolRunnerCtx) {
|
|||||||
},
|
},
|
||||||
clearResults: () => clearResults(s),
|
clearResults: () => clearResults(s),
|
||||||
reportError: (e: unknown) => {
|
reportError: (e: unknown) => {
|
||||||
s.displayError = toDisplayError(e);
|
s.sourceError = toDisplayError(e);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from "$lib/i18n/t";
|
||||||
|
import Badge from "./Badge.svelte";
|
||||||
|
import { trackPopover } from "./popover-position";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
tone: "warning" | "danger";
|
||||||
|
label: string;
|
||||||
|
detail?: string;
|
||||||
|
role?: "alert" | "status";
|
||||||
|
}
|
||||||
|
let { tone, label, detail = undefined, role = "status" }: Props = $props();
|
||||||
|
|
||||||
|
const id = $props.id();
|
||||||
|
let open = $state(false);
|
||||||
|
let btn = $state<HTMLButtonElement | null>(null);
|
||||||
|
let popover = $state<HTMLSpanElement | null>(null);
|
||||||
|
|
||||||
|
// While open, keep the popover placed next to the "?" button.
|
||||||
|
$effect(() => {
|
||||||
|
if (!open || !popover || !btn) return;
|
||||||
|
return trackPopover(popover, btn, "start");
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span class="detail-badge">
|
||||||
|
<Badge {tone} {label} {role} />
|
||||||
|
{#if detail}
|
||||||
|
<button
|
||||||
|
bind:this={btn}
|
||||||
|
type="button"
|
||||||
|
class="more"
|
||||||
|
aria-label={t("ui.detailsAria")}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={id}
|
||||||
|
popovertarget={id}
|
||||||
|
>
|
||||||
|
?
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
bind:this={popover}
|
||||||
|
{id}
|
||||||
|
class="popover"
|
||||||
|
role="tooltip"
|
||||||
|
popover="auto"
|
||||||
|
ontoggle={(e) => (open = e.newState === "open")}
|
||||||
|
>
|
||||||
|
{detail}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.detail-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-s);
|
||||||
|
}
|
||||||
|
.more {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
/* Мини-размер цели в 24px: попадание пальцем без промаха. */
|
||||||
|
width: calc(var(--space-xxxl) * 0.66);
|
||||||
|
height: calc(var(--space-xxxl) * 0.66);
|
||||||
|
padding: 0;
|
||||||
|
border: var(--size-border) solid var(--color-border);
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
background: var(--color-panel);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font: var(--font-size-s) var(--font-mono);
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.more:hover,
|
||||||
|
.more:focus-visible {
|
||||||
|
color: var(--color-text);
|
||||||
|
border-color: var(--color-main);
|
||||||
|
}
|
||||||
|
/* Нативный popover живёт в top layer: light dismiss, Escape и раскладка
|
||||||
|
поверх всего — из коробки. Позиционирование — trackPopover: anchor()
|
||||||
|
поддержан не во всех браузерах. */
|
||||||
|
.popover {
|
||||||
|
position: fixed;
|
||||||
|
margin: 0;
|
||||||
|
translate: 0 var(--space-s);
|
||||||
|
width: max-content;
|
||||||
|
max-width: min(
|
||||||
|
calc(var(--space-xxxl) * 12),
|
||||||
|
calc(100vw - 2 * var(--space-xl))
|
||||||
|
);
|
||||||
|
padding: var(--space-m) var(--space-l);
|
||||||
|
border: var(--size-border-thick) solid var(--color-text-muted);
|
||||||
|
border-radius: var(--radius-m);
|
||||||
|
background: var(--color-background);
|
||||||
|
color: var(--color-text);
|
||||||
|
font: var(--font-size-s) var(--font-mono);
|
||||||
|
font-weight: normal;
|
||||||
|
text-align: left;
|
||||||
|
text-transform: none;
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
// Popover dismiss: close on pointerdown outside the root and on Escape.
|
|
||||||
// Subscribe inside `$effect` while the popover is open; returns unsubscribe.
|
|
||||||
export function onDismiss(
|
|
||||||
root: () => HTMLElement | null,
|
|
||||||
close: () => void,
|
|
||||||
): () => void {
|
|
||||||
function onPointerDown(e: PointerEvent) {
|
|
||||||
if (!root()?.contains(e.target as Node)) close();
|
|
||||||
}
|
|
||||||
function onKey(e: KeyboardEvent) {
|
|
||||||
if (e.key === "Escape") close();
|
|
||||||
}
|
|
||||||
window.addEventListener("pointerdown", onPointerDown);
|
|
||||||
window.addEventListener("keydown", onKey);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("pointerdown", onPointerDown);
|
|
||||||
window.removeEventListener("keydown", onKey);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// Position a top-layer popover next to its anchor button. The Popover API
|
||||||
|
// itself does not place the element; CSS anchor positioning is not available
|
||||||
|
// in every browser, so placement is computed from the anchor rect and kept
|
||||||
|
// in sync with scroll/resize while the popover stays open.
|
||||||
|
export function trackPopover(
|
||||||
|
popover: HTMLElement,
|
||||||
|
anchor: HTMLElement,
|
||||||
|
align: "start" | "end" = "end",
|
||||||
|
): () => void {
|
||||||
|
const place = () => {
|
||||||
|
const a = anchor.getBoundingClientRect();
|
||||||
|
// The UA stylesheet gives popovers inset: 0; without resetting it the
|
||||||
|
// over-constrained box (left: 0 vs our right) resolves to left: 0.
|
||||||
|
popover.style.inset = "auto";
|
||||||
|
popover.style.top = `${a.bottom}px`;
|
||||||
|
if (align === "end" || a.left + popover.offsetWidth > window.innerWidth) {
|
||||||
|
popover.style.right = `${window.innerWidth - a.right}px`;
|
||||||
|
} else {
|
||||||
|
popover.style.left = `${a.left}px`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
place();
|
||||||
|
window.addEventListener("scroll", place, true);
|
||||||
|
window.addEventListener("resize", place);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("scroll", place, true);
|
||||||
|
window.removeEventListener("resize", place);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,7 +3,16 @@ import {
|
|||||||
COMPRESSION_LEVELS,
|
COMPRESSION_LEVELS,
|
||||||
findMaxColorsWithin,
|
findMaxColorsWithin,
|
||||||
findQualityWithin,
|
findQualityWithin,
|
||||||
|
fitWithinBytes,
|
||||||
} from "./compress";
|
} from "./compress";
|
||||||
|
import { createPixelImage, type PixelImage } from "./types";
|
||||||
|
|
||||||
|
const img: PixelImage = createPixelImage(4, 4);
|
||||||
|
const blobOf = (size: number) => new Blob([new Uint8Array(size)]);
|
||||||
|
const encodeByQuality =
|
||||||
|
(sizeOf: (q: number) => number) =>
|
||||||
|
async (_img: PixelImage, _mime: string, quality?: number) =>
|
||||||
|
blobOf(sizeOf(Math.round((quality ?? 1) * 100)));
|
||||||
|
|
||||||
describe("COMPRESSION_LEVELS", () => {
|
describe("COMPRESSION_LEVELS", () => {
|
||||||
it("presets ordered by descending colors", () => {
|
it("presets ordered by descending colors", () => {
|
||||||
@@ -77,3 +86,31 @@ describe("findQualityWithin", () => {
|
|||||||
expect(calls).toBeLessThanOrEqual(10); // log2(99)+2 probes
|
expect(calls).toBeLessThanOrEqual(10); // log2(99)+2 probes
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("fitWithinBytes", () => {
|
||||||
|
it("bmp ignores the limit and reports the raw size", async () => {
|
||||||
|
const r = await fitWithinBytes(img, "image/bmp", 100, undefined, async () =>
|
||||||
|
blobOf(5000),
|
||||||
|
);
|
||||||
|
expect(r.achievedBytes).toBe(5000);
|
||||||
|
expect(r.achievedBytes).toBeGreaterThan(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the initial encode when it already fits", async () => {
|
||||||
|
const encode = encodeByQuality(() => 500);
|
||||||
|
const r = await fitWithinBytes(img, "image/jpeg", 1000, 0.8, encode);
|
||||||
|
expect(r.achievedBytes).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lossy: searches quality until the target fits", async () => {
|
||||||
|
const encode = encodeByQuality((q) => 500 + q * 50);
|
||||||
|
const r = await fitWithinBytes(img, "image/jpeg", 5000, 1, encode);
|
||||||
|
expect(r.achievedBytes).toBeLessThanOrEqual(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lossy: unreachable target reports achievedBytes above the limit", async () => {
|
||||||
|
const encode = encodeByQuality((q) => 500 + q * 50);
|
||||||
|
const r = await fitWithinBytes(img, "image/webp", 100, 1, encode);
|
||||||
|
expect(r.achievedBytes).toBeGreaterThan(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -91,6 +91,12 @@ export type EncodeFn = (
|
|||||||
quality?: number,
|
quality?: number,
|
||||||
) => Promise<Blob>;
|
) => Promise<Blob>;
|
||||||
|
|
||||||
|
export interface FitWithinBytesResult {
|
||||||
|
blob: Blob;
|
||||||
|
/** Size of the returned blob; > targetBytes means the limit was unreachable. */
|
||||||
|
achievedBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
/** Encodes img to fit targetBytes (quality search for lossy, color count for
|
/** Encodes img to fit targetBytes (quality search for lossy, color count for
|
||||||
* PNG; BMP as-is). `encodeFn` injected (core/io.encode): policy stays DOM-free. */
|
* PNG; BMP as-is). `encodeFn` injected (core/io.encode): policy stays DOM-free. */
|
||||||
export async function fitWithinBytes(
|
export async function fitWithinBytes(
|
||||||
@@ -99,18 +105,24 @@ export async function fitWithinBytes(
|
|||||||
targetBytes: number,
|
targetBytes: number,
|
||||||
quality: number | undefined,
|
quality: number | undefined,
|
||||||
encodeFn: EncodeFn,
|
encodeFn: EncodeFn,
|
||||||
): Promise<Blob> {
|
): Promise<FitWithinBytesResult> {
|
||||||
if (mime === "image/bmp") return encodeFn(img, mime);
|
if (mime === "image/bmp") {
|
||||||
|
const blob = await encodeFn(img, mime);
|
||||||
|
return { blob, achievedBytes: blob.size };
|
||||||
|
}
|
||||||
const initial = await encodeFn(img, mime, quality);
|
const initial = await encodeFn(img, mime, quality);
|
||||||
if (initial.size <= targetBytes) return initial;
|
if (initial.size <= targetBytes)
|
||||||
|
return { blob: initial, achievedBytes: initial.size };
|
||||||
if (mime === "image/png") {
|
if (mime === "image/png") {
|
||||||
const encodeSize = async (k: number) =>
|
const encodeSize = async (k: number) =>
|
||||||
(await encodeFn(quantizeImage(img, k).image, mime)).size;
|
(await encodeFn(quantizeImage(img, k).image, mime)).size;
|
||||||
const k = await findMaxColorsWithin(targetBytes, 256, encodeSize);
|
const k = await findMaxColorsWithin(targetBytes, 256, encodeSize);
|
||||||
return encodeFn(quantizeImage(img, k).image, mime);
|
const blob = await encodeFn(quantizeImage(img, k).image, mime);
|
||||||
|
return { blob, achievedBytes: blob.size };
|
||||||
}
|
}
|
||||||
const encodeSize = async (q: number) =>
|
const encodeSize = async (q: number) =>
|
||||||
(await encodeFn(img, mime, q / 100)).size;
|
(await encodeFn(img, mime, q / 100)).size;
|
||||||
const q = await findQualityWithin(targetBytes, encodeSize);
|
const q = await findQualityWithin(targetBytes, encodeSize);
|
||||||
return encodeFn(img, mime, q / 100);
|
const blob = await encodeFn(img, mime, q / 100);
|
||||||
|
return { blob, achievedBytes: blob.size };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,13 @@ export const en: Dict = {
|
|||||||
warnFirstOnly:
|
warnFirstOnly:
|
||||||
"Step {n}: analyze works on one image — showing the first of {total}",
|
"Step {n}: analyze works on one image — showing the first of {total}",
|
||||||
warnSourceSkip: "{skipped} of {total} files could not be read",
|
warnSourceSkip: "{skipped} of {total} files could not be read",
|
||||||
|
warnSizeLimitMiss:
|
||||||
|
"Could not fit into {limit} KB — downloaded file is {actual} KB",
|
||||||
|
stepFailed: "Step {n} failed",
|
||||||
|
warnPartialShort: "Some files failed",
|
||||||
|
warnFirstOnlyShort: "First file only",
|
||||||
|
warnSourceSkipShort: "Files skipped",
|
||||||
|
warnSizeLimitMissShort: "Over size limit",
|
||||||
},
|
},
|
||||||
savedChains: {
|
savedChains: {
|
||||||
pageTitle: "Saved pipelines — easy-png-tools",
|
pageTitle: "Saved pipelines — easy-png-tools",
|
||||||
@@ -139,6 +146,7 @@ export const en: Dict = {
|
|||||||
sizeLimitKb: "Max size, KB",
|
sizeLimitKb: "Max size, KB",
|
||||||
sizeLimitBmp:
|
sizeLimitBmp:
|
||||||
"Size limit does not apply to BMP — it is always uncompressed",
|
"Size limit does not apply to BMP — it is always uncompressed",
|
||||||
|
actionFailed: "Failed",
|
||||||
},
|
},
|
||||||
textSource: {
|
textSource: {
|
||||||
placeholder: "Paste base64 / hex / bytes…",
|
placeholder: "Paste base64 / hex / bytes…",
|
||||||
@@ -168,6 +176,7 @@ export const en: Dict = {
|
|||||||
nothingFound: "Nothing found — try another word.",
|
nothingFound: "Nothing found — try another word.",
|
||||||
},
|
},
|
||||||
ui: {
|
ui: {
|
||||||
|
detailsAria: "Show details",
|
||||||
showMask: "Show mask",
|
showMask: "Show mask",
|
||||||
decrease: "Decrease",
|
decrease: "Decrease",
|
||||||
increase: "Increase",
|
increase: "Increase",
|
||||||
|
|||||||
@@ -69,6 +69,13 @@ export const ru: Dict = {
|
|||||||
warnFirstOnly:
|
warnFirstOnly:
|
||||||
"Шаг {n}: анализ работает с одним изображением — показан первый из {total}",
|
"Шаг {n}: анализ работает с одним изображением — показан первый из {total}",
|
||||||
warnSourceSkip: "{skipped} из {total} файлов не удалось прочитать",
|
warnSourceSkip: "{skipped} из {total} файлов не удалось прочитать",
|
||||||
|
warnSizeLimitMiss:
|
||||||
|
"Не удалось ужаться до {limit} КБ — скачан файл на {actual} КБ",
|
||||||
|
stepFailed: "Шаг {n}: ошибка",
|
||||||
|
warnPartialShort: "Часть файлов с ошибкой",
|
||||||
|
warnFirstOnlyShort: "Только первый файл",
|
||||||
|
warnSourceSkipShort: "Файлы пропущены",
|
||||||
|
warnSizeLimitMissShort: "Больше лимита",
|
||||||
},
|
},
|
||||||
savedChains: {
|
savedChains: {
|
||||||
pageTitle: "Сохранённые пайплайны — easy-png-tools",
|
pageTitle: "Сохранённые пайплайны — easy-png-tools",
|
||||||
@@ -140,6 +147,7 @@ export const ru: Dict = {
|
|||||||
alphaLoss: "В результате есть прозрачность — {format} её уберёт",
|
alphaLoss: "В результате есть прозрачность — {format} её уберёт",
|
||||||
sizeLimitKb: "Лимит, КБ",
|
sizeLimitKb: "Лимит, КБ",
|
||||||
sizeLimitBmp: "Лимит размера не действует на BMP — он всегда несжатый",
|
sizeLimitBmp: "Лимит размера не действует на BMP — он всегда несжатый",
|
||||||
|
actionFailed: "Ошибка",
|
||||||
},
|
},
|
||||||
textSource: {
|
textSource: {
|
||||||
placeholder: "Вставьте base64 / hex / байты…",
|
placeholder: "Вставьте base64 / hex / байты…",
|
||||||
@@ -169,6 +177,7 @@ export const ru: Dict = {
|
|||||||
nothingFound: "Ничего не найдено — попробуйте другое слово.",
|
nothingFound: "Ничего не найдено — попробуйте другое слово.",
|
||||||
},
|
},
|
||||||
ui: {
|
ui: {
|
||||||
|
detailsAria: "Показать подробности",
|
||||||
showMask: "Показать маску",
|
showMask: "Показать маску",
|
||||||
decrease: "Уменьшить",
|
decrease: "Уменьшить",
|
||||||
increase: "Увеличить",
|
increase: "Увеличить",
|
||||||
|
|||||||
@@ -68,18 +68,41 @@ export function verdictTone(key: string): "success" | "danger" | "info" {
|
|||||||
return "info";
|
return "info";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Human-readable text for a chain run/source warning. */
|
/** Short label for a warning badge; detail holds the full message (popover). */
|
||||||
export function chainWarningText(w: ChainWarning): string {
|
export interface WarningNotice {
|
||||||
|
label: string;
|
||||||
|
detail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human-readable badge + popover text for a chain run/source warning. */
|
||||||
|
export function chainWarningNotice(w: ChainWarning): WarningNotice {
|
||||||
switch (w.kind) {
|
switch (w.kind) {
|
||||||
case "partial":
|
case "partial":
|
||||||
return t("chain.warnPartial", { n: w.step, ok: w.ok, total: w.total });
|
return {
|
||||||
|
label: t("chain.warnPartialShort"),
|
||||||
|
detail: t("chain.warnPartial", { n: w.step, ok: w.ok, total: w.total }),
|
||||||
|
};
|
||||||
case "firstOnly":
|
case "firstOnly":
|
||||||
return t("chain.warnFirstOnly", { n: w.step, total: w.total });
|
return {
|
||||||
|
label: t("chain.warnFirstOnlyShort"),
|
||||||
|
detail: t("chain.warnFirstOnly", { n: w.step, total: w.total }),
|
||||||
|
};
|
||||||
case "sourceSkip":
|
case "sourceSkip":
|
||||||
return t("chain.warnSourceSkip", {
|
return {
|
||||||
skipped: w.skipped,
|
label: t("chain.warnSourceSkipShort"),
|
||||||
total: w.total,
|
detail: t("chain.warnSourceSkip", {
|
||||||
});
|
skipped: w.skipped,
|
||||||
|
total: w.total,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
case "sizeLimitMiss":
|
||||||
|
return {
|
||||||
|
label: t("chain.warnSizeLimitMissShort"),
|
||||||
|
detail: t("chain.warnSizeLimitMiss", {
|
||||||
|
limit: w.limitKb,
|
||||||
|
actual: w.actualKb,
|
||||||
|
}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ export type ChainExecutor = (
|
|||||||
export type ChainWarning =
|
export type ChainWarning =
|
||||||
| { kind: "partial"; step: number; ok: number; total: number }
|
| { kind: "partial"; step: number; ok: number; total: number }
|
||||||
| { kind: "firstOnly"; step: number; total: number }
|
| { kind: "firstOnly"; step: number; total: number }
|
||||||
| { kind: "sourceSkip"; skipped: number; total: number };
|
| { kind: "sourceSkip"; skipped: number; total: number }
|
||||||
|
| { kind: "sizeLimitMiss"; limitKb: number; actualKb: number };
|
||||||
|
|
||||||
export class ChainStepError extends Error {
|
export class ChainStepError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
|
|||||||
Reference in New Issue
Block a user