288 lines
11 KiB
TypeScript
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;
|
|
}
|
|
}
|