mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-09-15 05:56:36 +00:00
refactor: move old libs and components to old folder
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,131 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { rgbToHex } from "$lib/core/color";
|
||||
import type { PixelImage } from "$lib/core/types";
|
||||
import PipetteLoupe from "./tool/PipetteLoupe.svelte";
|
||||
|
||||
interface Props {
|
||||
image: PixelImage | null;
|
||||
pipetteActive?: boolean;
|
||||
onPickColor?: (hex: string) => void;
|
||||
}
|
||||
|
||||
let { image, pipetteActive = false, onPickColor }: Props = $props();
|
||||
|
||||
let canvas = $state<HTMLCanvasElement | undefined>();
|
||||
let hover = $state<{
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
px: number;
|
||||
py: number;
|
||||
hex: string;
|
||||
} | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (!canvas || !image) return;
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
ctx.putImageData(
|
||||
new ImageData(image.data, image.width, image.height),
|
||||
0,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
function pixelAt(
|
||||
event: MouseEvent,
|
||||
): { px: number; py: number; hex: string } | null {
|
||||
if (!canvas || !image) return null;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) return null;
|
||||
const px = Math.floor(
|
||||
(event.clientX - rect.left) * (canvas.width / rect.width),
|
||||
);
|
||||
const py = Math.floor(
|
||||
(event.clientY - rect.top) * (canvas.height / rect.height),
|
||||
);
|
||||
if (px < 0 || py < 0 || px >= canvas.width || py >= canvas.height)
|
||||
return null;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return null;
|
||||
const [r, g, b] = ctx.getImageData(px, py, 1, 1).data;
|
||||
return { px, py, hex: rgbToHex(r, g, b) };
|
||||
}
|
||||
|
||||
function pickColor(event: MouseEvent) {
|
||||
if (!pipetteActive || !onPickColor) return;
|
||||
const pixel = pixelAt(event);
|
||||
if (pixel) {
|
||||
onPickColor(pixel.hex);
|
||||
}
|
||||
}
|
||||
|
||||
function trackHover(event: MouseEvent) {
|
||||
if (!pipetteActive) {
|
||||
hover = null;
|
||||
return;
|
||||
}
|
||||
const pixel = pixelAt(event);
|
||||
hover = pixel
|
||||
? {
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
px: pixel.px,
|
||||
py: pixel.py,
|
||||
hex: pixel.hex,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
function clearHover() {
|
||||
hover = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if image}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<canvas
|
||||
bind:this={canvas}
|
||||
title="{image.width} × {image.height}"
|
||||
class:pipette={pipetteActive}
|
||||
onclick={pickColor}
|
||||
onmousemove={trackHover}
|
||||
onmouseleave={clearHover}
|
||||
></canvas>
|
||||
<p class="dims">{image.width} × {image.height} px</p>
|
||||
{#if pipetteActive && hover && image}
|
||||
<PipetteLoupe
|
||||
{image}
|
||||
px={hover.px}
|
||||
py={hover.py}
|
||||
hex={hover.hex}
|
||||
clientX={hover.clientX}
|
||||
clientY={hover.clientY}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
canvas {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 32rem;
|
||||
background: repeating-conic-gradient(
|
||||
var(--check-a) 0% 25%,
|
||||
var(--check-b) 0% 50%
|
||||
);
|
||||
background-size: 16px 16px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
canvas.pipette {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.dims {
|
||||
margin-top: var(--space-1);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-s);
|
||||
}
|
||||
</style>
|
||||
@@ -1,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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user