feat: add offset field component
This commit is contained in:
@@ -7,12 +7,13 @@
|
||||
> random-noise, linear-gradient, color-spectrum, random-colors, draw-grid,
|
||||
> placeholder, fit-on-background, change-canvas-size, resize, crop); **составной
|
||||
> тип `color-pair` полностью переведён** (4 инструмента: blend-two,
|
||||
> step-colors, linear-gradient, two-colors); preview
|
||||
> step-colors, linear-gradient, two-colors); **составной тип `offset`
|
||||
> полностью переведён** (4 инструмента: circle-mask, square-mask,
|
||||
> star-mask, wavy-mask); preview
|
||||
> научен применять **генераторы** (`executeGenerate`, кнопка Generate) и
|
||||
> рендерить все kinds схемы (slider/number/color/select/checkbox/dimension/
|
||||
> color-pair).
|
||||
> Следующее: `offset` (пилот circle-mask-png → square-mask → star-mask →
|
||||
> wavy-mask).
|
||||
> color-pair/offset).
|
||||
> Следующее: `position9` (add-text → date-stamp → watermark-image).
|
||||
>
|
||||
> Ключевые файлы нового registry: `web/src/lib/registry-new/{types,index,*}.ts`
|
||||
> (по файлу на категорию: geometry/alpha/convert/analyze/filters/color/generate)
|
||||
@@ -355,8 +356,12 @@ Typed field builders + `Field<T>` + `toolSchema<P>()` + `ToolSchema<P>` —
|
||||
Составной тип во всех видах: вложенный объект `pair: { from, to }` +
|
||||
`field.colorPair`, виджет `kit/fields/schema/ColorPairControl.svelte`,
|
||||
kind `color-pair` в схеме (default/sanitize). Тесты: +4 (596 passed).
|
||||
26. `offset` — пилот (circle-mask-png), затем square-mask → star-mask →
|
||||
wavy-mask
|
||||
26. `offset` — **все 4 инструмента переведены** ✔ (`circle-mask-png`,
|
||||
`square-mask-png`, `star-mask-png`, `wavy-mask-png` — run в
|
||||
`registry-new/alpha.ts`). Составной тип во всех видах: вложенный объект
|
||||
`offset: { x, y }` + `field.offset`, виджет
|
||||
`kit/fields/schema/OffsetControl.svelte`, kind `offset` в схеме
|
||||
(default/sanitize). Тесты: +4 (600 passed).
|
||||
27. `position9` — перевести add-text → date-stamp → watermark-image
|
||||
28. `font-style` — перевести text-to-png → add-text (если ещё не) →
|
||||
date-stamp
|
||||
|
||||
@@ -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>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user