feat: add offset field component

This commit is contained in:
2026-09-05 16:24:55 +05:00
parent 743ae817ee
commit 0c28925673
6 changed files with 336 additions and 7 deletions
@@ -13,6 +13,7 @@
import RangeControl from "./fields/schema/RangeControl.svelte";
import SelectControl from "./fields/schema/SelectControl.svelte";
import TextControl from "./fields/schema/TextControl.svelte";
import OffsetControl from "./fields/schema/OffsetControl.svelte";
interface FieldControlProps {
label: string;
@@ -30,6 +31,7 @@
checkbox: CheckboxControl,
text: TextControl,
dimension: DimensionField,
offset: OffsetControl,
};
interface Props {
@@ -0,0 +1,87 @@
<script lang="ts">
import type { FieldSpec, Offset, OffsetSpec } from "$lib/registry-schema";
interface Props {
label: string;
value: unknown;
spec: FieldSpec;
onchange?: (value: Offset) => void;
}
let { label, value, spec, onchange }: Props = $props();
const sp = $derived(spec as OffsetSpec);
const current = $derived.by(() => {
const v = value as Partial<Offset> | undefined;
return {
x: typeof v?.x === "number" && Number.isFinite(v.x) ? v.x : sp.x,
y: typeof v?.y === "number" && Number.isFinite(v.y) ? v.y : sp.y,
};
});
function setAxis(axis: "x" | "y", n: number) {
onchange?.({ ...current, [axis]: n });
}
</script>
<div class="control offset-control">
<span class="offset-label">{label}</span>
<div class="offset-field">
<label class="offset-axis">
<span>X</span>
<input
type="number"
min={sp.min}
max={sp.max}
value={current.x}
oninput={(e) =>
setAxis("x", Number((e.target as HTMLInputElement).value))}
/>
</label>
<label class="offset-axis">
<span>Y</span>
<input
type="number"
min={sp.min}
max={sp.max}
value={current.y}
oninput={(e) =>
setAxis("y", Number((e.target as HTMLInputElement).value))}
/>
</label>
</div>
</div>
<style>
.control {
display: grid;
gap: var(--space-m);
margin-bottom: var(--space-xl);
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-m);
}
.offset-label {
color: var(--color-text);
font-size: var(--font-size-s);
letter-spacing: var(--space-text-l);
text-transform: uppercase;
}
.offset-field {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-m);
}
.offset-axis > span {
display: block;
margin-bottom: var(--space-m);
}
.offset-axis input {
width: 100%;
padding: var(--space-m) var(--space-l);
background: var(--color-background);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-s);
color: var(--color-text);
font: var(--font-size-s) var(--font-mono);
}
</style>
+135 -1
View File
@@ -1,7 +1,15 @@
import type { ToolEntry } from "./types";
import { field, toolSchema } from "../registry-schema";
import type { Offset } from "../registry-schema";
import { colorMask, removeColorToAlpha } from "../core/alpha";
import { contourImage, strokeImage } from "../core/morphology";
import {
boxTest,
circleTest,
renderShape,
starTest,
wavyTest,
} from "../core/shapes";
interface AddStrokeParams {
color: string;
@@ -64,4 +72,130 @@ const removeColor: ToolEntry<RemoveColorParams> = {
preview: (img, p) => colorMask(img, p.targetColor, p.tolerance),
};
export const alphaEntries = [addStroke, findContour, removeColor];
interface CircleMaskParams {
size: number;
offset: Offset;
}
const circleMaskSchema = toolSchema<CircleMaskParams>({
size: field.slider({ min: 20, max: 100, step: 1, default: 100 }),
offset: field.offset({ min: -50, max: 50, x: 0, y: 0 }),
});
const circleMask: ToolEntry<CircleMaskParams> = {
id: "circle-mask-png",
title: "Circle Mask PNG",
description:
"Cuts the image into a circle. Diameter is set as a share of the smaller side.",
category: "alpha",
schema: circleMaskSchema,
run: (img, p) =>
renderShape(
img,
circleTest(p.size / 200),
p.offset.x / 100,
p.offset.y / 100,
),
};
interface SquareMaskParams {
widthPct: number;
heightPct: number;
offset: Offset;
}
const squareMaskSchema = toolSchema<SquareMaskParams>({
widthPct: field.slider({ min: 10, max: 100, step: 1, default: 100 }),
heightPct: field.slider({ min: 10, max: 100, step: 1, default: 100 }),
offset: field.offset({ min: -50, max: 50, x: 0, y: 0 }),
});
const squareMask: ToolEntry<SquareMaskParams> = {
id: "square-mask-png",
title: "Square Mask PNG",
description:
"Cuts the image into a rectangle with sides as a share of the smaller side.",
category: "alpha",
schema: squareMaskSchema,
run: (img, p) =>
renderShape(
img,
boxTest(p.widthPct / 200, p.heightPct / 200),
p.offset.x / 100,
p.offset.y / 100,
),
};
interface StarMaskParams {
points: number;
innerRadius: number;
size: number;
rotation: number;
offset: Offset;
}
const starMaskSchema = toolSchema<StarMaskParams>({
points: field.slider({ min: 3, max: 12, step: 1, default: 5 }),
innerRadius: field.slider({ min: 10, max: 90, step: 1, default: 45 }),
size: field.slider({ min: 20, max: 100, step: 1, default: 100 }),
rotation: field.slider({ min: -180, max: 180, step: 1, default: 0 }),
offset: field.offset({ min: -50, max: 50, x: 0, y: 0 }),
});
const starMask: ToolEntry<StarMaskParams> = {
id: "star-mask-png",
title: "Star Mask PNG",
description:
"Cuts the image into an n-pointed star with adjustable inner radius and rotation.",
category: "alpha",
schema: starMaskSchema,
run: (img, p) =>
renderShape(
img,
starTest(p.points, p.innerRadius / 100, p.size / 200, p.rotation),
p.offset.x / 100,
p.offset.y / 100,
),
};
interface WavyMaskParams {
size: number;
amplitude: number;
waves: number;
phase: number;
offset: Offset;
}
const wavyMaskSchema = toolSchema<WavyMaskParams>({
size: field.slider({ min: 20, max: 100, step: 1, default: 90 }),
amplitude: field.slider({ min: 2, max: 30, step: 1, default: 8 }),
waves: field.slider({ min: 3, max: 24, step: 1, default: 8 }),
phase: field.slider({ min: 0, max: 360, step: 1, default: 0 }),
offset: field.offset({ min: -50, max: 50, x: 0, y: 0 }),
});
const wavyMask: ToolEntry<WavyMaskParams> = {
id: "wavy-mask-png",
title: "Wavy Mask PNG",
description:
"Cuts the image into a wavy-edged circle: radius is modulated by a sine with chosen amplitude and frequency.",
category: "alpha",
schema: wavyMaskSchema,
run: (img, p) =>
renderShape(
img,
wavyTest(p.size / 200, p.amplitude / 200, p.waves, p.phase),
p.offset.x / 100,
p.offset.y / 100,
),
};
export const alphaEntries = [
addStroke,
findContour,
removeColor,
circleMask,
squareMask,
starMask,
wavyMask,
];
@@ -244,6 +244,66 @@ describe("registry-new (переведённые инструменты)", () =>
expect(s.pair).toEqual({ from: "#ffffff", to: "#00ff00" });
expect(s.threshold).toBe(100);
});
it("circle-mask: срезает углы и сохраняет центр", async () => {
const tool = TOOLS.find((t) => t.id === "circle-mask-png")!;
const img = await tool.run!(
solid(100, 100),
sanitizeSchemaParams(tool.schema, {
size: 100,
offset: { x: 0, y: 0 },
}),
);
// Внешний угол (0,0) — вне круга радиуса 50 → прозрачен
expect(img.data[3]).toBe(0);
// Центр (50,50) — внутри
expect(img.data[(50 * img.width + 50) * 4 + 3]).toBe(255);
});
it("circle-mask: offset сдвигает фигуру", async () => {
const tool = TOOLS.find((t) => t.id === "circle-mask-png")!;
const img = await tool.run!(
solid(100, 100),
sanitizeSchemaParams(tool.schema, {
size: 50,
offset: { x: 50, y: 0 },
}),
);
// Радиус 25, центр смещён к x=100; пиксель (75,50) внутри, (49,50) снаружи
expect(img.data[(50 * img.width + 75) * 4 + 3]).toBe(255);
expect(img.data[(50 * img.width + 49) * 4 + 3]).toBe(0);
});
it("star-mask: сохраняет центр, режет углы", async () => {
const tool = TOOLS.find((t) => t.id === "star-mask-png")!;
const img = await tool.run!(
solid(100, 100),
sanitizeSchemaParams(tool.schema, {
points: 5,
innerRadius: 45,
size: 100,
rotation: 0,
offset: { x: 0, y: 0 },
}),
);
expect(img.data[(50 * img.width + 50) * 4 + 3]).toBe(255);
expect(img.data[3]).toBe(0);
});
it("sanitize клампит offset к min/max и чинит мусор", () => {
const tool = TOOLS.find((t) => t.id === "wavy-mask-png")!;
const s = sanitizeSchemaParams(tool.schema, {
size: 90,
amplitude: 8,
waves: 8,
phase: 0,
offset: { x: 999, y: "abc", extra: true } as unknown as Record<
string,
unknown
>,
});
expect(s.offset).toEqual({ x: 50, y: 0 });
});
});
function solid(width: number, height: number) {
+41
View File
@@ -76,6 +76,21 @@ export interface ColorPairSpec {
to: string;
}
/** Смещение фигуры/объекта по центру: x + y в процентах. */
export interface Offset {
x: number;
y: number;
}
export interface OffsetSpec {
kind: "offset";
/** Общий диапазон для обеих осей (в процентах). */
min: number;
max: number;
x: number;
y: number;
}
/**
* «Объект as const» kind → спека поля. Единственный источник правды для
* перечня kinds: `FieldSpecKind` = ключи map, `FieldSpec` = значение по любому
@@ -91,6 +106,7 @@ export const fieldSpecs = {
checkbox: {} as CheckboxSpec,
dimension: {} as DimensionSpec,
"color-pair": {} as ColorPairSpec,
offset: {} as OffsetSpec,
} as const;
export type FieldSpecKind = keyof typeof fieldSpecs;
@@ -147,6 +163,14 @@ export const field = {
colorPair: (s: { from: string; to: string }): Field<ColorPair> => ({
spec: { kind: "color-pair", ...s },
}),
offset: (s: {
min: number;
max: number;
x: number;
y: number;
}): Field<Offset> => ({
spec: { kind: "offset", ...s },
}),
};
/**
@@ -177,6 +201,8 @@ export function defaultSchemaParams<P>(
out[key] = { width: spec.width, height: spec.height };
} else if (spec.kind === "color-pair") {
out[key] = { from: spec.from, to: spec.to };
} else if (spec.kind === "offset") {
out[key] = { x: spec.x, y: spec.y };
} else {
out[key] = spec.default;
}
@@ -260,6 +286,21 @@ export function sanitizeSchemaParams<P>(
out[key] = { from, to };
break;
}
case "offset": {
const r =
typeof raw === "object" && raw !== null && "x" in raw && "y" in raw
? (raw as Record<string, unknown>)
: undefined;
const x =
typeof r?.x === "number" && Number.isFinite(r.x) ? r.x : spec.x;
const y =
typeof r?.y === "number" && Number.isFinite(r.y) ? r.y : spec.y;
out[key] = {
x: clamp(x, spec.min, spec.max),
y: clamp(y, spec.min, spec.max),
};
break;
}
}
}
return out;