Compare commits

..
6 Commits
21 changed files with 580 additions and 208 deletions
+10
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
-19
View File
@@ -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);
};
}
+37
View File
@@ -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);
});
});
+17 -5
View File
@@ -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 };
} }
+9
View File
@@ -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",
+9
View File
@@ -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: "Увеличить",
+31 -8
View File
@@ -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,
}),
};
} }
} }
+2 -1
View File
@@ -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(