refactor: move old libs and components to old folder

This commit is contained in:
2026-09-10 12:01:54 +05:00
parent bf2f8e57c8
commit 3834dadd03
64 changed files with 111 additions and 110 deletions
@@ -1,50 +0,0 @@
<script lang="ts">
import Button from "./ui/Button.svelte";
import { downloadBlob, encode } from "$lib/core/io";
import type { PixelImage } from "$lib/core/types";
import type { OutputFormat } from "$lib/registry";
import { t } from "$lib/i18n/t";
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>
-97
View File
@@ -1,97 +0,0 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { isSupportedImage, unsupportedImageError } from "$lib/core/io";
import { t } from "$lib/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>
-120
View File
@@ -1,120 +0,0 @@
<script lang="ts">
import {
ACCEPTED_IMAGE_TYPES,
isSupportedImage,
unsupportedImageError,
} from "$lib/core/io";
import { t } from "$lib/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>
-55
View File
@@ -1,55 +0,0 @@
<script lang="ts">
import type { ImageInfo } from "$lib/core/analyze";
import { LOCALE_TAGS } from "$lib/i18n/dict";
import { getLocale } from "$lib/i18n/locale.svelte";
import { t } from "$lib/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>
-117
View File
@@ -1,117 +0,0 @@
<script lang="ts">
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";
import type { ParamDef, ToolEntry } from "$lib/registry";
import { optionLabel, paramLabel } from "$lib/i18n/tool-strings";
import { t } from "$lib/i18n/t";
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>
-131
View File
@@ -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>
-547
View File
@@ -1,547 +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 {
defaultParams,
getTool,
outputOf,
sanitizeParams,
type ToolEntry,
} from "$lib/registry";
import {
loadStoredSteps,
newStepId,
saveSteps,
type PipelineStep,
} from "$lib/tools/pipeline";
import { TOOL_ICONS } from "$lib/tools/tool-icons";
import DownloadButton from "./DownloadButton.svelte";
import ParamForm from "./ParamForm.svelte";
import Preview from "./Preview.svelte";
import ToolSearch from "./search/ToolSearch.svelte";
import { createAutoRunner } from "$lib/tools/auto-run";
import { executeStep } from "$lib/tools/executor";
import { clearOverlay, setOverlay } from "$lib/tools/overlay-store.svelte";
import { t } from "$lib/i18n/t";
import { toolDescription, toolTitle } from "$lib/i18n/tool-strings";
import type { StageStatus } from "./stage/stage-props";
import ChainToolBlock from "./chain/ChainToolBlock.svelte";
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 { outputOf, sanitizeParams, type ToolEntry } from "$lib/registry";
import type { PixelImage } from "$lib/core/types";
import { t } from "$lib/i18n/t";
import { toolTitle } from "$lib/i18n/tool-strings";
import DownloadButton from "../DownloadButton.svelte";
import Button from "../ui/Button.svelte";
import EmptyState from "../ui/EmptyState.svelte";
import ParamsCard from "../tool/ParamsCard.svelte";
import Preview from "../Preview.svelte";
import { TOOL_ICONS } from "$lib/tools/tool-icons";
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/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 { isChainable, TOOLS } from "$lib/registry";
import { LOCALE_TAGS } from "$lib/i18n/dict";
import { getLocale } from "$lib/i18n/locale.svelte";
import { normalizeForSearch, scoreDoc } from "$lib/i18n/matching";
import { t } from "$lib/i18n/t";
import {
toolDescription,
toolSearchDoc,
toolTitle,
} from "$lib/i18n/tool-strings";
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,363 +0,0 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { outputOf, sanitizeParams, type ToolEntry } from "$lib/registry";
import type { ImageInfo } from "$lib/core/analyze";
import type { PixelImage } from "$lib/core/types";
import { t } from "$lib/i18n/t";
import { toolTitle } from "$lib/i18n/tool-strings";
import { TOOL_ICONS } from "$lib/tools/tool-icons";
import DownloadButton from "../DownloadButton.svelte";
import Button from "../ui/Button.svelte";
import EmptyState from "../ui/EmptyState.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 Preview from "../Preview.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/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/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/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 ParamForm from "../ParamForm.svelte";
import type { ParamDef, ToolEntry } from "$lib/registry";
import { t } from "$lib/i18n/t";
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 Button from "../ui/Button.svelte";
import DownloadButton from "../DownloadButton.svelte";
import EmptyState from "../ui/EmptyState.svelte";
import InfoPanel from "../InfoPanel.svelte";
import Preview from "../Preview.svelte";
import TextResult from "./TextResult.svelte";
import type { ImageInfo } from "$lib/core/analyze";
import type { PixelImage } from "$lib/core/types";
import { outputOf, type ToolEntry } from "$lib/registry";
import { t } from "$lib/i18n/t";
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/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/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/i18n/t";
import { getMergedDict } from "$lib/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>
-83
View File
@@ -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/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>
-54
View File
@@ -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/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>