feat: add position9 field component
This commit is contained in:
@@ -9,11 +9,13 @@
|
|||||||
> тип `color-pair` полностью переведён** (4 инструмента: blend-two,
|
> тип `color-pair` полностью переведён** (4 инструмента: blend-two,
|
||||||
> step-colors, linear-gradient, two-colors); **составной тип `offset`
|
> step-colors, linear-gradient, two-colors); **составной тип `offset`
|
||||||
> полностью переведён** (4 инструмента: circle-mask, square-mask,
|
> полностью переведён** (4 инструмента: circle-mask, square-mask,
|
||||||
> star-mask, wavy-mask); preview
|
> star-mask, wavy-mask); **составной тип `position9` — переведены
|
||||||
|
> add-text-png и date-stamp-png** (водяной знак-картинка — отдельный шаг:
|
||||||
|
> требует overlay-механику в новом превью); preview
|
||||||
> научен применять **генераторы** (`executeGenerate`, кнопка Generate) и
|
> научен применять **генераторы** (`executeGenerate`, кнопка Generate) и
|
||||||
> рендерить все kinds схемы (slider/number/color/select/checkbox/dimension/
|
> рендерить все kinds схемы (slider/number/color/select/checkbox/dimension/
|
||||||
> color-pair/offset).
|
> color-pair/offset/position9).
|
||||||
> Следующее: `position9` (add-text → date-stamp → watermark-image).
|
> Следующее: `font-style` (text-to-png → add-text → date-stamp).
|
||||||
>
|
>
|
||||||
> Ключевые файлы нового registry: `web/src/lib/registry-new/{types,index,*}.ts`
|
> Ключевые файлы нового registry: `web/src/lib/registry-new/{types,index,*}.ts`
|
||||||
> (по файлу на категорию: geometry/alpha/convert/analyze/filters/color/generate)
|
> (по файлу на категорию: geometry/alpha/convert/analyze/filters/color/generate)
|
||||||
@@ -362,7 +364,15 @@ Typed field builders + `Field<T>` + `toolSchema<P>()` + `ToolSchema<P>` —
|
|||||||
`offset: { x, y }` + `field.offset`, виджет
|
`offset: { x, y }` + `field.offset`, виджет
|
||||||
`kit/fields/schema/OffsetControl.svelte`, kind `offset` в схеме
|
`kit/fields/schema/OffsetControl.svelte`, kind `offset` в схеме
|
||||||
(default/sanitize). Тесты: +4 (600 passed).
|
(default/sanitize). Тесты: +4 (600 passed).
|
||||||
27. `position9` — перевести add-text → date-stamp → watermark-image
|
27. `position9` — **переведены `add-text-png` и `date-stamp-png`** ✔ (run в
|
||||||
|
`registry-new/text.ts`, оба `domOnly`). Составной тип во всех видах:
|
||||||
|
значение — строка `Position9`, kind `position9` в схеме (default/sanitize
|
||||||
|
по `POSITION9_VALUES`), виджет 3×3 `kit/fields/schema/PositionControl.svelte`.
|
||||||
|
В `ToolEntry` добавлен флаг `domOnly` (превью-executor держит такие
|
||||||
|
инструменты вне worker). `watermark-image-png` — отдельный под-шаг:
|
||||||
|
ему нужен overlay-source (`getOverlay`/store), которого в новом превью пока
|
||||||
|
нет. Тесты: +2 (602 passed). `font-style` и `plate` на этих инструментах
|
||||||
|
сводятся в шаги 28-29.
|
||||||
28. `font-style` — перевести text-to-png → add-text (если ещё не) →
|
28. `font-style` — перевести text-to-png → add-text (если ещё не) →
|
||||||
date-stamp
|
date-stamp
|
||||||
29. `plate` — перевести add-text → date-stamp
|
29. `plate` — перевести add-text → date-stamp
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
import SelectControl from "./fields/schema/SelectControl.svelte";
|
import SelectControl from "./fields/schema/SelectControl.svelte";
|
||||||
import TextControl from "./fields/schema/TextControl.svelte";
|
import TextControl from "./fields/schema/TextControl.svelte";
|
||||||
import OffsetControl from "./fields/schema/OffsetControl.svelte";
|
import OffsetControl from "./fields/schema/OffsetControl.svelte";
|
||||||
|
import PositionControl from "./fields/schema/PositionControl.svelte";
|
||||||
|
|
||||||
interface FieldControlProps {
|
interface FieldControlProps {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -32,6 +33,7 @@
|
|||||||
text: TextControl,
|
text: TextControl,
|
||||||
dimension: DimensionField,
|
dimension: DimensionField,
|
||||||
offset: OffsetControl,
|
offset: OffsetControl,
|
||||||
|
position9: PositionControl,
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Props {
|
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>
|
||||||
@@ -6,6 +6,7 @@ import { analyzeEntries } from "./analyze";
|
|||||||
import { filtersEntries } from "./filters";
|
import { filtersEntries } from "./filters";
|
||||||
import { colorEntries } from "./color";
|
import { colorEntries } from "./color";
|
||||||
import { generateEntries } from "./generate";
|
import { generateEntries } from "./generate";
|
||||||
|
import { textEntries } from "./text";
|
||||||
|
|
||||||
export type { ToolEntry } from "./types";
|
export type { ToolEntry } from "./types";
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ export const TOOLS: ToolEntry[] = [
|
|||||||
...filtersEntries,
|
...filtersEntries,
|
||||||
...colorEntries,
|
...colorEntries,
|
||||||
...generateEntries,
|
...generateEntries,
|
||||||
|
...textEntries,
|
||||||
] as unknown as ToolEntry[];
|
] as unknown as ToolEntry[];
|
||||||
|
|
||||||
export function getTool(id: string): ToolEntry | undefined {
|
export function getTool(id: string): ToolEntry | undefined {
|
||||||
|
|||||||
@@ -304,6 +304,37 @@ describe("registry-new (переведённые инструменты)", () =>
|
|||||||
});
|
});
|
||||||
expect(s.offset).toEqual({ x: 50, y: 0 });
|
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) {
|
function solid(width: number, height: number) {
|
||||||
|
|||||||
@@ -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];
|
||||||
@@ -13,6 +13,8 @@ export type ToolEntry<P = Record<string, unknown>> = {
|
|||||||
description: string;
|
description: string;
|
||||||
category: CategoryId;
|
category: CategoryId;
|
||||||
schema: ToolSchema<P>;
|
schema: ToolSchema<P>;
|
||||||
|
/** Требует DOM (canvas/document); превью-executor запускает напрямую, не в worker. */
|
||||||
|
domOnly?: boolean;
|
||||||
/** Применение к входному изображению. Для чистых генераторов отсутствует. */
|
/** Применение к входному изображению. Для чистых генераторов отсутствует. */
|
||||||
run?(img: PixelImage, params: P): Promise<PixelImage> | PixelImage;
|
run?(img: PixelImage, params: P): Promise<PixelImage> | PixelImage;
|
||||||
generate?(params: P): Promise<PixelImage> | PixelImage;
|
generate?(params: P): Promise<PixelImage> | PixelImage;
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
// с полями `P`, а `Field<P[K]>` тип-проверялся на соответствие `P[K]`.
|
// с полями `P`, а `Field<P[K]>` тип-проверялся на соответствие `P[K]`.
|
||||||
// Ошибки компилятора ловят расхождения между интерфейсом Params и схемой.
|
// Ошибки компилятора ловят расхождения между интерфейсом Params и схемой.
|
||||||
|
|
||||||
|
import type { Position9 } from "./core/textdraw";
|
||||||
|
|
||||||
export interface NumberSpec {
|
export interface NumberSpec {
|
||||||
kind: "number";
|
kind: "number";
|
||||||
default: number;
|
default: number;
|
||||||
@@ -91,6 +93,27 @@ export interface OffsetSpec {
|
|||||||
y: number;
|
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 → спека поля. Единственный источник правды для
|
* «Объект as const» kind → спека поля. Единственный источник правды для
|
||||||
* перечня kinds: `FieldSpecKind` = ключи map, `FieldSpec` = значение по любому
|
* перечня kinds: `FieldSpecKind` = ключи map, `FieldSpec` = значение по любому
|
||||||
@@ -107,6 +130,7 @@ export const fieldSpecs = {
|
|||||||
dimension: {} as DimensionSpec,
|
dimension: {} as DimensionSpec,
|
||||||
"color-pair": {} as ColorPairSpec,
|
"color-pair": {} as ColorPairSpec,
|
||||||
offset: {} as OffsetSpec,
|
offset: {} as OffsetSpec,
|
||||||
|
position9: {} as Position9Spec,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type FieldSpecKind = keyof typeof fieldSpecs;
|
export type FieldSpecKind = keyof typeof fieldSpecs;
|
||||||
@@ -171,6 +195,9 @@ export const field = {
|
|||||||
}): Field<Offset> => ({
|
}): Field<Offset> => ({
|
||||||
spec: { kind: "offset", ...s },
|
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 };
|
out[key] = { from, to };
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "position9":
|
||||||
|
out[key] =
|
||||||
|
typeof raw === "string" &&
|
||||||
|
(POSITION9_VALUES as readonly string[]).includes(raw)
|
||||||
|
? (raw as Position9)
|
||||||
|
: spec.default;
|
||||||
|
break;
|
||||||
case "offset": {
|
case "offset": {
|
||||||
const r =
|
const r =
|
||||||
typeof raw === "object" && raw !== null && "x" in raw && "y" in raw
|
typeof raw === "object" && raw !== null && "x" in raw && "y" in raw
|
||||||
|
|||||||
Reference in New Issue
Block a user