Compare commits
13
Commits
6cbb9f95f0
...
a1438e6f7f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1438e6f7f | ||
|
|
5884f4bd82 | ||
|
|
e5b76ca3e1 | ||
|
|
a49785764b | ||
|
|
c0cebdc81f | ||
|
|
37a4a0576c | ||
|
|
b3d460a951 | ||
|
|
6be9d10c42 | ||
|
|
cc70750727 | ||
|
|
e2735c52ab | ||
|
|
d383cf29e3 | ||
|
|
bfad0c7d02 | ||
|
|
6750d5a373 |
@@ -1,8 +1,9 @@
|
|||||||
# План: дожать перевод инструментов в preview и переезд ветки `old/`
|
# План: дожать перевод инструментов в preview и переезд ветки `old/`
|
||||||
|
|
||||||
> Статус: **план.** Основа — `plan-composite-params.md` (Фазы 1–5). Текущее
|
> Статус: **переведено 121 из 125; остальные 4 — отложены/закрыты решениями.**
|
||||||
> состояние: **40 из 125 инструментов переведены в `registry-new`**, старый UI
|
> Текущее
|
||||||
> уже живёт на `(old)/`-маршрутах, линтер-изоляция old↔preview на месте
|
> состояние: **121 инструмент переведён в `registry-new`**, старый UI уже живёт
|
||||||
|
> на `(old)/`-маршрутах, линтер-изоляция old↔preview на месте
|
||||||
> (плагин `isolation/no-mixed-imports`). Здесь — что сделать, чтобы:
|
> (плагин `isolation/no-mixed-imports`). Здесь — что сделать, чтобы:
|
||||||
>
|
>
|
||||||
> 1. в preview был **весь** каталог инструментов (плюс закрыты известные долги);
|
> 1. в preview был **весь** каталог инструментов (плюс закрыты известные долги);
|
||||||
@@ -16,13 +17,16 @@
|
|||||||
|
|
||||||
## Текущая картина (факты)
|
## Текущая картина (факты)
|
||||||
|
|
||||||
- `registry.ts` + `registry/` — **125 инструментов**; `registry-new/` — **40**;
|
- `registry.ts` + `registry/` — **125 инструментов**; `registry-new/` — **121**.
|
||||||
остаток — **85**. (Ранний подсчёт «104» был занижен: извлечение суффиксом
|
Не переведены только: `png-info` (отложен, хвост-фича с exif), `compress-png`
|
||||||
`-png` теряло каналы `png-to-hsl/…`, текстовые вердикты `png-info` и т.п. и
|
и `reduce-to-size-png` (закрыто решениями, см. ниже), `watermark-image-png`
|
||||||
обратные конвертеры `png-to-base64/…`.)
|
(overlay — единственный реальный остаток).
|
||||||
- Preview (`/preview`) показывает только переведённые 40; старый UI работает
|
- Preview (`/preview`) показывает переведённые; старый UI работает
|
||||||
на `/`-маршрутах (группа `(old)/`): `+page`, `demo`, `list-tools`,
|
на `/`-маршрутах (группа `(old)/`): `+page`, `demo`, `list-tools`,
|
||||||
`tools/[id]`.
|
`tools/[id]`. Переведено всё кроме четырёх хвостов: color 18, filters 3,
|
||||||
|
geometry 13, alpha 13 (+watermark-image в остатке), generate 10 (mix/sort),
|
||||||
|
analyze 12 (6 масок + verify + 5 вердиктов), convert 14 (jpg/webp/bmp +
|
||||||
|
5 png→texт + 6 text→png), text `watermark-tile-png`.
|
||||||
- Плагин `isolation/no-mixed-imports` (`web/eslint-plugins/isolation/`)
|
- Плагин `isolation/no-mixed-imports` (`web/eslint-plugins/isolation/`)
|
||||||
полностью разделяет ветки: старый `lib/registry.ts|/registry/**`,
|
полностью разделяет ветки: старый `lib/registry.ts|/registry/**`,
|
||||||
`lib/registry-helpers.ts`, `lib/categories.ts`, `lib/tools/**`,
|
`lib/registry-helpers.ts`, `lib/categories.ts`, `lib/tools/**`,
|
||||||
@@ -39,72 +43,41 @@
|
|||||||
`ColorField.svelte`, пустой ruleset в `Toggle.svelte`, неиспользуемые токены
|
`ColorField.svelte`, пустой ruleset в `Toggle.svelte`, неиспользуемые токены
|
||||||
в `preview.css`.
|
в `preview.css`.
|
||||||
|
|
||||||
## Скоуп перевода: 85 инструментов осталось
|
## Скоуп перевода: переведено всё переводимое (отложено 4 хвоста)
|
||||||
|
|
||||||
По категориям (в скобках — сколько из категории уже переведено; подсчёт по
|
Каталог дожат до предела текущих решений: переведено 121 из 125. Оставшиеся
|
||||||
`registry/` vs `registry-new/`, без учёта генерируемых `SpaceId` и params):
|
4 инструмента **отложены осознанно** (не делаем сейчас):
|
||||||
|
|
||||||
- **color — 18** (из 25, переведено 7): grayscale-png, invert-colors-png,
|
- **`watermark-image-png` (alpha)** — **отложен**. Нужна overlay-механика
|
||||||
adjust-brightness-contrast-png, auto-contrast-png, sepia-png, posterize-png,
|
(второе изображение-знак через `getOverlay`/store + параметры
|
||||||
black-and-white-png, decrease-color-count-png, extract-channel-png,
|
scale/opacity/position/margin/aspect); решается отдельно, вместе с вопросом,
|
||||||
swap-channels-png, change-png-opacity, change-png-hue, плюс 6 канальных
|
как второй источник изображения вписывается в schema-driven preview.
|
||||||
`png-to-{hsl,hsv,hsi,cmyk,ycbcr,lab}` (генерируются из `SPACES`);
|
- **`png-info`** — **отложен осознанно**: будет серьёзно дорабатываться
|
||||||
`compress-png`/`reduce-to-size-png` — нет, они в convert. Простое; канальные —
|
отдельным райзом (exif-теги, редактирование, структурированный вывод),
|
||||||
на `core/channels.ts` (`renderSpace`). Нюанс: `change-png-opacity`/`hue`
|
для него нужен свой отдельный случай в UI, не «ещё один text/verdict».
|
||||||
похожи на переведённые, но это отдельные инструменты, не дубли.
|
- **`compress-png`** — **закрыт как дубликат** `decrease-color-count-png`:
|
||||||
- **geometry — 13** (из 18): rotate, flip, skew, zoom, shift, add-padding, tile,
|
пресеты сжатия 192/96/44 добавлены прямо в схему `decrease-color-count`.
|
||||||
trim-empty-space, change-aspect-ratio, swap-orientation, symmetric-copy,
|
- **`reduce-to-size-png`** — **отложен** до достройки кнопки Download
|
||||||
center-by-alpha, rotate-free. В основном простое (slider/select/color/checkbox).
|
(backlog №7, «экспорт с лимитом размера»).
|
||||||
Нюансы: `rotate-free` — угол 0..360 (посмотреть reuse `AngleControl` или
|
|
||||||
slider); `tile` — cols/rows (два числа, можно `dimension` или два slider);
|
|
||||||
`shift` — offsetX/Y в px + цвет (не путать с `offset`-процентами).
|
|
||||||
- **alpha — 14** (из 21): remove-alpha, set-alpha, extract-alpha-mask,
|
|
||||||
round-corners, invert-alpha, remove-background, make-thicker, make-thinner,
|
|
||||||
feather-edges, clean-edges, harden-alpha, despeckle-alpha, close-holes +
|
|
||||||
**`watermark-image-png`** (см. особый случай). Остальное простое.
|
|
||||||
- **generate — 10** (из 21): emoji-to-png (emoji+size); палитровые генераторы
|
|
||||||
color-wheel, complementary, analogous, triadic, tetradic, monochromatic,
|
|
||||||
shades (baseColor + count/spread — простое); **`mix-colors`** и
|
|
||||||
**`sort-colors`** — особые случаи (списки).
|
|
||||||
- **convert — 14** (из 16): text-source-инструменты
|
|
||||||
base64/data-uri/hex/bytes/rgb-values-to-png + обратные
|
|
||||||
png-to-base64/data-uri/hex/bytes/rgb-values/bmp — **особый случай** (нужен
|
|
||||||
text-источник в preview); `compress-png`/`reduce-to-size-png` — **особый
|
|
||||||
случай** (output/формат, связан с backlog №7); `svg-to-png` — width.
|
|
||||||
- **analyze — 12** (из 13): show-transparent, show-grayscale, show-color,
|
|
||||||
light/dark-pixel-mask, unique-color-mask (color+opacity+tolerance+mode) —
|
|
||||||
простое, частично переиспользуемый предикат; **`verify-is-png`** — текстовый
|
|
||||||
вердикт, особый случай; плюс 5 text-вердиктов `png-info`, `png-is-grayscale`,
|
|
||||||
`png-file-size`, `png-is-transparent`, `png-orientation` — тоже текстовый
|
|
||||||
вывод (см. text-source/вердикт-механику).
|
|
||||||
- **filters — 3** (из 8): blur (radius), sharpen (strength), silhouette
|
|
||||||
(color+threshold). Простое.
|
|
||||||
- **text — 1** (из 3): **`watermark-tile-png`** — text+font-style+opacity+angle+
|
|
||||||
stepX/stepY. Переиспользует font-style; угол — AngleControl-подобный.
|
|
||||||
|
|
||||||
### Особые случаи (требуют достройки фундамента, не просто перевод)
|
### Переведено в этом проходе (text-механика)
|
||||||
|
|
||||||
1. **`watermark-image-png`** — нужна overlay-механика: второе изображение-знак
|
- **Типы:** `ToolEntry.input: "file" | "text" | "none"`,
|
||||||
(`getOverlay`/store), которое загружается на странице инструмента и
|
`ToolEntry.result: "image" | "text" | "verdict"`, `OutputFormat` (mime/ext/
|
||||||
применяется поверх. В новом preview это единственный непереведённый
|
qualityParamId) для download.
|
||||||
инструмент с источником-картинкой помимо основного входа. Учесть связь с
|
- **Executor:** `executeFromText` / `executeToText` / `executeTextToText`
|
||||||
`overlay-store` (упомянут в `plan-redesign.md`).
|
(прямые, без worker), экспорт в `preview/executor/index.ts`.
|
||||||
2. **Text-source / text-вердикты** (base64/data-uri/hex/bytes/rgb-values-to-png
|
- **UI:** `SchemaTextSource` (textarea + Render), `SchemaTextResult`
|
||||||
и обратные png-to-base64/…; аналитические `png-info`, `png-is-grayscale`,
|
(кол-блок с Copy/Download .txt или бейдж-вердикт по префиксу Yes/No),
|
||||||
`png-file-size`, `png-is-transparent`, `png-orientation`, `verify-is-png`) —
|
интеграция в `SchemaPreview`/`SchemaToolView` (режимы file/text/generate,
|
||||||
в старом UI это `text`-параметры, из которых собирается изображение, либо
|
рендер image/text/verdict, скрытие image-download для text-результата).
|
||||||
текстовый результат вместо картинки. В новом preview нужно решить, как
|
- **Инструменты:** convert 5×png→text (base64/data-uri/hex/bytes/rgb-values),
|
||||||
подаётся текст-вход (текстовое поле/паста на странице инструмента) и как
|
6×text→png (base64/data-uri/hex/bytes/rgb-values/svg, +width), analyze
|
||||||
показывается результат для вердиктных инструментов (в конверсиях — не
|
verify-is-png (text→text) и 5 вердиктов (grayscale/file-size/transparent/
|
||||||
картинка, а текст/бейдж).
|
orientation). Доработка: `base64ToBytes` сужен до `Uint8Array<ArrayBuffer>`
|
||||||
3. **Output/формат** (`compress-png`, `reduce-to-size-png`; пересекается с
|
(совместимость с `decodeBytes`).
|
||||||
`convert-png-to-jpg`/`webp`) — по `backlog.md` №7 формат переезжает в
|
- **Гейты:** eslint 0, prettier чист, svelte-check — только предсуществующий
|
||||||
кнопку Download, а не в отдельный инструмент. Перед переводом решить:
|
ToolCard, тесты — только предсуществующий фейл `palette.test.ts`.
|
||||||
переводим как инструменты с quality-параметром или дожидаемся достройки
|
|
||||||
download-формата.
|
|
||||||
4. **`mix-colors` / `sort-colors`** — нужен список значений (цвета/веса или
|
|
||||||
источник). В схеме нет list-kind: либо новый kind, либо фиксированное
|
|
||||||
число слотов, либо отложить до отдельной задачи про списки.
|
|
||||||
|
|
||||||
## Известные долги перед переездом (завести tasks и закрыть)
|
## Известные долги перед переездом (завести tasks и закрыть)
|
||||||
|
|
||||||
@@ -119,20 +92,18 @@ a11y `ColorField`, пустой ruleset `Toggle`. Плюс два, мешающ
|
|||||||
|
|
||||||
## Шаги перевода остатка (порядок)
|
## Шаги перевода остатка (порядок)
|
||||||
|
|
||||||
1. **Простые пачки** (основной объём, 1 инструмент = 1 диф, паттерн Фазы 2):
|
1. **Простые пачки** — **выполнено**: color (18) → filters (3) → geometry (13)
|
||||||
color (18) → filters (3) → geometry простое → alpha (кроме watermark-image).
|
→ alpha (13/14, кроме watermark-image) → generate (10, включая mix/sort
|
||||||
2. **Analyze-маски и вердикты** (12): переиспользование предикатов
|
через kind `colors`) → analyze-маски (6) → text `watermark-tile-png`.
|
||||||
`core/masks` в новом `registry-new/analyze.ts`; `show-color-pixels` — аналог
|
2. **Text-механика** — **выполнено**: типы `input`/`result` + output-формат,
|
||||||
переведённого `extract-color-from-png` (уже в registry-new); text-вердикты
|
executor `executeFromText`/`toText`/`textToText`, UI
|
||||||
(png-info, png-is-grayscale, png-file-size, png-is-transparent,
|
`SchemaTextSource`/`SchemaTextResult` + интеграция в preview, перевод
|
||||||
png-orientation, verify-is-png) — вместе с text-source-механикой (п. 5).
|
конвертеров convert (5 png→text + 6 text→png) и analyze (verify + 5
|
||||||
3. **Generate-палитры** (8 без mix/sort): генераторы с палитрой (как
|
вердиктов). `png-info` отложен отдельным райзом (exif).
|
||||||
`color-spectrum`/`step-colors`, кнопка Generate).
|
3. **Шаг про overlay не делается** — `watermark-image-png` **отложен решением**
|
||||||
4. **Raw-пакеты** (watermark-tile): font-style + угол + dimension(+),
|
(см. «Скоуп перевода» выше), отдельным райзом вместе с вопросом второго
|
||||||
сверить с `add-text` (переведён).
|
источника изображения в schema-driven preview.
|
||||||
5. **Особые случаи** — отдельным переговоренным решением (overlay для
|
4. После категории — `schema.layout` для сгруппированных инструментов
|
||||||
watermark-image, text-source, output-формат, списки для mix/sort).
|
|
||||||
6. После категории — `schema.layout` для сгруппированных инструментов
|
|
||||||
(шаг 32-33 паттерн) и ручная проверка в `/preview`.
|
(шаг 32-33 паттерн) и ручная проверка в `/preview`.
|
||||||
|
|
||||||
## Переезд в `old/` (шаг 37 основного плана)
|
## Переезд в `old/` (шаг 37 основного плана)
|
||||||
@@ -163,7 +134,8 @@ a11y `ColorField`, пустой ruleset `Toggle`. Плюс два, мешающ
|
|||||||
дефолты совпадают со старыми, валидация/клампы поведения не меняют,
|
дефолты совпадают со старыми, валидация/клампы поведения не меняют,
|
||||||
результат эквивалентен старому UI (сверить на одинаковых входах).
|
результат эквивалентен старому UI (сверить на одинаковых входах).
|
||||||
2. **Тяжёлые кейсы руками:** генераторы, маски, font-style/plate/gradient на
|
2. **Тяжёлые кейсы руками:** генераторы, маски, font-style/plate/gradient на
|
||||||
всем наборе, watermark-image (после overlay), text-source, output-формат.
|
всем наборе, text-source, output-формат. (`watermark-image` — позже, после
|
||||||
|
overlay-райза.)
|
||||||
3. **Сбор фидбека** — отдельные задачи на каждый найденный фикс; чеклист
|
3. **Сбор фидбека** — отдельные задачи на каждый найденный фикс; чеклист
|
||||||
зафиксировать в этом документе по мере находок (или ссылкой на tasks).
|
зафиксировать в этом документе по мере находок (или ссылкой на tasks).
|
||||||
4. Сверка каталога с `tools-map.md` (покрытие/дедупликация
|
4. Сверка каталога с `tools-map.md` (покрытие/дедупликация
|
||||||
@@ -186,17 +158,15 @@ a11y `ColorField`, пустой ruleset `Toggle`. Плюс два, мешающ
|
|||||||
|
|
||||||
## Оценка трудозатрат
|
## Оценка трудозатрат
|
||||||
|
|
||||||
| Часть | Сложность | Оценка |
|
| Часть | Сложность | Оценка |
|
||||||
| ---------------------------------------------------------------- | --------------- | --------------------- |
|
| ---------------------------------------------------------------------- | -------------- | ------------------------- |
|
||||||
| Простые инструменты (color/filters/geometry/alpha без watermark) | Низкая-Средняя | ~5-8ч (≈47 шт) |
|
| Простые инструменты (color/filters/geometry/alpha/generate/маски/text) | Низкая-Средняя | **выполнено** (62 шт) |
|
||||||
| Analyze-маски (12, часть в п.5 с text-source) | Средняя | ~2ч |
|
| Text-механика + конвертеры/вердикты | Средняя | **выполнено** (16 шт) |
|
||||||
| Generate-палитры (8) | Средняя | ~2-3ч |
|
| watermark-image / png-info / reduce-to-size (отложены решениями) | Высокая | отложено, отдельные райзы |
|
||||||
| watermark-tile (text) | Средняя | ~1ч |
|
| Долги (ToolCard, palette.test.ts, lint:all остаток) | Средняя | ~2-4ч |
|
||||||
| Особые случаи (overlay/text-source/format/списки) | Средняя-Высокая | ~4-6ч + ревью решения |
|
| Переезд в `old/` + конфиг плагина + гейты | Средняя-Низкая | ~2-4ч |
|
||||||
| Долги (ToolCard, palette.test.ts, lint:all остаток) | Средняя | ~2-4ч |
|
| Проверка/фидбек + правки | Зависит | ~3-6ч |
|
||||||
| Переезд в `old/` + конфиг плагина + гейты | Средняя-Низкая | ~2-4ч |
|
| **Итого** | | **~9-18ч** по фазам |
|
||||||
| Проверка/фидбек + правки | Зависит | ~3-6ч |
|
|
||||||
| **Итого** | | **~21-34ч** по фазам |
|
|
||||||
|
|
||||||
> Оценки ориентировочные; особые случаи — с ревью решения, объём финальных
|
> Оценки ориентировочные; особые случаи — с ревью решения, объём финальных
|
||||||
> правок по фидбеку не предсказуем.
|
> правок по фидбеку не предсказуем.
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import DownloadButton from "./ui/DownloadButton.svelte";
|
||||||
|
import { Sparkles, Upload } from "@lucide/svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
inputMode: "file" | "text" | "none";
|
||||||
|
canDownload: boolean;
|
||||||
|
running: boolean;
|
||||||
|
onupload: (file: File) => void;
|
||||||
|
ongenerate: () => void;
|
||||||
|
ondownload: () => void;
|
||||||
|
}
|
||||||
|
let {
|
||||||
|
inputMode,
|
||||||
|
canDownload,
|
||||||
|
running,
|
||||||
|
onupload,
|
||||||
|
ongenerate,
|
||||||
|
ondownload,
|
||||||
|
}: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
{#if inputMode === "none"}
|
||||||
|
<button class="btn generate" onclick={ongenerate} disabled={running}>
|
||||||
|
<Sparkles size={14} />
|
||||||
|
{running ? "Generating…" : "Generate"}
|
||||||
|
</button>
|
||||||
|
{:else if inputMode === "file"}
|
||||||
|
<label class="btn upload">
|
||||||
|
<Upload size={14} /> Open image
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onchange={(e) => {
|
||||||
|
const f = (e.target as HTMLInputElement).files?.[0];
|
||||||
|
if (f) onupload(f);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
{#if canDownload}
|
||||||
|
<DownloadButton label="Download result" onclick={ondownload} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-l);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-m);
|
||||||
|
padding: var(--space-m) var(--space-l);
|
||||||
|
border: var(--size-border) solid var(--color-border);
|
||||||
|
border-radius: var(--radius-m);
|
||||||
|
font: var(--font-size-s) var(--font-mono);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.generate {
|
||||||
|
background: var(--color-main);
|
||||||
|
border-color: var(--color-main);
|
||||||
|
color: var(--color-background);
|
||||||
|
}
|
||||||
|
.generate:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.upload input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
import CheckboxControl from "./fields/schema/CheckboxControl.svelte";
|
import CheckboxControl from "./fields/schema/CheckboxControl.svelte";
|
||||||
import ColorControl from "./fields/schema/ColorControl.svelte";
|
import ColorControl from "./fields/schema/ColorControl.svelte";
|
||||||
import ColorPairControl from "./fields/schema/ColorPairControl.svelte";
|
import ColorPairControl from "./fields/schema/ColorPairControl.svelte";
|
||||||
|
import ColorsControl from "./fields/schema/ColorsControl.svelte";
|
||||||
import RangeControl from "./fields/schema/RangeControl.svelte";
|
import RangeControl from "./fields/schema/RangeControl.svelte";
|
||||||
import SelectControl from "./fields/schema/SelectControl.svelte";
|
import SelectControl from "./fields/schema/SelectControl.svelte";
|
||||||
import TextControl from "./fields/schema/TextControl.svelte";
|
import TextControl from "./fields/schema/TextControl.svelte";
|
||||||
@@ -31,6 +32,7 @@
|
|||||||
slider: RangeControl,
|
slider: RangeControl,
|
||||||
color: ColorControl,
|
color: ColorControl,
|
||||||
"color-pair": ColorPairControl,
|
"color-pair": ColorPairControl,
|
||||||
|
colors: ColorsControl,
|
||||||
select: SelectControl,
|
select: SelectControl,
|
||||||
checkbox: CheckboxControl,
|
checkbox: CheckboxControl,
|
||||||
text: TextControl,
|
text: TextControl,
|
||||||
|
|||||||
@@ -1,64 +1,78 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import MetaList from "$lib/components/kit/MetaList.svelte";
|
import MetaList from "$lib/components/kit/MetaList.svelte";
|
||||||
import DownloadButton from "$lib/components/kit/ui/DownloadButton.svelte";
|
import SchemaActions from "$lib/components/kit/SchemaActions.svelte";
|
||||||
import { toDataUrl } from "$lib/core/io";
|
import SchemaResultTile from "$lib/components/kit/SchemaResultTile.svelte";
|
||||||
|
import SchemaSourceTile from "$lib/components/kit/SchemaSourceTile.svelte";
|
||||||
import type { PixelImage } from "$lib/core/types";
|
import type { PixelImage } from "$lib/core/types";
|
||||||
import { Check, Sparkles, Upload } from "@lucide/svelte";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
inputMode?: "file" | "text" | "none";
|
||||||
|
resultKind?: "image" | "text" | "verdict";
|
||||||
source: PixelImage | null;
|
source: PixelImage | null;
|
||||||
result: PixelImage | null;
|
result: PixelImage | null;
|
||||||
running: boolean;
|
textSource?: string;
|
||||||
error: string;
|
textResult?: string | null;
|
||||||
isGenerator?: boolean;
|
running?: boolean;
|
||||||
|
error?: string;
|
||||||
onupload: (file: File) => void;
|
onupload: (file: File) => void;
|
||||||
ongenerate?: () => void;
|
ongenerate?: () => void;
|
||||||
|
ontextsource?: (text: string) => void;
|
||||||
|
onrendertext?: () => void;
|
||||||
|
oncopytext?: () => void;
|
||||||
|
ondownloadtxt?: () => void;
|
||||||
ondownload: () => void;
|
ondownload: () => void;
|
||||||
}
|
}
|
||||||
let {
|
let {
|
||||||
|
inputMode = "file",
|
||||||
|
resultKind = "image",
|
||||||
source,
|
source,
|
||||||
result,
|
result,
|
||||||
running,
|
textSource = "",
|
||||||
error,
|
textResult = null,
|
||||||
isGenerator = false,
|
running = false,
|
||||||
|
error = "",
|
||||||
onupload,
|
onupload,
|
||||||
ongenerate,
|
ongenerate,
|
||||||
|
ontextsource,
|
||||||
|
onrendertext,
|
||||||
|
oncopytext,
|
||||||
|
ondownloadtxt,
|
||||||
ondownload,
|
ondownload,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let sourceUrl = $derived(source ? toDataUrl(source) : null);
|
const isGenerator = $derived(inputMode === "none");
|
||||||
let resultUrl = $derived(result ? toDataUrl(result) : null);
|
const sourceValue = $derived(
|
||||||
|
isGenerator
|
||||||
|
? "—"
|
||||||
|
: inputMode === "text"
|
||||||
|
? "text"
|
||||||
|
: source
|
||||||
|
? `${source.width} × ${source.height} px`
|
||||||
|
: "—",
|
||||||
|
);
|
||||||
|
const resultValue = $derived(
|
||||||
|
resultKind === "image"
|
||||||
|
? result
|
||||||
|
? `${result.width} × ${result.height} px`
|
||||||
|
: "—"
|
||||||
|
: textResult
|
||||||
|
? "text"
|
||||||
|
: "—",
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<span class="label"
|
<span class="label"
|
||||||
>{isGenerator ? "GENERATOR / RESULT" : "SOURCE / RESULT"}</span
|
>{isGenerator ? "GENERATOR / RESULT" : "SOURCE / RESULT"}</span
|
||||||
>
|
>
|
||||||
<div class="head-actions">
|
<SchemaActions
|
||||||
{#if isGenerator}
|
{inputMode}
|
||||||
<button
|
canDownload={resultKind === "image" && Boolean(result)}
|
||||||
class="upload generate-btn"
|
{running}
|
||||||
onclick={ongenerate}
|
{onupload}
|
||||||
disabled={running}
|
ongenerate={ongenerate ?? (() => {})}
|
||||||
>
|
{ondownload}
|
||||||
<Sparkles size={14} />
|
/>
|
||||||
{running ? "Generating…" : "Generate"}
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<label class="upload">
|
|
||||||
<Upload size={14} /> Open image
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
onchange={(e) => {
|
|
||||||
const f = (e.target as HTMLInputElement).files?.[0];
|
|
||||||
if (f) onupload(f);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{/if}
|
|
||||||
<DownloadButton label="Download result" onclick={ondownload} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
@@ -67,44 +81,31 @@
|
|||||||
|
|
||||||
<div class="pair">
|
<div class="pair">
|
||||||
{#if !isGenerator}
|
{#if !isGenerator}
|
||||||
<figure class="tile">
|
<SchemaSourceTile
|
||||||
<figcaption><span>SOURCE</span></figcaption>
|
mode={inputMode === "text" ? "text" : "file"}
|
||||||
<div class="canvas">
|
{source}
|
||||||
{#if sourceUrl}
|
{textSource}
|
||||||
<img src={sourceUrl} alt="source" />
|
{running}
|
||||||
{:else}
|
ontextinput={ontextsource}
|
||||||
<span class="empty">choose an image</span>
|
{onrendertext}
|
||||||
{/if}
|
/>
|
||||||
</div>
|
|
||||||
</figure>
|
|
||||||
{/if}
|
{/if}
|
||||||
<figure class="tile" class:full={isGenerator}>
|
<SchemaResultTile
|
||||||
<figcaption><span>RESULT {running ? "…" : ""}</span></figcaption>
|
{resultKind}
|
||||||
<div class="canvas checker">
|
{result}
|
||||||
{#if resultUrl}
|
{textResult}
|
||||||
<img src={resultUrl} alt="result" />
|
wide={isGenerator || resultKind !== "image"}
|
||||||
{:else if running}
|
{running}
|
||||||
<Check size={22} />
|
oncopy={oncopytext}
|
||||||
{:else}
|
{ondownloadtxt}
|
||||||
<span class="empty">
|
/>
|
||||||
{isGenerator ? "click Generate" : "no result yet"}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</figure>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
<MetaList
|
<MetaList
|
||||||
items={[
|
items={[
|
||||||
{
|
{ caption: "SOURCE", value: sourceValue },
|
||||||
caption: "SOURCE",
|
{ caption: "RESULT", value: resultValue },
|
||||||
value: source ? `${source.width} × ${source.height} px` : "—",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
caption: "RESULT",
|
|
||||||
value: result ? `${result.width} × ${result.height} px` : "—",
|
|
||||||
},
|
|
||||||
{ caption: "FORMAT", value: "PNG" },
|
{ caption: "FORMAT", value: "PNG" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -125,35 +126,6 @@
|
|||||||
letter-spacing: var(--space-text-l);
|
letter-spacing: var(--space-text-l);
|
||||||
color: var(--color-main);
|
color: var(--color-main);
|
||||||
}
|
}
|
||||||
.head-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-l);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.upload {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-m);
|
|
||||||
padding: var(--space-m) var(--space-l);
|
|
||||||
border: var(--size-border) solid var(--color-border);
|
|
||||||
border-radius: var(--radius-m);
|
|
||||||
font: var(--font-size-s) var(--font-mono);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.generate-btn {
|
|
||||||
background: var(--color-main);
|
|
||||||
border-color: var(--color-main);
|
|
||||||
color: var(--color-background);
|
|
||||||
}
|
|
||||||
.generate-btn:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
.upload input {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.error {
|
.error {
|
||||||
margin: 0 0 var(--space-l);
|
margin: 0 0 var(--space-l);
|
||||||
color: var(--color-danger);
|
color: var(--color-danger);
|
||||||
@@ -164,44 +136,6 @@
|
|||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: var(--space-l);
|
gap: var(--space-l);
|
||||||
}
|
}
|
||||||
.pair > .full {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
}
|
|
||||||
.tile figcaption {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-m);
|
|
||||||
margin-bottom: var(--space-m);
|
|
||||||
font: var(--font-size-s) var(--font-mono);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
.canvas {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: clamp(var(--space-brand), 30vh, 60vh);
|
|
||||||
border: var(--size-border) solid var(--color-border);
|
|
||||||
border-radius: var(--radius-m);
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--color-background-muted);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
.canvas img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: contain;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
.canvas.checker {
|
|
||||||
background: repeating-conic-gradient(
|
|
||||||
var(--color-checker-main) 0 25%,
|
|
||||||
var(--color-checker-alt) 0 50%
|
|
||||||
)
|
|
||||||
50% / 28px 28px;
|
|
||||||
}
|
|
||||||
.empty {
|
|
||||||
font: var(--font-size-s) var(--font-mono);
|
|
||||||
}
|
|
||||||
.meta {
|
.meta {
|
||||||
margin-top: var(--space-l);
|
margin-top: var(--space-l);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import SchemaTextResult from "./SchemaTextResult.svelte";
|
||||||
|
import { toDataUrl } from "$lib/core/io";
|
||||||
|
import type { PixelImage } from "$lib/core/types";
|
||||||
|
import { Check } from "@lucide/svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
resultKind?: "image" | "text" | "verdict";
|
||||||
|
result: PixelImage | null;
|
||||||
|
textResult?: string | null;
|
||||||
|
wide?: boolean;
|
||||||
|
running?: boolean;
|
||||||
|
oncopy?: () => void;
|
||||||
|
ondownloadtxt?: () => void;
|
||||||
|
}
|
||||||
|
let {
|
||||||
|
resultKind = "image",
|
||||||
|
result,
|
||||||
|
textResult = null,
|
||||||
|
wide = false,
|
||||||
|
running = false,
|
||||||
|
oncopy,
|
||||||
|
ondownloadtxt,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const resultUrl = $derived(result ? toDataUrl(result) : null);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<figure class="tile" style:grid-column={wide ? "1 / -1" : undefined}>
|
||||||
|
<figcaption><span>RESULT {running ? "…" : ""}</span></figcaption>
|
||||||
|
<div class="canvas" class:checker={resultKind === "image"}>
|
||||||
|
{#if resultKind === "text" && textResult}
|
||||||
|
<div class="text-result-wrap">
|
||||||
|
<SchemaTextResult
|
||||||
|
value={textResult}
|
||||||
|
kind="text"
|
||||||
|
oncopy={oncopy ?? (() => {})}
|
||||||
|
ondownload={ondownloadtxt ?? (() => {})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else if resultKind === "verdict" && textResult}
|
||||||
|
<div class="text-result-wrap">
|
||||||
|
<SchemaTextResult
|
||||||
|
value={textResult}
|
||||||
|
kind="verdict"
|
||||||
|
oncopy={oncopy ?? (() => {})}
|
||||||
|
ondownload={ondownloadtxt ?? (() => {})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else if resultKind === "image" && resultUrl}
|
||||||
|
<img src={resultUrl} alt="result" />
|
||||||
|
{:else if running}
|
||||||
|
<Check size={22} />
|
||||||
|
{:else}
|
||||||
|
<span class="empty">no result yet</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.tile figcaption {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-m);
|
||||||
|
margin-bottom: var(--space-m);
|
||||||
|
font: var(--font-size-s) var(--font-mono);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
.canvas {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: clamp(var(--space-brand), 30vh, 60vh);
|
||||||
|
border: var(--size-border) solid var(--color-border);
|
||||||
|
border-radius: var(--radius-m);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--color-background-muted);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
.canvas img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.canvas.checker {
|
||||||
|
background: repeating-conic-gradient(
|
||||||
|
var(--color-checker-main) 0 25%,
|
||||||
|
var(--color-checker-alt) 0 50%
|
||||||
|
)
|
||||||
|
50% / 28px 28px;
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
font: var(--font-size-s) var(--font-mono);
|
||||||
|
}
|
||||||
|
.text-result-wrap {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-l);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import SchemaTextSource from "./SchemaTextSource.svelte";
|
||||||
|
import { toDataUrl } from "$lib/core/io";
|
||||||
|
import type { PixelImage } from "$lib/core/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
mode: "file" | "text";
|
||||||
|
source: PixelImage | null;
|
||||||
|
textSource?: string;
|
||||||
|
running?: boolean;
|
||||||
|
ontextinput?: (text: string) => void;
|
||||||
|
onrendertext?: () => void;
|
||||||
|
}
|
||||||
|
let {
|
||||||
|
mode,
|
||||||
|
source,
|
||||||
|
textSource = "",
|
||||||
|
running = false,
|
||||||
|
ontextinput,
|
||||||
|
onrendertext,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const sourceUrl = $derived(source ? toDataUrl(source) : null);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<figure class="tile">
|
||||||
|
<figcaption><span>SOURCE</span></figcaption>
|
||||||
|
<div class="canvas">
|
||||||
|
{#if mode === "text"}
|
||||||
|
<div class="text-source-wrap">
|
||||||
|
<SchemaTextSource
|
||||||
|
value={textSource}
|
||||||
|
disabled={running}
|
||||||
|
oninput={ontextinput ?? (() => {})}
|
||||||
|
onrender={onrendertext ?? (() => {})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else if sourceUrl}
|
||||||
|
<img src={sourceUrl} alt="source" />
|
||||||
|
{:else}
|
||||||
|
<span class="empty">choose an image</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.tile figcaption {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-m);
|
||||||
|
margin-bottom: var(--space-m);
|
||||||
|
font: var(--font-size-s) var(--font-mono);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
.canvas {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: clamp(var(--space-brand), 30vh, 60vh);
|
||||||
|
border: var(--size-border) solid var(--color-border);
|
||||||
|
border-radius: var(--radius-m);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--color-background-muted);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
.canvas img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
font: var(--font-size-s) var(--font-mono);
|
||||||
|
}
|
||||||
|
.text-source-wrap {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-l);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Copy, Download, FileText } from "@lucide/svelte";
|
||||||
|
import IconButton from "./ui/IconButton.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: string;
|
||||||
|
kind: "text" | "verdict";
|
||||||
|
fileName?: string;
|
||||||
|
oncopy: () => void;
|
||||||
|
ondownload: () => void;
|
||||||
|
}
|
||||||
|
let {
|
||||||
|
value,
|
||||||
|
kind,
|
||||||
|
fileName = "result.txt",
|
||||||
|
oncopy,
|
||||||
|
ondownload,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const tone = $derived(
|
||||||
|
/^(yes|true)/i.test(value.trim())
|
||||||
|
? "success"
|
||||||
|
: /^(no|false)/i.test(value.trim())
|
||||||
|
? "danger"
|
||||||
|
: "info",
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if kind === "verdict"}
|
||||||
|
<div class="verdict">
|
||||||
|
<span class="verdict-label">RESULT</span>
|
||||||
|
<div class="badge badge-tone--{tone}" role="status">
|
||||||
|
<span class="verdict-text">{value}</span>
|
||||||
|
<IconButton icon={Copy} label="Copy" onclick={oncopy} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="text-result">
|
||||||
|
<div class="result-head">
|
||||||
|
<span class="result-label"><FileText size={14} /> {fileName}</span>
|
||||||
|
<div class="result-actions">
|
||||||
|
<IconButton icon={Copy} label="Copy" onclick={oncopy} />
|
||||||
|
<IconButton
|
||||||
|
icon={Download}
|
||||||
|
label="Download .txt"
|
||||||
|
onclick={ondownload}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<pre class="result-pre"><code>{value}</code></pre>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.text-result {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-m);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.result-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-m);
|
||||||
|
}
|
||||||
|
.result-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-m);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font: var(--font-size-s) var(--font-mono);
|
||||||
|
letter-spacing: var(--space-text-l);
|
||||||
|
}
|
||||||
|
.result-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-s);
|
||||||
|
}
|
||||||
|
.result-pre {
|
||||||
|
margin: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
max-height: var(--space-xxxl);
|
||||||
|
overflow: auto;
|
||||||
|
padding: var(--space-l) var(--space-m);
|
||||||
|
border: var(--size-border) solid var(--color-border);
|
||||||
|
border-radius: var(--radius-m);
|
||||||
|
background: var(--color-background);
|
||||||
|
color: var(--color-text);
|
||||||
|
font: var(--font-size-s) var(--font-mono);
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
.verdict {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-m);
|
||||||
|
justify-items: start;
|
||||||
|
}
|
||||||
|
.verdict-label {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font: var(--font-size-s) var(--font-mono);
|
||||||
|
letter-spacing: var(--space-text-l);
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-l);
|
||||||
|
padding: var(--space-m) var(--space-xl);
|
||||||
|
border: var(--size-border-thick) solid transparent;
|
||||||
|
border-radius: var(--radius-m);
|
||||||
|
font: var(--font-size-m) var(--font-mono);
|
||||||
|
}
|
||||||
|
.badge-tone--success {
|
||||||
|
background: var(--color-success-tint);
|
||||||
|
border-color: var(--color-success);
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
.badge-tone--danger {
|
||||||
|
background: var(--color-danger-tint);
|
||||||
|
border-color: var(--color-danger);
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
.badge-tone--info {
|
||||||
|
background: var(--color-info-tint);
|
||||||
|
border-color: var(--color-info);
|
||||||
|
color: var(--color-info);
|
||||||
|
}
|
||||||
|
.verdict-text {
|
||||||
|
line-height: normal;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { ArrowUp, Upload } from "@lucide/svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: string;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
oninput: (text: string) => void;
|
||||||
|
onrender: () => void;
|
||||||
|
onsample?: () => void;
|
||||||
|
}
|
||||||
|
let {
|
||||||
|
value,
|
||||||
|
placeholder = "Paste base64 / hex / bytes…",
|
||||||
|
disabled = false,
|
||||||
|
oninput,
|
||||||
|
onrender,
|
||||||
|
onsample,
|
||||||
|
}: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="text-source">
|
||||||
|
<textarea
|
||||||
|
spellcheck="false"
|
||||||
|
{placeholder}
|
||||||
|
rows="6"
|
||||||
|
{value}
|
||||||
|
oninput={(e) => oninput((e.target as HTMLTextAreaElement).value)}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
onrender();
|
||||||
|
}
|
||||||
|
}}></textarea>
|
||||||
|
<div class="actions">
|
||||||
|
{#if onsample}
|
||||||
|
<button type="button" class="secondary" onclick={onsample}>
|
||||||
|
<Upload size={14} /> Try sample
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button type="button" class="render" onclick={onrender} {disabled}>
|
||||||
|
<ArrowUp size={14} /> Render text
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.text-source {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-m);
|
||||||
|
}
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: var(--space-brand);
|
||||||
|
max-height: var(--space-xxxl);
|
||||||
|
resize: vertical;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: var(--space-m);
|
||||||
|
border: var(--size-border) solid var(--color-border);
|
||||||
|
border-radius: var(--radius-m);
|
||||||
|
background: var(--color-background);
|
||||||
|
color: var(--color-text);
|
||||||
|
font: var(--font-size-s) var(--font-mono);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-main);
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-m);
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.render {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: 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: var(--font-size-s) var(--font-mono);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.render:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.secondary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-m);
|
||||||
|
padding: var(--space-m) var(--space-l);
|
||||||
|
border: var(--size-border) solid var(--color-border);
|
||||||
|
border-radius: var(--radius-m);
|
||||||
|
background: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font: var(--font-size-s) var(--font-mono);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.secondary:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
border-color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -4,7 +4,13 @@
|
|||||||
import { debounce } from "$lib/core/debounce";
|
import { debounce } from "$lib/core/debounce";
|
||||||
import { decodeFile, encode } from "$lib/core/io";
|
import { decodeFile, encode } from "$lib/core/io";
|
||||||
import type { PixelImage } from "$lib/core/types";
|
import type { PixelImage } from "$lib/core/types";
|
||||||
import { executeGenerate, executeStep } from "$lib/preview/executor";
|
import {
|
||||||
|
executeFromText,
|
||||||
|
executeGenerate,
|
||||||
|
executeStep,
|
||||||
|
executeTextToText,
|
||||||
|
executeToText,
|
||||||
|
} from "$lib/preview/executor";
|
||||||
import type { ToolEntry } from "$lib/registry-new";
|
import type { ToolEntry } from "$lib/registry-new";
|
||||||
import {
|
import {
|
||||||
defaultSchemaParams,
|
defaultSchemaParams,
|
||||||
@@ -22,10 +28,14 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
const isGenerator = $derived(Boolean(tool.generate && !tool.run));
|
const isGenerator = $derived(Boolean(tool.generate && !tool.run));
|
||||||
|
const inputMode = $derived(tool.input ?? "file");
|
||||||
|
const resultKind = $derived(tool.result ?? "image");
|
||||||
|
|
||||||
let values = $state<Record<string, unknown>>({});
|
let values = $state<Record<string, unknown>>({});
|
||||||
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 textSource = $state("");
|
||||||
|
let textResult = $state<string | null>(null);
|
||||||
let running = $state(false);
|
let running = $state(false);
|
||||||
let error = $state("");
|
let error = $state("");
|
||||||
let started = $state(false);
|
let started = $state(false);
|
||||||
@@ -45,6 +55,7 @@
|
|||||||
function reset() {
|
function reset() {
|
||||||
values = schema ? defaultSchemaParams(schema) : {};
|
values = schema ? defaultSchemaParams(schema) : {};
|
||||||
result = null;
|
result = null;
|
||||||
|
textResult = null;
|
||||||
error = "";
|
error = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,12 +75,29 @@
|
|||||||
await runGenerate();
|
await runGenerate();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!source) return;
|
|
||||||
error = "";
|
error = "";
|
||||||
running = true;
|
|
||||||
const params = sanitizeSchemaParams(schema, values);
|
const params = sanitizeSchemaParams(schema, values);
|
||||||
|
running = true;
|
||||||
try {
|
try {
|
||||||
result = await executeStep(tool, source, params);
|
if (inputMode === "text") {
|
||||||
|
if (!textSource.trim()) return;
|
||||||
|
if (tool.textToText) {
|
||||||
|
textResult = await executeTextToText(tool, textSource);
|
||||||
|
result = null;
|
||||||
|
} else {
|
||||||
|
result = await executeFromText(tool, textSource, params);
|
||||||
|
textResult = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!source) return;
|
||||||
|
if (resultKind !== "image" && tool.toText) {
|
||||||
|
textResult = await executeToText(tool, source, params);
|
||||||
|
result = null;
|
||||||
|
} else {
|
||||||
|
result = await executeStep(tool, source, params);
|
||||||
|
textResult = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = errorText(e);
|
error = errorText(e);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -92,12 +120,36 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function download() {
|
async function download() {
|
||||||
if (!result) return;
|
if (!result || !schema) return;
|
||||||
const blob = await encode(result, "image/png");
|
const out = tool.output;
|
||||||
|
const quality = out?.qualityParamId
|
||||||
|
? Number(values[out.qualityParamId]) / 100
|
||||||
|
: undefined;
|
||||||
|
const blob = await encode(result, out?.mime ?? "image/png", quality);
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = "with-border.png";
|
a.download = `${tool.id}.${out?.ext ?? "png"}`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyText() {
|
||||||
|
if (!textResult) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(textResult);
|
||||||
|
} catch (e) {
|
||||||
|
error = errorText(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadText() {
|
||||||
|
if (!textResult) return;
|
||||||
|
const blob = new Blob([textResult], { type: "text/plain" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${tool.id}.txt`;
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
@@ -107,7 +159,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (isGenerator || !source || !started) return;
|
if (inputMode === "text" || isGenerator || !source || !started) return;
|
||||||
void values;
|
void values;
|
||||||
debouncedRun();
|
debouncedRun();
|
||||||
return () => debouncedRun.cancel();
|
return () => debouncedRun.cancel();
|
||||||
@@ -142,11 +194,20 @@
|
|||||||
<SchemaPreview
|
<SchemaPreview
|
||||||
{source}
|
{source}
|
||||||
{result}
|
{result}
|
||||||
|
{textSource}
|
||||||
|
{textResult}
|
||||||
|
{inputMode}
|
||||||
|
{resultKind}
|
||||||
{running}
|
{running}
|
||||||
{error}
|
{error}
|
||||||
{isGenerator}
|
|
||||||
onupload={handleFile}
|
onupload={handleFile}
|
||||||
ongenerate={runGenerate}
|
ongenerate={runGenerate}
|
||||||
|
ontextsource={(t) => {
|
||||||
|
textSource = t;
|
||||||
|
}}
|
||||||
|
onrendertext={run}
|
||||||
|
oncopytext={copyText}
|
||||||
|
ondownloadtxt={downloadText}
|
||||||
ondownload={download}
|
ondownload={download}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type {
|
||||||
|
ColorList,
|
||||||
|
ColorListSpec,
|
||||||
|
FieldSpec,
|
||||||
|
} from "$lib/registry-schema";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
label: string;
|
||||||
|
value: unknown;
|
||||||
|
spec: FieldSpec;
|
||||||
|
onchange?: (value: ColorList) => void;
|
||||||
|
}
|
||||||
|
let { label, value, spec, onchange }: Props = $props();
|
||||||
|
|
||||||
|
const sp = $derived(spec as ColorListSpec);
|
||||||
|
const current = $derived.by((): ColorList => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const valid = value.filter(
|
||||||
|
(c): c is string => typeof c === "string" && /^#[0-9a-f]{6}$/i.test(c),
|
||||||
|
);
|
||||||
|
if (valid.length > 0) return valid;
|
||||||
|
}
|
||||||
|
return [...sp.default];
|
||||||
|
});
|
||||||
|
|
||||||
|
function setColor(index: number, hex: string) {
|
||||||
|
const next = [...current];
|
||||||
|
next[index] = hex;
|
||||||
|
onchange?.(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addColor() {
|
||||||
|
onchange?.([...current, "#000000"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeColor(index: number) {
|
||||||
|
if (current.length <= 1) return;
|
||||||
|
onchange?.(current.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="control colors-control">
|
||||||
|
<span class="ctrl-label">{label}</span>
|
||||||
|
<div class="chips">
|
||||||
|
{#each current as hex, i (i)}
|
||||||
|
<div class="chip">
|
||||||
|
<label class="swatch-wrap">
|
||||||
|
<span class="swatch" style="background:{hex}"></span>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={hex}
|
||||||
|
oninput={(e) => setColor(i, (e.target as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<span class="hex">{hex}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="remove"
|
||||||
|
onclick={() => removeColor(i)}
|
||||||
|
disabled={current.length <= 1}
|
||||||
|
aria-label="Remove color"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="add" onclick={addColor}>+ Add color</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.control {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-m);
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font: var(--font-size-s) var(--font-mono);
|
||||||
|
letter-spacing: var(--space-text-m);
|
||||||
|
}
|
||||||
|
.ctrl-label {
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: var(--font-size-s);
|
||||||
|
letter-spacing: var(--space-text-l);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-m);
|
||||||
|
}
|
||||||
|
.chip {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--space-xxl) minmax(0, auto) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-m);
|
||||||
|
padding: var(--space-s) var(--space-m);
|
||||||
|
border: var(--size-border) solid var(--color-border);
|
||||||
|
border-radius: var(--radius-m);
|
||||||
|
}
|
||||||
|
.swatch-wrap {
|
||||||
|
display: grid;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.swatch {
|
||||||
|
width: var(--space-xxl);
|
||||||
|
height: var(--space-xxl);
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
border: var(--size-border) solid var(--color-border);
|
||||||
|
}
|
||||||
|
.swatch-wrap input[type="color"] {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.hex {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-s);
|
||||||
|
}
|
||||||
|
.remove {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: var(--space-xl);
|
||||||
|
height: var(--space-xl);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
background: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-s);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.remove:hover:not(:disabled) {
|
||||||
|
color: var(--color-text);
|
||||||
|
background: var(--color-background-muted);
|
||||||
|
}
|
||||||
|
.remove:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.add {
|
||||||
|
justify-self: start;
|
||||||
|
padding: var(--space-s) var(--space-m);
|
||||||
|
border: var(--size-border) solid var(--color-border);
|
||||||
|
border-radius: var(--radius-m);
|
||||||
|
background: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font: var(--font-size-s) var(--font-mono);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.add:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
border-color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -85,7 +85,7 @@ export function stripDataUri(text: string): string {
|
|||||||
return m ? text.slice(m[0].length) : text.trim();
|
return m ? text.slice(m[0].length) : text.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function base64ToBytes(text: string): Uint8Array {
|
export function base64ToBytes(text: string): Uint8Array<ArrayBuffer> {
|
||||||
const clean = text.replace(/\s+/g, "");
|
const clean = text.replace(/\s+/g, "");
|
||||||
const binary = atob(clean);
|
const binary = atob(clean);
|
||||||
const bytes = new Uint8Array(binary.length);
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
|||||||
@@ -55,6 +55,53 @@ export async function executeGenerate(
|
|||||||
return await tool.generate(params);
|
return await tool.generate(params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MaybeTextRunnable = MaybeRunnable & {
|
||||||
|
runFromText?: (
|
||||||
|
text: string,
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
) => Promise<PixelImage> | PixelImage;
|
||||||
|
toText?: (
|
||||||
|
img: PixelImage,
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
) => Promise<string> | string;
|
||||||
|
textToText?: (text: string) => Promise<string> | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Текст → изображение (text-source конвертеры). Выполняется напрямую. */
|
||||||
|
export async function executeFromText(
|
||||||
|
tool: MaybeTextRunnable,
|
||||||
|
text: string,
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
): Promise<PixelImage> {
|
||||||
|
if (!tool.runFromText) {
|
||||||
|
throw new Error("errors.noTextInput");
|
||||||
|
}
|
||||||
|
return await tool.runFromText(text, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Изображение → текст (текстовые конвертеры и вердикты). */
|
||||||
|
export async function executeToText(
|
||||||
|
tool: MaybeTextRunnable,
|
||||||
|
img: PixelImage,
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
): Promise<string> {
|
||||||
|
if (!tool.toText) {
|
||||||
|
throw new Error("errors.noTextResult");
|
||||||
|
}
|
||||||
|
return await tool.toText(img, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Текст → текст (напр. verify-is-png). */
|
||||||
|
export async function executeTextToText(
|
||||||
|
tool: MaybeTextRunnable,
|
||||||
|
text: string,
|
||||||
|
): Promise<string> {
|
||||||
|
if (!tool.textToText) {
|
||||||
|
throw new Error("errors.noTextResult");
|
||||||
|
}
|
||||||
|
return await tool.textToText(text);
|
||||||
|
}
|
||||||
|
|
||||||
async function runDirect(
|
async function runDirect(
|
||||||
tool: MaybeRunnable,
|
tool: MaybeRunnable,
|
||||||
img: PixelImage,
|
img: PixelImage,
|
||||||
|
|||||||
@@ -1 +1,7 @@
|
|||||||
export { executeGenerate, executeStep } from "./executor";
|
export {
|
||||||
|
executeFromText,
|
||||||
|
executeGenerate,
|
||||||
|
executeStep,
|
||||||
|
executeTextToText,
|
||||||
|
executeToText,
|
||||||
|
} from "./executor";
|
||||||
|
|||||||
@@ -1,8 +1,26 @@
|
|||||||
import type { ToolEntry } from "./types";
|
import type { ToolEntry } from "./types";
|
||||||
import { field, toolSchema } from "../registry-schema";
|
import { field, toolSchema } from "../registry-schema";
|
||||||
import type { Offset } from "../registry-schema";
|
import type { Offset } from "../registry-schema";
|
||||||
import { colorMask, removeColorToAlpha } from "../core/alpha";
|
import {
|
||||||
import { contourImage, strokeImage } from "../core/morphology";
|
colorMask,
|
||||||
|
extractAlphaMask,
|
||||||
|
flattenOntoColor,
|
||||||
|
hardenAlpha,
|
||||||
|
invertAlpha,
|
||||||
|
removeColorToAlpha,
|
||||||
|
roundCorners,
|
||||||
|
setAlphaChannel,
|
||||||
|
} from "../core/alpha";
|
||||||
|
import { backgroundMaskPreview, removeBackground } from "../core/background";
|
||||||
|
import {
|
||||||
|
closingImage,
|
||||||
|
contourImage,
|
||||||
|
dilateImage,
|
||||||
|
erodeImage,
|
||||||
|
openingImage,
|
||||||
|
strokeImage,
|
||||||
|
} from "../core/morphology";
|
||||||
|
import { defringe, featherAlpha } from "../core/pixel-fx";
|
||||||
import {
|
import {
|
||||||
boxTest,
|
boxTest,
|
||||||
circleTest,
|
circleTest,
|
||||||
@@ -242,6 +260,251 @@ const wavyMask: ToolEntry<WavyMaskParams> = {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface EmptyParams {}
|
||||||
|
|
||||||
|
export const removeAlphaChannelSchema = toolSchema<EmptyParams>({});
|
||||||
|
|
||||||
|
const removeAlphaChannel: ToolEntry<EmptyParams> = {
|
||||||
|
id: "remove-alpha-channel-png",
|
||||||
|
title: "Remove alpha channel PNG",
|
||||||
|
description:
|
||||||
|
"Composites the image over a white background and saves without transparency.",
|
||||||
|
category: "alpha",
|
||||||
|
schema: removeAlphaChannelSchema,
|
||||||
|
run: (img) => flattenOntoColor(img, "#ffffff"),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SetAlphaChannelParams {
|
||||||
|
percent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setAlphaChannelSchema = toolSchema<SetAlphaChannelParams>({
|
||||||
|
percent: field.slider({ min: 0, max: 100, step: 1, default: 100 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const setAlphaChannelTool: ToolEntry<SetAlphaChannelParams> = {
|
||||||
|
id: "set-alpha-channel-png",
|
||||||
|
title: "Set alpha channel PNG",
|
||||||
|
description: "Assigns the same opacity to all pixels; colors stay unchanged.",
|
||||||
|
category: "alpha",
|
||||||
|
schema: setAlphaChannelSchema,
|
||||||
|
run: (img, p) => setAlphaChannel(img, p.percent),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const extractAlphaMaskSchema = toolSchema<EmptyParams>({});
|
||||||
|
|
||||||
|
const extractAlphaMaskTool: ToolEntry<EmptyParams> = {
|
||||||
|
id: "extract-alpha-mask-png",
|
||||||
|
title: "Extract alpha mask PNG",
|
||||||
|
description: "Turns transparency into a black-and-white opaque mask.",
|
||||||
|
category: "alpha",
|
||||||
|
schema: extractAlphaMaskSchema,
|
||||||
|
run: (img) => extractAlphaMask(img),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface RoundCornersParams {
|
||||||
|
radius: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const roundCornersSchema = toolSchema<RoundCornersParams>({
|
||||||
|
radius: field.slider({ min: 0, max: 50, step: 1, default: 10 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const roundCornersTool: ToolEntry<RoundCornersParams> = {
|
||||||
|
id: "round-corners-png",
|
||||||
|
title: "Round corners PNG",
|
||||||
|
description:
|
||||||
|
"Clips corners by a radius set as a percentage of half the smaller side.",
|
||||||
|
category: "alpha",
|
||||||
|
schema: roundCornersSchema,
|
||||||
|
run: (img, p) => roundCorners(img, p.radius),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const invertAlphaSchema = toolSchema<EmptyParams>({});
|
||||||
|
|
||||||
|
const invertAlphaTool: ToolEntry<EmptyParams> = {
|
||||||
|
id: "invert-alpha-png",
|
||||||
|
title: "Invert alpha PNG",
|
||||||
|
description: "Opaque areas become transparent and vice versa.",
|
||||||
|
category: "alpha",
|
||||||
|
schema: invertAlphaSchema,
|
||||||
|
run: (img) => invertAlpha(img),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface RemoveBackgroundParams {
|
||||||
|
color: string;
|
||||||
|
tolerance: number;
|
||||||
|
outerOnly: boolean;
|
||||||
|
smooth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const removeBackgroundSchema = toolSchema<RemoveBackgroundParams>(
|
||||||
|
{
|
||||||
|
color: field.color({ default: "#ffffff" }),
|
||||||
|
tolerance: field.slider({ min: 0, max: 100, step: 1, default: 10 }),
|
||||||
|
outerOnly: field.checkbox({ default: true }),
|
||||||
|
smooth: field.slider({ min: 0, max: 8, step: 1, default: 1 }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
layout: {
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
title: "Background",
|
||||||
|
cols: 2,
|
||||||
|
fields: ["color", "tolerance"],
|
||||||
|
},
|
||||||
|
{ title: "Options", fields: ["outerOnly", "smooth"] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeBackgroundTool: ToolEntry<RemoveBackgroundParams> = {
|
||||||
|
id: "remove-background-png",
|
||||||
|
title: "Remove background PNG (smart)",
|
||||||
|
description:
|
||||||
|
"Removes a solid background: by color with tolerance, outer regions from the edges only, or every matching pixel. Can smooth the boundary.",
|
||||||
|
category: "alpha",
|
||||||
|
schema: removeBackgroundSchema,
|
||||||
|
run: (img, p) =>
|
||||||
|
removeBackground(img, {
|
||||||
|
color: p.color,
|
||||||
|
tolerancePercent: p.tolerance,
|
||||||
|
outerOnly: p.outerOnly,
|
||||||
|
smoothPasses: p.smooth,
|
||||||
|
}),
|
||||||
|
preview: (img, p) =>
|
||||||
|
backgroundMaskPreview(img, {
|
||||||
|
color: p.color,
|
||||||
|
tolerancePercent: p.tolerance,
|
||||||
|
outerOnly: p.outerOnly,
|
||||||
|
smoothPasses: p.smooth,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface MakeThickerParams {
|
||||||
|
radius: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makeThickerSchema = toolSchema<MakeThickerParams>({
|
||||||
|
radius: field.slider({ min: 1, max: 10, step: 1, default: 2 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeThickerTool: ToolEntry<MakeThickerParams> = {
|
||||||
|
id: "make-thicker-png",
|
||||||
|
title: "Thicken PNG",
|
||||||
|
description: "Expands opaque areas by the given number of pixels.",
|
||||||
|
category: "alpha",
|
||||||
|
schema: makeThickerSchema,
|
||||||
|
run: (img, p) => dilateImage(img, p.radius),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface MakeThinnerParams {
|
||||||
|
radius: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makeThinnerSchema = toolSchema<MakeThinnerParams>({
|
||||||
|
radius: field.slider({ min: 1, max: 10, step: 1, default: 1 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeThinnerTool: ToolEntry<MakeThinnerParams> = {
|
||||||
|
id: "make-thinner-png",
|
||||||
|
title: "Thin PNG",
|
||||||
|
description: "Shrinks opaque areas — thins the strokes of text and details.",
|
||||||
|
category: "alpha",
|
||||||
|
schema: makeThinnerSchema,
|
||||||
|
run: (img, p) => erodeImage(img, p.radius),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FeatherEdgesParams {
|
||||||
|
radius: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const featherEdgesSchema = toolSchema<FeatherEdgesParams>({
|
||||||
|
radius: field.slider({ min: 1, max: 20, step: 1, default: 3 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const featherEdgesTool: ToolEntry<FeatherEdgesParams> = {
|
||||||
|
id: "feather-edges-png",
|
||||||
|
title: "Feather Edges PNG",
|
||||||
|
description:
|
||||||
|
"Blurs only the alpha channel: hard cutout edges become soft and gradual, colors stay untouched.",
|
||||||
|
category: "alpha",
|
||||||
|
schema: featherEdgesSchema,
|
||||||
|
run: (img, p) => featherAlpha(img, p.radius),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CleanEdgesParams {
|
||||||
|
radius: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const cleanEdgesSchema = toolSchema<CleanEdgesParams>({
|
||||||
|
radius: field.slider({ min: 1, max: 10, step: 1, default: 3 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const cleanEdgesTool: ToolEntry<CleanEdgesParams> = {
|
||||||
|
id: "clean-edges-png",
|
||||||
|
title: "Clean Edges PNG (defringe)",
|
||||||
|
description:
|
||||||
|
"Replaces edge-halo colors of semi-transparent pixels with the nearest fully opaque color. Alpha stays as is.",
|
||||||
|
category: "alpha",
|
||||||
|
schema: cleanEdgesSchema,
|
||||||
|
run: (img, p) => defringe(img, p.radius),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface HardenAlphaParams {
|
||||||
|
threshold: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hardenAlphaSchema = toolSchema<HardenAlphaParams>({
|
||||||
|
threshold: field.slider({ min: 0, max: 100, step: 1, default: 50 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const hardenAlphaTool: ToolEntry<HardenAlphaParams> = {
|
||||||
|
id: "harden-alpha-png",
|
||||||
|
title: "Harden edges PNG",
|
||||||
|
description:
|
||||||
|
"Binarizes the alpha channel by threshold: semi-transparent pixels become either fully transparent or fully opaque.",
|
||||||
|
category: "alpha",
|
||||||
|
schema: hardenAlphaSchema,
|
||||||
|
run: (img, p) => hardenAlpha(img, p.threshold),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DespeckleAlphaParams {
|
||||||
|
radius: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const despeckleAlphaSchema = toolSchema<DespeckleAlphaParams>({
|
||||||
|
radius: field.slider({ min: 1, max: 3, step: 1, default: 1 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const despeckleAlphaTool: ToolEntry<DespeckleAlphaParams> = {
|
||||||
|
id: "despeckle-alpha-png",
|
||||||
|
title: "Despeckle PNG",
|
||||||
|
description:
|
||||||
|
"Opening: removes lone semi-transparent pixels and small specks.",
|
||||||
|
category: "alpha",
|
||||||
|
schema: despeckleAlphaSchema,
|
||||||
|
run: (img, p) => openingImage(img, p.radius),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CloseHolesParams {
|
||||||
|
radius: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const closeHolesSchema = toolSchema<CloseHolesParams>({
|
||||||
|
radius: field.slider({ min: 1, max: 3, step: 1, default: 1 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const closeHolesTool: ToolEntry<CloseHolesParams> = {
|
||||||
|
id: "close-holes-png",
|
||||||
|
title: "Close holes PNG",
|
||||||
|
description: "Closing: fills lone transparent dots inside the object.",
|
||||||
|
category: "alpha",
|
||||||
|
schema: closeHolesSchema,
|
||||||
|
run: (img, p) => closingImage(img, p.radius),
|
||||||
|
};
|
||||||
|
|
||||||
export const alphaEntries = [
|
export const alphaEntries = [
|
||||||
addStroke,
|
addStroke,
|
||||||
findContour,
|
findContour,
|
||||||
@@ -250,4 +513,17 @@ export const alphaEntries = [
|
|||||||
squareMask,
|
squareMask,
|
||||||
starMask,
|
starMask,
|
||||||
wavyMask,
|
wavyMask,
|
||||||
|
removeAlphaChannel,
|
||||||
|
setAlphaChannelTool,
|
||||||
|
extractAlphaMaskTool,
|
||||||
|
roundCornersTool,
|
||||||
|
invertAlphaTool,
|
||||||
|
removeBackgroundTool,
|
||||||
|
makeThickerTool,
|
||||||
|
makeThinnerTool,
|
||||||
|
featherEdgesTool,
|
||||||
|
cleanEdgesTool,
|
||||||
|
hardenAlphaTool,
|
||||||
|
despeckleAlphaTool,
|
||||||
|
closeHolesTool,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import type { ToolEntry } from "./types";
|
import type { ToolEntry } from "./types";
|
||||||
import { field, toolSchema } from "../registry-schema";
|
import { field, toolSchema } from "../registry-schema";
|
||||||
import { extractByColor } from "../core/masks";
|
import {
|
||||||
|
extractByColor,
|
||||||
|
isGrayscaleish,
|
||||||
|
luma01,
|
||||||
|
rarityPredicate,
|
||||||
|
renderPredicateMask,
|
||||||
|
} from "../core/masks";
|
||||||
|
import { hasTransparency, isGrayscale, orientationOf } from "../core/analyze";
|
||||||
|
import { encode } from "../core/io";
|
||||||
|
import { base64ToBytes, looksLikePng, stripDataUri } from "../core/textio";
|
||||||
|
|
||||||
interface ExtractColorParams {
|
interface ExtractColorParams {
|
||||||
color: string;
|
color: string;
|
||||||
@@ -22,4 +31,242 @@ const extractColor: ToolEntry<ExtractColorParams> = {
|
|||||||
run: (img, p) => extractByColor(img, p.color, p.tolerance),
|
run: (img, p) => extractByColor(img, p.color, p.tolerance),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const analyzeEntries = [extractColor];
|
interface MaskParams {
|
||||||
|
mode: "binary" | "highlight";
|
||||||
|
color: string;
|
||||||
|
opacity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maskBaseFields = {
|
||||||
|
mode: field.select({
|
||||||
|
default: "binary",
|
||||||
|
options: [
|
||||||
|
{ value: "binary", label: "Black & white mask" },
|
||||||
|
{ value: "highlight", label: "Color highlight" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
color: field.color({ default: "#ff00aa" }),
|
||||||
|
opacity: field.slider({ min: 0, max: 100, step: 5, default: 70 }),
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderMask(
|
||||||
|
img: Parameters<typeof renderPredicateMask>[0],
|
||||||
|
p: MaskParams,
|
||||||
|
predicate: (r: number, g: number, b: number, a: number) => boolean,
|
||||||
|
) {
|
||||||
|
return renderPredicateMask(img, predicate, {
|
||||||
|
mode: p.mode,
|
||||||
|
color: p.color,
|
||||||
|
opacityPercent: p.opacity,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const showTransparentSchema = toolSchema<MaskParams>({
|
||||||
|
...maskBaseFields,
|
||||||
|
mode: field.select({
|
||||||
|
default: "highlight",
|
||||||
|
options: [
|
||||||
|
{ value: "binary", label: "Black & white mask" },
|
||||||
|
{ value: "highlight", label: "Color highlight" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const showTransparent: ToolEntry<MaskParams> = {
|
||||||
|
id: "show-transparent-png",
|
||||||
|
title: "Show Transparent Areas PNG",
|
||||||
|
description:
|
||||||
|
"Highlights every transparent or semi-transparent pixel with the chosen color so gaps become obvious.",
|
||||||
|
category: "analyze",
|
||||||
|
schema: showTransparentSchema,
|
||||||
|
run: (img, p) => renderMask(img, p, (_r, _g, _b, a) => a < 255),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface GrayscalePixelsParams extends MaskParams {
|
||||||
|
tolerance: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const showGrayscalePixelsSchema = toolSchema<GrayscalePixelsParams>({
|
||||||
|
...maskBaseFields,
|
||||||
|
tolerance: field.slider({ min: 0, max: 64, step: 1, default: 0 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const showGrayscalePixels: ToolEntry<GrayscalePixelsParams> = {
|
||||||
|
id: "show-grayscale-pixels-png",
|
||||||
|
title: "Show Grayscale Pixels PNG",
|
||||||
|
description:
|
||||||
|
"Finds pixels whose channels are nearly equal and renders them as a mask. Tolerance is in channel units.",
|
||||||
|
category: "analyze",
|
||||||
|
schema: showGrayscalePixelsSchema,
|
||||||
|
run: (img, p) =>
|
||||||
|
renderMask(img, p, (r, g, b) => isGrayscaleish(r, g, b, p.tolerance)),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ColorPixelsParams extends MaskParams {
|
||||||
|
tolerance: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const showColorPixelsSchema = toolSchema<ColorPixelsParams>({
|
||||||
|
...maskBaseFields,
|
||||||
|
tolerance: field.slider({ min: 0, max: 64, step: 1, default: 8 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const showColorPixels: ToolEntry<ColorPixelsParams> = {
|
||||||
|
id: "show-color-pixels-png",
|
||||||
|
title: "Show Color Pixels PNG",
|
||||||
|
description:
|
||||||
|
"Finds colored (non-gray) pixels beyond the channel tolerance and renders them as a mask.",
|
||||||
|
category: "analyze",
|
||||||
|
schema: showColorPixelsSchema,
|
||||||
|
run: (img, p) =>
|
||||||
|
renderMask(img, p, (r, g, b) => !isGrayscaleish(r, g, b, p.tolerance)),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface LightPixelParams extends MaskParams {
|
||||||
|
threshold: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const lightPixelMaskSchema = toolSchema<LightPixelParams>({
|
||||||
|
...maskBaseFields,
|
||||||
|
threshold: field.slider({ min: 0, max: 100, step: 1, default: 70 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const lightPixelMask: ToolEntry<LightPixelParams> = {
|
||||||
|
id: "light-pixel-mask-png",
|
||||||
|
title: "Light Pixel Mask PNG",
|
||||||
|
description: "Selects pixels brighter than the luminance threshold.",
|
||||||
|
category: "analyze",
|
||||||
|
schema: lightPixelMaskSchema,
|
||||||
|
run: (img, p) =>
|
||||||
|
renderMask(img, p, (r, g, b) => luma01(r, g, b) >= p.threshold / 100),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DarkPixelParams extends MaskParams {
|
||||||
|
threshold: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const darkPixelMaskSchema = toolSchema<DarkPixelParams>({
|
||||||
|
...maskBaseFields,
|
||||||
|
threshold: field.slider({ min: 0, max: 100, step: 1, default: 30 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const darkPixelMask: ToolEntry<DarkPixelParams> = {
|
||||||
|
id: "dark-pixel-mask-png",
|
||||||
|
title: "Dark Pixel Mask PNG",
|
||||||
|
description: "Selects pixels darker than the luminance threshold.",
|
||||||
|
category: "analyze",
|
||||||
|
schema: darkPixelMaskSchema,
|
||||||
|
run: (img, p) =>
|
||||||
|
renderMask(img, p, (r, g, b) => luma01(r, g, b) <= p.threshold / 100),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface UniqueColorParams extends MaskParams {
|
||||||
|
rarity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const uniqueColorMaskSchema = toolSchema<UniqueColorParams>({
|
||||||
|
...maskBaseFields,
|
||||||
|
rarity: field.slider({ min: 1, max: 50, step: 1, default: 1 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const uniqueColorMask: ToolEntry<UniqueColorParams> = {
|
||||||
|
id: "unique-color-mask-png",
|
||||||
|
title: "Unique Color Mask PNG",
|
||||||
|
description:
|
||||||
|
"Selects colors that occur no more than the given number of times — rare and one-off pixels.",
|
||||||
|
category: "analyze",
|
||||||
|
schema: uniqueColorMaskSchema,
|
||||||
|
run: (img, p) => renderMask(img, p, rarityPredicate(img, p.rarity)),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface NoParams {}
|
||||||
|
|
||||||
|
const emptySchema = toolSchema<NoParams>({});
|
||||||
|
|
||||||
|
const verifyIsPng: ToolEntry<NoParams> = {
|
||||||
|
id: "verify-is-png",
|
||||||
|
title: "Verify If Image Is a PNG",
|
||||||
|
description:
|
||||||
|
"Checks the signature of pasted base64 / data-uri content and reports whether it is a real PNG.",
|
||||||
|
category: "analyze",
|
||||||
|
schema: emptySchema,
|
||||||
|
input: "text",
|
||||||
|
result: "verdict",
|
||||||
|
textToText: (text) =>
|
||||||
|
looksLikePng(base64ToBytes(stripDataUri(text)))
|
||||||
|
? "Yes — valid PNG signature."
|
||||||
|
: "No — the content is not a PNG.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const pngIsGrayscale: ToolEntry<NoParams> = {
|
||||||
|
id: "png-is-grayscale",
|
||||||
|
title: "Check: is PNG grayscale?",
|
||||||
|
description: "Reports whether the image consists only of shades of gray.",
|
||||||
|
category: "analyze",
|
||||||
|
schema: emptySchema,
|
||||||
|
result: "verdict",
|
||||||
|
toText: (img) =>
|
||||||
|
isGrayscale(img) ? "Yes — grayscale." : "No — contains colors.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const pngFileSize: ToolEntry<NoParams> = {
|
||||||
|
id: "png-file-size",
|
||||||
|
title: "PNG File Size",
|
||||||
|
description: "Encodes the image as PNG and reports the resulting file size.",
|
||||||
|
category: "analyze",
|
||||||
|
schema: emptySchema,
|
||||||
|
domOnly: true,
|
||||||
|
result: "verdict",
|
||||||
|
toText: async (img) => {
|
||||||
|
const blob = await encode(img, "image/png");
|
||||||
|
const kb = blob.size / 1024;
|
||||||
|
const kbText = kb >= 100 ? Math.round(kb).toString() : kb.toFixed(1);
|
||||||
|
return `${kbText} KB`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const pngIsTransparent: ToolEntry<NoParams> = {
|
||||||
|
id: "png-is-transparent",
|
||||||
|
title: "Check: is PNG transparent?",
|
||||||
|
description:
|
||||||
|
"Reports whether the image contains transparent or semi-transparent pixels.",
|
||||||
|
category: "analyze",
|
||||||
|
schema: emptySchema,
|
||||||
|
result: "verdict",
|
||||||
|
toText: (img) =>
|
||||||
|
hasTransparency(img) ? "Yes — has transparency." : "No — fully opaque.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const pngOrientation: ToolEntry<NoParams> = {
|
||||||
|
id: "png-orientation",
|
||||||
|
title: "PNG orientation",
|
||||||
|
description: "Reports whether it is portrait, landscape or square.",
|
||||||
|
category: "analyze",
|
||||||
|
schema: emptySchema,
|
||||||
|
result: "verdict",
|
||||||
|
toText: (img) => {
|
||||||
|
switch (orientationOf(img)) {
|
||||||
|
case "portrait":
|
||||||
|
return "Portrait";
|
||||||
|
case "landscape":
|
||||||
|
return "Landscape";
|
||||||
|
default:
|
||||||
|
return "Square";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const analyzeEntries = [
|
||||||
|
extractColor,
|
||||||
|
showTransparent,
|
||||||
|
showGrayscalePixels,
|
||||||
|
showColorPixels,
|
||||||
|
lightPixelMask,
|
||||||
|
darkPixelMask,
|
||||||
|
uniqueColorMask,
|
||||||
|
verifyIsPng,
|
||||||
|
pngIsGrayscale,
|
||||||
|
pngFileSize,
|
||||||
|
pngIsTransparent,
|
||||||
|
pngOrientation,
|
||||||
|
];
|
||||||
|
|||||||
@@ -1,4 +1,23 @@
|
|||||||
import { gammaCorrection, temperature, twoColors, tint } from "../core/color";
|
import {
|
||||||
|
autoContrast,
|
||||||
|
brightnessContrast,
|
||||||
|
changeHue,
|
||||||
|
extractChannel,
|
||||||
|
gammaCorrection,
|
||||||
|
grayscale,
|
||||||
|
invert,
|
||||||
|
posterize,
|
||||||
|
sepia,
|
||||||
|
setOpacity,
|
||||||
|
swapChannels,
|
||||||
|
temperature,
|
||||||
|
thresholdBlackWhite,
|
||||||
|
tint,
|
||||||
|
twoColors,
|
||||||
|
type ChannelSwapPair,
|
||||||
|
type RgbChannel,
|
||||||
|
} from "../core/color";
|
||||||
|
import { renderSpace, SPACES, type SpaceId } from "../core/channels";
|
||||||
import { parseHexList } from "../core/palette";
|
import { parseHexList } from "../core/palette";
|
||||||
import { ditherImage, mapToNearest, quantizeImage } from "../core/quantize";
|
import { ditherImage, mapToNearest, quantizeImage } from "../core/quantize";
|
||||||
import { field, toolSchema, type ColorPair } from "../registry-schema";
|
import { field, toolSchema, type ColorPair } from "../registry-schema";
|
||||||
@@ -142,6 +161,324 @@ const ditheringTool: ToolEntry<DitheringParams> = {
|
|||||||
run: (img, p) => ditherImage(img, p.colors, p.pattern),
|
run: (img, p) => ditherImage(img, p.colors, p.pattern),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface EmptyParams {}
|
||||||
|
|
||||||
|
export const grayscaleSchema = toolSchema<EmptyParams>({});
|
||||||
|
|
||||||
|
const grayscaleTool: ToolEntry<EmptyParams> = {
|
||||||
|
id: "grayscale-png",
|
||||||
|
title: "Grayscale PNG",
|
||||||
|
description:
|
||||||
|
"Converts the image to shades of gray using the BT.601 luminance formula. Alpha is preserved.",
|
||||||
|
category: "color",
|
||||||
|
schema: grayscaleSchema,
|
||||||
|
run: (img) => grayscale(img),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const invertColorsSchema = toolSchema<EmptyParams>({});
|
||||||
|
|
||||||
|
const invertColorsTool: ToolEntry<EmptyParams> = {
|
||||||
|
id: "invert-colors-png",
|
||||||
|
title: "Invert colors PNG",
|
||||||
|
description: "Inverts each color channel (255 − value). Alpha is unchanged.",
|
||||||
|
category: "color",
|
||||||
|
schema: invertColorsSchema,
|
||||||
|
run: (img) => invert(img),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface BrightnessContrastParams {
|
||||||
|
brightness: number;
|
||||||
|
contrast: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const brightnessContrastSchema = toolSchema<BrightnessContrastParams>(
|
||||||
|
{
|
||||||
|
brightness: field.slider({ min: -100, max: 100, step: 1, default: 0 }),
|
||||||
|
contrast: field.slider({ min: -100, max: 100, step: 1, default: 0 }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
layout: {
|
||||||
|
groups: [
|
||||||
|
{ title: "Adjust", cols: 2, fields: ["brightness", "contrast"] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const brightnessContrastTool: ToolEntry<BrightnessContrastParams> = {
|
||||||
|
id: "adjust-brightness-contrast-png",
|
||||||
|
title: "Brightness & contrast PNG",
|
||||||
|
description:
|
||||||
|
"Adjusts brightness and contrast in the range from −100 to +100. Zero means no change.",
|
||||||
|
category: "color",
|
||||||
|
schema: brightnessContrastSchema,
|
||||||
|
run: (img, p) => brightnessContrast(img, p.brightness, p.contrast),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface OpacityParams {
|
||||||
|
percent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const opacitySchema = toolSchema<OpacityParams>({
|
||||||
|
percent: field.slider({ min: 0, max: 100, step: 1, default: 100 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const opacityTool: ToolEntry<OpacityParams> = {
|
||||||
|
id: "change-png-opacity",
|
||||||
|
title: "Change PNG opacity",
|
||||||
|
description:
|
||||||
|
"Multiplies the alpha channel by a percentage: 0% — fully transparent, 100% — unchanged.",
|
||||||
|
category: "color",
|
||||||
|
schema: opacitySchema,
|
||||||
|
run: (img, p) => setOpacity(img, p.percent),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sepiaSchema = toolSchema<EmptyParams>({});
|
||||||
|
|
||||||
|
const sepiaTool: ToolEntry<EmptyParams> = {
|
||||||
|
id: "sepia-png",
|
||||||
|
title: "Sepia effect",
|
||||||
|
description: "Tints the image into the warm brown tones of classic sepia.",
|
||||||
|
category: "color",
|
||||||
|
schema: sepiaSchema,
|
||||||
|
run: (img) => sepia(img),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface HueShiftParams {
|
||||||
|
degrees: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hueShiftSchema = toolSchema<HueShiftParams>({
|
||||||
|
degrees: field.slider({ min: -180, max: 180, step: 1, default: 0 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const hueShiftTool: ToolEntry<HueShiftParams> = {
|
||||||
|
id: "change-png-hue",
|
||||||
|
title: "Change hue PNG",
|
||||||
|
description:
|
||||||
|
"Shifts the hue around the circle. Saturation and lightness are preserved.",
|
||||||
|
category: "color",
|
||||||
|
schema: hueShiftSchema,
|
||||||
|
run: (img, p) => changeHue(img, p.degrees),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ExtractChannelParams {
|
||||||
|
channel: RgbChannel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const extractChannelSchema = toolSchema<ExtractChannelParams>({
|
||||||
|
channel: field.select({
|
||||||
|
default: "red",
|
||||||
|
options: [
|
||||||
|
{ value: "red", label: "Red" },
|
||||||
|
{ value: "green", label: "Green" },
|
||||||
|
{ value: "blue", label: "Blue" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const extractChannelTool: ToolEntry<ExtractChannelParams> = {
|
||||||
|
id: "extract-channel-png",
|
||||||
|
title: "Extract channel PNG",
|
||||||
|
description:
|
||||||
|
"Keeps only the chosen channel — red, green or blue — as shades of gray.",
|
||||||
|
category: "color",
|
||||||
|
schema: extractChannelSchema,
|
||||||
|
run: (img, p) => extractChannel(img, p.channel),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SwapChannelsParams {
|
||||||
|
pair: ChannelSwapPair;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const swapChannelsSchema = toolSchema<SwapChannelsParams>({
|
||||||
|
pair: field.select({
|
||||||
|
default: "r-g",
|
||||||
|
options: [
|
||||||
|
{ value: "r-g", label: "Red ↔ Green" },
|
||||||
|
{ value: "r-b", label: "Red ↔ Blue" },
|
||||||
|
{ value: "g-b", label: "Green ↔ Blue" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const swapChannelsTool: ToolEntry<SwapChannelsParams> = {
|
||||||
|
id: "swap-channels-png",
|
||||||
|
title: "Swap channels PNG",
|
||||||
|
description:
|
||||||
|
"Swaps two color channels — a quick way to get unusual coloring.",
|
||||||
|
category: "color",
|
||||||
|
schema: swapChannelsSchema,
|
||||||
|
run: (img, p) => swapChannels(img, p.pair),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface BlackAndWhiteParams {
|
||||||
|
threshold: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const blackAndWhiteSchema = toolSchema<BlackAndWhiteParams>({
|
||||||
|
threshold: field.slider({ min: 0, max: 100, step: 1, default: 50 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const blackAndWhiteTool: ToolEntry<BlackAndWhiteParams> = {
|
||||||
|
id: "black-and-white-png",
|
||||||
|
title: "Black & white threshold PNG",
|
||||||
|
description:
|
||||||
|
"Hard binarization by luminance: every pixel becomes black or white.",
|
||||||
|
category: "color",
|
||||||
|
schema: blackAndWhiteSchema,
|
||||||
|
run: (img, p) => thresholdBlackWhite(img, p.threshold),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PosterizeParams {
|
||||||
|
levels: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const posterizeSchema = toolSchema<PosterizeParams>({
|
||||||
|
levels: field.slider({ min: 2, max: 16, step: 1, default: 4 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const posterizeTool: ToolEntry<PosterizeParams> = {
|
||||||
|
id: "posterize-png",
|
||||||
|
title: "Posterize PNG",
|
||||||
|
description: "Reduces the number of levels per channel — a poster effect.",
|
||||||
|
category: "color",
|
||||||
|
schema: posterizeSchema,
|
||||||
|
run: (img, p) => posterize(img, p.levels),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const autoContrastSchema = toolSchema<EmptyParams>({});
|
||||||
|
|
||||||
|
const autoContrastTool: ToolEntry<EmptyParams> = {
|
||||||
|
id: "auto-contrast-png",
|
||||||
|
title: "Auto contrast PNG",
|
||||||
|
description:
|
||||||
|
"Stretches each channel's range across the full available brightness range.",
|
||||||
|
category: "color",
|
||||||
|
schema: autoContrastSchema,
|
||||||
|
run: (img) => autoContrast(img),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DecreaseColorCountParams {
|
||||||
|
maxColors:
|
||||||
|
"2" | "4" | "8" | "16" | "32" | "44" | "64" | "96" | "128" | "192" | "256";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const decreaseColorCountSchema = toolSchema<DecreaseColorCountParams>({
|
||||||
|
maxColors: field.select({
|
||||||
|
default: "16",
|
||||||
|
options: [
|
||||||
|
{ value: "2", label: "2" },
|
||||||
|
{ value: "4", label: "4" },
|
||||||
|
{ value: "8", label: "8" },
|
||||||
|
{ value: "16", label: "16 (extreme)" },
|
||||||
|
{ value: "32", label: "32" },
|
||||||
|
{ value: "44", label: "44 (strong)" },
|
||||||
|
{ value: "64", label: "64" },
|
||||||
|
{ value: "96", label: "96 (balanced)" },
|
||||||
|
{ value: "128", label: "128" },
|
||||||
|
{ value: "192", label: "192 (light)" },
|
||||||
|
{ value: "256", label: "256" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const decreaseColorCountTool: ToolEntry<DecreaseColorCountParams> = {
|
||||||
|
id: "decrease-color-count-png",
|
||||||
|
title: "Decrease Color Count PNG",
|
||||||
|
description:
|
||||||
|
"Median-cut engine as a quick way to drop to 2–256 colors. Presets marked (extreme/strong/balanced/light) match the classic compression levels.",
|
||||||
|
category: "color",
|
||||||
|
schema: decreaseColorCountSchema,
|
||||||
|
run: (img, p) => quantizeImage(img, Number(p.maxColors)).image,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ChannelParams {
|
||||||
|
component: string;
|
||||||
|
display: "gray" | "color";
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpaceEntry = {
|
||||||
|
id: SpaceId;
|
||||||
|
suffix: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CHANNEL_SPACES: SpaceEntry[] = [
|
||||||
|
{
|
||||||
|
id: "hsl",
|
||||||
|
suffix: "hsl",
|
||||||
|
title: "Split PNG into HSL",
|
||||||
|
description:
|
||||||
|
"Decomposes the image into Hue, Saturation and Lightness components.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "hsv",
|
||||||
|
suffix: "hsv",
|
||||||
|
title: "Split PNG into HSV",
|
||||||
|
description:
|
||||||
|
"Decomposes the image into Hue, Saturation and Value (brightness) components.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "hsi",
|
||||||
|
suffix: "hsi",
|
||||||
|
title: "Split PNG into HSI",
|
||||||
|
description:
|
||||||
|
"Decomposes the image into Hue, Saturation and Intensity components.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cmyk",
|
||||||
|
suffix: "cmyk",
|
||||||
|
title: "Convert PNG to CMYK Colors",
|
||||||
|
description:
|
||||||
|
"Decomposes the image into print-style Cyan, Magenta, Yellow and Key (black) components.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ycbcr",
|
||||||
|
suffix: "ycbcr",
|
||||||
|
title: "Convert PNG to YCbCr Colors",
|
||||||
|
description:
|
||||||
|
"Decomposes the image into Luma (Y) and Blue-difference / Red-difference chroma components.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "lab",
|
||||||
|
suffix: "lab",
|
||||||
|
title: "Convert PNG to LAB Colors",
|
||||||
|
description:
|
||||||
|
"Decomposes the image into perceptual Lightness and green–magenta / blue–yellow opponents.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function channelEntries(): ToolEntry<ChannelParams>[] {
|
||||||
|
return CHANNEL_SPACES.map((space) => {
|
||||||
|
const components = SPACES[space.id].components;
|
||||||
|
return {
|
||||||
|
id: `png-to-${space.suffix}`,
|
||||||
|
title: space.title,
|
||||||
|
description: space.description,
|
||||||
|
category: "color",
|
||||||
|
schema: toolSchema<ChannelParams>({
|
||||||
|
component: field.select({
|
||||||
|
default: components[0],
|
||||||
|
options: components.map((c) => ({
|
||||||
|
value: c,
|
||||||
|
label: c.toUpperCase(),
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
display: field.select({
|
||||||
|
default: "gray",
|
||||||
|
options: [
|
||||||
|
{ value: "gray", label: "Grayscale" },
|
||||||
|
{ value: "color", label: "Space as RGB" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
run: (img, p) => renderSpace(img, space.id, p.component, p.display),
|
||||||
|
} satisfies ToolEntry<ChannelParams>;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export const colorEntries = [
|
export const colorEntries = [
|
||||||
twoColorsTool,
|
twoColorsTool,
|
||||||
gammaTool,
|
gammaTool,
|
||||||
@@ -150,4 +487,17 @@ export const colorEntries = [
|
|||||||
quantizeTool,
|
quantizeTool,
|
||||||
customPalette,
|
customPalette,
|
||||||
ditheringTool,
|
ditheringTool,
|
||||||
|
grayscaleTool,
|
||||||
|
invertColorsTool,
|
||||||
|
brightnessContrastTool,
|
||||||
|
opacityTool,
|
||||||
|
sepiaTool,
|
||||||
|
hueShiftTool,
|
||||||
|
extractChannelTool,
|
||||||
|
swapChannelsTool,
|
||||||
|
blackAndWhiteTool,
|
||||||
|
posterizeTool,
|
||||||
|
autoContrastTool,
|
||||||
|
decreaseColorCountTool,
|
||||||
|
...channelEntries(),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,7 +1,20 @@
|
|||||||
import type { ToolEntry } from "./types";
|
import type { ToolEntry } from "./types";
|
||||||
import { field, toolSchema } from "../registry-schema";
|
import { field, toolSchema } from "../registry-schema";
|
||||||
import { flattenOntoColor } from "../core/alpha";
|
import { flattenOntoColor } from "../core/alpha";
|
||||||
|
import {
|
||||||
|
base64ToBytes,
|
||||||
|
bytesToImage,
|
||||||
|
imageToByteRows,
|
||||||
|
imageToRgbValues,
|
||||||
|
rgbValuesToImage,
|
||||||
|
stripDataUri,
|
||||||
|
} from "../core/textio";
|
||||||
|
import { hexToPixels, pixelsToHex } from "../core/text";
|
||||||
import { clonePixelImage } from "../core/types";
|
import { clonePixelImage } from "../core/types";
|
||||||
|
import { decodeBytes, decodeSvgText, toBase64, toDataUrl } from "../core/io";
|
||||||
|
|
||||||
|
const widthProps = (defaultValue: number) =>
|
||||||
|
field.number({ min: 1, max: 10000, step: 1, default: defaultValue });
|
||||||
|
|
||||||
interface ConvertToJpgParams {
|
interface ConvertToJpgParams {
|
||||||
background: string;
|
background: string;
|
||||||
@@ -21,6 +34,7 @@ const convertToJpg: ToolEntry<ConvertToJpgParams> = {
|
|||||||
category: "convert",
|
category: "convert",
|
||||||
schema: convertToJpgSchema,
|
schema: convertToJpgSchema,
|
||||||
run: (img, p) => flattenOntoColor(img, p.background),
|
run: (img, p) => flattenOntoColor(img, p.background),
|
||||||
|
output: { mime: "image/jpeg", ext: "jpg", qualityParamId: "quality" },
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ConvertToWebpParams {
|
interface ConvertToWebpParams {
|
||||||
@@ -39,6 +53,193 @@ const convertToWebp: ToolEntry<ConvertToWebpParams> = {
|
|||||||
category: "convert",
|
category: "convert",
|
||||||
schema: convertToWebpSchema,
|
schema: convertToWebpSchema,
|
||||||
run: (img) => clonePixelImage(img),
|
run: (img) => clonePixelImage(img),
|
||||||
|
output: { mime: "image/webp", ext: "webp", qualityParamId: "quality" },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const convertEntries = [convertToJpg, convertToWebp];
|
interface ConvertToBmpParams {}
|
||||||
|
|
||||||
|
export const convertToBmpSchema = toolSchema<ConvertToBmpParams>({});
|
||||||
|
|
||||||
|
const convertToBmp: ToolEntry<ConvertToBmpParams> = {
|
||||||
|
id: "png-to-bmp",
|
||||||
|
title: "Convert PNG to BMP",
|
||||||
|
description:
|
||||||
|
"Saves the image as 24-bit BMP without an alpha channel: transparency is replaced with a black background.",
|
||||||
|
category: "convert",
|
||||||
|
schema: convertToBmpSchema,
|
||||||
|
run: (img) => flattenOntoColor(img, "#000000"),
|
||||||
|
output: { mime: "image/bmp", ext: "bmp" },
|
||||||
|
};
|
||||||
|
|
||||||
|
interface NoParams {}
|
||||||
|
|
||||||
|
const emptySchema = toolSchema<NoParams>({});
|
||||||
|
|
||||||
|
const pngToBase64: ToolEntry<NoParams> = {
|
||||||
|
id: "png-to-base64",
|
||||||
|
title: "PNG to Base64",
|
||||||
|
description:
|
||||||
|
"Encodes the image into a base64 string for embedding in code or styles.",
|
||||||
|
category: "convert",
|
||||||
|
schema: emptySchema,
|
||||||
|
result: "text",
|
||||||
|
toText: (img) => toBase64(img),
|
||||||
|
};
|
||||||
|
|
||||||
|
const pngToDataUri: ToolEntry<NoParams> = {
|
||||||
|
id: "png-to-data-uri",
|
||||||
|
title: "PNG to Data URI",
|
||||||
|
description:
|
||||||
|
"Builds a full data-uri (data:image/png;base64,…) for embedding in HTML/CSS.",
|
||||||
|
category: "convert",
|
||||||
|
schema: emptySchema,
|
||||||
|
result: "text",
|
||||||
|
toText: (img) => toDataUrl(img),
|
||||||
|
};
|
||||||
|
|
||||||
|
const pngToHex: ToolEntry<NoParams> = {
|
||||||
|
id: "png-to-hex",
|
||||||
|
title: "PNG to HEX pixels",
|
||||||
|
description:
|
||||||
|
"Shows all pixels as rrggbbaa hex values — row by row, space separated.",
|
||||||
|
category: "convert",
|
||||||
|
schema: emptySchema,
|
||||||
|
result: "text",
|
||||||
|
toText: (img) => pixelsToHex(img),
|
||||||
|
};
|
||||||
|
|
||||||
|
const pngToBytes: ToolEntry<NoParams> = {
|
||||||
|
id: "png-to-bytes",
|
||||||
|
title: "PNG to Bytes",
|
||||||
|
description:
|
||||||
|
"Lists every pixel as four decimal bytes (R G B A), one image row per line.",
|
||||||
|
category: "convert",
|
||||||
|
schema: emptySchema,
|
||||||
|
result: "text",
|
||||||
|
toText: (img) => imageToByteRows(img),
|
||||||
|
};
|
||||||
|
|
||||||
|
const pngToRgbValues: ToolEntry<NoParams> = {
|
||||||
|
id: "png-to-rgb-values",
|
||||||
|
title: "PNG to RGB Values",
|
||||||
|
description: "Lists every pixel as rgba(r, g, b, a), one image row per line.",
|
||||||
|
category: "convert",
|
||||||
|
schema: emptySchema,
|
||||||
|
result: "text",
|
||||||
|
toText: (img) => imageToRgbValues(img),
|
||||||
|
};
|
||||||
|
|
||||||
|
const base64ToPng: ToolEntry<NoParams> = {
|
||||||
|
id: "base64-to-png",
|
||||||
|
title: "Base64 to PNG",
|
||||||
|
description:
|
||||||
|
"Decodes a base64 string or data-uri back into an image. Paste the string on the left.",
|
||||||
|
category: "convert",
|
||||||
|
schema: emptySchema,
|
||||||
|
input: "text",
|
||||||
|
runFromText: async (text) => decodeBytes(base64ToBytes(stripDataUri(text))),
|
||||||
|
};
|
||||||
|
|
||||||
|
const dataUriToPng: ToolEntry<NoParams> = {
|
||||||
|
id: "data-uri-to-png",
|
||||||
|
title: "Data URI to PNG",
|
||||||
|
description: "Decodes data:image/…;base64,… back into an image file.",
|
||||||
|
category: "convert",
|
||||||
|
schema: emptySchema,
|
||||||
|
input: "text",
|
||||||
|
runFromText: async (text) => decodeBytes(base64ToBytes(stripDataUri(text))),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface HexToPngParams {
|
||||||
|
width: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hexToPngSchema = toolSchema<HexToPngParams>({
|
||||||
|
width: widthProps(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
const hexToPng: ToolEntry<HexToPngParams> = {
|
||||||
|
id: "hex-to-png",
|
||||||
|
title: "HEX pixels to PNG",
|
||||||
|
description:
|
||||||
|
"Assembles an image from rrggbbaa hex values (space separated). Set the width — the height is computed automatically.",
|
||||||
|
category: "convert",
|
||||||
|
schema: hexToPngSchema,
|
||||||
|
input: "text",
|
||||||
|
runFromText: (text, p) => hexToPixels(text, Math.trunc(p.width)),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface BytesToPngParams {
|
||||||
|
width: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const bytesToPngSchema = toolSchema<BytesToPngParams>({
|
||||||
|
width: widthProps(32),
|
||||||
|
});
|
||||||
|
|
||||||
|
const bytesToPng: ToolEntry<BytesToPngParams> = {
|
||||||
|
id: "bytes-to-png",
|
||||||
|
title: "Bytes to PNG",
|
||||||
|
description:
|
||||||
|
"Assembles an image from decimal RGBA byte numbers (any separators). Set the width — height is computed automatically.",
|
||||||
|
category: "convert",
|
||||||
|
schema: bytesToPngSchema,
|
||||||
|
input: "text",
|
||||||
|
runFromText: (text, p) => bytesToImage(text, Math.trunc(p.width)),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface RgbValuesToPngParams {
|
||||||
|
width: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rgbValuesToPngSchema = toolSchema<RgbValuesToPngParams>({
|
||||||
|
width: widthProps(32),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rgbValuesToPng: ToolEntry<RgbValuesToPngParams> = {
|
||||||
|
id: "rgb-values-to-png",
|
||||||
|
title: "RGB Values to PNG",
|
||||||
|
description:
|
||||||
|
"Assembles an image from rgba(r, g, b, a) numbers. Set the width — height is computed automatically.",
|
||||||
|
category: "convert",
|
||||||
|
schema: rgbValuesToPngSchema,
|
||||||
|
input: "text",
|
||||||
|
runFromText: (text, p) => rgbValuesToImage(text, Math.trunc(p.width)),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SvgToPngParams {
|
||||||
|
width: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const svgToPngSchema = toolSchema<SvgToPngParams>({
|
||||||
|
width: widthProps(512),
|
||||||
|
});
|
||||||
|
|
||||||
|
const svgToPng: ToolEntry<SvgToPngParams> = {
|
||||||
|
id: "svg-to-png",
|
||||||
|
title: "SVG to PNG",
|
||||||
|
description:
|
||||||
|
"Decodes SVG markup into a raster image. Paste the SVG code on the left.",
|
||||||
|
category: "convert",
|
||||||
|
schema: svgToPngSchema,
|
||||||
|
input: "text",
|
||||||
|
domOnly: true,
|
||||||
|
runFromText: (text, p) => decodeSvgText(text, Math.trunc(p.width)),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const convertEntries = [
|
||||||
|
convertToJpg,
|
||||||
|
convertToWebp,
|
||||||
|
convertToBmp,
|
||||||
|
pngToBase64,
|
||||||
|
pngToDataUri,
|
||||||
|
pngToHex,
|
||||||
|
pngToBytes,
|
||||||
|
pngToRgbValues,
|
||||||
|
base64ToPng,
|
||||||
|
dataUriToPng,
|
||||||
|
hexToPng,
|
||||||
|
bytesToPng,
|
||||||
|
rgbValuesToPng,
|
||||||
|
svgToPng,
|
||||||
|
];
|
||||||
|
|||||||
@@ -1,9 +1,71 @@
|
|||||||
import type { ToolEntry } from "./types";
|
import type { ToolEntry } from "./types";
|
||||||
import { field, toolSchema } from "../registry-schema";
|
import { field, toolSchema } from "../registry-schema";
|
||||||
import { addNoise, pixelate, shuffleBlocks } from "../core/pixel-fx";
|
import {
|
||||||
|
addNoise,
|
||||||
|
pixelate,
|
||||||
|
shuffleBlocks,
|
||||||
|
silhouette,
|
||||||
|
} from "../core/pixel-fx";
|
||||||
|
import { gaussianBlur, sharpen as sharpenImage } from "../core/convolution";
|
||||||
import { vignette } from "../core/effects";
|
import { vignette } from "../core/effects";
|
||||||
import { jpegRoundtrip } from "../core/io";
|
import { jpegRoundtrip } from "../core/io";
|
||||||
|
|
||||||
|
interface BlurParams {
|
||||||
|
radius: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const blurSchema = toolSchema<BlurParams>({
|
||||||
|
radius: field.slider({ min: 1, max: 32, step: 1, default: 4 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const blurTool: ToolEntry<BlurParams> = {
|
||||||
|
id: "blur-png",
|
||||||
|
title: "Blur PNG",
|
||||||
|
description:
|
||||||
|
"Gaussian blur: three passes of separable box blur — fast at any radius. Transparent edges do not darken.",
|
||||||
|
category: "filters",
|
||||||
|
schema: blurSchema,
|
||||||
|
run: (img, p) => gaussianBlur(img, p.radius),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SharpenParams {
|
||||||
|
strength: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sharpenSchema = toolSchema<SharpenParams>({
|
||||||
|
strength: field.slider({ min: 0, max: 100, step: 1, default: 50 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const sharpenTool: ToolEntry<SharpenParams> = {
|
||||||
|
id: "sharpen-png",
|
||||||
|
title: "Sharpen PNG",
|
||||||
|
description:
|
||||||
|
"Emphasizes edges with a sharpening kernel; strength sets the blend with the original. 0% means no change.",
|
||||||
|
category: "filters",
|
||||||
|
schema: sharpenSchema,
|
||||||
|
run: (img, p) => sharpenImage(img, p.strength),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SilhouetteParams {
|
||||||
|
color: string;
|
||||||
|
threshold: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const silhouetteSchema = toolSchema<SilhouetteParams>({
|
||||||
|
color: field.color({ default: "#111318" }),
|
||||||
|
threshold: field.slider({ min: 0, max: 100, step: 1, default: 10 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const silhouetteTool: ToolEntry<SilhouetteParams> = {
|
||||||
|
id: "silhouette-png",
|
||||||
|
title: "Silhouette PNG",
|
||||||
|
description:
|
||||||
|
"Turns all visible pixels into a single solid color while keeping their transparency — instant silhouette.",
|
||||||
|
category: "filters",
|
||||||
|
schema: silhouetteSchema,
|
||||||
|
run: (img, p) => silhouette(img, p.color, p.threshold * 2.55),
|
||||||
|
};
|
||||||
|
|
||||||
interface VignetteParams {
|
interface VignetteParams {
|
||||||
strength: number;
|
strength: number;
|
||||||
}
|
}
|
||||||
@@ -129,4 +191,7 @@ export const filtersEntries = [
|
|||||||
randomizePixels,
|
randomizePixels,
|
||||||
addNoiseTool,
|
addNoiseTool,
|
||||||
jpegArtifacts,
|
jpegArtifacts,
|
||||||
|
blurTool,
|
||||||
|
sharpenTool,
|
||||||
|
silhouetteTool,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
import { renderTextToImage } from "../core/domText";
|
import { renderEmoji, renderTextToImage } from "../core/domText";
|
||||||
import { colorSpectrum, drawGrid, randomColorBlocks } from "../core/gen-tools";
|
import { colorSpectrum, drawGrid, randomColorBlocks } from "../core/gen-tools";
|
||||||
import { noiseImage, solidImage } from "../core/generate";
|
import { noiseImage, solidImage } from "../core/generate";
|
||||||
import { changeCanvasSize } from "../core/geometry";
|
import { changeCanvasSize } from "../core/geometry";
|
||||||
import type { PixelImage } from "../core/types";
|
import type { PixelImage } from "../core/types";
|
||||||
import {
|
import {
|
||||||
|
analogousSet,
|
||||||
|
complementarySet,
|
||||||
hexToRgb,
|
hexToRgb,
|
||||||
|
mixColors,
|
||||||
|
monochromaticSet,
|
||||||
renderBlend,
|
renderBlend,
|
||||||
renderSwatches,
|
renderSwatches,
|
||||||
|
renderWheel,
|
||||||
|
shadeSet,
|
||||||
|
sortPalette,
|
||||||
stepColors,
|
stepColors,
|
||||||
|
tetradicSet,
|
||||||
|
triadicSet,
|
||||||
} from "../core/palette";
|
} from "../core/palette";
|
||||||
import {
|
import {
|
||||||
field,
|
field,
|
||||||
@@ -439,6 +448,293 @@ const stepColorsTool: ToolEntry<StepColorsParams> = {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface EmojiToPngParams {
|
||||||
|
emoji: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const emojiToPngSchema = toolSchema<EmojiToPngParams>({
|
||||||
|
emoji: field.text({ default: "😀" }),
|
||||||
|
size: field.slider({ min: 32, max: 1024, step: 16, default: 256 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const emojiToPng: ToolEntry<EmojiToPngParams> = {
|
||||||
|
id: "emoji-to-png",
|
||||||
|
title: "Emoji to PNG",
|
||||||
|
description:
|
||||||
|
"Renders an emoji or any Unicode symbol as a transparent PNG of the chosen size.",
|
||||||
|
category: "generate",
|
||||||
|
domOnly: true,
|
||||||
|
schema: emojiToPngSchema,
|
||||||
|
generate: (p) => renderEmoji(p.emoji, Math.round(p.size)),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ColorWheelParams {
|
||||||
|
size: number;
|
||||||
|
lightness: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const colorWheelSchema = toolSchema<ColorWheelParams>({
|
||||||
|
size: field.slider({ min: 128, max: 1024, step: 16, default: 512 }),
|
||||||
|
lightness: field.slider({ min: 0, max: 100, step: 1, default: 50 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const colorWheelTool: ToolEntry<ColorWheelParams> = {
|
||||||
|
id: "color-wheel-png",
|
||||||
|
title: "Color Wheel PNG",
|
||||||
|
description:
|
||||||
|
"Generates an HSL color wheel: hue around the circle, saturation from center to edge, chosen lightness.",
|
||||||
|
category: "generate",
|
||||||
|
schema: colorWheelSchema,
|
||||||
|
generate: (p) => renderWheel(p.size, p.lightness),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PaletteBaseParams {
|
||||||
|
baseColor: string;
|
||||||
|
width: number;
|
||||||
|
layout: "strip" | "grid";
|
||||||
|
}
|
||||||
|
|
||||||
|
const paletteBaseSchema = {
|
||||||
|
baseColor: field.color({ default: "#2563eb" }),
|
||||||
|
width: field.slider({ min: 128, max: 1024, step: 16, default: 512 }),
|
||||||
|
layout: field.select({
|
||||||
|
default: "grid",
|
||||||
|
options: [
|
||||||
|
{ value: "grid", label: "Grid" },
|
||||||
|
{ value: "strip", label: "Strip" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const paletteLayoutGroup = {
|
||||||
|
layout: {
|
||||||
|
groups: [
|
||||||
|
{ title: "Base color", fields: ["baseColor"] },
|
||||||
|
{
|
||||||
|
title: "Output",
|
||||||
|
cols: 2,
|
||||||
|
fields: ["width", "layout"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const complementaryTool: ToolEntry<PaletteBaseParams> = {
|
||||||
|
id: "complementary-png",
|
||||||
|
title: "Complementary Palette PNG",
|
||||||
|
description:
|
||||||
|
"Two opposite colors on the color wheel — the base and its complement.",
|
||||||
|
category: "generate",
|
||||||
|
schema: toolSchema<PaletteBaseParams>(
|
||||||
|
{ ...paletteBaseSchema },
|
||||||
|
{ ...paletteLayoutGroup },
|
||||||
|
),
|
||||||
|
generate: (p) =>
|
||||||
|
renderSwatches(complementarySet(p.baseColor), p.width, p.layout),
|
||||||
|
};
|
||||||
|
|
||||||
|
const triadicTool: ToolEntry<PaletteBaseParams> = {
|
||||||
|
id: "triadic-png",
|
||||||
|
title: "Triadic Palette PNG",
|
||||||
|
description: "Three colors evenly spaced 120° apart on the color wheel.",
|
||||||
|
category: "generate",
|
||||||
|
schema: toolSchema<PaletteBaseParams>(
|
||||||
|
{
|
||||||
|
...paletteBaseSchema,
|
||||||
|
baseColor: field.color({ default: "#ff0000" }),
|
||||||
|
},
|
||||||
|
{ ...paletteLayoutGroup },
|
||||||
|
),
|
||||||
|
generate: (p) => renderSwatches(triadicSet(p.baseColor), p.width, p.layout),
|
||||||
|
};
|
||||||
|
|
||||||
|
const tetradicTool: ToolEntry<PaletteBaseParams> = {
|
||||||
|
id: "tetradic-png",
|
||||||
|
title: "Tetradic Palette PNG",
|
||||||
|
description:
|
||||||
|
"Four colors in two complementary pairs, 90° apart on the wheel.",
|
||||||
|
category: "generate",
|
||||||
|
schema: toolSchema<PaletteBaseParams>(
|
||||||
|
{
|
||||||
|
...paletteBaseSchema,
|
||||||
|
baseColor: field.color({ default: "#8000ff" }),
|
||||||
|
},
|
||||||
|
{ ...paletteLayoutGroup },
|
||||||
|
),
|
||||||
|
generate: (p) => renderSwatches(tetradicSet(p.baseColor), p.width, p.layout),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AnalogousParams extends PaletteBaseParams {
|
||||||
|
spread: number;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const analogousTool: ToolEntry<AnalogousParams> = {
|
||||||
|
id: "analogous-png",
|
||||||
|
title: "Analogous Palette PNG",
|
||||||
|
description:
|
||||||
|
"Neighboring hues around the base color — calm, related color scheme.",
|
||||||
|
category: "generate",
|
||||||
|
schema: toolSchema<AnalogousParams>(
|
||||||
|
{
|
||||||
|
...paletteBaseSchema,
|
||||||
|
baseColor: field.color({ default: "#22c55e" }),
|
||||||
|
spread: field.slider({ min: 10, max: 90, step: 5, default: 30 }),
|
||||||
|
count: field.slider({ min: 3, max: 9, step: 1, default: 5 }),
|
||||||
|
},
|
||||||
|
{ ...paletteLayoutGroup },
|
||||||
|
),
|
||||||
|
generate: (p) =>
|
||||||
|
renderSwatches(
|
||||||
|
analogousSet(p.baseColor, p.spread, p.count),
|
||||||
|
p.width,
|
||||||
|
p.layout,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface MonochromaticParams extends PaletteBaseParams {
|
||||||
|
count: number;
|
||||||
|
range: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const monochromaticTool: ToolEntry<MonochromaticParams> = {
|
||||||
|
id: "monochromatic-png",
|
||||||
|
title: "Monochromatic Palette PNG",
|
||||||
|
description:
|
||||||
|
"Tones of a single hue: lightness varies within the chosen range, hue and saturation stay fixed.",
|
||||||
|
category: "generate",
|
||||||
|
schema: toolSchema<MonochromaticParams>(
|
||||||
|
{
|
||||||
|
...paletteBaseSchema,
|
||||||
|
baseColor: field.color({ default: "#0ea5e9" }),
|
||||||
|
count: field.slider({ min: 2, max: 9, step: 1, default: 5 }),
|
||||||
|
range: field.slider({ min: 10, max: 90, step: 5, default: 40 }),
|
||||||
|
},
|
||||||
|
{ ...paletteLayoutGroup },
|
||||||
|
),
|
||||||
|
generate: (p) =>
|
||||||
|
renderSwatches(
|
||||||
|
monochromaticSet(p.baseColor, p.count, p.range),
|
||||||
|
p.width,
|
||||||
|
p.layout,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ShadesParams extends PaletteBaseParams {
|
||||||
|
count: number;
|
||||||
|
depth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shadesTool: ToolEntry<ShadesParams> = {
|
||||||
|
id: "shades-png",
|
||||||
|
title: "Shade Ramp PNG",
|
||||||
|
description: "A ramp of the base color getting darker step by step.",
|
||||||
|
category: "generate",
|
||||||
|
schema: toolSchema<ShadesParams>(
|
||||||
|
{
|
||||||
|
...paletteBaseSchema,
|
||||||
|
baseColor: field.color({ default: "#f59e0b" }),
|
||||||
|
count: field.slider({ min: 2, max: 9, step: 1, default: 5 }),
|
||||||
|
depth: field.slider({ min: 10, max: 90, step: 5, default: 50 }),
|
||||||
|
},
|
||||||
|
{ ...paletteLayoutGroup },
|
||||||
|
),
|
||||||
|
generate: (p) =>
|
||||||
|
renderSwatches(shadeSet(p.baseColor, p.count, p.depth), p.width, p.layout),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface MixColorsParams {
|
||||||
|
colors: string[];
|
||||||
|
width: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mixColorsSchema = toolSchema<MixColorsParams>(
|
||||||
|
{
|
||||||
|
colors: field.colors({ default: ["#ff0000", "#00ff00", "#0000ff"] }),
|
||||||
|
width: field.slider({ min: 128, max: 1024, step: 16, default: 512 }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
layout: {
|
||||||
|
groups: [
|
||||||
|
{ title: "Colors", fields: ["colors"] },
|
||||||
|
{ title: "Output", fields: ["width"] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const mixColorsTool: ToolEntry<MixColorsParams> = {
|
||||||
|
id: "mix-colors-png",
|
||||||
|
title: "Mix Colors PNG",
|
||||||
|
description:
|
||||||
|
"Averages the selected colors into one swatch. Colors become one uniform fill.",
|
||||||
|
category: "generate",
|
||||||
|
schema: mixColorsSchema,
|
||||||
|
generate: (p) => renderSwatches([mixColors(p.colors)], p.width, "strip"),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SortColorsParams {
|
||||||
|
colors: string[];
|
||||||
|
order: "hue" | "luma" | "sat";
|
||||||
|
width: number;
|
||||||
|
layout: "strip" | "grid";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sortColorsSchema = toolSchema<SortColorsParams>(
|
||||||
|
{
|
||||||
|
colors: field.colors({
|
||||||
|
default: [
|
||||||
|
"#ff0000",
|
||||||
|
"#ff8800",
|
||||||
|
"#ffff00",
|
||||||
|
"#00cc44",
|
||||||
|
"#0066ff",
|
||||||
|
"#8800ff",
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
order: field.select({
|
||||||
|
default: "hue",
|
||||||
|
options: [
|
||||||
|
{ value: "hue", label: "Hue" },
|
||||||
|
{ value: "luma", label: "Brightness" },
|
||||||
|
{ value: "sat", label: "Saturation" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
width: field.slider({ min: 128, max: 1024, step: 16, default: 512 }),
|
||||||
|
layout: field.select({
|
||||||
|
default: "grid",
|
||||||
|
options: [
|
||||||
|
{ value: "grid", label: "Grid" },
|
||||||
|
{ value: "strip", label: "Strip" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
layout: {
|
||||||
|
groups: [
|
||||||
|
{ title: "Colors", fields: ["colors"] },
|
||||||
|
{
|
||||||
|
title: "Output",
|
||||||
|
cols: 2,
|
||||||
|
fields: ["order", "width", "layout"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const sortColorsTool: ToolEntry<SortColorsParams> = {
|
||||||
|
id: "sort-colors-png",
|
||||||
|
title: "Sort Colors PNG",
|
||||||
|
description:
|
||||||
|
"Renders the chosen colors as swatches sorted by hue, brightness or saturation.",
|
||||||
|
category: "generate",
|
||||||
|
schema: sortColorsSchema,
|
||||||
|
generate: (p) =>
|
||||||
|
renderSwatches(sortPalette(p.colors, p.order), p.width, p.layout),
|
||||||
|
};
|
||||||
|
|
||||||
interface TextToPngParams {
|
interface TextToPngParams {
|
||||||
text: string;
|
text: string;
|
||||||
style: FontStyle;
|
style: FontStyle;
|
||||||
@@ -505,5 +801,15 @@ export const generateEntries = [
|
|||||||
placeholder,
|
placeholder,
|
||||||
blendTwo,
|
blendTwo,
|
||||||
stepColorsTool,
|
stepColorsTool,
|
||||||
|
emojiToPng,
|
||||||
|
colorWheelTool,
|
||||||
|
complementaryTool,
|
||||||
|
triadicTool,
|
||||||
|
tetradicTool,
|
||||||
|
analogousTool,
|
||||||
|
monochromaticTool,
|
||||||
|
shadesTool,
|
||||||
|
mixColorsTool,
|
||||||
|
sortColorsTool,
|
||||||
textToPng,
|
textToPng,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,10 +1,26 @@
|
|||||||
import { ToolError } from "../core/errors";
|
import { ToolError } from "../core/errors";
|
||||||
import {
|
import {
|
||||||
|
rotateFreeImage,
|
||||||
|
skewImage,
|
||||||
|
transformImage,
|
||||||
|
zoomImage,
|
||||||
|
} from "../core/affine";
|
||||||
|
import {
|
||||||
|
centerByAlpha,
|
||||||
changeCanvasSize,
|
changeCanvasSize,
|
||||||
crop,
|
crop,
|
||||||
|
cropToRatio,
|
||||||
expandCanvas,
|
expandCanvas,
|
||||||
|
flip,
|
||||||
|
forceOrientation,
|
||||||
|
padToRatio,
|
||||||
resize,
|
resize,
|
||||||
|
rotate90,
|
||||||
|
symmetricCopy,
|
||||||
|
tile,
|
||||||
|
trimToContent,
|
||||||
type Anchor9,
|
type Anchor9,
|
||||||
|
type FlipAxis,
|
||||||
} from "../core/geometry";
|
} from "../core/geometry";
|
||||||
import { field, toolSchema, type Dimension } from "../registry-schema";
|
import { field, toolSchema, type Dimension } from "../registry-schema";
|
||||||
import type { ToolEntry } from "./types";
|
import type { ToolEntry } from "./types";
|
||||||
@@ -222,10 +238,349 @@ const cropTool: ToolEntry<CropParams> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface RotateParams {
|
||||||
|
angle: "90" | "180" | "270";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rotateSchema = toolSchema<RotateParams>({
|
||||||
|
angle: field.select({
|
||||||
|
default: "90",
|
||||||
|
options: [
|
||||||
|
{ value: "90", label: "90° clockwise" },
|
||||||
|
{ value: "180", label: "180°" },
|
||||||
|
{ value: "270", label: "270° clockwise" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rotateTool: ToolEntry<RotateParams> = {
|
||||||
|
id: "rotate-png",
|
||||||
|
title: "Rotate PNG",
|
||||||
|
description: "Rotates by 90°, 180° or 270° clockwise without quality loss.",
|
||||||
|
category: "geometry",
|
||||||
|
schema: rotateSchema,
|
||||||
|
run: (img, p) => rotate90(img, Number(p.angle) / 90),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FlipParams {
|
||||||
|
axis: FlipAxis;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const flipSchema = toolSchema<FlipParams>({
|
||||||
|
axis: field.select({
|
||||||
|
default: "horizontal",
|
||||||
|
options: [
|
||||||
|
{ value: "horizontal", label: "Horizontal (left to right)" },
|
||||||
|
{ value: "vertical", label: "Vertical (top to bottom)" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const flipTool: ToolEntry<FlipParams> = {
|
||||||
|
id: "flip-png",
|
||||||
|
title: "Flip PNG",
|
||||||
|
description: "Mirrors horizontally or vertically without quality loss.",
|
||||||
|
category: "geometry",
|
||||||
|
schema: flipSchema,
|
||||||
|
run: (img, p) => flip(img, p.axis),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AddPaddingParams {
|
||||||
|
padding: number;
|
||||||
|
transparent: boolean;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const addPaddingSchema = toolSchema<AddPaddingParams>(
|
||||||
|
{
|
||||||
|
padding: field.number({ min: 1, max: 2000, step: 1, default: 10 }),
|
||||||
|
transparent: field.checkbox({ default: true }),
|
||||||
|
color: field.color({ default: "#ffffff" }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
layout: {
|
||||||
|
groups: [
|
||||||
|
{ title: "Padding", fields: ["padding"] },
|
||||||
|
{ title: "Fill", fields: ["transparent", "color"] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const addPaddingTool: ToolEntry<AddPaddingParams> = {
|
||||||
|
id: "add-padding-png",
|
||||||
|
title: "Add padding to PNG",
|
||||||
|
description:
|
||||||
|
"Expands the canvas on all sides by the chosen number of pixels.",
|
||||||
|
category: "geometry",
|
||||||
|
schema: addPaddingSchema,
|
||||||
|
run: (img, p) =>
|
||||||
|
expandCanvas(
|
||||||
|
img,
|
||||||
|
p.padding,
|
||||||
|
p.padding,
|
||||||
|
p.padding,
|
||||||
|
p.padding,
|
||||||
|
p.transparent ? undefined : p.color,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TileParams {
|
||||||
|
columns: number;
|
||||||
|
rows: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const tileSchema = toolSchema<TileParams>({
|
||||||
|
columns: field.number({ min: 1, max: 50, step: 1, default: 2 }),
|
||||||
|
rows: field.number({ min: 1, max: 50, step: 1, default: 2 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const tileTool: ToolEntry<TileParams> = {
|
||||||
|
id: "tile-png",
|
||||||
|
title: "Tile PNG",
|
||||||
|
description: "Repeats the image in a grid of the chosen columns and rows.",
|
||||||
|
category: "geometry",
|
||||||
|
schema: tileSchema,
|
||||||
|
run: (img, p) => tile(img, p.columns, p.rows),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface EmptyParams {}
|
||||||
|
|
||||||
|
export const centerByAlphaSchema = toolSchema<EmptyParams>({});
|
||||||
|
|
||||||
|
const centerByAlphaTool: ToolEntry<EmptyParams> = {
|
||||||
|
id: "center-by-alpha-png",
|
||||||
|
title: "Center PNG by content",
|
||||||
|
description:
|
||||||
|
"Finds the opaque part of the image and centers it on the original canvas.",
|
||||||
|
category: "geometry",
|
||||||
|
schema: centerByAlphaSchema,
|
||||||
|
run: (img) => centerByAlpha(img),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SkewParams {
|
||||||
|
degX: number;
|
||||||
|
degY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const skewSchema = toolSchema<SkewParams>({
|
||||||
|
degX: field.slider({ min: -80, max: 80, step: 1, default: 0 }),
|
||||||
|
degY: field.slider({ min: -80, max: 80, step: 1, default: 0 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const skewTool: ToolEntry<SkewParams> = {
|
||||||
|
id: "skew-png",
|
||||||
|
title: "Skew PNG",
|
||||||
|
description:
|
||||||
|
"Shifts content horizontally and vertically — a perspective effect.",
|
||||||
|
category: "geometry",
|
||||||
|
schema: skewSchema,
|
||||||
|
run: (img, p) => skewImage(img, p.degX, p.degY),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface RotateFreeParams {
|
||||||
|
angle: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rotateFreeSchema = toolSchema<RotateFreeParams>({
|
||||||
|
angle: field.slider({ min: -180, max: 180, step: 1, default: 15 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rotateFreeTool: ToolEntry<RotateFreeParams> = {
|
||||||
|
id: "rotate-free-png",
|
||||||
|
title: "Rotate by custom angle",
|
||||||
|
description:
|
||||||
|
"Rotation by any angle. The canvas grows to fit the new bounds; corners stay transparent.",
|
||||||
|
category: "geometry",
|
||||||
|
schema: rotateFreeSchema,
|
||||||
|
run: (img, p) => rotateFreeImage(img, p.angle),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ZoomParams {
|
||||||
|
scale: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const zoomSchema = toolSchema<ZoomParams>({
|
||||||
|
scale: field.slider({ min: 100, max: 500, step: 10, default: 200 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const zoomTool: ToolEntry<ZoomParams> = {
|
||||||
|
id: "zoom-png",
|
||||||
|
title: "Zoom PNG",
|
||||||
|
description:
|
||||||
|
"Magnifies content toward the center. The canvas keeps its size — edges are cropped.",
|
||||||
|
category: "geometry",
|
||||||
|
schema: zoomSchema,
|
||||||
|
run: (img, p) => zoomImage(img, p.scale),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TrimEmptySpaceParams {
|
||||||
|
threshold: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const trimEmptySpaceSchema = toolSchema<TrimEmptySpaceParams>({
|
||||||
|
threshold: field.slider({ min: 0, max: 254, step: 1, default: 0 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const trimEmptySpaceTool: ToolEntry<TrimEmptySpaceParams> = {
|
||||||
|
id: "trim-empty-space-png",
|
||||||
|
title: "Trim Empty Space PNG",
|
||||||
|
description:
|
||||||
|
"Crops transparent borders around the content. Pixels with alpha above the threshold count as content.",
|
||||||
|
category: "geometry",
|
||||||
|
schema: trimEmptySpaceSchema,
|
||||||
|
run: (img, p) => trimToContent(img, p.threshold),
|
||||||
|
};
|
||||||
|
|
||||||
|
type AspectRatio = "1:1" | "4:3" | "3:4" | "3:2" | "2:3" | "16:9" | "9:16";
|
||||||
|
|
||||||
|
interface ChangeAspectRatioParams {
|
||||||
|
ratio: AspectRatio;
|
||||||
|
mode: "crop" | "pad";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const changeAspectRatioSchema = toolSchema<ChangeAspectRatioParams>({
|
||||||
|
ratio: field.select({
|
||||||
|
default: "1:1",
|
||||||
|
options: [
|
||||||
|
{ value: "1:1", label: "1:1" },
|
||||||
|
{ value: "4:3", label: "4:3" },
|
||||||
|
{ value: "3:4", label: "3:4" },
|
||||||
|
{ value: "3:2", label: "3:2" },
|
||||||
|
{ value: "2:3", label: "2:3" },
|
||||||
|
{ value: "16:9", label: "16:9" },
|
||||||
|
{ value: "9:16", label: "9:16" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
mode: field.select({
|
||||||
|
default: "crop",
|
||||||
|
options: [
|
||||||
|
{ value: "crop", label: "Crop to fill" },
|
||||||
|
{ value: "pad", label: "Pad to fit" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const changeAspectRatioTool: ToolEntry<ChangeAspectRatioParams> = {
|
||||||
|
id: "change-aspect-ratio-png",
|
||||||
|
title: "Change Aspect Ratio PNG",
|
||||||
|
description:
|
||||||
|
"Fits the image into a target aspect ratio: crop the center to fill, or pad with transparency.",
|
||||||
|
category: "geometry",
|
||||||
|
schema: changeAspectRatioSchema,
|
||||||
|
run: (img, p) => {
|
||||||
|
const [rw, rh] = p.ratio.split(":").map(Number);
|
||||||
|
const ratio = rw / rh;
|
||||||
|
return p.mode === "pad" ? padToRatio(img, ratio) : cropToRatio(img, ratio);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SwapOrientationParams {
|
||||||
|
target: "portrait" | "landscape";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const swapOrientationSchema = toolSchema<SwapOrientationParams>({
|
||||||
|
target: field.select({
|
||||||
|
default: "portrait",
|
||||||
|
options: [
|
||||||
|
{ value: "portrait", label: "Portrait" },
|
||||||
|
{ value: "landscape", label: "Landscape" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const swapOrientationTool: ToolEntry<SwapOrientationParams> = {
|
||||||
|
id: "swap-orientation-png",
|
||||||
|
title: "Swap Orientation PNG",
|
||||||
|
description:
|
||||||
|
"Rotates the image by 90° when its orientation differs from the target — landscape becomes portrait and back. Square images are untouched.",
|
||||||
|
category: "geometry",
|
||||||
|
schema: swapOrientationSchema,
|
||||||
|
run: (img, p) => forceOrientation(img, p.target),
|
||||||
|
};
|
||||||
|
|
||||||
|
type SymmetricAxis = "horizontal" | "vertical";
|
||||||
|
type KeepSide = "left" | "right" | "top" | "bottom";
|
||||||
|
|
||||||
|
interface SymmetricCopyParams {
|
||||||
|
axis: SymmetricAxis;
|
||||||
|
keepSide: KeepSide;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const symmetricCopySchema = toolSchema<SymmetricCopyParams>({
|
||||||
|
axis: field.select({
|
||||||
|
default: "vertical",
|
||||||
|
options: [
|
||||||
|
{ value: "vertical", label: "Vertical (double width)" },
|
||||||
|
{ value: "horizontal", label: "Horizontal (double height)" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
keepSide: field.select({
|
||||||
|
default: "left",
|
||||||
|
options: [
|
||||||
|
{ value: "left", label: "Left" },
|
||||||
|
{ value: "right", label: "Right" },
|
||||||
|
{ value: "top", label: "Top" },
|
||||||
|
{ value: "bottom", label: "Bottom" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const symmetricCopyTool: ToolEntry<SymmetricCopyParams> = {
|
||||||
|
id: "symmetric-copy-png",
|
||||||
|
title: "Symmetric Copy PNG",
|
||||||
|
description:
|
||||||
|
"Doubles the canvas by mirroring the kept side onto the empty half — instant symmetric pattern.",
|
||||||
|
category: "geometry",
|
||||||
|
schema: symmetricCopySchema,
|
||||||
|
run: (img, p) => symmetricCopy(img, p.axis, p.keepSide),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ShiftParams {
|
||||||
|
offsetX: number;
|
||||||
|
offsetY: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const shiftSchema = toolSchema<ShiftParams>({
|
||||||
|
offsetX: field.number({ min: -5000, max: 5000, step: 1, default: 0 }),
|
||||||
|
offsetY: field.number({ min: -5000, max: 5000, step: 1, default: 0 }),
|
||||||
|
color: field.color({ default: "#ffffff" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const shiftTool: ToolEntry<ShiftParams> = {
|
||||||
|
id: "shift-png",
|
||||||
|
title: "Shift PNG",
|
||||||
|
description: "Moves content by the given X and Y offset.",
|
||||||
|
category: "geometry",
|
||||||
|
schema: shiftSchema,
|
||||||
|
run: (img, p) =>
|
||||||
|
transformImage(
|
||||||
|
img,
|
||||||
|
[1, 0, 0, 1, -Math.trunc(p.offsetX), -Math.trunc(p.offsetY)],
|
||||||
|
img.width,
|
||||||
|
img.height,
|
||||||
|
p.color,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
export const geometryEntries = [
|
export const geometryEntries = [
|
||||||
addBorder,
|
addBorder,
|
||||||
fitOnBackground,
|
fitOnBackground,
|
||||||
changeCanvasSizeTool,
|
changeCanvasSizeTool,
|
||||||
resizeTool,
|
resizeTool,
|
||||||
cropTool,
|
cropTool,
|
||||||
|
rotateTool,
|
||||||
|
flipTool,
|
||||||
|
addPaddingTool,
|
||||||
|
tileTool,
|
||||||
|
centerByAlphaTool,
|
||||||
|
skewTool,
|
||||||
|
rotateFreeTool,
|
||||||
|
zoomTool,
|
||||||
|
trimEmptySpaceTool,
|
||||||
|
changeAspectRatioTool,
|
||||||
|
swapOrientationTool,
|
||||||
|
symmetricCopyTool,
|
||||||
|
shiftTool,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { formatStamp } from "../core/datefmt";
|
import { formatStamp } from "../core/datefmt";
|
||||||
import { drawTextBlock } from "../core/domText";
|
import { drawTextBlock, drawTextTile } from "../core/domText";
|
||||||
import type { Position9 } from "../core/textdraw";
|
import type { Position9 } from "../core/textdraw";
|
||||||
import type { FontStyle, Plate } from "../registry-schema";
|
import type { FontStyle, Plate } from "../registry-schema";
|
||||||
import { field, toolSchema } from "../registry-schema";
|
import { field, toolSchema } from "../registry-schema";
|
||||||
@@ -122,4 +122,65 @@ const dateStamp: ToolEntry<DateStampParams> = {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const textEntries = [addText, dateStamp];
|
interface WatermarkTileParams {
|
||||||
|
text: string;
|
||||||
|
style: FontStyle;
|
||||||
|
opacity: number;
|
||||||
|
angle: number;
|
||||||
|
stepX: number;
|
||||||
|
stepY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const watermarkTileSchema = toolSchema<WatermarkTileParams>(
|
||||||
|
{
|
||||||
|
text: field.text({ default: "DRAFT", placeholder: "Watermark text" }),
|
||||||
|
style: field.fontStyle({
|
||||||
|
min: 12,
|
||||||
|
max: 160,
|
||||||
|
size: 56,
|
||||||
|
font: "sans",
|
||||||
|
bold: true,
|
||||||
|
color: "#ffffff",
|
||||||
|
}),
|
||||||
|
opacity: field.slider({ min: 5, max: 100, step: 5, default: 30 }),
|
||||||
|
angle: field.slider({ min: -90, max: 90, step: 1, default: -30 }),
|
||||||
|
stepX: field.slider({ min: 40, max: 600, step: 10, default: 220 }),
|
||||||
|
stepY: field.slider({ min: 40, max: 600, step: 10, default: 180 }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
layout: {
|
||||||
|
groups: [
|
||||||
|
{ title: "Watermark", fields: ["text", "style", "opacity"] },
|
||||||
|
{
|
||||||
|
title: "Tile",
|
||||||
|
cols: 2,
|
||||||
|
fields: ["angle", "stepX", "stepY"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const watermarkTile: ToolEntry<WatermarkTileParams> = {
|
||||||
|
id: "watermark-tile-png",
|
||||||
|
title: "Watermark Tile PNG",
|
||||||
|
description:
|
||||||
|
"Covers the image with a repeating diagonal semi-transparent text tile — a protection watermark.",
|
||||||
|
category: "text",
|
||||||
|
domOnly: true,
|
||||||
|
schema: watermarkTileSchema,
|
||||||
|
run: (img, p) =>
|
||||||
|
drawTextTile(img, {
|
||||||
|
text: p.text,
|
||||||
|
fontSize: p.style.size,
|
||||||
|
font: p.style.font,
|
||||||
|
bold: p.style.bold,
|
||||||
|
color: p.style.color,
|
||||||
|
opacityPercent: p.opacity,
|
||||||
|
stepX: p.stepX,
|
||||||
|
stepY: p.stepY,
|
||||||
|
angleDeg: p.angle,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const textEntries = [addText, dateStamp, watermarkTile];
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import type { CategoryId } from "../preview/categories";
|
import type { CategoryId } from "../preview/categories";
|
||||||
import type { ToolSchema } from "../registry-schema";
|
import type { ToolSchema } from "../registry-schema";
|
||||||
import type { PixelImage } from "../core/types";
|
import type { PixelImage } from "../core/types";
|
||||||
|
import type { OutputMime } from "../core/io";
|
||||||
|
|
||||||
|
/** Формат скачивания, отличный от PNG (bmp/jpeg/webp). */
|
||||||
|
export interface OutputFormat {
|
||||||
|
mime: OutputMime;
|
||||||
|
ext: string;
|
||||||
|
qualityParamId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Инструмент нового registry: полностью типизирован на `Params`, схема —
|
* Инструмент нового registry: полностью типизирован на `Params`, схема —
|
||||||
@@ -15,6 +23,10 @@ export type ToolEntry<P = Record<string, unknown>> = {
|
|||||||
schema: ToolSchema<P>;
|
schema: ToolSchema<P>;
|
||||||
/** Требует DOM (canvas/document); превью-executor запускает напрямую, не в worker. */
|
/** Требует DOM (canvas/document); превью-executor запускает напрямую, не в worker. */
|
||||||
domOnly?: boolean;
|
domOnly?: boolean;
|
||||||
|
/** Чем управляется инструмент: файлом (по умолчанию), текстом или ничем (генератор). */
|
||||||
|
input?: "file" | "text" | "none";
|
||||||
|
/** Тип результата: картинка (по умолчанию), большой текст или короткий вердикт. */
|
||||||
|
result?: "image" | "text" | "verdict";
|
||||||
/** Применение к входному изображению. Для чистых генераторов отсутствует. */
|
/** Применение к входному изображению. Для чистых генераторов отсутствует. */
|
||||||
run?(img: PixelImage, params: P): Promise<PixelImage> | PixelImage;
|
run?(img: PixelImage, params: P): Promise<PixelImage> | PixelImage;
|
||||||
generate?(params: P): Promise<PixelImage> | PixelImage;
|
generate?(params: P): Promise<PixelImage> | PixelImage;
|
||||||
@@ -22,5 +34,7 @@ export type ToolEntry<P = Record<string, unknown>> = {
|
|||||||
runFromText?(text: string, params: P): Promise<PixelImage> | PixelImage;
|
runFromText?(text: string, params: P): Promise<PixelImage> | PixelImage;
|
||||||
textToText?(text: string): Promise<string> | string;
|
textToText?(text: string): Promise<string> | string;
|
||||||
preview?(img: PixelImage, params: P): Promise<PixelImage> | PixelImage;
|
preview?(img: PixelImage, params: P): Promise<PixelImage> | PixelImage;
|
||||||
|
/** Формат/качество скачивания результата; по умолчанию — PNG. */
|
||||||
|
output?: OutputFormat;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -79,6 +79,14 @@ export interface ColorPairSpec {
|
|||||||
to: string;
|
to: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Список цветов (палитра): массив hex-строк. */
|
||||||
|
export type ColorList = string[];
|
||||||
|
|
||||||
|
export interface ColorListSpec {
|
||||||
|
kind: "colors";
|
||||||
|
default: string[];
|
||||||
|
}
|
||||||
|
|
||||||
/** Смещение фигуры/объекта по центру: x + y в процентах. */
|
/** Смещение фигуры/объекта по центру: x + y в процентах. */
|
||||||
export interface Offset {
|
export interface Offset {
|
||||||
x: number;
|
x: number;
|
||||||
@@ -187,6 +195,7 @@ export const fieldSpecs = {
|
|||||||
checkbox: {} as CheckboxSpec,
|
checkbox: {} as CheckboxSpec,
|
||||||
dimension: {} as DimensionSpec,
|
dimension: {} as DimensionSpec,
|
||||||
"color-pair": {} as ColorPairSpec,
|
"color-pair": {} as ColorPairSpec,
|
||||||
|
colors: {} as ColorListSpec,
|
||||||
offset: {} as OffsetSpec,
|
offset: {} as OffsetSpec,
|
||||||
position9: {} as Position9Spec,
|
position9: {} as Position9Spec,
|
||||||
"font-style": {} as FontStyleSpec,
|
"font-style": {} as FontStyleSpec,
|
||||||
@@ -262,6 +271,9 @@ export const field = {
|
|||||||
colorPair: (s: { from: string; to: string }): Field<ColorPair> => ({
|
colorPair: (s: { from: string; to: string }): Field<ColorPair> => ({
|
||||||
spec: { kind: "color-pair", ...s },
|
spec: { kind: "color-pair", ...s },
|
||||||
}),
|
}),
|
||||||
|
colors: (s: { default: string[] }): Field<ColorList> => ({
|
||||||
|
spec: { kind: "colors", ...s },
|
||||||
|
}),
|
||||||
offset: (s: {
|
offset: (s: {
|
||||||
min: number;
|
min: number;
|
||||||
max: number;
|
max: number;
|
||||||
@@ -327,6 +339,8 @@ export function defaultSchemaParams<P>(
|
|||||||
out[key] = { width: spec.width, height: spec.height };
|
out[key] = { width: spec.width, height: spec.height };
|
||||||
} else if (spec.kind === "color-pair") {
|
} else if (spec.kind === "color-pair") {
|
||||||
out[key] = { from: spec.from, to: spec.to };
|
out[key] = { from: spec.from, to: spec.to };
|
||||||
|
} else if (spec.kind === "colors") {
|
||||||
|
out[key] = [...spec.default];
|
||||||
} else if (spec.kind === "offset") {
|
} else if (spec.kind === "offset") {
|
||||||
out[key] = { x: spec.x, y: spec.y };
|
out[key] = { x: spec.x, y: spec.y };
|
||||||
} else if (spec.kind === "font-style") {
|
} else if (spec.kind === "font-style") {
|
||||||
@@ -431,6 +445,15 @@ export function sanitizeSchemaParams<P>(
|
|||||||
out[key] = { from, to };
|
out[key] = { from, to };
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "colors": {
|
||||||
|
const r =
|
||||||
|
Array.isArray(raw) &&
|
||||||
|
raw.every((c) => typeof c === "string" && /^#[0-9a-f]{6}$/i.test(c))
|
||||||
|
? (raw as string[])
|
||||||
|
: undefined;
|
||||||
|
out[key] = r && r.length > 0 ? [...r] : [...spec.default];
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "position9":
|
case "position9":
|
||||||
out[key] =
|
out[key] =
|
||||||
typeof raw === "string" &&
|
typeof raw === "string" &&
|
||||||
|
|||||||
Reference in New Issue
Block a user