feat: complete TASK-WP4-03 text templates
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
import type { CanvasState } from "@dada/shared-contracts";
|
||||
|
||||
import type { CanvasElementIdentity } from "./editor-elements.js";
|
||||
|
||||
type CanvasElement = CanvasState["elements"][number];
|
||||
|
||||
export type TextTemplateCategory = "flower" | "simple" | "tag" | "title";
|
||||
export type TextAlign = "center" | "left" | "right";
|
||||
|
||||
export interface TextTemplateDefinition {
|
||||
available: boolean;
|
||||
catalogOrder: number;
|
||||
category: TextTemplateCategory;
|
||||
defaultFontId: string;
|
||||
defaultFontSize: number;
|
||||
defaultText: string;
|
||||
displayName: string;
|
||||
fontUrl?: string;
|
||||
resourceClass: "parameter_only" | "zip_template";
|
||||
resourceVersion: string;
|
||||
templateId: string;
|
||||
}
|
||||
|
||||
export interface FontOption {
|
||||
displayName: string;
|
||||
fontId: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface TextStylePatch {
|
||||
backgroundColor?: string;
|
||||
backgroundEnabled?: boolean;
|
||||
backgroundOpacity?: number;
|
||||
fillColor?: string;
|
||||
fontOverride?: string | null;
|
||||
letterSpacing?: number;
|
||||
lineHeight?: number;
|
||||
strokeColor?: string;
|
||||
strokeEnabled?: boolean;
|
||||
strokeWidth?: number;
|
||||
textAlign?: TextAlign;
|
||||
}
|
||||
|
||||
const fixtureVersion = "wp4-fixture-v1";
|
||||
const defaults = {
|
||||
background_color: "#FFE62C",
|
||||
background_enabled: false,
|
||||
background_opacity: 1,
|
||||
fill_color: "#111111",
|
||||
letter_spacing: 1,
|
||||
line_height: 1.2,
|
||||
stroke_color: "#000000",
|
||||
stroke_enabled: false,
|
||||
stroke_width: 0,
|
||||
text_align: "center",
|
||||
} as const;
|
||||
|
||||
type CatalogSeed = [id: string, category: TextTemplateCategory, displayName: string, defaultText: string, defaultFontId: string, available?: boolean, resourceClass?: "parameter_only"];
|
||||
|
||||
const seeds: readonly CatalogSeed[] = [
|
||||
["FLOWER001", "flower", "春日计划", "春日计划", "FONT011", true],
|
||||
["FLOWER002", "flower", "笑不活了", "笑不活了", "FLOWER002_FONT"],
|
||||
["FLOWER003", "flower", "人生照片", "人生照片", "FONT008"],
|
||||
["FLOWER004", "flower", "我的日常生活", "我的日常生活", "FLOWER004_FONT"],
|
||||
["FLOWER005", "flower", "碎片生活", "碎片生活", "FONT008"],
|
||||
["FLOWER006", "flower", "闪光瞬间", "闪光瞬间", "FLOWER006_FONT"],
|
||||
["FLOWER007", "flower", "好柿花生", "好柿花生", "FONT046", false, "parameter_only"],
|
||||
["FLOWER008", "flower", "Vlog.", "Vlog.", "FONT005"],
|
||||
["H001", "title", "电影生活记录", "电影生活记录", "H001_FONT"],
|
||||
["H002", "title", "30°C", "30°C", "H002_FONT"],
|
||||
["H003", "title", "生活分享家", "生活分享家", "FONT039", true],
|
||||
["H004", "title", "快乐充值成功", "快乐充值成功", "FONT046"],
|
||||
["H005", "title", "日常的镜头", "日常的镜头", "H005_FONT"],
|
||||
["H006", "title", "慢生活指南", "慢生活指南", "FONT052"],
|
||||
["H007", "title", "做个有闲人", "做个有闲人", "H007_FONT"],
|
||||
["H008", "title", "海滩日记", "海滩日记", "H008_FONT"],
|
||||
["TAG001", "tag", "自定义标签", "自定义标签", "FONT027"],
|
||||
["TAG002", "tag", "自定义标签", "自定义标签", "FONT043"],
|
||||
["TAG003", "tag", "打卡x1", "打卡x1", "FONT043"],
|
||||
["TAG004", "tag", "自定义标签", "自定义标签", "TAG004_FONT"],
|
||||
["TAG005", "tag", "自定义标签", "自定义标签", "FONT008"],
|
||||
["TAG006", "tag", "City Walk", "City Walk", "TAG006_FONT"],
|
||||
["TAG007", "tag", "打卡x1", "打卡x1", "FONT043"],
|
||||
["TAG051", "tag", "自定义标签", "自定义标签", "FONT022"],
|
||||
["SIMPLE001", "simple", "碎片回忆录", "碎片回忆录", "SIMPLE001_FONT"],
|
||||
["SIMPLE002", "simple", "秋天的信笺", "秋天的信笺", "SIMPLE002_FONT"],
|
||||
["SIMPLE003", "simple", "返航时海鸟追着船盘旋", "返航时海鸟追着船盘旋", "SIMPLE003_FONT"],
|
||||
["SIMPLE004", "simple", "下段旅程,幸福丰盛。", "下段旅程,幸福丰盛。", "SIMPLE002_FONT"],
|
||||
["SIMPLE005", "simple", "见信好。", "见信好。", "SIMPLE005_FONT"],
|
||||
["SIMPLE006", "simple", "万物回春", "万物回春", "SIMPLE001_FONT"],
|
||||
["SIMPLE007", "simple", "周而复始。", "周而复始。", "SIMPLE007_FONT"],
|
||||
["SIMPLE008", "simple", "周五愉快", "周五愉快", "SIMPLE008_FONT"],
|
||||
];
|
||||
|
||||
export const P0A_TEXT_TEMPLATES: readonly TextTemplateDefinition[] = seeds.map((seed, catalogOrder) => ({
|
||||
available: seed[5] === true,
|
||||
catalogOrder,
|
||||
category: seed[1],
|
||||
defaultFontId: seed[4],
|
||||
defaultFontSize: 48,
|
||||
defaultText: seed[3],
|
||||
displayName: seed[2],
|
||||
...(seed[5] === true ? { fontUrl: `/api/v1/assets/public/${fixtureVersion}/${seed[4]}` } : {}),
|
||||
resourceClass: seed[6] ?? "zip_template",
|
||||
resourceVersion: fixtureVersion,
|
||||
templateId: seed[0],
|
||||
}));
|
||||
|
||||
export const P0A_FONT_OPTIONS: readonly FontOption[] = [
|
||||
{ displayName: "默陌手写", fontId: "FONT011", url: `/api/v1/assets/public/${fixtureVersion}/FONT011` },
|
||||
{ displayName: "字由油漆", fontId: "FONT039", url: `/api/v1/assets/public/${fixtureVersion}/FONT039` },
|
||||
{ displayName: "Lexend Deca", fontId: "FONT081", url: `/api/v1/assets/public/${fixtureVersion}/FONT081` },
|
||||
];
|
||||
|
||||
export function fontOption(fontId: string) {
|
||||
return P0A_FONT_OPTIONS.find((option) => option.fontId === fontId);
|
||||
}
|
||||
|
||||
export function fontIdForTextElement(element: CanvasElement) {
|
||||
const defaultFontId = element.style_parameters?.default_font_id;
|
||||
return element.font_override ?? (typeof defaultFontId === "string" ? defaultFontId : undefined);
|
||||
}
|
||||
|
||||
function cloneElement(element: CanvasElement): CanvasElement {
|
||||
return structuredClone(element);
|
||||
}
|
||||
|
||||
function templateById(templates: readonly TextTemplateDefinition[], templateId: string) {
|
||||
const template = templates.find((candidate) => candidate.templateId === templateId);
|
||||
if (!template) throw new Error("text_template_not_found");
|
||||
return template;
|
||||
}
|
||||
|
||||
function checkedColor(value: string) {
|
||||
if (!/^#[0-9A-F]{6}$/i.test(value)) throw new Error("text_color_invalid");
|
||||
return value.toUpperCase();
|
||||
}
|
||||
|
||||
function isStep(value: number, minimum: number, step: number) {
|
||||
return Math.abs((value - minimum) / step - Math.round((value - minimum) / step)) < 1e-8;
|
||||
}
|
||||
|
||||
function templateStyle(template: TextTemplateDefinition): Record<string, string | number | boolean | null> {
|
||||
return { ...defaults, default_font_id: template.defaultFontId };
|
||||
}
|
||||
|
||||
export function searchTextTemplates(
|
||||
templates: readonly TextTemplateDefinition[],
|
||||
input: { category?: TextTemplateCategory; query?: string },
|
||||
) {
|
||||
const query = input.query?.trim().toLocaleLowerCase("zh-CN") ?? "";
|
||||
return templates
|
||||
.filter((template) => !input.category || template.category === input.category)
|
||||
.filter((template) => !query || template.displayName.toLocaleLowerCase("zh-CN").includes(query))
|
||||
.toSorted((left, right) => left.catalogOrder - right.catalogOrder);
|
||||
}
|
||||
|
||||
export function createTextTemplateElement(
|
||||
template: TextTemplateDefinition,
|
||||
identity: CanvasElementIdentity,
|
||||
zIndex: number,
|
||||
transform: Partial<Pick<CanvasElement, "position" | "rotation" | "scale">> = {},
|
||||
): CanvasElement {
|
||||
if (!template.available || !template.fontUrl) throw new Error("text_template_unavailable");
|
||||
return {
|
||||
content: template.defaultText,
|
||||
created_at: identity.createdAt,
|
||||
element_id: identity.elementId,
|
||||
font_size: template.defaultFontSize,
|
||||
opacity: 1,
|
||||
position: transform.position ?? { x: 0.5, y: 0.5 },
|
||||
resource_version: template.resourceVersion,
|
||||
rotation: transform.rotation ?? 0,
|
||||
scale: transform.scale ?? { x: 1, y: 1 },
|
||||
style_parameters: templateStyle(template),
|
||||
template_or_asset_id: template.templateId,
|
||||
type: "text_template",
|
||||
z_index: zIndex,
|
||||
};
|
||||
}
|
||||
|
||||
export function effectiveFontSize(element: CanvasElement) {
|
||||
return (element.font_size ?? 48) * element.scale.x;
|
||||
}
|
||||
|
||||
export class TextEditSession {
|
||||
readonly original: CanvasElement;
|
||||
private draft: CanvasElement;
|
||||
private readonly templates: readonly TextTemplateDefinition[];
|
||||
|
||||
constructor(element: CanvasElement, templates: readonly TextTemplateDefinition[]) {
|
||||
if (element.type !== "text_template") throw new Error("text_element_required");
|
||||
this.original = cloneElement(element);
|
||||
this.draft = cloneElement(element);
|
||||
this.templates = templates;
|
||||
}
|
||||
|
||||
get value() { return cloneElement(this.draft); }
|
||||
|
||||
cancel() { return cloneElement(this.original); }
|
||||
|
||||
complete() {
|
||||
if (!(this.draft.content ?? "").trim()) throw new Error("text_content_required");
|
||||
return cloneElement(this.draft);
|
||||
}
|
||||
|
||||
setContent(content: string) {
|
||||
this.draft.content = content;
|
||||
}
|
||||
|
||||
setEffectiveFontSize(value: number) {
|
||||
if (!Number.isFinite(value) || value < 1 || value > 512) throw new Error("text_font_size_invalid");
|
||||
const base = this.draft.font_size ?? 48;
|
||||
const factor = value / base;
|
||||
this.draft.scale = { x: factor, y: factor };
|
||||
}
|
||||
|
||||
setStyle(patch: TextStylePatch) {
|
||||
const style = { ...(this.draft.style_parameters ?? {}) };
|
||||
if (patch.fillColor !== undefined) style.fill_color = checkedColor(patch.fillColor);
|
||||
if (patch.strokeColor !== undefined) style.stroke_color = checkedColor(patch.strokeColor);
|
||||
if (patch.backgroundColor !== undefined) style.background_color = checkedColor(patch.backgroundColor);
|
||||
if (patch.strokeEnabled !== undefined) style.stroke_enabled = patch.strokeEnabled;
|
||||
if (patch.backgroundEnabled !== undefined) style.background_enabled = patch.backgroundEnabled;
|
||||
if (patch.strokeWidth !== undefined) {
|
||||
if (!Number.isFinite(patch.strokeWidth) || patch.strokeWidth < 0 || patch.strokeWidth > 12) throw new Error("text_stroke_width_invalid");
|
||||
style.stroke_width = patch.strokeWidth;
|
||||
}
|
||||
if (patch.backgroundOpacity !== undefined) {
|
||||
if (!Number.isFinite(patch.backgroundOpacity) || patch.backgroundOpacity < 0 || patch.backgroundOpacity > 1) throw new Error("text_background_opacity_invalid");
|
||||
style.background_opacity = patch.backgroundOpacity;
|
||||
}
|
||||
if (patch.textAlign !== undefined) {
|
||||
if (!["center", "left", "right"].includes(patch.textAlign)) throw new Error("text_align_invalid");
|
||||
style.text_align = patch.textAlign;
|
||||
}
|
||||
if (patch.lineHeight !== undefined) {
|
||||
if (patch.lineHeight < 1 || patch.lineHeight > 1.9 || !isStep(patch.lineHeight, 1, 0.1)) throw new Error("text_line_height_invalid");
|
||||
style.line_height = patch.lineHeight;
|
||||
}
|
||||
if (patch.letterSpacing !== undefined) {
|
||||
if (patch.letterSpacing < 1 || patch.letterSpacing > 20 || !isStep(patch.letterSpacing, 1, 1)) throw new Error("text_letter_spacing_invalid");
|
||||
style.letter_spacing = patch.letterSpacing;
|
||||
}
|
||||
if (patch.fontOverride !== undefined) {
|
||||
if (patch.fontOverride === null) delete this.draft.font_override;
|
||||
else this.draft.font_override = patch.fontOverride;
|
||||
}
|
||||
this.draft.style_parameters = style;
|
||||
}
|
||||
|
||||
switchTemplate(templateId: string) {
|
||||
const template = templateById(this.templates, templateId);
|
||||
if (!template.available || !template.fontUrl) throw new Error("text_template_unavailable");
|
||||
const content = this.draft.content;
|
||||
this.draft = {
|
||||
...this.draft,
|
||||
font_size: template.defaultFontSize,
|
||||
resource_version: template.resourceVersion,
|
||||
style_parameters: templateStyle(template),
|
||||
template_or_asset_id: template.templateId,
|
||||
};
|
||||
if (content !== undefined) this.draft.content = content;
|
||||
delete this.draft.font_override;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user