feat: add show mask chip for tools

This commit is contained in:
2026-10-09 14:09:51 +05:00
parent a51e224d02
commit c22c6e5574
22 changed files with 521 additions and 30 deletions
@@ -15,6 +15,7 @@
dims?: string;
note?: string;
noteTone?: "warning" | "info";
actions?: Snippet;
}
let {
label,
@@ -26,6 +27,7 @@
dims = undefined,
note = undefined,
noteTone = "warning",
actions = undefined,
}: Props = $props();
</script>
@@ -44,6 +46,9 @@
{/if}
</span>
<span class="tile-note">
{#if actions}
{@render actions()}
{/if}
{#if note}
<Badge tone={noteTone} label={note} />
{/if}
@@ -5,6 +5,7 @@
import SchemaSourceTile from "./SchemaSourceTile.svelte";
import PreviewTile from "./PreviewTile.svelte";
import Toggle from "$lib/components/ui/Toggle.svelte";
import ChipButton from "$lib/components/ui/ChipButton.svelte";
import { toDataUrl } from "$lib/core/io";
import type { PixelImage } from "$lib/core/types";
import type { OutputMime } from "$lib/core/io";
@@ -27,9 +28,16 @@
textSource?: string;
textResult?: string | null;
textVars?: Record<string, string | number>;
mask?: PixelImage | null;
maskOn?: boolean;
ontogglemask?: () => void;
running?: boolean;
error?: string;
stepResults?: (PixelImage | null)[];
stepMaskable?: boolean[];
stepMaskOn?: boolean[];
stepMasks?: (PixelImage | null)[];
ontogglestepmask?: (i: number) => void;
aligned?: boolean;
ontogglealign?: () => void;
format?: OutputMime;
@@ -57,9 +65,16 @@
textSource = "",
textResult = null,
textVars = undefined,
mask = null,
maskOn = false,
ontogglemask,
running = false,
error = "",
stepResults = [],
stepMaskable = [],
stepMaskOn = [],
stepMasks = [],
ontogglestepmask,
aligned = false,
ontogglealign,
format = "image/png",
@@ -132,7 +147,21 @@
viewMode="image"
dims={`${img.width} × ${img.height}`}
>
<img src={toDataUrl(img)} alt="" />
{#snippet actions()}
{#if stepMaskable[i]}
<ChipButton
label={t("resultCard.maskToggle")}
active={stepMaskOn[i] ?? false}
onclick={() => ontogglestepmask?.(i)}
/>
{/if}
{/snippet}
<img
src={toDataUrl(
stepMaskOn[i] && stepMasks[i] ? (stepMasks[i] as PixelImage) : img,
)}
alt=""
/>
</PreviewTile>
{/if}
{/each}
@@ -145,6 +174,9 @@
{textVars}
{toolId}
{running}
{mask}
{maskOn}
{ontogglemask}
oncopy={oncopytext}
{ondownloadtxt}
/>
@@ -4,6 +4,7 @@
import { t } from "$lib/i18n/t";
import type { FileResult, ResultKind, ResultNote } from "$lib/registry";
import { Check } from "@lucide/svelte";
import ChipButton from "$lib/components/ui/ChipButton.svelte";
import SchemaTextResult from "./SchemaTextResult.svelte";
import PreviewTile from "./PreviewTile.svelte";
@@ -16,6 +17,9 @@
textVars?: Record<string, string | number>;
toolId?: string;
running?: boolean;
mask?: PixelImage | null;
maskOn?: boolean;
ontogglemask?: () => void;
oncopy?: () => void;
ondownloadtxt?: () => void;
}
@@ -28,11 +32,15 @@
textVars = undefined,
toolId = "",
running = false,
mask = null,
maskOn = false,
ontogglemask,
oncopy,
ondownloadtxt,
}: Props = $props();
const resultUrl = $derived(result ? toDataUrl(result) : null);
const shown = $derived(maskOn && mask ? mask : result);
const resultUrl = $derived(shown ? toDataUrl(shown) : null);
const resultDims = $derived(
resultKind === "image" && result
? `${result.width} × ${result.height}`
@@ -68,6 +76,15 @@
note={resultNote ? t(resultNote.key) : undefined}
noteTone={resultNote?.tone ?? "warning"}
>
{#snippet actions()}
{#if ontogglemask}
<ChipButton
label={t("resultCard.maskToggle")}
active={maskOn}
onclick={ontogglemask}
/>
{/if}
{/snippet}
<div
class="canvas"
class:checker={resultKind === "image"}
@@ -3,6 +3,7 @@
import { resolve } from "$app/paths";
import StepCard from "$lib/components/display/StepCard.svelte";
import Button from "$lib/components/ui/Button.svelte";
import ChipButton from "$lib/components/ui/ChipButton.svelte";
import { ButtonVariantDefine } from "$lib/components/ui/define";
import Toggle from "$lib/components/ui/Toggle.svelte";
import { isFav, toggleFav } from "$lib/favorites.svelte";
@@ -114,6 +115,10 @@
let formatQuality = $state<Record<string, number>>({});
// Optional download size limit (KB); undefined = no limit.
let limitKb = $state<number | undefined>(undefined);
// Mask preview per step (keyed by step.key): the toggle lives on every
// step result tile, not just the final one.
const maskOnKeys = new SvelteSet<string>();
let maskResults = $state<Record<string, PixelImage | null>>({});
let dragFrom = $state<number | null>(null);
const lastTool = $derived(
@@ -165,6 +170,10 @@
: current.text;
});
const stepMaskable = $derived(steps.map((s) => !!getTool(s.id)?.runMask));
const stepMaskOn = $derived(steps.map((s) => maskOnKeys.has(s.key)));
const stepMasks = $derived(steps.map((s) => maskResults[s.key] ?? null));
const debouncedRun = debounce(() => run(), 200);
function touchedFor(key: string): SvelteSet<string> {
@@ -209,6 +218,8 @@
textResult = null;
verdictVars = undefined;
displayError = null;
maskOnKeys.clear();
maskResults = {};
}
function setStepValue(
@@ -517,6 +528,41 @@
return () => debouncedRun.cancel();
});
// Mask preview: recomputed on the main thread per toggled step from that
// step's input; a mask never enters the chain itself.
$effect(() => {
void steps;
void stepResults;
void source;
const next: Record<string, PixelImage | null> = {};
steps.forEach((step, i) => {
if (!maskOnKeys.has(step.key)) return;
const stepTool = getTool(step.id);
if (!stepTool?.runMask) return;
const input = i === 0 ? source : stepResults[i - 1];
if (!input) return;
try {
next[step.key] = stepTool.runMask({
params: step.params,
source: input,
});
} catch {
next[step.key] = null;
}
});
maskResults = next;
});
function toggleStepMask(key: string) {
if (maskOnKeys.has(key)) maskOnKeys.delete(key);
else maskOnKeys.add(key);
}
function toggleStepMaskByIndex(i: number) {
const step = steps[i];
if (step) toggleStepMask(step.key);
}
function toDisplayError(e: unknown): DisplayError {
if (e instanceof ToolError) {
return { kind: "i18n", key: e.key, vars: e.vars };
@@ -614,6 +660,16 @@
</StepCard>
{/snippet}
{#snippet maskChip(i: number)}
{#if stepMaskable[i]}
<ChipButton
label={t("resultCard.maskToggle")}
active={stepMaskOn[i]}
onclick={() => toggleStepMask(steps[i].key)}
/>
{/if}
{/snippet}
{#if !alignedMode}
<div class="workspace">
<section class="settings">
@@ -648,6 +704,15 @@
{alphaLoss}
{stepResults}
{aligned}
{stepMaskable}
{stepMaskOn}
{stepMasks}
ontogglestepmask={toggleStepMaskByIndex}
mask={stepMasks.at(-1) ?? null}
maskOn={stepMaskOn.at(-1) ?? false}
ontogglemask={stepMaskable.at(-1)
? () => toggleStepMask(steps[steps.length - 1].key)
: undefined}
ontogglealign={() => (aligned = !aligned)}
onformat={(v) => (format = v)}
onquality={setFormatQuality}
@@ -746,6 +811,11 @@
textVars={verdictVars}
toolId={lastTool.id}
{running}
mask={stepMasks[i] ?? null}
maskOn={stepMaskOn[i] ?? false}
ontogglemask={stepMaskable[i]
? () => toggleStepMask(steps[i].key)
: undefined}
oncopy={copyText}
ondownloadtxt={downloadText}
/>
@@ -755,8 +825,16 @@
viewMode="image"
dims={out ? `${out.width} × ${out.height}` : undefined}
>
{#snippet actions()}
{@render maskChip(i)}
{/snippet}
{#if out}
<img src={toDataUrl(out)} alt="" />
<img
src={toDataUrl(
stepMaskOn[i] && stepMasks[i] ? stepMasks[i]! : out,
)}
alt=""
/>
{:else}
<span class="empty">{t("resultCard.noResult")}</span>
{/if}