feat(POSTV1-ASSET-ALL-16): 完整还原归档文字模板素材
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run

This commit is contained in:
suyx
2026-08-06 16:20:24 +08:00
parent ef6950c5df
commit a70b9fc241
18 changed files with 28556 additions and 1147 deletions
+4 -2
View File
@@ -2,6 +2,7 @@ import { isCanvasState, type CanvasState } from "@dada/shared-contracts";
import { DYNAMIC_RENDER_MODELS } from "./dynamic-render-models.js";
import type { DynamicTemplateId } from "./dynamic-provider.js";
import { textTemplateById } from "./text-assets.js";
type CanvasElement = CanvasState["elements"][number];
@@ -76,9 +77,10 @@ export function elementHalfExtents(state: CanvasState, element: CanvasElement):
const longestCharacterCount = Math.max(1, ...lines.map((line) => Array.from(line).length));
const widthPixels = longestLine * fontSize + (longestCharacterCount - 1) * letterSpacing + 32 + strokeWidth * 2;
const heightPixels = Math.max(1, lines.length) * fontSize * lineHeight + 32 + strokeWidth * 2;
const template = textTemplateById(element.template_or_asset_id);
return {
x: Math.max(hitHalfExtent, widthPixels / state.pixel_width / 2) * element.scale.x,
y: Math.max(hitHalfExtent, heightPixels / state.pixel_height / 2) * element.scale.y,
x: Math.max(hitHalfExtent, widthPixels / state.pixel_width / 2, (template?.renderModel.halfSize.width ?? 0) / state.pixel_width) * element.scale.x,
y: Math.max(hitHalfExtent, heightPixels / state.pixel_height / 2, (template?.renderModel.halfSize.height ?? 0) / state.pixel_height) * element.scale.y,
};
}
+26 -12
View File
@@ -32,6 +32,7 @@ import {
P0A_TEXT_TEMPLATES,
TextEditSession,
createTextTemplateElement,
textTemplateFontOptions,
type TextStylePatch,
type TextTemplateCategory,
type TextTemplateDefinition,
@@ -251,12 +252,14 @@ export function EditorPage({ projectId }: { projectId: string }) {
useEffect(() => {
if (!canvasState) return;
for (const element of canvasState.elements) {
const options = element.type === "text_template"
? [fontIdForTextElement(element)].map((fontId) => fontId ? fontOption(fontId) : undefined).filter((option) => option !== undefined)
: element.type === "dynamic_sticker" ? dynamicFontOptionsFor(element.template_or_asset_id) : [];
for (const option of options) void ensureFont(option.fontId, option.url);
}
const options = canvasState.elements.flatMap((element) => element.type === "text_template"
? [...textTemplateFontOptions(element.template_or_asset_id), ...[fontIdForTextElement(element)]
.map((fontId) => fontId ? fontOption(fontId) : undefined).filter((option) => option !== undefined)]
: element.type === "dynamic_sticker" ? dynamicFontOptionsFor(element.template_or_asset_id) : []);
const unique = [...new Map(options.map((option) => [option.fontId, option])).values()];
void (async () => {
for (const option of unique) await ensureFont(option.fontId, option.url);
})();
}, [canvasState?.elements.map((element) => `${element.element_id}:${element.font_override ?? ""}:${element.template_or_asset_id}`).join("|")]);
useEffect(() => {
@@ -300,8 +303,21 @@ export function EditorPage({ projectId }: { projectId: string }) {
}
async function retryTextFonts() {
const available = P0A_TEXT_TEMPLATES.filter((template) => template.available && template.fontUrl);
await Promise.all(available.map((template) => ensureFont(template.defaultFontId, template.fontUrl!, true)));
const failed = [...new Set(P0A_TEXT_TEMPLATES
.filter((template) => fontStatuses[template.defaultFontId] === "unavailable")
.map((template) => template.defaultFontId))];
for (const fontId of failed) {
const option = fontOption(fontId);
if (option) await ensureFont(option.fontId, option.url, true);
}
}
async function ensureTemplateFonts(template: TextTemplateDefinition) {
for (const option of textTemplateFontOptions(template.templateId)) {
const status = await ensureFont(option.fontId, option.url, fontStatuses[option.fontId] === "unavailable");
if (status !== "ready") return false;
}
return true;
}
function finalizeTextHistory() {
@@ -516,8 +532,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
async function addTextTemplate(template: TextTemplateDefinition) {
if (!template.fontUrl || !canvasState) return;
const status = await ensureFont(template.defaultFontId, template.fontUrl);
if (status !== "ready") {
if (!await ensureTemplateFonts(template)) {
showNotice("素材暂不可用,未使用系统字体替代。");
return;
}
@@ -581,8 +596,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
async function changeTextTemplate(templateId: string) {
const template = P0A_TEXT_TEMPLATES.find((candidate) => candidate.templateId === templateId);
if (!template?.fontUrl) return;
const status = await ensureFont(template.defaultFontId, template.fontUrl);
if (status !== "ready") {
if (!await ensureTemplateFonts(template)) {
showNotice("素材暂不可用,未使用系统字体替代。");
return;
}
+138 -32
View File
@@ -7,7 +7,15 @@ import type { DynamicTemplateId } from "./dynamic-provider.js";
import { DYNAMIC_RENDER_MODELS, dynamicFontOptionsFor, dynamicImageUrl, dynamicTextValue } from "./dynamic-render-models.js";
import type { CanvasPoint, CanvasRect } from "./editor-elements.js";
import { fontFamilyName, type ArchivedFontStatus } from "./text-font-loader.js";
import { fontIdForTextElement } from "./text-assets.js";
import {
fontIdForTextElement,
textTemplateById,
textTemplateFontOptions,
textTemplateImageUrls,
type TextTemplateImageLayer,
type TextTemplateParticleLayer,
type TextTemplateTextLayer,
} from "./text-assets.js";
import { COLOR_CARD_HALF_SIZES, drawColorCard } from "./palette-provider.js";
interface Gesture {
@@ -81,47 +89,135 @@ function measureTextElement(context: CanvasRenderingContext2D, element: CanvasSt
};
}
function drawTextElement(context: CanvasRenderingContext2D, element: CanvasState["elements"][number], fontStatuses: Readonly<Record<string, ArchivedFontStatus>>) {
const fontId = fontIdForTextElement(element);
if (!fontId || fontStatuses[fontId] !== "ready") {
context.fillStyle = "#e5e7eb";
context.fillRect(-110, -34, 220, 68);
context.fillStyle = "#9f1d1d";
context.font = "600 22px Microsoft YaHei UI, sans-serif";
context.textAlign = "center";
context.fillText("字体不可用", 0, 8);
return;
function drawTemplateImageLayer(
context: CanvasRenderingContext2D,
layer: TextTemplateImageLayer,
resourceImages: Readonly<Record<string, HTMLImageElement>>,
) {
const image = resourceImages[layer.assetId];
if (!image) return;
context.save();
context.translate(layer.x, layer.y);
context.rotate(layer.rotation * Math.PI / 180);
context.scale(layer.scaleX, layer.scaleY);
context.drawImage(image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
context.restore();
}
function deterministicUnit(index: number, salt: number) {
const value = Math.sin(index * 12.9898 + salt * 78.233) * 43_758.5453;
return value - Math.floor(value);
}
function drawTemplateParticles(
context: CanvasRenderingContext2D,
layer: TextTemplateParticleLayer,
resourceImages: Readonly<Record<string, HTMLImageElement>>,
) {
const image = resourceImages[layer.assetId];
if (!image) return;
const columns = Math.max(1, Math.floor(layer.atlasColumns));
const rows = Math.max(1, Math.floor(layer.atlasRows));
const sourceWidth = image.naturalWidth / columns;
const sourceHeight = image.naturalHeight / rows;
const count = Math.max(6, Math.min(80, Math.round((layer.width + layer.height) / 18 * layer.density)));
context.save();
context.globalAlpha *= layer.alpha;
for (let index = 0; index < count; index += 1) {
const progress = index / count;
const angle = progress * Math.PI * 2;
const jitter = (deterministicUnit(index, 1) - 0.5) * layer.randomizePosition * 18;
const x = layer.x + Math.cos(angle) * (layer.width / 2 + jitter);
const y = layer.y + Math.sin(angle) * (layer.height / 2 + jitter);
const cell = index % (columns * rows);
const sourceX = (cell % columns) * sourceWidth;
const sourceY = Math.floor(cell / columns) * sourceHeight;
context.save();
context.translate(x, y);
context.rotate((layer.rotation + (deterministicUnit(index, 2) - 0.5) * layer.randomizeAngle * 360) * Math.PI / 180);
context.drawImage(
image, sourceX, sourceY, sourceWidth, sourceHeight,
-layer.particleWidth / 2, -layer.particleHeight / 2, layer.particleWidth, layer.particleHeight,
);
context.restore();
}
const fontSize = element.font_size ?? 48;
const lineHeight = styleValue(element, "line_height", 1.2);
const letterSpacing = styleValue(element, "letter_spacing", 1);
const align = styleValue(element, "text_align", "center") as CanvasTextAlign;
const lines = (element.content ?? "").split("\n");
prepareTextContext(context, element, fontId);
context.restore();
}
function drawTemplateTextLayer(
context: CanvasRenderingContext2D,
element: CanvasState["elements"][number],
layer: TextTemplateTextLayer,
resourceImages: Readonly<Record<string, HTMLImageElement>>,
) {
const editable = layer.editable;
const fontId = editable ? fontIdForTextElement(element) ?? layer.fontId : layer.fontId;
const fontSize = editable ? element.font_size ?? layer.fontSize : layer.fontSize;
const lineHeight = editable ? styleValue(element, "line_height", layer.lineHeight) : layer.lineHeight;
const letterSpacing = editable ? styleValue(element, "letter_spacing", layer.letterSpacing) : layer.letterSpacing;
const align = (editable ? styleValue(element, "text_align", layer.align) : layer.align) as CanvasTextAlign;
const lines = (editable ? element.content ?? layer.text : layer.text).split("\n");
context.save();
context.translate(layer.x, layer.y);
context.rotate(layer.rotation * Math.PI / 180);
context.scale(layer.scaleX, layer.scaleY);
context.font = `${fontSize}px "${fontFamilyName(fontId)}"`;
(context as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = `${letterSpacing}px`;
context.textAlign = align;
context.textBaseline = "middle";
const widths = lines.map((line) => context.measureText(line).width + Math.max(0, line.length - 1) * letterSpacing);
const widths = lines.map((line) => context.measureText(line).width + Math.max(0, Array.from(line).length - 1) * letterSpacing);
const textWidth = Math.max(1, ...widths);
const textHeight = Math.max(fontSize * lineHeight, lines.length * fontSize * lineHeight);
const padding = 16;
const backgroundEnabled = styleValue(element, "background_enabled", false);
if (backgroundEnabled) {
const elementOpacity = context.globalAlpha;
context.globalAlpha = elementOpacity * styleValue(element, "background_opacity", 1);
if (editable && styleValue(element, "background_enabled", false)) {
const alpha = context.globalAlpha;
context.globalAlpha = alpha * styleValue(element, "background_opacity", 1);
context.fillStyle = styleValue(element, "background_color", "#FFE62C");
context.fillRect(-textWidth / 2 - padding, -textHeight / 2 - padding, textWidth + padding * 2, textHeight + padding * 2);
context.globalAlpha = elementOpacity;
context.fillRect(-textWidth / 2 - 16, -textHeight / 2 - 16, textWidth + 32, textHeight + 32);
context.globalAlpha = alpha;
}
context.shadowColor = layer.shadowColor;
context.shadowBlur = layer.shadowBlur;
context.shadowOffsetX = layer.shadowOffsetX;
context.shadowOffsetY = layer.shadowOffsetY;
const fillOverridden = editable && styleValue(element, "template_fill_overridden", false);
const patternImage = !fillOverridden && layer.fillPatternAssetId ? resourceImages[layer.fillPatternAssetId] : undefined;
context.fillStyle = patternImage ? context.createPattern(patternImage, "repeat") ?? layer.fillColor
: editable ? styleValue(element, "fill_color", layer.fillColor) : layer.fillColor;
context.strokeStyle = editable ? styleValue(element, "stroke_color", layer.strokeColor) : layer.strokeColor;
context.lineWidth = editable ? styleValue(element, "stroke_width", layer.strokeWidth) : layer.strokeWidth;
const firstY = -((lines.length - 1) * fontSize * lineHeight) / 2;
const anchorX = align === "left" ? -textWidth / 2 : align === "right" ? textWidth / 2 : 0;
context.fillStyle = styleValue(element, "fill_color", "#111111");
context.strokeStyle = styleValue(element, "stroke_color", "#000000");
context.lineWidth = styleValue(element, "stroke_width", 0);
lines.forEach((line, index) => {
const y = firstY + index * fontSize * lineHeight;
if (styleValue(element, "stroke_enabled", false) && context.lineWidth > 0) context.strokeText(line, anchorX, y);
if ((editable ? styleValue(element, "stroke_enabled", layer.strokeWidth > 0) : layer.strokeWidth > 0) && context.lineWidth > 0) {
context.strokeText(line, anchorX, y);
}
context.fillText(line, anchorX, y);
});
context.restore();
}
function drawTextTemplate(
context: CanvasRenderingContext2D,
element: CanvasState["elements"][number],
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>,
resourceImages: Readonly<Record<string, HTMLImageElement>>,
) {
const template = textTemplateById(element.template_or_asset_id);
if (!template || textTemplateFontOptions(template.templateId).some((font) => fontStatuses[font.fontId] !== "ready")) {
drawDynamicUnavailable(context, "原版字体不可用");
return;
}
const layers = [
...template.renderModel.imageLayers.map((layer) => ({ kind: "image" as const, layer })),
...template.renderModel.particleLayers.map((layer) => ({ kind: "particles" as const, layer })),
...template.renderModel.textLayers.map((layer) => ({ kind: "text" as const, layer })),
].toSorted((left, right) => left.layer.order - right.layer.order);
for (const item of layers) {
if (item.kind === "image") drawTemplateImageLayer(context, item.layer, resourceImages);
else if (item.kind === "particles") drawTemplateParticles(context, item.layer, resourceImages);
else drawTemplateTextLayer(context, element, item.layer, resourceImages);
}
}
function drawDynamicUnavailable(context: CanvasRenderingContext2D, message: string) {
@@ -194,12 +290,16 @@ function elementSelectionHalfSize(
return { height: (model?.halfSize.height ?? 62) * element.scale.y, width: (model?.halfSize.width ?? 170) * element.scale.x };
}
if (element.type !== "text_template") return { height: 78 * element.scale.y, width: 78 * element.scale.x };
const template = textTemplateById(element.template_or_asset_id);
const fontId = fontIdForTextElement(element);
if (!fontId || fontStatuses[fontId] !== "ready") return { height: 34 * element.scale.y, width: 110 * element.scale.x };
context.save();
const geometry = measureTextElement(context, element, fontId);
context.restore();
return { height: geometry.height * element.scale.y / 2, width: geometry.width * element.scale.x / 2 };
return {
height: Math.max(geometry.height / 2, template?.renderModel.halfSize.height ?? 0) * element.scale.y,
width: Math.max(geometry.width / 2, template?.renderModel.halfSize.width ?? 0) * element.scale.x,
};
}
function drawElement(
@@ -228,7 +328,7 @@ function drawElement(
const height = image.naturalHeight * scale;
context.drawImage(image, -width / 2, -height / 2, width, height);
}
} else if (element.type === "text_template") drawTextElement(context, element, fontStatuses);
} else if (element.type === "text_template") drawTextTemplate(context, element, fontStatuses, resourceImages);
else if (element.type === "dynamic_sticker") drawDynamicSticker(context, element, fontStatuses, resourceImages);
context.restore();
}
@@ -271,6 +371,9 @@ function resourceUrlsForCanvas(canvasState: CanvasState) {
const model = DYNAMIC_RENDER_MODELS[element.template_or_asset_id as DynamicTemplateId];
for (const layer of model?.imageLayers ?? []) imageReferences.set(layer.assetId, dynamicImageUrl(element.resource_version, layer.assetId));
}
if (element.type === "text_template") {
for (const [assetId, url] of textTemplateImageUrls(element.template_or_asset_id, element.resource_version)) imageReferences.set(assetId, url);
}
}
return imageReferences;
}
@@ -347,7 +450,10 @@ function renderEditorScene(
function requiredFontIds(canvasState: CanvasState) {
return canvasState.elements.flatMap((element) => {
if (element.type === "text_template") return [fontIdForTextElement(element)].filter((fontId): fontId is string => Boolean(fontId));
if (element.type === "text_template") return [...new Set([
...textTemplateFontOptions(element.template_or_asset_id).map((font) => font.fontId),
...[fontIdForTextElement(element)].filter((fontId): fontId is string => Boolean(fontId)),
])];
if (element.type === "dynamic_sticker") return dynamicFontOptionsFor(element.template_or_asset_id).map((font) => font.fontId);
return [];
});
File diff suppressed because it is too large Load Diff
+157 -6
View File
@@ -9,6 +9,63 @@ type CanvasElement = CanvasState["elements"][number];
export type TextTemplateCategory = "flower" | "simple" | "tag" | "title";
export type TextAlign = "center" | "left" | "right";
export interface TextTemplateImageLayer {
assetId: string;
height: number;
order: number;
rotation: number;
scaleX: number;
scaleY: number;
width: number;
x: number;
y: number;
}
export interface TextTemplateParticleLayer extends TextTemplateImageLayer {
alpha: number;
atlasColumns: number;
atlasRows: number;
color: string;
density: number;
particleHeight: number;
particleWidth: number;
randomizeAngle: number;
randomizePosition: number;
}
export interface TextTemplateTextLayer {
align: TextAlign;
editable: boolean;
fillColor: string;
fillPatternAssetId?: string;
fontId: string;
fontSize: number;
height: number;
letterSpacing: number;
lineHeight: number;
order: number;
rotation: number;
scaleX: number;
scaleY: number;
shadowBlur: number;
shadowColor: string;
shadowOffsetX: number;
shadowOffsetY: number;
strokeColor: string;
strokeWidth: number;
text: string;
width: number;
x: number;
y: number;
}
export interface TextTemplateRenderModel {
halfSize: { height: number; width: number };
imageLayers: readonly TextTemplateImageLayer[];
particleLayers: readonly TextTemplateParticleLayer[];
textLayers: readonly TextTemplateTextLayer[];
}
export interface TextTemplateDefinition {
available: boolean;
catalogOrder: number;
@@ -19,6 +76,7 @@ export interface TextTemplateDefinition {
displayName: string;
fontUrl?: string;
previewUrl?: string;
renderModel: TextTemplateRenderModel;
resourceClass: "parameter_only" | "zip_template";
resourceVersion: string;
templateId: string;
@@ -45,6 +103,9 @@ export interface TextStylePatch {
}
const resourceVersion = P0A_COMPLEX_RELEASE_VERSION;
const textResourceRevision = complexAssetCatalog.text_resource_revision;
const publicTextAssetUrl = (assetId: string, version = resourceVersion) =>
`/api/v1/assets/public/${version}/${assetId}?revision=${textResourceRevision}`;
const defaults = {
background_color: "#FFE62C",
background_enabled: false,
@@ -60,6 +121,47 @@ const defaults = {
const textCatalogById = new Map(complexAssetCatalog.text_templates.map((item) => [item.template_id, item]));
function imageLayer(layer: {
asset_id: string; height: number; order: number; rotation: number; scale_x: number; scale_y: number; width: number; x: number; y: number;
}): TextTemplateImageLayer {
return {
assetId: layer.asset_id, height: layer.height, order: layer.order, rotation: layer.rotation,
scaleX: layer.scale_x, scaleY: layer.scale_y, width: layer.width, x: layer.x, y: layer.y,
};
}
interface RawTextLayer {
align: string; editable: boolean; fill_color: string; fill_pattern_asset_id?: string; font_id: string; font_size: number;
height: number; letter_spacing: number; line_height: number; order: number; rotation: number; scale_x: number; scale_y: number;
shadow_blur: number; shadow_color: string; shadow_offset_x: number; shadow_offset_y: number; stroke_color: string;
stroke_width: number; text: string; width: number; x: number; y: number;
}
function renderModel(item: (typeof complexAssetCatalog.text_templates)[number]): TextTemplateRenderModel {
return {
halfSize: { height: item.render_model.half_size.height, width: item.render_model.half_size.width },
imageLayers: item.render_model.image_layers.map(imageLayer),
particleLayers: item.render_model.particle_layers.map((layer) => ({
...imageLayer(layer), alpha: layer.alpha, atlasColumns: layer.atlas_columns, atlasRows: layer.atlas_rows,
color: layer.color, density: layer.density, particleHeight: layer.particle_height,
particleWidth: layer.particle_width, randomizeAngle: layer.randomize_angle, randomizePosition: layer.randomize_position,
})),
textLayers: item.render_model.text_layers.map((value) => {
const layer = value as unknown as RawTextLayer;
return {
align: layer.align as TextAlign, editable: layer.editable, fillColor: layer.fill_color,
...(layer.fill_pattern_asset_id ? { fillPatternAssetId: layer.fill_pattern_asset_id } : {}),
fontId: layer.font_id, fontSize: layer.font_size, height: layer.height, letterSpacing: layer.letter_spacing,
lineHeight: layer.line_height, order: layer.order, rotation: layer.rotation, scaleX: layer.scale_x,
scaleY: layer.scale_y, shadowBlur: layer.shadow_blur, shadowColor: layer.shadow_color,
shadowOffsetX: layer.shadow_offset_x, shadowOffsetY: layer.shadow_offset_y,
strokeColor: layer.stroke_color, strokeWidth: layer.stroke_width, text: layer.text,
width: layer.width, x: layer.x, y: layer.y,
};
}),
};
}
export const P0A_TEXT_TEMPLATES: readonly TextTemplateDefinition[] = P0A_TEXT_TEMPLATE_IDS.map((templateId, catalogOrder) => {
const item = textCatalogById.get(templateId);
if (!item) throw new Error(`missing text template definition ${templateId}`);
@@ -71,8 +173,9 @@ export const P0A_TEXT_TEMPLATES: readonly TextTemplateDefinition[] = P0A_TEXT_TE
defaultFontSize: item.default_font_size,
defaultText: item.default_text,
displayName: item.display_name,
fontUrl: `/api/v1/assets/public/${resourceVersion}/${item.default_font_id}`,
...(item.preview_asset_id ? { previewUrl: `/api/v1/assets/public/${resourceVersion}/${item.preview_asset_id}` } : {}),
fontUrl: publicTextAssetUrl(item.default_font_id),
...(item.preview_asset_id ? { previewUrl: publicTextAssetUrl(item.preview_asset_id) } : {}),
renderModel: renderModel(item),
resourceClass: item.resource_class as "parameter_only" | "zip_template",
resourceVersion,
templateId,
@@ -87,12 +190,45 @@ export const P0A_FONT_OPTIONS: readonly FontOption[] = P0A_REQUIRED_FONT_PANEL_I
return {
displayName: item.display_name,
fontId,
url: `/api/v1/assets/public/${resourceVersion}/${fontId}`,
url: publicTextAssetUrl(fontId),
};
});
const templateFontOptions: readonly FontOption[] = P0A_TEXT_TEMPLATES.flatMap((template) => {
const fontIds = [...new Set(template.renderModel.textLayers.map((layer) => layer.fontId))];
return fontIds.map((fontId, index) => ({
displayName: `${template.displayName}原版字体${fontIds.length > 1 ? ` ${index + 1}` : ""}`,
fontId,
url: publicTextAssetUrl(fontId, template.resourceVersion),
}));
});
const allFontOptions = new Map([...P0A_FONT_OPTIONS, ...templateFontOptions].map((option) => [option.fontId, option]));
export function fontOption(fontId: string) {
return P0A_FONT_OPTIONS.find((option) => option.fontId === fontId);
return allFontOptions.get(fontId);
}
export function textTemplateById(templateId: string) {
return P0A_TEXT_TEMPLATES.find((template) => template.templateId === templateId);
}
export function textTemplateFontOptions(templateId: string) {
const template = textTemplateById(templateId);
if (!template) return [];
return [...new Set(template.renderModel.textLayers.map((layer) => layer.fontId))]
.map((fontId) => fontOption(fontId)).filter((option): option is FontOption => option !== undefined);
}
export function textTemplateImageUrls(templateId: string, version = resourceVersion) {
const template = textTemplateById(templateId);
if (!template) return new Map<string, string>();
const assetIds = new Set([
...template.renderModel.imageLayers.map((layer) => layer.assetId),
...template.renderModel.particleLayers.map((layer) => layer.assetId),
...template.renderModel.textLayers.flatMap((layer) => layer.fillPatternAssetId ? [layer.fillPatternAssetId] : []),
]);
return new Map([...assetIds].map((assetId) => [assetId, publicTextAssetUrl(assetId, version)]));
}
export function fontIdForTextElement(element: CanvasElement) {
@@ -120,7 +256,19 @@ function isStep(value: number, minimum: number, step: number) {
}
function templateStyle(template: TextTemplateDefinition): Record<string, string | number | boolean | null> {
return { ...defaults, default_font_id: template.defaultFontId };
const primary = template.renderModel.textLayers.find((layer) => layer.editable) ?? template.renderModel.textLayers[0];
return {
...defaults,
default_font_id: template.defaultFontId,
fill_color: primary?.fillColor ?? defaults.fill_color,
letter_spacing: primary?.letterSpacing ?? defaults.letter_spacing,
line_height: primary?.lineHeight ?? defaults.line_height,
stroke_color: primary?.strokeColor ?? defaults.stroke_color,
stroke_enabled: (primary?.strokeWidth ?? 0) > 0,
stroke_width: primary?.strokeWidth ?? defaults.stroke_width,
template_fill_overridden: false,
text_align: primary?.align ?? defaults.text_align,
};
}
export function searchTextTemplates(
@@ -196,7 +344,10 @@ export class TextEditSession {
setStyle(patch: TextStylePatch) {
const style = { ...(this.draft.style_parameters ?? {}) };
if (patch.fillColor !== undefined) style.fill_color = checkedColor(patch.fillColor);
if (patch.fillColor !== undefined) {
style.fill_color = checkedColor(patch.fillColor);
style.template_fill_overridden = true;
}
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;
+4 -3
View File
@@ -31,14 +31,15 @@ export function TextTemplatePanel(props: {
<div className="editor-template-grid">
{visible.map((template) => {
const status = props.fontStatuses[template.defaultFontId] ?? "idle";
const unavailable = !template.available || status === "unavailable";
return <button aria-label={`${template.templateId} ${template.displayName}${unavailable ? " 素材暂不可用" : ""}`} disabled={!props.canAdd || unavailable || status === "loading"} key={template.templateId} onClick={() => props.onAdd(template)} type="button">
const unavailable = !template.available;
const retryable = template.available && status === "unavailable";
return <button aria-label={`${template.templateId} ${template.displayName}${unavailable ? " 素材暂不可用" : retryable ? " 字体待重试" : ""}`} disabled={!props.canAdd || unavailable || status === "loading"} key={template.templateId} onClick={() => props.onAdd(template)} type="button">
{template.previewUrl
? <img alt="" className="editor-template-preview" decoding="async" loading="lazy" src={template.previewUrl} />
: <span className={`editor-template-mark ${template.category}`}>{template.displayName.slice(0, 2)}</span>}
<strong>{template.templateId}</strong>
<span>{template.displayName}</span>
{unavailable ? <small></small> : status === "loading" ? <small></small> : null}
{unavailable ? <small></small> : retryable ? <small></small> : status === "loading" ? <small></small> : null}
</button>;
})}
</div>