mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-09-14 13:36:36 +00:00
feat: add tool search component, add chainable flag
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
<script lang="ts">
|
||||
import { isChainable, TOOLS } from '$lib/registry';
|
||||
|
||||
interface Props {
|
||||
size?: 'hero' | 'compact';
|
||||
onSelect: (toolId: string) => void;
|
||||
}
|
||||
|
||||
let { size = 'hero', onSelect }: Props = $props();
|
||||
|
||||
const candidates = TOOLS.filter(isChainable);
|
||||
|
||||
let query = $state('');
|
||||
let activeIndex = $state(0);
|
||||
let listOpen = $state(false);
|
||||
|
||||
type Match = { id: string; title: string; description: string; score: number };
|
||||
|
||||
function score(tool: (typeof TOOLS)[number], q: string): number | null {
|
||||
if (q.length === 0) return 1;
|
||||
const title = tool.title.toLowerCase();
|
||||
const id = tool.id.toLowerCase();
|
||||
const description = tool.description.toLowerCase();
|
||||
if (title.startsWith(q)) return 100;
|
||||
const inTitle = title.indexOf(q);
|
||||
if (inTitle >= 0) return 80 - Math.min(inTitle, 40);
|
||||
const inId = id.indexOf(q);
|
||||
if (inId >= 0) return 60 - Math.min(inId, 40);
|
||||
if (description.includes(q)) return 30;
|
||||
let pos = 0;
|
||||
let subScore = 20;
|
||||
for (const ch of q) {
|
||||
pos = title.indexOf(ch, pos);
|
||||
if (pos < 0) {
|
||||
subScore = -1;
|
||||
break;
|
||||
}
|
||||
subScore -= 1;
|
||||
pos += 1;
|
||||
}
|
||||
return subScore >= 0 ? subScore : null;
|
||||
}
|
||||
|
||||
const matches = $derived.by(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
const found: Match[] = [];
|
||||
for (const tool of candidates) {
|
||||
const s = score(tool, q);
|
||||
if (s !== null && s > 0) {
|
||||
found.push({ id: tool.id, title: tool.title, description: tool.description, score: s });
|
||||
}
|
||||
}
|
||||
found.sort((a, b) => b.score - a.score || a.title.localeCompare(b.title));
|
||||
return found.slice(0, size === 'hero' ? 12 : 8);
|
||||
});
|
||||
|
||||
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 {size}">
|
||||
<input
|
||||
type="search"
|
||||
bind:value={query}
|
||||
onfocus={() => (listOpen = true)}
|
||||
oninput={() => {
|
||||
listOpen = true;
|
||||
activeIndex = 0;
|
||||
}}
|
||||
onkeydown={onKeydown}
|
||||
placeholder="Найдите инструмент…"
|
||||
aria-label="Поиск инструмента"
|
||||
role="combobox"
|
||||
aria-expanded={listOpen}
|
||||
aria-controls="tool-search-list"
|
||||
/>
|
||||
{#if listOpen && query.trim().length > 0 && matches.length === 0}
|
||||
<p class="none text-muted">Ничего не найдено — попробуйте другое слово.</p>
|
||||
{:else if listOpen && matches.length > 0}
|
||||
<ul id="tool-search-list" role="listbox">
|
||||
{#each matches as match, index (match.id)}
|
||||
<li role="option" aria-selected={index === activeIndex}>
|
||||
<button
|
||||
type="button"
|
||||
class:selected={index === activeIndex}
|
||||
onclick={() => choose(match.id)}
|
||||
onmousemove={() => (activeIndex = index)}
|
||||
>
|
||||
<span class="title">{match.title}</span>
|
||||
<span class="hint text-caption text-muted">{match.description}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.search {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: var(--control-max-width);
|
||||
}
|
||||
|
||||
.hero {
|
||||
max-width: 36rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-m);
|
||||
background: var(--surface);
|
||||
font-size: var(--text-l);
|
||||
}
|
||||
|
||||
.hero input {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
font-size: var(--text-xl);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
border-color: var(--accent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
ul {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: calc(100% + var(--space-1));
|
||||
z-index: 40;
|
||||
list-style: none;
|
||||
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: 24rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
li button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: var(--space-2);
|
||||
border: 0;
|
||||
border-radius: var(--radius-s);
|
||||
background: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
li button.selected,
|
||||
li button:hover {
|
||||
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.none {
|
||||
margin: var(--space-1) 0 0;
|
||||
text-align: center;
|
||||
font-size: var(--text-s);
|
||||
}
|
||||
</style>
|
||||
@@ -92,6 +92,10 @@ export type ToolEntry = {
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
function num(params: Record<string, unknown>, id: string): number {
|
||||
const v = params[id];
|
||||
if (typeof v !== 'number' || !Number.isFinite(v)) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
import { CATEGORIES } from '$lib/categories';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
@@ -13,11 +12,9 @@
|
||||
<div class="app">
|
||||
<header>
|
||||
<a href="/" class="brand">easy-png-tools</a>
|
||||
<nav aria-label="Категории инструментов">
|
||||
<a class="nav-link workspace-link" href="/workspace">Рабочая область</a>
|
||||
{#each CATEGORIES as category (category.id)}
|
||||
<a class="nav-link" href="/#{category.id}">{category.label}</a>
|
||||
{/each}
|
||||
<nav aria-label="Разделы">
|
||||
<a class="nav-link workspace-link" href="/">Рабочая область</a>
|
||||
<a class="nav-link" href="/list-tools">Каталог</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
|
||||
+39
-76
@@ -1,97 +1,60 @@
|
||||
<script lang="ts">
|
||||
import { CATEGORIES } from '$lib/categories';
|
||||
import { TOOLS } from '$lib/registry';
|
||||
import { getTool } from '$lib/registry';
|
||||
import ToolPage from '$lib/components/ToolPage.svelte';
|
||||
import ToolSearch from '$lib/components/search/ToolSearch.svelte';
|
||||
|
||||
let selectedId = $state<string | null>(null);
|
||||
|
||||
let selected = $derived(selectedId !== null ? (getTool(selectedId) ?? null) : null);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>easy-png-tools — PNG-утилиты прямо в браузере</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Набор PNG-утилит, которые работают полностью локально в вашем браузере: конвертация, прозрачность, цвет, геометрия и анализ изображений."
|
||||
/>
|
||||
<title>
|
||||
{selected ? `${selected.title} — easy-png-tools` : 'easy-png-tools — PNG-утилиты прямо в браузере'}
|
||||
</title>
|
||||
</svelte:head>
|
||||
|
||||
<h1>easy-png-tools</h1>
|
||||
<p class="lead text-muted">
|
||||
Набор утилит для работы с PNG. Все операции выполняются локально в браузере — файлы никуда не
|
||||
отправляются.
|
||||
</p>
|
||||
|
||||
{#each CATEGORIES as category (category.id)}
|
||||
{@const categoryTools = TOOLS.filter((tool) => tool.category === category.id)}
|
||||
{#if categoryTools.length > 0}
|
||||
<section id={category.id} class="category" aria-labelledby="{category.id}-heading">
|
||||
<h2 id="{category.id}-heading" class="heading-section">{category.label}</h2>
|
||||
<div class="grid">
|
||||
{#each categoryTools as tool (tool.id)}
|
||||
<a class="card panel" href="/tools/{tool.id}">
|
||||
<span class="card-title">{tool.title}</span>
|
||||
<span class="card-desc text-caption text-muted">{tool.description}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{#if !selected}
|
||||
<section class="hero">
|
||||
<h1>Что делаем с изображением?</h1>
|
||||
<p class="lead text-muted">Найдите инструмент — все операции выполняются локально в браузере.</p>
|
||||
<ToolSearch size="hero" onSelect={(id) => (selectedId = id)} />
|
||||
</section>
|
||||
{/if}
|
||||
{/each}
|
||||
{:else}
|
||||
<section class="workbench">
|
||||
<button type="button" class="back text-caption text-muted" onclick={() => (selectedId = null)}>
|
||||
← Сменить инструмент
|
||||
</button>
|
||||
{#key selectedId}
|
||||
<ToolPage tool={selected} />
|
||||
{/key}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
text-align: center;
|
||||
padding-top: var(--space-5);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: var(--space-1);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.lead {
|
||||
max-width: 48rem;
|
||||
margin-bottom: var(--space-5);
|
||||
max-width: 40rem;
|
||||
margin: 0 auto var(--space-4);
|
||||
}
|
||||
|
||||
.category {
|
||||
margin-bottom: var(--space-5);
|
||||
scroll-margin-top: 5rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
.back {
|
||||
display: inline-block;
|
||||
margin-bottom: var(--space-3);
|
||||
border: 0;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-3) var(--space-4);
|
||||
color: inherit;
|
||||
transition:
|
||||
border-color var(--transition-fast),
|
||||
box-shadow var(--transition-fast),
|
||||
transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
text-decoration: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: var(--shadow-card);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-weight: 600;
|
||||
font-size: var(--text-m);
|
||||
}
|
||||
|
||||
.card:hover .card-title {
|
||||
.back:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<script lang="ts">
|
||||
import { CATEGORIES } from '$lib/categories';
|
||||
import { TOOLS } from '$lib/registry';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Все инструменты — easy-png-tools</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Полный каталог PNG-утилит: конвертация, прозрачность, цвет, геометрия, анализ и генерация изображений."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<h1>Каталог инструментов</h1>
|
||||
<p class="lead text-muted">
|
||||
{TOOLS.length} утилит для работы с PNG. Все операции выполняются локально в браузере.
|
||||
</p>
|
||||
|
||||
{#each CATEGORIES as category (category.id)}
|
||||
{@const categoryTools = TOOLS.filter((tool) => tool.category === category.id)}
|
||||
{#if categoryTools.length > 0}
|
||||
<section id={category.id} class="category" aria-labelledby="{category.id}-heading">
|
||||
<h2 id="{category.id}-heading" class="heading-section">{category.label}</h2>
|
||||
<div class="grid">
|
||||
{#each categoryTools as tool (tool.id)}
|
||||
<a class="card panel" href="/tools/{tool.id}">
|
||||
<span class="card-title">{tool.title}</span>
|
||||
<span class="card-desc text-caption text-muted">{tool.description}</span>
|
||||
</a>
|
||||
{/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);
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-3) var(--space-4);
|
||||
color: inherit;
|
||||
transition:
|
||||
border-color var(--transition-fast),
|
||||
box-shadow var(--transition-fast),
|
||||
transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
text-decoration: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: var(--shadow-card);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-weight: 600;
|
||||
font-size: var(--text-m);
|
||||
}
|
||||
|
||||
.card:hover .card-title {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user