mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-11-13 17:08:20 +00:00
feat: remove v1 routes and libs
This commit is contained in:
@@ -1,283 +0,0 @@
|
||||
@layer app {
|
||||
:root {
|
||||
color-scheme: light;
|
||||
|
||||
--bg: #f6f7f9;
|
||||
--surface: #ffffff;
|
||||
--text: #1b1e24;
|
||||
--text-muted: #5c6470;
|
||||
--accent: #2563eb;
|
||||
--accent-hover: #1d4ed8;
|
||||
--accent-contrast: #ffffff;
|
||||
--link: var(--accent);
|
||||
--border: #dfe2e8;
|
||||
--danger: #e5484d;
|
||||
--danger-strong: #b3261e;
|
||||
--check-a: #eceff3;
|
||||
--check-b: #ffffff;
|
||||
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 1rem;
|
||||
--space-4: 1.5rem;
|
||||
--space-5: 2.5rem;
|
||||
|
||||
--radius-s: 6px;
|
||||
--radius-m: 10px;
|
||||
|
||||
--text-xs: 0.8rem;
|
||||
--text-s: 0.85rem;
|
||||
--text-m: 0.95rem;
|
||||
--text-l: 1.1rem;
|
||||
--text-xl: 1.25rem;
|
||||
--text-xxl: 1.75rem;
|
||||
--leading-tight: 1.25;
|
||||
|
||||
--shadow-card: 0 4px 14px rgb(37 99 235 / 12%);
|
||||
--transition-fast: 120ms ease;
|
||||
|
||||
--control-height: 2.4rem;
|
||||
--control-max-width: 16rem;
|
||||
|
||||
--font-sans:
|
||||
system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial,
|
||||
sans-serif;
|
||||
--font-mono: ui-monospace, "Cascadia Code", Consolas, monospace;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
|
||||
--bg: #12151a;
|
||||
--surface: #1b2027;
|
||||
--text: #e6e9ee;
|
||||
--text-muted: #98a1ad;
|
||||
--accent-hover: #4c85f0;
|
||||
--link: #6ea0ff;
|
||||
--border: #303842;
|
||||
--danger: #ff7479;
|
||||
--danger-strong: #ff8589;
|
||||
--check-a: #232830;
|
||||
--check-b: #171b21;
|
||||
|
||||
--shadow-card: 0 4px 14px rgb(0 0 0 / 45%);
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: var(--font-sans);
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
line-height: 1.55;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: var(--text-xxl);
|
||||
line-height: var(--leading-tight);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: var(--text-xl);
|
||||
line-height: var(--leading-tight);
|
||||
}
|
||||
|
||||
p,
|
||||
label {
|
||||
font-size: var(--text-m);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--link);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-s);
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.text-caption {
|
||||
font-size: var(--text-s);
|
||||
}
|
||||
|
||||
.heading-section {
|
||||
font-size: var(--text-l);
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-m);
|
||||
}
|
||||
|
||||
.control {
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-s);
|
||||
background: var(--surface);
|
||||
max-width: var(--control-max-width);
|
||||
}
|
||||
|
||||
input.control[type="number"] {
|
||||
font-family: var(--font-mono);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--danger);
|
||||
border-radius: var(--radius-s);
|
||||
background: color-mix(in srgb, var(--danger) 8%, var(--surface));
|
||||
color: var(--danger-strong);
|
||||
font-size: var(--text-s);
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.tool-stage {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
align-items: start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tool-stage.single {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tool-stage .cell {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.tool-stage .cell + .cell {
|
||||
border-left: 1px solid var(--border);
|
||||
padding-left: var(--space-4);
|
||||
}
|
||||
|
||||
.cell-media {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 14rem;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chain-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.edge-legend {
|
||||
position: absolute;
|
||||
top: -0.65em;
|
||||
padding: 0 var(--space-2);
|
||||
background: var(--surface);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.params-sep {
|
||||
position: relative;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 0 var(--space-4);
|
||||
padding-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
/* Панель этапа для инлайн-раскладки: исходник | параметры | результат */
|
||||
.pane {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pane-legend {
|
||||
top: -0.65em;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.actions-row {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.actions-row > button {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
.tool-stage {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tool-stage .cell + .cell {
|
||||
border-left: none;
|
||||
padding-left: 0;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: var(--space-4);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,6 @@
|
||||
*
|
||||
* Единая точка правды: тип `CategoryId` и порядок отображения в каталоге
|
||||
* выводятся из одного `as const`-объекта, чтобы не рассинхронизироваться.
|
||||
* Этот файл — локальная копия подхода для нового UI; старый
|
||||
* `lib/v1/categories` (массив + union вручную) не трогаем — он обслуживает
|
||||
* старый UI в `/v1/*`.
|
||||
*
|
||||
* Человекочитаемые названия живут в словарях i18n: секция categories,
|
||||
* ключ = CategoryId.
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
// Новая типизированная схема параметров (для нового UI, старый UI не трогаем).
|
||||
//
|
||||
// Параллельный источник правды к старому `ParamDef[]`: старый pipeline
|
||||
// продолжает работать на `ParamDef[]` как сейчас, а новый строит UI/deфолты/
|
||||
// валидацию поверх `ToolSchema<P>`.
|
||||
// Типизированная схема параметров для нового UI.
|
||||
//
|
||||
// Связка типов на этапе компиляции (защита от рассинхрона):
|
||||
// - `field.x<T>()` возвращает `Field<T>` — phantom `__fieldT` связывает
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
export type CategoryId =
|
||||
| "convert"
|
||||
| "alpha"
|
||||
| "color"
|
||||
| "geometry"
|
||||
| "analyze"
|
||||
| "generate"
|
||||
| "filters"
|
||||
| "text";
|
||||
|
||||
/**
|
||||
* Порядок категорий в каталоге. Человекочитаемые названия живут в словарях
|
||||
* i18n: секция categories, ключ = CategoryId.
|
||||
*/
|
||||
export const CATEGORIES: readonly CategoryId[] = [
|
||||
"convert",
|
||||
"alpha",
|
||||
"color",
|
||||
"geometry",
|
||||
"filters",
|
||||
"text",
|
||||
"analyze",
|
||||
"generate",
|
||||
];
|
||||
@@ -1,50 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { downloadBlob, encode } from "$lib/core/io";
|
||||
import type { PixelImage } from "$lib/core/types";
|
||||
import { t } from "$lib/v1/i18n/t";
|
||||
import type { OutputFormat } from "$lib/v1/registry";
|
||||
import Button from "./ui/Button.svelte";
|
||||
|
||||
interface Props {
|
||||
image: PixelImage | null;
|
||||
format?: OutputFormat;
|
||||
baseName: string;
|
||||
params: Record<string, unknown>;
|
||||
onError?: (e: unknown) => void;
|
||||
}
|
||||
|
||||
let { image, format, baseName, params, onError }: Props = $props();
|
||||
|
||||
let busy = $state(false);
|
||||
|
||||
async function download() {
|
||||
if (!image || !format || busy) return;
|
||||
busy = true;
|
||||
try {
|
||||
const raw = format.qualityParamId
|
||||
? params[format.qualityParamId]
|
||||
: undefined;
|
||||
const quality =
|
||||
typeof raw === "number"
|
||||
? Math.min(Math.max(raw, 1), 100) / 100
|
||||
: undefined;
|
||||
const blob = await encode(image, format.mime, quality);
|
||||
downloadBlob(blob, `${baseName}.${format.ext}`);
|
||||
} catch (e) {
|
||||
onError?.(e);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
fullWidth
|
||||
disabled={!image || !format}
|
||||
{busy}
|
||||
busyText={t("download.busy")}
|
||||
onclick={download}
|
||||
>
|
||||
{t("download.file", { ext: format?.ext ?? "png" })}
|
||||
</Button>
|
||||
@@ -1,97 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
import { isSupportedImage, unsupportedImageError } from "$lib/core/io";
|
||||
import { t } from "$lib/v1/i18n/t";
|
||||
|
||||
interface Props {
|
||||
onFile: (file: File) => void;
|
||||
onError?: (e: unknown) => void;
|
||||
label?: string;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
onFile,
|
||||
onError,
|
||||
label = t("dropZone.overlayDefault"),
|
||||
children,
|
||||
}: Props = $props();
|
||||
|
||||
let depth = $state(0);
|
||||
const dragging = $derived(depth > 0);
|
||||
|
||||
function enter() {
|
||||
depth += 1;
|
||||
}
|
||||
|
||||
function leave() {
|
||||
depth = Math.max(0, depth - 1);
|
||||
}
|
||||
|
||||
function over(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function end() {
|
||||
depth = 0;
|
||||
}
|
||||
|
||||
function drop(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
depth = 0;
|
||||
const file = event.dataTransfer?.files[0];
|
||||
if (!file) return;
|
||||
if (!isSupportedImage(file)) {
|
||||
onError?.(unsupportedImageError(file));
|
||||
return;
|
||||
}
|
||||
onFile(file);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="area"
|
||||
class:dragging
|
||||
role="region"
|
||||
aria-label={label}
|
||||
ondragenter={enter}
|
||||
ondragleave={leave}
|
||||
ondragover={over}
|
||||
ondragend={end}
|
||||
ondrop={drop}
|
||||
>
|
||||
{@render children()}
|
||||
{#if dragging}
|
||||
<div class="veil" aria-hidden="true">
|
||||
<p>{label}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.area {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.veil {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-3);
|
||||
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
|
||||
border: 2px dashed var(--accent);
|
||||
border-radius: var(--radius-m);
|
||||
color: var(--link);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,120 +0,0 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
ACCEPTED_IMAGE_TYPES,
|
||||
isSupportedImage,
|
||||
unsupportedImageError,
|
||||
} from "$lib/core/io";
|
||||
import { t } from "$lib/v1/i18n/t";
|
||||
|
||||
interface Props {
|
||||
onFile: (file: File) => void;
|
||||
onError?: (e: unknown) => void;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
let { onFile, onError, label = t("dropZone.pickDefault") }: Props = $props();
|
||||
|
||||
let input = $state<HTMLInputElement | undefined>();
|
||||
let depth = $state(0);
|
||||
const dragging = $derived(depth > 0);
|
||||
|
||||
function enter() {
|
||||
depth += 1;
|
||||
}
|
||||
|
||||
function leave() {
|
||||
depth = Math.max(0, depth - 1);
|
||||
}
|
||||
|
||||
function over(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function end() {
|
||||
depth = 0;
|
||||
}
|
||||
|
||||
function accept(file: File | undefined | null) {
|
||||
if (!file) return;
|
||||
if (!isSupportedImage(file)) {
|
||||
onError?.(unsupportedImageError(file));
|
||||
return;
|
||||
}
|
||||
onFile(file);
|
||||
}
|
||||
|
||||
function openPicker() {
|
||||
input?.click();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="dropzone panel"
|
||||
class:dragging
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label={label}
|
||||
onclick={openPicker}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
openPicker();
|
||||
}
|
||||
}}
|
||||
ondragenter={enter}
|
||||
ondragleave={leave}
|
||||
ondragover={over}
|
||||
ondragend={end}
|
||||
ondrop={(e) => {
|
||||
e.preventDefault();
|
||||
depth = 0;
|
||||
accept(e.dataTransfer?.files[0]);
|
||||
}}
|
||||
>
|
||||
<span aria-hidden="true" class="icon">⬇</span>
|
||||
{label}
|
||||
</div>
|
||||
<input
|
||||
bind:this={input}
|
||||
type="file"
|
||||
accept={ACCEPTED_IMAGE_TYPES}
|
||||
hidden
|
||||
onchange={() => {
|
||||
accept(input?.files?.[0]);
|
||||
if (input) {
|
||||
input.value = "";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.dropzone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
min-height: 14rem;
|
||||
padding: var(--space-4);
|
||||
border-style: dashed;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition:
|
||||
border-color var(--transition-fast),
|
||||
background var(--transition-fast);
|
||||
}
|
||||
|
||||
.dropzone:hover,
|
||||
.dropzone:focus-visible,
|
||||
.dragging {
|
||||
border-color: var(--link);
|
||||
background: color-mix(in srgb, var(--accent) 6%, var(--surface));
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 1.8rem;
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -1,55 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { ImageInfo } from "$lib/core/analyze";
|
||||
import { LOCALE_TAGS } from "$lib/v1/i18n/dict";
|
||||
import { getLocale } from "$lib/v1/i18n/locale.svelte";
|
||||
import { t } from "$lib/v1/i18n/t";
|
||||
|
||||
interface Props {
|
||||
info: ImageInfo | null;
|
||||
}
|
||||
|
||||
let { info }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if info}
|
||||
<dl>
|
||||
<div class="panel">
|
||||
<dt>{t("infoPanel.dimensions")}</dt>
|
||||
<dd>{info.width} × {info.height} px</dd>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<dt>{t("infoPanel.alpha")}</dt>
|
||||
<dd>
|
||||
{info.hasAlpha ? t("infoPanel.alphaYes") : t("infoPanel.alphaNo")}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<dt>{t("infoPanel.colorCount")}</dt>
|
||||
<dd>{info.colorCount.toLocaleString(LOCALE_TAGS[getLocale()])}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
dl {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
margin: var(--space-3) 0;
|
||||
}
|
||||
|
||||
dl > div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
}
|
||||
|
||||
dt {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
</style>
|
||||
@@ -1,117 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { t } from "$lib/v1/i18n/t";
|
||||
import { optionLabel, paramLabel } from "$lib/v1/i18n/tool-strings";
|
||||
import type { ParamDef, ToolEntry } from "$lib/v1/registry";
|
||||
import CheckboxField from "./ui/CheckboxField.svelte";
|
||||
import ColorField from "./ui/ColorField.svelte";
|
||||
import SelectField from "./ui/SelectField.svelte";
|
||||
import SliderField from "./ui/SliderField.svelte";
|
||||
import TextField from "./ui/TextField.svelte";
|
||||
|
||||
interface Props {
|
||||
tool: ToolEntry;
|
||||
params: ParamDef[];
|
||||
values: Record<string, any>;
|
||||
pipetteTargetId?: string | null;
|
||||
onPipetteToggle?: (id: string) => void;
|
||||
hasMask?: boolean;
|
||||
showMask?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
tool,
|
||||
params,
|
||||
values = $bindable(),
|
||||
pipetteTargetId = null,
|
||||
onPipetteToggle,
|
||||
hasMask = false,
|
||||
showMask = $bindable(false),
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="params-grid">
|
||||
{#if hasMask}
|
||||
<CheckboxField
|
||||
id="show-mask"
|
||||
label={t("ui.showMask")}
|
||||
bind:checked={showMask}
|
||||
/>
|
||||
{/if}
|
||||
{#each params as param (param.id)}
|
||||
<div class="field">
|
||||
{#if param.type === "checkbox"}
|
||||
<CheckboxField
|
||||
id={param.id}
|
||||
label={paramLabel(tool, param)}
|
||||
bind:checked={values[param.id]}
|
||||
/>
|
||||
{:else if param.type === "number"}
|
||||
<TextField
|
||||
id={param.id}
|
||||
label={paramLabel(tool, param)}
|
||||
type="number"
|
||||
min={param.min}
|
||||
max={param.max}
|
||||
step={param.step}
|
||||
bind:value={values[param.id]}
|
||||
/>
|
||||
{:else if param.type === "slider"}
|
||||
<SliderField
|
||||
id={param.id}
|
||||
label={paramLabel(tool, param)}
|
||||
min={param.min}
|
||||
max={param.max}
|
||||
step={param.step}
|
||||
default={param.default}
|
||||
bind:value={values[param.id]}
|
||||
/>
|
||||
{:else if param.type === "select"}
|
||||
<SelectField
|
||||
id={param.id}
|
||||
label={paramLabel(tool, param)}
|
||||
options={param.options.map((o) => ({
|
||||
value: o.value,
|
||||
label: optionLabel(tool, param, o.value),
|
||||
}))}
|
||||
bind:value={values[param.id]}
|
||||
/>
|
||||
{:else if param.type === "color"}
|
||||
<ColorField
|
||||
id={param.id}
|
||||
label={paramLabel(tool, param)}
|
||||
bind:value={values[param.id]}
|
||||
pipetteActive={pipetteTargetId === param.id}
|
||||
onPipetteToggle={() => onPipetteToggle?.(param.id)}
|
||||
/>
|
||||
{:else if param.type === "text"}
|
||||
<TextField
|
||||
id={param.id}
|
||||
label={paramLabel(tool, param)}
|
||||
type="text"
|
||||
placeholder={param.placeholder}
|
||||
bind:value={values[param.id]}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.params-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
column-gap: var(--space-5);
|
||||
}
|
||||
|
||||
@media (min-width: 75rem) {
|
||||
.params-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 50rem) {
|
||||
.params-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,131 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { rgbToHex } from "$lib/core/color";
|
||||
import type { PixelImage } from "$lib/core/types";
|
||||
import PipetteLoupe from "./tool/PipetteLoupe.svelte";
|
||||
|
||||
interface Props {
|
||||
image: PixelImage | null;
|
||||
pipetteActive?: boolean;
|
||||
onPickColor?: (hex: string) => void;
|
||||
}
|
||||
|
||||
let { image, pipetteActive = false, onPickColor }: Props = $props();
|
||||
|
||||
let canvas = $state<HTMLCanvasElement | undefined>();
|
||||
let hover = $state<{
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
px: number;
|
||||
py: number;
|
||||
hex: string;
|
||||
} | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (!canvas || !image) return;
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
ctx.putImageData(
|
||||
new ImageData(image.data, image.width, image.height),
|
||||
0,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
function pixelAt(
|
||||
event: MouseEvent,
|
||||
): { px: number; py: number; hex: string } | null {
|
||||
if (!canvas || !image) return null;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) return null;
|
||||
const px = Math.floor(
|
||||
(event.clientX - rect.left) * (canvas.width / rect.width),
|
||||
);
|
||||
const py = Math.floor(
|
||||
(event.clientY - rect.top) * (canvas.height / rect.height),
|
||||
);
|
||||
if (px < 0 || py < 0 || px >= canvas.width || py >= canvas.height)
|
||||
return null;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return null;
|
||||
const [r, g, b] = ctx.getImageData(px, py, 1, 1).data;
|
||||
return { px, py, hex: rgbToHex(r, g, b) };
|
||||
}
|
||||
|
||||
function pickColor(event: MouseEvent) {
|
||||
if (!pipetteActive || !onPickColor) return;
|
||||
const pixel = pixelAt(event);
|
||||
if (pixel) {
|
||||
onPickColor(pixel.hex);
|
||||
}
|
||||
}
|
||||
|
||||
function trackHover(event: MouseEvent) {
|
||||
if (!pipetteActive) {
|
||||
hover = null;
|
||||
return;
|
||||
}
|
||||
const pixel = pixelAt(event);
|
||||
hover = pixel
|
||||
? {
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
px: pixel.px,
|
||||
py: pixel.py,
|
||||
hex: pixel.hex,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
function clearHover() {
|
||||
hover = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if image}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<canvas
|
||||
bind:this={canvas}
|
||||
title="{image.width} × {image.height}"
|
||||
class:pipette={pipetteActive}
|
||||
onclick={pickColor}
|
||||
onmousemove={trackHover}
|
||||
onmouseleave={clearHover}
|
||||
></canvas>
|
||||
<p class="dims">{image.width} × {image.height} px</p>
|
||||
{#if pipetteActive && hover && image}
|
||||
<PipetteLoupe
|
||||
{image}
|
||||
px={hover.px}
|
||||
py={hover.py}
|
||||
hex={hover.hex}
|
||||
clientX={hover.clientX}
|
||||
clientY={hover.clientY}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
canvas {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 32rem;
|
||||
background: repeating-conic-gradient(
|
||||
var(--check-a) 0% 25%,
|
||||
var(--check-b) 0% 50%
|
||||
);
|
||||
background-size: 16px 16px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
canvas.pipette {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.dims {
|
||||
margin-top: var(--space-1);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-s);
|
||||
}
|
||||
</style>
|
||||
@@ -1,543 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { imageInfo, type ImageInfo } from "$lib/core/analyze";
|
||||
import { ToolError } from "$lib/core/errors";
|
||||
import {
|
||||
decodeFile,
|
||||
isSupportedImage,
|
||||
unsupportedImageError,
|
||||
} from "$lib/core/io";
|
||||
import type { PixelImage } from "$lib/core/types";
|
||||
import { t } from "$lib/v1/i18n/t";
|
||||
import { toolDescription, toolTitle } from "$lib/v1/i18n/tool-strings";
|
||||
import {
|
||||
defaultParams,
|
||||
getTool,
|
||||
sanitizeParams,
|
||||
type ToolEntry,
|
||||
} from "$lib/v1/registry";
|
||||
import { createAutoRunner } from "$lib/v1/tools/auto-run";
|
||||
import { executeStep } from "$lib/v1/tools/executor";
|
||||
import { clearOverlay, setOverlay } from "$lib/v1/tools/overlay-store.svelte";
|
||||
import {
|
||||
loadStoredSteps,
|
||||
newStepId,
|
||||
saveSteps,
|
||||
type PipelineStep,
|
||||
} from "$lib/v1/tools/pipeline";
|
||||
import { TOOL_ICONS } from "$lib/v1/tools/tool-icons";
|
||||
import ChainToolBlock from "./chain/ChainToolBlock.svelte";
|
||||
import ToolSearch from "./search/ToolSearch.svelte";
|
||||
import type { StageStatus } from "./stage/stage-props";
|
||||
import ToolStage from "./stage/ToolStage.svelte";
|
||||
import ToolStageClassic from "./stage/ToolStageClassic.svelte";
|
||||
|
||||
export type PresetStep = { toolId: string; values?: Record<string, unknown> };
|
||||
|
||||
let {
|
||||
tool,
|
||||
restoreChain = false,
|
||||
stageVariant = "inline",
|
||||
presetBaseValues,
|
||||
presetChain,
|
||||
}: {
|
||||
tool: ToolEntry;
|
||||
restoreChain?: boolean;
|
||||
stageVariant?: "classic" | "inline";
|
||||
presetBaseValues?: Record<string, unknown>;
|
||||
presetChain?: PresetStep[];
|
||||
} = $props();
|
||||
|
||||
const StageComponent = $derived(
|
||||
stageVariant === "classic" ? ToolStageClassic : ToolStage,
|
||||
);
|
||||
|
||||
type Status = StageStatus;
|
||||
|
||||
const isPreset = $derived(
|
||||
(presetChain?.length ?? 0) > 0 ||
|
||||
Object.keys(presetBaseValues ?? {}).length > 0,
|
||||
);
|
||||
|
||||
let status = $state<Status>("idle");
|
||||
let source = $state<PixelImage | null>(null);
|
||||
let result = $state<PixelImage | null>(null);
|
||||
let previewResult = $state<PixelImage | null>(null);
|
||||
let textResult = $state<string | null>(null);
|
||||
let showMask = $state(false);
|
||||
let info = $state<ImageInfo | null>(null);
|
||||
let errorText = $state("");
|
||||
let overlayImage = $state<PixelImage | null>(null);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let values = $state<Record<string, any>>({
|
||||
...defaultParams(tool),
|
||||
...presetBaseValues,
|
||||
});
|
||||
// svelte-ignore state_referenced_locally
|
||||
let chain = $state<PipelineStep[]>(
|
||||
restoreChain
|
||||
? loadStoredSteps()
|
||||
: (presetChain ?? []).flatMap((preset) => {
|
||||
const stepTool = getTool(preset.toolId);
|
||||
return stepTool
|
||||
? [
|
||||
{
|
||||
id: newStepId(),
|
||||
toolId: preset.toolId,
|
||||
values: { ...defaultParams(stepTool), ...preset.values },
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}),
|
||||
);
|
||||
let chainResults = $state<(PixelImage | null)[]>([]);
|
||||
let lastRunChainJson = "";
|
||||
|
||||
const isInfo = $derived(tool.resultType === "info");
|
||||
const isSourceless = $derived(tool.sourceMode === "none");
|
||||
const isTextSource = $derived(tool.sourceMode === "text");
|
||||
const sanitized = $derived(sanitizeParams(tool, values));
|
||||
const canChainBase = $derived(
|
||||
(tool.resultType ?? "image") === "image" && tool.sourceMode !== "text",
|
||||
);
|
||||
const hasMask = $derived(typeof tool.preview === "function");
|
||||
const shownBase = $derived(
|
||||
showMask && previewResult ? previewResult : result,
|
||||
);
|
||||
const hasFilledSteps = $derived(chain.some((step) => step.toolId !== ""));
|
||||
|
||||
function addChainStep() {
|
||||
chain.push({ id: newStepId(), toolId: "", values: {} });
|
||||
}
|
||||
|
||||
function removeChainStep(index: number) {
|
||||
chain.splice(index, 1);
|
||||
chainResults = [];
|
||||
}
|
||||
|
||||
function removeChain() {
|
||||
chain.length = 0;
|
||||
chainResults = [];
|
||||
}
|
||||
|
||||
function toggleChain() {
|
||||
if (chain.length > 0) {
|
||||
removeChain();
|
||||
} else {
|
||||
addChainStep();
|
||||
}
|
||||
}
|
||||
|
||||
function applyChainTool(index: number, toolId: string) {
|
||||
const stepTool = getTool(toolId);
|
||||
if (!stepTool || !chain[index]) return;
|
||||
chain[index].toolId = toolId;
|
||||
chain[index].values = defaultParams(stepTool);
|
||||
}
|
||||
|
||||
const runner = createAutoRunner();
|
||||
let hasLastRun = false;
|
||||
let lastRunSource: PixelImage | null = null;
|
||||
let lastRunValuesJson = "";
|
||||
let pipetteTargetId = $state<string | null>(null);
|
||||
|
||||
function handlePipetteToggle(id: string) {
|
||||
pipetteTargetId = pipetteTargetId === id ? null : id;
|
||||
}
|
||||
|
||||
function handlePickColor(hex: string) {
|
||||
if (!pipetteTargetId) return;
|
||||
values[pipetteTargetId] = hex;
|
||||
pipetteTargetId = null;
|
||||
}
|
||||
|
||||
async function handleFile(file: File) {
|
||||
errorText = "";
|
||||
status = "processing";
|
||||
try {
|
||||
source = await decodeFile(file);
|
||||
values = defaultParams(tool);
|
||||
result = null;
|
||||
previewResult = null;
|
||||
showMask = false;
|
||||
pipetteTargetId = null;
|
||||
info = isInfo ? imageInfo(source) : null;
|
||||
if (isInfo) {
|
||||
status = "loaded";
|
||||
} else {
|
||||
await runTool();
|
||||
}
|
||||
} catch (e) {
|
||||
showError(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTextSubmit(text: string) {
|
||||
if (!isTextSource) return;
|
||||
errorText = "";
|
||||
status = "processing";
|
||||
if (tool.textToText) {
|
||||
try {
|
||||
result = null;
|
||||
previewResult = null;
|
||||
info = null;
|
||||
textResult = await tool.textToText(text);
|
||||
status = "loaded";
|
||||
} catch (e) {
|
||||
showError(e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!tool.runFromText) return;
|
||||
try {
|
||||
source = await tool.runFromText(text, defaultParams(tool));
|
||||
values = defaultParams(tool);
|
||||
result = null;
|
||||
textResult = null;
|
||||
previewResult = null;
|
||||
showMask = false;
|
||||
pipetteTargetId = null;
|
||||
info = null;
|
||||
await runTool();
|
||||
} catch (e) {
|
||||
showError(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function runTool() {
|
||||
if (isInfo) return;
|
||||
if (!source && !isSourceless) return;
|
||||
const token = runner.next();
|
||||
hasLastRun = true;
|
||||
lastRunSource = source;
|
||||
lastRunValuesJson = JSON.stringify(sanitized);
|
||||
lastRunChainJson = JSON.stringify(chain);
|
||||
try {
|
||||
let next: PixelImage | null = null;
|
||||
let nextText: string | null = null;
|
||||
|
||||
if (tool.resultType === "text") {
|
||||
nextText = await tool.toText!(source!, sanitized);
|
||||
} else if (isSourceless) {
|
||||
next = await tool.generate!(sanitized);
|
||||
} else {
|
||||
next = await executeStep(tool, source!, sanitized);
|
||||
}
|
||||
let nextPreview: PixelImage | null = null;
|
||||
if (tool.preview && source) {
|
||||
try {
|
||||
nextPreview = await tool.preview(source, sanitized);
|
||||
} catch {
|
||||
nextPreview = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!runner.isCurrent(token)) return;
|
||||
result = next;
|
||||
previewResult = nextPreview;
|
||||
textResult = nextText;
|
||||
|
||||
const collected: (PixelImage | null)[] = [];
|
||||
let current: PixelImage | null = next ?? source;
|
||||
for (let i = 0; i < chain.length; i++) {
|
||||
const step = chain[i];
|
||||
const stepTool = step.toolId === "" ? undefined : getTool(step.toolId);
|
||||
if (!current || !stepTool?.run) {
|
||||
collected.push(null);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
current = await executeStep(
|
||||
stepTool,
|
||||
current,
|
||||
sanitizeParams(stepTool, step.values),
|
||||
);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
t("toolPage.stepError", {
|
||||
n: i + 2,
|
||||
title: toolTitle(stepTool),
|
||||
msg: errorMessage(e),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (!runner.isCurrent(token)) return;
|
||||
collected.push(current);
|
||||
}
|
||||
chainResults = collected;
|
||||
status = "loaded";
|
||||
} catch (e) {
|
||||
if (!runner.isCurrent(token)) return;
|
||||
showError(e);
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const valuesJson = JSON.stringify(sanitized);
|
||||
const chainJson = JSON.stringify(chain);
|
||||
if (
|
||||
hasLastRun &&
|
||||
source === lastRunSource &&
|
||||
valuesJson === lastRunValuesJson &&
|
||||
chainJson === lastRunChainJson
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!source && !isSourceless) return;
|
||||
if (isInfo) return;
|
||||
return runner.schedule(() => void runTool());
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (isPreset) return;
|
||||
const filled = chain.filter((step) => step.toolId !== "");
|
||||
if (filled.length === 0 && !hasLastRun) return;
|
||||
saveSteps(filled);
|
||||
});
|
||||
|
||||
function errorMessage(e: unknown): string {
|
||||
if (e instanceof ToolError) return t(e.key, e.vars);
|
||||
return t(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
|
||||
function showError(e: unknown) {
|
||||
status = source ? "loaded" : "idle";
|
||||
errorText = errorMessage(e);
|
||||
}
|
||||
|
||||
function reset() {
|
||||
source = null;
|
||||
result = null;
|
||||
previewResult = null;
|
||||
textResult = null;
|
||||
showMask = false;
|
||||
pipetteTargetId = null;
|
||||
info = null;
|
||||
errorText = "";
|
||||
status = "idle";
|
||||
clearOverlay();
|
||||
values = { ...defaultParams(tool), ...presetBaseValues };
|
||||
}
|
||||
|
||||
async function handleOverlayFile(file: File) {
|
||||
try {
|
||||
setOverlay(await decodeFile(file));
|
||||
} catch (e) {
|
||||
errorText = errorMessage(e);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePaste(event: ClipboardEvent) {
|
||||
const items = event.clipboardData?.items;
|
||||
if (!items) return;
|
||||
for (const item of items) {
|
||||
if (!item.type.startsWith("image/")) continue;
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
event.preventDefault();
|
||||
if (!isSupportedImage(file)) {
|
||||
errorText = errorMessage(unsupportedImageError(file));
|
||||
return;
|
||||
}
|
||||
handleFile(file);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onpaste={handlePaste} />
|
||||
|
||||
<section>
|
||||
<h1>{toolTitle(tool)}</h1>
|
||||
<p class="description text-muted">{toolDescription(tool)}</p>
|
||||
|
||||
{#if errorText}
|
||||
<div class="error-banner" role="alert">{errorText}</div>
|
||||
{/if}
|
||||
|
||||
<StageComponent
|
||||
mode="base"
|
||||
{tool}
|
||||
{source}
|
||||
{result}
|
||||
displayImage={shownBase}
|
||||
{info}
|
||||
{status}
|
||||
{isInfo}
|
||||
{isSourceless}
|
||||
{isTextSource}
|
||||
{textResult}
|
||||
{sanitized}
|
||||
{canChainBase}
|
||||
hasChain={chain.length > 0}
|
||||
{hasMask}
|
||||
bind:showMask
|
||||
bind:values
|
||||
{pipetteTargetId}
|
||||
{handleFile}
|
||||
onTextSubmit={handleTextSubmit}
|
||||
onSourceError={(e) => (errorText = errorMessage(e))}
|
||||
{reset}
|
||||
{toggleChain}
|
||||
{handlePipetteToggle}
|
||||
{handlePickColor}
|
||||
{showError}
|
||||
{errorMessage}
|
||||
{overlayImage}
|
||||
onOverlayFile={(f) => void handleOverlayFile(f)}
|
||||
onOverlayError={(e) => (errorText = errorMessage(e))}
|
||||
onOverlayClear={clearOverlay}
|
||||
/>
|
||||
|
||||
<div class="chain-stack">
|
||||
{#each chain as step, index (step.id)}
|
||||
{#if step.toolId === ""}
|
||||
<div class="panel empty-slot">
|
||||
<header>
|
||||
<h3 class="heading-section">
|
||||
{t("toolPage.stepHeading", { n: index + 2 })}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="remove-step"
|
||||
aria-label={t("toolPage.removeStepAria")}
|
||||
onclick={() => removeChainStep(index)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</header>
|
||||
<ToolSearch
|
||||
onSelect={(id) => applyChainTool(index, id)}
|
||||
chainableOnly
|
||||
/>
|
||||
</div>
|
||||
{:else if getTool(step.toolId)}
|
||||
{@const stepTool = getTool(step.toolId)!}
|
||||
{#if stageVariant === "inline"}
|
||||
{@const StepIcon = TOOL_ICONS[stepTool.id]}
|
||||
<ToolStage
|
||||
mode="chain"
|
||||
{index}
|
||||
tool={stepTool}
|
||||
bind:values={step.values}
|
||||
input={index === 0 ? result : (chainResults[index - 1] ?? null)}
|
||||
result={chainResults[index] ?? null}
|
||||
busy={status === "processing"}
|
||||
isLast={index === chain.length - 1}
|
||||
onRemove={() => removeChainStep(index)}
|
||||
onError={showError}
|
||||
onAddStep={addChainStep}
|
||||
onRemoveChain={removeChain}
|
||||
>
|
||||
{#snippet header()}
|
||||
<span class="edge-legend step-legend">
|
||||
{#if StepIcon}
|
||||
<span class="step-icon" aria-hidden="true">
|
||||
<StepIcon size={14} strokeWidth={2} />
|
||||
</span>
|
||||
{/if}
|
||||
{t("chain.stepLabel", {
|
||||
n: index + 2,
|
||||
title: toolTitle(stepTool),
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
class="remove-step"
|
||||
aria-label={t("toolPage.removeStepAria")}
|
||||
title={t("toolPage.removeStepAria")}
|
||||
onclick={() => removeChainStep(index)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
{/snippet}
|
||||
</ToolStage>
|
||||
{:else}
|
||||
<ChainToolBlock
|
||||
{index}
|
||||
tool={stepTool}
|
||||
bind:values={step.values}
|
||||
input={index === 0 ? result : (chainResults[index - 1] ?? null)}
|
||||
result={chainResults[index] ?? null}
|
||||
busy={status === "processing"}
|
||||
isLast={index === chain.length - 1}
|
||||
onRemove={() => removeChainStep(index)}
|
||||
onError={showError}
|
||||
onAddStep={addChainStep}
|
||||
onRemoveChain={removeChain}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
h1 {
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.description {
|
||||
max-width: 48rem;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.step-legend {
|
||||
left: var(--space-3);
|
||||
top: -0.75em;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.step-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
border-radius: var(--radius-s);
|
||||
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
|
||||
color: var(--link);
|
||||
}
|
||||
|
||||
.empty-slot {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.empty-slot header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.empty-slot h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.remove-step {
|
||||
width: 1.6rem;
|
||||
height: 1.6rem;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-s);
|
||||
background: var(--surface);
|
||||
color: var(--text-muted);
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.remove-step:hover {
|
||||
border-color: var(--danger);
|
||||
color: var(--danger);
|
||||
}
|
||||
</style>
|
||||
@@ -1,193 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { PixelImage } from "$lib/core/types";
|
||||
import { t } from "$lib/v1/i18n/t";
|
||||
import { toolTitle } from "$lib/v1/i18n/tool-strings";
|
||||
import { outputOf, sanitizeParams, type ToolEntry } from "$lib/v1/registry";
|
||||
import { TOOL_ICONS } from "$lib/v1/tools/tool-icons";
|
||||
import DownloadButton from "../DownloadButton.svelte";
|
||||
import Preview from "../Preview.svelte";
|
||||
import ParamsCard from "../tool/ParamsCard.svelte";
|
||||
import Button from "../ui/Button.svelte";
|
||||
import EmptyState from "../ui/EmptyState.svelte";
|
||||
|
||||
interface Props {
|
||||
index: number;
|
||||
tool: ToolEntry;
|
||||
values: Record<string, any>;
|
||||
input: PixelImage | null;
|
||||
result: PixelImage | null;
|
||||
busy: boolean;
|
||||
isLast: boolean;
|
||||
onRemove: () => void;
|
||||
onError: (e: unknown) => void;
|
||||
onAddStep: () => void;
|
||||
onRemoveChain: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
index,
|
||||
tool,
|
||||
values = $bindable(),
|
||||
input,
|
||||
result,
|
||||
busy,
|
||||
isLast,
|
||||
onRemove,
|
||||
onError,
|
||||
onAddStep,
|
||||
onRemoveChain,
|
||||
}: Props = $props();
|
||||
|
||||
const format = $derived(outputOf(tool));
|
||||
const safeParams = $derived(sanitizeParams(tool, values));
|
||||
const StepIcon = $derived(TOOL_ICONS[tool.id]);
|
||||
</script>
|
||||
|
||||
<div class="block">
|
||||
<div class="panel tool-stage">
|
||||
<span class="edge-legend step-legend">
|
||||
{#if StepIcon}
|
||||
<span class="step-icon" aria-hidden="true"
|
||||
><StepIcon size={14} strokeWidth={2} /></span
|
||||
>
|
||||
{/if}
|
||||
{t("chain.stepLabel", { n: index + 2, title: toolTitle(tool) })}
|
||||
<button
|
||||
type="button"
|
||||
class="remove"
|
||||
aria-label={t("chain.removeStepAria")}
|
||||
title={t("chain.removeStepAria")}
|
||||
onclick={onRemove}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
|
||||
<div class="cell">
|
||||
<span class="edge-legend cell-legend" aria-hidden="true"
|
||||
>{t("chain.inputLegend")}</span
|
||||
>
|
||||
<div class="cell-media">
|
||||
<Preview image={input} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<span class="edge-legend cell-legend" aria-hidden="true"
|
||||
>{t("chain.resultLegend")}</span
|
||||
>
|
||||
{#if busy && !result}
|
||||
<div class="cell-media">
|
||||
<EmptyState title={t("chain.busyTitle")} hint={t("chain.busyHint")} />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="cell-media">
|
||||
<Preview image={result} />
|
||||
{#if busy}
|
||||
<span class="recalc" aria-live="polite"
|
||||
>{t("resultCard.recalc")}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="actions-row">
|
||||
<DownloadButton
|
||||
image={result}
|
||||
{format}
|
||||
baseName="{index + 2}-{tool.id}"
|
||||
params={safeParams}
|
||||
{onError}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
fullWidth
|
||||
onclick={isLast ? onAddStep : onRemoveChain}
|
||||
>
|
||||
{isLast ? t("resultCard.nextTool") : t("resultCard.breakChain")}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if tool.params.length > 0}
|
||||
<div class="params-sep">
|
||||
<span class="edge-legend" aria-hidden="true"
|
||||
>{t("chain.paramsLegend")}</span
|
||||
>
|
||||
</div>
|
||||
<ParamsCard {tool} params={tool.params} bind:values />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tool-stage {
|
||||
position: relative;
|
||||
padding-top: var(--space-3);
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.step-legend {
|
||||
left: var(--space-3);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.step-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
border-radius: var(--radius-s);
|
||||
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
|
||||
color: var(--link);
|
||||
}
|
||||
|
||||
.remove {
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-s);
|
||||
background: var(--surface);
|
||||
color: var(--text-muted);
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
}
|
||||
|
||||
.remove:hover {
|
||||
border-color: var(--danger);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.cell {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cell-legend {
|
||||
top: -0.9em;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.recalc {
|
||||
position: absolute;
|
||||
top: var(--space-2);
|
||||
right: var(--space-2);
|
||||
padding: 2px var(--space-2);
|
||||
border-radius: var(--radius-s);
|
||||
background: color-mix(in srgb, var(--accent) 12%, var(--surface));
|
||||
color: var(--link);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
</style>
|
||||
@@ -1,109 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { TOOL_ICONS } from "$lib/v1/tools/tool-icons";
|
||||
|
||||
interface Props {
|
||||
toolId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
selected?: boolean;
|
||||
href?: string;
|
||||
onActivate?: () => void;
|
||||
onHover?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
toolId,
|
||||
title,
|
||||
description,
|
||||
selected = false,
|
||||
href,
|
||||
onActivate,
|
||||
onHover,
|
||||
}: Props = $props();
|
||||
|
||||
const Icon = $derived(TOOL_ICONS[toolId]);
|
||||
</script>
|
||||
|
||||
{#snippet content()}
|
||||
<span class="icon-box" aria-hidden="true">
|
||||
{#if Icon}<Icon size={24} strokeWidth={1.75} />{/if}
|
||||
</span>
|
||||
<span class="text">
|
||||
<span class="title">{title}</span>
|
||||
<span class="hint text-caption text-muted">{description}</span>
|
||||
</span>
|
||||
{/snippet}
|
||||
|
||||
{#if href}
|
||||
<a {href} class="card" class:selected onmousemove={onHover}>
|
||||
{@render content()}
|
||||
</a>
|
||||
{:else}
|
||||
<div
|
||||
class="card"
|
||||
class:selected
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
tabindex="-1"
|
||||
onclick={onActivate}
|
||||
onmousemove={onHover}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === "Enter") onActivate?.();
|
||||
}}
|
||||
>
|
||||
{@render content()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
padding: var(--space-2);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-s);
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.card:hover,
|
||||
.card.selected {
|
||||
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
|
||||
border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
|
||||
}
|
||||
|
||||
.icon-box {
|
||||
flex: none;
|
||||
width: 2.6rem;
|
||||
height: 2.6rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-s);
|
||||
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
|
||||
color: var(--link);
|
||||
}
|
||||
|
||||
.text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: var(--text-m);
|
||||
}
|
||||
|
||||
.hint {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -1,176 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { LOCALE_TAGS } from "$lib/v1/i18n/dict";
|
||||
import { getLocale } from "$lib/v1/i18n/locale.svelte";
|
||||
import { normalizeForSearch, scoreDoc } from "$lib/v1/i18n/matching";
|
||||
import { t } from "$lib/v1/i18n/t";
|
||||
import {
|
||||
toolDescription,
|
||||
toolSearchDoc,
|
||||
toolTitle,
|
||||
} from "$lib/v1/i18n/tool-strings";
|
||||
import { isChainable, TOOLS } from "$lib/v1/registry";
|
||||
import ToolCard from "./ToolCard.svelte";
|
||||
|
||||
interface Props {
|
||||
onSelect: (toolId: string) => void;
|
||||
/** true — только инструменты, пригодные в шаг цепочки (слот добавления шага). */
|
||||
chainableOnly?: boolean;
|
||||
}
|
||||
|
||||
let { onSelect, chainableOnly = false }: Props = $props();
|
||||
|
||||
const candidates = $derived(
|
||||
chainableOnly ? TOOLS.filter(isChainable) : TOOLS,
|
||||
);
|
||||
|
||||
let query = $state("");
|
||||
let activeIndex = $state(0);
|
||||
let listOpen = $state(false);
|
||||
|
||||
type Match = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
popularity: number;
|
||||
score: number;
|
||||
};
|
||||
|
||||
const matches = $derived.by(() => {
|
||||
const q = normalizeForSearch(query.trim());
|
||||
const found: Match[] = [];
|
||||
for (const tool of candidates) {
|
||||
const s = scoreDoc(toolSearchDoc(tool), q);
|
||||
if (s !== null && s > 0) {
|
||||
found.push({
|
||||
id: tool.id,
|
||||
title: toolTitle(tool),
|
||||
description: toolDescription(tool),
|
||||
popularity: tool.popularity ?? 50,
|
||||
score: s,
|
||||
});
|
||||
}
|
||||
}
|
||||
const collator = new Intl.Collator(LOCALE_TAGS[getLocale()]);
|
||||
found.sort(
|
||||
(a, b) =>
|
||||
b.score - a.score ||
|
||||
b.popularity - a.popularity ||
|
||||
collator.compare(a.title, b.title),
|
||||
);
|
||||
return found.slice(0, 12);
|
||||
});
|
||||
|
||||
function choose(id: string) {
|
||||
listOpen = false;
|
||||
query = "";
|
||||
activeIndex = 0;
|
||||
onSelect(id);
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (!listOpen || matches.length === 0) return;
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
activeIndex = (activeIndex + 1) % matches.length;
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
activeIndex = (activeIndex - 1 + matches.length) % matches.length;
|
||||
} else if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
const match = matches[Math.min(activeIndex, matches.length - 1)];
|
||||
if (match) choose(match.id);
|
||||
} else if (event.key === "Escape") {
|
||||
listOpen = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="search">
|
||||
<input
|
||||
type="search"
|
||||
bind:value={query}
|
||||
onfocus={() => (listOpen = true)}
|
||||
oninput={() => {
|
||||
listOpen = true;
|
||||
activeIndex = 0;
|
||||
}}
|
||||
onkeydown={onKeydown}
|
||||
placeholder={t("search.placeholder")}
|
||||
aria-label={t("search.aria")}
|
||||
role="combobox"
|
||||
aria-expanded={listOpen}
|
||||
aria-controls="tool-search-list"
|
||||
/>
|
||||
{#if listOpen && query.trim().length > 0 && matches.length === 0}
|
||||
<p class="none text-muted">{t("search.nothingFound")}</p>
|
||||
{:else if listOpen && matches.length > 0}
|
||||
<div id="tool-search-list" class="cards" role="listbox">
|
||||
{#each matches as match, index (match.id)}
|
||||
<ToolCard
|
||||
toolId={match.id}
|
||||
title={match.title}
|
||||
description={match.description}
|
||||
selected={index === activeIndex}
|
||||
onActivate={() => choose(match.id)}
|
||||
onHover={() => (activeIndex = index)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.search {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 36rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-m);
|
||||
background: var(--surface);
|
||||
font-size: var(--text-xl);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
border-color: var(--link);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.cards {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: calc(100% + var(--space-1));
|
||||
z-index: 40;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-1);
|
||||
margin: 0;
|
||||
padding: var(--space-1);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-m);
|
||||
box-shadow: var(--shadow-card);
|
||||
max-height: 26rem;
|
||||
overflow-y: auto;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
.cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.none {
|
||||
margin: var(--space-1) 0 0;
|
||||
text-align: center;
|
||||
font-size: var(--text-s);
|
||||
}
|
||||
</style>
|
||||
@@ -1,362 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { ImageInfo } from "$lib/core/analyze";
|
||||
import type { PixelImage } from "$lib/core/types";
|
||||
import { t } from "$lib/v1/i18n/t";
|
||||
import { outputOf, sanitizeParams, type ToolEntry } from "$lib/v1/registry";
|
||||
import { TOOL_ICONS } from "$lib/v1/tools/tool-icons";
|
||||
import type { Snippet } from "svelte";
|
||||
import DownloadButton from "../DownloadButton.svelte";
|
||||
import Preview from "../Preview.svelte";
|
||||
import OverlayCard from "../tool/OverlayCard.svelte";
|
||||
import ParamsCard from "../tool/ParamsCard.svelte";
|
||||
import ResultCard from "../tool/ResultCard.svelte";
|
||||
import SourceCard from "../tool/SourceCard.svelte";
|
||||
import TextInputCard from "../tool/TextInputCard.svelte";
|
||||
import Button from "../ui/Button.svelte";
|
||||
import EmptyState from "../ui/EmptyState.svelte";
|
||||
import type { StageStatus } from "./stage-props";
|
||||
|
||||
interface Props {
|
||||
mode: "base" | "chain";
|
||||
header?: Snippet;
|
||||
tool: ToolEntry;
|
||||
source?: PixelImage | null;
|
||||
displayImage?: PixelImage | null;
|
||||
info?: ImageInfo | null;
|
||||
status?: StageStatus;
|
||||
isInfo?: boolean;
|
||||
isSourceless?: boolean;
|
||||
isTextSource?: boolean;
|
||||
textResult?: string | null;
|
||||
sanitized?: Record<string, any>;
|
||||
canChainBase?: boolean;
|
||||
hasChain?: boolean;
|
||||
hasMask?: boolean;
|
||||
pipetteTargetId?: string | null;
|
||||
values: Record<string, any>;
|
||||
showMask?: boolean;
|
||||
handleFile?: (file: File) => Promise<void> | void;
|
||||
onTextSubmit?: (text: string) => Promise<void> | void;
|
||||
onSourceError?: (e: unknown) => void;
|
||||
reset?: () => void;
|
||||
toggleChain?: () => void;
|
||||
handlePipetteToggle?: (id: string) => void;
|
||||
handlePickColor?: (hex: string) => void;
|
||||
showError?: (e: unknown) => void;
|
||||
overlayImage?: PixelImage | null;
|
||||
onOverlayFile?: (file: File) => void;
|
||||
onOverlayError?: (e: unknown) => void;
|
||||
onOverlayClear?: () => void;
|
||||
index?: number;
|
||||
input?: PixelImage | null;
|
||||
result?: PixelImage | null;
|
||||
busy?: boolean;
|
||||
isLast?: boolean;
|
||||
onRemove?: () => void;
|
||||
onError?: (e: unknown) => void;
|
||||
onAddStep?: () => void;
|
||||
onRemoveChain?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
mode,
|
||||
header,
|
||||
tool,
|
||||
source = null,
|
||||
displayImage = null,
|
||||
info = null,
|
||||
status = "idle",
|
||||
isInfo = false,
|
||||
isSourceless = false,
|
||||
isTextSource = false,
|
||||
textResult = null,
|
||||
sanitized = {},
|
||||
canChainBase = false,
|
||||
hasChain = false,
|
||||
hasMask = false,
|
||||
pipetteTargetId = null,
|
||||
values = $bindable({}),
|
||||
showMask = $bindable(false),
|
||||
handleFile,
|
||||
onTextSubmit,
|
||||
onSourceError,
|
||||
reset,
|
||||
toggleChain,
|
||||
handlePipetteToggle,
|
||||
handlePickColor,
|
||||
showError,
|
||||
overlayImage = null,
|
||||
onOverlayFile,
|
||||
onOverlayError,
|
||||
onOverlayClear,
|
||||
index = 0,
|
||||
input = null,
|
||||
result = null,
|
||||
busy = false,
|
||||
isLast = false,
|
||||
onRemove,
|
||||
onError,
|
||||
onAddStep,
|
||||
onRemoveChain,
|
||||
}: Props = $props();
|
||||
|
||||
const isChain = $derived(mode === "chain");
|
||||
const format = $derived(outputOf(tool));
|
||||
const safeParams = $derived(sanitizeParams(tool, values ?? {}));
|
||||
const StepIcon = $derived(TOOL_ICONS[tool.id]);
|
||||
const showParams = $derived(
|
||||
mode === "chain"
|
||||
? tool.params.length > 0
|
||||
: (source !== null || isSourceless) &&
|
||||
!isInfo &&
|
||||
(tool.params.length > 0 || hasMask),
|
||||
);
|
||||
|
||||
const leftLegend = $derived(
|
||||
isChain ? t("chain.inputLegend") : t("toolPage.legendSource"),
|
||||
);
|
||||
const midLegend = $derived(
|
||||
isChain ? t("chain.paramsLegend") : t("toolPage.legendParams"),
|
||||
);
|
||||
const rightLegend = $derived(
|
||||
isChain
|
||||
? t("chain.resultLegend")
|
||||
: isInfo
|
||||
? t("toolPage.legendSummary")
|
||||
: t("toolPage.legendResult"),
|
||||
);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="panel stage-grid"
|
||||
class:no-params={!showParams}
|
||||
class:single={!isChain && !!isSourceless}
|
||||
>
|
||||
{#if header}
|
||||
{@render header()}
|
||||
{/if}
|
||||
|
||||
{#if isChain}
|
||||
<section class="pane">
|
||||
<span class="edge-legend pane-legend" aria-hidden="true"
|
||||
>{leftLegend}</span
|
||||
>
|
||||
<div class="pane-body">
|
||||
<Preview image={input} />
|
||||
</div>
|
||||
</section>
|
||||
{:else if !isSourceless}
|
||||
<section class="pane">
|
||||
<span class="edge-legend pane-legend" aria-hidden="true"
|
||||
>{leftLegend}</span
|
||||
>
|
||||
<div class="pane-body">
|
||||
{#if isTextSource && !source}
|
||||
<TextInputCard onSubmit={(text) => onTextSubmit?.(text)} />
|
||||
{:else}
|
||||
<SourceCard
|
||||
{source}
|
||||
onFile={(f) => handleFile?.(f)}
|
||||
onError={(e) => onSourceError?.(e)}
|
||||
onReset={() => reset?.()}
|
||||
pipetteActive={!!pipetteTargetId}
|
||||
onPickColor={(hex) => handlePickColor?.(hex)}
|
||||
/>
|
||||
{#if tool.needsOverlaySource && source}
|
||||
<OverlayCard
|
||||
overlay={overlayImage}
|
||||
onFile={(f) => onOverlayFile?.(f)}
|
||||
onError={(e) => onOverlayError?.(e)}
|
||||
onClear={() => onOverlayClear?.()}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if showParams}
|
||||
<section class="pane pane-params">
|
||||
<span class="edge-legend pane-legend" aria-hidden="true">{midLegend}</span
|
||||
>
|
||||
<div class="pane-body">
|
||||
<ParamsCard
|
||||
{tool}
|
||||
params={tool.params}
|
||||
bind:values
|
||||
{pipetteTargetId}
|
||||
onPipetteToggle={(id) => handlePipetteToggle?.(id)}
|
||||
{hasMask}
|
||||
bind:showMask
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section class="pane">
|
||||
<span class="edge-legend pane-legend" aria-hidden="true">{rightLegend}</span
|
||||
>
|
||||
<div class="pane-body">
|
||||
{#if isChain}
|
||||
{#if busy && !result}
|
||||
<EmptyState title={t("chain.busyTitle")} hint={t("chain.busyHint")} />
|
||||
{:else}
|
||||
<Preview image={result} />
|
||||
{#if busy}
|
||||
<span class="recalc" aria-live="polite"
|
||||
>{t("resultCard.recalc")}</span
|
||||
>
|
||||
{/if}
|
||||
<div class="actions-row">
|
||||
<DownloadButton
|
||||
image={result}
|
||||
{format}
|
||||
baseName="{index + 2}-{tool.id}"
|
||||
params={safeParams}
|
||||
onError={(e) => showError?.(e)}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
fullWidth
|
||||
onclick={isLast ? onAddStep : onRemoveChain}
|
||||
>
|
||||
{isLast ? t("resultCard.nextTool") : t("resultCard.breakChain")}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<ResultCard
|
||||
{tool}
|
||||
sourceLoaded={isSourceless ? true : !!source}
|
||||
{status}
|
||||
{result}
|
||||
{displayImage}
|
||||
{info}
|
||||
{isInfo}
|
||||
params={sanitized}
|
||||
{textResult}
|
||||
onChainToggle={canChainBase ? toggleChain : undefined}
|
||||
{hasChain}
|
||||
onDownloadError={(e) => showError?.(e)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.stage-grid {
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 14rem;
|
||||
}
|
||||
|
||||
.pane-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pane + .pane {
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: var(--space-4);
|
||||
}
|
||||
|
||||
/* Параметры в узкой колонке: вертикальные поля с переносами.
|
||||
:global под локальным якорем даёт специфичность выше scoped-правил полей. */
|
||||
.pane-params :global(.root) {
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.pane-params :global(.params-grid) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.pane-params :global(.field) {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.pane-params :global(.field > label) {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.pane-params :global(.control-slot),
|
||||
.pane-params :global(.row) {
|
||||
align-items: stretch;
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
row-gap: var(--space-1);
|
||||
}
|
||||
|
||||
.pane-params :global(.hint) {
|
||||
align-self: auto;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.pane-params :global(input[type="range"]) {
|
||||
order: -1;
|
||||
flex: 1 1 100%;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pane-params :global(.row > output) {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.pane-params :global(input.control),
|
||||
.pane-params :global(select.control) {
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.recalc {
|
||||
position: absolute;
|
||||
top: var(--space-2);
|
||||
right: var(--space-2);
|
||||
padding: 2px var(--space-2);
|
||||
border-radius: var(--radius-s);
|
||||
background: color-mix(in srgb, var(--accent) 12%, var(--surface));
|
||||
color: var(--link);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
@media (min-width: 75rem) {
|
||||
.stage-grid:not(.no-params):not(.single) {
|
||||
grid-template-columns: minmax(0, 1fr) clamp(13rem, 17vw, 18rem) minmax(
|
||||
0,
|
||||
1fr
|
||||
);
|
||||
}
|
||||
|
||||
.stage-grid.no-params:not(.single),
|
||||
.stage-grid.single.no-params {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.stage-grid.single:not(.no-params) {
|
||||
grid-template-columns: clamp(13rem, 17vw, 18rem) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.pane + .pane {
|
||||
border-top: none;
|
||||
padding-top: 0;
|
||||
border-left: 1px solid var(--border);
|
||||
padding-left: var(--space-4);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,120 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { t } from "$lib/v1/i18n/t";
|
||||
import ParamsCard from "../tool/ParamsCard.svelte";
|
||||
import ResultCard from "../tool/ResultCard.svelte";
|
||||
import SourceCard from "../tool/SourceCard.svelte";
|
||||
import TextInputCard from "../tool/TextInputCard.svelte";
|
||||
import type { StageProps } from "./stage-props";
|
||||
|
||||
let {
|
||||
tool,
|
||||
source,
|
||||
result,
|
||||
displayImage,
|
||||
info,
|
||||
status,
|
||||
isInfo,
|
||||
isSourceless,
|
||||
isTextSource,
|
||||
textResult,
|
||||
sanitized,
|
||||
canChainBase,
|
||||
hasChain,
|
||||
hasMask,
|
||||
showMask = $bindable(false),
|
||||
values = $bindable(),
|
||||
pipetteTargetId,
|
||||
handleFile,
|
||||
onTextSubmit,
|
||||
onSourceError,
|
||||
reset,
|
||||
toggleChain,
|
||||
handlePipetteToggle,
|
||||
handlePickColor,
|
||||
showError,
|
||||
errorMessage,
|
||||
}: StageProps = $props();
|
||||
</script>
|
||||
|
||||
<div class="panel tool-block">
|
||||
<div class="tool-stage" class:single={isSourceless}>
|
||||
{#if !isSourceless}
|
||||
<span class="edge-legend source-legend" aria-hidden="true"
|
||||
>{t("toolPage.legendSource")}</span
|
||||
>
|
||||
<div class="cell">
|
||||
{#if isTextSource && !source}
|
||||
<TextInputCard onSubmit={onTextSubmit} />
|
||||
{:else}
|
||||
<SourceCard
|
||||
{source}
|
||||
onFile={handleFile}
|
||||
onError={onSourceError}
|
||||
onReset={reset}
|
||||
pipetteActive={!!pipetteTargetId}
|
||||
onPickColor={handlePickColor}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<span class="edge-legend result-legend" aria-hidden="true">
|
||||
{isInfo ? t("toolPage.legendSummary") : t("toolPage.legendResult")}
|
||||
</span>
|
||||
<div class="cell">
|
||||
<ResultCard
|
||||
{tool}
|
||||
sourceLoaded={isSourceless ? true : !!source}
|
||||
{status}
|
||||
{result}
|
||||
{displayImage}
|
||||
{info}
|
||||
{isInfo}
|
||||
params={sanitized}
|
||||
{textResult}
|
||||
onChainToggle={canChainBase ? toggleChain : undefined}
|
||||
{hasChain}
|
||||
onDownloadError={showError}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if (source || isSourceless) && !isInfo && (tool.params.length > 0 || hasMask)}
|
||||
<div class="params-sep">
|
||||
<span class="edge-legend" aria-hidden="true"
|
||||
>{t("toolPage.legendParams")}</span
|
||||
>
|
||||
</div>
|
||||
<ParamsCard
|
||||
{tool}
|
||||
params={tool.params}
|
||||
bind:values
|
||||
{pipetteTargetId}
|
||||
onPipetteToggle={handlePipetteToggle}
|
||||
{hasMask}
|
||||
bind:showMask
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tool-stage {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.source-legend {
|
||||
left: 25%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.result-legend {
|
||||
left: 75%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
.source-legend,
|
||||
.result-legend {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,48 +0,0 @@
|
||||
import type { ImageInfo } from "$lib/core/analyze";
|
||||
import type { PixelImage } from "$lib/core/types";
|
||||
import type { ToolEntry } from "$lib/v1/registry";
|
||||
|
||||
export type StageStatus = "idle" | "loaded" | "processing" | "error";
|
||||
|
||||
export interface StageProps {
|
||||
tool: ToolEntry;
|
||||
source: PixelImage | null;
|
||||
result: PixelImage | null;
|
||||
displayImage: PixelImage | null;
|
||||
info: ImageInfo | null;
|
||||
status: StageStatus;
|
||||
isInfo: boolean;
|
||||
isSourceless: boolean;
|
||||
isTextSource: boolean;
|
||||
textResult: string | null;
|
||||
sanitized: Record<string, any>;
|
||||
canChainBase: boolean;
|
||||
hasChain: boolean;
|
||||
hasMask: boolean;
|
||||
showMask: boolean;
|
||||
values: Record<string, any>;
|
||||
pipetteTargetId: string | null;
|
||||
handleFile: (file: File) => Promise<void> | void;
|
||||
onTextSubmit: (text: string) => Promise<void> | void;
|
||||
onSourceError: (e: unknown) => void;
|
||||
reset: () => void;
|
||||
toggleChain: () => void;
|
||||
handlePipetteToggle: (id: string) => void;
|
||||
handlePickColor: (hex: string) => void;
|
||||
showError: (e: unknown) => void;
|
||||
errorMessage: (e: unknown) => string;
|
||||
}
|
||||
|
||||
export interface ChainStageProps {
|
||||
tool: ToolEntry;
|
||||
index: number;
|
||||
input: PixelImage | null;
|
||||
result: PixelImage | null;
|
||||
busy: boolean;
|
||||
isLast: boolean;
|
||||
values: Record<string, any>;
|
||||
onRemove: () => void;
|
||||
onError: (e: unknown) => void;
|
||||
onAddStep: () => void;
|
||||
onRemoveChain: () => void;
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { t } from "$lib/v1/i18n/t";
|
||||
import type { PixelImage } from "$lib/core/types";
|
||||
import DropZone from "../DropZone.svelte";
|
||||
import Preview from "../Preview.svelte";
|
||||
import Button from "../ui/Button.svelte";
|
||||
|
||||
interface Props {
|
||||
overlay: PixelImage | null;
|
||||
onFile: (file: File) => void;
|
||||
onError: (e: unknown) => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
let { overlay, onFile, onError, onClear }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="overlay-card">
|
||||
<span class="edge-legend overlay-legend" aria-hidden="true"
|
||||
>{t("ui.overlayTitle")}</span
|
||||
>
|
||||
{#if !overlay}
|
||||
<DropZone {onFile} {onError} label={t("ui.overlayDrop")} />
|
||||
{:else}
|
||||
<div class="preview-wrap">
|
||||
<Preview image={overlay} />
|
||||
<Button variant="secondary" onclick={onClear}
|
||||
>{t("ui.overlayRemove")}</Button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.overlay-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-3);
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.overlay-legend {
|
||||
top: -0.65em;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.overlay-card :global(.dropzone) {
|
||||
min-height: 7rem;
|
||||
font-size: var(--text-s);
|
||||
}
|
||||
|
||||
.preview-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.preview-wrap :global(.media) {
|
||||
max-height: 12rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,51 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { t } from "$lib/v1/i18n/t";
|
||||
import type { ParamDef, ToolEntry } from "$lib/v1/registry";
|
||||
import ParamForm from "../ParamForm.svelte";
|
||||
|
||||
interface Props {
|
||||
tool: ToolEntry;
|
||||
params: ParamDef[];
|
||||
values: Record<string, any>;
|
||||
pipetteTargetId?: string | null;
|
||||
onPipetteToggle?: (id: string) => void;
|
||||
hasMask?: boolean;
|
||||
showMask?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
tool,
|
||||
params,
|
||||
values = $bindable(),
|
||||
pipetteTargetId = null,
|
||||
onPipetteToggle,
|
||||
hasMask = false,
|
||||
showMask = $bindable(false),
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="root">
|
||||
{#if params.length > 0 || hasMask}
|
||||
<ParamForm
|
||||
{tool}
|
||||
{params}
|
||||
bind:values
|
||||
{pipetteTargetId}
|
||||
{onPipetteToggle}
|
||||
{hasMask}
|
||||
bind:showMask
|
||||
/>
|
||||
{:else}
|
||||
<p class="hint text-caption text-muted">{t("paramsCard.noParams")}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.root {
|
||||
padding: 0 var(--space-4) var(--space-3);
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,129 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { PixelImage } from "$lib/core/types";
|
||||
|
||||
interface Props {
|
||||
image: PixelImage;
|
||||
px: number;
|
||||
py: number;
|
||||
hex: string;
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
}
|
||||
|
||||
let { image, px, py, hex, clientX, clientY }: Props = $props();
|
||||
|
||||
const SIZE = 110;
|
||||
const ZOOM = 8;
|
||||
const BLOCK = 15;
|
||||
const HALF = Math.floor(BLOCK / 2);
|
||||
const CENTER = SIZE / 2;
|
||||
|
||||
let loupeCanvas = $state<HTMLCanvasElement | undefined>();
|
||||
|
||||
let srcCanvas: HTMLCanvasElement | null = null;
|
||||
let srcKey: PixelImage | null = null;
|
||||
|
||||
function ensureSource(): HTMLCanvasElement | null {
|
||||
if (!srcCanvas || srcKey !== image) {
|
||||
srcCanvas = document.createElement("canvas");
|
||||
srcCanvas.width = image.width;
|
||||
srcCanvas.height = image.height;
|
||||
srcCanvas
|
||||
.getContext("2d")
|
||||
?.putImageData(
|
||||
new ImageData(image.data, image.width, image.height),
|
||||
0,
|
||||
0,
|
||||
);
|
||||
srcKey = image;
|
||||
}
|
||||
return srcCanvas;
|
||||
}
|
||||
|
||||
function clamp(v: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, v));
|
||||
}
|
||||
|
||||
const blockX = $derived(
|
||||
clamp(px - HALF, 0, Math.max(0, image.width - BLOCK)),
|
||||
);
|
||||
const blockY = $derived(
|
||||
clamp(py - HALF, 0, Math.max(0, image.height - BLOCK)),
|
||||
);
|
||||
const flipBelow = $derived(clientY < SIZE + 24);
|
||||
|
||||
$effect(() => {
|
||||
if (!loupeCanvas) return;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
loupeCanvas.width = SIZE * dpr;
|
||||
loupeCanvas.height = SIZE * dpr;
|
||||
const ctx = loupeCanvas.getContext("2d");
|
||||
const src = ensureSource();
|
||||
if (!ctx || !src) return;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.fillStyle = "#111318";
|
||||
ctx.fillRect(0, 0, SIZE, SIZE);
|
||||
ctx.drawImage(
|
||||
src,
|
||||
blockX,
|
||||
blockY,
|
||||
BLOCK,
|
||||
BLOCK,
|
||||
CENTER + (blockX - px) * ZOOM,
|
||||
CENTER + (blockY - py) * ZOOM,
|
||||
BLOCK * ZOOM,
|
||||
BLOCK * ZOOM,
|
||||
);
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.9)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(
|
||||
CENTER - ZOOM / 2 + 0.5,
|
||||
CENTER - ZOOM / 2 + 0.5,
|
||||
ZOOM,
|
||||
ZOOM,
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="loupe"
|
||||
class:flip={flipBelow}
|
||||
style="left:{clientX}px;top:{clientY}px"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<canvas bind:this={loupeCanvas} style="width:{SIZE}px;height:{SIZE}px"
|
||||
></canvas>
|
||||
<p class="hex">{hex}</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.loupe {
|
||||
position: fixed;
|
||||
z-index: 50;
|
||||
transform: translate(-50%, calc(-100% - 16px));
|
||||
padding: 4px 4px 2px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-m);
|
||||
box-shadow: var(--shadow-card);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.loupe.flip {
|
||||
transform: translate(-50%, 16px);
|
||||
}
|
||||
|
||||
canvas {
|
||||
display: block;
|
||||
border-radius: var(--radius-s);
|
||||
}
|
||||
|
||||
.hex {
|
||||
margin: 2px 0 0;
|
||||
text-align: center;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -1,126 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { ImageInfo } from "$lib/core/analyze";
|
||||
import type { PixelImage } from "$lib/core/types";
|
||||
import { t } from "$lib/v1/i18n/t";
|
||||
import { outputOf, type ToolEntry } from "$lib/v1/registry";
|
||||
import DownloadButton from "../DownloadButton.svelte";
|
||||
import InfoPanel from "../InfoPanel.svelte";
|
||||
import Preview from "../Preview.svelte";
|
||||
import Button from "../ui/Button.svelte";
|
||||
import EmptyState from "../ui/EmptyState.svelte";
|
||||
import TextResult from "./TextResult.svelte";
|
||||
|
||||
type Status = "idle" | "loaded" | "processing" | "error";
|
||||
|
||||
interface Props {
|
||||
tool: ToolEntry;
|
||||
sourceLoaded: boolean;
|
||||
status: Status;
|
||||
result: PixelImage | null;
|
||||
displayImage: PixelImage | null;
|
||||
info: ImageInfo | null;
|
||||
isInfo: boolean;
|
||||
params: Record<string, unknown>;
|
||||
textResult: string | null;
|
||||
onChainToggle?: () => void;
|
||||
hasChain?: boolean;
|
||||
onDownloadError: (e: unknown) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
tool,
|
||||
sourceLoaded,
|
||||
status,
|
||||
result,
|
||||
displayImage,
|
||||
info,
|
||||
isInfo,
|
||||
params,
|
||||
textResult,
|
||||
onChainToggle,
|
||||
hasChain = false,
|
||||
onDownloadError,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
{#if !sourceLoaded}
|
||||
<div class="media">
|
||||
<EmptyState
|
||||
title={t("resultCard.emptyTitle")}
|
||||
hint={t("resultCard.emptyHint")}
|
||||
/>
|
||||
</div>
|
||||
{:else if !isInfo && status === "processing" && !result}
|
||||
<div class="media">
|
||||
<EmptyState
|
||||
title={t("resultCard.processingTitle")}
|
||||
hint={t("resultCard.processingHint")}
|
||||
/>
|
||||
</div>
|
||||
{:else if isInfo}
|
||||
<div class="media">
|
||||
{#if info}
|
||||
<InfoPanel {info} />
|
||||
{/if}
|
||||
</div>
|
||||
{:else if tool.resultType === "text"}
|
||||
<div class="media">
|
||||
{#if textResult !== null}
|
||||
<TextResult text={textResult} filename={tool.id} toolId={tool.id} />
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="media">
|
||||
<Preview image={displayImage} />
|
||||
{#if status === "processing"}
|
||||
<span class="recalc" aria-live="polite">{t("resultCard.recalc")}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="actions-row">
|
||||
<DownloadButton
|
||||
image={result}
|
||||
format={outputOf(tool)}
|
||||
baseName={tool.id}
|
||||
{params}
|
||||
onError={onDownloadError}
|
||||
/>
|
||||
{#if onChainToggle}
|
||||
<Button variant="secondary" fullWidth onclick={onChainToggle}>
|
||||
{hasChain ? t("resultCard.breakChain") : t("resultCard.nextTool")}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.media {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 16rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.recalc {
|
||||
position: absolute;
|
||||
top: var(--space-2);
|
||||
right: var(--space-2);
|
||||
padding: 2px var(--space-2);
|
||||
border-radius: var(--radius-s);
|
||||
background: color-mix(in srgb, var(--accent) 12%, var(--surface));
|
||||
color: var(--link);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
</style>
|
||||
@@ -1,60 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { PixelImage } from "$lib/core/types";
|
||||
import { t } from "$lib/v1/i18n/t";
|
||||
import DropOverlay from "../DropOverlay.svelte";
|
||||
import DropZone from "../DropZone.svelte";
|
||||
import Preview from "../Preview.svelte";
|
||||
import Button from "../ui/Button.svelte";
|
||||
|
||||
interface Props {
|
||||
source: PixelImage | null;
|
||||
onFile: (file: File) => void;
|
||||
onError: (e: unknown) => void;
|
||||
onReset: () => void;
|
||||
pipetteActive?: boolean;
|
||||
onPickColor?: (hex: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
source,
|
||||
onFile,
|
||||
onError,
|
||||
onReset,
|
||||
pipetteActive = false,
|
||||
onPickColor,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
{#if !source}
|
||||
<DropZone {onFile} {onError} />
|
||||
{:else}
|
||||
<DropOverlay {onFile} {onError}>
|
||||
<div class="media">
|
||||
<Preview image={source} {pipetteActive} {onPickColor} />
|
||||
</div>
|
||||
<Button variant="secondary" onclick={onReset}
|
||||
>{t("sourceCard.replaceImage")}</Button
|
||||
>
|
||||
</DropOverlay>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.media {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 16rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,61 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Button from "../ui/Button.svelte";
|
||||
import { t } from "$lib/v1/i18n/t";
|
||||
|
||||
interface Props {
|
||||
onSubmit: (text: string) => void;
|
||||
}
|
||||
|
||||
let { onSubmit }: Props = $props();
|
||||
|
||||
let text = $state("");
|
||||
|
||||
function submit() {
|
||||
if (text.trim().length === 0) return;
|
||||
onSubmit(text);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
<h2 class="heading-section">{t("textInput.heading")}</h2>
|
||||
<textarea
|
||||
class="input"
|
||||
rows="8"
|
||||
bind:value={text}
|
||||
placeholder={t("textInput.placeholder")}
|
||||
aria-label={t("textInput.aria")}></textarea>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onclick={submit}
|
||||
disabled={text.trim().length === 0}
|
||||
>
|
||||
{t("textInput.decode")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
padding: var(--space-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-s);
|
||||
background: var(--surface);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-s);
|
||||
min-height: 12rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,102 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { downloadBlob } from "$lib/core/io";
|
||||
import { t } from "$lib/v1/i18n/t";
|
||||
import { getMergedDict } from "$lib/v1/i18n/locale.svelte";
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
filename: string;
|
||||
toolId?: string;
|
||||
}
|
||||
|
||||
let { text, filename, toolId }: Props = $props();
|
||||
|
||||
const shown = $derived(
|
||||
toolId ? (getMergedDict().tools[toolId]?.results?.[text] ?? text) : text,
|
||||
);
|
||||
|
||||
let copied = $state(false);
|
||||
|
||||
async function copy() {
|
||||
await navigator.clipboard.writeText(shown);
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 1500);
|
||||
}
|
||||
|
||||
function download() {
|
||||
downloadBlob(new Blob([shown], { type: "text/plain" }), `${filename}.txt`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="panel text-result">
|
||||
<textarea
|
||||
class="output"
|
||||
rows="10"
|
||||
readonly
|
||||
value={shown}
|
||||
aria-label={t("textResult.outputAria")}></textarea>
|
||||
<div class="actions">
|
||||
<button type="button" class="secondary" onclick={copy}>
|
||||
{copied ? t("textResult.copied") : t("textResult.copy")}
|
||||
</button>
|
||||
<button type="button" class="primary" onclick={download}
|
||||
>{t("textResult.downloadTxt")}</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.text-result {
|
||||
padding: var(--space-3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.output {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
padding: var(--space-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-s);
|
||||
background: var(--bg);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.actions button {
|
||||
flex: 1;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-s);
|
||||
border: 1px solid transparent;
|
||||
font-weight: 600;
|
||||
font-size: var(--text-m);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.actions .primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
}
|
||||
|
||||
.actions .primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.actions .secondary {
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.actions .secondary:hover {
|
||||
border-color: var(--link);
|
||||
color: var(--link);
|
||||
}
|
||||
</style>
|
||||
@@ -1,83 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
interface Props {
|
||||
variant?: "primary" | "secondary";
|
||||
type?: "button" | "submit";
|
||||
disabled?: boolean;
|
||||
busy?: boolean;
|
||||
busyText?: string;
|
||||
fullWidth?: boolean;
|
||||
onclick?: () => void;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
variant = "primary",
|
||||
type = "button",
|
||||
disabled = false,
|
||||
busy = false,
|
||||
busyText = "",
|
||||
fullWidth = false,
|
||||
onclick,
|
||||
children,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
{type}
|
||||
class={fullWidth ? `${variant} fullwidth` : variant}
|
||||
aria-busy={busy}
|
||||
disabled={disabled || busy}
|
||||
{onclick}
|
||||
>
|
||||
{#if busy && busyText}
|
||||
{busyText}
|
||||
{:else}
|
||||
{@render children()}
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<style>
|
||||
button {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-s);
|
||||
border: 1px solid transparent;
|
||||
font-weight: 600;
|
||||
font-size: var(--text-m);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--transition-fast),
|
||||
border-color var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
}
|
||||
|
||||
button.primary:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
button.secondary:hover:not(:disabled) {
|
||||
border-color: var(--link);
|
||||
color: var(--link);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.fullwidth {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -1,56 +0,0 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
id: string;
|
||||
label: string;
|
||||
checked?: boolean;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
let { id, label, checked = $bindable(false), hint }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="checkbox-row">
|
||||
<label class="checkbox" for={id}>
|
||||
<input {id} type="checkbox" bind:checked />
|
||||
{label}
|
||||
</label>
|
||||
{#if hint}
|
||||
<p class="hint text-caption text-muted">{hint}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: calc(var(--space-1) + 2px) 0;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
cursor: pointer;
|
||||
font-size: var(--text-caption, var(--text-s));
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
cursor: pointer;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
label:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
@@ -1,87 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Field from "./Field.svelte";
|
||||
import { t } from "$lib/v1/i18n/t";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
label: string;
|
||||
value?: string;
|
||||
hint?: string;
|
||||
pipetteActive?: boolean;
|
||||
onPipetteToggle?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
id,
|
||||
label,
|
||||
value = $bindable("#000000"),
|
||||
hint,
|
||||
pipetteActive = false,
|
||||
onPipetteToggle,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<Field {id} {label} {hint}>
|
||||
<div class="row">
|
||||
<input {id} class="control swatch" type="color" bind:value />
|
||||
{#if onPipetteToggle}
|
||||
<button
|
||||
type="button"
|
||||
class="pipette"
|
||||
aria-label={t("ui.pipette")}
|
||||
aria-pressed={pipetteActive}
|
||||
title={t("ui.pipette")}
|
||||
onclick={onPipetteToggle}
|
||||
>
|
||||
◎
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<style>
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.swatch {
|
||||
height: var(--control-height);
|
||||
width: 3.5rem;
|
||||
padding: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pipette {
|
||||
flex: none;
|
||||
width: var(--control-height);
|
||||
height: var(--control-height);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-s);
|
||||
background: var(--surface);
|
||||
color: var(--text-muted);
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color var(--transition-fast),
|
||||
color var(--transition-fast),
|
||||
background var(--transition-fast);
|
||||
}
|
||||
|
||||
.pipette:hover {
|
||||
border-color: var(--link);
|
||||
color: var(--link);
|
||||
}
|
||||
|
||||
.pipette[aria-pressed="true"] {
|
||||
border-color: var(--link);
|
||||
color: var(--link);
|
||||
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
|
||||
}
|
||||
</style>
|
||||
@@ -1,38 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
hint?: string;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { title, hint, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="empty panel">
|
||||
<p class="title">{title}</p>
|
||||
{#if hint}
|
||||
<p class="text-caption text-muted">{hint}</p>
|
||||
{/if}
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-1);
|
||||
min-height: 8rem;
|
||||
padding: var(--space-4);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -1,54 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { id, label, hint, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="field">
|
||||
<label class="text-caption text-muted" for={id}>{label}</label>
|
||||
<div class="control-slot">
|
||||
{@render children()}
|
||||
{#if hint}
|
||||
<p class="hint text-caption text-muted">{hint}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: calc(var(--space-1) + 2px) 0;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
label {
|
||||
flex: none;
|
||||
max-width: 45%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.control-slot {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
align-self: flex-end;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
@@ -1,21 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Field from "./Field.svelte";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
label: string;
|
||||
value?: string;
|
||||
options: { value: string; label: string }[];
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
let { id, label, value = $bindable(""), options, hint }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Field {id} {label} {hint}>
|
||||
<select {id} class="control" bind:value>
|
||||
{#each options as option (option.value)}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</Field>
|
||||
@@ -1,127 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Field from "./Field.svelte";
|
||||
import { t } from "$lib/v1/i18n/t";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
label: string;
|
||||
value?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
default?: number;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
id,
|
||||
label,
|
||||
value = $bindable(0),
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
default: defaultValue,
|
||||
hint,
|
||||
}: Props = $props();
|
||||
|
||||
function decrement() {
|
||||
if (min !== undefined && value <= min) return;
|
||||
value = Math.max(min ?? -Infinity, value - (step ?? 1));
|
||||
}
|
||||
|
||||
function increment() {
|
||||
if (max !== undefined && value >= max) return;
|
||||
value = Math.min(max ?? Infinity, value + (step ?? 1));
|
||||
}
|
||||
|
||||
function reset() {
|
||||
if (defaultValue !== undefined) value = defaultValue;
|
||||
}
|
||||
|
||||
const resetDisabled = $derived(
|
||||
defaultValue === undefined || value === defaultValue,
|
||||
);
|
||||
</script>
|
||||
|
||||
<Field {id} {label} {hint}>
|
||||
<div class="row">
|
||||
<button
|
||||
type="button"
|
||||
class="step"
|
||||
aria-label={t("ui.decrease")}
|
||||
onclick={decrement}>−</button
|
||||
>
|
||||
<input {id} type="range" {min} {max} {step} bind:value />
|
||||
<button
|
||||
type="button"
|
||||
class="step"
|
||||
aria-label={t("ui.increase")}
|
||||
onclick={increment}>+</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="step"
|
||||
aria-label={t("ui.reset")}
|
||||
disabled={resetDisabled}
|
||||
onclick={reset}
|
||||
>
|
||||
↺
|
||||
</button>
|
||||
<output>{value}</output>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<style>
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
width: 100%;
|
||||
max-width: 22rem;
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
flex: 1;
|
||||
min-width: 3rem;
|
||||
accent-color: var(--link);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.step {
|
||||
flex: none;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-s);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
}
|
||||
|
||||
.step:hover:not(:disabled) {
|
||||
border-color: var(--link);
|
||||
color: var(--link);
|
||||
}
|
||||
|
||||
.step:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
output {
|
||||
flex: none;
|
||||
min-width: 3ch;
|
||||
text-align: right;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-s);
|
||||
}
|
||||
</style>
|
||||
@@ -1,47 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Field from "./Field.svelte";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
label: string;
|
||||
value?: string | number;
|
||||
type?: "number" | "text";
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
hint?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
id,
|
||||
label,
|
||||
value = $bindable(""),
|
||||
type = "text",
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
hint,
|
||||
placeholder,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<Field {id} {label} {hint}>
|
||||
<input
|
||||
class="control"
|
||||
{id}
|
||||
{type}
|
||||
{min}
|
||||
{max}
|
||||
{step}
|
||||
{placeholder}
|
||||
bind:value
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<style>
|
||||
input.control::placeholder {
|
||||
color: var(--text-muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
@@ -1,56 +0,0 @@
|
||||
import type { CategoryId } from "../categories";
|
||||
|
||||
export const LOCALES = ["ru", "en"] as const;
|
||||
export type Locale = (typeof LOCALES)[number];
|
||||
|
||||
export const BASE_LOCALE: Locale = "en";
|
||||
|
||||
export const LOCALE_TAGS: Record<Locale, string> = {
|
||||
ru: "ru-RU",
|
||||
en: "en-US",
|
||||
};
|
||||
|
||||
export function isLocale(value: unknown): value is Locale {
|
||||
return (
|
||||
typeof value === "string" && (LOCALES as readonly string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
export type ToolStrings = {
|
||||
title?: string;
|
||||
description?: string;
|
||||
/** Подписи параметров по их id. */
|
||||
params?: Record<string, string>;
|
||||
/** Подписи опций select: paramId -> value -> label. */
|
||||
options?: Record<string, Record<string, string>>;
|
||||
/** Тексты-результаты текстовых инструментов (analyze): ключ -> строка. */
|
||||
results?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type HeaderStrings = {
|
||||
workspace: string;
|
||||
catalog: string;
|
||||
sectionsAria: string;
|
||||
footerNote: string;
|
||||
};
|
||||
|
||||
export type Dict = {
|
||||
header: HeaderStrings;
|
||||
categories: Record<CategoryId, string>;
|
||||
home: Record<string, string>;
|
||||
catalog: Record<string, string>;
|
||||
toolPage: Record<string, string>;
|
||||
chain: Record<string, string>;
|
||||
sourceCard: Record<string, string>;
|
||||
resultCard: Record<string, string>;
|
||||
paramsCard: Record<string, string>;
|
||||
textInput: Record<string, string>;
|
||||
textResult: Record<string, string>;
|
||||
download: Record<string, string>;
|
||||
infoPanel: Record<string, string>;
|
||||
dropZone: Record<string, string>;
|
||||
search: Record<string, string>;
|
||||
ui: Record<string, string>;
|
||||
errors: Record<string, string>;
|
||||
tools: Record<string, ToolStrings>;
|
||||
};
|
||||
@@ -1,188 +0,0 @@
|
||||
import type { Dict } from "./dict";
|
||||
|
||||
export const en: Dict = {
|
||||
header: {
|
||||
workspace: "Workspace",
|
||||
catalog: "Catalog",
|
||||
sectionsAria: "Sections",
|
||||
footerNote:
|
||||
"All operations run locally in your browser — your files are never uploaded anywhere.",
|
||||
},
|
||||
categories: {
|
||||
convert: "Convert",
|
||||
alpha: "Transparency",
|
||||
color: "Color",
|
||||
geometry: "Geometry",
|
||||
filters: "Filters",
|
||||
text: "Text",
|
||||
analyze: "Analyze",
|
||||
generate: "Generate",
|
||||
},
|
||||
home: {
|
||||
defaultTitle: "easy-png-tools — PNG utilities right in your browser",
|
||||
heroTitle: "What do you want to do with the image?",
|
||||
heroLead: "Find a tool — everything runs locally in your browser.",
|
||||
restoreLast: "↩ Restore last: {title}",
|
||||
changeTool: "← Change tool",
|
||||
},
|
||||
catalog: {
|
||||
pageTitle: "All tools — easy-png-tools",
|
||||
metaDescription:
|
||||
"Full catalog of PNG utilities: convert, transparency, color, geometry, analysis and image generation.",
|
||||
heading: "Tool catalog",
|
||||
lead: "{count} utilities for working with PNG. Everything runs locally in your browser.",
|
||||
},
|
||||
toolPage: {
|
||||
fallbackTitle: "Tool",
|
||||
legendSource: "Source",
|
||||
legendSummary: "Summary",
|
||||
legendResult: "Result",
|
||||
legendParams: "Parameters",
|
||||
stepHeading: "Step {n}",
|
||||
removeStepAria: "Remove step",
|
||||
stepError: "Step {n} ({title}): {msg}",
|
||||
},
|
||||
chain: {
|
||||
stepLabel: "Step {n}: {title}",
|
||||
inputLegend: "Input",
|
||||
resultLegend: "Result",
|
||||
paramsLegend: "Parameters",
|
||||
busyTitle: "Processing…",
|
||||
busyHint: "Running chain step",
|
||||
removeStepAria: "Remove step",
|
||||
},
|
||||
sourceCard: {
|
||||
replaceImage: "Replace image",
|
||||
},
|
||||
resultCard: {
|
||||
emptyTitle: "The result will appear here",
|
||||
emptyHint: "First upload a source image on the left",
|
||||
processingTitle: "Processing…",
|
||||
processingHint: "The image is being processed, this will take a moment",
|
||||
recalc: "Recalculating…",
|
||||
nextTool: "⛓ Next tool",
|
||||
breakChain: "✂ Break the chain",
|
||||
},
|
||||
paramsCard: {
|
||||
noParams: "This tool has no parameters — the result is ready as is.",
|
||||
},
|
||||
textInput: {
|
||||
heading: "Text",
|
||||
placeholder: "Paste data here",
|
||||
aria: "Text data",
|
||||
decode: "Decode",
|
||||
},
|
||||
textResult: {
|
||||
outputAria: "Text result",
|
||||
copied: "Copied",
|
||||
copy: "Copy",
|
||||
downloadTxt: "Download .txt",
|
||||
},
|
||||
download: {
|
||||
busy: "Preparing file…",
|
||||
file: "Download .{ext}",
|
||||
},
|
||||
infoPanel: {
|
||||
dimensions: "Dimensions",
|
||||
alpha: "Alpha channel",
|
||||
alphaYes: "yes — there are semi-transparent pixels",
|
||||
alphaNo: "no",
|
||||
colorCount: "Unique colors (RGBA)",
|
||||
},
|
||||
dropZone: {
|
||||
pickDefault: "Drop an image here or click to choose a file",
|
||||
overlayDefault: "Release the file to replace the image",
|
||||
},
|
||||
search: {
|
||||
placeholder: "Find a tool…",
|
||||
aria: "Search tools",
|
||||
nothingFound: "Nothing found — try another word.",
|
||||
},
|
||||
ui: {
|
||||
showMask: "Show mask",
|
||||
decrease: "Decrease",
|
||||
increase: "Increase",
|
||||
reset: "Reset",
|
||||
pipette: "Eyedropper",
|
||||
themeLight: "Light theme",
|
||||
themeDark: "Dark theme",
|
||||
overlayTitle: "Watermark",
|
||||
overlayDrop: "Drop a watermark PNG or click",
|
||||
overlayRemove: "Remove watermark",
|
||||
},
|
||||
errors: {
|
||||
noImageRun: "This tool does not process images",
|
||||
sourceRequired: "Upload an image first",
|
||||
textRequired: "Enter text first",
|
||||
workerFailed: "Worker execution failed",
|
||||
workerUnavailable: "Worker is unavailable",
|
||||
notFound: "Tool not found",
|
||||
noWatermark: "Pick a watermark image first",
|
||||
badTransform: "Degenerate transformation matrix",
|
||||
skewAngle: "Skew angles cannot be 90° or -90°",
|
||||
badHex: 'Invalid HEX color: "{value}"',
|
||||
radiusInt: "Radius must be a non-negative integer",
|
||||
kernelSize: "Kernel does not match the image dimensions",
|
||||
sizeInt: "Width and height must be integers ≥ 1",
|
||||
cropBounds: "Crop area does not intersect the image",
|
||||
noCanvasCtx: "Canvas 2D context is unavailable in this environment",
|
||||
badBase64: "Expected a base64 string or a data-uri of an image",
|
||||
qualityRange: "quality must be within 0..1",
|
||||
svgSize: "Could not determine SVG dimensions",
|
||||
svgLoad: "Failed to load SVG — check the markup",
|
||||
encodeUnsupported: "The browser does not support encoding to {mime}",
|
||||
unsupportedFile:
|
||||
"Unsupported file format ({type}). Supported formats are PNG, JPEG, WebP, GIF and BMP.",
|
||||
widthInt: "Image width must be an integer ≥ 1",
|
||||
noHexPixels: "No hex pixel values found",
|
||||
badPixelToken:
|
||||
"Each pixel must be 8 hex characters RRGGBBAA, separated by spaces",
|
||||
pixelCountMismatch:
|
||||
"Pixel count ({count}) is not divisible by width {width} without a remainder",
|
||||
toolNotFound: 'Tool "{id}" not found',
|
||||
badJson: "The file is not valid JSON",
|
||||
badPipelineShape: "The file structure does not look like a chain of steps",
|
||||
pipelineVersion: "Unsupported chain version: {version}",
|
||||
noSteps: "The file has no list of steps",
|
||||
paramNumber: 'Parameter "{id}" must be a number',
|
||||
paramString: 'Parameter "{id}" must be a string',
|
||||
paramBool: 'Parameter "{id}" must be a checkbox value',
|
||||
resizeSize: "Width and/or height must be positive",
|
||||
cropSize: "Crop width and height must be positive",
|
||||
sizePositive: "Dimensions must be positive and finite",
|
||||
tooManyParts: "Too many parts ({count}). Maximum is 1000.",
|
||||
},
|
||||
tools: {
|
||||
"png-file-size": {
|
||||
results: {
|
||||
line: "PNG size: {kb} KB",
|
||||
},
|
||||
},
|
||||
"verify-is-png": {
|
||||
results: {
|
||||
verifyYes: "Yes — this is a valid PNG signature.",
|
||||
verifyNo: "No — the signature does not match a PNG file.",
|
||||
},
|
||||
},
|
||||
"png-is-grayscale": {
|
||||
results: {
|
||||
grayscaleYes: "Yes — all pixels are shades of gray.",
|
||||
grayscaleNo: "No — colored pixels were found.",
|
||||
},
|
||||
},
|
||||
"png-is-transparent": {
|
||||
results: {
|
||||
transparentYes:
|
||||
"Yes — there are transparent or semi-transparent pixels.",
|
||||
transparentNo: "No — all pixels are fully opaque.",
|
||||
},
|
||||
},
|
||||
"png-orientation": {
|
||||
results: {
|
||||
orientationPortrait: "Portrait — height is greater than width.",
|
||||
orientationLandscape: "Landscape — width is greater than height.",
|
||||
orientationSquare: "Square — the sides are equal.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,82 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getLocale, initLocale, setLocale } from "./locale.svelte";
|
||||
import { interpolate, t } from "./t";
|
||||
import { en } from "./en";
|
||||
|
||||
type Store = Record<string, string>;
|
||||
|
||||
function stubStorage(): { store: Store } {
|
||||
const store: Store = {};
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: (k: string) => (k in store ? store[k] : null),
|
||||
setItem: (k: string, v: string) => {
|
||||
store[k] = v;
|
||||
},
|
||||
});
|
||||
vi.stubGlobal("window", {});
|
||||
return { store };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setLocale("ru");
|
||||
delete (en.home as Record<string, string>).onlyEnKey;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("t", () => {
|
||||
it("возвращает строку по точечному пути активной локали", () => {
|
||||
expect(t("header.workspace")).toBe("Рабочая область");
|
||||
setLocale("en");
|
||||
expect(t("header.catalog")).toBe("Catalog");
|
||||
});
|
||||
|
||||
it("фолбэк на английскую базу, если в активной локали нет ключа", () => {
|
||||
(en.home as Record<string, string>).onlyEnKey = "Only English string";
|
||||
setLocale("ru");
|
||||
expect(t("home.onlyEnKey")).toBe("Only English string");
|
||||
});
|
||||
|
||||
it("неизвестный путь возвращает сам путь", () => {
|
||||
expect(t("no.such.key")).toBe("no.such.key");
|
||||
});
|
||||
});
|
||||
|
||||
describe("interpolate", () => {
|
||||
it("подставляет переменные в шаблон", () => {
|
||||
expect(interpolate("Шаг {n} из {total}", { n: 2, total: 5 })).toBe(
|
||||
"Шаг 2 из 5",
|
||||
);
|
||||
});
|
||||
|
||||
it("оставляет плейсхолдер без переменной как есть", () => {
|
||||
expect(interpolate("Привет, {name}!", {})).toBe("Привет, {name}!");
|
||||
});
|
||||
|
||||
it("без переменных возвращает строку без изменений", () => {
|
||||
expect(interpolate("Просто текст")).toBe("Просто текст");
|
||||
});
|
||||
});
|
||||
|
||||
describe("персист локали", () => {
|
||||
it("initLocale читает сохранённый выбор", () => {
|
||||
const { store } = stubStorage();
|
||||
store["locale"] = "en";
|
||||
initLocale();
|
||||
expect(getLocale()).toBe("en");
|
||||
});
|
||||
|
||||
it("initLocale игнорирует мусор в хранилище", () => {
|
||||
const { store } = stubStorage();
|
||||
store["locale"] = "fr";
|
||||
initLocale();
|
||||
expect(getLocale()).toBe("ru");
|
||||
});
|
||||
|
||||
it("setLocale сохраняет выбор в localStorage", () => {
|
||||
const { store } = stubStorage();
|
||||
initLocale();
|
||||
setLocale("en");
|
||||
expect(store["locale"]).toBe("en");
|
||||
expect(getLocale()).toBe("en");
|
||||
});
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
import { BASE_LOCALE, isLocale, type Dict, type Locale } from "./dict";
|
||||
import { ru } from "./ru";
|
||||
import { en } from "./en";
|
||||
|
||||
const STORAGE_KEY = "locale";
|
||||
|
||||
const DICTS: Record<Locale, Dict> = { ru, en };
|
||||
|
||||
let locale = $state<Locale>(BASE_LOCALE);
|
||||
|
||||
function storage(): Storage | null {
|
||||
return typeof localStorage === "undefined" ? null : localStorage;
|
||||
}
|
||||
|
||||
export function getLocale(): Locale {
|
||||
return locale;
|
||||
}
|
||||
|
||||
export function getDict(): Dict {
|
||||
return DICTS[locale];
|
||||
}
|
||||
|
||||
/** Словарь активной локали; для отсутствующих в ней значений — фолбэк на базовую. */
|
||||
export function getMergedDict(): Dict {
|
||||
if (locale === BASE_LOCALE) return DICTS[BASE_LOCALE];
|
||||
const active = DICTS[locale];
|
||||
const base = DICTS[BASE_LOCALE];
|
||||
return new Proxy(base, {
|
||||
get(_target, section: string) {
|
||||
const a = (active as unknown as Record<string, unknown>)[section];
|
||||
const b = (base as unknown as Record<string, unknown>)[section];
|
||||
if (a && b && typeof a === "object" && typeof b === "object") {
|
||||
return { ...(b as object), ...(a as object) };
|
||||
}
|
||||
return a ?? b;
|
||||
},
|
||||
}) as Dict;
|
||||
}
|
||||
|
||||
export function setLocale(next: Locale): void {
|
||||
locale = next;
|
||||
storage()?.setItem(STORAGE_KEY, next);
|
||||
syncLangAttr();
|
||||
}
|
||||
|
||||
/** Читает сохранённый выбор и синхронизирует атрибут lang. Вызывается на клиенте. */
|
||||
export function initLocale(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
const saved = storage()?.getItem(STORAGE_KEY);
|
||||
if (isLocale(saved)) locale = saved;
|
||||
syncLangAttr();
|
||||
}
|
||||
|
||||
function syncLangAttr(): void {
|
||||
if (typeof document !== "undefined") {
|
||||
document.documentElement.lang = locale;
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TOOLS } from "../registry";
|
||||
import { setLocale } from "./locale.svelte";
|
||||
import { normalizeForSearch, scoreDoc, type SearchDoc } from "./matching";
|
||||
import { toolSearchDoc } from "./tool-strings";
|
||||
|
||||
describe("normalizeForSearch", () => {
|
||||
it("нижний регистр", () => {
|
||||
expect(normalizeForSearch("Rotate PNG")).toBe("rotate png");
|
||||
});
|
||||
|
||||
it("ё заменяется на е", () => {
|
||||
expect(normalizeForSearch("Ёлка и ёж")).toBe("елка и еж");
|
||||
});
|
||||
|
||||
it("диакритика снимается через NFD", () => {
|
||||
expect(normalizeForSearch("café")).toBe("cafe");
|
||||
expect(normalizeForSearch("Über")).toBe("uber");
|
||||
});
|
||||
});
|
||||
|
||||
describe("scoreDoc", () => {
|
||||
const doc: SearchDoc = {
|
||||
id: "rotate-free-png",
|
||||
titles: ["Повернуть на произвольный угол"],
|
||||
descriptions: [
|
||||
"Поворот на любой угол. Холст расширяется под новые габариты.",
|
||||
],
|
||||
};
|
||||
|
||||
it("пустой запрос даёт нейтральный балл", () => {
|
||||
expect(scoreDoc(doc, "")).toBe(1);
|
||||
});
|
||||
|
||||
it("префикс названия ценнее подстроки", () => {
|
||||
const prefix = scoreDoc(doc, normalizeForSearch("повер"));
|
||||
const infix = scoreDoc(doc, normalizeForSearch("верну"));
|
||||
expect(prefix!).toBe(100);
|
||||
expect(infix!).toBeGreaterThan(30);
|
||||
});
|
||||
|
||||
it("подстрока названия ценнее совпадения в описании", () => {
|
||||
const title = scoreDoc(doc, normalizeForSearch("угол"));
|
||||
const desc = scoreDoc(doc, normalizeForSearch("габариты"));
|
||||
expect(title!).toBeGreaterThan(desc!);
|
||||
});
|
||||
|
||||
it("нет совпадения — null", () => {
|
||||
expect(scoreDoc(doc, normalizeForSearch("квант"))).toBeNull();
|
||||
});
|
||||
|
||||
it("запрос с опечаткой ё/е всё равно находит", () => {
|
||||
expect(scoreDoc(doc, normalizeForSearch("повёрнут"))).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("кросс-языковой поиск на реальном реестре", () => {
|
||||
it("английский запрос находит инструмент при русской локали", () => {
|
||||
setLocale("ru");
|
||||
const hits = TOOLS.filter((tool) => {
|
||||
const s = scoreDoc(toolSearchDoc(tool), normalizeForSearch("rotate"));
|
||||
return s !== null && s > 0;
|
||||
}).map((tool) => tool.id);
|
||||
expect(hits).toContain("rotate-png");
|
||||
expect(hits).toContain("rotate-free-png");
|
||||
});
|
||||
|
||||
it("русский запрос находит инструмент при английской локали", () => {
|
||||
setLocale("en");
|
||||
try {
|
||||
for (const q of ["повернуть", "пово"]) {
|
||||
const hits = TOOLS.filter((tool) => {
|
||||
const s = scoreDoc(toolSearchDoc(tool), normalizeForSearch(q));
|
||||
return s !== null && s > 0;
|
||||
}).map((tool) => tool.id);
|
||||
expect(hits, `запрос «${q}» при en-локали`).toContain("rotate-png");
|
||||
expect(hits, `запрос «${q}» при en-локали`).toContain(
|
||||
"rotate-free-png",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setLocale("ru");
|
||||
}
|
||||
});
|
||||
|
||||
it("поиск работает и по описанию, не только по названию", () => {
|
||||
setLocale("en");
|
||||
try {
|
||||
const hits = TOOLS.filter((tool) => {
|
||||
const s = scoreDoc(
|
||||
toolSearchDoc(tool),
|
||||
normalizeForSearch("полупрозрачные"),
|
||||
);
|
||||
return s !== null && s > 0;
|
||||
}).map((tool) => tool.id);
|
||||
expect(hits).toContain("png-is-transparent");
|
||||
} finally {
|
||||
setLocale("ru");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,69 +0,0 @@
|
||||
/**
|
||||
* Нормализация строки для поиска: нижний регистр, ё→е, снятие диакритики (NFD).
|
||||
*/
|
||||
export function normalizeForSearch(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replaceAll("ё", "е")
|
||||
.normalize("NFD")
|
||||
.replace(/\p{M}/gu, "");
|
||||
}
|
||||
|
||||
export type SearchDoc = {
|
||||
id: string;
|
||||
titles: string[];
|
||||
descriptions: string[];
|
||||
};
|
||||
|
||||
const SUBSEQUENCE_BASE = 20;
|
||||
|
||||
function bestTitleScore(titles: string[], q: string): number | null {
|
||||
let best: number | null = null;
|
||||
for (const raw of titles) {
|
||||
const title = normalizeForSearch(raw);
|
||||
if (title.startsWith(q)) return 100;
|
||||
const at = title.indexOf(q);
|
||||
if (at >= 0) {
|
||||
const s = 80 - Math.min(at, 40);
|
||||
if (best === null || s > best) best = s;
|
||||
continue;
|
||||
}
|
||||
let pos = 0;
|
||||
let sub = SUBSEQUENCE_BASE;
|
||||
for (const ch of q) {
|
||||
pos = title.indexOf(ch, pos);
|
||||
if (pos < 0) {
|
||||
sub = -1;
|
||||
break;
|
||||
}
|
||||
sub -= 1;
|
||||
pos += 1;
|
||||
}
|
||||
if (sub >= 0 && (best === null || sub > best)) best = sub;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Скоринг документа против нормализованного запроса.
|
||||
* Пустой запрос — нейтральный балл; отсутствие совпадения — null.
|
||||
*/
|
||||
export function scoreDoc(
|
||||
doc: SearchDoc,
|
||||
normalizedQuery: string,
|
||||
): number | null {
|
||||
if (normalizedQuery.length === 0) return 1;
|
||||
if (normalizeForSearch(doc.id).includes(normalizedQuery)) {
|
||||
const at = normalizeForSearch(doc.id).indexOf(normalizedQuery);
|
||||
const byId = 60 - Math.min(at, 40);
|
||||
const byTitle = bestTitleScore(doc.titles, normalizedQuery);
|
||||
if (byTitle !== null && byTitle >= 80) return byTitle;
|
||||
return Math.max(byId, byTitle ?? -1);
|
||||
}
|
||||
const byTitle = bestTitleScore(doc.titles, normalizedQuery);
|
||||
if (byTitle !== null && byTitle >= 20) return byTitle;
|
||||
for (const raw of doc.descriptions) {
|
||||
if (normalizeForSearch(raw).includes(normalizedQuery)) return 30;
|
||||
}
|
||||
return byTitle !== null && byTitle > 0 ? byTitle : null;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,25 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isChainable, TOOLS } from "../registry";
|
||||
import { normalizeForSearch, scoreDoc } from "./matching";
|
||||
import { toolSearchDoc } from "./tool-strings";
|
||||
|
||||
function hits(q: string): string[] {
|
||||
const nq = normalizeForSearch(q);
|
||||
return TOOLS.filter((t) => {
|
||||
const s = scoreDoc(toolSearchDoc(t), nq);
|
||||
return s !== null && s > 0;
|
||||
}).map((t) => t.id);
|
||||
}
|
||||
|
||||
describe("полнота поиска", () => {
|
||||
it("генератор (не chainable) находится поиском — раньше отфильтровывался", () => {
|
||||
expect(isChainable(TOOLS.find((t) => t.id === "color-wheel-png")!)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(hits("color wheel")).toContain("color-wheel-png");
|
||||
});
|
||||
|
||||
it("анализатор-маска тоже ищется", () => {
|
||||
expect(hits("уникальных цветов")).toContain("unique-color-mask-png");
|
||||
});
|
||||
});
|
||||
@@ -1,96 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { TOOLS } from "../registry";
|
||||
import { LOCALE_TAGS } from "./dict";
|
||||
import { setLocale } from "./locale.svelte";
|
||||
import { normalizeForSearch, scoreDoc } from "./matching";
|
||||
import { t } from "./t";
|
||||
import { toolSearchDoc } from "./tool-strings";
|
||||
|
||||
afterEach(() => {
|
||||
setLocale("ru");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("Смоук §6 i18n", () => {
|
||||
it("1. html lang следует за локалью", () => {
|
||||
const doc = { documentElement: { lang: "" } };
|
||||
vi.stubGlobal("document", doc);
|
||||
setLocale("en");
|
||||
expect(doc.documentElement.lang).toBe("en");
|
||||
setLocale("ru");
|
||||
expect(doc.documentElement.lang).toBe("ru");
|
||||
});
|
||||
|
||||
it("3. Ключевые секции переведены без смеси языков", () => {
|
||||
const samples: Array<[string, string, string]> = [
|
||||
["header.workspace", "Рабочая область", "Workspace"],
|
||||
["catalog.heading", "Каталог инструментов", "Tool catalog"],
|
||||
[
|
||||
"home.heroTitle",
|
||||
"Что делаем с изображением?",
|
||||
"What do you want to do",
|
||||
],
|
||||
["chain.inputLegend", "Вход", "Input"],
|
||||
["resultCard.nextTool", "Следующий инструмент", "Next tool"],
|
||||
["download.busy", "Готовим файл", "Preparing file"],
|
||||
];
|
||||
for (const [key, ruPart, enPart] of samples) {
|
||||
setLocale("ru");
|
||||
expect(t(key), key + " @ru").toContain(ruPart);
|
||||
setLocale("en");
|
||||
expect(t(key), key + " @en").toContain(enPart);
|
||||
}
|
||||
});
|
||||
|
||||
it("5. Ошибки с vars локализуются на оба языка", () => {
|
||||
setLocale("ru");
|
||||
expect(t("errors.badHex", { value: "#zz" })).toBe(
|
||||
'Некорректный HEX-цвет: "#zz"',
|
||||
);
|
||||
expect(t("errors.toolNotFound", { id: "x" })).toContain("не найден");
|
||||
setLocale("en");
|
||||
expect(t("errors.badHex", { value: "#zz" })).toBe(
|
||||
'Invalid HEX color: "#zz"',
|
||||
);
|
||||
});
|
||||
|
||||
it("6. Поиск кросс-языковой в обе стороны", () => {
|
||||
const ids = (q: string) =>
|
||||
TOOLS.filter((tool) => {
|
||||
const s = scoreDoc(toolSearchDoc(tool), normalizeForSearch(q));
|
||||
return s !== null && s > 0;
|
||||
}).map((tool) => tool.id);
|
||||
|
||||
setLocale("ru");
|
||||
let hits = ids("rotate");
|
||||
expect(hits).toContain("rotate-png");
|
||||
hits = ids("повер");
|
||||
expect(hits).toContain("rotate-png");
|
||||
|
||||
setLocale("en");
|
||||
hits = ids("пово");
|
||||
expect(hits).toContain("rotate-free-png");
|
||||
hits = ids("rotate");
|
||||
expect(hits).toContain("rotate-png");
|
||||
});
|
||||
|
||||
it("6b. Ё не мешает совпадению", () => {
|
||||
const blackWhite = TOOLS.find((tool) => tool.id === "black-and-white-png")!;
|
||||
setLocale("ru");
|
||||
const hit = scoreDoc(
|
||||
toolSearchDoc(blackWhite),
|
||||
normalizeForSearch("ЧЁРНО"),
|
||||
);
|
||||
expect(hit).not.toBeNull();
|
||||
const withoutYo = scoreDoc(
|
||||
toolSearchDoc(blackWhite),
|
||||
normalizeForSearch("черно"),
|
||||
);
|
||||
expect(withoutYo).not.toBeNull();
|
||||
});
|
||||
|
||||
it("7. Теги локалей для форматирования чисел корректны", () => {
|
||||
expect(LOCALE_TAGS.ru).toBe("ru-RU");
|
||||
expect(LOCALE_TAGS.en).toBe("en-US");
|
||||
});
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
import { getMergedDict } from "./locale.svelte";
|
||||
|
||||
export function interpolate(
|
||||
template: string,
|
||||
vars?: Record<string, string | number>,
|
||||
): string {
|
||||
if (!vars) return template;
|
||||
return template.replace(/\{(\w+)\}/g, (match, name: string) =>
|
||||
name in vars ? String(vars[name]) : match,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Перевод по точечному пути вида 'header.workspace' или 'errors.ERR_BAD_HEX'.
|
||||
* Сначала активная локаль, затем базовая; если ключа нет нигде — возвращается сам путь.
|
||||
*/
|
||||
export function t(
|
||||
path: string,
|
||||
vars?: Record<string, string | number>,
|
||||
): string {
|
||||
let node: unknown = getMergedDict();
|
||||
for (const part of path.split(".")) {
|
||||
if (
|
||||
node &&
|
||||
typeof node === "object" &&
|
||||
part in (node as Record<string, unknown>)
|
||||
) {
|
||||
node = (node as Record<string, unknown>)[part];
|
||||
} else {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
return typeof node === "string" ? interpolate(node, vars) : path;
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import type { ParamDef, ToolEntry } from "$lib/v1/registry";
|
||||
import { getMergedDict } from "./locale.svelte";
|
||||
import type { SearchDoc } from "./matching";
|
||||
import { ru } from "./ru";
|
||||
|
||||
/**
|
||||
* Строки инструмента на активной локали.
|
||||
* Русский текст живёт в самом реестре (базовая локаль),
|
||||
* переводы — в секции tools словарей; отсутствие ключа даёт фолбэк на реестр.
|
||||
*/
|
||||
export function toolTitle(tool: ToolEntry): string {
|
||||
return getMergedDict().tools[tool.id]?.title ?? tool.title;
|
||||
}
|
||||
|
||||
export function toolDescription(tool: ToolEntry): string {
|
||||
return getMergedDict().tools[tool.id]?.description ?? tool.description;
|
||||
}
|
||||
|
||||
export function paramLabel(tool: ToolEntry, param: ParamDef): string {
|
||||
const viaDict = getMergedDict().tools[tool.id]?.params?.[param.id];
|
||||
if (viaDict) return viaDict;
|
||||
const viaDef = param as { label?: string };
|
||||
return viaDef.label ?? param.id;
|
||||
}
|
||||
|
||||
export function optionLabel(
|
||||
tool: ToolEntry,
|
||||
param: ParamDef,
|
||||
value: string,
|
||||
): string {
|
||||
const viaDict = getMergedDict().tools[tool.id]?.options?.[param.id]?.[value];
|
||||
if (viaDict) return viaDict;
|
||||
if (param.type === "select") {
|
||||
return param.options.find((o) => o.value === value)?.label ?? value;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function dedupe(values: string[]): string[] {
|
||||
return [...new Set(values.filter((v) => v.length > 0))];
|
||||
}
|
||||
|
||||
/**
|
||||
* Поисковый документ инструмента: строки активной локали, русского и английского
|
||||
* (реестр) вместе — запрос находит инструмент по любому из языков независимо
|
||||
* от того, какой сейчас включён.
|
||||
*/
|
||||
export function toolSearchDoc(tool: ToolEntry): SearchDoc {
|
||||
const active = getMergedDict().tools[tool.id];
|
||||
return {
|
||||
id: tool.id,
|
||||
titles: dedupe([
|
||||
active?.title ?? "",
|
||||
ru.tools[tool.id]?.title ?? "",
|
||||
tool.title,
|
||||
]),
|
||||
descriptions: dedupe([
|
||||
active?.description ?? "",
|
||||
ru.tools[tool.id]?.description ?? "",
|
||||
tool.description,
|
||||
]),
|
||||
};
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TOOLS } from "../registry";
|
||||
import { en } from "./en";
|
||||
import { ru } from "./ru";
|
||||
|
||||
describe("секция tools словарей покрывает реестр", () => {
|
||||
it("у каждого инструмента есть перевод ru с непустыми title/description", () => {
|
||||
for (const tool of TOOLS) {
|
||||
const strings = ru.tools[tool.id];
|
||||
expect(strings, `нет перевода для ${tool.id}`).toBeDefined();
|
||||
expect(strings?.title, `${tool.id}: title`).toBeTruthy();
|
||||
expect(strings?.description, `${tool.id}: description`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("переведены подписи всех параметров", () => {
|
||||
for (const tool of TOOLS) {
|
||||
const params = ru.tools[tool.id]?.params ?? {};
|
||||
for (const param of tool.params) {
|
||||
const label = params[param.id];
|
||||
expect(
|
||||
label,
|
||||
`${tool.id}.${param.id}: нет перевода подписи`,
|
||||
).toBeDefined();
|
||||
expect(label?.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("переведены подписи всех опций select", () => {
|
||||
for (const tool of TOOLS) {
|
||||
const options = ru.tools[tool.id]?.options ?? {};
|
||||
for (const param of tool.params) {
|
||||
if (param.type !== "select") continue;
|
||||
const labels = options[param.id] ?? {};
|
||||
for (const option of param.options) {
|
||||
const label = labels[option.value];
|
||||
expect(
|
||||
label,
|
||||
`${tool.id}.${param.id}[${option.value}]: нет перевода`,
|
||||
).toBeDefined();
|
||||
expect(label.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("в словарях нет лишних ключей инструментов и параметров", () => {
|
||||
const ids = new Set(TOOLS.map((tool) => tool.id));
|
||||
for (const dict of [ru.tools, en.tools]) {
|
||||
for (const id of Object.keys(dict)) {
|
||||
expect(ids.has(id), `лишний инструмент в словаре: ${id}`).toBe(true);
|
||||
const tool = TOOLS.find((t) => t.id === id)!;
|
||||
const paramIds = new Set(tool.params.map((p) => p.id));
|
||||
for (const paramId of Object.keys(dict[id].params ?? {})) {
|
||||
expect(
|
||||
paramIds.has(paramId),
|
||||
`${id}: лишний параметр ${paramId}`,
|
||||
).toBe(true);
|
||||
}
|
||||
const selectIds = new Set(
|
||||
tool.params.filter((p) => p.type === "select").map((p) => p.id),
|
||||
);
|
||||
for (const paramId of Object.keys(dict[id].options ?? {})) {
|
||||
expect(
|
||||
selectIds.has(paramId),
|
||||
`${id}: options у не-select параметра ${paramId}`,
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("тексты-результаты анализаторов переведены в обоих словарях", () => {
|
||||
const expectations: Array<[string, string[]]> = [
|
||||
["png-is-grayscale", ["grayscaleYes", "grayscaleNo"]],
|
||||
["png-is-transparent", ["transparentYes", "transparentNo"]],
|
||||
[
|
||||
"png-orientation",
|
||||
["orientationPortrait", "orientationLandscape", "orientationSquare"],
|
||||
],
|
||||
];
|
||||
for (const [id, keys] of expectations) {
|
||||
for (const key of keys) {
|
||||
expect(en.tools[id]?.results?.[key], `${id}.${key} (en)`).toBeDefined();
|
||||
expect(ru.tools[id]?.results?.[key], `${id}.${key} (ru)`).toBeDefined();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
import { ToolError } from "../core/errors";
|
||||
|
||||
export function num(params: Record<string, unknown>, id: string): number {
|
||||
const v = params[id];
|
||||
if (typeof v !== "number" || !Number.isFinite(v)) {
|
||||
throw new ToolError("errors.paramNumber", { id });
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
export function str(params: Record<string, unknown>, id: string): string {
|
||||
const v = params[id];
|
||||
if (typeof v !== "string") {
|
||||
throw new ToolError("errors.paramString", { id });
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
export function bool(params: Record<string, unknown>, id: string): boolean {
|
||||
const v = params[id];
|
||||
if (typeof v !== "boolean") {
|
||||
throw new ToolError("errors.paramBool", { id });
|
||||
}
|
||||
return v;
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CATEGORIES } from "./categories";
|
||||
import { TOOL_ICONS } from "./tools/tool-icons";
|
||||
import {
|
||||
defaultParams,
|
||||
getTool,
|
||||
outputOf,
|
||||
sanitizeParams,
|
||||
TOOLS,
|
||||
type ParamDef,
|
||||
type ToolEntry,
|
||||
} from "./registry";
|
||||
|
||||
describe("реестр инструментов", () => {
|
||||
it("id уникальны и в kebab-case", () => {
|
||||
const ids = TOOLS.map((t) => t.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
for (const id of ids) {
|
||||
expect(id).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(TOOLS.map((t) => [t.id, t] as const))(
|
||||
"%s: категория валидна",
|
||||
(_, tool) => {
|
||||
expect(CATEGORIES).toContain(tool.category);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(TOOLS.map((t) => [t.id, t] as const))(
|
||||
"%s: исполнители определены",
|
||||
(_, tool) => {
|
||||
if (tool.resultType === "text") {
|
||||
expect(
|
||||
typeof tool.toText === "function" ||
|
||||
typeof tool.textToText === "function",
|
||||
).toBe(true);
|
||||
} else if (tool.sourceMode === "none") {
|
||||
expect(typeof tool.generate).toBe("function");
|
||||
} else {
|
||||
expect(typeof tool.run).toBe("function");
|
||||
}
|
||||
if (tool.preview) {
|
||||
expect(typeof tool.preview).toBe("function");
|
||||
}
|
||||
expect(tool.title.length).toBeGreaterThan(0);
|
||||
expect(tool.description.length).toBeGreaterThan(0);
|
||||
},
|
||||
);
|
||||
|
||||
it("у select дефолт входит в options, у number/slider дефолт в диапазоне", () => {
|
||||
for (const tool of TOOLS) {
|
||||
for (const param of tool.params) {
|
||||
if (param.type === "select") {
|
||||
expect(param.options.map((o) => o.value)).toContain(param.default);
|
||||
expect(param.options.length).toBeGreaterThan(0);
|
||||
}
|
||||
if (param.type === "number") {
|
||||
expect(param.min === undefined || param.default >= param.min).toBe(
|
||||
true,
|
||||
);
|
||||
expect(param.max === undefined || param.default <= param.max).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
if (param.type === "slider") {
|
||||
expect(param.min).toBeLessThan(param.max);
|
||||
expect(param.default).toBeGreaterThanOrEqual(param.min);
|
||||
expect(param.default).toBeLessThanOrEqual(param.max);
|
||||
}
|
||||
if (param.type === "color") {
|
||||
expect(param.default).toMatch(/^#[0-9a-f]{6}$/i);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("id параметров уникальны внутри инструмента", () => {
|
||||
for (const tool of TOOLS) {
|
||||
const ids = tool.params.map((p) => p.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
}
|
||||
});
|
||||
|
||||
it("qualityParamId ссылается на существующий числовой параметр", () => {
|
||||
for (const tool of TOOLS) {
|
||||
const output = outputOf(tool);
|
||||
if (output?.qualityParamId) {
|
||||
const param = tool.params.find(
|
||||
(p): p is Extract<ParamDef, { type: "number" | "slider" }> =>
|
||||
p.id === output.qualityParamId,
|
||||
);
|
||||
expect(param).toBeDefined();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("png-info не имеет формата вывода, конвертеры имеют jpeg/webp, остальные — png", () => {
|
||||
expect(outputOf(getToolOrThrow("png-info"))).toBeUndefined();
|
||||
expect(outputOf(getToolOrThrow("convert-png-to-jpg"))?.mime).toBe(
|
||||
"image/jpeg",
|
||||
);
|
||||
expect(outputOf(getToolOrThrow("convert-png-to-webp"))?.mime).toBe(
|
||||
"image/webp",
|
||||
);
|
||||
expect(outputOf(getToolOrThrow("grayscale-png"))).toEqual({
|
||||
mime: "image/png",
|
||||
ext: "png",
|
||||
});
|
||||
});
|
||||
|
||||
it("defaultParams собирает значения по умолчанию", () => {
|
||||
const resize = getToolOrThrow("resize-png");
|
||||
expect(defaultParams(resize)).toEqual({
|
||||
width: 0,
|
||||
height: 0,
|
||||
keepAspect: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("словарь иконок покрывает все инструменты", () => {
|
||||
for (const tool of TOOLS) {
|
||||
expect(TOOL_ICONS[tool.id]).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("у каждого инструмента популярность в диапазоне 0..100 и ключ иконки", () => {
|
||||
for (const tool of TOOLS) {
|
||||
expect(tool.popularity).toBeGreaterThanOrEqual(0);
|
||||
expect(tool.popularity).toBeLessThanOrEqual(100);
|
||||
expect(typeof tool.icon).toBe("string");
|
||||
expect(tool.icon!.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeParams", () => {
|
||||
it("заменяет пустые и невалидные числа на дефолт", () => {
|
||||
const resize = getToolOrThrow("resize-png");
|
||||
expect(
|
||||
sanitizeParams(resize, {
|
||||
width: undefined,
|
||||
height: null,
|
||||
keepAspect: true,
|
||||
}),
|
||||
).toEqual({
|
||||
width: 0,
|
||||
height: 0,
|
||||
keepAspect: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("клампит числа в диапазон параметра", () => {
|
||||
const bc = getToolOrThrow("adjust-brightness-contrast-png");
|
||||
expect(sanitizeParams(bc, { brightness: 5000, contrast: -999 })).toEqual({
|
||||
brightness: 100,
|
||||
contrast: -100,
|
||||
});
|
||||
});
|
||||
|
||||
it("возвращает дефолт для невалидных select, checkbox и color", () => {
|
||||
const rotate = getToolOrThrow("rotate-png");
|
||||
expect(sanitizeParams(rotate, { angle: "45" })).toEqual({ angle: "90" });
|
||||
const resize = getToolOrThrow("resize-png");
|
||||
expect(
|
||||
sanitizeParams(resize, { width: 10, height: 10, keepAspect: "yes" }),
|
||||
).toEqual({
|
||||
width: 10,
|
||||
height: 10,
|
||||
keepAspect: true,
|
||||
});
|
||||
const jpg = getToolOrThrow("convert-png-to-jpg");
|
||||
expect(sanitizeParams(jpg, { background: "red", quality: 50 })).toEqual({
|
||||
background: "#ffffff",
|
||||
quality: 50,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("run инструмента resize-png", () => {
|
||||
const img = {
|
||||
width: 100,
|
||||
height: 50,
|
||||
data: new Uint8ClampedArray(100 * 50 * 4),
|
||||
};
|
||||
const runResize = async (params: Record<string, unknown>) =>
|
||||
getToolOrThrow("resize-png").run!(img, params);
|
||||
|
||||
it("keepAspect + одна сторона — вторая считается по пропорции", async () => {
|
||||
const out = await runResize({ width: 200, height: 0, keepAspect: true });
|
||||
expect(out.width).toBe(200);
|
||||
expect(out.height).toBe(100);
|
||||
});
|
||||
|
||||
it("keepAspect + обе стороны — вписывание в размеры без искажения", async () => {
|
||||
const out = await runResize({ width: 50, height: 50, keepAspect: true });
|
||||
expect(out.width).toBe(50);
|
||||
expect(out.height).toBe(25);
|
||||
});
|
||||
|
||||
it("без keepAspect — обе стороны как задано", async () => {
|
||||
const out = await runResize({ width: 30, height: 40, keepAspect: false });
|
||||
expect(out.width).toBe(30);
|
||||
expect(out.height).toBe(40);
|
||||
});
|
||||
|
||||
it("обе стороны 0 — человекочитаемая ошибка", async () => {
|
||||
await expect(
|
||||
runResize({ width: 0, height: 0, keepAspect: true }),
|
||||
).rejects.toThrow("errors.resizeSize");
|
||||
});
|
||||
});
|
||||
|
||||
function getToolOrThrow(id: string): ToolEntry {
|
||||
const tool = getTool(id);
|
||||
if (!tool) throw new Error(`Инструмент "${id}" не найден`);
|
||||
return tool;
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
import type { CategoryId } from "./categories";
|
||||
import type { OutputMime } from "../core/io";
|
||||
import type { PixelImage } from "../core/types";
|
||||
|
||||
import { alphaEntries } from "./registry/alpha";
|
||||
import { analyzeEntries } from "./registry/analyze";
|
||||
import { colorEntries } from "./registry/color";
|
||||
import { convertEntries } from "./registry/convert";
|
||||
import { filterEntries } from "./registry/filters";
|
||||
import { generateEntries } from "./registry/generate";
|
||||
import { geometryEntries } from "./registry/geometry";
|
||||
import { textEntries } from "./registry/text";
|
||||
|
||||
// TODO: remove convert tools - move to load image \ download button flow
|
||||
|
||||
export type ParamDef =
|
||||
| {
|
||||
id: string;
|
||||
label: string;
|
||||
type: "number";
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
default: number;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
label: string;
|
||||
type: "slider";
|
||||
min: number;
|
||||
max: number;
|
||||
step?: number;
|
||||
default: number;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
label: string;
|
||||
type: "select";
|
||||
options: { value: string; label: string }[];
|
||||
default: string;
|
||||
}
|
||||
| { id: string; label: string; type: "checkbox"; default: boolean }
|
||||
| { id: string; label: string; type: "color"; default: string }
|
||||
| {
|
||||
id: string;
|
||||
label: string;
|
||||
type: "text";
|
||||
default: string;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
export type OutputFormat = {
|
||||
mime: OutputMime;
|
||||
ext: string;
|
||||
qualityParamId?: string;
|
||||
};
|
||||
|
||||
export type SourceMode = "file" | "none" | "text";
|
||||
|
||||
export type ToolEntry<P = Record<string, unknown>> = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: CategoryId;
|
||||
sourceMode?: SourceMode;
|
||||
params: ParamDef[];
|
||||
run?: (img: PixelImage, params: P) => Promise<PixelImage> | PixelImage;
|
||||
generate?: (params: P) => Promise<PixelImage> | PixelImage;
|
||||
toText?: (img: PixelImage, params: P) => Promise<string> | string;
|
||||
runFromText?: (text: string, params: P) => Promise<PixelImage> | PixelImage;
|
||||
textToText?: (text: string) => Promise<string> | string;
|
||||
preview?: (img: PixelImage, params: P) => Promise<PixelImage> | PixelImage;
|
||||
popularity?: number;
|
||||
icon?: string;
|
||||
resultType?: "image" | "info" | "text";
|
||||
output?: OutputFormat;
|
||||
domOnly?: boolean;
|
||||
needsOverlaySource?: boolean;
|
||||
};
|
||||
|
||||
export const PNG_OUTPUT: OutputFormat = { mime: "image/png", ext: "png" };
|
||||
|
||||
export function isChainable(tool: ToolEntry): boolean {
|
||||
return (
|
||||
(tool.resultType ?? "image") === "image" &&
|
||||
(!tool.sourceMode || tool.sourceMode === "file")
|
||||
);
|
||||
}
|
||||
|
||||
const _convert = convertEntries();
|
||||
const _geometry = geometryEntries();
|
||||
const _alpha = alphaEntries();
|
||||
const _color = colorEntries();
|
||||
const _analyze = analyzeEntries();
|
||||
const _generate = generateEntries();
|
||||
const _text = textEntries();
|
||||
const _filters = filterEntries();
|
||||
|
||||
export const TOOLS: ToolEntry[] = [
|
||||
..._color.slice(0, 6),
|
||||
..._analyze.slice(0, 6),
|
||||
..._convert.slice(0, 16),
|
||||
..._geometry.slice(0, 8),
|
||||
..._alpha.slice(0, 4),
|
||||
..._geometry.slice(8, 9),
|
||||
..._filters.slice(0, 2),
|
||||
..._color.slice(6, 17),
|
||||
..._convert.slice(16, 18),
|
||||
..._alpha.slice(4, 20),
|
||||
..._analyze.slice(6, 9),
|
||||
..._generate,
|
||||
..._text,
|
||||
..._alpha.slice(20, 21),
|
||||
..._analyze.slice(9, 10),
|
||||
..._geometry.slice(9, 18),
|
||||
..._filters.slice(2, 8),
|
||||
..._color.slice(17, 25),
|
||||
..._convert.slice(18, 20),
|
||||
..._analyze.slice(10, 11),
|
||||
..._convert.slice(20, 21),
|
||||
..._analyze.slice(11, 13),
|
||||
];
|
||||
|
||||
import { TOOL_POPULARITY } from "./tools/tool-popularity";
|
||||
|
||||
for (const entry of TOOLS) {
|
||||
entry.icon = entry.id;
|
||||
entry.popularity = TOOL_POPULARITY[entry.id] ?? 50;
|
||||
}
|
||||
|
||||
export function getTool(id: string): ToolEntry | undefined {
|
||||
return TOOLS.find((tool) => tool.id === id);
|
||||
}
|
||||
|
||||
export function defaultParams(tool: ToolEntry): Record<string, unknown> {
|
||||
return Object.fromEntries(tool.params.map((p) => [p.id, p.default]));
|
||||
}
|
||||
|
||||
export function sanitizeParams(
|
||||
tool: ToolEntry,
|
||||
values: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const param of tool.params) {
|
||||
const raw = values[param.id];
|
||||
switch (param.type) {
|
||||
case "number":
|
||||
case "slider": {
|
||||
const n =
|
||||
typeof raw === "number" && Number.isFinite(raw) ? raw : param.default;
|
||||
out[param.id] = clampRange(n, param.min, param.max);
|
||||
break;
|
||||
}
|
||||
case "select":
|
||||
out[param.id] =
|
||||
typeof raw === "string" && param.options.some((o) => o.value === raw)
|
||||
? raw
|
||||
: param.default;
|
||||
break;
|
||||
case "checkbox":
|
||||
out[param.id] = typeof raw === "boolean" ? raw : param.default;
|
||||
break;
|
||||
case "color":
|
||||
out[param.id] =
|
||||
typeof raw === "string" && /^#[0-9a-f]{6}$/i.test(raw)
|
||||
? raw
|
||||
: param.default;
|
||||
break;
|
||||
case "text":
|
||||
out[param.id] = typeof raw === "string" ? raw : param.default;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function clampRange(value: number, min?: number, max?: number): number {
|
||||
if (min !== undefined && value < min) return min;
|
||||
if (max !== undefined && value > max) return max;
|
||||
return value;
|
||||
}
|
||||
|
||||
export function outputOf(tool: ToolEntry): OutputFormat | undefined {
|
||||
if (tool.resultType === "info") return undefined;
|
||||
return tool.output ?? PNG_OUTPUT;
|
||||
}
|
||||
@@ -1,674 +0,0 @@
|
||||
import type { ToolEntry } from "../registry";
|
||||
import { ToolError } from "../../core/errors";
|
||||
import {
|
||||
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 { featherAlpha, defringe } from "../../core/pixel-fx";
|
||||
import { drawImageWatermark } from "../../core/domText";
|
||||
import { getOverlay } from "../tools/overlay-store.svelte";
|
||||
import {
|
||||
renderShape,
|
||||
circleTest,
|
||||
boxTest,
|
||||
starTest,
|
||||
wavyTest,
|
||||
} from "../../core/shapes";
|
||||
import type { Position9 } from "../../core/textdraw";
|
||||
import { num, str } from "../registry-helpers";
|
||||
|
||||
export function alphaEntries(): ToolEntry[] {
|
||||
return [
|
||||
{
|
||||
id: "circle-mask-png",
|
||||
title: "Circle Mask PNG",
|
||||
description:
|
||||
"Cuts the image into a circle. Diameter is set as a share of the smaller side.",
|
||||
category: "alpha",
|
||||
params: [
|
||||
{
|
||||
id: "size",
|
||||
label: "Diameter, % of smaller side",
|
||||
type: "slider",
|
||||
min: 20,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 100,
|
||||
},
|
||||
{
|
||||
id: "offsetX",
|
||||
label: "Offset X, %",
|
||||
type: "slider",
|
||||
min: -50,
|
||||
max: 50,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
{
|
||||
id: "offsetY",
|
||||
label: "Offset Y, %",
|
||||
type: "slider",
|
||||
min: -50,
|
||||
max: 50,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
],
|
||||
run: (img, p) =>
|
||||
renderShape(
|
||||
img,
|
||||
circleTest(num(p, "size") / 200),
|
||||
num(p, "offsetX") / 100,
|
||||
num(p, "offsetY") / 100,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "square-mask-png",
|
||||
title: "Square Mask PNG",
|
||||
description:
|
||||
"Cuts the image into a rectangle with sides as a share of the smaller side.",
|
||||
category: "alpha",
|
||||
params: [
|
||||
{
|
||||
id: "widthPct",
|
||||
label: "Width, % of smaller side",
|
||||
type: "slider",
|
||||
min: 10,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 100,
|
||||
},
|
||||
{
|
||||
id: "heightPct",
|
||||
label: "Height, % of smaller side",
|
||||
type: "slider",
|
||||
min: 10,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 100,
|
||||
},
|
||||
{
|
||||
id: "offsetX",
|
||||
label: "Offset X, %",
|
||||
type: "slider",
|
||||
min: -50,
|
||||
max: 50,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
{
|
||||
id: "offsetY",
|
||||
label: "Offset Y, %",
|
||||
type: "slider",
|
||||
min: -50,
|
||||
max: 50,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
],
|
||||
run: (img, p) =>
|
||||
renderShape(
|
||||
img,
|
||||
boxTest(num(p, "widthPct") / 200, num(p, "heightPct") / 200),
|
||||
num(p, "offsetX") / 100,
|
||||
num(p, "offsetY") / 100,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "star-mask-png",
|
||||
title: "Star Mask PNG",
|
||||
description:
|
||||
"Cuts the image into an n-pointed star with adjustable inner radius and rotation.",
|
||||
category: "alpha",
|
||||
params: [
|
||||
{
|
||||
id: "points",
|
||||
label: "Points",
|
||||
type: "slider",
|
||||
min: 3,
|
||||
max: 12,
|
||||
step: 1,
|
||||
default: 5,
|
||||
},
|
||||
{
|
||||
id: "innerRadius",
|
||||
label: "Inner radius, %",
|
||||
type: "slider",
|
||||
min: 10,
|
||||
max: 90,
|
||||
step: 1,
|
||||
default: 45,
|
||||
},
|
||||
{
|
||||
id: "size",
|
||||
label: "Outer radius, % of smaller side",
|
||||
type: "slider",
|
||||
min: 20,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 100,
|
||||
},
|
||||
{
|
||||
id: "rotation",
|
||||
label: "Rotation, °",
|
||||
type: "slider",
|
||||
min: -180,
|
||||
max: 180,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
{
|
||||
id: "offsetX",
|
||||
label: "Offset X, %",
|
||||
type: "slider",
|
||||
min: -50,
|
||||
max: 50,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
{
|
||||
id: "offsetY",
|
||||
label: "Offset Y, %",
|
||||
type: "slider",
|
||||
min: -50,
|
||||
max: 50,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
],
|
||||
run: (img, p) =>
|
||||
renderShape(
|
||||
img,
|
||||
starTest(
|
||||
num(p, "points"),
|
||||
num(p, "innerRadius") / 100,
|
||||
num(p, "size") / 200,
|
||||
num(p, "rotation"),
|
||||
),
|
||||
num(p, "offsetX") / 100,
|
||||
num(p, "offsetY") / 100,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "wavy-mask-png",
|
||||
title: "Wavy Mask PNG",
|
||||
description:
|
||||
"Cuts the image into a wavy-edged circle: radius is modulated by a sine with chosen amplitude and frequency.",
|
||||
category: "alpha",
|
||||
params: [
|
||||
{
|
||||
id: "size",
|
||||
label: "Base radius, % of smaller side",
|
||||
type: "slider",
|
||||
min: 20,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 90,
|
||||
},
|
||||
{
|
||||
id: "amplitude",
|
||||
label: "Wave amplitude, %",
|
||||
type: "slider",
|
||||
min: 2,
|
||||
max: 30,
|
||||
step: 1,
|
||||
default: 8,
|
||||
},
|
||||
{
|
||||
id: "waves",
|
||||
label: "Waves count",
|
||||
type: "slider",
|
||||
min: 3,
|
||||
max: 24,
|
||||
step: 1,
|
||||
default: 8,
|
||||
},
|
||||
{
|
||||
id: "phase",
|
||||
label: "Phase, °",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 360,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
{
|
||||
id: "offsetX",
|
||||
label: "Offset X, %",
|
||||
type: "slider",
|
||||
min: -50,
|
||||
max: 50,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
{
|
||||
id: "offsetY",
|
||||
label: "Offset Y, %",
|
||||
type: "slider",
|
||||
min: -50,
|
||||
max: 50,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
],
|
||||
run: (img, p) =>
|
||||
renderShape(
|
||||
img,
|
||||
wavyTest(
|
||||
num(p, "size") / 200,
|
||||
num(p, "amplitude") / 200,
|
||||
num(p, "waves"),
|
||||
num(p, "phase"),
|
||||
),
|
||||
num(p, "offsetX") / 100,
|
||||
num(p, "offsetY") / 100,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "remove-alpha-channel-png",
|
||||
title: "Remove alpha channel PNG",
|
||||
description:
|
||||
"Composites the image over a white background and saves without transparency.",
|
||||
category: "alpha",
|
||||
params: [],
|
||||
run: (img) => flattenOntoColor(img, "#ffffff"),
|
||||
},
|
||||
{
|
||||
id: "set-alpha-channel-png",
|
||||
title: "Set alpha channel PNG",
|
||||
description:
|
||||
"Assigns the same opacity to all pixels; colors stay unchanged.",
|
||||
category: "alpha",
|
||||
params: [
|
||||
{
|
||||
id: "percent",
|
||||
label: "Opacity, %",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 100,
|
||||
},
|
||||
],
|
||||
run: (img, p) => setAlphaChannel(img, num(p, "percent")),
|
||||
},
|
||||
{
|
||||
id: "extract-alpha-mask-png",
|
||||
title: "Extract alpha mask PNG",
|
||||
description: "Turns transparency into a black-and-white opaque mask.",
|
||||
category: "alpha",
|
||||
params: [],
|
||||
run: (img) => extractAlphaMask(img),
|
||||
},
|
||||
{
|
||||
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",
|
||||
params: [
|
||||
{
|
||||
id: "radius",
|
||||
label: "Corner radius, %",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 50,
|
||||
step: 1,
|
||||
default: 10,
|
||||
},
|
||||
],
|
||||
run: (img, p) => roundCorners(img, num(p, "radius")),
|
||||
},
|
||||
{
|
||||
id: "invert-alpha-png",
|
||||
title: "Invert alpha PNG",
|
||||
description: "Opaque areas become transparent and vice versa.",
|
||||
category: "alpha",
|
||||
params: [],
|
||||
run: (img) => invertAlpha(img),
|
||||
},
|
||||
{
|
||||
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",
|
||||
params: [
|
||||
{
|
||||
id: "color",
|
||||
label: "Background color",
|
||||
type: "color",
|
||||
default: "#ffffff",
|
||||
},
|
||||
{
|
||||
id: "tolerance",
|
||||
label: "Similarity tolerance, %",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 10,
|
||||
},
|
||||
{
|
||||
id: "outerOnly",
|
||||
label: "Outer regions only",
|
||||
type: "checkbox",
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
id: "smooth",
|
||||
label: "Edge smoothing, passes",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 8,
|
||||
step: 1,
|
||||
default: 1,
|
||||
},
|
||||
],
|
||||
run: (img, p) =>
|
||||
removeBackground(img, {
|
||||
color: str(p, "color"),
|
||||
tolerancePercent: num(p, "tolerance"),
|
||||
outerOnly: p["outerOnly"] === true,
|
||||
smoothPasses: num(p, "smooth"),
|
||||
}),
|
||||
preview: (img, p) =>
|
||||
backgroundMaskPreview(img, {
|
||||
color: str(p, "color"),
|
||||
tolerancePercent: num(p, "tolerance"),
|
||||
outerOnly: p["outerOnly"] === true,
|
||||
smoothPasses: num(p, "smooth"),
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "add-stroke-png",
|
||||
title: "Outline PNG",
|
||||
description:
|
||||
"Adds a colored ring outline around the opaque content with the chosen thickness.",
|
||||
category: "alpha",
|
||||
params: [
|
||||
{
|
||||
id: "color",
|
||||
label: "Outline color",
|
||||
type: "color",
|
||||
default: "#ff0000",
|
||||
},
|
||||
{
|
||||
id: "thickness",
|
||||
label: "Thickness, px",
|
||||
type: "slider",
|
||||
min: 1,
|
||||
max: 10,
|
||||
step: 1,
|
||||
default: 3,
|
||||
},
|
||||
],
|
||||
run: (img, p) => strokeImage(img, num(p, "thickness"), str(p, "color")),
|
||||
},
|
||||
{
|
||||
id: "find-contour-png",
|
||||
title: "Find contour PNG",
|
||||
description:
|
||||
"Leaves only a line along the boundary of opaque regions in the chosen color and thickness.",
|
||||
category: "alpha",
|
||||
params: [
|
||||
{
|
||||
id: "color",
|
||||
label: "Line color",
|
||||
type: "color",
|
||||
default: "#000000",
|
||||
},
|
||||
{
|
||||
id: "thickness",
|
||||
label: "Line thickness, px",
|
||||
type: "slider",
|
||||
min: 1,
|
||||
max: 5,
|
||||
step: 1,
|
||||
default: 1,
|
||||
},
|
||||
],
|
||||
run: (img, p) => contourImage(img, num(p, "thickness"), str(p, "color")),
|
||||
},
|
||||
{
|
||||
id: "make-thicker-png",
|
||||
title: "Thicken PNG",
|
||||
description: "Expands opaque areas by the given number of pixels.",
|
||||
category: "alpha",
|
||||
params: [
|
||||
{
|
||||
id: "radius",
|
||||
label: "Amount, px",
|
||||
type: "slider",
|
||||
min: 1,
|
||||
max: 10,
|
||||
step: 1,
|
||||
default: 2,
|
||||
},
|
||||
],
|
||||
run: (img, p) => dilateImage(img, num(p, "radius")),
|
||||
},
|
||||
{
|
||||
id: "make-thinner-png",
|
||||
title: "Thin PNG",
|
||||
description:
|
||||
"Shrinks opaque areas — thins the strokes of text and details.",
|
||||
category: "alpha",
|
||||
params: [
|
||||
{
|
||||
id: "radius",
|
||||
label: "Amount, px",
|
||||
type: "slider",
|
||||
min: 1,
|
||||
max: 10,
|
||||
step: 1,
|
||||
default: 1,
|
||||
},
|
||||
],
|
||||
run: (img, p) => erodeImage(img, num(p, "radius")),
|
||||
},
|
||||
{
|
||||
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",
|
||||
params: [
|
||||
{
|
||||
id: "radius",
|
||||
label: "Feather radius, px",
|
||||
type: "slider",
|
||||
min: 1,
|
||||
max: 20,
|
||||
step: 1,
|
||||
default: 3,
|
||||
},
|
||||
],
|
||||
run: (img, p) => featherAlpha(img, num(p, "radius")),
|
||||
},
|
||||
{
|
||||
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",
|
||||
params: [
|
||||
{
|
||||
id: "radius",
|
||||
label: "Search radius, px",
|
||||
type: "slider",
|
||||
min: 1,
|
||||
max: 10,
|
||||
step: 1,
|
||||
default: 3,
|
||||
},
|
||||
],
|
||||
run: (img, p) => defringe(img, num(p, "radius")),
|
||||
},
|
||||
{
|
||||
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",
|
||||
params: [
|
||||
{
|
||||
id: "threshold",
|
||||
label: "Alpha threshold, %",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 50,
|
||||
},
|
||||
],
|
||||
run: (img, p) => hardenAlpha(img, num(p, "threshold")),
|
||||
},
|
||||
{
|
||||
id: "despeckle-alpha-png",
|
||||
title: "Despeckle PNG",
|
||||
description:
|
||||
"Opening: removes lone semi-transparent pixels and small specks.",
|
||||
category: "alpha",
|
||||
params: [
|
||||
{
|
||||
id: "radius",
|
||||
label: "Cleanup radius, px",
|
||||
type: "slider",
|
||||
min: 1,
|
||||
max: 3,
|
||||
step: 1,
|
||||
default: 1,
|
||||
},
|
||||
],
|
||||
run: (img, p) => openingImage(img, num(p, "radius")),
|
||||
},
|
||||
{
|
||||
id: "close-holes-png",
|
||||
title: "Close holes PNG",
|
||||
description: "Closing: fills lone transparent dots inside the object.",
|
||||
category: "alpha",
|
||||
params: [
|
||||
{
|
||||
id: "radius",
|
||||
label: "Closing radius, px",
|
||||
type: "slider",
|
||||
min: 1,
|
||||
max: 3,
|
||||
step: 1,
|
||||
default: 1,
|
||||
},
|
||||
],
|
||||
run: (img, p) => closingImage(img, num(p, "radius")),
|
||||
},
|
||||
{
|
||||
id: "remove-color-from-png",
|
||||
title: "Remove color from PNG (make transparent)",
|
||||
description:
|
||||
"Makes all pixels close to the chosen color transparent. The tolerance sets the allowed deviation as a percentage of the maximum color distance.",
|
||||
category: "alpha",
|
||||
params: [
|
||||
{
|
||||
id: "targetColor",
|
||||
label: "Color to remove",
|
||||
type: "color",
|
||||
default: "#00ff00",
|
||||
},
|
||||
{
|
||||
id: "tolerance",
|
||||
label: "Similarity threshold, %",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 10,
|
||||
},
|
||||
],
|
||||
run: (img, p) =>
|
||||
removeColorToAlpha(img, str(p, "targetColor"), num(p, "tolerance")),
|
||||
preview: (img, p) =>
|
||||
colorMask(img, str(p, "targetColor"), num(p, "tolerance")),
|
||||
},
|
||||
{
|
||||
id: "watermark-image-png",
|
||||
title: "Watermark Image PNG",
|
||||
description:
|
||||
"Overlays another PNG (logo/signature) on top: scale from canvas width, opacity, 3×3 position. The mark lives only while the page is open — after restoring a chain, pick it again.",
|
||||
category: "alpha",
|
||||
domOnly: true,
|
||||
needsOverlaySource: true,
|
||||
params: [
|
||||
{
|
||||
id: "scale",
|
||||
label: "Mark width, % of canvas",
|
||||
type: "slider",
|
||||
min: 5,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 30,
|
||||
},
|
||||
{
|
||||
id: "opacity",
|
||||
label: "Opacity, %",
|
||||
type: "slider",
|
||||
min: 5,
|
||||
max: 100,
|
||||
step: 5,
|
||||
default: 60,
|
||||
},
|
||||
{
|
||||
id: "position",
|
||||
label: "Position",
|
||||
type: "select",
|
||||
default: "bottom-right",
|
||||
options: [
|
||||
{ value: "top-left", label: "Top left" },
|
||||
{ value: "top-center", label: "Top center" },
|
||||
{ value: "top-right", label: "Top right" },
|
||||
{ value: "middle-left", label: "Middle left" },
|
||||
{ value: "center", label: "Center" },
|
||||
{ value: "middle-right", label: "Middle right" },
|
||||
{ value: "bottom-left", label: "Bottom left" },
|
||||
{ value: "bottom-center", label: "Bottom center" },
|
||||
{ value: "bottom-right", label: "Bottom right" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "margin",
|
||||
label: "Margin, px",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 200,
|
||||
step: 1,
|
||||
default: 24,
|
||||
},
|
||||
],
|
||||
run: (img, p) => {
|
||||
const mark = getOverlay();
|
||||
if (!mark) throw new ToolError("errors.noWatermark");
|
||||
return drawImageWatermark(img, {
|
||||
mark,
|
||||
scalePercent: num(p, "scale"),
|
||||
opacityPercent: num(p, "opacity"),
|
||||
position: str(p, "position") as Position9,
|
||||
margin: num(p, "margin"),
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
import type { ToolEntry } from "../registry";
|
||||
import {
|
||||
extractByColor,
|
||||
isGrayscaleish,
|
||||
luma01,
|
||||
rarityPredicate,
|
||||
renderPredicateMask,
|
||||
} from "../../core/masks";
|
||||
import {
|
||||
hasTransparency,
|
||||
isGrayscale,
|
||||
orientationOf,
|
||||
} from "../../core/analyze";
|
||||
import { looksLikePng, base64ToBytes, stripDataUri } from "../../core/textio";
|
||||
import { clonePixelImage, type PixelImage } from "../../core/types";
|
||||
import { encode } from "../../core/io";
|
||||
import { t } from "../i18n/t";
|
||||
import { num, str } from "../registry-helpers";
|
||||
|
||||
type MaskToolSpec = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
defaultMode: "binary" | "highlight";
|
||||
predicate: (
|
||||
img: PixelImage,
|
||||
p: Record<string, unknown>,
|
||||
) => (r: number, g: number, b: number, a: number) => boolean;
|
||||
extraParams?: ToolEntry["params"];
|
||||
};
|
||||
|
||||
const MASK_TOOLS: MaskToolSpec[] = [
|
||||
{
|
||||
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.",
|
||||
defaultMode: "highlight",
|
||||
predicate: (_img, _p) => (_r, _g, _b, a) => a < 255,
|
||||
},
|
||||
{
|
||||
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.",
|
||||
defaultMode: "binary",
|
||||
predicate: (_i, p) => (r, g, b) =>
|
||||
isGrayscaleish(r, g, b, num(p, "tolerance")),
|
||||
extraParams: [
|
||||
{
|
||||
id: "tolerance",
|
||||
label: "Channel tolerance",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 64,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
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.",
|
||||
defaultMode: "binary",
|
||||
predicate: (_i, p) => (r, g, b) =>
|
||||
!isGrayscaleish(r, g, b, num(p, "tolerance")),
|
||||
extraParams: [
|
||||
{
|
||||
id: "tolerance",
|
||||
label: "Channel tolerance",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 64,
|
||||
step: 1,
|
||||
default: 8,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "light-pixel-mask-png",
|
||||
title: "Light Pixel Mask PNG",
|
||||
description: "Selects pixels brighter than the luminance threshold.",
|
||||
defaultMode: "binary",
|
||||
predicate: (_i, p) => (r, g, b) =>
|
||||
luma01(r, g, b) >= num(p, "threshold") / 100,
|
||||
extraParams: [
|
||||
{
|
||||
id: "threshold",
|
||||
label: "Luminance threshold, %",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 70,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "dark-pixel-mask-png",
|
||||
title: "Dark Pixel Mask PNG",
|
||||
description: "Selects pixels darker than the luminance threshold.",
|
||||
defaultMode: "binary",
|
||||
predicate: (_i, p) => (r, g, b) =>
|
||||
luma01(r, g, b) <= num(p, "threshold") / 100,
|
||||
extraParams: [
|
||||
{
|
||||
id: "threshold",
|
||||
label: "Luminance threshold, %",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 30,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
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.",
|
||||
defaultMode: "binary",
|
||||
predicate: (img, p) => rarityPredicate(img, num(p, "rarity")),
|
||||
extraParams: [
|
||||
{
|
||||
id: "rarity",
|
||||
label: "Max occurrences",
|
||||
type: "slider",
|
||||
min: 1,
|
||||
max: 50,
|
||||
step: 1,
|
||||
default: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function maskEntries(): ToolEntry[] {
|
||||
return MASK_TOOLS.map((spec) => ({
|
||||
id: spec.id,
|
||||
title: spec.title,
|
||||
description: spec.description,
|
||||
category: "analyze" as const,
|
||||
params: [
|
||||
...(spec.extraParams ?? []),
|
||||
{
|
||||
id: "mode",
|
||||
label: "Mask mode",
|
||||
type: "select" as const,
|
||||
default: spec.defaultMode,
|
||||
options: [
|
||||
{ value: "binary", label: "Black & white mask" },
|
||||
{ value: "highlight", label: "Color highlight" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "color",
|
||||
label: "Highlight color",
|
||||
type: "color",
|
||||
default: "#ff00aa",
|
||||
},
|
||||
{
|
||||
id: "opacity",
|
||||
label: "Highlight opacity, %",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 5,
|
||||
default: 70,
|
||||
},
|
||||
],
|
||||
run: (img, p) =>
|
||||
renderPredicateMask(img, spec.predicate(img, p), {
|
||||
mode: str(p, "mode") === "highlight" ? "highlight" : "binary",
|
||||
color: str(p, "color"),
|
||||
opacityPercent: num(p, "opacity"),
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
export function analyzeEntries(): ToolEntry[] {
|
||||
return [
|
||||
...maskEntries(),
|
||||
{
|
||||
id: "extract-color-from-png",
|
||||
title: "Extract Color from PNG",
|
||||
description:
|
||||
"Keeps only pixels close to the chosen color and makes everything else transparent — the inverse of Remove Color.",
|
||||
category: "analyze",
|
||||
params: [
|
||||
{
|
||||
id: "color",
|
||||
label: "Color to keep",
|
||||
type: "color",
|
||||
default: "#00ff88",
|
||||
},
|
||||
{
|
||||
id: "tolerance",
|
||||
label: "Similarity tolerance, %",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 50,
|
||||
step: 1,
|
||||
default: 10,
|
||||
},
|
||||
],
|
||||
run: (img, p) =>
|
||||
extractByColor(img, str(p, "color"), num(p, "tolerance")),
|
||||
},
|
||||
{
|
||||
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",
|
||||
sourceMode: "text",
|
||||
params: [],
|
||||
resultType: "text",
|
||||
textToText: (text) =>
|
||||
looksLikePng(base64ToBytes(stripDataUri(text)))
|
||||
? "verifyYes"
|
||||
: "verifyNo",
|
||||
},
|
||||
{
|
||||
id: "png-info",
|
||||
title: "PNG info",
|
||||
description:
|
||||
"Shows dimensions, alpha presence and the number of unique colors of the uploaded image.",
|
||||
category: "analyze",
|
||||
params: [],
|
||||
resultType: "info",
|
||||
run: (img) => clonePixelImage(img),
|
||||
},
|
||||
{
|
||||
id: "png-is-grayscale",
|
||||
title: "Check: is PNG grayscale?",
|
||||
description: "Reports whether the image consists only of shades of gray.",
|
||||
category: "analyze",
|
||||
params: [],
|
||||
resultType: "text",
|
||||
toText: (img) => (isGrayscale(img) ? "grayscaleYes" : "grayscaleNo"),
|
||||
},
|
||||
{
|
||||
id: "png-file-size",
|
||||
title: "PNG File Size",
|
||||
description:
|
||||
"Encodes the image as PNG and reports the resulting file size.",
|
||||
category: "analyze",
|
||||
domOnly: true,
|
||||
params: [],
|
||||
resultType: "text",
|
||||
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 t("tools.png-file-size.results.line", {
|
||||
kb: kbText,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "png-is-transparent",
|
||||
title: "Check: is PNG transparent?",
|
||||
description:
|
||||
"Reports whether the image contains transparent or semi-transparent pixels.",
|
||||
category: "analyze",
|
||||
params: [],
|
||||
resultType: "text",
|
||||
toText: (img) =>
|
||||
hasTransparency(img) ? "transparentYes" : "transparentNo",
|
||||
},
|
||||
{
|
||||
id: "png-orientation",
|
||||
title: "PNG orientation",
|
||||
description: "Reports whether it is portrait, landscape or square.",
|
||||
category: "analyze",
|
||||
params: [],
|
||||
resultType: "text",
|
||||
toText: (img) => {
|
||||
switch (orientationOf(img)) {
|
||||
case "portrait":
|
||||
return "orientationPortrait";
|
||||
case "landscape":
|
||||
return "orientationLandscape";
|
||||
default:
|
||||
return "orientationSquare";
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,501 +0,0 @@
|
||||
import type { ToolEntry } from "../registry";
|
||||
import {
|
||||
autoContrast,
|
||||
brightnessContrast,
|
||||
changeHue,
|
||||
extractChannel,
|
||||
gammaCorrection,
|
||||
grayscale,
|
||||
invert,
|
||||
posterize,
|
||||
sepia,
|
||||
setOpacity,
|
||||
swapChannels,
|
||||
temperature,
|
||||
tint,
|
||||
thresholdBlackWhite,
|
||||
twoColors,
|
||||
type ChannelSwapPair,
|
||||
type RgbChannel,
|
||||
} from "../../core/color";
|
||||
import { renderSpace, SPACES, type SpaceId } from "../../core/channels";
|
||||
import { quantizeImage, ditherImage, mapToNearest } from "../../core/quantize";
|
||||
import { parseHexList } from "../../core/palette";
|
||||
import { num, str } from "../registry-helpers";
|
||||
|
||||
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[] {
|
||||
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" as const,
|
||||
params: [
|
||||
{
|
||||
id: "component",
|
||||
label: "Component",
|
||||
type: "select" as const,
|
||||
default: components[0],
|
||||
options: components.map((c) => ({
|
||||
value: c,
|
||||
label: c.toUpperCase(),
|
||||
})),
|
||||
},
|
||||
{
|
||||
id: "display",
|
||||
label: "Display mode",
|
||||
type: "select" as const,
|
||||
default: "gray",
|
||||
options: [
|
||||
{ value: "gray", label: "Grayscale" },
|
||||
{ value: "color", label: "Space as RGB" },
|
||||
],
|
||||
},
|
||||
],
|
||||
run: (img, p) =>
|
||||
renderSpace(
|
||||
img,
|
||||
space.id,
|
||||
str(p, "component"),
|
||||
str(p, "display") === "color" ? "color" : "gray",
|
||||
),
|
||||
} satisfies ToolEntry;
|
||||
});
|
||||
}
|
||||
|
||||
export function colorEntries(): ToolEntry[] {
|
||||
return [
|
||||
...channelEntries(),
|
||||
{
|
||||
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",
|
||||
params: [],
|
||||
run: (img) => grayscale(img),
|
||||
},
|
||||
{
|
||||
id: "invert-colors-png",
|
||||
title: "Invert colors PNG",
|
||||
description:
|
||||
"Inverts each color channel (255 − value). Alpha is unchanged.",
|
||||
category: "color",
|
||||
params: [],
|
||||
run: (img) => invert(img),
|
||||
},
|
||||
{
|
||||
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",
|
||||
params: [
|
||||
{
|
||||
id: "brightness",
|
||||
label: "Brightness",
|
||||
type: "slider",
|
||||
min: -100,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
{
|
||||
id: "contrast",
|
||||
label: "Contrast",
|
||||
type: "slider",
|
||||
min: -100,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
],
|
||||
run: (img, p) =>
|
||||
brightnessContrast(img, num(p, "brightness"), num(p, "contrast")),
|
||||
},
|
||||
{
|
||||
id: "change-png-opacity",
|
||||
title: "Change PNG opacity",
|
||||
description:
|
||||
"Multiplies the alpha channel by a percentage: 0% — fully transparent, 100% — unchanged.",
|
||||
category: "color",
|
||||
params: [
|
||||
{
|
||||
id: "percent",
|
||||
label: "Opacity, %",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 100,
|
||||
},
|
||||
],
|
||||
run: (img, p) => setOpacity(img, num(p, "percent")),
|
||||
},
|
||||
{
|
||||
id: "sepia-png",
|
||||
title: "Sepia effect",
|
||||
description:
|
||||
"Tints the image into the warm brown tones of classic sepia.",
|
||||
category: "color",
|
||||
params: [],
|
||||
run: (img) => sepia(img),
|
||||
},
|
||||
{
|
||||
id: "change-png-hue",
|
||||
title: "Change hue PNG",
|
||||
description:
|
||||
"Shifts the hue around the circle. Saturation and lightness are preserved.",
|
||||
category: "color",
|
||||
params: [
|
||||
{
|
||||
id: "degrees",
|
||||
label: "Hue shift, °",
|
||||
type: "slider",
|
||||
min: -180,
|
||||
max: 180,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
],
|
||||
run: (img, p) => changeHue(img, num(p, "degrees")),
|
||||
},
|
||||
{
|
||||
id: "extract-channel-png",
|
||||
title: "Extract channel PNG",
|
||||
description:
|
||||
"Keeps only the chosen channel — red, green or blue — as shades of gray.",
|
||||
category: "color",
|
||||
params: [
|
||||
{
|
||||
id: "channel",
|
||||
label: "Channel",
|
||||
type: "select",
|
||||
default: "red",
|
||||
options: [
|
||||
{ value: "red", label: "Red" },
|
||||
{ value: "green", label: "Green" },
|
||||
{ value: "blue", label: "Blue" },
|
||||
],
|
||||
},
|
||||
],
|
||||
run: (img, p) => extractChannel(img, str(p, "channel") as RgbChannel),
|
||||
},
|
||||
{
|
||||
id: "swap-channels-png",
|
||||
title: "Swap channels PNG",
|
||||
description:
|
||||
"Swaps two color channels — a quick way to get unusual coloring.",
|
||||
category: "color",
|
||||
params: [
|
||||
{
|
||||
id: "pair",
|
||||
label: "Channel pair",
|
||||
type: "select",
|
||||
default: "r-g",
|
||||
options: [
|
||||
{ value: "r-g", label: "Red ↔ Green" },
|
||||
{ value: "r-b", label: "Red ↔ Blue" },
|
||||
{ value: "g-b", label: "Green ↔ Blue" },
|
||||
],
|
||||
},
|
||||
],
|
||||
run: (img, p) => swapChannels(img, str(p, "pair") as ChannelSwapPair),
|
||||
},
|
||||
{
|
||||
id: "black-and-white-png",
|
||||
title: "Black & white threshold PNG",
|
||||
description:
|
||||
"Hard binarization by luminance: every pixel becomes black or white.",
|
||||
category: "color",
|
||||
params: [
|
||||
{
|
||||
id: "threshold",
|
||||
label: "Brightness threshold, %",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 50,
|
||||
},
|
||||
],
|
||||
run: (img, p) => thresholdBlackWhite(img, num(p, "threshold")),
|
||||
},
|
||||
{
|
||||
id: "posterize-png",
|
||||
title: "Posterize PNG",
|
||||
description:
|
||||
"Reduces the number of levels per channel — a poster effect.",
|
||||
category: "color",
|
||||
params: [
|
||||
{
|
||||
id: "levels",
|
||||
label: "Levels per channel",
|
||||
type: "slider",
|
||||
min: 2,
|
||||
max: 16,
|
||||
step: 1,
|
||||
default: 4,
|
||||
},
|
||||
],
|
||||
run: (img, p) => posterize(img, num(p, "levels")),
|
||||
},
|
||||
{
|
||||
id: "two-colors-png",
|
||||
title: "Two colors PNG",
|
||||
description:
|
||||
"Recolors the image into two chosen colors by luminance threshold.",
|
||||
category: "color",
|
||||
params: [
|
||||
{
|
||||
id: "lightColor",
|
||||
label: "Light areas color",
|
||||
type: "color",
|
||||
default: "#ffffff",
|
||||
},
|
||||
{
|
||||
id: "darkColor",
|
||||
label: "Dark areas color",
|
||||
type: "color",
|
||||
default: "#000000",
|
||||
},
|
||||
{
|
||||
id: "threshold",
|
||||
label: "Brightness threshold, %",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 50,
|
||||
},
|
||||
],
|
||||
run: (img, p) =>
|
||||
twoColors(
|
||||
img,
|
||||
str(p, "lightColor"),
|
||||
str(p, "darkColor"),
|
||||
num(p, "threshold"),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "gamma-png",
|
||||
title: "Gamma correction PNG",
|
||||
description:
|
||||
"Corrects midtone brightness. <1 darker, >1 lighter, 1 — unchanged.",
|
||||
category: "color",
|
||||
params: [
|
||||
{
|
||||
id: "value",
|
||||
label: "Gamma",
|
||||
type: "slider",
|
||||
min: 0.1,
|
||||
max: 3,
|
||||
step: 0.05,
|
||||
default: 1,
|
||||
},
|
||||
],
|
||||
run: (img, p) => gammaCorrection(img, num(p, "value")),
|
||||
},
|
||||
{
|
||||
id: "auto-contrast-png",
|
||||
title: "Auto contrast PNG",
|
||||
description:
|
||||
"Stretches each channel's range across the full available brightness range.",
|
||||
category: "color",
|
||||
params: [],
|
||||
run: (img) => autoContrast(img),
|
||||
},
|
||||
{
|
||||
id: "temperature-png",
|
||||
title: "Temperature PNG",
|
||||
description:
|
||||
"Positive values make the image warmer (more orange), negative ones cooler (more blue).",
|
||||
category: "color",
|
||||
params: [
|
||||
{
|
||||
id: "percent",
|
||||
label: "Temperature",
|
||||
type: "slider",
|
||||
min: -100,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
],
|
||||
run: (img, p) => temperature(img, num(p, "percent")),
|
||||
},
|
||||
{
|
||||
id: "tint-png",
|
||||
title: "Tint PNG",
|
||||
description:
|
||||
"Multiplies color channels by the chosen tint with the given strength.",
|
||||
category: "color",
|
||||
params: [
|
||||
{
|
||||
id: "color",
|
||||
label: "Tint color",
|
||||
type: "color",
|
||||
default: "#ffb060",
|
||||
},
|
||||
{
|
||||
id: "strength",
|
||||
label: "Strength, %",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 30,
|
||||
},
|
||||
],
|
||||
run: (img, p) => tint(img, str(p, "color"), num(p, "strength")),
|
||||
},
|
||||
{
|
||||
id: "quantize-png",
|
||||
title: "Quantize PNG",
|
||||
description:
|
||||
"Reduces the image to k colors via median-cut palette. Transparent pixels are preserved.",
|
||||
category: "color",
|
||||
params: [
|
||||
{
|
||||
id: "colors",
|
||||
label: "Colors (k)",
|
||||
type: "slider",
|
||||
min: 2,
|
||||
max: 64,
|
||||
step: 1,
|
||||
default: 16,
|
||||
},
|
||||
],
|
||||
run: (img, p) => quantizeImage(img, num(p, "colors")).image,
|
||||
},
|
||||
{
|
||||
id: "decrease-color-count-png",
|
||||
title: "Decrease Color Count PNG",
|
||||
description:
|
||||
"Same median-cut engine with fixed power-of-two presets — quick way to drop to 2–256 colors.",
|
||||
category: "color",
|
||||
params: [
|
||||
{
|
||||
id: "maxColors",
|
||||
label: "Max colors",
|
||||
type: "select",
|
||||
default: "16",
|
||||
options: [
|
||||
{ value: "2", label: "2" },
|
||||
{ value: "4", label: "4" },
|
||||
{ value: "8", label: "8" },
|
||||
{ value: "16", label: "16" },
|
||||
{ value: "32", label: "32" },
|
||||
{ value: "64", label: "64" },
|
||||
{ value: "128", label: "128" },
|
||||
{ value: "256", label: "256" },
|
||||
],
|
||||
},
|
||||
],
|
||||
run: (img, p) => quantizeImage(img, num(p, "maxColors")).image,
|
||||
},
|
||||
{
|
||||
id: "custom-palette-png",
|
||||
title: "Custom Palette PNG",
|
||||
description:
|
||||
"Maps every pixel to the nearest color from your comma-separated hex list.",
|
||||
category: "color",
|
||||
params: [
|
||||
{
|
||||
id: "colors",
|
||||
label: "Palette (comma-separated hex)",
|
||||
type: "text",
|
||||
default: "#000000,#ffffff",
|
||||
},
|
||||
],
|
||||
run: (img, p) => mapToNearest(img, parseHexList(str(p, "colors"))),
|
||||
},
|
||||
{
|
||||
id: "dithering-png",
|
||||
title: "Dithering PNG",
|
||||
description:
|
||||
"Applies Floyd–Steinberg error diffusion or ordered Bayer dithering while reducing to k colors.",
|
||||
category: "color",
|
||||
params: [
|
||||
{
|
||||
id: "colors",
|
||||
label: "Colors (k)",
|
||||
type: "slider",
|
||||
min: 2,
|
||||
max: 16,
|
||||
step: 1,
|
||||
default: 4,
|
||||
},
|
||||
{
|
||||
id: "pattern",
|
||||
label: "Pattern",
|
||||
type: "select",
|
||||
default: "floyd-steinberg",
|
||||
options: [
|
||||
{ value: "floyd-steinberg", label: "Floyd–Steinberg" },
|
||||
{ value: "bayer", label: "Bayer 4×4" },
|
||||
],
|
||||
},
|
||||
],
|
||||
run: (img, p) =>
|
||||
ditherImage(
|
||||
img,
|
||||
num(p, "colors"),
|
||||
str(p, "pattern") === "bayer" ? "bayer" : "floyd-steinberg",
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,354 +0,0 @@
|
||||
import type { ToolEntry } from "../registry";
|
||||
import { clonePixelImage } from "../../core/types";
|
||||
import { flattenOntoColor } from "../../core/alpha";
|
||||
import { toBase64, toDataUrl, decodeSvgText, encode } from "../../core/io";
|
||||
import { pixelsToHex, hexToPixels } from "../../core/text";
|
||||
import {
|
||||
imageToByteRows,
|
||||
bytesToImage,
|
||||
imageToRgbValues,
|
||||
rgbValuesToImage,
|
||||
} from "../../core/textio";
|
||||
import {
|
||||
COMPRESSION_LEVELS,
|
||||
findMaxColorsWithin,
|
||||
type CompressionLevel,
|
||||
} from "../../core/compress";
|
||||
import { quantizeImage } from "../../core/quantize";
|
||||
import { num, str } from "../registry-helpers";
|
||||
|
||||
function decodeToPng(
|
||||
id: string,
|
||||
title: string,
|
||||
description: string,
|
||||
): ToolEntry {
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
category: "convert",
|
||||
params: [],
|
||||
run: (img) => clonePixelImage(img),
|
||||
};
|
||||
}
|
||||
|
||||
export function convertEntries(): ToolEntry[] {
|
||||
return [
|
||||
decodeToPng(
|
||||
"jpg-to-png",
|
||||
"Convert JPG to PNG",
|
||||
"Opens a JPEG and saves it as lossless PNG. Transparency, if present, is preserved.",
|
||||
),
|
||||
decodeToPng(
|
||||
"webp-to-png",
|
||||
"Convert WebP to PNG",
|
||||
"Re-encodes a WebP image into universal PNG.",
|
||||
),
|
||||
decodeToPng(
|
||||
"gif-to-png",
|
||||
"Convert GIF to PNG",
|
||||
"Extracts the first frame of a GIF animation and saves it as PNG.",
|
||||
),
|
||||
decodeToPng(
|
||||
"bmp-to-png",
|
||||
"Convert BMP to PNG",
|
||||
"Re-encodes BMP into compact lossless PNG.",
|
||||
),
|
||||
decodeToPng(
|
||||
"ico-to-png",
|
||||
"Convert ICO to PNG",
|
||||
"Turns an .ico icon into a regular PNG of the chosen size.",
|
||||
),
|
||||
{
|
||||
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",
|
||||
params: [],
|
||||
run: (img) => flattenOntoColor(img, "#000000"),
|
||||
output: { mime: "image/bmp", ext: "bmp" },
|
||||
},
|
||||
{
|
||||
id: "png-to-base64",
|
||||
title: "PNG to Base64",
|
||||
description:
|
||||
"Encodes the image into a base64 string for embedding in code or styles.",
|
||||
category: "convert",
|
||||
params: [],
|
||||
resultType: "text",
|
||||
toText: (img) => toBase64(img),
|
||||
},
|
||||
{
|
||||
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",
|
||||
sourceMode: "text",
|
||||
params: [],
|
||||
run: (img) => clonePixelImage(img),
|
||||
},
|
||||
{
|
||||
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",
|
||||
params: [],
|
||||
resultType: "text",
|
||||
toText: (img) => toDataUrl(img),
|
||||
},
|
||||
{
|
||||
id: "data-uri-to-png",
|
||||
title: "Data URI to PNG",
|
||||
description: "Decodes data:image/…;base64,… back into an image file.",
|
||||
category: "convert",
|
||||
sourceMode: "text",
|
||||
params: [],
|
||||
run: (img) => clonePixelImage(img),
|
||||
},
|
||||
{
|
||||
id: "png-to-hex",
|
||||
title: "PNG to HEX pixels",
|
||||
description:
|
||||
"Shows all pixels as rrggbbaa hex values — row by row, space separated.",
|
||||
category: "convert",
|
||||
params: [],
|
||||
resultType: "text",
|
||||
toText: (img) => pixelsToHex(img),
|
||||
},
|
||||
{
|
||||
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",
|
||||
sourceMode: "text",
|
||||
params: [
|
||||
{
|
||||
id: "width",
|
||||
label: "Image width",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 10000,
|
||||
step: 1,
|
||||
default: 1,
|
||||
},
|
||||
],
|
||||
runFromText: (text, p) =>
|
||||
hexToPixels(text, Math.trunc(Number(p["width"]))),
|
||||
run: (img) => clonePixelImage(img),
|
||||
},
|
||||
{
|
||||
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",
|
||||
params: [],
|
||||
resultType: "text",
|
||||
toText: (img) => imageToByteRows(img),
|
||||
},
|
||||
{
|
||||
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",
|
||||
sourceMode: "text",
|
||||
params: [
|
||||
{
|
||||
id: "width",
|
||||
label: "Image width",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 10000,
|
||||
step: 1,
|
||||
default: 32,
|
||||
},
|
||||
],
|
||||
runFromText: (text, p) =>
|
||||
bytesToImage(
|
||||
text,
|
||||
Math.trunc(num(p as Record<string, unknown>, "width")),
|
||||
),
|
||||
run: (img) => clonePixelImage(img),
|
||||
},
|
||||
{
|
||||
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",
|
||||
params: [],
|
||||
resultType: "text",
|
||||
toText: (img) => imageToRgbValues(img),
|
||||
},
|
||||
{
|
||||
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",
|
||||
sourceMode: "text",
|
||||
params: [
|
||||
{
|
||||
id: "width",
|
||||
label: "Image width",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 10000,
|
||||
step: 1,
|
||||
default: 32,
|
||||
},
|
||||
],
|
||||
runFromText: (text, p) =>
|
||||
rgbValuesToImage(
|
||||
text,
|
||||
Math.trunc(num(p as Record<string, unknown>, "width")),
|
||||
),
|
||||
run: (img) => clonePixelImage(img),
|
||||
},
|
||||
{
|
||||
id: "convert-png-to-jpg",
|
||||
title: "Convert PNG to JPG",
|
||||
description:
|
||||
"Transparency is composited over the chosen backdrop color (white by default) and saved as JPEG.",
|
||||
category: "convert",
|
||||
params: [
|
||||
{
|
||||
id: "background",
|
||||
label: "Backdrop color",
|
||||
type: "color",
|
||||
default: "#ffffff",
|
||||
},
|
||||
{
|
||||
id: "quality",
|
||||
label: "JPEG quality",
|
||||
type: "slider",
|
||||
min: 1,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 90,
|
||||
},
|
||||
],
|
||||
output: {
|
||||
mime: "image/jpeg",
|
||||
ext: "jpg",
|
||||
qualityParamId: "quality",
|
||||
},
|
||||
run: (img, p) => flattenOntoColor(img, str(p, "background")),
|
||||
},
|
||||
{
|
||||
id: "convert-png-to-webp",
|
||||
title: "Convert PNG to WebP",
|
||||
description:
|
||||
"Re-encodes the image into WebP with adjustable quality. Transparency is preserved.",
|
||||
category: "convert",
|
||||
params: [
|
||||
{
|
||||
id: "quality",
|
||||
label: "WebP quality",
|
||||
type: "slider",
|
||||
min: 1,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 90,
|
||||
},
|
||||
],
|
||||
output: {
|
||||
mime: "image/webp",
|
||||
ext: "webp",
|
||||
qualityParamId: "quality",
|
||||
},
|
||||
run: (img) => clonePixelImage(img),
|
||||
},
|
||||
{
|
||||
id: "compress-png",
|
||||
title: "Compress PNG",
|
||||
description:
|
||||
"Shrinks the PNG by reducing its palette to a preset level. Honest trade-off: fewer colors = smaller file.",
|
||||
category: "convert",
|
||||
params: [
|
||||
{
|
||||
id: "level",
|
||||
label: "Compression level",
|
||||
type: "select",
|
||||
default: "balanced",
|
||||
options: [
|
||||
{ value: "light", label: "Light (192 colors)" },
|
||||
{ value: "balanced", label: "Balanced (96 colors)" },
|
||||
{ value: "strong", label: "Strong (44 colors)" },
|
||||
{ value: "extreme", label: "Extreme (16 colors)" },
|
||||
],
|
||||
},
|
||||
],
|
||||
run: (img, p) => {
|
||||
const level = str(p, "level") as CompressionLevel;
|
||||
const k = COMPRESSION_LEVELS[level] ?? COMPRESSION_LEVELS.balanced;
|
||||
return quantizeImage(img, k).image;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "reduce-to-size-png",
|
||||
title: "Reduce PNG to Size",
|
||||
description:
|
||||
"Binary-searches the palette size until the encoded PNG fits the target KB. Best effort: if even 2 colors exceed the target, returns the 2-color version.",
|
||||
category: "convert",
|
||||
domOnly: true,
|
||||
params: [
|
||||
{
|
||||
id: "targetKB",
|
||||
label: "Target size, KB",
|
||||
type: "slider",
|
||||
min: 5,
|
||||
max: 2000,
|
||||
step: 5,
|
||||
default: 100,
|
||||
},
|
||||
{
|
||||
id: "maxColors",
|
||||
label: "Max colors to try",
|
||||
type: "slider",
|
||||
min: 2,
|
||||
max: 256,
|
||||
step: 1,
|
||||
default: 256,
|
||||
},
|
||||
],
|
||||
run: async (img, p) => {
|
||||
const targetBytes = num(p, "targetKB") * 1024;
|
||||
const maxK = num(p, "maxColors");
|
||||
const encodeSize = async (k: number): Promise<number | null> => {
|
||||
const blob = await encode(quantizeImage(img, k).image, "image/png");
|
||||
return blob.size;
|
||||
};
|
||||
const k = await findMaxColorsWithin(targetBytes, maxK, encodeSize);
|
||||
return quantizeImage(img, k).image;
|
||||
},
|
||||
},
|
||||
{
|
||||
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",
|
||||
sourceMode: "text",
|
||||
params: [
|
||||
{
|
||||
id: "width",
|
||||
label: "Result width, px",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 10000,
|
||||
step: 1,
|
||||
default: 512,
|
||||
},
|
||||
],
|
||||
runFromText: (text, p) =>
|
||||
decodeSvgText(text, Math.trunc(num(p, "width"))),
|
||||
run: (img) => clonePixelImage(img),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
import type { ToolEntry } from "../registry";
|
||||
import { gaussianBlur, sharpen as sharpenImage } from "../../core/convolution";
|
||||
import { vignette } from "../../core/effects";
|
||||
import {
|
||||
pixelate,
|
||||
shuffleBlocks,
|
||||
addNoise,
|
||||
silhouette,
|
||||
} from "../../core/pixel-fx";
|
||||
import { jpegRoundtrip } from "../../core/io";
|
||||
import { num, str } from "../registry-helpers";
|
||||
|
||||
export function filterEntries(): ToolEntry[] {
|
||||
return [
|
||||
{
|
||||
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",
|
||||
params: [
|
||||
{
|
||||
id: "radius",
|
||||
label: "Radius, px",
|
||||
type: "slider",
|
||||
min: 1,
|
||||
max: 32,
|
||||
step: 1,
|
||||
default: 4,
|
||||
},
|
||||
],
|
||||
run: (img, p) => gaussianBlur(img, num(p, "radius")),
|
||||
},
|
||||
{
|
||||
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",
|
||||
params: [
|
||||
{
|
||||
id: "strength",
|
||||
label: "Strength, %",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 50,
|
||||
},
|
||||
],
|
||||
run: (img, p) => sharpenImage(img, num(p, "strength")),
|
||||
},
|
||||
{
|
||||
id: "vignette-png",
|
||||
title: "Vignette PNG",
|
||||
description:
|
||||
"Smoothly darkens the edges of the image, leaving the center untouched.",
|
||||
category: "filters",
|
||||
params: [
|
||||
{
|
||||
id: "strength",
|
||||
label: "Darkening strength, %",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 5,
|
||||
default: 50,
|
||||
},
|
||||
],
|
||||
run: (img, p) => vignette(img, num(p, "strength")),
|
||||
},
|
||||
{
|
||||
id: "pixelate-png",
|
||||
title: "Pixelate PNG",
|
||||
description:
|
||||
"Averages every blockSize×blockSize area into one color — classic mosaic.",
|
||||
category: "filters",
|
||||
params: [
|
||||
{
|
||||
id: "blockSize",
|
||||
label: "Block size, px",
|
||||
type: "slider",
|
||||
min: 2,
|
||||
max: 64,
|
||||
step: 1,
|
||||
default: 8,
|
||||
},
|
||||
],
|
||||
run: (img, p) => pixelate(img, num(p, "blockSize")),
|
||||
},
|
||||
{
|
||||
id: "randomize-pixels-png",
|
||||
title: "Randomize Pixels PNG",
|
||||
description:
|
||||
"Shuffles blocks of the image between positions. Same seed gives the same arrangement.",
|
||||
category: "filters",
|
||||
params: [
|
||||
{
|
||||
id: "blockSize",
|
||||
label: "Block size, px",
|
||||
type: "slider",
|
||||
min: 1,
|
||||
max: 64,
|
||||
step: 1,
|
||||
default: 8,
|
||||
},
|
||||
{
|
||||
id: "seed",
|
||||
label: "Seed",
|
||||
type: "number",
|
||||
min: 0,
|
||||
max: 999999,
|
||||
step: 1,
|
||||
default: 42,
|
||||
},
|
||||
],
|
||||
run: (img, p) => shuffleBlocks(img, num(p, "blockSize"), num(p, "seed")),
|
||||
},
|
||||
{
|
||||
id: "add-noise-png",
|
||||
title: "Add Noise to PNG",
|
||||
description:
|
||||
"Adds film-grain style noise. Deterministic by seed; monochrome keeps original hue balance.",
|
||||
category: "filters",
|
||||
params: [
|
||||
{
|
||||
id: "amount",
|
||||
label: "Amount, %",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 25,
|
||||
},
|
||||
{
|
||||
id: "mode",
|
||||
label: "Noise type",
|
||||
type: "select",
|
||||
default: "mono",
|
||||
options: [
|
||||
{ value: "mono", label: "Monochrome grain" },
|
||||
{ value: "color", label: "Color noise" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "seed",
|
||||
label: "Seed",
|
||||
type: "number",
|
||||
min: 0,
|
||||
max: 999999,
|
||||
step: 1,
|
||||
default: 1234,
|
||||
},
|
||||
],
|
||||
run: (img, p) =>
|
||||
addNoise(
|
||||
img,
|
||||
num(p, "amount"),
|
||||
str(p, "mode") === "color" ? "color" : "mono",
|
||||
num(p, "seed"),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "silhouette-png",
|
||||
title: "Silhouette PNG",
|
||||
description:
|
||||
"Turns all visible pixels into a single solid color while keeping their transparency — instant silhouette.",
|
||||
category: "filters",
|
||||
params: [
|
||||
{
|
||||
id: "color",
|
||||
label: "Silhouette color",
|
||||
type: "color",
|
||||
default: "#111318",
|
||||
},
|
||||
{
|
||||
id: "threshold",
|
||||
label: "Visibility threshold, %",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 10,
|
||||
},
|
||||
],
|
||||
run: (img, p) =>
|
||||
silhouette(img, str(p, "color"), num(p, "threshold") * 2.55),
|
||||
},
|
||||
{
|
||||
id: "jpeg-artifacts-png",
|
||||
title: "JPEG artifacts",
|
||||
description:
|
||||
"Simulates low-quality JPEG re-compression — visible blocks and smeared colors.",
|
||||
category: "filters",
|
||||
params: [
|
||||
{
|
||||
id: "quality",
|
||||
label: "JPEG quality",
|
||||
type: "slider",
|
||||
min: 1,
|
||||
max: 50,
|
||||
step: 1,
|
||||
default: 10,
|
||||
},
|
||||
],
|
||||
run: (img, p) => jpegRoundtrip(img, num(p, "quality")),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,950 +0,0 @@
|
||||
import type { ToolEntry } from "../registry";
|
||||
import { ToolError } from "../../core/errors";
|
||||
import { solidImage, noiseImage, gradientImage } from "../../core/generate";
|
||||
import {
|
||||
colorSpectrum,
|
||||
drawGrid,
|
||||
randomColorBlocks,
|
||||
} from "../../core/gen-tools";
|
||||
import {
|
||||
renderTextToImage,
|
||||
renderEmoji,
|
||||
type TextFont,
|
||||
} from "../../core/domText";
|
||||
import { changeCanvasSize } from "../../core/geometry";
|
||||
import {
|
||||
renderWheel,
|
||||
renderSwatches,
|
||||
renderBlend,
|
||||
complementarySet,
|
||||
triadicSet,
|
||||
tetradicSet,
|
||||
analogousSet,
|
||||
monochromaticSet,
|
||||
shadeSet,
|
||||
mixColors,
|
||||
parseHexList,
|
||||
stepColors,
|
||||
sortPalette,
|
||||
type SortKey,
|
||||
} from "../../core/palette";
|
||||
import { num, str, bool } from "../registry-helpers";
|
||||
|
||||
function hexToRgba(hex: string, alpha = 255): [number, number, number, number] {
|
||||
const match = /^#([0-9a-f]{6})$/i.exec(hex.trim());
|
||||
if (!match) {
|
||||
throw new ToolError("errors.badHex", { value: hex });
|
||||
}
|
||||
const d = match[1];
|
||||
return [
|
||||
parseInt(d.slice(0, 2), 16),
|
||||
parseInt(d.slice(2, 4), 16),
|
||||
parseInt(d.slice(4, 6), 16),
|
||||
alpha,
|
||||
];
|
||||
}
|
||||
|
||||
function paletteParams(baseDefault: string) {
|
||||
return [
|
||||
{
|
||||
id: "baseColor",
|
||||
label: "Base color",
|
||||
type: "color" as const,
|
||||
default: baseDefault,
|
||||
},
|
||||
{
|
||||
id: "width",
|
||||
label: "Width",
|
||||
type: "slider" as const,
|
||||
min: 128,
|
||||
max: 1024,
|
||||
step: 16,
|
||||
default: 512,
|
||||
},
|
||||
{
|
||||
id: "layout",
|
||||
label: "Layout",
|
||||
type: "select" as const,
|
||||
default: "grid",
|
||||
options: [
|
||||
{ value: "grid", label: "Grid" },
|
||||
{ value: "strip", label: "Strip" },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function generateEntries(): ToolEntry[] {
|
||||
return [
|
||||
{
|
||||
id: "create-empty-png",
|
||||
title: "Create empty PNG",
|
||||
description:
|
||||
"Generates a canvas of the chosen size — transparent or filled with color.",
|
||||
category: "generate",
|
||||
sourceMode: "none",
|
||||
params: [
|
||||
{
|
||||
id: "width",
|
||||
label: "Width",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 20000,
|
||||
step: 1,
|
||||
default: 800,
|
||||
},
|
||||
{
|
||||
id: "height",
|
||||
label: "Height",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 20000,
|
||||
step: 1,
|
||||
default: 600,
|
||||
},
|
||||
{
|
||||
id: "transparent",
|
||||
label: "Transparent",
|
||||
type: "checkbox",
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
id: "color",
|
||||
label: "Color",
|
||||
type: "color",
|
||||
default: "#ffffff",
|
||||
},
|
||||
],
|
||||
generate: (p) =>
|
||||
solidImage(
|
||||
Math.trunc(num(p, "width")),
|
||||
Math.trunc(num(p, "height")),
|
||||
p["transparent"] === true ? [0, 0, 0, 0] : hexToRgba(str(p, "color")),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "single-color-png",
|
||||
title: "Create solid color PNG",
|
||||
description: "Generates a rectangle of the given size and color.",
|
||||
category: "generate",
|
||||
sourceMode: "none",
|
||||
params: [
|
||||
{
|
||||
id: "width",
|
||||
label: "Width",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 20000,
|
||||
step: 1,
|
||||
default: 256,
|
||||
},
|
||||
{
|
||||
id: "height",
|
||||
label: "Height",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 20000,
|
||||
step: 1,
|
||||
default: 256,
|
||||
},
|
||||
{
|
||||
id: "color",
|
||||
label: "Color",
|
||||
type: "color",
|
||||
default: "#ff0000",
|
||||
},
|
||||
],
|
||||
generate: (p) =>
|
||||
solidImage(
|
||||
Math.trunc(num(p, "width")),
|
||||
Math.trunc(num(p, "height")),
|
||||
hexToRgba(str(p, "color")),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "random-noise-png",
|
||||
title: "Create random noise PNG",
|
||||
description:
|
||||
"Generates an image with random pixels. The seed fixes the result: one seed — one image.",
|
||||
category: "generate",
|
||||
sourceMode: "none",
|
||||
params: [
|
||||
{
|
||||
id: "width",
|
||||
label: "Width",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 5000,
|
||||
step: 1,
|
||||
default: 512,
|
||||
},
|
||||
{
|
||||
id: "height",
|
||||
label: "Height",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 5000,
|
||||
step: 1,
|
||||
default: 512,
|
||||
},
|
||||
{
|
||||
id: "seed",
|
||||
label: "Seed",
|
||||
type: "number",
|
||||
min: 0,
|
||||
max: 999999999,
|
||||
step: 1,
|
||||
default: 1,
|
||||
},
|
||||
],
|
||||
generate: (p) =>
|
||||
noiseImage(
|
||||
Math.trunc(num(p, "width")),
|
||||
Math.trunc(num(p, "height")),
|
||||
num(p, "seed"),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "text-to-png",
|
||||
title: "Text to PNG",
|
||||
description:
|
||||
"Creates a PNG image from text: the canvas is sized to fit the label plus padding.",
|
||||
category: "generate",
|
||||
sourceMode: "none",
|
||||
domOnly: true,
|
||||
params: [
|
||||
{
|
||||
id: "text",
|
||||
label: "Text",
|
||||
type: "text",
|
||||
default: "Hello!",
|
||||
placeholder: "Your text",
|
||||
},
|
||||
{
|
||||
id: "fontSize",
|
||||
label: "Font size, px",
|
||||
type: "slider",
|
||||
min: 8,
|
||||
max: 300,
|
||||
step: 1,
|
||||
default: 96,
|
||||
},
|
||||
{
|
||||
id: "font",
|
||||
label: "Font",
|
||||
type: "select",
|
||||
default: "sans",
|
||||
options: [
|
||||
{ value: "sans", label: "Sans-serif" },
|
||||
{ value: "serif", label: "Serif" },
|
||||
{ value: "mono", label: "Monospace" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "bold",
|
||||
label: "Bold",
|
||||
type: "checkbox",
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
id: "color",
|
||||
label: "Text color",
|
||||
type: "color",
|
||||
default: "#111318",
|
||||
},
|
||||
{
|
||||
id: "transparentBg",
|
||||
label: "Transparent background",
|
||||
type: "checkbox",
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
id: "backgroundColor",
|
||||
label: "Background color",
|
||||
type: "color",
|
||||
default: "#ffffff",
|
||||
},
|
||||
{
|
||||
id: "padding",
|
||||
label: "Padding, px",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 200,
|
||||
step: 2,
|
||||
default: 24,
|
||||
},
|
||||
],
|
||||
generate: (p) =>
|
||||
renderTextToImage({
|
||||
text: str(p, "text"),
|
||||
fontSize: num(p, "fontSize"),
|
||||
font: str(p, "font") as TextFont,
|
||||
bold: bool(p, "bold"),
|
||||
color: str(p, "color"),
|
||||
backgroundColor: str(p, "backgroundColor"),
|
||||
transparentBg: bool(p, "transparentBg"),
|
||||
padding: num(p, "padding"),
|
||||
}),
|
||||
},
|
||||
{
|
||||
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",
|
||||
sourceMode: "none",
|
||||
domOnly: true,
|
||||
params: [
|
||||
{
|
||||
id: "emoji",
|
||||
label: "Emoji / symbol",
|
||||
type: "text",
|
||||
default: "\u{1F600}",
|
||||
},
|
||||
{
|
||||
id: "size",
|
||||
label: "Size",
|
||||
type: "slider",
|
||||
min: 32,
|
||||
max: 1024,
|
||||
step: 16,
|
||||
default: 256,
|
||||
},
|
||||
],
|
||||
generate: (p) => renderEmoji(str(p, "emoji"), Math.trunc(num(p, "size"))),
|
||||
},
|
||||
{
|
||||
id: "placeholder-png",
|
||||
title: "Create Placeholder PNG",
|
||||
description:
|
||||
"Generates a placeholder rectangle with its dimensions printed in the center.",
|
||||
category: "generate",
|
||||
sourceMode: "none",
|
||||
domOnly: true,
|
||||
params: [
|
||||
{
|
||||
id: "width",
|
||||
label: "Width",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 5000,
|
||||
step: 1,
|
||||
default: 800,
|
||||
},
|
||||
{
|
||||
id: "height",
|
||||
label: "Height",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 5000,
|
||||
step: 1,
|
||||
default: 400,
|
||||
},
|
||||
{
|
||||
id: "backgroundColor",
|
||||
label: "Background",
|
||||
type: "color",
|
||||
default: "#dfe2e8",
|
||||
},
|
||||
{
|
||||
id: "color",
|
||||
label: "Text color",
|
||||
type: "color",
|
||||
default: "#5c6470",
|
||||
},
|
||||
{
|
||||
id: "showText",
|
||||
label: "Print dimensions",
|
||||
type: "checkbox",
|
||||
default: true,
|
||||
},
|
||||
],
|
||||
generate: (p) => {
|
||||
const w = Math.trunc(num(p, "width"));
|
||||
const h = Math.trunc(num(p, "height"));
|
||||
const label = renderTextToImage({
|
||||
text: `${w} × ${h}`,
|
||||
fontSize: Math.max(12, Math.round(Math.min(w, h) * 0.14)),
|
||||
font: "sans",
|
||||
bold: true,
|
||||
color: str(p, "color"),
|
||||
backgroundColor: str(p, "backgroundColor"),
|
||||
transparentBg: false,
|
||||
padding: 0,
|
||||
});
|
||||
return changeCanvasSize(label, w, h, "center");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "linear-gradient-png",
|
||||
title: "Create gradient PNG",
|
||||
description:
|
||||
"Generates a smooth transition between two colors, horizontally or vertically.",
|
||||
category: "generate",
|
||||
sourceMode: "none",
|
||||
params: [
|
||||
{
|
||||
id: "width",
|
||||
label: "Width",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 20000,
|
||||
step: 1,
|
||||
default: 800,
|
||||
},
|
||||
{
|
||||
id: "height",
|
||||
label: "Height",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 20000,
|
||||
step: 1,
|
||||
default: 600,
|
||||
},
|
||||
{
|
||||
id: "fromColor",
|
||||
label: "Start color",
|
||||
type: "color",
|
||||
default: "#000000",
|
||||
},
|
||||
{
|
||||
id: "toColor",
|
||||
label: "End color",
|
||||
type: "color",
|
||||
default: "#ffffff",
|
||||
},
|
||||
{
|
||||
id: "direction",
|
||||
label: "Direction",
|
||||
type: "select",
|
||||
default: "horizontal",
|
||||
options: [
|
||||
{ value: "horizontal", label: "Horizontal" },
|
||||
{ value: "vertical", label: "Vertical" },
|
||||
],
|
||||
},
|
||||
],
|
||||
generate: (p) =>
|
||||
gradientImage(
|
||||
Math.trunc(num(p, "width")),
|
||||
Math.trunc(num(p, "height")),
|
||||
hexToRgba(str(p, "fromColor")),
|
||||
hexToRgba(str(p, "toColor")),
|
||||
str(p, "direction") === "vertical" ? "vertical" : "horizontal",
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "color-spectrum-png",
|
||||
title: "Color Spectrum PNG",
|
||||
description:
|
||||
"Full hue rainbow 0–360° along the chosen axis with adjustable saturation and lightness.",
|
||||
category: "generate",
|
||||
sourceMode: "none",
|
||||
params: [
|
||||
{
|
||||
id: "width",
|
||||
label: "Width",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 5000,
|
||||
step: 1,
|
||||
default: 1024,
|
||||
},
|
||||
{
|
||||
id: "height",
|
||||
label: "Height",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 5000,
|
||||
step: 1,
|
||||
default: 128,
|
||||
},
|
||||
{
|
||||
id: "direction",
|
||||
label: "Direction",
|
||||
type: "select",
|
||||
default: "horizontal",
|
||||
options: [
|
||||
{ value: "horizontal", label: "Horizontal" },
|
||||
{ value: "vertical", label: "Vertical" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "saturation",
|
||||
label: "Saturation, %",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 100,
|
||||
},
|
||||
{
|
||||
id: "lightness",
|
||||
label: "Lightness, %",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 50,
|
||||
},
|
||||
],
|
||||
generate: (p) =>
|
||||
colorSpectrum(
|
||||
Math.trunc(num(p, "width")),
|
||||
Math.trunc(num(p, "height")),
|
||||
str(p, "direction") === "vertical" ? "vertical" : "horizontal",
|
||||
num(p, "saturation"),
|
||||
num(p, "lightness"),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "random-colors-png",
|
||||
title: "Random Color Blocks PNG",
|
||||
description:
|
||||
"Fills the canvas with random vivid color blocks. Deterministic by seed.",
|
||||
category: "generate",
|
||||
sourceMode: "none",
|
||||
params: [
|
||||
{
|
||||
id: "width",
|
||||
label: "Width",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 5000,
|
||||
step: 1,
|
||||
default: 512,
|
||||
},
|
||||
{
|
||||
id: "height",
|
||||
label: "Height",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 5000,
|
||||
step: 1,
|
||||
default: 512,
|
||||
},
|
||||
{
|
||||
id: "blockSize",
|
||||
label: "Block size, px",
|
||||
type: "slider",
|
||||
min: 4,
|
||||
max: 256,
|
||||
step: 2,
|
||||
default: 64,
|
||||
},
|
||||
{
|
||||
id: "seed",
|
||||
label: "Seed",
|
||||
type: "number",
|
||||
min: 0,
|
||||
max: 999999999,
|
||||
step: 1,
|
||||
default: 7,
|
||||
},
|
||||
],
|
||||
generate: (p) =>
|
||||
randomColorBlocks(
|
||||
Math.trunc(num(p, "width")),
|
||||
Math.trunc(num(p, "height")),
|
||||
num(p, "blockSize"),
|
||||
num(p, "seed"),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "draw-grid-png",
|
||||
title: "Draw Grid PNG",
|
||||
description:
|
||||
"Draws a grid with custom columns, rows and line width on a transparent or white background.",
|
||||
category: "generate",
|
||||
sourceMode: "none",
|
||||
params: [
|
||||
{
|
||||
id: "width",
|
||||
label: "Width",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 5000,
|
||||
step: 1,
|
||||
default: 512,
|
||||
},
|
||||
{
|
||||
id: "height",
|
||||
label: "Height",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 5000,
|
||||
step: 1,
|
||||
default: 512,
|
||||
},
|
||||
{
|
||||
id: "cols",
|
||||
label: "Columns",
|
||||
type: "slider",
|
||||
min: 1,
|
||||
max: 64,
|
||||
step: 1,
|
||||
default: 8,
|
||||
},
|
||||
{
|
||||
id: "rows",
|
||||
label: "Rows",
|
||||
type: "slider",
|
||||
min: 1,
|
||||
max: 64,
|
||||
step: 1,
|
||||
default: 8,
|
||||
},
|
||||
{
|
||||
id: "lineWidth",
|
||||
label: "Line width, px",
|
||||
type: "slider",
|
||||
min: 1,
|
||||
max: 40,
|
||||
step: 1,
|
||||
default: 2,
|
||||
},
|
||||
{
|
||||
id: "color",
|
||||
label: "Line color",
|
||||
type: "color",
|
||||
default: "#111318",
|
||||
},
|
||||
{
|
||||
id: "transparentBg",
|
||||
label: "Transparent background",
|
||||
type: "checkbox",
|
||||
default: true,
|
||||
},
|
||||
],
|
||||
generate: (p) =>
|
||||
drawGrid(
|
||||
Math.trunc(num(p, "width")),
|
||||
Math.trunc(num(p, "height")),
|
||||
num(p, "cols"),
|
||||
num(p, "rows"),
|
||||
num(p, "lineWidth"),
|
||||
str(p, "color"),
|
||||
bool(p, "transparentBg"),
|
||||
),
|
||||
},
|
||||
{
|
||||
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",
|
||||
sourceMode: "none",
|
||||
params: [
|
||||
{
|
||||
id: "width",
|
||||
label: "Size",
|
||||
type: "slider",
|
||||
min: 128,
|
||||
max: 1024,
|
||||
step: 16,
|
||||
default: 512,
|
||||
},
|
||||
{
|
||||
id: "lightness",
|
||||
label: "Lightness, %",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 50,
|
||||
},
|
||||
],
|
||||
generate: (p) =>
|
||||
renderWheel(Math.trunc(num(p, "width")), num(p, "lightness")),
|
||||
},
|
||||
{
|
||||
id: "complementary-png",
|
||||
title: "Complementary Palette PNG",
|
||||
description:
|
||||
"Two opposite colors on the color wheel — the base and its complement.",
|
||||
category: "generate",
|
||||
sourceMode: "none",
|
||||
params: paletteParams("#2563eb"),
|
||||
generate: (p) =>
|
||||
renderSwatches(
|
||||
complementarySet(str(p, "baseColor")),
|
||||
num(p, "width"),
|
||||
str(p, "layout") as "strip" | "grid",
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "triadic-png",
|
||||
title: "Triadic Palette PNG",
|
||||
description: "Three colors evenly spaced 120° apart on the color wheel.",
|
||||
category: "generate",
|
||||
sourceMode: "none",
|
||||
params: paletteParams("#ff0000"),
|
||||
generate: (p) =>
|
||||
renderSwatches(
|
||||
triadicSet(str(p, "baseColor")),
|
||||
num(p, "width"),
|
||||
str(p, "layout") as "strip" | "grid",
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "tetradic-png",
|
||||
title: "Tetradic Palette PNG",
|
||||
description:
|
||||
"Four colors in two complementary pairs, 90° apart on the wheel.",
|
||||
category: "generate",
|
||||
sourceMode: "none",
|
||||
params: paletteParams("#8000ff"),
|
||||
generate: (p) =>
|
||||
renderSwatches(
|
||||
tetradicSet(str(p, "baseColor")),
|
||||
num(p, "width"),
|
||||
str(p, "layout") as "strip" | "grid",
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "analogous-png",
|
||||
title: "Analogous Palette PNG",
|
||||
description:
|
||||
"Neighboring hues around the base color — calm, related color scheme.",
|
||||
category: "generate",
|
||||
sourceMode: "none",
|
||||
params: [
|
||||
...paletteParams("#22c55e"),
|
||||
{
|
||||
id: "spread",
|
||||
label: "Hue spread, °",
|
||||
type: "slider",
|
||||
min: 10,
|
||||
max: 90,
|
||||
step: 5,
|
||||
default: 30,
|
||||
},
|
||||
{
|
||||
id: "count",
|
||||
label: "Colors",
|
||||
type: "slider",
|
||||
min: 3,
|
||||
max: 9,
|
||||
step: 1,
|
||||
default: 5,
|
||||
},
|
||||
],
|
||||
generate: (p) =>
|
||||
renderSwatches(
|
||||
analogousSet(str(p, "baseColor"), num(p, "spread"), num(p, "count")),
|
||||
num(p, "width"),
|
||||
str(p, "layout") as "strip" | "grid",
|
||||
),
|
||||
},
|
||||
{
|
||||
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",
|
||||
sourceMode: "none",
|
||||
params: [
|
||||
...paletteParams("#0ea5e9"),
|
||||
{
|
||||
id: "count",
|
||||
label: "Colors",
|
||||
type: "slider",
|
||||
min: 2,
|
||||
max: 9,
|
||||
step: 1,
|
||||
default: 5,
|
||||
},
|
||||
{
|
||||
id: "range",
|
||||
label: "Lightness range, %",
|
||||
type: "slider",
|
||||
min: 10,
|
||||
max: 90,
|
||||
step: 5,
|
||||
default: 40,
|
||||
},
|
||||
],
|
||||
generate: (p) =>
|
||||
renderSwatches(
|
||||
monochromaticSet(
|
||||
str(p, "baseColor"),
|
||||
num(p, "count"),
|
||||
num(p, "range"),
|
||||
),
|
||||
num(p, "width"),
|
||||
str(p, "layout") as "strip" | "grid",
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "shades-png",
|
||||
title: "Shade Ramp PNG",
|
||||
description: "A ramp of the base color getting darker step by step.",
|
||||
category: "generate",
|
||||
sourceMode: "none",
|
||||
params: [
|
||||
...paletteParams("#f59e0b"),
|
||||
{
|
||||
id: "count",
|
||||
label: "Colors",
|
||||
type: "slider",
|
||||
min: 2,
|
||||
max: 9,
|
||||
step: 1,
|
||||
default: 5,
|
||||
},
|
||||
{
|
||||
id: "depth",
|
||||
label: "Darkening depth, %",
|
||||
type: "slider",
|
||||
min: 10,
|
||||
max: 90,
|
||||
step: 5,
|
||||
default: 50,
|
||||
},
|
||||
],
|
||||
generate: (p) =>
|
||||
renderSwatches(
|
||||
shadeSet(str(p, "baseColor"), num(p, "count"), num(p, "depth")),
|
||||
num(p, "width"),
|
||||
str(p, "layout") as "strip" | "grid",
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "mix-colors-png",
|
||||
title: "Mix Colors PNG",
|
||||
description:
|
||||
"Averages several hex colors into one swatch. Enter comma-separated #hex values; invalid tokens are skipped.",
|
||||
category: "generate",
|
||||
sourceMode: "none",
|
||||
params: [
|
||||
{
|
||||
id: "colors",
|
||||
label: "Colors (comma-separated hex)",
|
||||
type: "text",
|
||||
default: "#ff0000,#00ff00,#0000ff",
|
||||
},
|
||||
{
|
||||
id: "width",
|
||||
label: "Width",
|
||||
type: "slider",
|
||||
min: 128,
|
||||
max: 1024,
|
||||
step: 16,
|
||||
default: 512,
|
||||
},
|
||||
],
|
||||
generate: (p) =>
|
||||
renderSwatches(
|
||||
[mixColors(parseHexList(str(p, "colors")))],
|
||||
num(p, "width"),
|
||||
"strip",
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "blend-two-png",
|
||||
title: "Blend Two Colors PNG",
|
||||
description: "A continuous horizontal gradient between two colors.",
|
||||
category: "generate",
|
||||
sourceMode: "none",
|
||||
params: [
|
||||
{
|
||||
id: "colorA",
|
||||
label: "Color A",
|
||||
type: "color",
|
||||
default: "#000000",
|
||||
},
|
||||
{
|
||||
id: "colorB",
|
||||
label: "Color B",
|
||||
type: "color",
|
||||
default: "#ffffff",
|
||||
},
|
||||
{
|
||||
id: "width",
|
||||
label: "Width",
|
||||
type: "slider",
|
||||
min: 128,
|
||||
max: 1024,
|
||||
step: 16,
|
||||
default: 512,
|
||||
},
|
||||
],
|
||||
generate: (p) =>
|
||||
renderBlend(str(p, "colorA"), str(p, "colorB"), num(p, "width")),
|
||||
},
|
||||
{
|
||||
id: "step-colors-png",
|
||||
title: "Color Steps PNG",
|
||||
description: "A discrete set of evenly spaced steps between two colors.",
|
||||
category: "generate",
|
||||
sourceMode: "none",
|
||||
params: [
|
||||
{
|
||||
id: "colorA",
|
||||
label: "Color A",
|
||||
type: "color",
|
||||
default: "#000000",
|
||||
},
|
||||
{
|
||||
id: "colorB",
|
||||
label: "Color B",
|
||||
type: "color",
|
||||
default: "#ffffff",
|
||||
},
|
||||
{
|
||||
id: "steps",
|
||||
label: "Steps",
|
||||
type: "slider",
|
||||
min: 2,
|
||||
max: 12,
|
||||
step: 1,
|
||||
default: 6,
|
||||
},
|
||||
...paletteParams("#808080").filter((q) => q.id !== "baseColor"),
|
||||
],
|
||||
generate: (p) =>
|
||||
renderSwatches(
|
||||
stepColors(str(p, "colorA"), str(p, "colorB"), num(p, "steps")),
|
||||
num(p, "width"),
|
||||
str(p, "layout") as "strip" | "grid",
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "sort-colors-png",
|
||||
title: "Sort Colors PNG",
|
||||
description:
|
||||
"Renders your hex list as swatches sorted by hue, brightness or saturation. Invalid tokens are skipped.",
|
||||
category: "generate",
|
||||
sourceMode: "none",
|
||||
params: [
|
||||
{
|
||||
id: "colors",
|
||||
label: "Colors (comma-separated hex)",
|
||||
type: "text",
|
||||
default: "#ff0000,#ff8800,#ffff00,#00cc44,#0066ff,#8800ff",
|
||||
},
|
||||
{
|
||||
id: "order",
|
||||
label: "Sort by",
|
||||
type: "select",
|
||||
default: "hue",
|
||||
options: [
|
||||
{ value: "hue", label: "Hue" },
|
||||
{ value: "luma", label: "Brightness" },
|
||||
{ value: "sat", label: "Saturation" },
|
||||
],
|
||||
},
|
||||
...paletteParams("#ffffff").filter((q) => q.id !== "baseColor"),
|
||||
],
|
||||
generate: (p) =>
|
||||
renderSwatches(
|
||||
sortPalette(
|
||||
parseHexList(str(p, "colors")),
|
||||
str(p, "order") as SortKey,
|
||||
),
|
||||
num(p, "width"),
|
||||
str(p, "layout") as "strip" | "grid",
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,645 +0,0 @@
|
||||
import {
|
||||
rotateFreeImage,
|
||||
skewImage,
|
||||
transformImage,
|
||||
zoomImage,
|
||||
} from "../../core/affine";
|
||||
import { ToolError } from "../../core/errors";
|
||||
import {
|
||||
centerByAlpha,
|
||||
changeCanvasSize,
|
||||
crop,
|
||||
cropToRatio,
|
||||
expandCanvas,
|
||||
flip,
|
||||
forceOrientation,
|
||||
padToRatio,
|
||||
resize,
|
||||
rotate90,
|
||||
symmetricCopy,
|
||||
tile,
|
||||
trimToContent,
|
||||
type Anchor9,
|
||||
} from "../../core/geometry";
|
||||
import type { ToolEntry } from "../registry";
|
||||
import { num, str } from "../registry-helpers";
|
||||
|
||||
export function geometryEntries(): ToolEntry[] {
|
||||
return [
|
||||
{
|
||||
id: "resize-png",
|
||||
title: "Resize PNG",
|
||||
description:
|
||||
"Scales the image with bilinear interpolation. With aspect kept, one side defines the scale; if both are set, the image fits inside them.",
|
||||
category: "geometry",
|
||||
params: [
|
||||
{
|
||||
id: "width",
|
||||
label: "Width (0 = auto)",
|
||||
type: "number",
|
||||
min: 0,
|
||||
max: 20000,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
{
|
||||
id: "height",
|
||||
label: "Height (0 = auto)",
|
||||
type: "number",
|
||||
min: 0,
|
||||
max: 20000,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
{
|
||||
id: "keepAspect",
|
||||
label: "Keep aspect ratio",
|
||||
type: "checkbox",
|
||||
default: true,
|
||||
},
|
||||
],
|
||||
run: (img, p) => {
|
||||
const keepAspect = p["keepAspect"] === true;
|
||||
let w = Math.trunc(num(p, "width"));
|
||||
let h = Math.trunc(num(p, "height"));
|
||||
if (keepAspect) {
|
||||
if (w > 0 && h > 0) {
|
||||
const scale = Math.min(w / img.width, h / img.height);
|
||||
w = Math.max(1, Math.round(img.width * scale));
|
||||
h = Math.max(1, Math.round(img.height * scale));
|
||||
} else if (w > 0) {
|
||||
h = Math.max(1, Math.round((img.height / img.width) * w));
|
||||
} else if (h > 0) {
|
||||
w = Math.max(1, Math.round((img.width / img.height) * h));
|
||||
}
|
||||
}
|
||||
if (w <= 0 || h <= 0) {
|
||||
throw new ToolError("errors.resizeSize");
|
||||
}
|
||||
return resize(img, w, h);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "crop-png",
|
||||
title: "Crop PNG",
|
||||
description:
|
||||
"Cuts out a rectangular area. Coordinates and sizes may go beyond the image — the area is clipped to the intersection.",
|
||||
category: "geometry",
|
||||
params: [
|
||||
{
|
||||
id: "x",
|
||||
label: "X (from left)",
|
||||
type: "number",
|
||||
min: -0,
|
||||
max: 2000,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
{
|
||||
id: "y",
|
||||
label: "Y (from top)",
|
||||
type: "number",
|
||||
min: 0,
|
||||
max: 1200,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
{
|
||||
id: "width",
|
||||
label: "Area width",
|
||||
type: "number",
|
||||
min: 0,
|
||||
max: 2000,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
{
|
||||
id: "height",
|
||||
label: "Area height",
|
||||
type: "number",
|
||||
min: 0,
|
||||
max: 1200,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
],
|
||||
run: (img, p) => {
|
||||
const w = Math.trunc(num(p, "width"));
|
||||
const h = Math.trunc(num(p, "height"));
|
||||
if (w <= 0 || h <= 0) {
|
||||
throw new ToolError("errors.cropSize");
|
||||
}
|
||||
return crop(
|
||||
img,
|
||||
Math.trunc(num(p, "x")),
|
||||
Math.trunc(num(p, "y")),
|
||||
w,
|
||||
h,
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "rotate-png",
|
||||
title: "Rotate PNG",
|
||||
description:
|
||||
"Rotates by 90°, 180° or 270° clockwise without quality loss.",
|
||||
category: "geometry",
|
||||
params: [
|
||||
{
|
||||
id: "angle",
|
||||
label: "Rotation angle",
|
||||
type: "select",
|
||||
default: "90",
|
||||
options: [
|
||||
{ value: "90", label: "90° clockwise" },
|
||||
{ value: "180", label: "180°" },
|
||||
{ value: "270", label: "270° clockwise" },
|
||||
],
|
||||
},
|
||||
],
|
||||
run: (img, p) => rotate90(img, Number(str(p, "angle")) / 90),
|
||||
},
|
||||
{
|
||||
id: "flip-png",
|
||||
title: "Flip PNG",
|
||||
description: "Mirrors horizontally or vertically without quality loss.",
|
||||
category: "geometry",
|
||||
params: [
|
||||
{
|
||||
id: "axis",
|
||||
label: "Flip axis",
|
||||
type: "select",
|
||||
default: "horizontal",
|
||||
options: [
|
||||
{
|
||||
value: "horizontal",
|
||||
label: "Horizontal (left to right)",
|
||||
},
|
||||
{
|
||||
value: "vertical",
|
||||
label: "Vertical (top to bottom)",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
run: (img, p) =>
|
||||
flip(img, str(p, "axis") === "vertical" ? "vertical" : "horizontal"),
|
||||
},
|
||||
{
|
||||
id: "add-padding-png",
|
||||
title: "Add padding to PNG",
|
||||
description:
|
||||
"Expands the canvas on all sides by the chosen number of pixels.",
|
||||
category: "geometry",
|
||||
params: [
|
||||
{
|
||||
id: "padding",
|
||||
label: "Padding, px",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 2000,
|
||||
step: 1,
|
||||
default: 10,
|
||||
},
|
||||
{
|
||||
id: "transparent",
|
||||
label: "Transparent padding",
|
||||
type: "checkbox",
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
id: "color",
|
||||
label: "Padding color",
|
||||
type: "color",
|
||||
default: "#ffffff",
|
||||
},
|
||||
],
|
||||
run: (img, p) =>
|
||||
expandCanvas(
|
||||
img,
|
||||
num(p, "padding"),
|
||||
num(p, "padding"),
|
||||
num(p, "padding"),
|
||||
num(p, "padding"),
|
||||
p["transparent"] === true ? undefined : str(p, "color"),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "add-border-png",
|
||||
title: "Add border to PNG",
|
||||
description:
|
||||
"Draws a colored frame of the chosen thickness around the image.",
|
||||
category: "geometry",
|
||||
params: [
|
||||
{
|
||||
id: "thickness",
|
||||
label: "Border thickness, px",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 500,
|
||||
step: 1,
|
||||
default: 5,
|
||||
},
|
||||
{
|
||||
id: "color",
|
||||
label: "Border color",
|
||||
type: "color",
|
||||
default: "#000000",
|
||||
},
|
||||
],
|
||||
run: (img, p) =>
|
||||
expandCanvas(
|
||||
img,
|
||||
num(p, "thickness"),
|
||||
num(p, "thickness"),
|
||||
num(p, "thickness"),
|
||||
num(p, "thickness"),
|
||||
str(p, "color"),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "fit-on-background-png",
|
||||
title: "Fit PNG onto background",
|
||||
description:
|
||||
"Places the image centered on a canvas of the given size with a transparent or colored background.",
|
||||
category: "geometry",
|
||||
params: [
|
||||
{
|
||||
id: "width",
|
||||
label: "Canvas width",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 20000,
|
||||
step: 1,
|
||||
default: 800,
|
||||
},
|
||||
{
|
||||
id: "height",
|
||||
label: "Canvas height",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 20000,
|
||||
step: 1,
|
||||
default: 600,
|
||||
},
|
||||
{
|
||||
id: "transparent",
|
||||
label: "Transparent background",
|
||||
type: "checkbox",
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
id: "color",
|
||||
label: "Background color",
|
||||
type: "color",
|
||||
default: "#ffffff",
|
||||
},
|
||||
],
|
||||
run: (img, p) => {
|
||||
const width = Math.trunc(num(p, "width"));
|
||||
const height = Math.trunc(num(p, "height"));
|
||||
if (width <= 0 || height <= 0) {
|
||||
throw new ToolError("errors.sizePositive");
|
||||
}
|
||||
const left = Math.max(0, Math.floor((width - img.width) / 2));
|
||||
const top = Math.max(0, Math.floor((height - img.height) / 2));
|
||||
return expandCanvas(
|
||||
img,
|
||||
left,
|
||||
top,
|
||||
Math.max(0, width - img.width - left),
|
||||
Math.max(0, height - img.height - top),
|
||||
p["transparent"] === true ? undefined : str(p, "color"),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "tile-png",
|
||||
title: "Tile PNG",
|
||||
description:
|
||||
"Repeats the image in a grid of the chosen columns and rows.",
|
||||
category: "geometry",
|
||||
params: [
|
||||
{
|
||||
id: "columns",
|
||||
label: "Columns",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 50,
|
||||
step: 1,
|
||||
default: 2,
|
||||
},
|
||||
{
|
||||
id: "rows",
|
||||
label: "Rows",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 50,
|
||||
step: 1,
|
||||
default: 2,
|
||||
},
|
||||
],
|
||||
run: (img, p) => tile(img, num(p, "columns"), num(p, "rows")),
|
||||
},
|
||||
{
|
||||
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",
|
||||
params: [],
|
||||
run: (img) => centerByAlpha(img),
|
||||
},
|
||||
{
|
||||
id: "skew-png",
|
||||
title: "Skew PNG",
|
||||
description:
|
||||
"Shifts content horizontally and vertically — a perspective effect.",
|
||||
category: "geometry",
|
||||
params: [
|
||||
{
|
||||
id: "degX",
|
||||
label: "Skew X, °",
|
||||
type: "slider",
|
||||
min: -80,
|
||||
max: 80,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
{
|
||||
id: "degY",
|
||||
label: "Skew Y, °",
|
||||
type: "slider",
|
||||
min: -80,
|
||||
max: 80,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
],
|
||||
run: (img, p) => skewImage(img, num(p, "degX"), num(p, "degY")),
|
||||
},
|
||||
{
|
||||
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",
|
||||
params: [
|
||||
{
|
||||
id: "angle",
|
||||
label: "Angle, °",
|
||||
type: "slider",
|
||||
min: -180,
|
||||
max: 180,
|
||||
step: 1,
|
||||
default: 15,
|
||||
},
|
||||
],
|
||||
run: (img, p) => rotateFreeImage(img, num(p, "angle")),
|
||||
},
|
||||
{
|
||||
id: "zoom-png",
|
||||
title: "Zoom PNG",
|
||||
description:
|
||||
"Magnifies content toward the center. The canvas keeps its size — edges are cropped.",
|
||||
category: "geometry",
|
||||
params: [
|
||||
{
|
||||
id: "scale",
|
||||
label: "Scale, %",
|
||||
type: "slider",
|
||||
min: 100,
|
||||
max: 500,
|
||||
step: 10,
|
||||
default: 200,
|
||||
},
|
||||
],
|
||||
run: (img, p) => zoomImage(img, num(p, "scale")),
|
||||
},
|
||||
{
|
||||
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",
|
||||
params: [
|
||||
{
|
||||
id: "threshold",
|
||||
label: "Alpha threshold",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 254,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
],
|
||||
run: (img, p) => trimToContent(img, num(p, "threshold")),
|
||||
},
|
||||
{
|
||||
id: "change-canvas-size-png",
|
||||
title: "Change Canvas Size PNG",
|
||||
description:
|
||||
"Sets the exact canvas size: overflow is cropped, missing space is filled with transparency. Anchor picks which part of the image stays.",
|
||||
category: "geometry",
|
||||
params: [
|
||||
{
|
||||
id: "width",
|
||||
label: "Width",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 20000,
|
||||
step: 1,
|
||||
default: 800,
|
||||
},
|
||||
{
|
||||
id: "height",
|
||||
label: "Height",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 20000,
|
||||
step: 1,
|
||||
default: 600,
|
||||
},
|
||||
{
|
||||
id: "anchor",
|
||||
label: "Anchor",
|
||||
type: "select",
|
||||
default: "center",
|
||||
options: [
|
||||
{ value: "top-left", label: "Top left" },
|
||||
{ value: "top-center", label: "Top center" },
|
||||
{ value: "top-right", label: "Top right" },
|
||||
{ value: "middle-left", label: "Middle left" },
|
||||
{ value: "center", label: "Center" },
|
||||
{ value: "middle-right", label: "Middle right" },
|
||||
{ value: "bottom-left", label: "Bottom left" },
|
||||
{ value: "bottom-center", label: "Bottom center" },
|
||||
{ value: "bottom-right", label: "Bottom right" },
|
||||
],
|
||||
},
|
||||
],
|
||||
run: (img, p) =>
|
||||
changeCanvasSize(
|
||||
img,
|
||||
Math.trunc(num(p, "width")),
|
||||
Math.trunc(num(p, "height")),
|
||||
str(p, "anchor") as Anchor9,
|
||||
),
|
||||
},
|
||||
{
|
||||
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",
|
||||
params: [
|
||||
{
|
||||
id: "ratio",
|
||||
label: "Target ratio",
|
||||
type: "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" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "mode",
|
||||
label: "Mode",
|
||||
type: "select",
|
||||
default: "crop",
|
||||
options: [
|
||||
{ value: "crop", label: "Crop to fill" },
|
||||
{ value: "pad", label: "Pad to fit" },
|
||||
],
|
||||
},
|
||||
],
|
||||
run: (img, p) => {
|
||||
const [rw, rh] = str(p, "ratio").split(":").map(Number);
|
||||
const ratio = rw / rh;
|
||||
return str(p, "mode") === "pad"
|
||||
? padToRatio(img, ratio)
|
||||
: cropToRatio(img, ratio);
|
||||
},
|
||||
},
|
||||
{
|
||||
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",
|
||||
params: [
|
||||
{
|
||||
id: "target",
|
||||
label: "Target orientation",
|
||||
type: "select",
|
||||
default: "portrait",
|
||||
options: [
|
||||
{ value: "portrait", label: "Portrait" },
|
||||
{ value: "landscape", label: "Landscape" },
|
||||
],
|
||||
},
|
||||
],
|
||||
run: (img, p) =>
|
||||
forceOrientation(
|
||||
img,
|
||||
str(p, "target") === "landscape" ? "landscape" : "portrait",
|
||||
),
|
||||
},
|
||||
{
|
||||
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",
|
||||
params: [
|
||||
{
|
||||
id: "axis",
|
||||
label: "Mirror line",
|
||||
type: "select",
|
||||
default: "vertical",
|
||||
options: [
|
||||
{
|
||||
value: "vertical",
|
||||
label: "Vertical (double width)",
|
||||
},
|
||||
{
|
||||
value: "horizontal",
|
||||
label: "Horizontal (double height)",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "keepSide",
|
||||
label: "Keep side",
|
||||
type: "select",
|
||||
default: "left",
|
||||
options: [
|
||||
{ value: "left", label: "Left" },
|
||||
{ value: "right", label: "Right" },
|
||||
{ value: "top", label: "Top" },
|
||||
{ value: "bottom", label: "Bottom" },
|
||||
],
|
||||
},
|
||||
],
|
||||
run: (img, p) =>
|
||||
symmetricCopy(
|
||||
img,
|
||||
str(p, "axis") === "horizontal" ? "horizontal" : "vertical",
|
||||
str(p, "keepSide") as "left" | "right" | "top" | "bottom",
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "shift-png",
|
||||
title: "Shift PNG",
|
||||
description: "Moves content by the given X and Y offset.",
|
||||
category: "geometry",
|
||||
params: [
|
||||
{
|
||||
id: "offsetX",
|
||||
label: "Offset X, px",
|
||||
type: "number",
|
||||
min: -5000,
|
||||
max: 5000,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
{
|
||||
id: "offsetY",
|
||||
label: "Offset Y, px",
|
||||
type: "number",
|
||||
min: -5000,
|
||||
max: 5000,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
{
|
||||
id: "color",
|
||||
label: "Background color",
|
||||
type: "color",
|
||||
default: "#ffffff",
|
||||
},
|
||||
],
|
||||
run: (img, p) =>
|
||||
transformImage(
|
||||
img,
|
||||
[
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
-Math.trunc(num(p, "offsetX")),
|
||||
-Math.trunc(num(p, "offsetY")),
|
||||
],
|
||||
img.width,
|
||||
img.height,
|
||||
str(p, "color"),
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,315 +0,0 @@
|
||||
import type { ToolEntry } from "../registry";
|
||||
import { drawTextBlock, drawTextTile, type TextFont } from "../../core/domText";
|
||||
import { formatStamp } from "../../core/datefmt";
|
||||
import type { Position9 } from "../../core/textdraw";
|
||||
import { num, str, bool } from "../registry-helpers";
|
||||
|
||||
export function textEntries(): ToolEntry[] {
|
||||
return [
|
||||
{
|
||||
id: "add-text-png",
|
||||
title: "Add text to PNG",
|
||||
description:
|
||||
"Draws a text label on the image: font, size, color, bold, position on a 3×3 grid and an optional backing plate.",
|
||||
category: "text",
|
||||
domOnly: true,
|
||||
params: [
|
||||
{
|
||||
id: "text",
|
||||
label: "Text",
|
||||
type: "text",
|
||||
default: "Hello!",
|
||||
placeholder: "Your text",
|
||||
},
|
||||
{
|
||||
id: "fontSize",
|
||||
label: "Font size, px",
|
||||
type: "slider",
|
||||
min: 8,
|
||||
max: 200,
|
||||
step: 1,
|
||||
default: 48,
|
||||
},
|
||||
{
|
||||
id: "color",
|
||||
label: "Text color",
|
||||
type: "color",
|
||||
default: "#ffffff",
|
||||
},
|
||||
{
|
||||
id: "font",
|
||||
label: "Font",
|
||||
type: "select",
|
||||
default: "sans",
|
||||
options: [
|
||||
{ value: "sans", label: "Sans-serif" },
|
||||
{ value: "serif", label: "Serif" },
|
||||
{ value: "mono", label: "Monospace" },
|
||||
],
|
||||
},
|
||||
{ id: "bold", label: "Bold", type: "checkbox", default: true },
|
||||
{
|
||||
id: "position",
|
||||
label: "Position",
|
||||
type: "select",
|
||||
default: "bottom-right",
|
||||
options: [
|
||||
{ value: "top-left", label: "Top left" },
|
||||
{ value: "top-center", label: "Top center" },
|
||||
{ value: "top-right", label: "Top right" },
|
||||
{ value: "middle-left", label: "Middle left" },
|
||||
{ value: "center", label: "Center" },
|
||||
{ value: "middle-right", label: "Middle right" },
|
||||
{ value: "bottom-left", label: "Bottom left" },
|
||||
{ value: "bottom-center", label: "Bottom center" },
|
||||
{ value: "bottom-right", label: "Bottom right" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "margin",
|
||||
label: "Margin, px",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 200,
|
||||
step: 1,
|
||||
default: 24,
|
||||
},
|
||||
{
|
||||
id: "plate",
|
||||
label: "Backing plate",
|
||||
type: "checkbox",
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
id: "plateColor",
|
||||
label: "Plate color",
|
||||
type: "color",
|
||||
default: "#000000",
|
||||
},
|
||||
{
|
||||
id: "plateOpacity",
|
||||
label: "Plate opacity, %",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 5,
|
||||
default: 60,
|
||||
},
|
||||
],
|
||||
run: (img, p) =>
|
||||
drawTextBlock(img, {
|
||||
text: str(p, "text"),
|
||||
fontSize: num(p, "fontSize"),
|
||||
font: str(p, "font") as TextFont,
|
||||
bold: bool(p, "bold"),
|
||||
color: str(p, "color"),
|
||||
opacityPercent: 100,
|
||||
position: str(p, "position") as Position9,
|
||||
margin: num(p, "margin"),
|
||||
plateColor: bool(p, "plate") ? str(p, "plateColor") : undefined,
|
||||
plateOpacityPercent: num(p, "plateOpacity"),
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "date-stamp-png",
|
||||
title: "Date stamp PNG",
|
||||
description:
|
||||
"Stamps the current date and time using a format string (YYYY MM DD hh mm ss tokens). Same styling options as Add text.",
|
||||
category: "text",
|
||||
domOnly: true,
|
||||
params: [
|
||||
{
|
||||
id: "format",
|
||||
label: "Format",
|
||||
type: "text",
|
||||
default: "YYYY-MM-DD",
|
||||
placeholder: "YYYY-MM-DD hh:mm",
|
||||
},
|
||||
{
|
||||
id: "fontSize",
|
||||
label: "Font size, px",
|
||||
type: "slider",
|
||||
min: 8,
|
||||
max: 200,
|
||||
step: 1,
|
||||
default: 32,
|
||||
},
|
||||
{
|
||||
id: "color",
|
||||
label: "Text color",
|
||||
type: "color",
|
||||
default: "#ffffff",
|
||||
},
|
||||
{
|
||||
id: "font",
|
||||
label: "Font",
|
||||
type: "select",
|
||||
default: "mono",
|
||||
options: [
|
||||
{ value: "sans", label: "Sans-serif" },
|
||||
{ value: "serif", label: "Serif" },
|
||||
{ value: "mono", label: "Monospace" },
|
||||
],
|
||||
},
|
||||
{ id: "bold", label: "Bold", type: "checkbox", default: false },
|
||||
{
|
||||
id: "position",
|
||||
label: "Position",
|
||||
type: "select",
|
||||
default: "bottom-right",
|
||||
options: [
|
||||
{ value: "top-left", label: "Top left" },
|
||||
{ value: "top-center", label: "Top center" },
|
||||
{ value: "top-right", label: "Top right" },
|
||||
{ value: "middle-left", label: "Middle left" },
|
||||
{ value: "center", label: "Center" },
|
||||
{ value: "middle-right", label: "Middle right" },
|
||||
{ value: "bottom-left", label: "Bottom left" },
|
||||
{ value: "bottom-center", label: "Bottom center" },
|
||||
{ value: "bottom-right", label: "Bottom right" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "margin",
|
||||
label: "Margin, px",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 200,
|
||||
step: 1,
|
||||
default: 20,
|
||||
},
|
||||
{
|
||||
id: "plate",
|
||||
label: "Backing plate",
|
||||
type: "checkbox",
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
id: "plateColor",
|
||||
label: "Plate color",
|
||||
type: "color",
|
||||
default: "#000000",
|
||||
},
|
||||
{
|
||||
id: "plateOpacity",
|
||||
label: "Plate opacity, %",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 5,
|
||||
default: 55,
|
||||
},
|
||||
],
|
||||
run: (img, p) =>
|
||||
drawTextBlock(img, {
|
||||
text: formatStamp(new Date(), str(p, "format")),
|
||||
fontSize: num(p, "fontSize"),
|
||||
font: str(p, "font") as TextFont,
|
||||
bold: bool(p, "bold"),
|
||||
color: str(p, "color"),
|
||||
opacityPercent: 100,
|
||||
position: str(p, "position") as Position9,
|
||||
margin: num(p, "margin"),
|
||||
plateColor: bool(p, "plate") ? str(p, "plateColor") : undefined,
|
||||
plateOpacityPercent: num(p, "plateOpacity"),
|
||||
}),
|
||||
},
|
||||
{
|
||||
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,
|
||||
params: [
|
||||
{
|
||||
id: "text",
|
||||
label: "Text",
|
||||
type: "text",
|
||||
default: "DRAFT",
|
||||
placeholder: "Watermark text",
|
||||
},
|
||||
{
|
||||
id: "fontSize",
|
||||
label: "Font size, px",
|
||||
type: "slider",
|
||||
min: 12,
|
||||
max: 160,
|
||||
step: 1,
|
||||
default: 56,
|
||||
},
|
||||
{
|
||||
id: "color",
|
||||
label: "Text color",
|
||||
type: "color",
|
||||
default: "#ffffff",
|
||||
},
|
||||
{
|
||||
id: "opacity",
|
||||
label: "Opacity, %",
|
||||
type: "slider",
|
||||
min: 5,
|
||||
max: 100,
|
||||
step: 5,
|
||||
default: 30,
|
||||
},
|
||||
{
|
||||
id: "angle",
|
||||
label: "Angle, °",
|
||||
type: "slider",
|
||||
min: -90,
|
||||
max: 90,
|
||||
step: 1,
|
||||
default: -30,
|
||||
},
|
||||
{
|
||||
id: "stepX",
|
||||
label: "Step X, px",
|
||||
type: "slider",
|
||||
min: 40,
|
||||
max: 600,
|
||||
step: 10,
|
||||
default: 220,
|
||||
},
|
||||
{
|
||||
id: "stepY",
|
||||
label: "Step Y, px",
|
||||
type: "slider",
|
||||
min: 40,
|
||||
max: 600,
|
||||
step: 10,
|
||||
default: 180,
|
||||
},
|
||||
{
|
||||
id: "font",
|
||||
label: "Font",
|
||||
type: "select",
|
||||
default: "sans",
|
||||
options: [
|
||||
{ value: "sans", label: "Sans-serif" },
|
||||
{ value: "serif", label: "Serif" },
|
||||
{ value: "mono", label: "Monospace" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "bold",
|
||||
label: "Bold",
|
||||
type: "checkbox",
|
||||
default: true,
|
||||
},
|
||||
],
|
||||
run: (img, p) =>
|
||||
drawTextTile(img, {
|
||||
text: str(p, "text"),
|
||||
fontSize: num(p, "fontSize"),
|
||||
font: str(p, "font") as TextFont,
|
||||
bold: bool(p, "bold"),
|
||||
color: str(p, "color"),
|
||||
opacityPercent: num(p, "opacity"),
|
||||
stepX: num(p, "stepX"),
|
||||
stepY: num(p, "stepY"),
|
||||
angleDeg: num(p, "angle"),
|
||||
}),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
export function createAutoRunner(delayMs = 300) {
|
||||
let token = 0;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
return {
|
||||
next(): number {
|
||||
cancelTimer();
|
||||
return ++token;
|
||||
},
|
||||
isCurrent(t: number): boolean {
|
||||
return t === token;
|
||||
},
|
||||
schedule(run: () => void): () => void {
|
||||
cancelTimer();
|
||||
timer = setTimeout(run, delayMs);
|
||||
return cancelTimer;
|
||||
},
|
||||
};
|
||||
|
||||
function cancelTimer() {
|
||||
if (timer !== undefined) {
|
||||
clearTimeout(timer);
|
||||
timer = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { executeStep } from "./executor";
|
||||
import { makeImage } from "../../core/test-helpers";
|
||||
import type { PixelImage } from "../../core/types";
|
||||
|
||||
function cloneOf(img: PixelImage): PixelImage {
|
||||
return {
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
data: new Uint8ClampedArray(img.data),
|
||||
};
|
||||
}
|
||||
|
||||
describe("executeStep: прямой путь (среда без Worker)", () => {
|
||||
it("возвращает результат инструмента", async () => {
|
||||
const img = makeImage(1, 1, [[1, 2, 3, 4]]);
|
||||
const out = await executeStep(
|
||||
{ id: "stub", run: (i) => cloneOf(i) },
|
||||
img,
|
||||
{},
|
||||
);
|
||||
expect(out).toEqual(img);
|
||||
});
|
||||
|
||||
it("пробрасывает ошибку инструмента как есть", async () => {
|
||||
const boom = () => {
|
||||
throw new Error("бум");
|
||||
};
|
||||
await expect(
|
||||
executeStep(
|
||||
{ id: "stub", run: boom },
|
||||
makeImage(1, 1, [[0, 0, 0, 255]]),
|
||||
{},
|
||||
),
|
||||
).rejects.toThrow("бум");
|
||||
});
|
||||
|
||||
it("инструмент без run даёт понятную ошибку", async () => {
|
||||
await expect(
|
||||
executeStep({ id: "stub" }, makeImage(1, 1, [[0, 0, 0, 255]]), {}),
|
||||
).rejects.toThrow("errors.noImageRun");
|
||||
});
|
||||
});
|
||||
@@ -1,136 +0,0 @@
|
||||
import type { PixelImage } from "../../core/types";
|
||||
import { ToolError } from "../../core/errors";
|
||||
|
||||
type MaybeRunnable = {
|
||||
id: string;
|
||||
domOnly?: boolean;
|
||||
run?: (
|
||||
img: PixelImage,
|
||||
params: Record<string, unknown>,
|
||||
) => Promise<PixelImage> | PixelImage;
|
||||
};
|
||||
|
||||
export async function executeStep(
|
||||
tool: MaybeRunnable,
|
||||
img: PixelImage,
|
||||
params: Record<string, unknown>,
|
||||
): Promise<PixelImage> {
|
||||
if (!tool.run) {
|
||||
throw new Error("errors.noImageRun");
|
||||
}
|
||||
if (tool.domOnly) {
|
||||
return await runDirect(tool, img, params);
|
||||
}
|
||||
if (typeof Worker === "undefined") {
|
||||
return await runDirect(tool, img, params);
|
||||
}
|
||||
const worker = ensureWorker();
|
||||
if (worker === null) {
|
||||
return await runDirect(tool, img, params);
|
||||
}
|
||||
try {
|
||||
return await runInWorker(worker, tool.id, img, params);
|
||||
} catch (workerError) {
|
||||
disableWorker();
|
||||
void workerError;
|
||||
return await runDirect(tool, img, params);
|
||||
}
|
||||
}
|
||||
|
||||
async function runDirect(
|
||||
tool: MaybeRunnable,
|
||||
img: PixelImage,
|
||||
params: Record<string, unknown>,
|
||||
): Promise<PixelImage> {
|
||||
if (!tool.run) {
|
||||
throw new Error("errors.noImageRun");
|
||||
}
|
||||
return await tool.run(img, params);
|
||||
}
|
||||
|
||||
let worker: Worker | null = null;
|
||||
let workerTried = false;
|
||||
let nextRequestId = 1;
|
||||
const pending = new Map<
|
||||
number,
|
||||
{ resolve: (img: PixelImage) => void; reject: (e: Error) => void }
|
||||
>();
|
||||
|
||||
function ensureWorker(): Worker | null {
|
||||
if (workerTried) return worker;
|
||||
workerTried = true;
|
||||
try {
|
||||
const candidate = new Worker(
|
||||
new URL("./executor.worker.ts", import.meta.url),
|
||||
{
|
||||
type: "module",
|
||||
},
|
||||
);
|
||||
candidate.onmessage = (event: MessageEvent) => {
|
||||
const payload = event.data as {
|
||||
id: number;
|
||||
ok: boolean;
|
||||
width?: number;
|
||||
height?: number;
|
||||
data?: Uint8ClampedArray;
|
||||
error?: string;
|
||||
errorKey?: string;
|
||||
errorVars?: Record<string, string | number>;
|
||||
};
|
||||
const entry = pending.get(payload.id);
|
||||
if (!entry) return;
|
||||
pending.delete(payload.id);
|
||||
if (payload.ok && payload.data && payload.width && payload.height) {
|
||||
entry.resolve({
|
||||
width: payload.width,
|
||||
height: payload.height,
|
||||
data: new Uint8ClampedArray(payload.data),
|
||||
});
|
||||
} else if (payload.errorKey) {
|
||||
entry.reject(new ToolError(payload.errorKey, payload.errorVars));
|
||||
} else {
|
||||
entry.reject(new Error(payload.error ?? "errors.workerFailed"));
|
||||
}
|
||||
};
|
||||
candidate.onerror = () => {
|
||||
worker = null;
|
||||
for (const entry of pending.values()) {
|
||||
entry.reject(new Error("errors.workerUnavailable"));
|
||||
}
|
||||
pending.clear();
|
||||
};
|
||||
worker = candidate;
|
||||
} catch {
|
||||
worker = null;
|
||||
}
|
||||
return worker;
|
||||
}
|
||||
|
||||
function disableWorker(): void {
|
||||
if (worker) {
|
||||
worker.terminate();
|
||||
}
|
||||
worker = null;
|
||||
}
|
||||
|
||||
function runInWorker(
|
||||
workerInstance: Worker,
|
||||
toolId: string,
|
||||
img: PixelImage,
|
||||
params: Record<string, unknown>,
|
||||
): Promise<PixelImage> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = nextRequestId++;
|
||||
pending.set(id, { resolve, reject });
|
||||
workerInstance.postMessage({
|
||||
id,
|
||||
toolId,
|
||||
image: {
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
data: new Uint8ClampedArray(img.data),
|
||||
},
|
||||
params,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
/// <reference lib="webworker" />
|
||||
import type { PixelImage } from "../../core/types";
|
||||
import { ToolError } from "../../core/errors";
|
||||
import { getTool, sanitizeParams } from "../registry";
|
||||
|
||||
type WorkerRequest = {
|
||||
id: number;
|
||||
toolId: string;
|
||||
image: { width: number; height: number; data: Uint8ClampedArray };
|
||||
params: Record<string, unknown>;
|
||||
};
|
||||
|
||||
self.onmessage = (event: MessageEvent<WorkerRequest>) => {
|
||||
void handle(event.data);
|
||||
};
|
||||
|
||||
async function handle(request: WorkerRequest): Promise<void> {
|
||||
try {
|
||||
const tool = getTool(request.toolId);
|
||||
if (!tool?.run) {
|
||||
throw new Error("errors.noImageRun");
|
||||
}
|
||||
const image: PixelImage = {
|
||||
width: request.image.width,
|
||||
height: request.image.height,
|
||||
data: new Uint8ClampedArray(request.image.data),
|
||||
};
|
||||
const output = await tool.run(image, sanitizeParams(tool, request.params));
|
||||
const payload = {
|
||||
id: request.id,
|
||||
ok: true,
|
||||
width: output.width,
|
||||
height: output.height,
|
||||
data: output.data,
|
||||
};
|
||||
(self as unknown as Worker).postMessage(payload, [output.data.buffer]);
|
||||
} catch (e) {
|
||||
const toolError = e instanceof ToolError ? e : undefined;
|
||||
(self as unknown as Worker).postMessage({
|
||||
id: request.id,
|
||||
ok: false,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
errorKey: toolError?.key,
|
||||
errorVars: toolError?.vars,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { PixelImage } from "$lib/core/types";
|
||||
|
||||
/**
|
||||
* Картинка-знак для watermark-image-png. Живёт только в состоянии страницы:
|
||||
* сознательно не сохраняется в pipeline/localStorage — после восстановления
|
||||
* цепочки знак нужно выбрать заново.
|
||||
*/
|
||||
let overlay = $state<PixelImage | null>(null);
|
||||
|
||||
export function getOverlay(): PixelImage | null {
|
||||
return overlay;
|
||||
}
|
||||
|
||||
export function setOverlay(img: PixelImage): void {
|
||||
overlay = img;
|
||||
}
|
||||
|
||||
export function clearOverlay(): void {
|
||||
overlay = null;
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createStep, parseDocument } from "./pipeline";
|
||||
|
||||
describe("createStep", () => {
|
||||
it("создаёт шаг с дефолтными значениями инструмента", () => {
|
||||
const step = createStep("grayscale-png");
|
||||
expect(step.toolId).toBe("grayscale-png");
|
||||
expect(step.id.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("бросает ошибку для неизвестного инструмента", () => {
|
||||
expect(() => createStep("no-such-tool")).toThrow(/errors\.toolNotFound/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseDocument", () => {
|
||||
const valid = JSON.stringify({
|
||||
version: 1,
|
||||
steps: [
|
||||
{ id: "a", toolId: "grayscale-png", values: {} },
|
||||
{
|
||||
id: "b",
|
||||
toolId: "adjust-brightness-contrast-png",
|
||||
values: { brightness: 5000, contrast: "мусор" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
it("разбирает корректный документ и санитизирует значения", () => {
|
||||
const steps = parseDocument(valid);
|
||||
expect(steps).toHaveLength(2);
|
||||
expect(steps[0].id).toBe("a");
|
||||
expect(steps[1].values).toEqual({ brightness: 100, contrast: 0 });
|
||||
});
|
||||
|
||||
it.each([
|
||||
"не json",
|
||||
"{}",
|
||||
JSON.stringify({ version: 99, steps: [] }),
|
||||
JSON.stringify({ version: 1 }),
|
||||
])("отклоняет мусор: %s", (raw) => {
|
||||
expect(() => parseDocument(raw as string)).toThrow();
|
||||
});
|
||||
|
||||
it("пропускает шаги с неизвестным инструментом", () => {
|
||||
const raw = JSON.stringify({
|
||||
version: 1,
|
||||
steps: [
|
||||
{ id: "x", toolId: "nope", values: {} },
|
||||
{ id: "y", toolId: "flip-png", values: { axis: "vertical" } },
|
||||
],
|
||||
});
|
||||
const steps = parseDocument(raw);
|
||||
expect(steps.map((s) => s.id)).toEqual(["y"]);
|
||||
});
|
||||
});
|
||||
@@ -1,102 +0,0 @@
|
||||
import { getTool, sanitizeParams } from "../registry";
|
||||
import { ToolError } from "../../core/errors";
|
||||
|
||||
export type PipelineStep = {
|
||||
id: string;
|
||||
toolId: string;
|
||||
values: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type PipelineDocument = { version: number; steps: PipelineStep[] };
|
||||
|
||||
export const PIPELINE_VERSION = 1;
|
||||
const STORAGE_KEY = "workspace-pipeline";
|
||||
|
||||
export function createStep(toolId: string): PipelineStep {
|
||||
const tool = getTool(toolId);
|
||||
if (!tool) {
|
||||
throw new ToolError("errors.toolNotFound", { id: toolId });
|
||||
}
|
||||
return { id: newStepId(), toolId, values: {} };
|
||||
}
|
||||
|
||||
export function newStepId(): string {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `step-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e9).toString(36)}`;
|
||||
}
|
||||
|
||||
export function parseDocument(raw: string): PipelineStep[] {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new ToolError("errors.badJson");
|
||||
}
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
throw new ToolError("errors.badPipelineShape");
|
||||
}
|
||||
const doc = parsed as { version?: unknown; steps?: unknown };
|
||||
if (doc.version !== PIPELINE_VERSION) {
|
||||
throw new ToolError("errors.pipelineVersion", {
|
||||
version: String(doc.version),
|
||||
});
|
||||
}
|
||||
if (!Array.isArray(doc.steps)) {
|
||||
throw new ToolError("errors.noSteps");
|
||||
}
|
||||
const steps: PipelineStep[] = [];
|
||||
for (const rawStep of doc.steps) {
|
||||
const step = validateStep(rawStep);
|
||||
if (step) {
|
||||
steps.push(step);
|
||||
}
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
|
||||
function validateStep(raw: unknown): PipelineStep | null {
|
||||
if (typeof raw !== "object" || raw === null) return null;
|
||||
const candidate = raw as { id?: unknown; toolId?: unknown; values?: unknown };
|
||||
if (typeof candidate.toolId !== "string") return null;
|
||||
const tool = getTool(candidate.toolId);
|
||||
if (
|
||||
!tool ||
|
||||
typeof candidate.values !== "object" ||
|
||||
candidate.values === null
|
||||
)
|
||||
return null;
|
||||
const values = candidate.values as Record<string, unknown>;
|
||||
return {
|
||||
id:
|
||||
typeof candidate.id === "string" && candidate.id.length > 0
|
||||
? candidate.id
|
||||
: newStepId(),
|
||||
toolId: candidate.toolId,
|
||||
values: sanitizeParams(tool, values),
|
||||
};
|
||||
}
|
||||
|
||||
export function saveSteps(steps: PipelineStep[]): void {
|
||||
if (typeof localStorage === "undefined") return;
|
||||
try {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({ version: PIPELINE_VERSION, steps }),
|
||||
);
|
||||
} catch {
|
||||
// переполнение квоты или приватный режим — сохранение необязательно для работы
|
||||
}
|
||||
}
|
||||
|
||||
export function loadStoredSteps(): PipelineStep[] {
|
||||
if (typeof localStorage === "undefined") return [];
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
return parseDocument(raw);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
import {
|
||||
AppWindow,
|
||||
Binary,
|
||||
Blend,
|
||||
CalendarDays,
|
||||
ClipboardPaste,
|
||||
Circle,
|
||||
Contrast,
|
||||
Crop,
|
||||
Crosshair,
|
||||
Dices,
|
||||
Droplet,
|
||||
Droplets,
|
||||
Eye,
|
||||
FileImage,
|
||||
FileOutput,
|
||||
FilePlus2,
|
||||
Film,
|
||||
FlipHorizontal2,
|
||||
Focus,
|
||||
Frame,
|
||||
Grid3x3,
|
||||
Hammer,
|
||||
Hash,
|
||||
Image as ImageIcon,
|
||||
Info,
|
||||
Layers,
|
||||
Link2,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Moon,
|
||||
PaintBucket,
|
||||
Palette,
|
||||
Rainbow,
|
||||
Repeat2,
|
||||
RotateCw,
|
||||
Ruler,
|
||||
Scaling,
|
||||
Scan,
|
||||
Scissors,
|
||||
SearchCheck,
|
||||
FaceSlightlySmiling,
|
||||
Square,
|
||||
Stamp,
|
||||
Star,
|
||||
Sun,
|
||||
Type,
|
||||
WavesHorizontal,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
} from "@lucide/svelte";
|
||||
|
||||
export const TOOL_ICONS: Record<string, typeof AppWindow> = {
|
||||
"resize-png": Scaling,
|
||||
"crop-png": Crop,
|
||||
"rotate-png": RotateCw,
|
||||
"flip-png": FlipHorizontal2,
|
||||
"grayscale-png": Contrast,
|
||||
"invert-colors-png": Blend,
|
||||
"adjust-brightness-contrast-png": Sun,
|
||||
"convert-png-to-jpg": FileImage,
|
||||
"convert-png-to-webp": FileImage,
|
||||
"remove-color-from-png": Scissors,
|
||||
"png-info": Info,
|
||||
"jpg-to-png": FileImage,
|
||||
"webp-to-png": FileImage,
|
||||
"gif-to-png": Film,
|
||||
"bmp-to-png": FileImage,
|
||||
"ico-to-png": AppWindow,
|
||||
"png-to-bmp": FileOutput,
|
||||
"png-to-base64": Binary,
|
||||
"base64-to-png": ClipboardPaste,
|
||||
"png-to-data-uri": Link2,
|
||||
"data-uri-to-png": ClipboardPaste,
|
||||
"png-to-hex": Hash,
|
||||
"hex-to-png": Hash,
|
||||
"change-png-opacity": Droplet,
|
||||
"sepia-png": Palette,
|
||||
"change-png-hue": Rainbow,
|
||||
"extract-channel-png": Layers,
|
||||
"swap-channels-png": Repeat2,
|
||||
"black-and-white-png": Contrast,
|
||||
"posterize-png": Layers,
|
||||
"two-colors-png": Blend,
|
||||
"invert-alpha-png": Droplets,
|
||||
"remove-alpha-channel-png": Square,
|
||||
"set-alpha-channel-png": Droplet,
|
||||
"extract-alpha-mask-png": Layers,
|
||||
"round-corners-png": Frame,
|
||||
"add-padding-png": Maximize2,
|
||||
"add-border-png": Frame,
|
||||
"fit-on-background-png": ImageIcon,
|
||||
"tile-png": Grid3x3,
|
||||
"center-by-alpha-png": Crosshair,
|
||||
"create-empty-png": FilePlus2,
|
||||
"single-color-png": PaintBucket,
|
||||
"random-noise-png": Dices,
|
||||
"linear-gradient-png": Sun,
|
||||
"png-is-grayscale": SearchCheck,
|
||||
"png-is-transparent": Eye,
|
||||
"png-orientation": Ruler,
|
||||
"blur-png": Droplets,
|
||||
"sharpen-png": Focus,
|
||||
"remove-background-png": Scissors,
|
||||
"add-stroke-png": Square,
|
||||
"add-text-png": Type,
|
||||
"date-stamp-png": CalendarDays,
|
||||
"png-to-hsl": Blend,
|
||||
"png-to-hsv": Droplet,
|
||||
"png-to-hsi": Focus,
|
||||
"png-to-cmyk": PaintBucket,
|
||||
"png-to-ycbcr": Layers,
|
||||
"png-to-lab": Ruler,
|
||||
"show-transparent-png": Eye,
|
||||
"show-grayscale-pixels-png": Contrast,
|
||||
"show-color-pixels-png": Rainbow,
|
||||
"light-pixel-mask-png": Sun,
|
||||
"dark-pixel-mask-png": Moon,
|
||||
"unique-color-mask-png": Dices,
|
||||
"extract-color-from-png": Focus,
|
||||
"quantize-png": Contrast,
|
||||
"decrease-color-count-png": Scaling,
|
||||
"custom-palette-png": Palette,
|
||||
"dithering-png": Dices,
|
||||
"feather-edges-png": Droplets,
|
||||
"clean-edges-png": Scissors,
|
||||
"pixelate-png": Grid3x3,
|
||||
"randomize-pixels-png": Dices,
|
||||
"add-noise-png": Hash,
|
||||
"silhouette-png": Contrast,
|
||||
"trim-empty-space-png": Crop,
|
||||
"change-canvas-size-png": Frame,
|
||||
"change-aspect-ratio-png": Scaling,
|
||||
"swap-orientation-png": RotateCw,
|
||||
"symmetric-copy-png": FlipHorizontal2,
|
||||
"compress-png": Minimize2,
|
||||
"reduce-to-size-png": Scaling,
|
||||
"png-file-size": Info,
|
||||
"png-to-bytes": Binary,
|
||||
"bytes-to-png": Binary,
|
||||
"png-to-rgb-values": Hash,
|
||||
"rgb-values-to-png": Hash,
|
||||
"verify-is-png": SearchCheck,
|
||||
"text-to-png": Type,
|
||||
"emoji-to-png": FaceSlightlySmiling,
|
||||
"placeholder-png": Frame,
|
||||
"color-spectrum-png": Rainbow,
|
||||
"random-colors-png": Dices,
|
||||
"draw-grid-png": Grid3x3,
|
||||
"circle-mask-png": Circle,
|
||||
"square-mask-png": Square,
|
||||
"star-mask-png": Star,
|
||||
"wavy-mask-png": WavesHorizontal,
|
||||
"watermark-tile-png": Stamp,
|
||||
"watermark-image-png": FileImage,
|
||||
"color-wheel-png": Rainbow,
|
||||
"complementary-png": Contrast,
|
||||
"triadic-png": Hash,
|
||||
"tetradic-png": Grid3x3,
|
||||
"analogous-png": Blend,
|
||||
"monochromatic-png": Droplets,
|
||||
"shades-png": Sun,
|
||||
"mix-colors-png": PaintBucket,
|
||||
"blend-two-png": Layers,
|
||||
"step-colors-png": Scaling,
|
||||
"sort-colors-png": SearchCheck,
|
||||
"find-contour-png": Scan,
|
||||
"make-thicker-png": ZoomIn,
|
||||
"make-thinner-png": ZoomOut,
|
||||
"harden-alpha-png": Sun,
|
||||
"despeckle-alpha-png": SearchCheck,
|
||||
"close-holes-png": Hammer,
|
||||
"skew-png": Repeat2,
|
||||
"rotate-free-png": RotateCw,
|
||||
"zoom-png": ZoomIn,
|
||||
"shift-png": Crosshair,
|
||||
"vignette-png": Sun,
|
||||
"jpeg-artifacts-png": Layers,
|
||||
"gamma-png": Contrast,
|
||||
"auto-contrast-png": Contrast,
|
||||
"temperature-png": Sun,
|
||||
"tint-png": Droplet,
|
||||
"svg-to-png": FileImage,
|
||||
};
|
||||
@@ -1,53 +0,0 @@
|
||||
export const TOOL_POPULARITY: Record<string, number> = {
|
||||
"crop-png": 92,
|
||||
"resize-png": 90,
|
||||
"remove-background-png": 95,
|
||||
"blur-png": 85,
|
||||
"sharpen-png": 80,
|
||||
"convert-png-to-jpg": 82,
|
||||
"jpg-to-png": 78,
|
||||
"rotate-png": 75,
|
||||
"flip-png": 70,
|
||||
"convert-png-to-webp": 68,
|
||||
"webp-to-png": 62,
|
||||
"grayscale-png": 60,
|
||||
"adjust-brightness-contrast-png": 58,
|
||||
"invert-colors-png": 52,
|
||||
"remove-color-from-png": 65,
|
||||
"change-png-opacity": 45,
|
||||
"sepia-png": 40,
|
||||
"change-png-hue": 38,
|
||||
"add-padding-png": 42,
|
||||
"add-border-png": 38,
|
||||
"two-colors-png": 35,
|
||||
"black-and-white-png": 42,
|
||||
"posterize-png": 30,
|
||||
"round-corners-png": 36,
|
||||
"fit-on-background-png": 34,
|
||||
"extract-channel-png": 24,
|
||||
"swap-channels-png": 22,
|
||||
"invert-alpha-png": 26,
|
||||
"remove-alpha-channel-png": 30,
|
||||
"set-alpha-channel-png": 24,
|
||||
"extract-alpha-mask-png": 20,
|
||||
"tile-png": 28,
|
||||
"center-by-alpha-png": 26,
|
||||
"png-info": 44,
|
||||
"png-is-transparent": 18,
|
||||
"png-is-grayscale": 14,
|
||||
"png-orientation": 16,
|
||||
"png-to-base64": 40,
|
||||
"base64-to-png": 32,
|
||||
"png-to-data-uri": 34,
|
||||
"data-uri-to-png": 26,
|
||||
"png-to-hex": 10,
|
||||
"hex-to-png": 8,
|
||||
"gif-to-png": 30,
|
||||
"bmp-to-png": 18,
|
||||
"ico-to-png": 20,
|
||||
"png-to-bmp": 12,
|
||||
"create-empty-png": 25,
|
||||
"single-color-png": 20,
|
||||
"random-noise-png": 12,
|
||||
"linear-gradient-png": 22,
|
||||
};
|
||||
@@ -1,176 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from "$app/paths";
|
||||
import { getTheme, initTheme, setTheme } from "$lib/theme.svelte";
|
||||
import { LOCALES, type Locale } from "$lib/v1/i18n/dict";
|
||||
import { getLocale, initLocale, setLocale } from "$lib/v1/i18n/locale.svelte";
|
||||
import { t } from "$lib/v1/i18n/t";
|
||||
import { onMount } from "svelte";
|
||||
import "../../app_v1.css";
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
onMount(() => {
|
||||
initLocale();
|
||||
initTheme();
|
||||
});
|
||||
|
||||
const LANG_LABELS: Record<Locale, string> = { ru: "RU", en: "EN" };
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={`${resolve("/")}favicon.svg`} />
|
||||
</svelte:head>
|
||||
|
||||
<div class="app">
|
||||
<header>
|
||||
<a href={resolve("/v1")} class="brand">easy-png-tools</a>
|
||||
<nav aria-label={t("header.sectionsAria")}>
|
||||
<a class="nav-link workspace-link" href={resolve("/v1")}>
|
||||
{t("header.workspace")}
|
||||
</a>
|
||||
<a class="nav-link" href={resolve("/v1/list-tools")}>
|
||||
{t("header.catalog")}
|
||||
</a>
|
||||
<div class="lang-switch" role="group" aria-label="Language / Язык">
|
||||
{#each LOCALES as l (l)}
|
||||
<button
|
||||
type="button"
|
||||
class="lang-btn"
|
||||
class:active={getLocale() === l}
|
||||
aria-pressed={getLocale() === l}
|
||||
onclick={() => setLocale(l)}
|
||||
>
|
||||
{LANG_LABELS[l]}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="theme-switch" role="group" aria-label="Theme / Тема">
|
||||
<button
|
||||
type="button"
|
||||
class="lang-btn"
|
||||
class:active={getTheme() === "light"}
|
||||
aria-pressed={getTheme() === "light"}
|
||||
title={t("ui.themeLight")}
|
||||
aria-label={t("ui.themeLight")}
|
||||
onclick={() => setTheme("light")}
|
||||
>
|
||||
☀
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="lang-btn"
|
||||
class:active={getTheme() === "dark"}
|
||||
aria-pressed={getTheme() === "dark"}
|
||||
title={t("ui.themeDark")}
|
||||
aria-label={t("ui.themeDark")}
|
||||
onclick={() => setTheme("dark")}
|
||||
>
|
||||
☾
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{@render children()}
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p class="text-caption text-muted">{t("header.footerNote")}</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.app {
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.brand:hover {
|
||||
text-decoration: none;
|
||||
color: var(--link);
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-s);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-s);
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: var(--link);
|
||||
background: var(--bg);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.lang-switch,
|
||||
.theme-switch {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
margin-left: var(--space-2);
|
||||
padding: 2px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-s);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.lang-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-s);
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: calc(var(--radius-s) - 1px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.lang-btn:hover {
|
||||
color: var(--link);
|
||||
}
|
||||
|
||||
.lang-btn.active {
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
.workspace-link {
|
||||
font-weight: 600;
|
||||
color: var(--link);
|
||||
}
|
||||
main {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
padding: var(--space-4) var(--space-5);
|
||||
}
|
||||
footer {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,119 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { t } from "$lib/v1/i18n/t";
|
||||
import { toolTitle } from "$lib/v1/i18n/tool-strings";
|
||||
import ToolPage from "$lib/v1/components/ToolPage.svelte";
|
||||
import ToolSearch from "$lib/v1/components/search/ToolSearch.svelte";
|
||||
import Button from "$lib/v1/components/ui/Button.svelte";
|
||||
import { getTool } from "$lib/v1/registry";
|
||||
|
||||
const LAST_TOOL_KEY = "last-tool-id";
|
||||
|
||||
function loadLastToolId(): string | null {
|
||||
if (typeof localStorage === "undefined") return null;
|
||||
try {
|
||||
const id = localStorage.getItem(LAST_TOOL_KEY);
|
||||
return id !== null && getTool(id) ? id : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
let selectedId = $state<string | null>(null);
|
||||
let restoreOnOpen = $state(false);
|
||||
let lastToolId = $state<string | null>(loadLastToolId());
|
||||
|
||||
function openTool(id: string, restore = false) {
|
||||
if (!getTool(id)) return;
|
||||
selectedId = id;
|
||||
restoreOnOpen = restore;
|
||||
lastToolId = id;
|
||||
if (typeof localStorage === "undefined") return;
|
||||
try {
|
||||
localStorage.setItem(LAST_TOOL_KEY, id);
|
||||
} catch {
|
||||
// приватный режим — просто не запоминаем
|
||||
}
|
||||
}
|
||||
|
||||
function closeTool() {
|
||||
selectedId = null;
|
||||
}
|
||||
|
||||
let selected = $derived(
|
||||
selectedId !== null ? (getTool(selectedId) ?? null) : null,
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>
|
||||
{selected ? `${selected.title} — easy-png-tools` : t("home.defaultTitle")}
|
||||
</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if !selected}
|
||||
<section class="hero">
|
||||
<h1>{t("home.heroTitle")}</h1>
|
||||
<p class="lead text-muted">{t("home.heroLead")}</p>
|
||||
<ToolSearch onSelect={(id) => openTool(id)} />
|
||||
{#if lastToolId !== null && getTool(lastToolId)}
|
||||
{@const restoreId = lastToolId}
|
||||
<div class="restore-row">
|
||||
<Button
|
||||
variant="secondary"
|
||||
fullWidth
|
||||
onclick={() => openTool(restoreId, true)}
|
||||
>
|
||||
{t("home.restoreLast", {
|
||||
title: getTool(restoreId) ? toolTitle(getTool(restoreId)!) : "",
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{:else}
|
||||
<section class="workbench">
|
||||
<button
|
||||
type="button"
|
||||
class="back text-caption text-muted"
|
||||
onclick={closeTool}
|
||||
>
|
||||
{t("home.changeTool")}
|
||||
</button>
|
||||
{#key selectedId}
|
||||
<ToolPage tool={selected} restoreChain={restoreOnOpen} />
|
||||
{/key}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
text-align: center;
|
||||
padding-top: var(--space-5);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.lead {
|
||||
max-width: 40rem;
|
||||
margin: 0 auto var(--space-4);
|
||||
}
|
||||
|
||||
.restore-row {
|
||||
max-width: 36rem;
|
||||
margin: var(--space-2) auto 0;
|
||||
}
|
||||
|
||||
.back {
|
||||
display: inline-block;
|
||||
margin-bottom: var(--space-3);
|
||||
border: 0;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.back:hover {
|
||||
color: var(--link);
|
||||
}
|
||||
</style>
|
||||
@@ -1,40 +0,0 @@
|
||||
<script lang="ts">
|
||||
import ToolPage from "$lib/v1/components/ToolPage.svelte";
|
||||
import { getTool } from "$lib/v1/registry";
|
||||
|
||||
const base = getTool("linear-gradient-png")!;
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Mockup: цепочка инструментов — easy-png-tools</title>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
</svelte:head>
|
||||
|
||||
<section class="demo">
|
||||
<ToolPage
|
||||
tool={base}
|
||||
stageVariant="inline"
|
||||
presetBaseValues={{
|
||||
width: 520,
|
||||
height: 340,
|
||||
fromColor: "#e5484d",
|
||||
toColor: "#fbbf24",
|
||||
direction: "horizontal",
|
||||
}}
|
||||
presetChain={[
|
||||
{
|
||||
toolId: "remove-background-png",
|
||||
values: { color: "#fbbf24", tolerance: 10, outerOnly: true, smooth: 2 },
|
||||
},
|
||||
{ toolId: "add-stroke-png", values: { color: "#2563eb", thickness: 8 } },
|
||||
{ toolId: "round-corners-png", values: { radius: 28 } },
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.demo {
|
||||
max-width: 100rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
@@ -1,83 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from "$app/paths";
|
||||
import { t } from "$lib/v1/i18n/t";
|
||||
import { toolDescription, toolTitle } from "$lib/v1/i18n/tool-strings";
|
||||
import { CATEGORIES } from "$lib/v1/categories";
|
||||
import ToolCard from "$lib/v1/components/search/ToolCard.svelte";
|
||||
import { TOOLS } from "$lib/v1/registry";
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{t("catalog.pageTitle")}</title>
|
||||
<meta name="description" content={t("catalog.metaDescription")} />
|
||||
</svelte:head>
|
||||
|
||||
<h1>{t("catalog.heading")}</h1>
|
||||
<p class="lead text-muted">{t("catalog.lead", { count: TOOLS.length })}</p>
|
||||
|
||||
{#each CATEGORIES as category (category)}
|
||||
{@const categoryTools = TOOLS.filter((tool) => tool.category === category)}
|
||||
{#if categoryTools.length > 0}
|
||||
<section
|
||||
id={category}
|
||||
class="category"
|
||||
aria-labelledby="{category}-heading"
|
||||
>
|
||||
<h2 id="{category}-heading" class="heading-section">
|
||||
{t(`categories.${category}`)}
|
||||
</h2>
|
||||
<div class="grid">
|
||||
{#each categoryTools as tool (tool.id)}
|
||||
<div class="panel">
|
||||
<ToolCard
|
||||
toolId={tool.id}
|
||||
title={toolTitle(tool)}
|
||||
description={toolDescription(tool)}
|
||||
href={resolve(`/tools/${tool.id}`)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<style>
|
||||
h1 {
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.lead {
|
||||
max-width: 48rem;
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.category {
|
||||
margin-bottom: var(--space-5);
|
||||
scroll-margin-top: 5rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.grid > .panel {
|
||||
padding: var(--space-2) var(--space-2) var(--space-3);
|
||||
transition:
|
||||
border-color var(--transition-fast),
|
||||
box-shadow var(--transition-fast),
|
||||
transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.grid > .panel:hover {
|
||||
border-color: var(--accent);
|
||||
box-shadow: var(--shadow-card);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,23 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { t } from "$lib/v1/i18n/t";
|
||||
import { toolDescription, toolTitle } from "$lib/v1/i18n/tool-strings";
|
||||
import ToolPage from "$lib/v1/components/ToolPage.svelte";
|
||||
import { getTool } from "$lib/v1/registry";
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
const tool = $derived(getTool(data.id));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title
|
||||
>{tool ? toolTitle(tool) : t("toolPage.fallbackTitle")} — easy-png-tools</title
|
||||
>
|
||||
{#if tool}
|
||||
<meta name="description" content={toolDescription(tool)} />
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
{#if tool}
|
||||
<ToolPage {tool} />
|
||||
{/if}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { t } from "$lib/v1/i18n/t";
|
||||
import { getTool, TOOLS } from "$lib/v1/registry";
|
||||
import { error } from "@sveltejs/kit";
|
||||
import type { EntryGenerator, PageLoad } from "./$types";
|
||||
|
||||
export const entries: EntryGenerator = () =>
|
||||
TOOLS.map((tool) => ({ id: tool.id }));
|
||||
|
||||
export const load: PageLoad = ({ params }) => {
|
||||
const tool = getTool(params.id);
|
||||
if (!tool) {
|
||||
error(404, t("errors.notFound"));
|
||||
}
|
||||
return { id: tool.id };
|
||||
};
|
||||
Reference in New Issue
Block a user