Compare commits

...
12 Commits
36 changed files with 1281 additions and 275 deletions
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
id: generate_token id: generate_token
uses: actions/create-github-app-token@v1 uses: actions/create-github-app-token@v1
with: with:
app-id: ${{ secrets.XBOCT_BOT_ID }} app-id: ${{ vars.XBOCT_BOT_ID }}
private-key: ${{ secrets.XBOCT_BOT_PRIVATE_KEY }} private-key: ${{ secrets.XBOCT_BOT_PRIVATE_KEY }}
- name: Checkout - name: Checkout
+15
View File
@@ -2,6 +2,21 @@
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.4.0](https://github.com/Ku6epXBOCTuK/easy-png-tools/compare/83e75b5589ee200b6e216bc0187404b8be086149..v0.4.0) - 2026-10-07
#### Features
- add og description\image\etc - ([31ace35](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/31ace359d7ff187cbe9ba968facc498e75e66b3d)) - Ku6epXBOCTuK
- add socials icons - ([fbe6139](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/fbe6139585aeaca2b70b55224115c9e2b7653ca6)) - Ku6epXBOCTuK
- add favorites tools - ([2f11cb8](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/2f11cb86ade08b91f48c823f3354f62260bae262)) - Ku6epXBOCTuK
- add split to parts by tile size - ([32cb369](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/32cb369ca96a96fc0194a63d8086db610f3f190a)) - Ku6epXBOCTuK
- aligned mode for pipeline - ([b7be973](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/b7be973b54091b071e0c751533a73ad1d0ea11fb)) - Ku6epXBOCTuK
- change tool inplace - ([1ba03b7](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/1ba03b731d80212eedcf9d8985478dc78e2e32f4)) - Ku6epXBOCTuK
- collapse\expand steps in pipeline - ([5c58451](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/5c58451f2f5a0dde167d7b2bebcc8f3190f20212)) - Ku6epXBOCTuK
#### Bug Fixes
- select mode via segmented button in split by parts - ([676be38](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/676be383d9fb71b7dc4e83d8c243dc39c60f6c36)) - Ku6epXBOCTuK
- add text to no param tools - ([e7d3925](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e7d3925eb2063b0265a396bf693b4ed95b49782b)) - Ku6epXBOCTuK
- - -
## [v0.3.0](https://github.com/Ku6epXBOCTuK/easy-png-tools/compare/a9327eaa2757917fc1d58fb0a2989e98a0e1b07c..v0.3.0) - 2026-10-06 ## [v0.3.0](https://github.com/Ku6epXBOCTuK/easy-png-tools/compare/a9327eaa2757917fc1d58fb0a2989e98a0e1b07c..v0.3.0) - 2026-10-06
#### Features #### Features
- add info\warning badge, update size range workflow - ([92cc590](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/92cc5909594a8fe25f6cc70427b219e0dc22d774)) - Ku6epXBOCTuK - add info\warning badge, update size range workflow - ([92cc590](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/92cc5909594a8fe25f6cc70427b219e0dc22d774)) - Ku6epXBOCTuK
+23 -24
View File
@@ -15,14 +15,14 @@
страницы — возможно, надо указать все форматы (связано с SEO) страницы — возможно, надо указать все форматы (связано с SEO)
- [ ] кнопка «добавить инструмент» в pipeline — обдумать селектор с нормальным - [ ] кнопка «добавить инструмент» в pipeline — обдумать селектор с нормальным
поиском, фильтром возможных инструментов и т.д. поиском, фильтром возможных инструментов и т.д.
- [ ] посмотреть реализацию flip animation svelte для анимации переключения
режима aligned\normal
- [ ] переделать визуал toggle компонента (которым режим переключается)
- [ ] **Идея 1. Заменить инструмент шага без удаления** — оценка M. В заголовке - [ ] **Идея 1. Заменить инструмент шага без удаления** — оценка M. В заголовке
карточки шага кнопка «поменять инструмент»: заменяет инструмент на месте карточки шага кнопка «поменять инструмент»: заменяет инструмент на месте
(любой шаг, не только первый), остальные шаги и их параметры сохраняются, (любой шаг, не только первый), остальные шаги и их параметры сохраняются,
вход перечитывается. Открытый вопрос: что делать с несовместимыми входами вход перечитывается. Открытый вопрос: что делать с несовместимыми входами
(text-source ↔ file). (text-source ↔ file).
- [ ] **Идея 2. Сворачивать инструменты в chain** — оценка S. Тоггл сворачивания
звена до заголовка «Шаг n: название» (превью скрываются). Состояние
свёрнутости помнить в workspace-pipeline.
- [ ] **Идея 3. Несколько цепочек: сохранение и загрузка** — оценка M. - [ ] **Идея 3. Несколько цепочек: сохранение и загрузка** — оценка M.
Именованные цепочки в localStorage (список, создать/переименовать/ Именованные цепочки в localStorage (список, создать/переименовать/
удалить), быстрое переключение. Формат экспортного JSON расширить удалить), быстрое переключение. Формат экспортного JSON расширить
@@ -57,23 +57,14 @@
браузере, порядок файлов, поведение many → 1 → many в одной цепочке. браузере, порядок файлов, поведение many → 1 → many в одной цепочке.
Зависит от идеи 10 (остальные потребители 1 → many) и идеи 4 (batch по Зависит от идеи 10 (остальные потребители 1 → many) и идеи 4 (batch по
архиву), `docs/roadmap.md` фаза 8 «batch-страница». архиву), `docs/roadmap.md` фаза 8 «batch-страница».
- [ ] **Идея 17. Промежуточные результаты шагов + выравнивание рядов** — оценка
M. Два режима просмотра цепочки: - **без выравнивания (по умолчанию):** в
панели просмотра цепочка изображений по 2 в колонку «исходник → результат
1 → результат 2 → … → конечный результат»; - **с выравниванием (галочка
«выровнять инструменты и просмотр»):** у каждого инструмента показаны его
исходник и результат построчно «параметры — исходник — результат»;
промежуточные картинки при этом осознанно дублируются на экране —
результат шага является исходником следующего.
## Инструменты ## Инструменты
- [ ] remove-alpha-channel - дубль инструмента с выбором цвета на замену, что с
такими делать вообще? оставить как seo? можно ли под капотом использовать
другой инструмент с хардкодед настройками?
- [ ] qr code to text, text to qr code - [ ] qr code to text, text to qr code
- [ ] bar code to text, text to bar code - [ ] bar code to text, text to bar code
- [ ] split-into-parts-png — режим «по размеру тайла»: указываешь размер тайла
(16×16, 32×32…, список `SIZE_PRESETS`), инструмент сам считает сетку —
сценарий «режу спрайтшит, знаю размер тайла, не знаю сетку» (новый режим:
схема + core, из задачи C3)
- [ ] нужен инструмент для удаления фона по выбранной точке - удалять внутри - [ ] нужен инструмент для удаления фона по выбранной точке - удалять внутри
выделенной области выделенной области
- [ ] сделать конвертацию в смайлики\награды для твитча (6 размеров или выбор - [ ] сделать конвертацию в смайлики\награды для твитча (6 размеров или выбор
@@ -144,11 +135,13 @@
## UI и качество ## UI и качество
- [ ] мёртвый i18n-ключ `noParams` («нет параметров — результат готов»): его - [ ] split to parts - посмотреть вариант с toggle component - может будет
ничем не читают. Либо показывать подсказку для инструментов с пустой удобнее чем sectioned buttons
схемой (FA-E1), либо удалить ключ из обеих локалей
- [ ] неудобно настраивать ползунками, некоторые инструменты удобнее настроить - [ ] неудобно настраивать ползунками, некоторые инструменты удобнее настроить
через drag-n-drop. звучит сложно, но надо придумать процесс через drag-n-drop. звучит сложно, но надо придумать процесс
- [ ] fav: текущий ui слишком дерганный — при клике на звёздочку элементы
мгновенно пересортировываются и под мышкой оказывается другой элемент.
Нужна или анимация, или механизм отложенного обновления позиций
- [ ] просмотр картинки в оригинальном размере, и возможно с зумом отдельных - [ ] просмотр картинки в оригинальном размере, и возможно с зумом отдельных
областей, чтобы просмотреть мелкие артефакты, выбор разных подложек - областей, чтобы просмотреть мелкие артефакты, выбор разных подложек -
черная\белая\цветные\серая для проверки прозрачных картинок черная\белая\цветные\серая для проверки прозрачных картинок
@@ -185,11 +178,6 @@
решения: node Vitest не имеет DOM/canvas, а e2e уже покрывает поведение. решения: node Vitest не имеет DOM/canvas, а e2e уже покрывает поведение.
- [ ] Ошибка декодирования изображения из `createImageBitmap` пока показывается - [ ] Ошибка декодирования изображения из `createImageBitmap` пока показывается
сырым сообщением браузера; нужен `ToolError`-маппер и i18n-ключ. сырым сообщением браузера; нужен `ToolError`-маппер и i18n-ключ.
- [ ] **Идея 5. Избранные инструменты (fav tools)** — оценка S/M.
Тоггл-звёздочка на карточках каталога, результатах поиска и в шапке
страницы инструмента; список slug в localStorage. Избранное показывается
отдельной секцией сверху каталога и поднимается в выдаче поиска (бонус к
popularity при скоринге).
- [ ] **Идея 13. Контраст текста на акцентных кнопках — частично закрыт**. Тона - [ ] **Идея 13. Контраст текста на акцентных кнопках — частично закрыт**. Тона
`--color-main`/`--color-accent` подняты под текущий порог `--color-main`/`--color-accent` подняты под текущий порог
`palette.test.ts`; targeted-тест проходит. Light-тема всё ещё ниже AA 4.5 `palette.test.ts`; targeted-тест проходит. Light-тема всё ещё ниже AA 4.5
@@ -239,7 +227,15 @@
переименований уже опубликованных страниц; клонов с canonical не делать переименований уже опубликованных страниц; клонов с canonical не делать
(`docs/plan-seo.md` §5) (`docs/plan-seo.md` §5)
- [ ] sitemap.xml из реестра + `Sitemap:` в robots.txt (S1b/S1c) - [ ] sitemap.xml из реестра + `Sitemap:` в robots.txt (S1b/S1c)
- [ ] Seo.svelte: canonical + OG-теги, per-tool OG-картинки (S1d/S1e) - [ ] Seo.svelte: per-tool OG-картинки (S1e) — у каждой страницы инструмента
своя превью-карточка 1200×630 для соцсетей/мессенджеров: имя инструмента,
бренд, иконка. Сейчас все страницы ссылаются на общую
`static/og/default.png` (генерирует `pnpm --dir web gen:og` —
Playwright-скриншот HTML-карточки в `web/scripts/gen-og.mjs`). Что делать:
расширить gen-og.mjs — проход по реестру страниц, карточка с названием
инструмента в `static/og/[slug].png`, в `Seo.svelte` подставлять og-URL по
slug страницы. Критерий приёмки: у `/tools/flip-png` в og:image своя
картинка с названием инструмента, файл лежит в `static/og/`
- [ ] контент страницы инструмента: seo-блок в словарях, how-to/FAQ/related (S2) - [ ] контент страницы инструмента: seo-блок в словарях, how-to/FAQ/related (S2)
- [ ] JSON-LD (SoftwareApplication/FAQPage/Breadcrumb) + llms.txt (S3) - [ ] JSON-LD (SoftwareApplication/FAQPage/Breadcrumb) + llms.txt (S3)
- [ ] дистрибуция: закрепить репо, alternative.to, Product Hunt, Reddit/HN (S4c) - [ ] дистрибуция: закрепить репо, alternative.to, Product Hunt, Reddit/HN (S4c)
@@ -250,6 +246,9 @@
## Платформа (API/PWA) ## Платформа (API/PWA)
- [ ] Vercel: переезд хостинга с GitHub Pages + глобальная статистика
популярности инструментов через Vercel KV (serverless-функция: INCR при
открытии инструмента, чтение топа для скоринга поиска).
- [ ] PWA: manifest + service worker, оффлайн-режим, установка (после S1) - [ ] PWA: manifest + service worker, оффлайн-режим, установка (после S1)
- [ ] API: спайк серверного ядра (нативный Rust vs edge-wasm), ключи, free tier - [ ] API: спайк серверного ядра (нативный Rust vs edge-wasm), ключи, free tier
(после wasm-фаз 5–7) (после wasm-фаз 5–7)
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "easy-png-tools", "name": "easy-png-tools",
"version": "0.3.0", "version": "0.4.0",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
+4 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "web", "name": "web",
"private": true, "private": true,
"version": "0.3.0", "version": "0.4.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
@@ -14,6 +14,7 @@
"test:coverage": "vitest run --coverage", "test:coverage": "vitest run --coverage",
"test:rules": "vitest run eslint-plugins/__tests__", "test:rules": "vitest run eslint-plugins/__tests__",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"gen:og": "node scripts/gen-og.mjs",
"format": "prettier --write . --log-level warn --cache", "format": "prettier --write . --log-level warn --cache",
"format:check": "prettier --check . --log-level warn --cache", "format:check": "prettier --check . --log-level warn --cache",
"lint": "eslint .", "lint": "eslint .",
@@ -54,6 +55,7 @@
"@fontsource/ibm-plex-mono": "^5.3.0", "@fontsource/ibm-plex-mono": "^5.3.0",
"@fontsource/ibm-plex-sans": "^5.3.0", "@fontsource/ibm-plex-sans": "^5.3.0",
"@lucide/svelte": "^1.33.0", "@lucide/svelte": "^1.33.0",
"fflate": "^0.8.3" "fflate": "^0.8.3",
"simple-icons": "^16.34.0"
} }
} }
+9
View File
@@ -20,6 +20,9 @@ importers:
fflate: fflate:
specifier: ^0.8.3 specifier: ^0.8.3
version: 0.8.3 version: 0.8.3
simple-icons:
specifier: ^16.34.0
version: 16.34.0
devDependencies: devDependencies:
'@csstools/postcss-global-data': '@csstools/postcss-global-data':
specifier: ^4.0.0 specifier: ^4.0.0
@@ -1387,6 +1390,10 @@ packages:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'} engines: {node: '>=14'}
simple-icons@16.34.0:
resolution: {integrity: sha512-UTxKn0PVspk84B2hHF3Dqiqht1yDjJmDc/t4cO7PyCQxCMk6OjEu95pnpGjhLaGeQ4qBrGASbefTIe/+czrX7g==}
engines: {node: '>=0.12.18'}
sirv@3.0.2: sirv@3.0.2:
resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -2805,6 +2812,8 @@ snapshots:
signal-exit@4.1.0: {} signal-exit@4.1.0: {}
simple-icons@16.34.0: {}
sirv@3.0.2: sirv@3.0.2:
dependencies: dependencies:
'@polka/url': 1.0.0-next.29 '@polka/url': 1.0.0-next.29
+56
View File
@@ -0,0 +1,56 @@
// Генерирует static/og/default.png — дефолтная OG-карточка 1200×630.
// Запуск: pnpm --dir web gen:og. Карточка рисуется в браузере (текст, иконки),
// per-tool карточки — этим же harness позже (docs/plan-seo.md S1e).
import { mkdirSync } from "node:fs";
import { chromium } from "playwright";
const W = 1200;
const H = 630;
const IMAGE_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>`;
function cardHtml() {
return `<!doctype html><html><body style="margin:0">
<div style="
width:${W}px;height:${H}px;box-sizing:border-box;
background:#0a120c;
background-image:linear-gradient(#12281a 1px, transparent 1px),linear-gradient(90deg,#12281a 1px, transparent 1px);
background-size:40px 40px;
display:flex;align-items:center;justify-content:space-between;
padding:80px;font-family:ui-monospace,'Cascadia Mono',Consolas,monospace;
border:3px solid #16a34a;
">
<div>
<div style="
width:72px;height:72px;background:#86efac;color:#052e16;
display:flex;align-items:center;justify-content:center;
font-size:34px;font-weight:700;margin-bottom:32px;
">EP</div>
<div style="color:#86efac;font-size:56px;font-weight:700;letter-spacing:-1px;">easy-png-tools</div>
<div style="color:#a7c4b1;font-size:28px;margin-top:24px;">PNG utilities right in your browser</div>
<div style="color:#4d7c5f;font-size:22px;margin-top:16px;">Convert · Crop · Resize — locally, no uploads</div>
</div>
<div style="
color:#86efac;width:300px;height:300px;border:3px solid #16a34a;
background:#0f1f14;display:flex;align-items:center;justify-content:center;
"><div style="width:240px;height:240px;">${IMAGE_ICON}</div></div>
</div>
</body></html>`;
}
mkdirSync(new URL("../static/og/", import.meta.url), { recursive: true });
const browser = await chromium.launch();
try {
const page = await browser.newPage({ viewport: { width: W, height: H } });
await page.setContent(cardHtml());
await page.screenshot({
path: new URL("../static/og/default.png", import.meta.url).pathname.replace(
/^\/([A-Za-z]:)/,
"$1",
),
});
console.log(`og/default.png written (${W}x${H})`);
} finally {
await browser.close();
}
+30
View File
@@ -0,0 +1,30 @@
<script lang="ts">
import { SITE_URL } from "$lib/seo";
interface Props {
title: string;
description: string;
/** Путь страницы от корня роутера ("/", "/list-tools", "/tools/…"). */
path: string;
}
let { title, description, path }: Props = $props();
const url = $derived(`${SITE_URL}${path}`);
const image = `${SITE_URL}/og/default.png`;
</script>
<svelte:head>
<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href={url} />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="easy-png-tools" />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={url} />
<meta property="og:image" content={image} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={image} />
</svelte:head>
+31 -2
View File
@@ -1,8 +1,10 @@
<script lang="ts"> <script lang="ts">
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
import { ArrowUpRight } from "@lucide/svelte"; import { ArrowUpRight, Star } from "@lucide/svelte";
import type { Component } from "svelte"; import type { Component } from "svelte";
import Icon from "$lib/components/ui/Icon.svelte"; import Icon from "$lib/components/ui/Icon.svelte";
import { isFav, toggleFav } from "$lib/favorites.svelte";
import { t } from "$lib/i18n/t";
interface Props { interface Props {
title: string; title: string;
@@ -20,6 +22,20 @@
<strong>{title}</strong> <strong>{title}</strong>
{#if description}<span>{description}</span>{/if} {#if description}<span>{description}</span>{/if}
</span> </span>
<button
type="button"
class="tool-fav"
class:active={isFav(slug)}
aria-label={isFav(slug) ? t("ui.removeFav") : t("ui.addFav")}
aria-pressed={isFav(slug)}
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleFav(slug);
}}
>
<Star size={15} fill={isFav(slug) ? "currentColor" : "none"} />
</button>
<span class="tool-index"> <span class="tool-index">
{index !== undefined ? index.toString().padStart(2, "0") : ""} {index !== undefined ? index.toString().padStart(2, "0") : ""}
</span> </span>
@@ -31,7 +47,7 @@
display: grid; display: grid;
grid-template-columns: grid-template-columns:
var(--size-tool-icon) minmax(0, 1fr) var(--space-xxl) var(--size-tool-icon) minmax(0, 1fr) var(--space-xxl)
var(--size-tool-arrow); var(--space-xxl) var(--size-tool-arrow);
align-items: center; align-items: center;
gap: var(--space-l); gap: var(--space-l);
min-height: var(--size-tool-min-height); min-height: var(--size-tool-min-height);
@@ -54,6 +70,19 @@
color: var(--color-main); color: var(--color-main);
border-radius: var(--radius-m); border-radius: var(--radius-m);
} }
.tool-fav {
display: grid;
place-items: center;
padding: var(--space-s);
border: none;
background: none;
color: var(--color-text-muted);
cursor: pointer;
}
.tool-fav:hover,
.tool-fav.active {
color: var(--color-warning);
}
.tool-copy { .tool-copy {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+15 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { GripVertical, X } from "@lucide/svelte"; import { ChevronDown, ChevronRight, GripVertical, X } from "@lucide/svelte";
import IconButton from "$lib/components/ui/IconButton.svelte"; import IconButton from "$lib/components/ui/IconButton.svelte";
import { t } from "$lib/i18n/t"; import { t } from "$lib/i18n/t";
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
@@ -9,9 +9,11 @@
title?: string; title?: string;
type?: string; type?: string;
draggable?: boolean; draggable?: boolean;
collapsed?: boolean;
tools?: Snippet; tools?: Snippet;
children?: Snippet; children?: Snippet;
onremove?: () => void; onremove?: () => void;
ontoggle?: () => void;
ondragstart?: (e: DragEvent) => void; ondragstart?: (e: DragEvent) => void;
ondragover?: (e: DragEvent) => void; ondragover?: (e: DragEvent) => void;
ondrop?: (e: DragEvent) => void; ondrop?: (e: DragEvent) => void;
@@ -23,9 +25,11 @@
title, title,
type, type,
draggable = false, draggable = false,
collapsed = false,
tools, tools,
children, children,
onremove, onremove,
ontoggle,
ondragstart, ondragstart,
ondragover, ondragover,
ondrop, ondrop,
@@ -54,6 +58,15 @@
</div> </div>
<div class="step-tools"> <div class="step-tools">
{#if tools}{@render tools()}{/if} {#if tools}{@render tools()}{/if}
{#if ontoggle}
<IconButton
icon={collapsed ? ChevronRight : ChevronDown}
label={collapsed ? t("chain.expandStep") : t("chain.collapseStep")}
variant="clear"
size="s"
onclick={() => ontoggle()}
/>
{/if}
{#if onremove} {#if onremove}
<IconButton <IconButton
icon={X} icon={X}
@@ -65,7 +78,7 @@
{/if} {/if}
</div> </div>
</header> </header>
{#if children} {#if children && !collapsed}
<div class="step-body"> <div class="step-body">
{@render children()} {@render children()}
</div> </div>
@@ -0,0 +1,33 @@
<script lang="ts">
import Control from "./Control.svelte";
import Segmented from "$lib/components/ui/Segmented.svelte";
import { optionLabel } from "$lib/i18n/schema-tool-strings";
import type { FieldSpec, SegmentedSpec } from "$lib/registry-schema";
interface Props {
label: string;
value: unknown;
spec: FieldSpec;
toolId: string;
fieldId: string;
onchange?: (value: string) => void;
}
let { label, value, spec, toolId, fieldId, onchange }: Props = $props();
const sp = $derived(spec as SegmentedSpec);
const current = $derived(
typeof value === "string" && sp.options.some((o) => o.value === value)
? value
: sp.default,
);
const options = $derived(
sp.options.map((o) => ({
value: o.value,
label: optionLabel(toolId, fieldId, o.value, o.label),
})),
);
</script>
<Control {label}>
<Segmented {options} value={current} onchange={(v) => onchange?.(v)} />
</Control>
@@ -1,7 +1,10 @@
<script lang="ts"> <script lang="ts">
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
import BrandIcon from "$lib/components/ui/BrandIcon.svelte";
import LangToggle from "$lib/components/ui/LangToggle.svelte"; import LangToggle from "$lib/components/ui/LangToggle.svelte";
import ThemeToggle from "$lib/components/ui/ThemeToggle.svelte"; import ThemeToggle from "$lib/components/ui/ThemeToggle.svelte";
import { t } from "$lib/i18n/t";
import { siGithub, siTelegram, siTwitch } from "simple-icons";
import TopNav from "./TopNav.svelte"; import TopNav from "./TopNav.svelte";
interface Props { interface Props {
@@ -23,6 +26,33 @@
</a> </a>
<TopNav /> <TopNav />
<div class="top-actions"> <div class="top-actions">
<a
class="social"
href="https://github.com/Ku6epXBOCTuK/easy-png-tools"
target="_blank"
rel="noopener noreferrer"
aria-label={t("ui.github")}
>
<BrandIcon path={siGithub.path} size={16} />
</a>
<a
class="social"
href="https://t.me/Ku6epXBOCTuK_feed"
target="_blank"
rel="noopener noreferrer"
aria-label={t("ui.telegram")}
>
<BrandIcon path={siTelegram.path} size={16} />
</a>
<a
class="social"
href="https://www.twitch.tv/ku6epxboctuk"
target="_blank"
rel="noopener noreferrer"
aria-label={t("ui.twitch")}
>
<BrandIcon path={siTwitch.path} size={16} />
</a>
<ThemeToggle {theme} {ontoggle} /> <ThemeToggle {theme} {ontoggle} />
<LangToggle /> <LangToggle />
</div> </div>
@@ -64,4 +94,12 @@
align-items: center; align-items: center;
gap: var(--space-xl); gap: var(--space-xl);
} }
.social {
display: inline-flex;
align-items: center;
color: var(--color-text-muted);
}
.social:hover {
color: var(--color-text);
}
</style> </style>
@@ -1,123 +1,21 @@
<script lang="ts"> <script lang="ts">
import Icon from "$lib/components/ui/Icon.svelte";
import { pageTitle } from "$lib/i18n/schema-tool-strings";
import { t } from "$lib/i18n/t"; import { t } from "$lib/i18n/t";
import { chainablePages } from "$lib/pipeline.svelte"; import ToolPickerButton from "./ToolPickerButton.svelte";
import type { Page } from "$lib/registry";
import { Plus } from "@lucide/svelte";
interface Props { interface Props {
onadd: (toolId: string) => void; onadd: (toolId: string) => void;
} }
let { onadd }: Props = $props(); let { onadd }: Props = $props();
const pages = chainablePages();
let open = $state(false);
let root = $state<HTMLDivElement | null>(null);
function choose(page: Page) {
onadd(page.steps[0].id);
open = false;
}
$effect(() => {
if (!open) return;
function onPointerDown(e: PointerEvent) {
if (!root?.contains(e.target as Node)) open = false;
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") open = false;
}
window.addEventListener("pointerdown", onPointerDown);
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("keydown", onKey);
};
});
</script> </script>
<div class="add-step" bind:this={root}> <div class="add-step">
<button <ToolPickerButton label={t("chain.addStep")} {onadd} />
type="button"
class="add-btn"
aria-haspopup="menu"
aria-expanded={open}
onclick={() => (open = !open)}
>
<Icon icon={Plus} size={14} />
<span>{t("chain.addStep")}</span>
</button>
{#if open}
<div class="menu" role="menu">
{#each pages as page (page.slug)}
<button
type="button"
class="item"
role="menuitem"
onclick={() => choose(page)}
>
{pageTitle(page)}
</button>
{/each}
</div>
{/if}
</div> </div>
<style> <style>
.add-step { .add-step {
position: relative;
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
margin: var(--space-s) 0; margin: var(--space-s) 0;
} }
.add-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-m);
padding: var(--space-m) var(--space-l);
border: var(--size-border) solid var(--color-main);
border-radius: var(--radius-m);
background: var(--color-main);
color: var(--color-background);
font: 600 var(--font-size-s) var(--font-mono);
cursor: pointer;
}
.add-btn:hover {
filter: brightness(1.1);
}
.menu {
position: absolute;
top: calc(100% + var(--space-s));
right: 0;
z-index: var(--z-dropdown);
min-width: calc(var(--space-xxxl) * 8);
max-height: calc(var(--space-xxxl) * 8);
overflow-y: auto;
display: grid;
gap: var(--space-s);
padding: var(--space-m);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-m);
background: var(--color-panel);
}
.item {
padding: var(--space-m) var(--space-l);
border: var(--size-border) solid transparent;
border-radius: var(--radius-s);
background: transparent;
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
text-align: left;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item:hover {
color: var(--color-text);
border-color: var(--color-border);
}
</style> </style>
@@ -18,6 +18,7 @@
import ColorsControl from "$lib/components/fields/schema/ColorsControl.svelte"; import ColorsControl from "$lib/components/fields/schema/ColorsControl.svelte";
import RangeControl from "$lib/components/fields/schema/RangeControl.svelte"; import RangeControl from "$lib/components/fields/schema/RangeControl.svelte";
import SelectControl from "$lib/components/fields/schema/SelectControl.svelte"; import SelectControl from "$lib/components/fields/schema/SelectControl.svelte";
import SegmentedControl from "$lib/components/fields/schema/SegmentedControl.svelte";
import TextControl from "$lib/components/fields/schema/TextControl.svelte"; import TextControl from "$lib/components/fields/schema/TextControl.svelte";
import OffsetControl from "$lib/components/fields/schema/OffsetControl.svelte"; import OffsetControl from "$lib/components/fields/schema/OffsetControl.svelte";
import PositionControl from "$lib/components/fields/schema/PositionControl.svelte"; import PositionControl from "$lib/components/fields/schema/PositionControl.svelte";
@@ -42,6 +43,7 @@
"color-pair": ColorPairControl, "color-pair": ColorPairControl,
colors: ColorsControl, colors: ColorsControl,
select: SelectControl, select: SelectControl,
segmented: SegmentedControl,
checkbox: CheckboxControl, checkbox: CheckboxControl,
text: TextControl, text: TextControl,
dimension: DimensionField, dimension: DimensionField,
@@ -75,6 +77,7 @@
}: Props = $props(); }: Props = $props();
const layoutGroups = $derived(resolveLayoutGroups(schema)); const layoutGroups = $derived(resolveLayoutGroups(schema));
const isEmpty = $derived(Object.keys(schema.fields).length === 0);
function effectiveSpec(spec: FieldSpec): FieldSpec { function effectiveSpec(spec: FieldSpec): FieldSpec {
if (sourceDims && spec.kind === "number" && spec.maxFromSource) { if (sourceDims && spec.kind === "number" && spec.maxFromSource) {
@@ -82,8 +85,16 @@
} }
return spec; return spec;
} }
function isHidden(spec: FieldSpec): boolean {
const rule = spec.visibleWhen;
return rule !== undefined && values[rule.field] !== rule.equals;
}
</script> </script>
{#if isEmpty}
<p class="no-params">{t("paramsCard.noParams")}</p>
{:else}
{#each layoutGroups as group (group.key)} {#each layoutGroups as group (group.key)}
{#if group.title} {#if group.title}
<span class="group-title">{groupLabel(group.title)}</span> <span class="group-title">{groupLabel(group.title)}</span>
@@ -95,16 +106,19 @@
: undefined} : undefined}
> >
{#each group.fields as id (id)} {#each group.fields as id (id)}
{@const Control = FIELDS[schema.fields[id].spec.kind]} {@const spec = schema.fields[id].spec}
{@const Control = FIELDS[spec.kind]}
{#if !isHidden(spec)}
<Control <Control
label={fieldLabel(schema.fields[id], id)} label={fieldLabel(schema.fields[id], id)}
value={values[id]} value={values[id]}
spec={effectiveSpec(schema.fields[id].spec)} spec={effectiveSpec(spec)}
{toolId} {toolId}
fieldId={id} fieldId={id}
{sourceDims} {sourceDims}
onchange={(v, axis) => onchange(id, v, axis)} onchange={(v, axis) => onchange(id, v, axis)}
/> />
{/if}
{/each} {/each}
</div> </div>
{/each} {/each}
@@ -118,6 +132,7 @@
onclick={onreset} onclick={onreset}
/> />
</div> </div>
{/if}
<style> <style>
.group-title { .group-title {
@@ -132,6 +147,11 @@
.group-title:first-child { .group-title:first-child {
margin-top: 0; margin-top: 0;
} }
.no-params {
margin: 0;
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
}
.group-fields { .group-fields {
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -3,6 +3,9 @@
import SchemaActions from "./SchemaActions.svelte"; import SchemaActions from "./SchemaActions.svelte";
import SchemaResultTile from "./SchemaResultTile.svelte"; import SchemaResultTile from "./SchemaResultTile.svelte";
import SchemaSourceTile from "./SchemaSourceTile.svelte"; import SchemaSourceTile from "./SchemaSourceTile.svelte";
import PreviewTile from "./PreviewTile.svelte";
import Toggle from "$lib/components/ui/Toggle.svelte";
import { toDataUrl } from "$lib/core/io";
import type { PixelImage } from "$lib/core/types"; import type { PixelImage } from "$lib/core/types";
import type { OutputMime } from "$lib/core/io"; import type { OutputMime } from "$lib/core/io";
import { t } from "$lib/i18n/t"; import { t } from "$lib/i18n/t";
@@ -26,6 +29,9 @@
textVars?: Record<string, string | number>; textVars?: Record<string, string | number>;
running?: boolean; running?: boolean;
error?: string; error?: string;
stepResults?: (PixelImage | null)[];
aligned?: boolean;
ontogglealign?: () => void;
format?: OutputMime; format?: OutputMime;
quality?: number; quality?: number;
alphaLoss?: boolean; alphaLoss?: boolean;
@@ -51,6 +57,9 @@
textVars = undefined, textVars = undefined,
running = false, running = false,
error = "", error = "",
stepResults = [],
aligned = false,
ontogglealign,
format = "image/png", format = "image/png",
quality = undefined, quality = undefined,
alphaLoss = false, alphaLoss = false,
@@ -71,6 +80,16 @@
<div class="panel-head"> <div class="panel-head">
<span class="label">{t("resultCard.previewPanel")}</span> <span class="label">{t("resultCard.previewPanel")}</span>
{#if stepResults.length > 1}
<label class="align-toggle">
<Toggle
checked={aligned}
label={t("chain.alignToggle")}
onchange={() => ontogglealign?.()}
/>
<span>{t("chain.alignToggle")}</span>
</label>
{/if}
<SchemaActions <SchemaActions
{inputMode} {inputMode}
{resultKind} {resultKind}
@@ -100,6 +119,17 @@
{onrendertext} {onrendertext}
{onupload} {onupload}
/> />
{#each stepResults.slice(0, -1) as img, i (i)}
{#if img}
<PreviewTile
label={t("chain.stepResult", { n: i + 1 })}
viewMode="image"
dims={`${img.width} × ${img.height}`}
>
<img src={toDataUrl(img)} alt="" />
</PreviewTile>
{/if}
{/each}
<SchemaResultTile <SchemaResultTile
{resultKind} {resultKind}
{result} {result}
@@ -118,7 +148,7 @@
.panel-head { .panel-head {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: baseline; align-items: center;
gap: var(--space-m); gap: var(--space-m);
padding-bottom: var(--space-l); padding-bottom: var(--space-l);
border-bottom: var(--size-border) solid var(--color-border); border-bottom: var(--size-border) solid var(--color-border);
@@ -130,6 +160,15 @@
text-transform: uppercase; text-transform: uppercase;
color: var(--color-main); color: var(--color-main);
} }
.align-toggle {
display: inline-flex;
align-items: center;
gap: var(--space-m);
margin-right: auto;
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
cursor: pointer;
}
.error { .error {
margin: 0 0 var(--space-l); margin: 0 0 var(--space-l);
color: var(--color-danger); color: var(--color-danger);
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import AddStepButton from "./AddStepButton.svelte";
import SchemaFields from "./SchemaFields.svelte";
import SchemaPreview from "./SchemaPreview.svelte";
import StepCard from "$lib/components/display/StepCard.svelte"; import StepCard from "$lib/components/display/StepCard.svelte";
import Toggle from "$lib/components/ui/Toggle.svelte";
import { isFav, toggleFav } from "$lib/favorites.svelte";
import { Star } from "@lucide/svelte";
import { hasTransparency } from "$lib/core/analyze"; import { hasTransparency } from "$lib/core/analyze";
import { debounce } from "$lib/core/debounce"; import { debounce } from "$lib/core/debounce";
import { ToolError, type ErrorVars } from "$lib/core/errors"; import { ToolError, type ErrorVars } from "$lib/core/errors";
@@ -11,6 +11,7 @@
downloadBlob, downloadBlob,
encode, encode,
outputFormatByMime, outputFormatByMime,
toDataUrl,
type OutputMime, type OutputMime,
} from "$lib/core/io"; } from "$lib/core/io";
import type { PixelImage } from "$lib/core/types"; import type { PixelImage } from "$lib/core/types";
@@ -23,6 +24,7 @@
import { t } from "$lib/i18n/t"; import { t } from "$lib/i18n/t";
import { import {
createChain, createChain,
createStep,
insertStep, insertStep,
loadChain, loadChain,
moveStep, moveStep,
@@ -49,6 +51,14 @@
import { downloadZip } from "$lib/zip"; import { downloadZip } from "$lib/zip";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { SvelteMap, SvelteSet } from "svelte/reactivity"; import { SvelteMap, SvelteSet } from "svelte/reactivity";
import AddStepButton from "./AddStepButton.svelte";
import PreviewTile from "./PreviewTile.svelte";
import SchemaActions from "./SchemaActions.svelte";
import SchemaFields from "./SchemaFields.svelte";
import SchemaPreview from "./SchemaPreview.svelte";
import SchemaResultTile from "./SchemaResultTile.svelte";
import SchemaSourceTile from "./SchemaSourceTile.svelte";
import ToolPickerButton from "./ToolPickerButton.svelte";
interface Props { interface Props {
page: Page; page: Page;
@@ -72,6 +82,8 @@
// Последняя правленая ось dimension-поля — ведущая при lockAspect. // Последняя правленая ось dimension-поля — ведущая при lockAspect.
const lastAxis: Record<string, "width" | "height"> = {}; const lastAxis: Record<string, "width" | "height"> = {};
let stepDims = $state<(Dimension | undefined)[]>([]); let stepDims = $state<(Dimension | undefined)[]>([]);
let stepResults = $state<(PixelImage | null)[]>([]);
let aligned = $state(false);
let source = $state<PixelImage | null>(null); let source = $state<PixelImage | null>(null);
let result = $state<PixelImage | null>(null); let result = $state<PixelImage | null>(null);
let fileResult = $state<FileResult | null>(null); let fileResult = $state<FileResult | null>(null);
@@ -95,6 +107,9 @@
const resultKind = $derived(lastTool.result ?? "image"); const resultKind = $derived(lastTool.result ?? "image");
// Цепочку можно продолжить, только если последний шаг отдаёт картинку. // Цепочку можно продолжить, только если последний шаг отдаёт картинку.
const canExtend = $derived((lastTool.result ?? "image") === "image"); const canExtend = $derived((lastTool.result ?? "image") === "image");
const alignedMode = $derived(
aligned && resultKind === "image" && steps.length > 1,
);
const alphaLoss = $derived( const alphaLoss = $derived(
resultKind === "image" && resultKind === "image" &&
@@ -240,6 +255,26 @@
steps = removeStep(steps, key); steps = removeStep(steps, key);
} }
function toggleStep(index: number) {
const step = steps[index];
if (!step) return;
steps = steps.with(index, { ...step, collapsed: !step.collapsed });
}
// Замена инструмента на месте: ключ и свёрнутость шага сохраняются,
// параметры — дефолты нового инструмента, вход пересчитывается при прогоне.
function replaceStepTool(index: number, toolId: string) {
const step = steps[index];
const next = createStep(toolId);
if (!step || !next) return;
touchedFor(step.key).clear();
steps = steps.with(index, {
...next,
key: step.key,
collapsed: step.collapsed,
});
}
function onStepDrop(e: DragEvent, to: number) { function onStepDrop(e: DragEvent, to: number) {
e.preventDefault(); e.preventDefault();
if (dragFrom !== null) steps = moveStep(steps, dragFrom, to); if (dragFrom !== null) steps = moveStep(steps, dragFrom, to);
@@ -305,6 +340,7 @@
try { try {
let current: PixelImage | undefined = source ?? undefined; let current: PixelImage | undefined = source ?? undefined;
const dims: (Dimension | undefined)[] = []; const dims: (Dimension | undefined)[] = [];
const results: (PixelImage | null)[] = [];
let out: ToolResult = ""; let out: ToolResult = "";
for (let i = 0; i < steps.length; i++) { for (let i = 0; i < steps.length; i++) {
const stepTool = getTool(steps[i].id); const stepTool = getTool(steps[i].id);
@@ -331,11 +367,14 @@
if (out && typeof out === "object" && "data" in out) { if (out && typeof out === "object" && "data" in out) {
current = out as PixelImage; current = out as PixelImage;
dims[i] = { width: current.width, height: current.height }; dims[i] = { width: current.width, height: current.height };
results[i] = current;
} else { } else {
current = undefined; current = undefined;
results[i] = null;
} }
} }
stepDims = dims; stepDims = dims;
stepResults = results;
assignResult(out); assignResult(out);
} catch (e) { } catch (e) {
displayError = toDisplayError(e); displayError = toDisplayError(e);
@@ -443,24 +482,31 @@
<div class="schema-tool"> <div class="schema-tool">
<header class="header"> <header class="header">
<div class="title-block"> <div class="title-block">
<h1>{pageTitle(page)}</h1> <h1>
{pageTitle(page)}
<button
type="button"
class="tool-fav"
class:active={isFav(page.slug)}
aria-label={isFav(page.slug) ? t("ui.removeFav") : t("ui.addFav")}
aria-pressed={isFav(page.slug)}
onclick={() => toggleFav(page.slug)}
>
<Star size={18} fill={isFav(page.slug) ? "currentColor" : "none"} />
</button>
</h1>
<p class="lede">{pageDescription(page)}</p> <p class="lede">{pageDescription(page)}</p>
</div> </div>
</header> </header>
<div class="workspace"> {#snippet stepCard(step: ChainStep, i: number)}
<section class="settings">
{#if canExtend}
<AddStepButton onadd={(id) => addStepAt(0, id)} />
{/if}
{#each steps as step, i (step.key)}
<StepCard <StepCard
index={i + 1} index={i + 1}
title={stepTitle(step.id)} title={stepTitle(step.id)}
draggable draggable
onremove={steps.length > 1 collapsed={step.collapsed ?? false}
? () => removeStepAt(step.key) ontoggle={() => toggleStep(i)}
: undefined} onremove={steps.length > 1 ? () => removeStepAt(step.key) : undefined}
ondragstart={(e) => { ondragstart={(e) => {
dragFrom = i; dragFrom = i;
if (e.dataTransfer) { if (e.dataTransfer) {
@@ -475,6 +521,13 @@
ondrop={(e) => onStepDrop(e, i)} ondrop={(e) => onStepDrop(e, i)}
ondragend={() => (dragFrom = null)} ondragend={() => (dragFrom = null)}
> >
{#snippet tools()}
<ToolPickerButton
label={t("chain.changeTool")}
iconOnly
onadd={(id) => replaceStepTool(i, id)}
/>
{/snippet}
<SchemaFields <SchemaFields
schema={toolSchemaOf(step)} schema={toolSchemaOf(step)}
values={step.params} values={step.params}
@@ -484,6 +537,16 @@
onreset={() => resetStep(i)} onreset={() => resetStep(i)}
/> />
</StepCard> </StepCard>
{/snippet}
{#if !alignedMode}
<div class="workspace">
<section class="settings">
{#if canExtend}
<AddStepButton onadd={(id) => addStepAt(0, id)} />
{/if}
{#each steps as step, i (step.key)}
{@render stepCard(step, i)}
{#if canExtend} {#if canExtend}
<AddStepButton onadd={(id) => addStepAt(i + 1, id)} /> <AddStepButton onadd={(id) => addStepAt(i + 1, id)} />
{/if} {/if}
@@ -507,6 +570,9 @@
{format} {format}
quality={currentQuality} quality={currentQuality}
{alphaLoss} {alphaLoss}
{stepResults}
{aligned}
ontogglealign={() => (aligned = !aligned)}
onformat={(v) => (format = v)} onformat={(v) => (format = v)}
onquality={setFormatQuality} onquality={setFormatQuality}
onupload={handleFile} onupload={handleFile}
@@ -520,6 +586,117 @@
/> />
</section> </section>
</div> </div>
{:else}
<div class="aligned">
<div class="aligned-row">
<div></div>
<div class="panel-segment head-segment">
<span class="label">{t("resultCard.previewPanel")}</span>
<label class="align-toggle">
<Toggle bind:checked={aligned} label={t("chain.alignToggle")} />
<span>{t("chain.alignToggle")}</span>
</label>
<SchemaActions
{inputMode}
{resultKind}
canDownload={result !== null}
{running}
{format}
quality={currentQuality}
{alphaLoss}
onupload={handleFile}
ondownload={download}
onformat={(v) => (format = v)}
onquality={setFormatQuality}
/>
</div>
</div>
{#if errorText}
<p class="error" role="alert">{errorText}</p>
{/if}
{#if canExtend}
<div class="aligned-row">
<AddStepButton onadd={(id) => addStepAt(0, id)} />
<div class="panel-segment connect-segment"></div>
</div>
{/if}
{#each steps as step, i (step.key)}
{@const input = i === 0 ? null : stepResults[i - 1]}
{@const out = stepResults[i]}
{#if i > 0}
<div class="group-divider">
<div class="gd-cell"></div>
</div>
{/if}
<div class="aligned-row">
{@render stepCard(step, i)}
<div
class="panel-segment pair-segment"
class:last-segment={i === steps.length - 1 && !canExtend}
>
{#if i === 0}
<SchemaSourceTile
mode={inputMode}
{source}
{textSource}
{running}
ontextinput={(v) => (textSource = v)}
onrendertext={run}
onupload={handleFile}
/>
{:else}
<PreviewTile
label={t("chain.inputLegend")}
viewMode="image"
dims={input ? `${input.width} × ${input.height}` : undefined}
>
{#if input}
<img src={toDataUrl(input)} alt="" />
{:else}
<span class="empty">{t("resultCard.noResult")}</span>
{/if}
</PreviewTile>
{/if}
{#if i === steps.length - 1}
<SchemaResultTile
{resultKind}
result={out}
{resultNote}
{fileResult}
{textResult}
textVars={verdictVars}
toolId={lastTool.id}
{running}
oncopy={copyText}
ondownloadtxt={downloadText}
/>
{:else}
<PreviewTile
label={t("chain.stepResult", { n: i + 1 })}
viewMode="image"
dims={out ? `${out.width} × ${out.height}` : undefined}
>
{#if out}
<img src={toDataUrl(out)} alt="" />
{:else}
<span class="empty">{t("resultCard.noResult")}</span>
{/if}
</PreviewTile>
{/if}
</div>
</div>
{#if canExtend}
<div class="aligned-row">
<AddStepButton onadd={(id) => addStepAt(i + 1, id)} />
<div
class="panel-segment connect-segment"
class:add-last={i === steps.length - 1}
></div>
</div>
{/if}
{/each}
</div>
{/if}
</div> </div>
{:else} {:else}
<p class="no-schema">{t("paramsCard.noSchema")}</p> <p class="no-schema">{t("paramsCard.noSchema")}</p>
@@ -538,11 +715,27 @@
margin-bottom: var(--space-xxxl); margin-bottom: var(--space-xxxl);
} }
.title-block h1 { .title-block h1 {
display: inline-flex;
align-items: center;
gap: var(--space-m);
margin: var(--space-s) 0 0; margin: var(--space-s) 0 0;
font-size: clamp(var(--font-size-xl), 4vw, var(--font-size-2xl)); font-size: clamp(var(--font-size-xl), 4vw, var(--font-size-2xl));
line-height: 1.1; line-height: 1.1;
color: var(--color-text); color: var(--color-text);
} }
.tool-fav {
display: inline-grid;
place-items: center;
padding: var(--space-s);
border: none;
background: none;
color: var(--color-text-muted);
cursor: pointer;
}
.tool-fav:hover,
.tool-fav.active {
color: var(--color-warning);
}
.lede { .lede {
max-width: 100%; max-width: 100%;
margin: var(--space-m) 0 0; margin: var(--space-m) 0 0;
@@ -568,9 +761,100 @@
.no-schema { .no-schema {
font: var(--font-size-s) var(--font-mono); font: var(--font-size-s) var(--font-mono);
} }
.aligned {
display: grid;
}
.aligned-row {
display: grid;
grid-template-columns: minmax(var(--size-workspace-min), 1fr) 2fr;
gap: var(--space-xxl);
align-items: start;
}
/* Сегменты справа образуют «одну панель»: общий фон, боковые границы,
верх у шапки и низ у последней строки. */
.panel-segment {
background: var(--color-panel);
border-left: var(--size-border) solid var(--color-border);
border-right: var(--size-border) solid var(--color-border);
padding: var(--space-xl);
}
.head-segment {
display: flex;
align-items: center;
gap: var(--space-xl);
flex-wrap: wrap;
border-top: var(--size-border) solid var(--color-border);
border-bottom: var(--size-border) solid var(--color-border);
border-radius: var(--radius-m) var(--radius-m) 0 0;
}
.head-segment .label {
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-l);
text-transform: uppercase;
color: var(--color-main);
}
.pair-segment {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-l);
}
/* Коннектор в строках «Add step»: продолжает боковые границы панели. */
.connect-segment {
align-self: stretch;
padding: 0;
}
.connect-segment.add-last {
border-bottom: var(--size-border) solid var(--color-border);
border-radius: 0 0 var(--radius-m) var(--radius-m);
}
/* Полоса-разделитель между группами: ячейки с фоном и боковыми границами
(как у карточки слева и панели справа), сверху — линия через всю ширину. */
.group-divider {
position: relative;
display: grid;
grid-template-columns: minmax(var(--size-workspace-min), 1fr) 2fr;
gap: var(--space-xxl);
}
.gd-cell {
grid-column: 2;
background: var(--color-panel);
border-left: var(--size-border) solid var(--color-border);
border-right: var(--size-border) solid var(--color-border);
padding: var(--space-s) 0;
}
.group-divider::after {
content: "";
position: absolute;
inset: 0 0 auto;
border-top: var(--size-border) solid var(--color-border);
}
.last-segment {
border-bottom: var(--size-border) solid var(--color-border);
border-radius: 0 0 var(--radius-m) var(--radius-m);
}
.align-toggle {
display: inline-flex;
align-items: center;
gap: var(--space-m);
margin-right: auto;
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
cursor: pointer;
}
.error {
margin: 0;
color: var(--color-danger);
font: var(--font-size-s) var(--font-mono);
}
.empty {
font: var(--font-size-s) var(--font-mono);
}
@media (--bp-tablet) { @media (--bp-tablet) {
.workspace { .workspace {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.aligned-row {
grid-template-columns: 1fr;
}
} }
</style> </style>
@@ -0,0 +1,140 @@
<script lang="ts">
import Icon from "$lib/components/ui/Icon.svelte";
import { pageTitle } from "$lib/i18n/schema-tool-strings";
import { chainablePages } from "$lib/pipeline.svelte";
import type { Page } from "$lib/registry";
import { Plus, Repeat } from "@lucide/svelte";
interface Props {
label: string;
iconOnly?: boolean;
onadd: (toolId: string) => void;
}
let { label, iconOnly = false, onadd }: Props = $props();
const pages = chainablePages();
let open = $state(false);
let root = $state<HTMLDivElement | null>(null);
function choose(page: Page) {
onadd(page.steps[0].id);
open = false;
}
$effect(() => {
if (!open) return;
function onPointerDown(e: PointerEvent) {
if (!root?.contains(e.target as Node)) open = false;
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") open = false;
}
window.addEventListener("pointerdown", onPointerDown);
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("keydown", onKey);
};
});
</script>
<div class="tool-picker" bind:this={root}>
<button
type="button"
class={iconOnly ? "icon-btn" : "add-btn"}
aria-haspopup="menu"
aria-expanded={open}
aria-label={iconOnly ? label : undefined}
onclick={() => (open = !open)}
>
{#if iconOnly}
<Icon icon={Repeat} size={14} />
{:else}
<Icon icon={Plus} size={14} />
<span>{label}</span>
{/if}
</button>
{#if open}
<div class="menu" role="menu">
{#each pages as page (page.slug)}
<button
type="button"
class="item"
role="menuitem"
onclick={() => choose(page)}
>
{pageTitle(page)}
</button>
{/each}
</div>
{/if}
</div>
<style>
.tool-picker {
position: relative;
display: inline-flex;
}
.add-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-m);
padding: var(--space-m) var(--space-l);
border: var(--size-border) solid var(--color-main);
border-radius: var(--radius-m);
background: var(--color-main);
color: var(--color-background);
font: 600 var(--font-size-s) var(--font-mono);
cursor: pointer;
}
.add-btn:hover {
filter: brightness(1.1);
}
.icon-btn {
display: inline-flex;
align-items: center;
padding: var(--space-s);
border: var(--size-border) solid transparent;
border-radius: var(--radius-m);
background: none;
color: var(--color-text-muted);
cursor: pointer;
}
.icon-btn:hover {
color: var(--color-text);
}
.menu {
position: absolute;
top: calc(100% + var(--space-s));
right: 0;
z-index: var(--z-dropdown);
min-width: calc(var(--space-xxxl) * 8);
max-height: calc(var(--space-xxxl) * 8);
overflow-y: auto;
display: grid;
gap: var(--space-s);
padding: var(--space-m);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-m);
background: var(--color-panel);
}
.item {
padding: var(--space-m) var(--space-l);
border: var(--size-border) solid transparent;
border-radius: var(--radius-s);
background: transparent;
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
text-align: left;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item:hover {
color: var(--color-text);
border-color: var(--color-border);
}
</style>
@@ -0,0 +1,17 @@
<script lang="ts">
interface Props {
path: string;
size?: number;
}
let { path, size = 16 }: Props = $props();
</script>
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path d={path} />
</svg>
+61
View File
@@ -7,6 +7,7 @@ import {
resize, resize,
rotate90, rotate90,
splitToParts, splitToParts,
splitToTileSize,
tile, tile,
} from "./geometry"; } from "./geometry";
import { expectImageEqual, makeImage } from "./test-helpers"; import { expectImageEqual, makeImage } from "./test-helpers";
@@ -322,3 +323,63 @@ describe("splitToParts", () => {
expect(parts[0].height).toBe(6); expect(parts[0].height).toBe(6);
}); });
}); });
describe("splitToTileSize", () => {
function grid(w: number, h: number): PixelImage {
const pixels: number[][] = [];
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const v = y * w + x + 1;
pixels.push([v, v, v, 255]);
}
}
return makeImage(w, h, pixels);
}
it("кратный размер: части ровно tileW×tileH без паддинга", () => {
const parts = splitToTileSize(grid(4, 4), 2, 2);
expect(parts).toHaveLength(4);
for (const part of parts) {
expect(part.width).toBe(2);
expect(part.height).toBe(2);
}
expectImageEqual(parts[1], [
[3, 3, 3, 255],
[4, 4, 4, 255],
[7, 7, 7, 255],
[8, 8, 8, 255],
]);
});
it("некратный размер: канвас дополняется прозрачным до кратности", () => {
const parts = splitToTileSize(grid(5, 3), 3, 2);
expect(parts).toHaveLength(4);
for (const part of parts) {
expect(part.width).toBe(3);
expect(part.height).toBe(2);
}
expectImageEqual(parts[1], [
[4, 4, 4, 255],
[5, 5, 5, 255],
[0, 0, 0, 0],
[9, 9, 9, 255],
[10, 10, 10, 255],
[0, 0, 0, 0],
]);
expectImageEqual(parts[2], [
[11, 11, 11, 255],
[12, 12, 12, 255],
[13, 13, 13, 255],
[0, 0, 0, 0],
[0, 0, 0, 0],
[0, 0, 0, 0],
]);
});
it("тайл больше картинки — одна часть с паддингом", () => {
const parts = splitToTileSize(grid(2, 2), 4, 4);
expect(parts).toHaveLength(1);
expect(parts[0].width).toBe(4);
expect(parts[0].height).toBe(4);
});
});
+25
View File
@@ -87,6 +87,31 @@ export function splitToParts(
return parts; return parts;
} }
/** Нарезка по размеру тайла: канвас дополняется прозрачным до кратности. */
export function splitToTileSize(
img: PixelImage,
tileW: number,
tileH: number,
): PixelImage[] {
const tw = Math.max(1, Math.trunc(tileW));
const th = Math.max(1, Math.trunc(tileH));
const cols = Math.max(1, Math.ceil(img.width / tw));
const rows = Math.max(1, Math.ceil(img.height / th));
const gridW = cols * tw;
const gridH = rows * th;
const padded =
gridW === img.width && gridH === img.height
? img
: expandCanvas(img, 0, 0, gridW - img.width, gridH - img.height);
const parts: PixelImage[] = [];
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
parts.push(crop(padded, col * tw, row * th, tw, th));
}
}
return parts;
}
export function centerByAlpha(img: PixelImage): PixelImage { export function centerByAlpha(img: PixelImage): PixelImage {
let minX = img.width; let minX = img.width;
let minY = img.height; let minY = img.height;
+47
View File
@@ -0,0 +1,47 @@
import { SvelteSet } from "svelte/reactivity";
const KEY = "fav-tools";
const slugs = new SvelteSet<string>();
function storage(): Storage | null {
return typeof localStorage === "undefined" ? null : localStorage;
}
export function initFavorites(): void {
const raw = storage()?.getItem(KEY);
if (!raw) return;
try {
const list: unknown = JSON.parse(raw);
if (Array.isArray(list)) {
for (const s of list) if (typeof s === "string") slugs.add(s);
}
} catch {
// мусор в localStorage — работаем с пустым набором
}
}
export function isFav(slug: string): boolean {
return slugs.has(slug);
}
export function toggleFav(slug: string): void {
if (slugs.has(slug)) {
slugs.delete(slug);
} else {
slugs.add(slug);
}
storage()?.setItem(KEY, JSON.stringify([...slugs]));
}
/** Избранные страницы из списка (порядок сохраняется). */
export function favPages<T extends { slug: string }>(pages: readonly T[]): T[] {
return pages.filter((p) => slugs.has(p.slug));
}
/** Те же страницы, но избранные первыми (бонус в выдаче поиска). */
export function favFirst<T extends { slug: string }>(pages: readonly T[]): T[] {
return [...pages].sort(
(a, b) => Number(slugs.has(b.slug)) - Number(slugs.has(a.slug)),
);
}
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import {
favFirst,
favPages,
initFavorites,
isFav,
toggleFav,
} from "./favorites.svelte";
describe("favorites", () => {
it("toggle добавляет и убирает, isFav отражает состояние", () => {
expect(isFav("flip-png")).toBe(false);
toggleFav("flip-png");
expect(isFav("flip-png")).toBe(true);
toggleFav("flip-png");
expect(isFav("flip-png")).toBe(false);
});
it("favPages фильтрует, favFirst поднимает избранное", () => {
toggleFav("resize-png");
const pages = [{ slug: "crop-png" }, { slug: "resize-png" }];
expect(favPages(pages)).toEqual([{ slug: "resize-png" }]);
expect(favFirst(pages).map((p) => p.slug)).toEqual([
"resize-png",
"crop-png",
]);
toggleFav("resize-png");
});
it("initFavorites без localStorage и с мусором не падает", () => {
expect(() => initFavorites()).not.toThrow();
});
});
+20
View File
@@ -36,6 +36,7 @@ export const en: Dict = {
lead: "Focused utilities for working with PNG. Inspect, transform, and export — locally in your browser.", lead: "Focused utilities for working with PNG. Inspect, transform, and export — locally in your browser.",
toolsAvailable: "Tools available", toolsAvailable: "Tools available",
toolsCount: "Tools", toolsCount: "Tools",
favorites: "Favorites",
}, },
toolPage: { toolPage: {
fallbackTitle: "Tool", fallbackTitle: "Tool",
@@ -49,6 +50,11 @@ export const en: Dict = {
}, },
chain: { chain: {
addStep: "Add step", addStep: "Add step",
changeTool: "Change tool",
collapseStep: "Collapse step",
expandStep: "Expand step",
stepResult: "Result {n}",
alignToggle: "Align tools and preview",
inputLegend: "Input", inputLegend: "Input",
resultLegend: "Result", resultLegend: "Result",
paramsLegend: "Parameters", paramsLegend: "Parameters",
@@ -162,6 +168,11 @@ export const en: Dict = {
removeColor: "Remove color", removeColor: "Remove color",
themeToggle: "Toggle theme", themeToggle: "Toggle theme",
language: "Language", language: "Language",
addFav: "Add to favorites",
removeFav: "Remove from favorites",
github: "GitHub",
telegram: "Telegram",
twitch: "Twitch",
}, },
errors: { errors: {
noImageRun: "This tool does not process images", noImageRun: "This tool does not process images",
@@ -230,6 +241,8 @@ export const en: Dict = {
colorTolerance: "Color tolerance", colorTolerance: "Color tolerance",
cols: "Cols", cols: "Cols",
columns: "Columns", columns: "Columns",
splitMode: "Split by",
tileSize: "Tile size",
component: "Component", component: "Component",
contrast: "Contrast", contrast: "Contrast",
count: "Count", count: "Count",
@@ -318,6 +331,8 @@ export const en: Dict = {
cropArea: "Crop area", cropArea: "Crop area",
fill: "Fill", fill: "Fill",
grid: "Grid", grid: "Grid",
splitMode: "Split by",
tileSize: "Tile size",
noise: "Noise", noise: "Noise",
offset: "Offset", offset: "Offset",
options: "Options", options: "Options",
@@ -370,6 +385,11 @@ export const en: Dict = {
orientationSquare: "Square — the sides are equal.", orientationSquare: "Square — the sides are equal.",
}, },
}, },
"split-into-parts": {
options: {
mode: { grid: "Grid", tile: "Tile size" },
},
},
"show-transparent": { "show-transparent": {
options: { options: {
mode: { binary: "Black & white mask", highlight: "Color highlight" }, mode: { binary: "Black & white mask", highlight: "Color highlight" },
+26
View File
@@ -37,6 +37,7 @@ export const ru: Dict = {
lead: "Утилиты для работы с PNG. Анализируйте, преобразуйте и экспортируйте — локально в браузере.", lead: "Утилиты для работы с PNG. Анализируйте, преобразуйте и экспортируйте — локально в браузере.",
toolsAvailable: "Инструментов доступно", toolsAvailable: "Инструментов доступно",
toolsCount: "Инструментов", toolsCount: "Инструментов",
favorites: "Избранное",
}, },
toolPage: { toolPage: {
fallbackTitle: "Инструмент", fallbackTitle: "Инструмент",
@@ -50,6 +51,11 @@ export const ru: Dict = {
}, },
chain: { chain: {
addStep: "Добавить шаг", addStep: "Добавить шаг",
changeTool: "Поменять инструмент",
collapseStep: "Свернуть шаг",
expandStep: "Развернуть шаг",
stepResult: "Результат {n}",
alignToggle: "Выровнять инструменты и просмотр",
inputLegend: "Вход", inputLegend: "Вход",
resultLegend: "Результат", resultLegend: "Результат",
paramsLegend: "Параметры", paramsLegend: "Параметры",
@@ -163,6 +169,11 @@ export const ru: Dict = {
removeColor: "Удалить цвет", removeColor: "Удалить цвет",
themeToggle: "Переключить тему", themeToggle: "Переключить тему",
language: "Язык", language: "Язык",
addFav: "В избранное",
removeFav: "Убрать из избранного",
github: "GitHub",
telegram: "Telegram",
twitch: "Twitch",
}, },
errors: { errors: {
noImageRun: "Этот инструмент не обрабатывает изображения", noImageRun: "Этот инструмент не обрабатывает изображения",
@@ -231,6 +242,8 @@ export const ru: Dict = {
colorTolerance: "Допуск похожести", colorTolerance: "Допуск похожести",
cols: "Колонки", cols: "Колонки",
columns: "Столбцы", columns: "Столбцы",
splitMode: "Делить по",
tileSize: "Размер тайла",
component: "Компонент", component: "Компонент",
contrast: "Контраст", contrast: "Контраст",
count: "Количество", count: "Количество",
@@ -319,6 +332,8 @@ export const ru: Dict = {
cropArea: "Область кадрирования", cropArea: "Область кадрирования",
fill: "Заливка", fill: "Заливка",
grid: "Сетка", grid: "Сетка",
splitMode: "Режим разделения",
tileSize: "Размер тайла",
noise: "Шум", noise: "Шум",
offset: "Смещение", offset: "Смещение",
options: "Параметры", options: "Параметры",
@@ -989,6 +1004,17 @@ export const ru: Dict = {
color: "Цвет фона", color: "Цвет фона",
}, },
}, },
"split-into-parts": {
params: {
mode: "Режим",
columns: "Столбцов",
rows: "Строк",
tile: "Размер тайла",
},
options: {
mode: { grid: "Сетка", tile: "Размер тайла" },
},
},
tile: { tile: {
params: { columns: "Столбцов", rows: "Строк" }, params: { columns: "Столбцов", rows: "Строк" },
}, },
+1 -1
View File
@@ -25,7 +25,7 @@ function selectOptions(
const spec = ( const spec = (
field as { spec?: { kind?: string; options?: SelectOption[] } } field as { spec?: { kind?: string; options?: SelectOption[] } }
).spec; ).spec;
if (spec?.kind !== "select") continue; if (spec?.kind !== "select" && spec?.kind !== "segmented") continue;
for (const option of spec.options ?? []) result.push({ fieldId, option }); for (const option of spec.options ?? []) result.push({ fieldId, option });
} }
return result; return result;
+8 -2
View File
@@ -2,10 +2,10 @@ import { getTool, PAGES, type Page } from "./registry";
import { defaultSchemaParams, sanitizeSchemaParams } from "./registry-schema"; import { defaultSchemaParams, sanitizeSchemaParams } from "./registry-schema";
export interface ChainStep { export interface ChainStep {
/** Стабильный ключ для keyed each и drag-n-drop (переживает reorder). */
key: string; key: string;
id: string; id: string;
params: Record<string, unknown>; params: Record<string, unknown>;
collapsed?: boolean;
} }
export const CHAIN_VERSION = 1; export const CHAIN_VERSION = 1;
@@ -77,6 +77,8 @@ export function parseChain(data: unknown): ChainStep[] | null {
out.push({ out.push({
key: uid(), key: uid(),
id, id,
collapsed:
(raw as { collapsed?: unknown }).collapsed === true ? true : undefined,
params: sanitizeSchemaParams( params: sanitizeSchemaParams(
tool.schema, tool.schema,
(params ?? {}) as Record<string, unknown>, (params ?? {}) as Record<string, unknown>,
@@ -101,7 +103,11 @@ export function saveChain(slug: string, steps: ChainStep[]): void {
storageKey(slug), storageKey(slug),
JSON.stringify({ JSON.stringify({
version: CHAIN_VERSION, version: CHAIN_VERSION,
steps: steps.map((s) => ({ id: s.id, params: s.params })), steps: steps.map((s) => ({
id: s.id,
params: s.params,
...(s.collapsed ? { collapsed: true } : {}),
})),
}), }),
); );
} }
+12
View File
@@ -79,6 +79,18 @@ describe("pipeline: parseChain (localStorage/импорт)", () => {
expect(chain[0].params).toMatchObject({ x: 5, y: 0 }); expect(chain[0].params).toMatchObject({ x: 5, y: 0 });
}); });
it("parseChain сохраняет collapsed, по умолчанию шаг развёрнут", () => {
const chain = parseChain({
version: CHAIN_VERSION,
steps: [
{ id: "crop", params: {}, collapsed: true },
{ id: "resize", params: {} },
],
})!;
expect(chain[0].collapsed).toBe(true);
expect(chain[1].collapsed).toBeUndefined();
});
it.each([ it.each([
["нет версии", { steps: [{ id: "crop" }] }], ["нет версии", { steps: [{ id: "crop" }] }],
["другая версия", { version: 99, steps: [{ id: "crop" }] }], ["другая версия", { version: 99, steps: [{ id: "crop" }] }],
+19
View File
@@ -16,6 +16,8 @@ import type { Position9 } from "./core/textdraw";
*/ */
export interface FieldSpecBase { export interface FieldSpecBase {
label?: string; label?: string;
/** Поле видно только когда другое поле равно значению (режимы инструмента). */
visibleWhen?: { field: string; equals: string };
} }
export interface NumberSpec extends FieldSpecBase { export interface NumberSpec extends FieldSpecBase {
@@ -48,6 +50,15 @@ export interface SelectSpec<V extends string = string> extends FieldSpecBase {
options: { value: V; label: string }[]; options: { value: V; label: string }[];
} }
/** То же, что select, но рендерится кнопками-сегментами (выбор в 1 клик). */
export interface SegmentedSpec<
V extends string = string,
> extends FieldSpecBase {
kind: "segmented";
default: V;
options: { value: V; label: string }[];
}
export interface TextSpec extends FieldSpecBase { export interface TextSpec extends FieldSpecBase {
kind: "text"; kind: "text";
default: string; default: string;
@@ -212,6 +223,7 @@ export const fieldSpecs = {
slider: {} as SliderSpec, slider: {} as SliderSpec,
color: {} as ColorSpec, color: {} as ColorSpec,
select: {} as SelectSpec, select: {} as SelectSpec,
segmented: {} as SegmentedSpec,
text: {} as TextSpec, text: {} as TextSpec,
checkbox: {} as CheckboxSpec, checkbox: {} as CheckboxSpec,
dimension: {} as DimensionSpec, dimension: {} as DimensionSpec,
@@ -275,6 +287,11 @@ export const field = {
select: <V extends string>(s: Omit<SelectSpec<V>, "kind">): Field<V> => ({ select: <V extends string>(s: Omit<SelectSpec<V>, "kind">): Field<V> => ({
spec: { kind: "select", ...s }, spec: { kind: "select", ...s },
}), }),
segmented: <V extends string>(
s: Omit<SegmentedSpec<V>, "kind">,
): Field<V> => ({
spec: { kind: "segmented", ...s },
}),
text: (s: Omit<TextSpec, "kind">): Field<string> => ({ text: (s: Omit<TextSpec, "kind">): Field<string> => ({
spec: { kind: "text", ...s }, spec: { kind: "text", ...s },
}), }),
@@ -291,6 +308,7 @@ export const field = {
maxFromSource?: boolean; maxFromSource?: boolean;
lockAspectWith?: string; lockAspectWith?: string;
presets?: boolean; presets?: boolean;
visibleWhen?: { field: string; equals: string };
}): Field<Dimension> => ({ }): Field<Dimension> => ({
spec: { kind: "dimension", ...s }, spec: { kind: "dimension", ...s },
}), }),
@@ -573,6 +591,7 @@ export function sanitizeSchemaParams<P>(
break; break;
} }
case "select": case "select":
case "segmented":
out[key] = out[key] =
typeof raw === "string" && spec.options.some((o) => o.value === raw) typeof raw === "string" && spec.options.some((o) => o.value === raw)
? raw ? raw
+19
View File
@@ -760,6 +760,25 @@ describe("split-into-parts", () => {
expect(files).toHaveLength(1024); expect(files).toHaveLength(1024);
}); });
it("режим tile: сетка считается от размера тайла, части точные", async () => {
const tool = TOOLS.find((t) => t.id === "split-into-parts")!;
const files = asFiles(
await tool.run({
source: solid(100, 70),
params: sanitizeSchemaParams(tool.schema, {
mode: "tile",
tile: { width: 32, height: 32 },
}),
}),
).files;
// 100/32 → 4 колонки, 70/32 → 3 строки
expect(files).toHaveLength(12);
for (const file of files) {
expect(file.image.width).toBe(32);
expect(file.image.height).toBe(32);
}
});
it("выбрасывает errors.tooManyParts при переполнении", () => { it("выбрасывает errors.tooManyParts при переполнении", () => {
const tool = TOOLS.find((t) => t.id === "split-into-parts")!; const tool = TOOLS.find((t) => t.id === "split-into-parts")!;
expect(() => expect(() =>
+53 -5
View File
@@ -17,6 +17,7 @@ import {
resize, resize,
rotate90, rotate90,
splitToParts, splitToParts,
splitToTileSize,
symmetricCopy, symmetricCopy,
tile, tile,
trimToContent, trimToContent,
@@ -437,17 +438,29 @@ const tileTool: Tool<TileParams> = {
const MAX_SPLIT_PARTS = 1024; const MAX_SPLIT_PARTS = 1024;
interface SplitPartsParams { interface SplitPartsParams {
mode: "grid" | "tile";
columns: number; columns: number;
rows: number; rows: number;
tile: Dimension;
} }
export const splitPartsSchema = toolSchema<SplitPartsParams>({ export const splitPartsSchema = toolSchema<SplitPartsParams>(
{
mode: field.segmented({
label: "fields.splitMode",
default: "grid",
options: [
{ value: "grid", label: "Grid" },
{ value: "tile", label: "Tile size" },
],
}),
columns: field.number({ columns: field.number({
label: "fields.columns", label: "fields.columns",
min: 1, min: 1,
max: 32, max: 32,
step: 1, step: 1,
default: 2, default: 2,
visibleWhen: { field: "mode", equals: "grid" },
}), }),
rows: field.number({ rows: field.number({
label: "fields.rows", label: "fields.rows",
@@ -455,8 +468,28 @@ export const splitPartsSchema = toolSchema<SplitPartsParams>({
max: 32, max: 32,
step: 1, step: 1,
default: 2, default: 2,
visibleWhen: { field: "mode", equals: "grid" },
}), }),
}); tile: field.dimension({
label: "fields.tileSize",
min: 1,
max: 20000,
width: 32,
height: 32,
presets: true,
visibleWhen: { field: "mode", equals: "tile" },
}),
},
{
layout: {
groups: [
{ title: "groups.splitMode", fields: ["mode"] },
{ title: "groups.grid", fields: ["columns", "rows"] },
{ title: "groups.tileSize", fields: ["tile"] },
],
},
},
);
const splitPartsTool: Tool<SplitPartsParams> = { const splitPartsTool: Tool<SplitPartsParams> = {
id: "split-into-parts", id: "split-into-parts",
@@ -466,8 +499,20 @@ const splitPartsTool: Tool<SplitPartsParams> = {
run: (ctx) => { run: (ctx) => {
const img = requireSource(ctx); const img = requireSource(ctx);
const params = ctx.params as SplitPartsParams; const params = ctx.params as SplitPartsParams;
const cols = Math.max(1, Math.trunc(params.columns)); const cols =
const rowsCount = Math.max(1, Math.trunc(params.rows)); params.mode === "tile"
? Math.max(
1,
Math.ceil(img.width / Math.max(1, Math.trunc(params.tile.width))),
)
: Math.max(1, Math.trunc(params.columns));
const rowsCount =
params.mode === "tile"
? Math.max(
1,
Math.ceil(img.height / Math.max(1, Math.trunc(params.tile.height))),
)
: Math.max(1, Math.trunc(params.rows));
const count = cols * rowsCount; const count = cols * rowsCount;
if (count > MAX_SPLIT_PARTS) { if (count > MAX_SPLIT_PARTS) {
throw new ToolError("errors.tooManyParts", { throw new ToolError("errors.tooManyParts", {
@@ -475,7 +520,10 @@ const splitPartsTool: Tool<SplitPartsParams> = {
max: MAX_SPLIT_PARTS, max: MAX_SPLIT_PARTS,
}); });
} }
const parts = splitToParts(img, cols, rowsCount); const parts =
params.mode === "tile"
? splitToTileSize(img, params.tile.width, params.tile.height)
: splitToParts(img, cols, rowsCount);
const rowLen = String(rowsCount).length; const rowLen = String(rowsCount).length;
const colLen = String(cols).length; const colLen = String(cols).length;
const files: ToolImageFile[] = parts.map((image, index) => { const files: ToolImageFile[] = parts.map((image, index) => {
+6
View File
@@ -0,0 +1,6 @@
/**
* Публичный URL сайта для canonical/OG (абсолютные URL, включая base-path
* GitHub Pages). После покупки домена меняется здесь — `docs/plan-seo.md` §6.
* Внимание: `$app/paths.base` для этого не подходит — он относительный.
*/
export const SITE_URL = "https://ku6epxboctuk.github.io/easy-png-tools";
+2
View File
@@ -3,6 +3,7 @@
import { page } from "$app/state"; import { page } from "$app/state";
import Footer from "$lib/components/layout/Footer.svelte"; import Footer from "$lib/components/layout/Footer.svelte";
import TopBar from "$lib/components/layout/TopBar.svelte"; import TopBar from "$lib/components/layout/TopBar.svelte";
import { initFavorites } from "$lib/favorites.svelte";
import { initLocale } from "$lib/i18n/locale.svelte"; import { initLocale } from "$lib/i18n/locale.svelte";
import { pageTitle } from "$lib/i18n/schema-tool-strings"; import { pageTitle } from "$lib/i18n/schema-tool-strings";
import { t } from "$lib/i18n/t"; import { t } from "$lib/i18n/t";
@@ -27,6 +28,7 @@
onMount(() => { onMount(() => {
initLocale(); initLocale();
initTheme(); initTheme();
initFavorites();
}); });
let crumb = $derived(toCrumb(page.url.pathname)); let crumb = $derived(toCrumb(page.url.pathname));
+27 -4
View File
@@ -4,36 +4,59 @@
import CatalogHeader from "$lib/components/catalog/CatalogHeader.svelte"; import CatalogHeader from "$lib/components/catalog/CatalogHeader.svelte";
import CatalogToolbar from "$lib/components/catalog/CatalogToolbar.svelte"; import CatalogToolbar from "$lib/components/catalog/CatalogToolbar.svelte";
import ToolCard from "$lib/components/catalog/ToolCard.svelte"; import ToolCard from "$lib/components/catalog/ToolCard.svelte";
import Seo from "$lib/components/Seo.svelte";
import { favFirst, isFav } from "$lib/favorites.svelte";
import { import {
pageDescription, pageDescription,
pageTitle, pageTitle,
searchPages, searchPages,
} 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 { PAGES } from "$lib/registry";
import { TOOL_ICONS } from "$lib/tool-icons"; import { TOOL_ICONS } from "$lib/tool-icons";
let query = $state(""); let query = $state("");
let category = $state<string>("all"); let category = $state<string>("all");
const favorites = $derived(
searchPages(
PAGES.filter((p) => isFav(p.slug)),
query,
),
);
const groups = $derived( const groups = $derived(
PREVIEW_GROUPS.map((g) => ({ PREVIEW_GROUPS.map((g) => ({
id: g.id, id: g.id,
label: t(`categories.${g.id}`), label: t(`categories.${g.id}`),
pages: searchPages(g.pages, query).filter( pages: favFirst(
searchPages(g.pages, query).filter(
() => category === "all" || category === g.id, () => category === "all" || category === g.id,
), ),
),
})).filter((g) => g.pages.length > 0), })).filter((g) => g.pages.length > 0),
); );
</script> </script>
<svelte:head> <Seo title={t("home.defaultTitle")} description={t("home.heroLead")} path="/" />
<title>{t("home.defaultTitle")}</title>
</svelte:head>
<div class="catalog-page"> <div class="catalog-page">
<CatalogHeader total={PREVIEW_TOTAL} /> <CatalogHeader total={PREVIEW_TOTAL} />
<CatalogToolbar bind:query bind:category /> <CatalogToolbar bind:query bind:category />
<div class="catalog-groups"> <div class="catalog-groups">
{#if favorites.length > 0}
<CatalogGroup label={t("catalog.favorites")} count={favorites.length}>
{#each favorites as page, i (page.slug)}
<ToolCard
title={pageTitle(page)}
slug={page.slug}
description={pageDescription(page)}
index={i + 1}
icon={TOOL_ICONS[page.slug]}
/>
{/each}
</CatalogGroup>
{/if}
{#each groups as group (group.id)} {#each groups as group (group.id)}
<CatalogGroup label={group.label} count={group.pages.length}> <CatalogGroup label={group.label} count={group.pages.length}>
{#each group.pages as page, i (page.slug)} {#each group.pages as page, i (page.slug)}
+31 -5
View File
@@ -4,37 +4,63 @@
import CatalogHeader from "$lib/components/catalog/CatalogHeader.svelte"; import CatalogHeader from "$lib/components/catalog/CatalogHeader.svelte";
import CatalogToolbar from "$lib/components/catalog/CatalogToolbar.svelte"; import CatalogToolbar from "$lib/components/catalog/CatalogToolbar.svelte";
import ToolCard from "$lib/components/catalog/ToolCard.svelte"; import ToolCard from "$lib/components/catalog/ToolCard.svelte";
import Seo from "$lib/components/Seo.svelte";
import { favFirst, isFav } from "$lib/favorites.svelte";
import { import {
pageDescription, pageDescription,
pageTitle, pageTitle,
searchPages, searchPages,
} 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 { PAGES } from "$lib/registry";
import { TOOL_ICONS } from "$lib/tool-icons"; import { TOOL_ICONS } from "$lib/tool-icons";
let query = $state(""); let query = $state("");
let category = $state<string>("all"); let category = $state<string>("all");
const favorites = $derived(
searchPages(
PAGES.filter((p) => isFav(p.slug)),
query,
),
);
const groups = $derived( const groups = $derived(
PREVIEW_GROUPS.map((g) => ({ PREVIEW_GROUPS.map((g) => ({
id: g.id, id: g.id,
label: t(`categories.${g.id}`), label: t(`categories.${g.id}`),
pages: searchPages(g.pages, query).filter( pages: favFirst(
searchPages(g.pages, query).filter(
() => category === "all" || category === g.id, () => category === "all" || category === g.id,
), ),
),
})).filter((g) => g.pages.length > 0), })).filter((g) => g.pages.length > 0),
); );
</script> </script>
<svelte:head> <Seo
<title>{t("catalog.pageTitle")}</title> title={t("catalog.pageTitle")}
<meta name="description" content={t("catalog.metaDescription")} /> description={t("catalog.metaDescription")}
</svelte:head> path="/list-tools"
/>
<div class="catalog-page"> <div class="catalog-page">
<CatalogHeader total={PREVIEW_TOTAL} /> <CatalogHeader total={PREVIEW_TOTAL} />
<CatalogToolbar bind:query bind:category /> <CatalogToolbar bind:query bind:category />
<div class="catalog-groups"> <div class="catalog-groups">
{#if favorites.length > 0}
<CatalogGroup label={t("catalog.favorites")} count={favorites.length}>
{#each favorites as page, i (page.slug)}
<ToolCard
title={pageTitle(page)}
slug={page.slug}
description={pageDescription(page)}
index={i + 1}
icon={TOOL_ICONS[page.slug]}
/>
{/each}
</CatalogGroup>
{/if}
{#each groups as group (group.id)} {#each groups as group (group.id)}
<CatalogGroup label={group.label} count={group.pages.length}> <CatalogGroup label={group.label} count={group.pages.length}>
{#each group.pages as page, i (page.slug)} {#each group.pages as page, i (page.slug)}
+12 -1
View File
@@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import EmptyState from "$lib/components/feedback/EmptyState.svelte"; import EmptyState from "$lib/components/feedback/EmptyState.svelte";
import SchemaToolView from "$lib/components/schema/SchemaToolView.svelte"; import SchemaToolView from "$lib/components/schema/SchemaToolView.svelte";
import Seo from "$lib/components/Seo.svelte";
import { pageDescription, pageTitle } from "$lib/i18n/schema-tool-strings";
import { t } from "$lib/i18n/t"; import { t } from "$lib/i18n/t";
import { getPageBySlug, getTool } from "$lib/registry"; import { getPageBySlug, getTool } from "$lib/registry";
import { SlidersHorizontal as ToolIcon } from "@lucide/svelte"; import { SlidersHorizontal as ToolIcon } from "@lucide/svelte";
@@ -14,8 +16,17 @@
const tool = $derived(page ? getTool(page.steps[0].id) : undefined); const tool = $derived(page ? getTool(page.steps[0].id) : undefined);
</script> </script>
{#if page}
<Seo
title={`easy-png-tools / ${pageTitle(page)}`}
description={pageDescription(page)}
path={`/tools/${page.slug}`}
/>
{/if}
<svelte:head> <svelte:head>
<title>easy-png-tools / {page?.title ?? t("toolPage.fallbackTitle")}</title> {#if !page}
<title>easy-png-tools / {t("toolPage.fallbackTitle")}</title>
{/if}
<meta name="robots" content="noindex, nofollow" /> <meta name="robots" content="noindex, nofollow" />
</svelte:head> </svelte:head>
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB