feat: fix tools pages, dom audit fix
This commit is contained in:
+2
-1
@@ -8,7 +8,8 @@
|
|||||||
"dev": "pnpm --dir web dev",
|
"dev": "pnpm --dir web dev",
|
||||||
"build": "pnpm --dir web build",
|
"build": "pnpm --dir web build",
|
||||||
"format": "pnpm --dir web format",
|
"format": "pnpm --dir web format",
|
||||||
"html": "pnpm --dir refs build && node scripts/extract-static.mjs refs/.next/server/app refs-html"
|
"html": "pnpm --dir refs build && node scripts/extract-static.mjs refs/.next/server/app refs-html",
|
||||||
|
"dom-audit": "pnpm --dir web refs-dom-audit"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "Ku6epXBOCTuK",
|
"author": "Ku6epXBOCTuK",
|
||||||
|
|||||||
+15
-221
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"generatedAt": "2026-08-29T02:50:59.952Z",
|
"generatedAt": "2026-08-29T04:04:14.643Z",
|
||||||
"reports": [
|
"reports": [
|
||||||
{
|
{
|
||||||
"route": "/preview/demo",
|
"route": "/preview/demo",
|
||||||
@@ -30,233 +30,27 @@
|
|||||||
{
|
{
|
||||||
"route": "/preview/tools/linear-gradient-png",
|
"route": "/preview/tools/linear-gradient-png",
|
||||||
"ref": "gradient.html",
|
"ref": "gradient.html",
|
||||||
"deltas": [
|
"deltas": [],
|
||||||
{
|
|
||||||
"path": "body > main:1 > div:2 > section:1",
|
|
||||||
"type": "tagMismatch",
|
|
||||||
"ours": "section",
|
|
||||||
"ref": "div"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "body > main:1 > div:2 > section:1",
|
|
||||||
"type": "textMismatch",
|
|
||||||
"ours": "",
|
|
||||||
"ref": "PNG PROCESSING SINGLE TOOL"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "body > main:1 > div:2 > section:1 > section:1",
|
|
||||||
"type": "added",
|
|
||||||
"tag": "section",
|
|
||||||
"text": "",
|
|
||||||
"nodes": 145
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "body > main:1 > div:2 > section:1 > span:1",
|
|
||||||
"type": "removed",
|
|
||||||
"tag": "span",
|
|
||||||
"text": "/",
|
|
||||||
"nodes": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "body > main:1 > div:2 > section:2",
|
|
||||||
"type": "tagMismatch",
|
|
||||||
"ours": "section",
|
|
||||||
"ref": "div"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "body > main:1 > div:2 > section:2 > header:1",
|
|
||||||
"type": "added",
|
|
||||||
"tag": "header",
|
|
||||||
"text": "",
|
|
||||||
"nodes": 5
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "body > main:1 > div:2 > section:2 > div:2 > div:1",
|
|
||||||
"type": "added",
|
|
||||||
"tag": "div",
|
|
||||||
"text": "",
|
|
||||||
"nodes": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "body > main:1 > div:2 > section:2 > div:2 > div:2",
|
|
||||||
"type": "added",
|
|
||||||
"tag": "div",
|
|
||||||
"text": "",
|
|
||||||
"nodes": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "body > main:1 > div:2 > section:2 > div:2 > h1:1",
|
|
||||||
"type": "removed",
|
|
||||||
"tag": "h1",
|
|
||||||
"text": "Gradient background.",
|
|
||||||
"nodes": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "body > main:1 > div:2 > section:2 > div:2 > p:2",
|
|
||||||
"type": "removed",
|
|
||||||
"tag": "p",
|
|
||||||
"text": "Create a clean, export-ready gradient with precise control over color, direction and transparency.",
|
|
||||||
"nodes": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "body > main:1 > div:2 > section:2 > span:2",
|
|
||||||
"type": "removed",
|
|
||||||
"tag": "span",
|
|
||||||
"text": "LIVE PREVIEW",
|
|
||||||
"nodes": 2
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "body > main:1 > div:2 > div:3",
|
|
||||||
"type": "removed",
|
|
||||||
"tag": "div",
|
|
||||||
"text": "",
|
|
||||||
"nodes": 66
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "body > main:1 > footer:3 > span:2",
|
|
||||||
"type": "added",
|
|
||||||
"tag": "span",
|
|
||||||
"text": "pipeline is local-only",
|
|
||||||
"nodes": 2
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "body > main:1 > footer:3 > span:2",
|
|
||||||
"type": "removed",
|
|
||||||
"tag": "span",
|
|
||||||
"text": "gradient tool · local-only",
|
|
||||||
"nodes": 1
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"counts": {
|
"counts": {
|
||||||
"added": 5,
|
"added": 0,
|
||||||
"removed": 6,
|
"removed": 0,
|
||||||
"tagMismatch": 2,
|
"tagMismatch": 0,
|
||||||
"textMismatch": 1,
|
"textMismatch": 0,
|
||||||
"nodesAdded": 154,
|
"nodesAdded": 0,
|
||||||
"nodesRemoved": 72
|
"nodesRemoved": 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"route": "/preview/tools/remove-background-png",
|
"route": "/preview/tools/remove-background-png",
|
||||||
"ref": "background-remover.html",
|
"ref": "background-remover.html",
|
||||||
"deltas": [
|
"deltas": [],
|
||||||
{
|
|
||||||
"path": "body > main:1 > div:2 > section:1",
|
|
||||||
"type": "tagMismatch",
|
|
||||||
"ours": "section",
|
|
||||||
"ref": "div"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "body > main:1 > div:2 > section:1",
|
|
||||||
"type": "textMismatch",
|
|
||||||
"ours": "",
|
|
||||||
"ref": "PNG PROCESSING SINGLE TOOL"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "body > main:1 > div:2 > section:1 > section:1",
|
|
||||||
"type": "added",
|
|
||||||
"tag": "section",
|
|
||||||
"text": "",
|
|
||||||
"nodes": 138
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "body > main:1 > div:2 > section:1 > span:1",
|
|
||||||
"type": "removed",
|
|
||||||
"tag": "span",
|
|
||||||
"text": "/",
|
|
||||||
"nodes": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "body > main:1 > div:2 > section:2",
|
|
||||||
"type": "tagMismatch",
|
|
||||||
"ours": "section",
|
|
||||||
"ref": "div"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "body > main:1 > div:2 > section:2 > header:1",
|
|
||||||
"type": "added",
|
|
||||||
"tag": "header",
|
|
||||||
"text": "",
|
|
||||||
"nodes": 5
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "body > main:1 > div:2 > section:2 > div:2 > div:1",
|
|
||||||
"type": "added",
|
|
||||||
"tag": "div",
|
|
||||||
"text": "",
|
|
||||||
"nodes": 4
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "body > main:1 > div:2 > section:2 > div:2 > div:2",
|
|
||||||
"type": "added",
|
|
||||||
"tag": "div",
|
|
||||||
"text": "",
|
|
||||||
"nodes": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "body > main:1 > div:2 > section:2 > div:2 > div:3",
|
|
||||||
"type": "added",
|
|
||||||
"tag": "div",
|
|
||||||
"text": "",
|
|
||||||
"nodes": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "body > main:1 > div:2 > section:2 > div:2 > h1:1",
|
|
||||||
"type": "removed",
|
|
||||||
"tag": "h1",
|
|
||||||
"text": "Remove background.",
|
|
||||||
"nodes": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "body > main:1 > div:2 > section:2 > div:2 > p:2",
|
|
||||||
"type": "removed",
|
|
||||||
"tag": "p",
|
|
||||||
"text": "Select a background color and tune the edge detection. Changes are processed automatically in your browser.",
|
|
||||||
"nodes": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "body > main:1 > div:2 > section:2 > span:2",
|
|
||||||
"type": "removed",
|
|
||||||
"tag": "span",
|
|
||||||
"text": "LIVE PREVIEW",
|
|
||||||
"nodes": 2
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "body > main:1 > div:2 > div:3",
|
|
||||||
"type": "removed",
|
|
||||||
"tag": "div",
|
|
||||||
"text": "",
|
|
||||||
"nodes": 71
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "body > main:1 > footer:3 > span:2",
|
|
||||||
"type": "added",
|
|
||||||
"tag": "span",
|
|
||||||
"text": "pipeline is local-only",
|
|
||||||
"nodes": 2
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "body > main:1 > footer:3 > span:2",
|
|
||||||
"type": "removed",
|
|
||||||
"tag": "span",
|
|
||||||
"text": "background remover · local-only",
|
|
||||||
"nodes": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "body > div:2",
|
|
||||||
"type": "added",
|
|
||||||
"tag": "div",
|
|
||||||
"text": "",
|
|
||||||
"nodes": 1
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"counts": {
|
"counts": {
|
||||||
"added": 7,
|
"added": 0,
|
||||||
"removed": 6,
|
"removed": 0,
|
||||||
"tagMismatch": 2,
|
"tagMismatch": 0,
|
||||||
"textMismatch": 1,
|
"textMismatch": 0,
|
||||||
"nodesAdded": 152,
|
"nodesAdded": 0,
|
||||||
"nodesRemoved": 77
|
"nodesRemoved": 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
+9
-37
@@ -1,6 +1,6 @@
|
|||||||
# DOM audit (phase A)
|
# DOM audit (phase A)
|
||||||
|
|
||||||
_2026-08-29T02:50:59.953Z_
|
_2026-08-29T04:04:14.645Z_
|
||||||
|
|
||||||
## Сводка
|
## Сводка
|
||||||
|
|
||||||
@@ -8,8 +8,8 @@ _2026-08-29T02:50:59.953Z_
|
|||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| `/preview/demo` | demo.html | 0 | 0 | 0 | 0 |
|
| `/preview/demo` | demo.html | 0 | 0 | 0 | 0 |
|
||||||
| `/preview/list-tools` | list-tools.html | 0 | 0 | 0 | 0 |
|
| `/preview/list-tools` | list-tools.html | 0 | 0 | 0 | 0 |
|
||||||
| `/preview/tools/linear-gradient-png` | gradient.html | 5 | 6 | 2 | 1 |
|
| `/preview/tools/linear-gradient-png` | gradient.html | 0 | 0 | 0 | 0 |
|
||||||
| `/preview/tools/remove-background-png` | background-remover.html | 7 | 6 | 2 | 1 |
|
| `/preview/tools/remove-background-png` | background-remover.html | 0 | 0 | 0 | 0 |
|
||||||
|
|
||||||
## /preview/demo (vs demo.html)
|
## /preview/demo (vs demo.html)
|
||||||
|
|
||||||
@@ -31,45 +31,17 @@ _расхождений нет_
|
|||||||
|
|
||||||
## /preview/tools/linear-gradient-png (vs gradient.html)
|
## /preview/tools/linear-gradient-png (vs gradient.html)
|
||||||
|
|
||||||
- added: **5** (≈154 узл.), removed: **6** (≈72 узл.), tag: **2**, text: **1**
|
- added: **0** (≈0 узл.), removed: **0** (≈0 узл.), tag: **0**, text: **0**
|
||||||
|
|
||||||
### Расхождения (топ 14)
|
### Расхождения (топ 0)
|
||||||
|
|
||||||
- `body > main:1 > div:2 > section:1` **tag**: ours `section` → ref `div`
|
_расхождений нет_
|
||||||
- `body > main:1 > div:2 > section:1` **text**: ours `` → ref `PNG PROCESSING SINGLE TOOL`
|
|
||||||
- `body > main:1 > div:2 > section:1 > section:1` **added** (`section` · 145 узл.)
|
|
||||||
- `body > main:1 > div:2 > section:1 > span:1` **removed** (`span` "/" · 1 узл.)
|
|
||||||
- `body > main:1 > div:2 > section:2` **tag**: ours `section` → ref `div`
|
|
||||||
- `body > main:1 > div:2 > section:2 > header:1` **added** (`header` · 5 узл.)
|
|
||||||
- `body > main:1 > div:2 > section:2 > div:2 > div:1` **added** (`div` · 1 узл.)
|
|
||||||
- `body > main:1 > div:2 > section:2 > div:2 > div:2` **added** (`div` · 1 узл.)
|
|
||||||
- `body > main:1 > div:2 > section:2 > div:2 > h1:1` **removed** (`h1` "Gradient background." · 1 узл.)
|
|
||||||
- `body > main:1 > div:2 > section:2 > div:2 > p:2` **removed** (`p` "Create a clean, export-ready gradient with precise control over color, direction and transparency." · 1 узл.)
|
|
||||||
- `body > main:1 > div:2 > section:2 > span:2` **removed** (`span` "LIVE PREVIEW" · 2 узл.)
|
|
||||||
- `body > main:1 > div:2 > div:3` **removed** (`div` · 66 узл.)
|
|
||||||
- `body > main:1 > footer:3 > span:2` **added** (`span` "pipeline is local-only" · 2 узл.)
|
|
||||||
- `body > main:1 > footer:3 > span:2` **removed** (`span` "gradient tool · local-only" · 1 узл.)
|
|
||||||
|
|
||||||
|
|
||||||
## /preview/tools/remove-background-png (vs background-remover.html)
|
## /preview/tools/remove-background-png (vs background-remover.html)
|
||||||
|
|
||||||
- added: **7** (≈152 узл.), removed: **6** (≈77 узл.), tag: **2**, text: **1**
|
- added: **0** (≈0 узл.), removed: **0** (≈0 узл.), tag: **0**, text: **0**
|
||||||
|
|
||||||
### Расхождения (топ 16)
|
### Расхождения (топ 0)
|
||||||
|
|
||||||
- `body > main:1 > div:2 > section:1` **tag**: ours `section` → ref `div`
|
_расхождений нет_
|
||||||
- `body > main:1 > div:2 > section:1` **text**: ours `` → ref `PNG PROCESSING SINGLE TOOL`
|
|
||||||
- `body > main:1 > div:2 > section:1 > section:1` **added** (`section` · 138 узл.)
|
|
||||||
- `body > main:1 > div:2 > section:1 > span:1` **removed** (`span` "/" · 1 узл.)
|
|
||||||
- `body > main:1 > div:2 > section:2` **tag**: ours `section` → ref `div`
|
|
||||||
- `body > main:1 > div:2 > section:2 > header:1` **added** (`header` · 5 узл.)
|
|
||||||
- `body > main:1 > div:2 > section:2 > div:2 > div:1` **added** (`div` · 4 узл.)
|
|
||||||
- `body > main:1 > div:2 > section:2 > div:2 > div:2` **added** (`div` · 1 узл.)
|
|
||||||
- `body > main:1 > div:2 > section:2 > div:2 > div:3` **added** (`div` · 1 узл.)
|
|
||||||
- `body > main:1 > div:2 > section:2 > div:2 > h1:1` **removed** (`h1` "Remove background." · 1 узл.)
|
|
||||||
- `body > main:1 > div:2 > section:2 > div:2 > p:2` **removed** (`p` "Select a background color and tune the edge detection. Changes are processed automatically in your browser." · 1 узл.)
|
|
||||||
- `body > main:1 > div:2 > section:2 > span:2` **removed** (`span` "LIVE PREVIEW" · 2 узл.)
|
|
||||||
- `body > main:1 > div:2 > div:3` **removed** (`div` · 71 узл.)
|
|
||||||
- `body > main:1 > footer:3 > span:2` **added** (`span` "pipeline is local-only" · 2 узл.)
|
|
||||||
- `body > main:1 > footer:3 > span:2` **removed** (`span` "background remover · local-only" · 1 узл.)
|
|
||||||
- `body > div:2` **added** (`div` · 1 узл.)
|
|
||||||
|
|||||||
+26
-14
@@ -6,7 +6,6 @@
|
|||||||
import { chromium } from "playwright";
|
import { chromium } from "playwright";
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { readFileSync, writeFileSync, mkdirSync, readdirSync } from "node:fs";
|
import { readFileSync, writeFileSync, mkdirSync, readdirSync } from "node:fs";
|
||||||
import { pathToFileURL } from "node:url";
|
|
||||||
import { resolve, join } from "node:path";
|
import { resolve, join } from "node:path";
|
||||||
|
|
||||||
const args = (() => {
|
const args = (() => {
|
||||||
@@ -145,6 +144,9 @@ const snapshot = (page) =>
|
|||||||
const walk = (el) => {
|
const walk = (el) => {
|
||||||
const tag = el.tagName.toLowerCase();
|
const tag = el.tagName.toLowerCase();
|
||||||
if (["script", "style", "noscript", "template"].includes(tag)) return null;
|
if (["script", "style", "noscript", "template"].includes(tag)) return null;
|
||||||
|
// SvelteKit ін'єктить #svelte-announcer (aria-live) прямо в <body>;
|
||||||
|
// у рефа (Next.js) його немає — це фреймворк-шум, не збіг дизайну.
|
||||||
|
if (el.id === "svelte-announcer") return null;
|
||||||
// Пропускаем не отображаемые узлы (Next-боилерплейт: <div hidden>,
|
// Пропускаем не отображаемые узлы (Next-боилерплейт: <div hidden>,
|
||||||
// оверлеи), чтобы они не конкурировали за матчинг с реальным контентом.
|
// оверлеи), чтобы они не конкурировали за матчинг с реальным контентом.
|
||||||
if (el.hasAttribute("hidden")) return null;
|
if (el.hasAttribute("hidden")) return null;
|
||||||
@@ -261,19 +263,29 @@ function tally(deltas) {
|
|||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function auditOne(page, target) {
|
async function auditOne(browser, target) {
|
||||||
const oursUrl = args.ours || `http://127.0.0.1:${PORT}${target.route}`;
|
const oursUrl = args.ours || `http://127.0.0.1:${PORT}${target.route}`;
|
||||||
const refUrl = pathToFileURL(resolve(REFS_DIR, target.ref)).href;
|
|
||||||
|
|
||||||
|
const page = await browser.newPage({
|
||||||
|
viewport: { width: 1440, height: 900 },
|
||||||
|
deviceScaleFactor: 1,
|
||||||
|
});
|
||||||
await page.goto(oursUrl, { waitUntil: "load" });
|
await page.goto(oursUrl, { waitUntil: "load" });
|
||||||
await page.evaluate(() => document.fonts && document.fonts.ready);
|
await page.waitForTimeout(600);
|
||||||
await page.waitForTimeout(300);
|
|
||||||
const ours = await snapshot(page);
|
const ours = await snapshot(page);
|
||||||
|
await page.close();
|
||||||
|
|
||||||
await page.goto(refUrl, { waitUntil: "load" });
|
// Реф грузим через goto(file://) в ИЗОЛИРОВАННОМ контексте: так http-страница
|
||||||
await page.evaluate(() => document.fonts && document.fonts.ready);
|
// и статический реф не делят один execution context (переход http → file в
|
||||||
await page.waitForTimeout(300);
|
// одной странице уничтожал контекст и ронял прогон). setContent не годится —
|
||||||
const ref = await snapshot(page);
|
// он не воспроизводит DOM рефа (губит 1 узел), поэтому используем goto.
|
||||||
|
const refUrl = "file:///" + resolve(REFS_DIR, target.ref).replace(/\\/g, "/");
|
||||||
|
const ctx = await browser.newContext();
|
||||||
|
const refPage = await ctx.newPage();
|
||||||
|
await refPage.goto(refUrl, { waitUntil: "load" });
|
||||||
|
await refPage.waitForTimeout(400);
|
||||||
|
const ref = await snapshot(refPage);
|
||||||
|
await ctx.close();
|
||||||
|
|
||||||
const deltas = [];
|
const deltas = [];
|
||||||
diff(ours, ref, "body", deltas);
|
diff(ours, ref, "body", deltas);
|
||||||
@@ -348,18 +360,18 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const browser = await chromium.launch();
|
const browser = await chromium.launch();
|
||||||
const page = await browser.newPage({
|
|
||||||
viewport: { width: 1440, height: 900 },
|
|
||||||
deviceScaleFactor: 1,
|
|
||||||
});
|
|
||||||
const reports = [];
|
const reports = [];
|
||||||
try {
|
try {
|
||||||
for (const target of targets) {
|
for (const target of targets) {
|
||||||
const rep = await auditOne(page, target);
|
try {
|
||||||
|
const rep = await auditOne(browser, target);
|
||||||
reports.push(rep);
|
reports.push(rep);
|
||||||
console.log(
|
console.log(
|
||||||
`[dom-audit] ${rep.route}: added=${rep.counts.added} removed=${rep.counts.removed} tag=${rep.counts.tagMismatch} text=${rep.counts.textMismatch}`,
|
`[dom-audit] ${rep.route}: added=${rep.counts.added} removed=${rep.counts.removed} tag=${rep.counts.tagMismatch} text=${rep.counts.textMismatch}`,
|
||||||
);
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[dom-audit] ${target.route} failed: ${e.message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
mkdirSync(resolve(WEB, "audit"), { recursive: true });
|
mkdirSync(resolve(WEB, "audit"), { recursive: true });
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ToolEntry } from "$lib/registry";
|
||||||
|
import {
|
||||||
|
getPreviewView,
|
||||||
|
initForm,
|
||||||
|
type FieldValue,
|
||||||
|
type PreviewToolView,
|
||||||
|
type SegmentedField,
|
||||||
|
} from "$lib/preview/tool-views";
|
||||||
|
import { Copy, Download, Check, RotateCcw, ChevronDown } from "@lucide/svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
tool: ToolEntry;
|
||||||
|
}
|
||||||
|
let { tool }: Props = $props();
|
||||||
|
|
||||||
|
const view = $derived(getPreviewView(tool));
|
||||||
|
let form = $state<Record<string, FieldValue>>(view ? initForm(view) : {});
|
||||||
|
$effect(() => {
|
||||||
|
if (view) form = initForm(view);
|
||||||
|
});
|
||||||
|
function reset() {
|
||||||
|
if (view) form = initForm(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
const num = (v: FieldValue) => v as number;
|
||||||
|
const str = (v: FieldValue) => v as string;
|
||||||
|
const bool = (v: FieldValue) => v as boolean;
|
||||||
|
const pair = (v: FieldValue) => v as { from: string; to: string };
|
||||||
|
|
||||||
|
const grad = $derived.by(() => {
|
||||||
|
if (!view || view.preview !== "gradient") return null;
|
||||||
|
const typeSel = num(form["type"]);
|
||||||
|
const type = (view.fields.find((f) => f.id === "type") as SegmentedField).options[typeSel];
|
||||||
|
const cp = pair(form["stops"]);
|
||||||
|
const dir = num(form["dir"]);
|
||||||
|
const op = num(form["op"]);
|
||||||
|
const css =
|
||||||
|
type === "Radial"
|
||||||
|
? `radial-gradient(circle, ${cp.from} 0%, ${cp.to} 100%)`
|
||||||
|
: `linear-gradient(${dir}deg, ${cp.from} 0%, ${cp.to} 100%)`;
|
||||||
|
return { type, cp, dir, op, css, code: `background: ${css};\nopacity: ${op / 100};` };
|
||||||
|
});
|
||||||
|
|
||||||
|
const DIRS = [0, 45, 90, 135, 180, 225, 270, 315];
|
||||||
|
const DIR_GLYPHS = ["→", "↗", "↑", "↖", "←", "↙", "↓", "↘"];
|
||||||
|
const SUBJECT_BG = "linear-gradient(145deg,#1769d2 0 38%,#00a8c7 38% 66%,#bd7411 66%)";
|
||||||
|
const SUBJECT_CLIP = "polygon(23% 11%, 73% 8%, 89% 30%, 79% 81%, 52% 93%, 17% 78%, 8% 39%)";
|
||||||
|
const CHECKER = "repeating-conic-gradient(#d7dce0 0 25%, #f3f5f6 0 50%) 50% / 28px 28px";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if view}
|
||||||
|
<div class="tool-page">
|
||||||
|
<div class="eyebrow">PNG PROCESSING <span>/</span> SINGLE TOOL</div>
|
||||||
|
<div class="tool-title">
|
||||||
|
<div>
|
||||||
|
<h1>{view.title}</h1>
|
||||||
|
<p class="lede">{view.lede}</p>
|
||||||
|
</div>
|
||||||
|
<span class="tool-status"><i></i> LIVE PREVIEW</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={view.layoutClass}>
|
||||||
|
<section class="settings-panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<div>
|
||||||
|
<span class="label">{view.panelLabel}</span>
|
||||||
|
<strong>{view.panelStrong}</strong>
|
||||||
|
</div>
|
||||||
|
<span class="step-type">{view.stepType}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#each view.fields as f}
|
||||||
|
{#if f.kind === "segmented"}
|
||||||
|
{@const sel = num(form[f.id])}
|
||||||
|
<div class="setting-group">
|
||||||
|
<label>{f.label}</label>
|
||||||
|
<div class="segmented wide">
|
||||||
|
{#each f.options as opt, i}
|
||||||
|
<button class={i === sel ? "selected" : ""} onclick={() => (form[f.id] = i)}>
|
||||||
|
{opt}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if f.kind === "color-pair"}
|
||||||
|
{@const cp = pair(form[f.id])}
|
||||||
|
<div class="setting-group">
|
||||||
|
<label>{f.label}</label>
|
||||||
|
<div class="color-row">
|
||||||
|
<div class="color-field">
|
||||||
|
<span class="swatch" style="background:{cp.from}"></span>
|
||||||
|
<input
|
||||||
|
value={cp.from}
|
||||||
|
oninput={(e) => (form[f.id] = { from: e.currentTarget.value, to: cp.to })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="stop-arrow">→</span>
|
||||||
|
<div class="color-field">
|
||||||
|
<span class="swatch" style="background:{cp.to}"></span>
|
||||||
|
<input
|
||||||
|
value={cp.to}
|
||||||
|
oninput={(e) => (form[f.id] = { from: cp.from, to: e.currentTarget.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="gradient-bar" style="background:{grad?.css}"></div>
|
||||||
|
</div>
|
||||||
|
{:else if f.kind === "slider" || f.kind === "direction"}
|
||||||
|
{@const v = num(form[f.id])}
|
||||||
|
<div class="setting-group">
|
||||||
|
<label>{f.label} <output>{v}{f.space ? " " : ""}{f.suffix}</output></label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={f.min}
|
||||||
|
max={f.max}
|
||||||
|
value={v}
|
||||||
|
oninput={(e) => (form[f.id] = Number(e.currentTarget.value))}
|
||||||
|
/>
|
||||||
|
{#if f.kind === "direction"}
|
||||||
|
<div class="direction-grid">
|
||||||
|
{#each DIRS as a, i}
|
||||||
|
<button class={a === v ? "selected" : ""} onclick={() => (form[f.id] = a)}>
|
||||||
|
{a}° {DIR_GLYPHS[i]}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if f.hints}
|
||||||
|
<div class="range-hints">
|
||||||
|
<span>{f.hints[0]}</span>
|
||||||
|
<span>{f.hints[1]}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else if f.kind === "color"}
|
||||||
|
{@const v = str(form[f.id])}
|
||||||
|
<div class="setting-group">
|
||||||
|
<label>{f.label}</label>
|
||||||
|
<div class="color-field">
|
||||||
|
<span class="swatch" style="background:{v}"></span>
|
||||||
|
<input value={v} oninput={(e) => (form[f.id] = e.currentTarget.value)} />
|
||||||
|
{#if f.native}
|
||||||
|
<input
|
||||||
|
class="native-color"
|
||||||
|
type="color"
|
||||||
|
aria-label="Choose background color"
|
||||||
|
value={v}
|
||||||
|
oninput={(e) => (form[f.id] = e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if f.reference}
|
||||||
|
<div class="color-reference">
|
||||||
|
<span style="background:{v}"></span> sampled from image background
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else if f.kind === "toggle"}
|
||||||
|
{@const on = bool(form[f.id])}
|
||||||
|
<div class="setting-group toggle-group">
|
||||||
|
<label>
|
||||||
|
<span>{f.label}</span>
|
||||||
|
<b class="toggle" class:on onclick={() => (form[f.id] = !on)}></b>
|
||||||
|
</label>
|
||||||
|
<p>{f.note}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<div class="settings-footer">
|
||||||
|
<button class="reset-btn" onclick={reset}>
|
||||||
|
<RotateCcw size={14} /> Reset
|
||||||
|
</button>
|
||||||
|
<span class="auto-note"><i></i> updates automatically</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if view.preview === "gradient"}
|
||||||
|
<section class={view.previewClass}>
|
||||||
|
<div class="preview-toolbar">
|
||||||
|
<div>
|
||||||
|
<span class="label">{view.toolbarLabel}</span>
|
||||||
|
<strong>{view.fileName}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="preview-actions">
|
||||||
|
<button class="secondary-btn"><Copy size={15} /> Copy CSS</button>
|
||||||
|
<button class="download-btn"
|
||||||
|
><Download size={15} /> {view.downloadLabel} <ChevronDown size={14} /></button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="large-canvas">
|
||||||
|
<div
|
||||||
|
class="gradient-art"
|
||||||
|
style="background:{grad?.css};opacity:{grad ? grad.op / 100 : 1}"
|
||||||
|
>
|
||||||
|
<div class="art-mark">PNG</div>
|
||||||
|
<span>easy-png-tools</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="code-block">
|
||||||
|
<div>
|
||||||
|
<span class="label">GENERATED CSS</span>
|
||||||
|
<button class="icon-btn" aria-label="Copy CSS"><Copy size={14} /></button>
|
||||||
|
</div>
|
||||||
|
<pre>{grad?.code}</pre>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{:else}
|
||||||
|
<section class={view.previewClass}>
|
||||||
|
<div class="preview-toolbar">
|
||||||
|
<div>
|
||||||
|
<span class="label">{view.toolbarLabel}</span>
|
||||||
|
<strong>{view.fileName}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="preview-actions">
|
||||||
|
<span class="processed"><Check size={14} /> processed</span>
|
||||||
|
<button class="download-btn"><Download size={15} /> Download result</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="comparison-grid">
|
||||||
|
<div class="image-card">
|
||||||
|
<div class="image-label"><span>SOURCE</span><b>original.png</b></div>
|
||||||
|
<div class="remover-canvas source-canvas">
|
||||||
|
<div
|
||||||
|
class="subject subject-source"
|
||||||
|
style="background:{SUBJECT_BG};opacity:1;clip-path:{SUBJECT_CLIP}"
|
||||||
|
>
|
||||||
|
<span>OBJECT</span>
|
||||||
|
</div>
|
||||||
|
<span class="canvas-size">1200 × 800</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="image-card">
|
||||||
|
<div class="image-label"><span>RESULT</span><b>removed-bg.png</b></div>
|
||||||
|
<div class="remover-canvas" style="background:{CHECKER}">
|
||||||
|
<div
|
||||||
|
class="subject"
|
||||||
|
style="background:{SUBJECT_BG};opacity:1;clip-path:{SUBJECT_CLIP}"
|
||||||
|
>
|
||||||
|
<span>PNG</span>
|
||||||
|
</div>
|
||||||
|
<span class="canvas-size">1200 × 800</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="result-meta">
|
||||||
|
<span>FORMAT <b>PNG-24</b></span>
|
||||||
|
<span>ALPHA <b>ENABLED</b></span>
|
||||||
|
<span>SIMILARITY <b>72 %</b></span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="tool-page">
|
||||||
|
<div class="eyebrow">PNG PROCESSING <span>/</span> SINGLE TOOL</div>
|
||||||
|
<div class="tool-title">
|
||||||
|
<div>
|
||||||
|
<h1>{tool.title}</h1>
|
||||||
|
<p class="lede">{tool.description}</p>
|
||||||
|
</div>
|
||||||
|
<span class="tool-status"><i></i> LIVE PREVIEW</span>
|
||||||
|
</div>
|
||||||
|
<div class="tool-layout">
|
||||||
|
<section class="settings-panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<div>
|
||||||
|
<span class="label">{tool.category.toUpperCase()} SETTINGS</span>
|
||||||
|
<strong>Configure output</strong>
|
||||||
|
</div>
|
||||||
|
<span class="step-type">TOOL 01</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-footer">
|
||||||
|
<span class="auto-note"><i></i> preview coming soon</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import type { ToolEntry } from "$lib/registry";
|
||||||
|
|
||||||
|
export type SegmentedField = {
|
||||||
|
id: string;
|
||||||
|
kind: "segmented";
|
||||||
|
label: string;
|
||||||
|
options: string[];
|
||||||
|
selected: number;
|
||||||
|
};
|
||||||
|
export type ColorPairField = {
|
||||||
|
id: string;
|
||||||
|
kind: "color-pair";
|
||||||
|
label: string;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
};
|
||||||
|
export type SliderField = {
|
||||||
|
id: string;
|
||||||
|
kind: "slider" | "direction";
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
suffix: string;
|
||||||
|
space?: boolean;
|
||||||
|
hints?: [string, string];
|
||||||
|
};
|
||||||
|
export type ColorField = {
|
||||||
|
id: string;
|
||||||
|
kind: "color";
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
native?: boolean;
|
||||||
|
reference?: boolean;
|
||||||
|
};
|
||||||
|
export type ToggleField = {
|
||||||
|
id: string;
|
||||||
|
kind: "toggle";
|
||||||
|
label: string;
|
||||||
|
checked: boolean;
|
||||||
|
note: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FieldDef =
|
||||||
|
| SegmentedField
|
||||||
|
| ColorPairField
|
||||||
|
| SliderField
|
||||||
|
| ColorField
|
||||||
|
| ToggleField;
|
||||||
|
|
||||||
|
export interface PreviewToolView {
|
||||||
|
title: string;
|
||||||
|
lede: string;
|
||||||
|
panelLabel: string;
|
||||||
|
panelStrong: string;
|
||||||
|
stepType: string;
|
||||||
|
toolbarLabel: string;
|
||||||
|
fileName: string;
|
||||||
|
downloadLabel: string;
|
||||||
|
layoutClass: string;
|
||||||
|
previewClass: string;
|
||||||
|
preview: "gradient" | "comparison";
|
||||||
|
fields: FieldDef[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FieldValue = number | string | boolean | { from: string; to: string };
|
||||||
|
|
||||||
|
function initValue(f: FieldDef): FieldValue {
|
||||||
|
switch (f.kind) {
|
||||||
|
case "segmented":
|
||||||
|
return f.selected;
|
||||||
|
case "color-pair":
|
||||||
|
return { from: f.from, to: f.to };
|
||||||
|
case "color":
|
||||||
|
return f.value;
|
||||||
|
case "slider":
|
||||||
|
case "direction":
|
||||||
|
return f.value;
|
||||||
|
case "toggle":
|
||||||
|
return f.checked;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initForm(view: PreviewToolView): Record<string, FieldValue> {
|
||||||
|
const form: Record<string, FieldValue> = {};
|
||||||
|
for (const f of view.fields) form[f.id] = initValue(f);
|
||||||
|
return form;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gradient: PreviewToolView = {
|
||||||
|
title: "Gradient background.",
|
||||||
|
lede: "Create a clean, export-ready gradient with precise control over color, direction and transparency.",
|
||||||
|
panelLabel: "GRADIENT SETTINGS",
|
||||||
|
panelStrong: "Configure output",
|
||||||
|
stepType: "TOOL 01",
|
||||||
|
toolbarLabel: "OUTPUT PREVIEW",
|
||||||
|
fileName: "gradient.png",
|
||||||
|
downloadLabel: "Download PNG",
|
||||||
|
layoutClass: "gradient-layout",
|
||||||
|
previewClass: "gradient-preview",
|
||||||
|
preview: "gradient",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: "type",
|
||||||
|
kind: "segmented",
|
||||||
|
label: "GRADIENT TYPE",
|
||||||
|
options: ["Linear", "Radial"],
|
||||||
|
selected: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "stops",
|
||||||
|
kind: "color-pair",
|
||||||
|
label: "COLOR STOPS",
|
||||||
|
from: "#1769D2",
|
||||||
|
to: "#00A8C7",
|
||||||
|
},
|
||||||
|
{ id: "dir", kind: "direction", label: "DIRECTION", value: 135, min: 0, max: 360, suffix: "°", space: true },
|
||||||
|
{ id: "op", kind: "slider", label: "OPACITY", value: 100, min: 0, max: 100, suffix: "%", space: true },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const background: PreviewToolView = {
|
||||||
|
title: "Remove background.",
|
||||||
|
lede: "Select a background color and tune the edge detection. Changes are processed automatically in your browser.",
|
||||||
|
panelLabel: "REMOVER SETTINGS",
|
||||||
|
panelStrong: "Configure detection",
|
||||||
|
stepType: "TOOL 02",
|
||||||
|
toolbarLabel: "SOURCE / RESULT",
|
||||||
|
fileName: "comparison.png",
|
||||||
|
downloadLabel: "Download result",
|
||||||
|
layoutClass: "remover-layout",
|
||||||
|
previewClass: "remover-preview",
|
||||||
|
preview: "comparison",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: "color",
|
||||||
|
kind: "color",
|
||||||
|
label: "BACKGROUND COLOR",
|
||||||
|
value: "#E8EEF2",
|
||||||
|
native: true,
|
||||||
|
reference: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sim",
|
||||||
|
kind: "slider",
|
||||||
|
label: "COLOR SIMILARITY",
|
||||||
|
value: 72,
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
suffix: "%",
|
||||||
|
space: true,
|
||||||
|
hints: ["strict edges", "more removal"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "outer",
|
||||||
|
kind: "toggle",
|
||||||
|
label: "OUTER COLOR ONLY",
|
||||||
|
checked: true,
|
||||||
|
note: "Only remove connected background pixels from the edges.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "mask",
|
||||||
|
kind: "toggle",
|
||||||
|
label: "SHOW MASK",
|
||||||
|
checked: false,
|
||||||
|
note: "Preview the detected transparency mask.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PREVIEW_TOOL_VIEWS: Record<string, PreviewToolView> = {
|
||||||
|
"linear-gradient-png": gradient,
|
||||||
|
"remove-background-png": background,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getPreviewView(tool: ToolEntry): PreviewToolView | undefined {
|
||||||
|
return PREVIEW_TOOL_VIEWS[tool.id];
|
||||||
|
}
|
||||||
@@ -34,6 +34,12 @@
|
|||||||
let crumb = $derived(toCrumb($page.url.pathname));
|
let crumb = $derived(toCrumb($page.url.pathname));
|
||||||
let status = $derived(toStatus($page.url.pathname));
|
let status = $derived(toStatus($page.url.pathname));
|
||||||
|
|
||||||
|
const FOOTER_NOTE: Record<string, string> = {
|
||||||
|
"/preview/tools/linear-gradient-png": "gradient tool · local-only",
|
||||||
|
"/preview/tools/remove-background-png": "background remover · local-only",
|
||||||
|
};
|
||||||
|
let footerNote = $derived(FOOTER_NOTE[$page.url.pathname] ?? "pipeline is local-only");
|
||||||
|
|
||||||
const CRUMB: Record<string, string> = {
|
const CRUMB: Record<string, string> = {
|
||||||
"/preview/demo": "WORKSPACE",
|
"/preview/demo": "WORKSPACE",
|
||||||
"/preview/list-tools": "CATALOG",
|
"/preview/list-tools": "CATALOG",
|
||||||
@@ -63,7 +69,9 @@
|
|||||||
{#if $page.url.pathname !== "/preview/list-tools"}
|
{#if $page.url.pathname !== "/preview/list-tools"}
|
||||||
<footer class="preview-footer">
|
<footer class="preview-footer">
|
||||||
<span>easy-png-tools <b>v2.4.0</b></span>
|
<span>easy-png-tools <b>v2.4.0</b></span>
|
||||||
<span><Link2 size={13} /> pipeline is local-only</span>
|
<span
|
||||||
|
>{#if footerNote === "pipeline is local-only"}<Link2 size={13} /> {/if}{footerNote}</span
|
||||||
|
>
|
||||||
<span>© 2024</span>
|
<span>© 2024</span>
|
||||||
</footer>
|
</footer>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,211 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { browser } from "$app/environment";
|
import { getTool } from "$lib/registry";
|
||||||
import { untrack } from "svelte";
|
import ToolView from "$lib/components/kit/ToolView.svelte";
|
||||||
import {
|
|
||||||
getTool,
|
|
||||||
defaultParams,
|
|
||||||
isChainable,
|
|
||||||
TOOLS,
|
|
||||||
} from "$lib/registry";
|
|
||||||
import { encode, downloadBlob, toDataUrl, decodeBytes } from "$lib/core/io";
|
|
||||||
import type { PixelImage } from "$lib/core/types";
|
|
||||||
import WorkspaceLayout from "$lib/components/kit/WorkspaceLayout.svelte";
|
|
||||||
import EmptyState from "$lib/components/kit/EmptyState.svelte";
|
import EmptyState from "$lib/components/kit/EmptyState.svelte";
|
||||||
import { SlidersHorizontal as ToolIcon } from "@lucide/svelte";
|
import { SlidersHorizontal as ToolIcon } from "@lucide/svelte";
|
||||||
import SettingsPanel from "./SettingsPanel.svelte";
|
|
||||||
import PreviewPanel from "./PreviewPanel.svelte";
|
|
||||||
|
|
||||||
type ParamValue = string | number | boolean;
|
|
||||||
|
|
||||||
interface ChainStep {
|
|
||||||
toolId: string;
|
|
||||||
params: Record<string, ParamValue>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: { id: string };
|
data: { id: string };
|
||||||
}
|
}
|
||||||
let { data }: Props = $props();
|
let { data }: Props = $props();
|
||||||
|
|
||||||
const initialTool = untrack(() => getTool(data.id));
|
const tool = $derived(getTool(data.id));
|
||||||
let steps = $state<ChainStep[]>(
|
|
||||||
initialTool
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
toolId: initialTool.id,
|
|
||||||
params: defaultParams(initialTool) as Record<string, ParamValue>,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [],
|
|
||||||
);
|
|
||||||
|
|
||||||
let sourceImg = $state<PixelImage | null>(null);
|
|
||||||
let sourceUrl = $state("");
|
|
||||||
let sourceName = $state("");
|
|
||||||
|
|
||||||
let stepOutputs = $state<{ toolId: string; title: string; url: string }[]>(
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
let resultImg = $state<PixelImage | null>(null);
|
|
||||||
let resultUrl = $state("");
|
|
||||||
let chainError = $state("");
|
|
||||||
let processing = $state(false);
|
|
||||||
|
|
||||||
const chainOptions = TOOLS.filter(isChainable).map((t) => ({
|
|
||||||
value: t.id,
|
|
||||||
label: t.title,
|
|
||||||
}));
|
|
||||||
let addId = $state(chainOptions[0]?.value ?? "");
|
|
||||||
|
|
||||||
const firstTool = $derived(steps[0] ? getTool(steps[0].toolId) : undefined);
|
|
||||||
const needsSource = $derived(
|
|
||||||
firstTool ? firstTool.sourceMode !== "none" : false,
|
|
||||||
);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const t = getTool(data.id);
|
|
||||||
if (t) {
|
|
||||||
steps = [
|
|
||||||
{
|
|
||||||
toolId: t.id,
|
|
||||||
params: defaultParams(t) as Record<string, ParamValue>,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
sourceImg = null;
|
|
||||||
sourceUrl = "";
|
|
||||||
sourceName = "";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!browser) return;
|
|
||||||
const chain = steps.map((s) => ({
|
|
||||||
tool: getTool(s.toolId),
|
|
||||||
params: { ...s.params },
|
|
||||||
}));
|
|
||||||
const src = sourceImg;
|
|
||||||
if (!chain.length || chain.some((c) => !c.tool)) {
|
|
||||||
resultImg = null;
|
|
||||||
resultUrl = "";
|
|
||||||
stepOutputs = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const first = chain[0].tool!;
|
|
||||||
if (first.sourceMode !== "none" && !src) {
|
|
||||||
resultImg = null;
|
|
||||||
resultUrl = "";
|
|
||||||
stepOutputs = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void runPipeline(chain, src);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function runPipeline(
|
|
||||||
chain: {
|
|
||||||
tool: ReturnType<typeof getTool>;
|
|
||||||
params: Record<string, ParamValue>;
|
|
||||||
}[],
|
|
||||||
src: PixelImage | null,
|
|
||||||
) {
|
|
||||||
processing = true;
|
|
||||||
try {
|
|
||||||
let current: PixelImage | null = src;
|
|
||||||
const outs: { toolId: string; title: string; url: string }[] = [];
|
|
||||||
for (const c of chain) {
|
|
||||||
const tool = c.tool!;
|
|
||||||
const resolve = (x: PixelImage | Promise<PixelImage>) =>
|
|
||||||
x instanceof Promise ? x : Promise.resolve(x);
|
|
||||||
let out: PixelImage;
|
|
||||||
if (!current && tool.sourceMode === "none" && tool.generate) {
|
|
||||||
out = await resolve(tool.generate(c.params));
|
|
||||||
} else if (current && tool.run) {
|
|
||||||
out = await resolve(tool.run(current, c.params));
|
|
||||||
} else if (!current && src && tool.run) {
|
|
||||||
out = await resolve(tool.run(src, c.params));
|
|
||||||
} else {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
current = out;
|
|
||||||
outs.push({ toolId: tool.id, title: tool.title, url: toDataUrl(out) });
|
|
||||||
}
|
|
||||||
stepOutputs = outs;
|
|
||||||
resultImg = current;
|
|
||||||
resultUrl = current ? toDataUrl(current) : "";
|
|
||||||
chainError = "";
|
|
||||||
} catch (e) {
|
|
||||||
chainError = String(e);
|
|
||||||
} finally {
|
|
||||||
processing = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onFile(file: File) {
|
|
||||||
if (!browser) return;
|
|
||||||
try {
|
|
||||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
||||||
const img = await decodeBytes(bytes);
|
|
||||||
sourceImg = img;
|
|
||||||
sourceName = file.name;
|
|
||||||
sourceUrl = toDataUrl(img);
|
|
||||||
chainError = "";
|
|
||||||
} catch (e) {
|
|
||||||
chainError = String(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearSource() {
|
|
||||||
sourceImg = null;
|
|
||||||
sourceUrl = "";
|
|
||||||
sourceName = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function setStepParam(index: number, id: string, value: ParamValue) {
|
|
||||||
steps[index].params[id] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function addStep() {
|
|
||||||
const tool = getTool(addId);
|
|
||||||
if (!tool) return;
|
|
||||||
steps = [
|
|
||||||
...steps,
|
|
||||||
{
|
|
||||||
toolId: tool.id,
|
|
||||||
params: defaultParams(tool) as Record<string, ParamValue>,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeStep(index: number) {
|
|
||||||
steps = steps.filter((_, i) => i !== index);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function downloadResult() {
|
|
||||||
if (!resultImg || !firstTool) return;
|
|
||||||
const blob = await encode(resultImg, "image/png");
|
|
||||||
const base = sourceName ? sourceName.replace(/\.[^.]+$/, "") : firstTool.id;
|
|
||||||
downloadBlob(blob, `${base}-pipeline.png`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const resultMeta = $derived(
|
|
||||||
resultImg
|
|
||||||
? [
|
|
||||||
{ caption: "STEPS", value: String(steps.length) },
|
|
||||||
{
|
|
||||||
caption: "DIMENSIONS",
|
|
||||||
value: `${resultImg.width} × ${resultImg.height} px`,
|
|
||||||
},
|
|
||||||
{ caption: "FORMAT", value: "PNG-24" },
|
|
||||||
]
|
|
||||||
: [],
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>easy-png-tools / {firstTool?.title ?? "Tool"}</title>
|
<title>easy-png-tools / {tool?.title ?? "Tool"}</title>
|
||||||
<meta name="robots" content="noindex, nofollow" />
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
{#if !firstTool}
|
{#if !tool}
|
||||||
<div class="notfound">
|
<div class="notfound">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="Tool not found"
|
title="Tool not found"
|
||||||
@@ -214,41 +26,7 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<WorkspaceLayout
|
<ToolView {tool} />
|
||||||
columns="minmax(0, 1fr) minmax(0, 1.1fr)"
|
|
||||||
gap="40px"
|
|
||||||
padding="48px clamp(24px, 4vw, 72px) 72px"
|
|
||||||
maxWidth="1680px"
|
|
||||||
stickyTop="84px"
|
|
||||||
padMobile="32px 16px 48px"
|
|
||||||
>
|
|
||||||
{#snippet settings()}
|
|
||||||
<SettingsPanel
|
|
||||||
{steps}
|
|
||||||
{chainOptions}
|
|
||||||
{addId}
|
|
||||||
onAddStep={addStep}
|
|
||||||
onSetAddId={(v) => (addId = v)}
|
|
||||||
onRemoveStep={removeStep}
|
|
||||||
onSetParam={setStepParam}
|
|
||||||
/>
|
|
||||||
{/snippet}
|
|
||||||
{#snippet preview()}
|
|
||||||
<PreviewPanel
|
|
||||||
{needsSource}
|
|
||||||
{sourceImg}
|
|
||||||
{sourceUrl}
|
|
||||||
{stepOutputs}
|
|
||||||
{resultUrl}
|
|
||||||
{resultMeta}
|
|
||||||
{processing}
|
|
||||||
{chainError}
|
|
||||||
onFile={onFile}
|
|
||||||
onClearSource={clearSource}
|
|
||||||
onDownload={downloadResult}
|
|
||||||
/>
|
|
||||||
{/snippet}
|
|
||||||
</WorkspaceLayout>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
Reference in New Issue
Block a user