feat: add position9 field component

This commit is contained in:
2026-09-05 16:36:48 +05:00
parent 0c28925673
commit f24ccf9240
8 changed files with 289 additions and 4 deletions
@@ -14,6 +14,7 @@
import SelectControl from "./fields/schema/SelectControl.svelte";
import TextControl from "./fields/schema/TextControl.svelte";
import OffsetControl from "./fields/schema/OffsetControl.svelte";
import PositionControl from "./fields/schema/PositionControl.svelte";
interface FieldControlProps {
label: string;
@@ -32,6 +33,7 @@
text: TextControl,
dimension: DimensionField,
offset: OffsetControl,
position9: PositionControl,
};
interface Props {
@@ -0,0 +1,90 @@
<script lang="ts">
import type { Position9 } from "$lib/core/textdraw";
import {
POSITION9_VALUES,
type FieldSpec,
type Position9Spec,
} from "$lib/registry-schema";
interface Props {
label: string;
value: unknown;
spec: FieldSpec;
onchange?: (value: Position9) => void;
}
let { label, value, spec, onchange }: Props = $props();
const sp = $derived(spec as Position9Spec);
const current = $derived(
typeof value === "string" &&
(POSITION9_VALUES as readonly string[]).includes(value)
? (value as Position9)
: sp.default,
);
</script>
<div class="control position-control">
<span class="position-label">{label}</span>
<div class="grid">
{#each POSITION9_VALUES as position (position)}
<button
type="button"
class="cell {current === position ? 'active' : ''}"
aria-label={position}
aria-pressed={current === position}
onclick={() => onchange?.(position)}
>
<span class="dot"></span>
</button>
{/each}
</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);
}
.position-label {
color: var(--color-text);
font-size: var(--font-size-s);
letter-spacing: var(--space-text-l);
text-transform: uppercase;
}
.grid {
display: inline-grid;
grid-template-columns: repeat(3, var(--space-xl));
gap: var(--space-s);
}
.cell {
display: grid;
place-items: center;
width: var(--space-xl);
height: var(--space-xl);
padding: 0;
background: var(--color-background);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-s);
cursor: pointer;
}
.cell:hover {
border-color: var(--color-main);
}
.dot {
width: var(--space-s);
height: var(--space-s);
border-radius: 50%;
background: var(--color-border);
}
.active {
border-color: var(--color-main);
background: var(--color-main);
}
.active .dot {
background: var(--color-text);
}
</style>
+2
View File
@@ -6,6 +6,7 @@ import { analyzeEntries } from "./analyze";
import { filtersEntries } from "./filters";
import { colorEntries } from "./color";
import { generateEntries } from "./generate";
import { textEntries } from "./text";
export type { ToolEntry } from "./types";
@@ -17,6 +18,7 @@ export const TOOLS: ToolEntry[] = [
...filtersEntries,
...colorEntries,
...generateEntries,
...textEntries,
] as unknown as ToolEntry[];
export function getTool(id: string): ToolEntry | undefined {
@@ -304,6 +304,37 @@ describe("registry-new (переведённые инструменты)", () =>
});
expect(s.offset).toEqual({ x: 50, y: 0 });
});
it("add-text: дефолты position9 и текстовых полей", () => {
const tool = TOOLS.find((t) => t.id === "add-text-png")!;
const d = defaultSchemaParams(tool.schema);
expect(d.position).toBe("bottom-right");
expect(d.text).toBe("Hello!");
expect(d.plate).toBe(false);
});
it("sanitize чинит мусор в position9 и оставляет валидные значения", () => {
const tool = TOOLS.find((t) => t.id === "date-stamp-png")!;
const s = sanitizeSchemaParams(tool.schema, {
format: "YYYY-MM-DD",
fontSize: 32,
color: "#ffffff",
font: "mono",
bold: "no" as unknown as boolean,
position: "somewhere-outside",
margin: 20,
plate: true,
plateColor: "#000000",
plateOpacity: 55,
});
expect(s.position).toBe("bottom-right");
expect(s.bold).toBe(false);
const valid = sanitizeSchemaParams(tool.schema, {
...defaultSchemaParams(tool.schema),
position: "top-center",
});
expect(valid.position).toBe("top-center");
});
});
function solid(width: number, height: number) {
+114
View File
@@ -0,0 +1,114 @@
import type { ToolEntry } from "./types";
import type { Position9 } from "../core/textdraw";
import { drawTextBlock, type TextFont } from "../core/domText";
import { formatStamp } from "../core/datefmt";
import { field, toolSchema } from "../registry-schema";
const FONT_OPTIONS = [
{ value: "sans", label: "Sans-serif" },
{ value: "serif", label: "Serif" },
{ value: "mono", label: "Monospace" },
] satisfies { value: TextFont; label: string }[];
interface AddTextParams {
text: string;
fontSize: number;
color: string;
font: TextFont;
bold: boolean;
position: Position9;
margin: number;
plate: boolean;
plateColor: string;
plateOpacity: number;
}
const addTextSchema = toolSchema<AddTextParams>({
text: field.text({ default: "Hello!", placeholder: "Your text" }),
fontSize: field.slider({ min: 8, max: 200, step: 1, default: 48 }),
color: field.color({ default: "#ffffff" }),
font: field.select<TextFont>({ default: "sans", options: FONT_OPTIONS }),
bold: field.checkbox({ default: true }),
position: field.position9({ default: "bottom-right" }),
margin: field.slider({ min: 0, max: 200, step: 1, default: 24 }),
plate: field.checkbox({ default: false }),
plateColor: field.color({ default: "#000000" }),
plateOpacity: field.slider({ min: 0, max: 100, step: 5, default: 60 }),
});
const addText: ToolEntry<AddTextParams> = {
id: "add-text-png",
title: "Add text to PNG",
description:
"Draws a text label on the image: font, size, color, bold, position on a 3×3 grid and an optional backing plate.",
category: "text",
domOnly: true,
schema: addTextSchema,
run: (img, p) =>
drawTextBlock(img, {
text: p.text,
fontSize: p.fontSize,
font: p.font,
bold: p.bold,
color: p.color,
opacityPercent: 100,
position: p.position,
margin: p.margin,
plateColor: p.plate ? p.plateColor : undefined,
plateOpacityPercent: p.plateOpacity,
}),
};
interface DateStampParams {
format: string;
fontSize: number;
color: string;
font: TextFont;
bold: boolean;
position: Position9;
margin: number;
plate: boolean;
plateColor: string;
plateOpacity: number;
}
const dateStampSchema = toolSchema<DateStampParams>({
format: field.text({
default: "YYYY-MM-DD",
placeholder: "YYYY-MM-DD hh:mm",
}),
fontSize: field.slider({ min: 8, max: 200, step: 1, default: 32 }),
color: field.color({ default: "#ffffff" }),
font: field.select<TextFont>({ default: "mono", options: FONT_OPTIONS }),
bold: field.checkbox({ default: false }),
position: field.position9({ default: "bottom-right" }),
margin: field.slider({ min: 0, max: 200, step: 1, default: 20 }),
plate: field.checkbox({ default: true }),
plateColor: field.color({ default: "#000000" }),
plateOpacity: field.slider({ min: 0, max: 100, step: 5, default: 55 }),
});
const dateStamp: ToolEntry<DateStampParams> = {
id: "date-stamp-png",
title: "Date stamp PNG",
description:
"Stamps the current date and time using a format string (YYYY MM DD hh mm ss tokens). Same styling options as Add text.",
category: "text",
domOnly: true,
schema: dateStampSchema,
run: (img, p) =>
drawTextBlock(img, {
text: formatStamp(new Date(), p.format),
fontSize: p.fontSize,
font: p.font,
bold: p.bold,
color: p.color,
opacityPercent: 100,
position: p.position,
margin: p.margin,
plateColor: p.plate ? p.plateColor : undefined,
plateOpacityPercent: p.plateOpacity,
}),
};
export const textEntries = [addText, dateStamp];
+2
View File
@@ -13,6 +13,8 @@ export type ToolEntry<P = Record<string, unknown>> = {
description: string;
category: CategoryId;
schema: ToolSchema<P>;
/** Требует DOM (canvas/document); превью-executor запускает напрямую, не в worker. */
domOnly?: boolean;
/** Применение к входному изображению. Для чистых генераторов отсутствует. */
run?(img: PixelImage, params: P): Promise<PixelImage> | PixelImage;
generate?(params: P): Promise<PixelImage> | PixelImage;
+34
View File
@@ -11,6 +11,8 @@
// с полями `P`, а `Field<P[K]>` тип-проверялся на соответствие `P[K]`.
// Ошибки компилятора ловят расхождения между интерфейсом Params и схемой.
import type { Position9 } from "./core/textdraw";
export interface NumberSpec {
kind: "number";
default: number;
@@ -91,6 +93,27 @@ export interface OffsetSpec {
y: number;
}
/**
* 9-позиционная сетка (3×3): top/middle/bottom × left/center/right.
* Значение совпадает со строковым типом `Position9` из core/textdraw.
*/
export const POSITION9_VALUES = [
"top-left",
"top-center",
"top-right",
"middle-left",
"center",
"middle-right",
"bottom-left",
"bottom-center",
"bottom-right",
] as const;
export interface Position9Spec {
kind: "position9";
default: Position9;
}
/**
* «Объект as const» kind → спека поля. Единственный источник правды для
* перечня kinds: `FieldSpecKind` = ключи map, `FieldSpec` = значение по любому
@@ -107,6 +130,7 @@ export const fieldSpecs = {
dimension: {} as DimensionSpec,
"color-pair": {} as ColorPairSpec,
offset: {} as OffsetSpec,
position9: {} as Position9Spec,
} as const;
export type FieldSpecKind = keyof typeof fieldSpecs;
@@ -171,6 +195,9 @@ export const field = {
}): Field<Offset> => ({
spec: { kind: "offset", ...s },
}),
position9: (s: { default: Position9 }): Field<Position9> => ({
spec: { kind: "position9", ...s },
}),
};
/**
@@ -286,6 +313,13 @@ export function sanitizeSchemaParams<P>(
out[key] = { from, to };
break;
}
case "position9":
out[key] =
typeof raw === "string" &&
(POSITION9_VALUES as readonly string[]).includes(raw)
? (raw as Position9)
: spec.default;
break;
case "offset": {
const r =
typeof raw === "object" && raw !== null && "x" in raw && "y" in raw