feat(POSTV1-ASSET-ALL-16): 完整还原归档文字模板素材
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 [];
|
||||
});
|
||||
|
||||
+20818
-1033
File diff suppressed because it is too large
Load Diff
+157
-6
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user