Files
tyx_AI_xhs/apps/web/src/text-assets.ts
T
suyx 4d1ec8ba7e
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 1m5s
feat: complete TASK-WP5-03 asset allowlist
2026-08-03 18:24:16 +08:00

288 lines
11 KiB
TypeScript

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<Record<typeof P0A_REQUIRED_FONT_PANEL_IDS[number], string>> = {
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<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;
}
}