import type { CanvasState } from "@dada/shared-contracts"; import { P0A_REQUIRED_FONT_PANEL_IDS, P0A_TEXT_TEMPLATE_IDS } from "@dada/template-registry"; 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"], ]; const seedById = new Map(seeds.map((seed) => [seed[0], seed])); export const P0A_TEXT_TEMPLATES: readonly TextTemplateDefinition[] = P0A_TEXT_TEMPLATE_IDS.map((templateId, catalogOrder) => { const seed = seedById.get(templateId); if (!seed) throw new Error(`missing text template definition ${templateId}`); return { 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, }; }); const fontOptionDefinitions: Readonly> = { FONT005: "Rammetto", FONT008: "正圆体", FONT011: "默陌手写", FONT021: "喜月体", FONT022: "素白体", FONT027: "锐正圆", FONT039: "字由油漆", FONT043: "喜脉体", FONT046: "可口可乐", FONT052: "Oraqle Script", FONT081: "Lexend Deca", }; export const P0A_FONT_OPTIONS: readonly FontOption[] = P0A_REQUIRED_FONT_PANEL_IDS.map((fontId) => ({ displayName: fontOptionDefinitions[fontId], fontId, url: `/api/v1/assets/public/${fixtureVersion}/${fontId}`, })); 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 { 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> = {}, ): 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; } }