Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a3b2c6914 | ||
|
|
42f993378f | ||
|
|
e9fd15e7b6 |
@@ -1,8 +1,13 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
import { COLOR_CARD_HALF_SIZES, P0A_COLOR_CARDS, createColorCardElement, drawColorCard, type ColorCardDefinition } from "./palette-provider.js";
|
import { P0A_COLOR_CARDS, createColorCardElement, drawColorCard, type ColorCardDefinition } from "./palette-provider.js";
|
||||||
|
|
||||||
const previewPalette = ["#04D960", "#0ABF58", "#5FD994", "#A0F2C4", "#D5F2E2"] as const;
|
const previewPalette = ["#04D960", "#0ABF58", "#5FD994", "#A0F2C4", "#D5F2E2"] as const;
|
||||||
|
const previewHalfSize = {
|
||||||
|
style_01: { height: 76, width: 26 }, style_02: { height: 77, width: 18 },
|
||||||
|
style_08: { height: 10, width: 73 }, style_16: { height: 9, width: 78 },
|
||||||
|
} as const;
|
||||||
|
|
||||||
function ColorCardPreview({ definition }: { definition: ColorCardDefinition }) {
|
function ColorCardPreview({ definition }: { definition: ColorCardDefinition }) {
|
||||||
const ref = useRef<HTMLCanvasElement>(null);
|
const ref = useRef<HTMLCanvasElement>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -13,8 +18,7 @@ function ColorCardPreview({ definition }: { definition: ColorCardDefinition }) {
|
|||||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
context.fillStyle = "#30343b";
|
context.fillStyle = "#30343b";
|
||||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
const half = COLOR_CARD_HALF_SIZES[definition.styleId];
|
const half = previewHalfSize[definition.styleId];
|
||||||
if (!half) return;
|
|
||||||
const scale = Math.min(1, 146 / (half.width * 2), 62 / (half.height * 2));
|
const scale = Math.min(1, 146 / (half.width * 2), 62 / (half.height * 2));
|
||||||
context.translate(canvas.width / 2, canvas.height / 2);
|
context.translate(canvas.width / 2, canvas.height / 2);
|
||||||
context.scale(scale, scale);
|
context.scale(scale, scale);
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { P0A_DYNAMIC_STICKER_IDS } from "@dada/template-registry";
|
|||||||
|
|
||||||
import { DYNAMIC_RESOURCE_VERSION } from "./dynamic-render-models.js";
|
import { DYNAMIC_RESOURCE_VERSION } from "./dynamic-render-models.js";
|
||||||
import type { CanvasElementIdentity } from "./editor-elements.js";
|
import type { CanvasElementIdentity } from "./editor-elements.js";
|
||||||
import complexAssetCatalog from "./generated/complex-assets.json";
|
|
||||||
|
|
||||||
type CanvasElement = CanvasState["elements"][number];
|
type CanvasElement = CanvasState["elements"][number];
|
||||||
|
|
||||||
@@ -46,17 +45,23 @@ export function dyn012DisplayParts(element: CanvasElement) {
|
|||||||
} as const;
|
} as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dynamicCatalogById = new Map(complexAssetCatalog.dynamic_stickers.map((item) => [item.template_id, item]));
|
const dynamicDefinitions: readonly DynamicStickerDefinition[] = [
|
||||||
|
{ category: "location", displayName: "地点标题", requiresLocationConsent: false, templateId: "DYN001" },
|
||||||
|
{ category: "location", displayName: "英文地点", requiresLocationConsent: false, templateId: "DYN002" },
|
||||||
|
{ category: "location", displayName: "城市地点", requiresLocationConsent: false, templateId: "DYN003" },
|
||||||
|
{ category: "location", displayName: "经纬地点", requiresLocationConsent: true, templateId: "DYN004" },
|
||||||
|
{ category: "other", displayName: "用户名组合", requiresLocationConsent: false, templateId: "DYN007" },
|
||||||
|
{ category: "time", displayName: "月与时间", requiresLocationConsent: false, templateId: "DYN008" },
|
||||||
|
{ category: "time", displayName: "完整日期", requiresLocationConsent: false, templateId: "DYN011" },
|
||||||
|
{ category: "time", displayName: "数字时间", requiresLocationConsent: false, templateId: "DYN012" },
|
||||||
|
{ category: "identity", displayName: "创作署名", requiresLocationConsent: false, templateId: "DYN015" },
|
||||||
|
{ category: "identity", displayName: "社交 ID", requiresLocationConsent: false, templateId: "DYN016" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
export const P0A_DYNAMIC_STICKERS: readonly DynamicStickerDefinition[] = P0A_DYNAMIC_STICKER_IDS.map((templateId) => {
|
export const P0A_DYNAMIC_STICKERS: readonly DynamicStickerDefinition[] = P0A_DYNAMIC_STICKER_IDS.map((templateId) => {
|
||||||
const item = dynamicCatalogById.get(templateId);
|
const definition = dynamicDefinitions.find((item) => item.templateId === templateId);
|
||||||
if (!item) throw new Error(`missing dynamic sticker definition ${templateId}`);
|
if (!definition) throw new Error(`missing dynamic sticker definition ${templateId}`);
|
||||||
return {
|
return definition;
|
||||||
category: item.category as DynamicCategory,
|
|
||||||
displayName: item.display_name,
|
|
||||||
requiresLocationConsent: item.requires_location_consent,
|
|
||||||
templateId,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function twoDigits(value: number) {
|
function twoDigits(value: number) {
|
||||||
@@ -91,29 +96,7 @@ function snapshotFor(templateId: DynamicTemplateId, context: DynamicProviderCont
|
|||||||
if (templateId === "DYN011") return { fields: { day, month, year }, value: `${year}.${month}.${day}` };
|
if (templateId === "DYN011") return { fields: { day, month, year }, value: `${year}.${month}.${day}` };
|
||||||
if (templateId === "DYN012") return { fields: { font_substitution: "FONT081", hour, minute }, value: `${hour}:${minute}` };
|
if (templateId === "DYN012") return { fields: { font_substitution: "FONT081", hour, minute }, value: `${hour}:${minute}` };
|
||||||
if (templateId === "DYN015") return { fields: { nickname: context.profile.creatorName }, value: context.profile.creatorName };
|
if (templateId === "DYN015") return { fields: { nickname: context.profile.creatorName }, value: context.profile.creatorName };
|
||||||
if (templateId === "DYN016") return { fields: { nickname: normalizeSocialId(context.profile.socialId) }, value: normalizeSocialId(context.profile.socialId) };
|
return { fields: { nickname: normalizeSocialId(context.profile.socialId) }, value: normalizeSocialId(context.profile.socialId) };
|
||||||
|
|
||||||
const definition = dynamicCatalogById.get(templateId);
|
|
||||||
if (!definition) throw new Error("dynamic_template_unavailable");
|
|
||||||
const location = context.location?.formattedValue ?? "输入地点";
|
|
||||||
const values: Record<string, string | number> = {
|
|
||||||
city: location,
|
|
||||||
city_en: location.toUpperCase(),
|
|
||||||
day,
|
|
||||||
hour,
|
|
||||||
latitude: context.location?.latitude ?? 0,
|
|
||||||
longitude: context.location?.longitude ?? 0,
|
|
||||||
minute,
|
|
||||||
month,
|
|
||||||
nickname: normalizeSocialId(context.profile.socialId),
|
|
||||||
title: location,
|
|
||||||
year,
|
|
||||||
};
|
|
||||||
const fields = Object.fromEntries(definition.required_fields.map((field) => [field, values[field] ?? ""]));
|
|
||||||
if (definition.category === "identity") return { fields, value: normalizeSocialId(context.profile.socialId) };
|
|
||||||
if (definition.category === "location") return { fields, value: location };
|
|
||||||
if (definition.category === "time") return { fields, value: `${year}.${month}.${day} ${hour}:${minute}` };
|
|
||||||
return { fields, value: context.profile.creatorName };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createDynamicStickerElement(
|
export function createDynamicStickerElement(
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import type { CanvasState } from "@dada/shared-contracts";
|
import type { CanvasState } from "@dada/shared-contracts";
|
||||||
import { P0A_COMPLEX_RELEASE_VERSION, P0A_DYNAMIC_STICKER_IDS } from "@dada/template-registry";
|
import { P0A_COMPLEX_RELEASE_VERSION } from "@dada/template-registry";
|
||||||
|
|
||||||
import { fontOption, type FontOption } from "./text-assets.js";
|
import { fontOption, type FontOption } from "./text-assets.js";
|
||||||
import type { DynamicTemplateId } from "./dynamic-provider.js";
|
import type { DynamicTemplateId } from "./dynamic-provider.js";
|
||||||
import complexAssetCatalog from "./generated/complex-assets.json";
|
|
||||||
|
|
||||||
type CanvasElement = CanvasState["elements"][number];
|
type CanvasElement = CanvasState["elements"][number];
|
||||||
|
|
||||||
@@ -56,13 +55,17 @@ const dynamicFont = (fontId: string): FontOption => ({
|
|||||||
url: `/api/v1/assets/public/${DYNAMIC_RESOURCE_VERSION}/${fontId}`,
|
url: `/api/v1/assets/public/${DYNAMIC_RESOURCE_VERSION}/${fontId}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const dynamicFontIds = [...new Set(complexAssetCatalog.dynamic_stickers.flatMap((item) => item.font_ids))]
|
export const DYNAMIC_FONT_OPTIONS: readonly FontOption[] = [
|
||||||
.filter((fontId) => fontId !== "FONT081")
|
dynamicFont("15974853bc3294ef68e7e6d58fe74fd7"),
|
||||||
.toSorted();
|
dynamicFont("46f8336813e4c48d06a1aef294fdccf6"),
|
||||||
|
dynamicFont("53ca6b704728520da50c145eabb2e635"),
|
||||||
|
dynamicFont("cca5efc0e02fb1bf62349bd68ef30fc1"),
|
||||||
|
dynamicFont("dd25b35dcb7ba4476cbaa9a9592e39e2"),
|
||||||
|
dynamicFont("e4210c9872f0c279b35273f230809821"),
|
||||||
|
dynamicFont("f4bfd4132df2d6be97ceabadf3853505"),
|
||||||
|
] as const;
|
||||||
|
|
||||||
export const DYNAMIC_FONT_OPTIONS: readonly FontOption[] = dynamicFontIds.map(dynamicFont);
|
export const DYNAMIC_RENDER_MODELS: Readonly<Record<DynamicTemplateId, DynamicRenderModel>> = {
|
||||||
|
|
||||||
const EXACT_DYNAMIC_RENDER_MODELS: Readonly<Record<string, DynamicRenderModel>> = {
|
|
||||||
DYN001: {
|
DYN001: {
|
||||||
halfSize: { height: 42, width: 130 },
|
halfSize: { height: 42, width: 130 },
|
||||||
imageLayers: [{ assetId: "DYN001-image28", height: 67, width: 219, x: -17.562, y: 2.203 }],
|
imageLayers: [{ assetId: "DYN001-image28", height: 67, width: 219, x: -17.562, y: 2.203 }],
|
||||||
@@ -142,38 +145,6 @@ const EXACT_DYNAMIC_RENDER_MODELS: Readonly<Record<string, DynamicRenderModel>>
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const dynamicCatalogById = new Map(complexAssetCatalog.dynamic_stickers.map((item) => [item.template_id, item]));
|
|
||||||
|
|
||||||
function genericTextValue(item: (typeof complexAssetCatalog.dynamic_stickers)[number]): DynamicTextValue {
|
|
||||||
if (item.category === "identity") return "nickname";
|
|
||||||
if (item.category === "location") return item.required_fields.includes("title") ? "title" : "city";
|
|
||||||
if (item.category === "time") return item.required_fields.includes("hour") && item.required_fields.includes("minute") ? "time" : "day";
|
|
||||||
return "nickname";
|
|
||||||
}
|
|
||||||
|
|
||||||
function genericDynamicModel(templateId: string): DynamicRenderModel {
|
|
||||||
const item = dynamicCatalogById.get(templateId);
|
|
||||||
if (!item) throw new Error(`missing dynamic render model ${templateId}`);
|
|
||||||
return {
|
|
||||||
halfSize: { height: 42, width: 170 },
|
|
||||||
imageLayers: [],
|
|
||||||
sourceCandidateId: item.source_candidate_id,
|
|
||||||
textLayers: [{
|
|
||||||
align: "center",
|
|
||||||
color: "#FFFFFF",
|
|
||||||
fontId: item.font_ids[0] ?? "FONT081",
|
|
||||||
fontSize: 34,
|
|
||||||
value: genericTextValue(item),
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DYNAMIC_RENDER_MODELS: Readonly<Record<DynamicTemplateId, DynamicRenderModel>> = Object.fromEntries(
|
|
||||||
P0A_DYNAMIC_STICKER_IDS.map((templateId) => [templateId, EXACT_DYNAMIC_RENDER_MODELS[templateId] ?? genericDynamicModel(templateId)]),
|
|
||||||
);
|
|
||||||
|
|
||||||
export function dynamicFontOptionsFor(templateId: string) {
|
export function dynamicFontOptionsFor(templateId: string) {
|
||||||
if (templateId === "DYN012") {
|
if (templateId === "DYN012") {
|
||||||
const replacement = fontOption("FONT081");
|
const replacement = fontOption("FONT081");
|
||||||
@@ -182,8 +153,7 @@ export function dynamicFontOptionsFor(templateId: string) {
|
|||||||
const model = DYNAMIC_RENDER_MODELS[templateId as DynamicTemplateId];
|
const model = DYNAMIC_RENDER_MODELS[templateId as DynamicTemplateId];
|
||||||
if (!model) return [];
|
if (!model) return [];
|
||||||
const ids = new Set(model.textLayers.map((layer) => layer.fontId));
|
const ids = new Set(model.textLayers.map((layer) => layer.fontId));
|
||||||
return [...ids].map((fontId) => fontOption(fontId) ?? DYNAMIC_FONT_OPTIONS.find((option) => option.fontId === fontId))
|
return DYNAMIC_FONT_OPTIONS.filter((option) => ids.has(option.fontId));
|
||||||
.filter((option): option is FontOption => option !== undefined);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function dynamicImageUrl(resourceVersion: string, assetId: string) {
|
export function dynamicImageUrl(resourceVersion: string, assetId: string) {
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ function DynamicPreview(props: { fontStatuses: Readonly<Record<string, ArchivedF
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
const model = DYNAMIC_RENDER_MODELS[props.templateId];
|
const model = DYNAMIC_RENDER_MODELS[props.templateId];
|
||||||
if (!model) return () => { active = false; };
|
|
||||||
const element = createDynamicStickerElement(props.templateId, {
|
const element = createDynamicStickerElement(props.templateId, {
|
||||||
location: { formattedValue: "温州", latitude: 27.9943, longitude: 120.6994 },
|
location: { formattedValue: "温州", latitude: 27.9943, longitude: 120.6994 },
|
||||||
now: new Date("2026-08-03T09:07:00+08:00"), profile: { creatorName: "Dada Creator", socialId: "@dada" },
|
now: new Date("2026-08-03T09:07:00+08:00"), profile: { creatorName: "Dada Creator", socialId: "@dada" },
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { isCanvasState, type CanvasState } from "@dada/shared-contracts";
|
|||||||
|
|
||||||
import { DYNAMIC_RENDER_MODELS } from "./dynamic-render-models.js";
|
import { DYNAMIC_RENDER_MODELS } from "./dynamic-render-models.js";
|
||||||
import type { DynamicTemplateId } from "./dynamic-provider.js";
|
import type { DynamicTemplateId } from "./dynamic-provider.js";
|
||||||
import { textTemplateById } from "./text-assets.js";
|
|
||||||
|
|
||||||
type CanvasElement = CanvasState["elements"][number];
|
type CanvasElement = CanvasState["elements"][number];
|
||||||
|
|
||||||
@@ -77,10 +76,9 @@ export function elementHalfExtents(state: CanvasState, element: CanvasElement):
|
|||||||
const longestCharacterCount = Math.max(1, ...lines.map((line) => Array.from(line).length));
|
const longestCharacterCount = Math.max(1, ...lines.map((line) => Array.from(line).length));
|
||||||
const widthPixels = longestLine * fontSize + (longestCharacterCount - 1) * letterSpacing + 32 + strokeWidth * 2;
|
const widthPixels = longestLine * fontSize + (longestCharacterCount - 1) * letterSpacing + 32 + strokeWidth * 2;
|
||||||
const heightPixels = Math.max(1, lines.length) * fontSize * lineHeight + 32 + strokeWidth * 2;
|
const heightPixels = Math.max(1, lines.length) * fontSize * lineHeight + 32 + strokeWidth * 2;
|
||||||
const template = textTemplateById(element.template_or_asset_id);
|
|
||||||
return {
|
return {
|
||||||
x: Math.max(hitHalfExtent, widthPixels / state.pixel_width / 2, (template?.renderModel.halfSize.width ?? 0) / state.pixel_width) * element.scale.x,
|
x: Math.max(hitHalfExtent, widthPixels / state.pixel_width / 2) * element.scale.x,
|
||||||
y: Math.max(hitHalfExtent, heightPixels / state.pixel_height / 2, (template?.renderModel.halfSize.height ?? 0) / state.pixel_height) * element.scale.y,
|
y: Math.max(hitHalfExtent, heightPixels / state.pixel_height / 2) * element.scale.y,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
.editor-page-shell {
|
.editor-page-shell {
|
||||||
height: 100vh;
|
min-height: 100vh;
|
||||||
height: 100dvh;
|
|
||||||
min-height: 0;
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: 56px minmax(0, 1fr) 32px;
|
grid-template-rows: 56px minmax(0, 1fr) 32px;
|
||||||
background: #e8e8e5;
|
background: #e8e8e5;
|
||||||
color: #111111;
|
color: #111111;
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-page-shell :focus-visible {
|
.editor-page-shell :focus-visible {
|
||||||
@@ -127,7 +124,6 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 280px minmax(0, 1fr) 320px;
|
grid-template-columns: 280px minmax(0, 1fr) 320px;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-assets-panel,
|
.editor-assets-panel,
|
||||||
@@ -399,8 +395,6 @@
|
|||||||
.editor-template-grid button:disabled { border-style: dashed; background: #e8e8e5; color: #62625d; cursor: not-allowed; }
|
.editor-template-grid button:disabled { border-style: dashed; background: #e8e8e5; color: #62625d; cursor: not-allowed; }
|
||||||
.editor-template-grid strong { overflow: hidden; font-family: Consolas, monospace; font-size: 10px; text-overflow: ellipsis; }
|
.editor-template-grid strong { overflow: hidden; font-family: Consolas, monospace; font-size: 10px; text-overflow: ellipsis; }
|
||||||
.editor-template-grid small { color: #8f1d14; font-size: 9px; }
|
.editor-template-grid small { color: #8f1d14; font-size: 9px; }
|
||||||
.editor-template-preview { width: 100%; height: 44px; object-fit: contain; border: 1px solid #111111; background: #30343b; }
|
|
||||||
.editor-template-live-preview { display: grid; width: 100%; height: 44px; place-items: center; overflow: hidden; border: 1px solid #111111; background: #30343b; white-space: nowrap; }
|
|
||||||
.editor-template-mark { display: grid; width: 100%; height: 44px; place-items: center; border: 1px solid #111111; background: #f2f400; font-size: 18px; font-weight: 800; }
|
.editor-template-mark { display: grid; width: 100%; height: 44px; place-items: center; border: 1px solid #111111; background: #f2f400; font-size: 18px; font-weight: 800; }
|
||||||
.editor-template-mark.title { background: #111111; color: #ffffff; }
|
.editor-template-mark.title { background: #111111; color: #ffffff; }
|
||||||
.editor-template-mark.tag { background: #dbeafe; }
|
.editor-template-mark.tag { background: #dbeafe; }
|
||||||
@@ -548,13 +542,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.editor-page-shell { height: auto; min-height: 100dvh; grid-template-rows: auto minmax(0, 1fr) auto; overflow: visible; }
|
.editor-page-shell { grid-template-rows: auto minmax(0, 1fr) auto; }
|
||||||
.editor-toolbar { display: flex; min-height: 56px; flex-wrap: wrap; gap: 8px; padding: 8px 10px; }
|
.editor-toolbar { display: flex; min-height: 56px; flex-wrap: wrap; gap: 8px; padding: 8px 10px; }
|
||||||
.editor-title { min-width: 0; flex: 1 1 calc(100% - 56px); }
|
.editor-title { min-width: 0; flex: 1 1 calc(100% - 56px); }
|
||||||
.editor-history-actions { order: 3; }
|
.editor-history-actions { order: 3; }
|
||||||
.editor-save-status { order: 4; flex: 1 1 128px; }
|
.editor-save-status { order: 4; flex: 1 1 128px; }
|
||||||
.editor-toolbar-controls > button { display: block; order: 5; }
|
.editor-toolbar-controls > button { display: block; order: 5; }
|
||||||
.editor-layout { grid-template-columns: 1fr; overflow: visible; }
|
.editor-layout { grid-template-columns: 1fr; }
|
||||||
.editor-assets-panel, .editor-inspector { border: 0; }
|
.editor-assets-panel, .editor-inspector { border: 0; }
|
||||||
.editor-assets-panel { order: 2; }
|
.editor-assets-panel { order: 2; }
|
||||||
.editor-inspector { order: 3; }
|
.editor-inspector { order: 3; }
|
||||||
|
|||||||
+52
-155
@@ -32,7 +32,6 @@ import {
|
|||||||
P0A_TEXT_TEMPLATES,
|
P0A_TEXT_TEMPLATES,
|
||||||
TextEditSession,
|
TextEditSession,
|
||||||
createTextTemplateElement,
|
createTextTemplateElement,
|
||||||
textTemplateFontOptions,
|
|
||||||
type TextStylePatch,
|
type TextStylePatch,
|
||||||
type TextTemplateCategory,
|
type TextTemplateCategory,
|
||||||
type TextTemplateDefinition,
|
type TextTemplateDefinition,
|
||||||
@@ -87,14 +86,6 @@ interface EditorExportResult {
|
|||||||
status: ExportFlowStatus;
|
status: ExportFlowStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
function withTextDraft(canvasState: CanvasState, textEdit: TextEditState | undefined) {
|
|
||||||
if (!textEdit) return canvasState;
|
|
||||||
return {
|
|
||||||
...canvasState,
|
|
||||||
elements: canvasState.elements.map((element) => element.element_id === textEdit.elementId ? textEdit.draft : element),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EditorProject {
|
interface EditorProject {
|
||||||
canvas_state?: CanvasState;
|
canvas_state?: CanvasState;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -164,26 +155,11 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
const elementControllerRef = useRef<CanvasElementController | undefined>(undefined);
|
const elementControllerRef = useRef<CanvasElementController | undefined>(undefined);
|
||||||
const dragRef = useRef<{ base: CanvasState; last: CanvasState; selectedIds: string[] } | undefined>(undefined);
|
const dragRef = useRef<{ base: CanvasState; last: CanvasState; selectedIds: string[] } | undefined>(undefined);
|
||||||
const opacityGestureRef = useRef<{ base: CanvasState; last: CanvasState; selectedIds: string[] } | undefined>(undefined);
|
const opacityGestureRef = useRef<{ base: CanvasState; last: CanvasState; selectedIds: string[] } | undefined>(undefined);
|
||||||
const textHistoryRef = useRef<{ base: CanvasState; elementId: string; last: CanvasState } | undefined>(undefined);
|
|
||||||
const clipboardRef = useRef<CanvasElement[]>([]);
|
const clipboardRef = useRef<CanvasElement[]>([]);
|
||||||
const fontLoaderRef = useRef<ArchivedFontLoader | undefined>(undefined);
|
const fontLoaderRef = useRef<ArchivedFontLoader | undefined>(undefined);
|
||||||
const conflictExportGuardRef = useRef(new ConflictExportGuard());
|
const conflictExportGuardRef = useRef(new ConflictExportGuard());
|
||||||
const candidateMenuRef = useRef<HTMLDivElement | null>(null);
|
const candidateMenuRef = useRef<HTMLDivElement | null>(null);
|
||||||
const candidateTriggerRef = useRef<HTMLButtonElement | null>(null);
|
const candidateTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const noticeTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
||||||
|
|
||||||
function showNotice(message: string) {
|
|
||||||
if (noticeTimerRef.current) clearTimeout(noticeTimerRef.current);
|
|
||||||
setNotice(message);
|
|
||||||
noticeTimerRef.current = setTimeout(() => {
|
|
||||||
setNotice("");
|
|
||||||
noticeTimerRef.current = undefined;
|
|
||||||
}, 3_000);
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => () => {
|
|
||||||
if (noticeTimerRef.current) clearTimeout(noticeTimerRef.current);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
@@ -199,7 +175,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
setDraftAdjustments(initial.background.adjustments);
|
setDraftAdjustments(initial.background.adjustments);
|
||||||
historyRef.current = new CanvasEditHistory(initial);
|
historyRef.current = new CanvasEditHistory(initial);
|
||||||
elementControllerRef.current = new CanvasElementController(initial);
|
elementControllerRef.current = new CanvasElementController(initial);
|
||||||
}).catch(() => { if (active) showNotice("编辑器暂时无法读取项目"); });
|
}).catch(() => { if (active) setNotice("编辑器暂时无法读取项目"); });
|
||||||
return () => { active = false; };
|
return () => { active = false; };
|
||||||
}, [projectId]);
|
}, [projectId]);
|
||||||
|
|
||||||
@@ -252,14 +228,12 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!canvasState) return;
|
if (!canvasState) return;
|
||||||
const options = canvasState.elements.flatMap((element) => element.type === "text_template"
|
for (const element of canvasState.elements) {
|
||||||
? [...textTemplateFontOptions(element.template_or_asset_id), ...[fontIdForTextElement(element)]
|
const options = element.type === "text_template"
|
||||||
.map((fontId) => fontId ? fontOption(fontId) : undefined).filter((option) => option !== undefined)]
|
? [fontIdForTextElement(element)].map((fontId) => fontId ? fontOption(fontId) : undefined).filter((option) => option !== undefined)
|
||||||
: element.type === "dynamic_sticker" ? dynamicFontOptionsFor(element.template_or_asset_id) : []);
|
: element.type === "dynamic_sticker" ? dynamicFontOptionsFor(element.template_or_asset_id) : [];
|
||||||
const unique = [...new Map(options.map((option) => [option.fontId, option])).values()];
|
for (const option of options) void ensureFont(option.fontId, option.url);
|
||||||
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("|")]);
|
}, [canvasState?.elements.map((element) => `${element.element_id}:${element.font_override ?? ""}:${element.template_or_asset_id}`).join("|")]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -287,10 +261,6 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
});
|
});
|
||||||
}, [canvasState, selectedIds.join("|")]);
|
}, [canvasState, selectedIds.join("|")]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (textEdit) commitTextDraftAutomatically(textEdit);
|
|
||||||
}, [textEdit?.draft]);
|
|
||||||
|
|
||||||
async function ensureFont(fontId: string, url: string, retry = false) {
|
async function ensureFont(fontId: string, url: string, retry = false) {
|
||||||
const current = fontStatuses[fontId];
|
const current = fontStatuses[fontId];
|
||||||
if (current === "ready" || (current === "unavailable" && !retry)) return current;
|
if (current === "ready" || (current === "unavailable" && !retry)) return current;
|
||||||
@@ -303,51 +273,28 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function retryTextFonts() {
|
async function retryTextFonts() {
|
||||||
const failed = [...new Set(P0A_TEXT_TEMPLATES
|
const available = P0A_TEXT_TEMPLATES.filter((template) => template.available && template.fontUrl);
|
||||||
.filter((template) => fontStatuses[template.defaultFontId] === "unavailable")
|
await Promise.all(available.map((template) => ensureFont(template.defaultFontId, template.fontUrl!, true)));
|
||||||
.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) {
|
function commitCanvas(next: CanvasState) {
|
||||||
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() {
|
|
||||||
const pending = textHistoryRef.current;
|
|
||||||
if (!pending) return undefined;
|
|
||||||
textHistoryRef.current = undefined;
|
|
||||||
historyRef.current?.commit(pending.last);
|
|
||||||
return pending.last;
|
|
||||||
}
|
|
||||||
|
|
||||||
function commitCanvas(next: CanvasState, options: { preserveTextEdit?: boolean } = {}) {
|
|
||||||
if (!project || saveStatus === "conflicted") return;
|
if (!project || saveStatus === "conflicted") return;
|
||||||
const finalizedText = finalizeTextHistory();
|
historyRef.current?.commit(next);
|
||||||
if (!finalizedText || JSON.stringify(finalizedText) !== JSON.stringify(next)) historyRef.current?.commit(next);
|
|
||||||
elementControllerRef.current?.replaceState(next);
|
elementControllerRef.current?.replaceState(next);
|
||||||
setSelectedIds(elementControllerRef.current?.selectedIds ?? []);
|
setSelectedIds(elementControllerRef.current?.selectedIds ?? []);
|
||||||
setCanvasState(next);
|
setCanvasState(next);
|
||||||
setDraftAdjustments(next.background.adjustments);
|
setDraftAdjustments(next.background.adjustments);
|
||||||
queueRef.current?.commit({ canvas_state: next, name: project.name });
|
queueRef.current?.commit({ canvas_state: next, name: project.name });
|
||||||
if (!options.preserveTextEdit) setTextEdit(undefined);
|
setTextEdit(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyPreview() {
|
function applyPreview() {
|
||||||
if (!canvasState) return;
|
if (!canvasState) return;
|
||||||
commitCanvas(updateBackgroundAdjustments(canvasState, draftAdjustments));
|
commitCanvas(updateBackgroundAdjustments(canvasState, draftAdjustments));
|
||||||
showNotice("底图调整已提交");
|
setNotice("底图调整已提交");
|
||||||
}
|
}
|
||||||
|
|
||||||
function undo() {
|
function undo() {
|
||||||
finalizeTextHistory();
|
|
||||||
const previous = historyRef.current?.undo();
|
const previous = historyRef.current?.undo();
|
||||||
if (previous) {
|
if (previous) {
|
||||||
elementControllerRef.current?.replaceState(previous);
|
elementControllerRef.current?.replaceState(previous);
|
||||||
@@ -360,7 +307,6 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function redo() {
|
function redo() {
|
||||||
finalizeTextHistory();
|
|
||||||
const next = historyRef.current?.redo();
|
const next = historyRef.current?.redo();
|
||||||
if (next) {
|
if (next) {
|
||||||
elementControllerRef.current?.replaceState(next);
|
elementControllerRef.current?.replaceState(next);
|
||||||
@@ -388,9 +334,9 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
const palette = await paletteForAsset(pendingBackground);
|
const palette = await paletteForAsset(pendingBackground);
|
||||||
commitCanvas(switchBackground(canvasState, pendingBackground, palette));
|
commitCanvas(switchBackground(canvasState, pendingBackground, palette));
|
||||||
setPendingBackground(undefined);
|
setPendingBackground(undefined);
|
||||||
showNotice("已更换底图,覆盖元素保留,底图处理已重置");
|
setNotice("已更换底图,覆盖元素保留,底图处理已重置");
|
||||||
} catch {
|
} catch {
|
||||||
showNotice("新底图无法读取,未更换底图或刷新色卡");
|
setNotice("新底图无法读取,未更换底图或刷新色卡");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,7 +353,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
commitCanvas(next);
|
commitCanvas(next);
|
||||||
setGuides([]);
|
setGuides([]);
|
||||||
setCandidateMenu(undefined);
|
setCandidateMenu(undefined);
|
||||||
showNotice(message);
|
setNotice(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
function addSticker(sticker: StaticStickerCatalogItem) {
|
function addSticker(sticker: StaticStickerCatalogItem) {
|
||||||
@@ -423,8 +369,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
}));
|
}));
|
||||||
commitElementOperation(controller, "贴纸已加入画布");
|
commitElementOperation(controller, "贴纸已加入画布");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message === "canvas_element_limit_reached") showNotice("画布最多 50 个元素,请先删除现有元素。");
|
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||||
else showNotice("贴纸未能加入画布");
|
else setNotice("贴纸未能加入画布");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,8 +383,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
controller.add(createColorCardElement(definition, palette, newElementIdentity(), canvasState.elements.length));
|
controller.add(createColorCardElement(definition, palette, newElementIdentity(), canvasState.elements.length));
|
||||||
commitElementOperation(controller, "色卡已按原始底图加入画布");
|
commitElementOperation(controller, "色卡已按原始底图加入画布");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message === "canvas_element_limit_reached") showNotice("画布最多 50 个元素,请先删除现有元素。");
|
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||||
else showNotice("无法从原始底图稳定提取五色,色卡未加入画布");
|
else setNotice("无法从原始底图稳定提取五色,色卡未加入画布");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,7 +394,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
if (templateId === "DYN012") {
|
if (templateId === "DYN012") {
|
||||||
const font = fontOption("FONT081");
|
const font = fontOption("FONT081");
|
||||||
if (!font || await ensureFont(font.fontId, font.url) !== "ready") {
|
if (!font || await ensureFont(font.fontId, font.url) !== "ready") {
|
||||||
showNotice("DYN012 的 FONT081 替代字体不可用,未使用系统字体替代。");
|
setNotice("DYN012 的 FONT081 替代字体不可用,未使用系统字体替代。");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -461,8 +407,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
commitElementOperation(controller, "动态值已确认并加入画布");
|
commitElementOperation(controller, "动态值已确认并加入画布");
|
||||||
setLocationDialog(undefined);
|
setLocationDialog(undefined);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message === "canvas_element_limit_reached") showNotice("画布最多 50 个元素,请先删除现有元素。");
|
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||||
else showNotice("动态贴纸未能加入画布");
|
else setNotice("动态贴纸未能加入画布");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -510,7 +456,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
controller.replaceElement(overrideDynamicStickerValue(element, value));
|
controller.replaceElement(overrideDynamicStickerValue(element, value));
|
||||||
commitElementOperation(controller, "动态贴纸显示文字已更新");
|
commitElementOperation(controller, "动态贴纸显示文字已更新");
|
||||||
} catch {
|
} catch {
|
||||||
showNotice("动态贴纸显示文字不能为空");
|
setNotice("动态贴纸显示文字不能为空");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -532,8 +478,9 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
|
|
||||||
async function addTextTemplate(template: TextTemplateDefinition) {
|
async function addTextTemplate(template: TextTemplateDefinition) {
|
||||||
if (!template.fontUrl || !canvasState) return;
|
if (!template.fontUrl || !canvasState) return;
|
||||||
if (!await ensureTemplateFonts(template)) {
|
const status = await ensureFont(template.defaultFontId, template.fontUrl);
|
||||||
showNotice("素材暂不可用,未使用系统字体替代。");
|
if (status !== "ready") {
|
||||||
|
setNotice("素材暂不可用,未使用系统字体替代。");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const controller = controllerForCurrent();
|
const controller = controllerForCurrent();
|
||||||
@@ -543,8 +490,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
commitElementOperation(controller, "文字模板已加入画布");
|
commitElementOperation(controller, "文字模板已加入画布");
|
||||||
void recordRecentTextTemplate(template.templateId, template.resourceVersion);
|
void recordRecentTextTemplate(template.templateId, template.resourceVersion);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message === "canvas_element_limit_reached") showNotice("画布最多 50 个元素,请先删除现有元素。");
|
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||||
else showNotice("文字模板未能加入画布");
|
else setNotice("文字模板未能加入画布");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -556,48 +503,18 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
action(edit);
|
action(edit);
|
||||||
return { ...current, draft: edit.value };
|
return { ...current, draft: edit.value };
|
||||||
} catch {
|
} catch {
|
||||||
showNotice("文字参数不在允许范围内");
|
setNotice("文字参数不在允许范围内");
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function commitTextDraftAutomatically(editState: TextEditState) {
|
|
||||||
if (!canvasState || !project || saveStatus === "conflicted") return;
|
|
||||||
const index = canvasState.elements.findIndex((element) => element.element_id === editState.elementId);
|
|
||||||
if (index < 0 || JSON.stringify(canvasState.elements[index]) === JSON.stringify(editState.draft)) return;
|
|
||||||
try {
|
|
||||||
const complete = new TextEditSession(editState.draft, P0A_TEXT_TEMPLATES).complete();
|
|
||||||
const next = structuredClone(canvasState);
|
|
||||||
next.elements[index] = complete;
|
|
||||||
const history = textHistoryRef.current;
|
|
||||||
if (!history || history.elementId !== editState.elementId) {
|
|
||||||
if (history) finalizeTextHistory();
|
|
||||||
textHistoryRef.current = { base: canvasState, elementId: editState.elementId, last: next };
|
|
||||||
} else {
|
|
||||||
history.last = next;
|
|
||||||
}
|
|
||||||
elementControllerRef.current?.replaceState(next);
|
|
||||||
setCanvasState(next);
|
|
||||||
queueRef.current?.commit({ canvas_state: next, name: project.name });
|
|
||||||
if (complete.template_or_asset_id !== editState.originalTemplateId) {
|
|
||||||
void recordRecentTextTemplate(complete.template_or_asset_id, complete.resource_version);
|
|
||||||
}
|
|
||||||
setTextEdit((current) => current?.elementId === editState.elementId ? {
|
|
||||||
...current,
|
|
||||||
draft: complete,
|
|
||||||
originalTemplateId: complete.template_or_asset_id,
|
|
||||||
} : current);
|
|
||||||
} catch (error) {
|
|
||||||
if (!(error instanceof Error && error.message === "text_content_required")) showNotice("文字编辑未能自动保存");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function changeTextTemplate(templateId: string) {
|
async function changeTextTemplate(templateId: string) {
|
||||||
const template = P0A_TEXT_TEMPLATES.find((candidate) => candidate.templateId === templateId);
|
const template = P0A_TEXT_TEMPLATES.find((candidate) => candidate.templateId === templateId);
|
||||||
if (!template?.fontUrl) return;
|
if (!template?.fontUrl) return;
|
||||||
if (!await ensureTemplateFonts(template)) {
|
const status = await ensureFont(template.defaultFontId, template.fontUrl);
|
||||||
showNotice("素材暂不可用,未使用系统字体替代。");
|
if (status !== "ready") {
|
||||||
|
setNotice("素材暂不可用,未使用系统字体替代。");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateTextDraft((edit) => edit.switchTemplate(templateId));
|
updateTextDraft((edit) => edit.switchTemplate(templateId));
|
||||||
@@ -610,7 +527,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
}
|
}
|
||||||
const option = fontOption(fontId);
|
const option = fontOption(fontId);
|
||||||
if (!option || await ensureFont(option.fontId, option.url) !== "ready") {
|
if (!option || await ensureFont(option.fontId, option.url) !== "ready") {
|
||||||
showNotice("字体素材暂不可用,未使用系统字体替代。");
|
setNotice("字体素材暂不可用,未使用系统字体替代。");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateTextDraft((edit) => edit.setStyle({ fontOverride: fontId }));
|
updateTextDraft((edit) => edit.setStyle({ fontOverride: fontId }));
|
||||||
@@ -618,11 +535,6 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
|
|
||||||
function completeTextEdit() {
|
function completeTextEdit() {
|
||||||
if (!textEdit) return;
|
if (!textEdit) return;
|
||||||
if (!pendingTextDraft()) {
|
|
||||||
finalizeTextHistory();
|
|
||||||
showNotice("文字修改已进入自动保存");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const edit = new TextEditSession(textEdit.draft, P0A_TEXT_TEMPLATES);
|
const edit = new TextEditSession(textEdit.draft, P0A_TEXT_TEMPLATES);
|
||||||
const complete = edit.complete();
|
const complete = edit.complete();
|
||||||
@@ -634,25 +546,17 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
void recordRecentTextTemplate(complete.template_or_asset_id, complete.resource_version);
|
void recordRecentTextTemplate(complete.template_or_asset_id, complete.resource_version);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message === "text_content_required") showNotice("请输入文字内容或删除该元素。");
|
if (error instanceof Error && error.message === "text_content_required") setNotice("请输入文字内容或删除该元素。");
|
||||||
else showNotice("文字编辑未能完成");
|
else setNotice("文字编辑未能完成");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelTextEdit() {
|
function cancelTextEdit() {
|
||||||
const pendingHistory = textHistoryRef.current;
|
|
||||||
if (pendingHistory && project) {
|
|
||||||
textHistoryRef.current = undefined;
|
|
||||||
elementControllerRef.current?.replaceState(pendingHistory.base);
|
|
||||||
setCanvasState(pendingHistory.base);
|
|
||||||
queueRef.current?.commit({ canvas_state: pendingHistory.base, name: project.name });
|
|
||||||
}
|
|
||||||
if (canvasState && textEdit) {
|
if (canvasState && textEdit) {
|
||||||
const source = pendingHistory?.base ?? canvasState;
|
const current = canvasState.elements.find((element) => element.element_id === textEdit.elementId);
|
||||||
const current = source.elements.find((element) => element.element_id === textEdit.elementId);
|
|
||||||
if (current) setTextEdit({ draft: structuredClone(current), elementId: current.element_id, originalTemplateId: current.template_or_asset_id });
|
if (current) setTextEdit({ draft: structuredClone(current), elementId: current.element_id, originalTemplateId: current.template_or_asset_id });
|
||||||
}
|
}
|
||||||
showNotice("已取消未提交的文字修改");
|
setNotice("已取消未提交的文字修改");
|
||||||
}
|
}
|
||||||
|
|
||||||
function pendingTextDraft() {
|
function pendingTextDraft() {
|
||||||
@@ -720,7 +624,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ran = await conflictExportGuardRef.current.run(() => executeExport(options));
|
const ran = await conflictExportGuardRef.current.run(() => executeExport(options));
|
||||||
if (!ran) showNotice("版本冲突时仅允许导出本页版本一次");
|
if (!ran) setNotice("版本冲突时仅允许导出本页版本一次");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function retryExportDownload() {
|
async function retryExportDownload() {
|
||||||
@@ -735,8 +639,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
action(controller);
|
action(controller);
|
||||||
commitElementOperation(controller, message);
|
commitElementOperation(controller, message);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message === "canvas_element_limit_reached") showNotice("画布最多 50 个元素,请先删除现有元素。");
|
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||||
else showNotice("对象操作未完成");
|
else setNotice("对象操作未完成");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -764,7 +668,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
opacityGestureRef.current = undefined;
|
opacityGestureRef.current = undefined;
|
||||||
if (gesture.last === gesture.base) return;
|
if (gesture.last === gesture.base) return;
|
||||||
commitCanvas(gesture.last);
|
commitCanvas(gesture.last);
|
||||||
showNotice("贴纸透明度已提交");
|
setNotice("贴纸透明度已提交");
|
||||||
}
|
}
|
||||||
|
|
||||||
function duplicateSelection() {
|
function duplicateSelection() {
|
||||||
@@ -784,7 +688,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
const controller = controllerForCurrent();
|
const controller = controllerForCurrent();
|
||||||
if (!controller || selectedIds.length === 0) return;
|
if (!controller || selectedIds.length === 0) return;
|
||||||
clipboardRef.current = controller.copySelected();
|
clipboardRef.current = controller.copySelected();
|
||||||
showNotice("已复制到画布剪贴板");
|
setNotice("已复制到画布剪贴板");
|
||||||
}
|
}
|
||||||
|
|
||||||
function pasteSelection() {
|
function pasteSelection() {
|
||||||
@@ -794,12 +698,11 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
controller.pasteElements(clipboardRef.current, () => newElementIdentity());
|
controller.pasteElements(clipboardRef.current, () => newElementIdentity());
|
||||||
commitElementOperation(controller, "已粘贴画布对象");
|
commitElementOperation(controller, "已粘贴画布对象");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message === "canvas_element_limit_reached") showNotice("画布最多 50 个元素,请先删除现有元素。");
|
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectAt(point: CanvasPoint, append: boolean) {
|
function selectAt(point: CanvasPoint, append: boolean) {
|
||||||
finalizeTextHistory();
|
|
||||||
const controller = controllerForCurrent();
|
const controller = controllerForCurrent();
|
||||||
if (!controller || !canvasState) return false;
|
if (!controller || !canvasState) return false;
|
||||||
const candidates = controller.candidatesAt(point);
|
const candidates = controller.candidatesAt(point);
|
||||||
@@ -809,8 +712,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
});
|
});
|
||||||
setSelectedIds(selection);
|
setSelectedIds(selection);
|
||||||
setCandidateMenu(undefined);
|
setCandidateMenu(undefined);
|
||||||
const dragBase = withTextDraft(canvasState, textEdit);
|
dragRef.current = { base: canvasState, last: canvasState, selectedIds: selection };
|
||||||
dragRef.current = { base: dragBase, last: dragBase, selectedIds: selection };
|
|
||||||
return candidates.length > 0;
|
return candidates.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -822,25 +724,19 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
const preview = previewController.moveSelected(delta);
|
const preview = previewController.moveSelected(delta);
|
||||||
drag.last = preview.state;
|
drag.last = preview.state;
|
||||||
setCanvasState(preview.state);
|
setCanvasState(preview.state);
|
||||||
setTextEdit((current) => {
|
|
||||||
if (!current || !drag.selectedIds.includes(current.elementId)) return current;
|
|
||||||
const movedDraft = preview.state.elements.find((element) => element.element_id === current.elementId);
|
|
||||||
return movedDraft ? { ...current, draft: movedDraft } : current;
|
|
||||||
});
|
|
||||||
setGuides(preview.guides);
|
setGuides(preview.guides);
|
||||||
}
|
}
|
||||||
|
|
||||||
function commitMove() {
|
function commitMove() {
|
||||||
const drag = dragRef.current;
|
const drag = dragRef.current;
|
||||||
if (!drag) return;
|
if (!drag) return;
|
||||||
commitCanvas(drag.last, { preserveTextEdit: true });
|
commitCanvas(drag.last);
|
||||||
showNotice("对象位置已提交");
|
setNotice("对象位置已提交");
|
||||||
setGuides([]);
|
setGuides([]);
|
||||||
dragRef.current = undefined;
|
dragRef.current = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function marqueeSelect(rectangle: CanvasRect, append: boolean) {
|
function marqueeSelect(rectangle: CanvasRect, append: boolean) {
|
||||||
finalizeTextHistory();
|
|
||||||
const controller = controllerForCurrent();
|
const controller = controllerForCurrent();
|
||||||
if (!controller) return;
|
if (!controller) return;
|
||||||
setSelectedIds(controller.marqueeSelect(rectangle, append || multiMode));
|
setSelectedIds(controller.marqueeSelect(rectangle, append || multiMode));
|
||||||
@@ -889,7 +785,6 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function clearSelection() {
|
function clearSelection() {
|
||||||
finalizeTextHistory();
|
|
||||||
elementControllerRef.current?.clearSelection();
|
elementControllerRef.current?.clearSelection();
|
||||||
setSelectedIds([]);
|
setSelectedIds([]);
|
||||||
setCandidateMenu(undefined);
|
setCandidateMenu(undefined);
|
||||||
@@ -901,7 +796,10 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
...canvasState,
|
...canvasState,
|
||||||
background: { ...canvasState.background, adjustments: draftAdjustments },
|
background: { ...canvasState.background, adjustments: draftAdjustments },
|
||||||
};
|
};
|
||||||
const renderedCanvasState = withTextDraft(backgroundPreviewState, textEdit);
|
const renderedCanvasState = textEdit ? {
|
||||||
|
...backgroundPreviewState,
|
||||||
|
elements: backgroundPreviewState.elements.map((element) => element.element_id === textEdit.elementId ? textEdit.draft : element),
|
||||||
|
} : backgroundPreviewState;
|
||||||
const imageUrl = `/api/v1/private-assets/projects/${projectId}/images/${canvasState.background.asset_id ?? project.current_image_id ?? ""}`;
|
const imageUrl = `/api/v1/private-assets/projects/${projectId}/images/${canvasState.background.asset_id ?? project.current_image_id ?? ""}`;
|
||||||
const canEdit = saveStatus !== "conflicted";
|
const canEdit = saveStatus !== "conflicted";
|
||||||
const selectedElements = renderedCanvasState.elements.filter((element) => selectedIds.includes(element.element_id));
|
const selectedElements = renderedCanvasState.elements.filter((element) => selectedIds.includes(element.element_id));
|
||||||
@@ -946,7 +844,6 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
fontStatuses={fontStatuses}
|
fontStatuses={fontStatuses}
|
||||||
onAdd={(template) => { void addTextTemplate(template); }}
|
onAdd={(template) => { void addTextTemplate(template); }}
|
||||||
onCategory={setTemplateCategory}
|
onCategory={setTemplateCategory}
|
||||||
onEnsure={(template) => { void ensureTemplateFonts(template); }}
|
|
||||||
onQuery={setTemplateQuery}
|
onQuery={setTemplateQuery}
|
||||||
onRetry={() => { void retryTextFonts(); }}
|
onRetry={() => { void retryTextFonts(); }}
|
||||||
query={templateQuery}
|
query={templateQuery}
|
||||||
@@ -979,6 +876,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
maxWidth: `min(720px, calc(${(canvasState.pixel_width / canvasState.pixel_height * 100).toFixed(4)}vh - ${(canvasState.pixel_width / canvasState.pixel_height * 168).toFixed(4)}px))`,
|
maxWidth: `min(720px, calc(${(canvasState.pixel_width / canvasState.pixel_height * 100).toFixed(4)}vh - ${(canvasState.pixel_width / canvasState.pixel_height * 168).toFixed(4)}px))`,
|
||||||
}}>
|
}}>
|
||||||
<EditorStage
|
<EditorStage
|
||||||
|
assetId={canvasState.background.asset_id}
|
||||||
canvasState={renderedCanvasState}
|
canvasState={renderedCanvasState}
|
||||||
fontStatuses={fontStatuses}
|
fontStatuses={fontStatuses}
|
||||||
guides={guides}
|
guides={guides}
|
||||||
@@ -986,7 +884,6 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
onClearSelection={clearSelection}
|
onClearSelection={clearSelection}
|
||||||
onCopy={copySelection}
|
onCopy={copySelection}
|
||||||
onDelete={deleteSelection}
|
onDelete={deleteSelection}
|
||||||
onDragStart={() => setCandidateMenu(undefined)}
|
|
||||||
onMarquee={marqueeSelect}
|
onMarquee={marqueeSelect}
|
||||||
onMoveCommit={commitMove}
|
onMoveCommit={commitMove}
|
||||||
onMovePreview={previewMove}
|
onMovePreview={previewMove}
|
||||||
|
|||||||
+66
-587
@@ -7,35 +7,19 @@ import type { DynamicTemplateId } from "./dynamic-provider.js";
|
|||||||
import { DYNAMIC_RENDER_MODELS, dynamicFontOptionsFor, dynamicImageUrl, dynamicTextValue } from "./dynamic-render-models.js";
|
import { DYNAMIC_RENDER_MODELS, dynamicFontOptionsFor, dynamicImageUrl, dynamicTextValue } from "./dynamic-render-models.js";
|
||||||
import type { CanvasPoint, CanvasRect } from "./editor-elements.js";
|
import type { CanvasPoint, CanvasRect } from "./editor-elements.js";
|
||||||
import { fontFamilyName, type ArchivedFontStatus } from "./text-font-loader.js";
|
import { fontFamilyName, type ArchivedFontStatus } from "./text-font-loader.js";
|
||||||
import {
|
import { fontIdForTextElement } from "./text-assets.js";
|
||||||
fontIdForTextElement,
|
import { drawColorCard } from "./palette-provider.js";
|
||||||
textTemplateById,
|
|
||||||
textTemplateFontOptions,
|
|
||||||
textTemplateImageUrls,
|
|
||||||
type TextTemplateImageLayer,
|
|
||||||
type TextTemplateFillTextureLayout,
|
|
||||||
type TextTemplateNinePatch,
|
|
||||||
type TextTemplateParticleLayer,
|
|
||||||
type TextTemplateTextLayer,
|
|
||||||
type TextTemplateTextPath,
|
|
||||||
type TextVerticalAlign,
|
|
||||||
} from "./text-assets.js";
|
|
||||||
import { COLOR_CARD_HALF_SIZES, drawColorCard } from "./palette-provider.js";
|
|
||||||
|
|
||||||
interface Gesture {
|
interface Gesture {
|
||||||
append: boolean;
|
append: boolean;
|
||||||
bounds: DOMRect;
|
|
||||||
hit: boolean;
|
hit: boolean;
|
||||||
longPressOpened: boolean;
|
longPressOpened: boolean;
|
||||||
moved: boolean;
|
|
||||||
pointerId: number;
|
pointerId: number;
|
||||||
start: CanvasPoint;
|
start: CanvasPoint;
|
||||||
startClient: CanvasPoint;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const DRAG_THRESHOLD_PX = 4;
|
|
||||||
|
|
||||||
interface EditorStageProps {
|
interface EditorStageProps {
|
||||||
|
assetId: string | null;
|
||||||
canvasState: CanvasState;
|
canvasState: CanvasState;
|
||||||
guides: readonly string[];
|
guides: readonly string[];
|
||||||
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>;
|
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>;
|
||||||
@@ -43,7 +27,6 @@ interface EditorStageProps {
|
|||||||
onClearSelection: () => void;
|
onClearSelection: () => void;
|
||||||
onCopy: () => void;
|
onCopy: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onDragStart: () => void;
|
|
||||||
onMarquee: (rectangle: CanvasRect, append: boolean) => void;
|
onMarquee: (rectangle: CanvasRect, append: boolean) => void;
|
||||||
onMoveCommit: () => void;
|
onMoveCommit: () => void;
|
||||||
onMovePreview: (delta: CanvasPoint) => void;
|
onMovePreview: (delta: CanvasPoint) => void;
|
||||||
@@ -55,10 +38,11 @@ interface EditorStageProps {
|
|||||||
selectedIds: readonly string[];
|
selectedIds: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function pointFromClient(clientX: number, clientY: number, bounds: DOMRect): CanvasPoint {
|
function pointFromEvent(event: PointerEvent<HTMLCanvasElement>): CanvasPoint {
|
||||||
|
const bounds = event.currentTarget.getBoundingClientRect();
|
||||||
return {
|
return {
|
||||||
x: Math.max(0, Math.min(1, (clientX - bounds.left) / bounds.width)),
|
x: Math.max(0, Math.min(1, (event.clientX - bounds.left) / bounds.width)),
|
||||||
y: Math.max(0, Math.min(1, (clientY - bounds.top) / bounds.height)),
|
y: Math.max(0, Math.min(1, (event.clientY - bounds.top) / bounds.height)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,47 +56,6 @@ interface TextPixelGeometry {
|
|||||||
width: number;
|
width: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NinePatchRectangle { height: number; width: number; x: number; y: number }
|
|
||||||
export interface NinePatchSlice { destination: NinePatchRectangle; source: NinePatchRectangle }
|
|
||||||
|
|
||||||
function destinationEdges(size: number, first: number, last: number) {
|
|
||||||
const fixed = first + last;
|
|
||||||
if (fixed <= size || fixed === 0) return [first, last] as const;
|
|
||||||
const ratio = size / fixed;
|
|
||||||
return [first * ratio, last * ratio] as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ninePatchSlices(
|
|
||||||
width: number,
|
|
||||||
height: number,
|
|
||||||
patch: TextTemplateNinePatch,
|
|
||||||
bitmapSize: { height: number; width: number } = { height: patch.sourceHeight, width: patch.sourceWidth },
|
|
||||||
): NinePatchSlice[] {
|
|
||||||
const [left, right] = destinationEdges(width, patch.left, patch.right);
|
|
||||||
const [top, bottom] = destinationEdges(height, patch.top, patch.bottom);
|
|
||||||
const sourceScaleX = bitmapSize.width / patch.sourceWidth;
|
|
||||||
const sourceScaleY = bitmapSize.height / patch.sourceHeight;
|
|
||||||
const sourceColumns = [0, patch.left * sourceScaleX, bitmapSize.width - patch.right * sourceScaleX, bitmapSize.width];
|
|
||||||
const sourceRows = [0, patch.top * sourceScaleY, bitmapSize.height - patch.bottom * sourceScaleY, bitmapSize.height];
|
|
||||||
const destinationColumns = [0, left, width - right, width];
|
|
||||||
const destinationRows = [0, top, height - bottom, height];
|
|
||||||
const slices: NinePatchSlice[] = [];
|
|
||||||
for (let row = 0; row < 3; row += 1) {
|
|
||||||
for (let column = 0; column < 3; column += 1) {
|
|
||||||
const source = {
|
|
||||||
height: sourceRows[row + 1]! - sourceRows[row]!, width: sourceColumns[column + 1]! - sourceColumns[column]!,
|
|
||||||
x: sourceColumns[column]!, y: sourceRows[row]!,
|
|
||||||
};
|
|
||||||
const destination = {
|
|
||||||
height: destinationRows[row + 1]! - destinationRows[row]!, width: destinationColumns[column + 1]! - destinationColumns[column]!,
|
|
||||||
x: destinationColumns[column]!, y: destinationRows[row]!,
|
|
||||||
};
|
|
||||||
if (source.width > 0 && source.height > 0 && destination.width > 0 && destination.height > 0) slices.push({ destination, source });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return slices;
|
|
||||||
}
|
|
||||||
|
|
||||||
function prepareTextContext(context: CanvasRenderingContext2D, element: CanvasState["elements"][number], fontId: string) {
|
function prepareTextContext(context: CanvasRenderingContext2D, element: CanvasState["elements"][number], fontId: string) {
|
||||||
const fontSize = element.font_size ?? 48;
|
const fontSize = element.font_size ?? 48;
|
||||||
const letterSpacing = styleValue(element, "letter_spacing", 1);
|
const letterSpacing = styleValue(element, "letter_spacing", 1);
|
||||||
@@ -134,447 +77,47 @@ function measureTextElement(context: CanvasRenderingContext2D, element: CanvasSt
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawTemplateImageLayer(
|
function drawTextElement(context: CanvasRenderingContext2D, element: CanvasState["elements"][number], fontStatuses: Readonly<Record<string, ArchivedFontStatus>>) {
|
||||||
context: CanvasRenderingContext2D,
|
const fontId = fontIdForTextElement(element);
|
||||||
layer: TextTemplateImageLayer,
|
if (!fontId || fontStatuses[fontId] !== "ready") {
|
||||||
resourceImages: Readonly<Record<string, HTMLImageElement>>,
|
context.fillStyle = "#e5e7eb";
|
||||||
) {
|
context.fillRect(-110, -34, 220, 68);
|
||||||
const image = resourceImages[layer.assetId];
|
context.fillStyle = "#9f1d1d";
|
||||||
if (!image) return;
|
context.font = "600 22px Microsoft YaHei UI, sans-serif";
|
||||||
context.save();
|
|
||||||
context.globalAlpha *= layer.alpha;
|
|
||||||
context.translate(layer.x, layer.y);
|
|
||||||
context.rotate(layer.rotation * Math.PI / 180);
|
|
||||||
context.scale(layer.scaleX, layer.scaleY);
|
|
||||||
const left = -layer.anchorX * layer.width;
|
|
||||||
const top = -layer.anchorY * layer.height;
|
|
||||||
if (layer.ninePatch) {
|
|
||||||
for (const slice of ninePatchSlices(
|
|
||||||
layer.width,
|
|
||||||
layer.height,
|
|
||||||
layer.ninePatch,
|
|
||||||
{ height: image.naturalHeight, width: image.naturalWidth },
|
|
||||||
)) {
|
|
||||||
context.drawImage(
|
|
||||||
image, slice.source.x, slice.source.y, slice.source.width, slice.source.height,
|
|
||||||
left + slice.destination.x, top + slice.destination.y, slice.destination.width, slice.destination.height,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else context.drawImage(image, left, top, 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();
|
|
||||||
}
|
|
||||||
context.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TextTemplateBoxLayoutInput {
|
|
||||||
align: "center" | "left" | "right";
|
|
||||||
anchorX: number;
|
|
||||||
anchorY: number;
|
|
||||||
boxHeight: number;
|
|
||||||
boxWidth: number;
|
|
||||||
fontSize: number;
|
|
||||||
lineCount: number;
|
|
||||||
lineHeight: number;
|
|
||||||
verticalAlign: TextVerticalAlign;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function textTemplateBoxLayout(input: TextTemplateBoxLayoutInput) {
|
|
||||||
const boxLeft = -input.anchorX * input.boxWidth;
|
|
||||||
const boxTop = -input.anchorY * input.boxHeight;
|
|
||||||
const lineAdvance = input.fontSize * input.lineHeight;
|
|
||||||
const blockHeight = input.fontSize + Math.max(0, input.lineCount - 1) * lineAdvance;
|
|
||||||
const x = input.align === "left" ? boxLeft
|
|
||||||
: input.align === "right" ? boxLeft + input.boxWidth
|
|
||||||
: boxLeft + input.boxWidth / 2;
|
|
||||||
const firstY = input.verticalAlign === "top" ? boxTop + input.fontSize / 2
|
|
||||||
: input.verticalAlign === "bottom" ? boxTop + input.boxHeight - blockHeight + input.fontSize / 2
|
|
||||||
: boxTop + (input.boxHeight - blockHeight) / 2 + input.fontSize / 2;
|
|
||||||
return { blockHeight, firstY, lineAdvance, x };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function boundedTextTemplateWidth(measuredWidth: number, boxWidth: number) {
|
|
||||||
return Math.max(1, Math.min(measuredWidth, boxWidth));
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TemplatePathPoint { x: number; y: number }
|
|
||||||
|
|
||||||
function mixPathPoint(from: TemplatePathPoint, to: TemplatePathPoint, amount: number): TemplatePathPoint {
|
|
||||||
return { x: from.x + (to.x - from.x) * amount, y: from.y + (to.y - from.y) * amount };
|
|
||||||
}
|
|
||||||
|
|
||||||
function quadraticPathPoint(from: TemplatePathPoint, control: TemplatePathPoint, to: TemplatePathPoint, amount: number) {
|
|
||||||
const inverse = 1 - amount;
|
|
||||||
return {
|
|
||||||
x: inverse * inverse * from.x + 2 * inverse * amount * control.x + amount * amount * to.x,
|
|
||||||
y: inverse * inverse * from.y + 2 * inverse * amount * control.y + amount * amount * to.y,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function conicPathPoint(from: TemplatePathPoint, control: TemplatePathPoint, to: TemplatePathPoint, weight: number, amount: number) {
|
|
||||||
const inverse = 1 - amount;
|
|
||||||
const denominator = inverse * inverse + 2 * weight * inverse * amount + amount * amount;
|
|
||||||
return {
|
|
||||||
x: (inverse * inverse * from.x + 2 * weight * inverse * amount * control.x + amount * amount * to.x) / denominator,
|
|
||||||
y: (inverse * inverse * from.y + 2 * weight * inverse * amount * control.y + amount * amount * to.y) / denominator,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function cubicPathPoint(from: TemplatePathPoint, first: TemplatePathPoint, second: TemplatePathPoint, to: TemplatePathPoint, amount: number) {
|
|
||||||
const inverse = 1 - amount;
|
|
||||||
return {
|
|
||||||
x: inverse ** 3 * from.x + 3 * inverse * inverse * amount * first.x + 3 * inverse * amount * amount * second.x + amount ** 3 * to.x,
|
|
||||||
y: inverse ** 3 * from.y + 3 * inverse * inverse * amount * first.y + 3 * inverse * amount * amount * second.y + amount ** 3 * to.y,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sampleTemplateTextPath(path: TextTemplateTextPath, steps = 28): TemplatePathPoint[] {
|
|
||||||
const first = path.points[0];
|
|
||||||
if (!first) return [];
|
|
||||||
const sampled: TemplatePathPoint[] = [first];
|
|
||||||
let current = first;
|
|
||||||
let pointIndex = 1;
|
|
||||||
let conicIndex = 0;
|
|
||||||
for (const verb of path.verbs.slice(1)) {
|
|
||||||
const appendCurve = (factory: (amount: number) => TemplatePathPoint) => {
|
|
||||||
for (let step = 1; step <= steps; step += 1) sampled.push(factory(step / steps));
|
|
||||||
current = sampled.at(-1)!;
|
|
||||||
};
|
|
||||||
if (verb === 1) {
|
|
||||||
const to = path.points[pointIndex++];
|
|
||||||
if (to) appendCurve((amount) => mixPathPoint(current, to, amount));
|
|
||||||
} else if (verb === 2) {
|
|
||||||
const control = path.points[pointIndex++];
|
|
||||||
const to = path.points[pointIndex++];
|
|
||||||
if (control && to) appendCurve((amount) => quadraticPathPoint(current, control, to, amount));
|
|
||||||
} else if (verb === 3) {
|
|
||||||
const control = path.points[pointIndex++];
|
|
||||||
const to = path.points[pointIndex++];
|
|
||||||
const weight = path.conicWeights[conicIndex++] ?? 1;
|
|
||||||
if (control && to) appendCurve((amount) => conicPathPoint(current, control, to, weight, amount));
|
|
||||||
} else if (verb === 4) {
|
|
||||||
const controlA = path.points[pointIndex++];
|
|
||||||
const controlB = path.points[pointIndex++];
|
|
||||||
const to = path.points[pointIndex++];
|
|
||||||
if (controlA && controlB && to) appendCurve((amount) => cubicPathPoint(current, controlA, controlB, to, amount));
|
|
||||||
} else if (verb === 5) appendCurve((amount) => mixPathPoint(current, first, amount));
|
|
||||||
}
|
|
||||||
return path.reversed ? sampled.reverse() : sampled;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pathLengths(points: readonly TemplatePathPoint[]) {
|
|
||||||
const lengths = [0];
|
|
||||||
for (let index = 1; index < points.length; index += 1) {
|
|
||||||
const previous = points[index - 1]!;
|
|
||||||
const current = points[index]!;
|
|
||||||
lengths.push(lengths[index - 1]! + Math.hypot(current.x - previous.x, current.y - previous.y));
|
|
||||||
}
|
|
||||||
return lengths;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pointOnSampledPath(points: readonly TemplatePathPoint[], lengths: readonly number[], distance: number) {
|
|
||||||
const total = lengths.at(-1) ?? 0;
|
|
||||||
const target = Math.max(0, Math.min(total, distance));
|
|
||||||
let index = 1;
|
|
||||||
while (index < lengths.length && lengths[index]! < target) index += 1;
|
|
||||||
const current = points[Math.min(index, points.length - 1)]!;
|
|
||||||
const previous = points[Math.max(0, index - 1)]!;
|
|
||||||
const start = lengths[Math.max(0, index - 1)] ?? 0;
|
|
||||||
const end = lengths[Math.min(index, lengths.length - 1)] ?? start;
|
|
||||||
const amount = end > start ? (target - start) / (end - start) : 0;
|
|
||||||
return {
|
|
||||||
angle: Math.atan2(current.y - previous.y, current.x - previous.x),
|
|
||||||
...mixPathPoint(previous, current, amount),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function pathMargin(value: number, length: number) {
|
|
||||||
return Math.abs(value) <= 1 ? value * length : value;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function textTextureCell(layout: TextTemplateFillTextureLayout, glyphIndex: number) {
|
|
||||||
const columns = Math.max(1, Math.trunc(layout.columns));
|
|
||||||
const rows = Math.max(1, Math.trunc(layout.rows));
|
|
||||||
const ids = layout.idList.length > 0 ? layout.idList : [0];
|
|
||||||
const rawId = Math.trunc(ids[Math.max(0, glyphIndex) % ids.length] ?? 0);
|
|
||||||
const cellId = ((rawId % (columns * rows)) + columns * rows) % (columns * rows);
|
|
||||||
return { column: cellId % columns, row: Math.floor(cellId / columns) };
|
|
||||||
}
|
|
||||||
|
|
||||||
function setGlyphTextureFill(
|
|
||||||
context: CanvasRenderingContext2D,
|
|
||||||
image: HTMLImageElement,
|
|
||||||
layout: TextTemplateFillTextureLayout,
|
|
||||||
glyphIndex: number,
|
|
||||||
bounds: { height: number; left: number; top: number; width: number },
|
|
||||||
) {
|
|
||||||
const sourceWidth = image.naturalWidth || image.width;
|
|
||||||
const sourceHeight = image.naturalHeight || image.height;
|
|
||||||
const pattern = sourceWidth > 0 && sourceHeight > 0 ? context.createPattern(image, "no-repeat") : null;
|
|
||||||
if (!pattern) return false;
|
|
||||||
const cell = textTextureCell(layout, glyphIndex);
|
|
||||||
const cellWidth = sourceWidth / Math.max(1, layout.columns);
|
|
||||||
const cellHeight = sourceHeight / Math.max(1, layout.rows);
|
|
||||||
const scaleX = Math.max(1, bounds.width) / cellWidth;
|
|
||||||
const scaleY = Math.max(1, bounds.height) / cellHeight;
|
|
||||||
pattern.setTransform({
|
|
||||||
a: scaleX,
|
|
||||||
b: 0,
|
|
||||||
c: 0,
|
|
||||||
d: scaleY,
|
|
||||||
e: bounds.left - cell.column * cellWidth * scaleX,
|
|
||||||
f: bounds.top - cell.row * cellHeight * scaleY,
|
|
||||||
});
|
|
||||||
context.fillStyle = pattern;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawTextOnTemplatePath(
|
|
||||||
context: CanvasRenderingContext2D,
|
|
||||||
text: string,
|
|
||||||
path: TextTemplateTextPath,
|
|
||||||
letterSpacing: number,
|
|
||||||
stroke: boolean,
|
|
||||||
fillGlyph?: (character: string, glyphIndex: number, width: number) => void,
|
|
||||||
) {
|
|
||||||
const sampled = sampleTemplateTextPath(path);
|
|
||||||
const lengths = pathLengths(sampled);
|
|
||||||
const total = lengths.at(-1) ?? 0;
|
|
||||||
if (sampled.length < 2 || total <= 0) return;
|
|
||||||
const glyphs = Array.from(text.replaceAll("\n", " ")).map((character) => ({ character, width: context.measureText(character).width }));
|
|
||||||
const textLength = glyphs.reduce((sum, glyph, index) => sum + glyph.width + (index === 0 ? 0 : letterSpacing), 0);
|
|
||||||
const firstMargin = Math.max(0, pathMargin(path.firstMargin, total));
|
|
||||||
const lastMargin = Math.max(0, pathMargin(path.lastMargin, total));
|
|
||||||
const available = Math.max(0, total - firstMargin - lastMargin);
|
|
||||||
let cursor = firstMargin + (path.forceAlignment || path.circle ? Math.max(0, available - textLength) / 2 : 0);
|
|
||||||
let textureGlyphIndex = 0;
|
|
||||||
for (const glyph of glyphs) {
|
|
||||||
const center = cursor + glyph.width / 2;
|
|
||||||
if (center > total - lastMargin) break;
|
|
||||||
const point = pointOnSampledPath(sampled, lengths, center);
|
|
||||||
context.save();
|
|
||||||
context.translate(point.x, point.y);
|
|
||||||
if (path.perpendicular) context.rotate(point.angle);
|
|
||||||
context.textAlign = "center";
|
context.textAlign = "center";
|
||||||
context.textBaseline = "middle";
|
context.fillText("字体不可用", 0, 8);
|
||||||
if (stroke) context.strokeText(glyph.character, 0, 0);
|
return;
|
||||||
if (fillGlyph && !/\s/u.test(glyph.character)) fillGlyph(glyph.character, textureGlyphIndex++, glyph.width);
|
|
||||||
else context.fillText(glyph.character, 0, 0);
|
|
||||||
context.restore();
|
|
||||||
cursor += glyph.width + letterSpacing;
|
|
||||||
}
|
}
|
||||||
}
|
const fontSize = element.font_size ?? 48;
|
||||||
|
const lineHeight = styleValue(element, "line_height", 1.2);
|
||||||
function drawStyledTemplateLine(
|
const letterSpacing = styleValue(element, "letter_spacing", 1);
|
||||||
context: CanvasRenderingContext2D,
|
const align = styleValue(element, "text_align", "center") as CanvasTextAlign;
|
||||||
line: string,
|
const lines = (element.content ?? "").split("\n");
|
||||||
align: TextTemplateTextLayer["align"],
|
prepareTextContext(context, element, fontId);
|
||||||
letterSpacing: number,
|
|
||||||
stroke: boolean,
|
|
||||||
patternImage: HTMLImageElement | undefined,
|
|
||||||
textureLayout: TextTemplateFillTextureLayout | undefined,
|
|
||||||
glyphColors: readonly string[] | undefined,
|
|
||||||
firstGlyphIndex: number,
|
|
||||||
) {
|
|
||||||
const glyphs = Array.from(line).map((character) => ({ character, width: context.measureText(character).width }));
|
|
||||||
const lineWidth = glyphs.reduce((sum, glyph, index) => sum + glyph.width + (index === 0 ? 0 : letterSpacing), 0);
|
|
||||||
let cursor = align === "left" ? 0 : align === "right" ? -lineWidth : -lineWidth / 2;
|
|
||||||
let textureGlyphIndex = firstGlyphIndex;
|
|
||||||
const fallbackFill = context.fillStyle;
|
|
||||||
context.textAlign = "center";
|
|
||||||
context.textBaseline = "middle";
|
|
||||||
for (const glyph of glyphs) {
|
|
||||||
const center = cursor + glyph.width / 2;
|
|
||||||
if (stroke) context.strokeText(glyph.character, center, 0);
|
|
||||||
if (!/\s/u.test(glyph.character)) {
|
|
||||||
context.fillStyle = fallbackFill;
|
|
||||||
const glyphColor = glyphColors?.[textureGlyphIndex];
|
|
||||||
if (glyphColor) {
|
|
||||||
context.fillStyle = glyphColor;
|
|
||||||
} else if (patternImage && textureLayout) {
|
|
||||||
setGlyphTextureFill(context, patternImage, textureLayout, textureGlyphIndex, {
|
|
||||||
height: Math.max(1, Number.parseFloat(context.font) || 1),
|
|
||||||
left: cursor,
|
|
||||||
top: -(Number.parseFloat(context.font) || 1) / 2,
|
|
||||||
width: glyph.width,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
textureGlyphIndex += 1;
|
|
||||||
}
|
|
||||||
context.fillText(glyph.character, center, 0);
|
|
||||||
cursor += glyph.width + letterSpacing;
|
|
||||||
}
|
|
||||||
context.fillStyle = fallbackFill;
|
|
||||||
return textureGlyphIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 TextTemplateTextLayer["align"];
|
|
||||||
const lines = (layer.contentLinked ? element.content ?? layer.text : layer.text).split("\n");
|
|
||||||
context.save();
|
|
||||||
context.globalAlpha *= layer.alpha;
|
|
||||||
context.translate(layer.x, layer.y);
|
|
||||||
context.rotate(layer.rotation * Math.PI / 180);
|
|
||||||
context.scale(layer.scaleX, layer.scaleY);
|
|
||||||
context.transform(1, Math.tan(-layer.skewX * Math.PI / 180), Math.tan(-layer.skewY * Math.PI / 180), 1, 0, 0);
|
|
||||||
context.font = `${fontSize}px "${fontFamilyName(fontId)}"`;
|
|
||||||
(context as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = `${letterSpacing}px`;
|
|
||||||
context.textAlign = align;
|
context.textAlign = align;
|
||||||
context.textBaseline = "middle";
|
context.textBaseline = "middle";
|
||||||
const widths = lines.map((line) => context.measureText(line).width + Math.max(0, Array.from(line).length - 1) * letterSpacing);
|
const widths = lines.map((line) => context.measureText(line).width + Math.max(0, line.length - 1) * letterSpacing);
|
||||||
const textWidth = boundedTextTemplateWidth(Math.max(1, ...widths), layer.width);
|
const textWidth = Math.max(1, ...widths);
|
||||||
const layout = textTemplateBoxLayout({
|
const textHeight = Math.max(fontSize * lineHeight, lines.length * fontSize * lineHeight);
|
||||||
align,
|
const padding = 16;
|
||||||
anchorX: layer.anchorX,
|
const backgroundEnabled = styleValue(element, "background_enabled", false);
|
||||||
anchorY: layer.anchorY,
|
if (backgroundEnabled) {
|
||||||
boxHeight: layer.height,
|
const elementOpacity = context.globalAlpha;
|
||||||
boxWidth: layer.width,
|
context.globalAlpha = elementOpacity * styleValue(element, "background_opacity", 1);
|
||||||
fontSize,
|
|
||||||
lineCount: lines.length,
|
|
||||||
lineHeight,
|
|
||||||
verticalAlign: layer.verticalAlign,
|
|
||||||
});
|
|
||||||
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.fillStyle = styleValue(element, "background_color", "#FFE62C");
|
||||||
const backgroundLeft = align === "left" ? layout.x : align === "right" ? layout.x - textWidth : layout.x - textWidth / 2;
|
context.fillRect(-textWidth / 2 - padding, -textHeight / 2 - padding, textWidth + padding * 2, textHeight + padding * 2);
|
||||||
context.fillRect(backgroundLeft - 16, layout.firstY - fontSize / 2 - 16, textWidth + 32, layout.blockHeight + 32);
|
context.globalAlpha = elementOpacity;
|
||||||
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;
|
|
||||||
const gradient = !fillOverridden && layer.fillGradient
|
|
||||||
? context.createLinearGradient(0, -layer.anchorY * layer.height, 0, (1 - layer.anchorY) * layer.height)
|
|
||||||
: undefined;
|
|
||||||
if (gradient && layer.fillGradient) {
|
|
||||||
gradient.addColorStop(0, layer.fillGradient.top);
|
|
||||||
gradient.addColorStop(1, layer.fillGradient.bottom);
|
|
||||||
}
|
|
||||||
const baseFill = editable ? styleValue(element, "fill_color", layer.fillColor) : layer.fillColor;
|
|
||||||
context.fillStyle = patternImage && !layer.fillTextureLayout ? context.createPattern(patternImage, "repeat") ?? baseFill
|
|
||||||
: gradient ?? baseFill;
|
|
||||||
context.strokeStyle = editable ? styleValue(element, "stroke_color", layer.strokeColor) : layer.strokeColor;
|
|
||||||
context.lineWidth = editable ? styleValue(element, "stroke_width", layer.strokeWidth) : layer.strokeWidth;
|
|
||||||
const stroke = (editable ? styleValue(element, "stroke_enabled", layer.strokeWidth > 0) : layer.strokeWidth > 0) && context.lineWidth > 0;
|
|
||||||
if (layer.textPath) {
|
|
||||||
(context as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = "0px";
|
|
||||||
const fallbackFill = context.fillStyle;
|
|
||||||
drawTextOnTemplatePath(context, lines.join(" "), layer.textPath, letterSpacing, stroke,
|
|
||||||
(patternImage && layer.fillTextureLayout) || (!fillOverridden && layer.glyphColors)
|
|
||||||
? (character, glyphIndex, width) => {
|
|
||||||
context.fillStyle = fallbackFill;
|
|
||||||
const glyphColor = layer.glyphColors?.[glyphIndex];
|
|
||||||
if (glyphColor) {
|
|
||||||
context.fillStyle = glyphColor;
|
|
||||||
} else if (patternImage && layer.fillTextureLayout) {
|
|
||||||
setGlyphTextureFill(context, patternImage, layer.fillTextureLayout, glyphIndex, {
|
|
||||||
height: fontSize, left: -width / 2, top: -fontSize / 2, width,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
context.fillText(character, 0, 0);
|
|
||||||
}
|
|
||||||
: undefined);
|
|
||||||
context.restore();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if ((patternImage && layer.fillTextureLayout) || (!fillOverridden && layer.glyphColors)) {
|
|
||||||
(context as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = "0px";
|
|
||||||
let glyphIndex = 0;
|
|
||||||
lines.forEach((line, index) => {
|
|
||||||
const y = layout.firstY + index * layout.lineAdvance;
|
|
||||||
const naturalWidth = context.measureText(line).width + Math.max(0, Array.from(line).length - 1) * letterSpacing;
|
|
||||||
const horizontalScale = Math.min(1, layer.width / Math.max(1, naturalWidth));
|
|
||||||
context.save();
|
|
||||||
context.translate(layout.x, y);
|
|
||||||
context.scale(horizontalScale, 1);
|
|
||||||
glyphIndex = drawStyledTemplateLine(context, line, align, letterSpacing, stroke,
|
|
||||||
patternImage, layer.fillTextureLayout, fillOverridden ? undefined : layer.glyphColors, glyphIndex);
|
|
||||||
context.restore();
|
|
||||||
});
|
|
||||||
context.restore();
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
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) => {
|
lines.forEach((line, index) => {
|
||||||
const y = layout.firstY + index * layout.lineAdvance;
|
const y = firstY + index * fontSize * lineHeight;
|
||||||
if (stroke) context.strokeText(line, layout.x, y, layer.width);
|
if (styleValue(element, "stroke_enabled", false) && context.lineWidth > 0) context.strokeText(line, anchorX, y);
|
||||||
context.fillText(line, layout.x, y, layer.width);
|
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) {
|
function drawDynamicUnavailable(context: CanvasRenderingContext2D, message: string) {
|
||||||
@@ -639,24 +182,22 @@ function elementSelectionHalfSize(
|
|||||||
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>,
|
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>,
|
||||||
) {
|
) {
|
||||||
if (element.type === "color_card") {
|
if (element.type === "color_card") {
|
||||||
const half = COLOR_CARD_HALF_SIZES[element.style_id ?? ""] ?? { height: 24, width: 78 };
|
if (element.style_id === "style_01") return { height: 76 * element.scale.y, width: 26 * element.scale.x };
|
||||||
return { height: half.height * element.scale.y, width: half.width * element.scale.x };
|
if (element.style_id === "style_02") return { height: 77 * element.scale.y, width: 18 * element.scale.x };
|
||||||
|
if (element.style_id === "style_08") return { height: 10 * element.scale.y, width: 73 * element.scale.x };
|
||||||
|
return { height: 9 * element.scale.y, width: 78 * element.scale.x };
|
||||||
}
|
}
|
||||||
if (element.type === "dynamic_sticker") {
|
if (element.type === "dynamic_sticker") {
|
||||||
const model = DYNAMIC_RENDER_MODELS[element.template_or_asset_id as DynamicTemplateId];
|
const model = DYNAMIC_RENDER_MODELS[element.template_or_asset_id as DynamicTemplateId];
|
||||||
return { height: (model?.halfSize.height ?? 62) * element.scale.y, width: (model?.halfSize.width ?? 170) * element.scale.x };
|
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 };
|
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);
|
const fontId = fontIdForTextElement(element);
|
||||||
if (!fontId || fontStatuses[fontId] !== "ready") return { height: 34 * element.scale.y, width: 110 * element.scale.x };
|
if (!fontId || fontStatuses[fontId] !== "ready") return { height: 34 * element.scale.y, width: 110 * element.scale.x };
|
||||||
context.save();
|
context.save();
|
||||||
const geometry = measureTextElement(context, element, fontId);
|
const geometry = measureTextElement(context, element, fontId);
|
||||||
context.restore();
|
context.restore();
|
||||||
return {
|
return { height: geometry.height * element.scale.y / 2, width: geometry.width * element.scale.x / 2 };
|
||||||
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(
|
function drawElement(
|
||||||
@@ -685,7 +226,7 @@ function drawElement(
|
|||||||
const height = image.naturalHeight * scale;
|
const height = image.naturalHeight * scale;
|
||||||
context.drawImage(image, -width / 2, -height / 2, width, height);
|
context.drawImage(image, -width / 2, -height / 2, width, height);
|
||||||
}
|
}
|
||||||
} else if (element.type === "text_template") drawTextTemplate(context, element, fontStatuses, resourceImages);
|
} else if (element.type === "text_template") drawTextElement(context, element, fontStatuses);
|
||||||
else if (element.type === "dynamic_sticker") drawDynamicSticker(context, element, fontStatuses, resourceImages);
|
else if (element.type === "dynamic_sticker") drawDynamicSticker(context, element, fontStatuses, resourceImages);
|
||||||
context.restore();
|
context.restore();
|
||||||
}
|
}
|
||||||
@@ -699,27 +240,6 @@ function loadCanvasImage(url: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
type CanvasImageLoader = (url: string) => Promise<HTMLImageElement | undefined>;
|
|
||||||
|
|
||||||
interface SceneResources {
|
|
||||||
background: HTMLImageElement | undefined;
|
|
||||||
resourceImages: Readonly<Record<string, HTMLImageElement>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createCachedCanvasImageLoader(): CanvasImageLoader {
|
|
||||||
const cache = new Map<string, Promise<HTMLImageElement | undefined>>();
|
|
||||||
return (url) => {
|
|
||||||
const cached = cache.get(url);
|
|
||||||
if (cached) return cached;
|
|
||||||
const pending = loadCanvasImage(url).then((image) => {
|
|
||||||
if (!image) cache.delete(url);
|
|
||||||
return image;
|
|
||||||
});
|
|
||||||
cache.set(url, pending);
|
|
||||||
return pending;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function resourceUrlsForCanvas(canvasState: CanvasState) {
|
function resourceUrlsForCanvas(canvasState: CanvasState) {
|
||||||
const imageReferences = new Map<string, string>();
|
const imageReferences = new Map<string, string>();
|
||||||
for (const element of canvasState.elements) {
|
for (const element of canvasState.elements) {
|
||||||
@@ -728,26 +248,15 @@ function resourceUrlsForCanvas(canvasState: CanvasState) {
|
|||||||
const model = DYNAMIC_RENDER_MODELS[element.template_or_asset_id as DynamicTemplateId];
|
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));
|
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;
|
return imageReferences;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sceneResourceKey(canvasState: CanvasState, projectId: string) {
|
async function loadSceneResources(canvasState: CanvasState, projectId: string) {
|
||||||
const background = canvasState.background.asset_id
|
const background = canvasState.background.asset_id
|
||||||
? `/api/v1/private-assets/projects/${encodeURIComponent(projectId)}/images/${encodeURIComponent(canvasState.background.asset_id)}`
|
? loadCanvasImage(`/api/v1/private-assets/projects/${encodeURIComponent(projectId)}/images/${encodeURIComponent(canvasState.background.asset_id)}`)
|
||||||
: null;
|
|
||||||
const resources = [...resourceUrlsForCanvas(canvasState)].toSorted(([left], [right]) => left.localeCompare(right));
|
|
||||||
return JSON.stringify({ background, projectId, resources });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadSceneResources(canvasState: CanvasState, projectId: string, loadImage: CanvasImageLoader = loadCanvasImage): Promise<SceneResources> {
|
|
||||||
const background = canvasState.background.asset_id
|
|
||||||
? loadImage(`/api/v1/private-assets/projects/${encodeURIComponent(projectId)}/images/${encodeURIComponent(canvasState.background.asset_id)}`)
|
|
||||||
: Promise.resolve(undefined);
|
: Promise.resolve(undefined);
|
||||||
const resources = Promise.all([...resourceUrlsForCanvas(canvasState)].map(async ([assetId, url]) => [assetId, await loadImage(url)] as const));
|
const resources = Promise.all([...resourceUrlsForCanvas(canvasState)].map(async ([assetId, url]) => [assetId, await loadCanvasImage(url)] as const));
|
||||||
const [image, loaded] = await Promise.all([background, resources]);
|
const [image, loaded] = await Promise.all([background, resources]);
|
||||||
return {
|
return {
|
||||||
background: image,
|
background: image,
|
||||||
@@ -807,10 +316,7 @@ function renderEditorScene(
|
|||||||
|
|
||||||
function requiredFontIds(canvasState: CanvasState) {
|
function requiredFontIds(canvasState: CanvasState) {
|
||||||
return canvasState.elements.flatMap((element) => {
|
return canvasState.elements.flatMap((element) => {
|
||||||
if (element.type === "text_template") return [...new Set([
|
if (element.type === "text_template") return [fontIdForTextElement(element)].filter((fontId): fontId is string => Boolean(fontId));
|
||||||
...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);
|
if (element.type === "dynamic_sticker") return dynamicFontOptionsFor(element.template_or_asset_id).map((font) => font.fontId);
|
||||||
return [];
|
return [];
|
||||||
});
|
});
|
||||||
@@ -840,29 +346,16 @@ export function EditorStage(props: EditorStageProps) {
|
|||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const gestureRef = useRef<Gesture | undefined>(undefined);
|
const gestureRef = useRef<Gesture | undefined>(undefined);
|
||||||
const longPressRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
const longPressRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
const imageLoaderRef = useRef<CanvasImageLoader | undefined>(undefined);
|
|
||||||
const [sceneResources, setSceneResources] = useState<{ key: string; resources: SceneResources }>();
|
|
||||||
const [marquee, setMarquee] = useState<CanvasRect>();
|
const [marquee, setMarquee] = useState<CanvasRect>();
|
||||||
const resourceKey = sceneResourceKey(props.canvasState, props.projectId);
|
|
||||||
|
|
||||||
if (!imageLoaderRef.current) imageLoaderRef.current = createCachedCanvasImageLoader();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
void loadSceneResources(props.canvasState, props.projectId, imageLoaderRef.current).then((resources) => {
|
|
||||||
if (active) setSceneResources({ key: resourceKey, resources });
|
|
||||||
});
|
|
||||||
return () => { active = false; };
|
|
||||||
}, [props.projectId, resourceKey]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) return undefined;
|
if (!canvas) return undefined;
|
||||||
if (canvas.width !== props.canvasState.pixel_width) canvas.width = props.canvasState.pixel_width;
|
canvas.width = props.canvasState.pixel_width;
|
||||||
if (canvas.height !== props.canvasState.pixel_height) canvas.height = props.canvasState.pixel_height;
|
canvas.height = props.canvasState.pixel_height;
|
||||||
const context = canvas.getContext("2d");
|
const context = canvas.getContext("2d");
|
||||||
if (!context) return undefined;
|
if (!context) return undefined;
|
||||||
if (!sceneResources || sceneResources.key !== resourceKey) return undefined;
|
|
||||||
const render = (image: HTMLImageElement | undefined, resourceImages: Readonly<Record<string, HTMLImageElement>>) => {
|
const render = (image: HTMLImageElement | undefined, resourceImages: Readonly<Record<string, HTMLImageElement>>) => {
|
||||||
renderEditorScene(context, props.canvasState, props.fontStatuses, image, resourceImages);
|
renderEditorScene(context, props.canvasState, props.fontStatuses, image, resourceImages);
|
||||||
context.lineWidth = 4;
|
context.lineWidth = 4;
|
||||||
@@ -884,22 +377,21 @@ export function EditorStage(props: EditorStageProps) {
|
|||||||
if (marquee) context.strokeRect(marquee.x * canvas.width, marquee.y * canvas.height, marquee.width * canvas.width, marquee.height * canvas.height);
|
if (marquee) context.strokeRect(marquee.x * canvas.width, marquee.y * canvas.height, marquee.width * canvas.width, marquee.height * canvas.height);
|
||||||
context.restore();
|
context.restore();
|
||||||
};
|
};
|
||||||
render(sceneResources.resources.background, sceneResources.resources.resourceImages);
|
void loadSceneResources(props.canvasState, props.projectId).then(({ background, resourceImages }) => {
|
||||||
return undefined;
|
if (!active) return;
|
||||||
}, [marquee, props.canvasState, props.fontStatuses, props.guides, props.selectedIds, resourceKey, sceneResources]);
|
render(background, resourceImages);
|
||||||
|
});
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [marquee, props.assetId, props.canvasState, props.fontStatuses, props.guides, props.projectId, props.selectedIds]);
|
||||||
|
|
||||||
useEffect(() => () => { if (longPressRef.current) clearTimeout(longPressRef.current); }, []);
|
useEffect(() => () => { if (longPressRef.current) clearTimeout(longPressRef.current); }, []);
|
||||||
|
|
||||||
function handlePointerDown(event: PointerEvent<HTMLCanvasElement>) {
|
function handlePointerDown(event: PointerEvent<HTMLCanvasElement>) {
|
||||||
if (event.button !== 0) return;
|
if (event.button !== 0) return;
|
||||||
const bounds = event.currentTarget.getBoundingClientRect();
|
const start = pointFromEvent(event);
|
||||||
const start = pointFromClient(event.clientX, event.clientY, bounds);
|
|
||||||
const append = event.shiftKey;
|
const append = event.shiftKey;
|
||||||
const hit = props.onSelect(start, append);
|
const hit = props.onSelect(start, append);
|
||||||
gestureRef.current = {
|
gestureRef.current = { append, hit, longPressOpened: false, pointerId: event.pointerId, start };
|
||||||
append, bounds, hit, longPressOpened: false, moved: false, pointerId: event.pointerId, start,
|
|
||||||
startClient: { x: event.clientX, y: event.clientY },
|
|
||||||
};
|
|
||||||
event.currentTarget.setPointerCapture(event.pointerId);
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
longPressRef.current = setTimeout(() => {
|
longPressRef.current = setTimeout(() => {
|
||||||
const gesture = gestureRef.current;
|
const gesture = gestureRef.current;
|
||||||
@@ -915,15 +407,9 @@ export function EditorStage(props: EditorStageProps) {
|
|||||||
props.onPointerMoved();
|
props.onPointerMoved();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const clientDistance = Math.hypot(event.clientX - gesture.startClient.x, event.clientY - gesture.startClient.y);
|
const point = pointFromEvent(event);
|
||||||
if (!gesture.moved) {
|
|
||||||
if (clientDistance < DRAG_THRESHOLD_PX) return;
|
|
||||||
gesture.moved = true;
|
|
||||||
gesture.longPressOpened = false;
|
|
||||||
props.onDragStart();
|
|
||||||
}
|
|
||||||
const point = pointFromClient(event.clientX, event.clientY, gesture.bounds);
|
|
||||||
const delta = { x: point.x - gesture.start.x, y: point.y - gesture.start.y };
|
const delta = { x: point.x - gesture.start.x, y: point.y - gesture.start.y };
|
||||||
|
if (Math.abs(delta.x) + Math.abs(delta.y) < 0.003) return;
|
||||||
if (longPressRef.current) clearTimeout(longPressRef.current);
|
if (longPressRef.current) clearTimeout(longPressRef.current);
|
||||||
props.onPointerMoved();
|
props.onPointerMoved();
|
||||||
if (gesture.hit && !gesture.longPressOpened) props.onMovePreview(delta);
|
if (gesture.hit && !gesture.longPressOpened) props.onMovePreview(delta);
|
||||||
@@ -934,18 +420,11 @@ export function EditorStage(props: EditorStageProps) {
|
|||||||
const gesture = gestureRef.current;
|
const gesture = gestureRef.current;
|
||||||
if (!gesture || gesture.pointerId !== event.pointerId) return;
|
if (!gesture || gesture.pointerId !== event.pointerId) return;
|
||||||
if (longPressRef.current) clearTimeout(longPressRef.current);
|
if (longPressRef.current) clearTimeout(longPressRef.current);
|
||||||
const clientDistance = Math.hypot(event.clientX - gesture.startClient.x, event.clientY - gesture.startClient.y);
|
const point = pointFromEvent(event);
|
||||||
const moved = gesture.moved || clientDistance >= DRAG_THRESHOLD_PX;
|
const delta = { x: point.x - gesture.start.x, y: point.y - gesture.start.y };
|
||||||
if (moved && !gesture.moved && !gesture.longPressOpened) {
|
const moved = Math.abs(delta.x) + Math.abs(delta.y) >= 0.003;
|
||||||
const point = pointFromClient(event.clientX, event.clientY, gesture.bounds);
|
|
||||||
const delta = { x: point.x - gesture.start.x, y: point.y - gesture.start.y };
|
|
||||||
if (gesture.hit) props.onMovePreview(delta);
|
|
||||||
}
|
|
||||||
if (gesture.hit && moved && !gesture.longPressOpened) props.onMoveCommit();
|
if (gesture.hit && moved && !gesture.longPressOpened) props.onMoveCommit();
|
||||||
else if (!gesture.hit && moved) {
|
else if (!gesture.hit && moved) props.onMarquee({ height: delta.y, width: delta.x, x: gesture.start.x, y: gesture.start.y }, gesture.append);
|
||||||
const point = pointFromClient(event.clientX, event.clientY, gesture.bounds);
|
|
||||||
props.onMarquee({ height: point.y - gesture.start.y, width: point.x - gesture.start.x, x: gesture.start.x, y: gesture.start.y }, gesture.append);
|
|
||||||
}
|
|
||||||
setMarquee(undefined);
|
setMarquee(undefined);
|
||||||
gestureRef.current = undefined;
|
gestureRef.current = undefined;
|
||||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,155 +0,0 @@
|
|||||||
:root {
|
|
||||||
--dada-interaction-duration: 120ms;
|
|
||||||
--dada-interaction-easing: cubic-bezier(0.2, 0.8, 0.2, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
:where(button:not(:disabled), a[href], label:has(input:not(:disabled))) {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
:where(button:not(:disabled), a[href]) {
|
|
||||||
transition:
|
|
||||||
transform var(--dada-interaction-duration) var(--dada-interaction-easing),
|
|
||||||
box-shadow var(--dada-interaction-duration) var(--dada-interaction-easing),
|
|
||||||
border-color var(--dada-interaction-duration) ease,
|
|
||||||
background-color var(--dada-interaction-duration) ease,
|
|
||||||
color var(--dada-interaction-duration) ease,
|
|
||||||
opacity var(--dada-interaction-duration) ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
:where(input:not(:disabled), select:not(:disabled), textarea:not(:disabled)) {
|
|
||||||
transition:
|
|
||||||
border-color var(--dada-interaction-duration) ease,
|
|
||||||
box-shadow var(--dada-interaction-duration) ease,
|
|
||||||
background-color var(--dada-interaction-duration) ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
:where(.product-page, .product-loading) :focus-visible {
|
|
||||||
outline: 2px solid #005fcc;
|
|
||||||
outline-offset: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:where(input:not(:disabled), select:not(:disabled), textarea:not(:disabled)):focus-visible {
|
|
||||||
border-color: #005fcc;
|
|
||||||
box-shadow: 0 0 0 3px rgb(0 95 204 / 16%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-card,
|
|
||||||
.project-preview img,
|
|
||||||
.ratio-control span,
|
|
||||||
.reference-input,
|
|
||||||
.editor-sticker-preview,
|
|
||||||
.editor-template-mark,
|
|
||||||
.editor-template-preview,
|
|
||||||
.editor-color-card-preview,
|
|
||||||
.editor-dynamic-preview,
|
|
||||||
.editor-source-preview-canvas,
|
|
||||||
.editor-thumb {
|
|
||||||
transition:
|
|
||||||
transform var(--dada-interaction-duration) var(--dada-interaction-easing),
|
|
||||||
box-shadow var(--dada-interaction-duration) var(--dada-interaction-easing),
|
|
||||||
border-color var(--dada-interaction-duration) ease,
|
|
||||||
background-color var(--dada-interaction-duration) ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (hover: hover) and (pointer: fine) {
|
|
||||||
:where(button:not(:disabled)):hover {
|
|
||||||
border-color: #111111;
|
|
||||||
box-shadow: 0 2px 0 rgb(17 17 17 / 35%);
|
|
||||||
}
|
|
||||||
|
|
||||||
:where(a[href]):hover {
|
|
||||||
color: #005fcc;
|
|
||||||
opacity: 0.78;
|
|
||||||
text-decoration-thickness: 2px;
|
|
||||||
text-underline-offset: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-header nav a:hover {
|
|
||||||
color: #111111;
|
|
||||||
background: #e9e9e5;
|
|
||||||
box-shadow: inset 0 -3px #111111;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-header nav a[aria-current="page"]:hover {
|
|
||||||
background: #f2f500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ratio-control label:hover span,
|
|
||||||
.reference-input:hover {
|
|
||||||
border-color: #111111;
|
|
||||||
background: #ffffd6;
|
|
||||||
box-shadow: inset 0 -3px #111111;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-card:hover {
|
|
||||||
border-color: #111111;
|
|
||||||
box-shadow: 0 3px 0 rgb(17 17 17 / 22%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-asset-tabs button:not(:disabled):hover,
|
|
||||||
.editor-source:hover,
|
|
||||||
.editor-sticker-grid button:not(:disabled):hover,
|
|
||||||
.editor-template-categories button:not(:disabled):hover,
|
|
||||||
.editor-template-grid button:not(:disabled):hover,
|
|
||||||
.editor-provider-grid button:not(:disabled):hover,
|
|
||||||
.editor-candidates button:not(:disabled):hover {
|
|
||||||
border-color: #111111;
|
|
||||||
background: #ffffd6;
|
|
||||||
}
|
|
||||||
|
|
||||||
:where(input:not(:disabled), select:not(:disabled), textarea:not(:disabled)):hover {
|
|
||||||
border-color: #111111;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference) {
|
|
||||||
:where(button:not(:disabled), a[href]):hover {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-card:hover .project-preview img,
|
|
||||||
.editor-sticker-grid button:not(:disabled):hover .editor-sticker-preview {
|
|
||||||
transform: scale(1.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-template-grid button:not(:disabled):hover .editor-template-mark,
|
|
||||||
.editor-template-grid button:not(:disabled):hover .editor-template-preview,
|
|
||||||
.editor-provider-grid button:not(:disabled):hover > :first-child {
|
|
||||||
transform: scale(1.03);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
|
||||||
:where(button:not(:disabled), a[href]):active {
|
|
||||||
transform: translateY(1px);
|
|
||||||
transition-duration: 45ms;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:where(button:not(:disabled)):active {
|
|
||||||
box-shadow: inset 0 2px 0 rgb(17 17 17 / 24%);
|
|
||||||
}
|
|
||||||
|
|
||||||
:where(a[href]):active {
|
|
||||||
opacity: 0.58;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
:where(button, a[href], input, select, textarea),
|
|
||||||
.project-card,
|
|
||||||
.project-preview img,
|
|
||||||
.ratio-control span,
|
|
||||||
.reference-input,
|
|
||||||
.editor-sticker-preview,
|
|
||||||
.editor-template-mark,
|
|
||||||
.editor-template-preview,
|
|
||||||
.editor-color-card-preview,
|
|
||||||
.editor-dynamic-preview,
|
|
||||||
.editor-source-preview-canvas,
|
|
||||||
.editor-thumb {
|
|
||||||
animation-duration: 0s !important;
|
|
||||||
transition-duration: 0s !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,8 +16,6 @@ import { ProjectDetailPage, ProjectsPage, WorkspacePage } from "./project-pages.
|
|||||||
import { EditorPage } from "./editor-page.js";
|
import { EditorPage } from "./editor-page.js";
|
||||||
import { AdminOverviewPage, AdminPlaceholderPage, AdminProtectedRoute } from "./admin-shell.js";
|
import { AdminOverviewPage, AdminPlaceholderPage, AdminProtectedRoute } from "./admin-shell.js";
|
||||||
|
|
||||||
import "./interaction-feedback.css";
|
|
||||||
|
|
||||||
const root = document.getElementById("root");
|
const root = document.getElementById("root");
|
||||||
|
|
||||||
if (!root) {
|
if (!root) {
|
||||||
|
|||||||
@@ -27,17 +27,6 @@ export const COLOR_CARD_SOURCE_GEOMETRY = {
|
|||||||
style_16: { bounds: { bottom: 9, left: -78, right: 78, top: -9 } },
|
style_16: { bounds: { bottom: 9, left: -78, right: 78, top: -9 } },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const COLOR_CARD_HALF_SIZES: Readonly<Record<string, { height: number; width: number }>> = {
|
|
||||||
style_01: { height: 76, width: 26 }, style_02: { height: 77, width: 18 },
|
|
||||||
style_03: { height: 75, width: 22 }, style_04: { height: 75, width: 18 },
|
|
||||||
style_05: { height: 18, width: 78 }, style_06: { height: 16, width: 78 },
|
|
||||||
style_07: { height: 20, width: 78 }, style_08: { height: 10, width: 73 },
|
|
||||||
style_09: { height: 75, width: 58 }, style_10: { height: 75, width: 60 },
|
|
||||||
style_11: { height: 18, width: 78 }, style_12: { height: 34, width: 70 },
|
|
||||||
style_13: { height: 75, width: 18 }, style_14: { height: 28, width: 60 },
|
|
||||||
style_15: { height: 16, width: 78 }, style_16: { height: 9, width: 78 },
|
|
||||||
};
|
|
||||||
|
|
||||||
function normalizedHex(value: string) {
|
function normalizedHex(value: string) {
|
||||||
return value.toUpperCase();
|
return value.toUpperCase();
|
||||||
}
|
}
|
||||||
@@ -150,65 +139,6 @@ export function drawColorCard(context: CanvasRenderingContext2D, element: Canvas
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (element.style_id === "style_03") {
|
|
||||||
colors.forEach((color, index) => {
|
|
||||||
context.fillStyle = color;
|
|
||||||
context.fillRect(-22, -75 + index * 30, 44, 29);
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (element.style_id === "style_04") {
|
|
||||||
context.strokeStyle = "#ffffff";
|
|
||||||
context.lineWidth = 2;
|
|
||||||
context.beginPath();
|
|
||||||
context.moveTo(0, -75);
|
|
||||||
context.lineTo(0, 75);
|
|
||||||
context.stroke();
|
|
||||||
colors.forEach((color, index) => {
|
|
||||||
context.fillStyle = color;
|
|
||||||
context.beginPath();
|
|
||||||
context.arc(0, -60 + index * 30, 9, 0, Math.PI * 2);
|
|
||||||
context.fill();
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (["style_05", "style_06", "style_07", "style_11"].includes(element.style_id ?? "")) {
|
|
||||||
if (element.style_id === "style_11") {
|
|
||||||
context.strokeStyle = "#ffffff";
|
|
||||||
context.lineWidth = 3;
|
|
||||||
context.strokeRect(-78, -18, 156, 36);
|
|
||||||
}
|
|
||||||
colors.forEach((color, index) => {
|
|
||||||
const left = -72 + index * 29;
|
|
||||||
context.fillStyle = color;
|
|
||||||
context.fillRect(left, -10, 28, 20);
|
|
||||||
if (element.style_id === "style_07") {
|
|
||||||
context.fillStyle = "#ffffff";
|
|
||||||
context.beginPath();
|
|
||||||
context.arc(left + 14, -16, 4, 0, Math.PI * 2);
|
|
||||||
context.fill();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (element.style_id === "style_05") {
|
|
||||||
context.fillStyle = "#111111";
|
|
||||||
context.fillRect(-78, -18, 20, 36);
|
|
||||||
context.fillStyle = "#ffffff";
|
|
||||||
context.font = "700 7px Arial, sans-serif";
|
|
||||||
context.textAlign = "center";
|
|
||||||
context.textBaseline = "middle";
|
|
||||||
context.fillText("C", -68, 0);
|
|
||||||
}
|
|
||||||
if (element.style_id === "style_06") {
|
|
||||||
context.fillStyle = "#ffffff";
|
|
||||||
context.beginPath();
|
|
||||||
context.moveTo(-4, -16);
|
|
||||||
context.lineTo(4, -16);
|
|
||||||
context.lineTo(0, -10);
|
|
||||||
context.closePath();
|
|
||||||
context.fill();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (element.style_id === "style_08") {
|
if (element.style_id === "style_08") {
|
||||||
colors.forEach((color, index) => {
|
colors.forEach((color, index) => {
|
||||||
const left = -73 + index * 29.2;
|
const left = -73 + index * 29.2;
|
||||||
@@ -229,61 +159,6 @@ export function drawColorCard(context: CanvasRenderingContext2D, element: Canvas
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (element.style_id === "style_09" || element.style_id === "style_10") {
|
|
||||||
context.fillStyle = "#ffffff";
|
|
||||||
context.fillRect(-58, -75, 116, 150);
|
|
||||||
colors.forEach((color, index) => {
|
|
||||||
context.fillStyle = color;
|
|
||||||
context.fillRect(-50, -64 + index * 23, 100, 22);
|
|
||||||
});
|
|
||||||
context.fillStyle = "#111111";
|
|
||||||
context.font = "700 8px Arial, sans-serif";
|
|
||||||
context.textAlign = "center";
|
|
||||||
context.textBaseline = "middle";
|
|
||||||
context.fillText(element.style_id === "style_09" ? "COLOR PALETTE" : "FIVE COLORS", 0, 63);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (element.style_id === "style_12") {
|
|
||||||
const positions = [[-52, -18], [0, -18], [52, -18], [-26, 18], [26, 18]] as const;
|
|
||||||
colors.forEach((color, index) => {
|
|
||||||
const position = positions[index]!;
|
|
||||||
context.fillStyle = color;
|
|
||||||
context.fillRect(position[0] - 24, position[1] - 14, 48, 28);
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (element.style_id === "style_13") {
|
|
||||||
colors.forEach((color, index) => {
|
|
||||||
context.fillStyle = color;
|
|
||||||
context.beginPath();
|
|
||||||
context.arc(0, -60 + index * 30, 11, 0, Math.PI * 2);
|
|
||||||
context.fill();
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (element.style_id === "style_14") {
|
|
||||||
colors.forEach((color, index) => {
|
|
||||||
context.globalAlpha = 0.9;
|
|
||||||
context.fillStyle = color;
|
|
||||||
context.beginPath();
|
|
||||||
context.arc(-40 + index * 20, 0, 24, 0, Math.PI * 2);
|
|
||||||
context.fill();
|
|
||||||
});
|
|
||||||
context.globalAlpha = 1;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (element.style_id === "style_15") {
|
|
||||||
colors.forEach((color, index) => {
|
|
||||||
context.fillStyle = color;
|
|
||||||
context.strokeStyle = "#ffffff";
|
|
||||||
context.lineWidth = 3;
|
|
||||||
context.beginPath();
|
|
||||||
context.arc(-58 + index * 29, 0, 12, 0, Math.PI * 2);
|
|
||||||
context.fill();
|
|
||||||
context.stroke();
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
context.fillStyle = "#ffffff";
|
context.fillStyle = "#ffffff";
|
||||||
context.beginPath();
|
context.beginPath();
|
||||||
context.moveTo(-78, -9);
|
context.moveTo(-78, -9);
|
||||||
|
|||||||
@@ -592,26 +592,15 @@
|
|||||||
height: 18px;
|
height: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-placeholder,
|
.project-placeholder {
|
||||||
.project-preview {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
height: 154px;
|
height: 154px;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-bottom: 1px solid #a5a59f;
|
border-bottom: 1px solid #a5a59f;
|
||||||
background: #d8d8d3;
|
background: #d8d8d3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-placeholder {
|
|
||||||
grid-template-columns: repeat(4, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-preview img {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-placeholder span {
|
.project-placeholder span {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: end center;
|
place-items: end center;
|
||||||
@@ -946,9 +935,9 @@
|
|||||||
font-size: 19px;
|
font-size: 19px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-current > .project-placeholder,
|
.project-current > .project-placeholder {
|
||||||
.project-current > .project-preview {
|
height: auto;
|
||||||
height: 480px;
|
min-height: 480px;
|
||||||
border: 1px solid #73736d;
|
border: 1px solid #73736d;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -956,10 +945,6 @@
|
|||||||
font-size: 80px;
|
font-size: 80px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-current > .project-preview img {
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-actions {
|
.project-actions {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
@@ -1028,8 +1013,7 @@
|
|||||||
padding: 6px;
|
padding: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-history li .project-placeholder,
|
.project-history li .project-placeholder {
|
||||||
.project-history li .project-preview {
|
|
||||||
height: 88px;
|
height: 88px;
|
||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
@@ -1455,9 +1439,8 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-current > .project-placeholder,
|
.project-current > .project-placeholder {
|
||||||
.project-current > .project-preview {
|
min-height: 360px;
|
||||||
height: 360px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.local-only-footer {
|
.local-only-footer {
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ interface LocalDataPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface AccountSettingsPayload {
|
interface AccountSettingsPayload {
|
||||||
csrf_token: string;
|
|
||||||
local_data: LocalDataPayload;
|
local_data: LocalDataPayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,33 +231,6 @@ function ProjectPlaceholder({ ratio, status }: { ratio: Ratio; status: ProjectSt
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProjectPreview({ alt, imageId, loading = "lazy", projectId, ratio, status }: {
|
|
||||||
alt: string;
|
|
||||||
imageId: string | null;
|
|
||||||
loading?: "eager" | "lazy";
|
|
||||||
projectId: string;
|
|
||||||
ratio: Ratio;
|
|
||||||
status: ProjectStatus;
|
|
||||||
}) {
|
|
||||||
const [loadFailed, setLoadFailed] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => setLoadFailed(false), [imageId, projectId]);
|
|
||||||
|
|
||||||
if (!imageId || loadFailed) return <ProjectPlaceholder ratio={ratio} status={status} />;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="project-preview" data-ratio={ratio} data-status={status}>
|
|
||||||
<img
|
|
||||||
alt={alt}
|
|
||||||
decoding="async"
|
|
||||||
loading={loading}
|
|
||||||
onError={() => setLoadFailed(true)}
|
|
||||||
src={`/api/v1/private-assets/projects/${encodeURIComponent(projectId)}/images/${encodeURIComponent(imageId)}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WorkspacePage() {
|
export function WorkspacePage() {
|
||||||
const promptId = useId();
|
const promptId = useId();
|
||||||
const [session, setSession] = useState<SessionPayload>();
|
const [session, setSession] = useState<SessionPayload>();
|
||||||
@@ -297,12 +269,7 @@ export function WorkspacePage() {
|
|||||||
if (!active) return;
|
if (!active) return;
|
||||||
if (modelResult.status === "fulfilled") setModels(modelResult.value);
|
if (modelResult.status === "fulfilled") setModels(modelResult.value);
|
||||||
if (taskResult.status === "fulfilled") setCurrentTask(taskResult.value);
|
if (taskResult.status === "fulfilled") setCurrentTask(taskResult.value);
|
||||||
const settings = settingsResult.status === "fulfilled" ? settingsResult.value : undefined;
|
if (settingsResult.status === "fulfilled" && settingsResult.value) setLocalData(settingsResult.value.local_data);
|
||||||
if (settings) {
|
|
||||||
setLocalData(settings.local_data);
|
|
||||||
// Account settings rotates the mutation token; keep the workspace token current.
|
|
||||||
setSession((current) => current ? { ...current, csrf_token: settings.csrf_token } : current);
|
|
||||||
}
|
|
||||||
setGenerationStateLoaded(true);
|
setGenerationStateLoaded(true);
|
||||||
});
|
});
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
@@ -601,13 +568,7 @@ function ProjectCard({ activeLimitReached, busy, onPurge, onRestore, onSelect, o
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
) : null}
|
) : null}
|
||||||
<ProjectPreview
|
<ProjectPlaceholder ratio={project.ratio} status={project.status} />
|
||||||
alt={`${project.name}预览图`}
|
|
||||||
imageId={project.current_image_id}
|
|
||||||
projectId={project.project_id}
|
|
||||||
ratio={project.ratio}
|
|
||||||
status={project.status}
|
|
||||||
/>
|
|
||||||
<div className="project-card-body">
|
<div className="project-card-body">
|
||||||
<div><h3 title={project.name}>{project.name}</h3><span>{project.status === "failed_empty" ? "生成失败" : project.status === "trashed" ? "回收站" : "项目"}</span></div>
|
<div><h3 title={project.name}>{project.name}</h3><span>{project.status === "failed_empty" ? "生成失败" : project.status === "trashed" ? "回收站" : "项目"}</span></div>
|
||||||
<p>{project.successful_image_count} 张成功图 · {project.ratio}</p>
|
<p>{project.successful_image_count} 张成功图 · {project.ratio}</p>
|
||||||
@@ -992,14 +953,7 @@ export function ProjectDetailPage({ projectId }: { projectId: string }) {
|
|||||||
<div className="project-detail-grid">
|
<div className="project-detail-grid">
|
||||||
<section className="project-current" aria-labelledby="current-image-title">
|
<section className="project-current" aria-labelledby="current-image-title">
|
||||||
<header><h2 id="current-image-title">当前底图</h2><span>{project.pixel_width ?? 1080} × {project.pixel_height ?? 1440}</span></header>
|
<header><h2 id="current-image-title">当前底图</h2><span>{project.pixel_width ?? 1080} × {project.pixel_height ?? 1440}</span></header>
|
||||||
<ProjectPreview
|
<ProjectPlaceholder ratio={project.ratio} status={project.status} />
|
||||||
alt={`${project.name}当前底图`}
|
|
||||||
imageId={project.current_image_id}
|
|
||||||
loading="eager"
|
|
||||||
projectId={project.project_id}
|
|
||||||
ratio={project.ratio}
|
|
||||||
status={project.status}
|
|
||||||
/>
|
|
||||||
<div className="project-actions">
|
<div className="project-actions">
|
||||||
<button disabled={conflicted || atHistoryLimit} onClick={() => window.location.assign(`/app?continue=${project.project_id}`)} type="button">继续生成</button>
|
<button disabled={conflicted || atHistoryLimit} onClick={() => window.location.assign(`/app?continue=${project.project_id}`)} type="button">继续生成</button>
|
||||||
{conflicted || !project.current_image_id ? <button disabled type="button">进入编辑器</button> : <a href={`/app/projects/${project.project_id}/editor`}>进入编辑器</a>}
|
{conflicted || !project.current_image_id ? <button disabled type="button">进入编辑器</button> : <a href={`/app/projects/${project.project_id}/editor`}>进入编辑器</a>}
|
||||||
@@ -1016,13 +970,7 @@ export function ProjectDetailPage({ projectId }: { projectId: string }) {
|
|||||||
<ol>
|
<ol>
|
||||||
{project.images.toReversed().map((image, index) => (
|
{project.images.toReversed().map((image, index) => (
|
||||||
<li key={image.image_id} data-current={image.image_id === project.current_image_id}>
|
<li key={image.image_id} data-current={image.image_id === project.current_image_id}>
|
||||||
<ProjectPreview
|
<ProjectPlaceholder ratio={project.ratio} status="active" />
|
||||||
alt={`生成结果 ${project.images.length - index}`}
|
|
||||||
imageId={image.image_id}
|
|
||||||
projectId={project.project_id}
|
|
||||||
ratio={project.ratio}
|
|
||||||
status="active"
|
|
||||||
/>
|
|
||||||
<div><strong>生成结果 {project.images.length - index}</strong><time dateTime={image.created_at}>{formatUpdatedAt(image.created_at)}</time><a href={`/api/v1/private-assets/projects/${project.project_id}/images/${image.image_id}`}>下载原始图</a></div>
|
<div><strong>生成结果 {project.images.length - index}</strong><time dateTime={image.created_at}>{formatUpdatedAt(image.created_at)}</time><a href={`/api/v1/private-assets/projects/${project.project_id}/images/${image.image_id}`}>下载原始图</a></div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
+67
-246
@@ -2,113 +2,11 @@ import type { CanvasState } from "@dada/shared-contracts";
|
|||||||
import { P0A_COMPLEX_RELEASE_VERSION, P0A_REQUIRED_FONT_PANEL_IDS, P0A_TEXT_TEMPLATE_IDS } from "@dada/template-registry";
|
import { P0A_COMPLEX_RELEASE_VERSION, P0A_REQUIRED_FONT_PANEL_IDS, P0A_TEXT_TEMPLATE_IDS } from "@dada/template-registry";
|
||||||
|
|
||||||
import type { CanvasElementIdentity } from "./editor-elements.js";
|
import type { CanvasElementIdentity } from "./editor-elements.js";
|
||||||
import complexAssetCatalog from "./generated/complex-assets.json";
|
|
||||||
|
|
||||||
type CanvasElement = CanvasState["elements"][number];
|
type CanvasElement = CanvasState["elements"][number];
|
||||||
|
|
||||||
export type TextTemplateCategory = "flower" | "simple" | "tag" | "title";
|
export type TextTemplateCategory = "flower" | "simple" | "tag" | "title";
|
||||||
export type TextAlign = "center" | "left" | "right";
|
export type TextAlign = "center" | "left" | "right";
|
||||||
export type TextVerticalAlign = "bottom" | "middle" | "top";
|
|
||||||
|
|
||||||
export interface TextTemplateImageLayer {
|
|
||||||
alpha: number;
|
|
||||||
anchorX: number;
|
|
||||||
anchorY: number;
|
|
||||||
assetId: string;
|
|
||||||
height: number;
|
|
||||||
order: number;
|
|
||||||
rotation: number;
|
|
||||||
scaleX: number;
|
|
||||||
scaleY: number;
|
|
||||||
width: number;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
ninePatch?: TextTemplateNinePatch;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TextTemplateNinePatch {
|
|
||||||
bottom: number;
|
|
||||||
left: number;
|
|
||||||
right: number;
|
|
||||||
sourceHeight: number;
|
|
||||||
sourceWidth: number;
|
|
||||||
top: 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 {
|
|
||||||
alpha: number;
|
|
||||||
align: TextAlign;
|
|
||||||
anchorX: number;
|
|
||||||
anchorY: number;
|
|
||||||
contentLinked: boolean;
|
|
||||||
editable: boolean;
|
|
||||||
fillColor: string;
|
|
||||||
fillGradient?: { bottom: string; top: string };
|
|
||||||
fillPatternAssetId?: string;
|
|
||||||
fillTextureLayout?: TextTemplateFillTextureLayout;
|
|
||||||
fontId: string;
|
|
||||||
fontSize: number;
|
|
||||||
height: number;
|
|
||||||
glyphColors?: readonly string[];
|
|
||||||
letterSpacing: number;
|
|
||||||
lineHeight: number;
|
|
||||||
order: number;
|
|
||||||
rotation: number;
|
|
||||||
scaleX: number;
|
|
||||||
scaleY: number;
|
|
||||||
shadowBlur: number;
|
|
||||||
shadowColor: string;
|
|
||||||
shadowOffsetX: number;
|
|
||||||
shadowOffsetY: number;
|
|
||||||
skewX: number;
|
|
||||||
skewY: number;
|
|
||||||
strokeColor: string;
|
|
||||||
strokeWidth: number;
|
|
||||||
text: string;
|
|
||||||
textPath?: TextTemplateTextPath;
|
|
||||||
verticalAlign: TextVerticalAlign;
|
|
||||||
width: number;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TextTemplateFillTextureLayout {
|
|
||||||
columns: number;
|
|
||||||
idList: readonly number[];
|
|
||||||
rows: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TextTemplateTextPath {
|
|
||||||
circle: boolean;
|
|
||||||
conicWeights: readonly number[];
|
|
||||||
firstMargin: number;
|
|
||||||
forceAlignment: boolean;
|
|
||||||
isAbsoluteMode: boolean;
|
|
||||||
lastMargin: number;
|
|
||||||
perpendicular: boolean;
|
|
||||||
points: readonly { x: number; y: number }[];
|
|
||||||
reversed: boolean;
|
|
||||||
verbs: readonly number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TextTemplateRenderModel {
|
|
||||||
halfSize: { height: number; width: number };
|
|
||||||
imageLayers: readonly TextTemplateImageLayer[];
|
|
||||||
particleLayers: readonly TextTemplateParticleLayer[];
|
|
||||||
textLayers: readonly TextTemplateTextLayer[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TextTemplateDefinition {
|
export interface TextTemplateDefinition {
|
||||||
available: boolean;
|
available: boolean;
|
||||||
@@ -119,8 +17,6 @@ export interface TextTemplateDefinition {
|
|||||||
defaultText: string;
|
defaultText: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
fontUrl?: string;
|
fontUrl?: string;
|
||||||
previewUrl?: string;
|
|
||||||
renderModel: TextTemplateRenderModel;
|
|
||||||
resourceClass: "parameter_only" | "zip_template";
|
resourceClass: "parameter_only" | "zip_template";
|
||||||
resourceVersion: string;
|
resourceVersion: string;
|
||||||
templateId: string;
|
templateId: string;
|
||||||
@@ -147,9 +43,6 @@ export interface TextStylePatch {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const resourceVersion = P0A_COMPLEX_RELEASE_VERSION;
|
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 = {
|
const defaults = {
|
||||||
background_color: "#FFE62C",
|
background_color: "#FFE62C",
|
||||||
background_enabled: false,
|
background_enabled: false,
|
||||||
@@ -163,142 +56,85 @@ const defaults = {
|
|||||||
text_align: "center",
|
text_align: "center",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const textCatalogById = new Map(complexAssetCatalog.text_templates.map((item) => [item.template_id, item]));
|
type CatalogSeed = [id: string, category: TextTemplateCategory, displayName: string, defaultText: string, defaultFontId: string, available?: boolean, resourceClass?: "parameter_only"];
|
||||||
|
|
||||||
function imageLayer(layer: {
|
const seeds: readonly CatalogSeed[] = [
|
||||||
alpha?: number; anchor_x?: number; anchor_y?: number; asset_id: string; height: number; nine_patch?: {
|
["FLOWER001", "flower", "春日计划", "春日计划", "FONT011", true],
|
||||||
bottom: number; left: number; right: number; source_height: number; source_width: number; top: number;
|
["FLOWER002", "flower", "笑不活了", "笑不活了", "FLOWER002_FONT"],
|
||||||
}; order: number; rotation: number; scale_x: number; scale_y: number; width: number; x: number; y: number;
|
["FLOWER003", "flower", "人生照片", "人生照片", "FONT008"],
|
||||||
}): TextTemplateImageLayer {
|
["FLOWER004", "flower", "我的日常生活", "我的日常生活", "FLOWER004_FONT"],
|
||||||
return {
|
["FLOWER005", "flower", "碎片生活", "碎片生活", "FONT008"],
|
||||||
alpha: layer.alpha ?? 1, anchorX: layer.anchor_x ?? 0.5, anchorY: layer.anchor_y ?? 0.5,
|
["FLOWER006", "flower", "闪光瞬间", "闪光瞬间", "FLOWER006_FONT"],
|
||||||
assetId: layer.asset_id, height: layer.height, order: layer.order, rotation: layer.rotation,
|
["FLOWER007", "flower", "好柿花生", "好柿花生", "FONT046", false, "parameter_only"],
|
||||||
scaleX: layer.scale_x, scaleY: layer.scale_y, width: layer.width, x: layer.x, y: layer.y,
|
["FLOWER008", "flower", "Vlog.", "Vlog.", "FONT005"],
|
||||||
...(layer.nine_patch ? { ninePatch: {
|
["H001", "title", "电影生活记录", "电影生活记录", "H001_FONT"],
|
||||||
bottom: layer.nine_patch.bottom, left: layer.nine_patch.left, right: layer.nine_patch.right,
|
["H002", "title", "30°C", "30°C", "H002_FONT"],
|
||||||
sourceHeight: layer.nine_patch.source_height, sourceWidth: layer.nine_patch.source_width, top: layer.nine_patch.top,
|
["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"],
|
||||||
|
];
|
||||||
|
|
||||||
interface RawTextLayer {
|
const seedById = new Map(seeds.map((seed) => [seed[0], seed]));
|
||||||
alpha?: number; align: string; anchor_x?: number; anchor_y?: number; content_linked?: boolean; editable: boolean; fill_color: string;
|
|
||||||
fill_gradient?: { bottom: string; top: string }; fill_pattern_asset_id?: string;
|
|
||||||
fill_texture_layout?: { columns: number; id_list: number[]; rows: number }; font_id: string; font_size: number;
|
|
||||||
glyph_colors?: string[]; 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;
|
|
||||||
skew_x?: number; skew_y?: number; stroke_width: number; text: string; text_path?: {
|
|
||||||
circle: boolean; conic_weights: number[]; first_margin: number; force_alignment: boolean; is_absolute_mode: boolean;
|
|
||||||
last_margin: number; perpendicular: boolean; points: Array<{ x: number; y: number }>; reversed: boolean; verbs: number[];
|
|
||||||
}; vertical_align: TextVerticalAlign; 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 {
|
|
||||||
alpha: layer.alpha ?? 1, align: layer.align as TextAlign, anchorX: layer.anchor_x ?? 0.5, anchorY: layer.anchor_y ?? 0.5,
|
|
||||||
contentLinked: layer.content_linked ?? layer.editable, editable: layer.editable, fillColor: layer.fill_color,
|
|
||||||
...(layer.fill_gradient ? { fillGradient: layer.fill_gradient } : {}),
|
|
||||||
...(layer.fill_pattern_asset_id ? { fillPatternAssetId: layer.fill_pattern_asset_id } : {}),
|
|
||||||
...(layer.fill_texture_layout ? { fillTextureLayout: {
|
|
||||||
columns: layer.fill_texture_layout.columns, idList: layer.fill_texture_layout.id_list, rows: layer.fill_texture_layout.rows,
|
|
||||||
} } : {}),
|
|
||||||
fontId: layer.font_id, fontSize: layer.font_size, ...(layer.glyph_colors ? { glyphColors: layer.glyph_colors } : {}),
|
|
||||||
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,
|
|
||||||
skewX: layer.skew_x ?? 0, skewY: layer.skew_y ?? 0,
|
|
||||||
strokeColor: layer.stroke_color, strokeWidth: layer.stroke_width, text: layer.text,
|
|
||||||
...(layer.text_path ? { textPath: {
|
|
||||||
circle: layer.text_path.circle, conicWeights: layer.text_path.conic_weights,
|
|
||||||
firstMargin: layer.text_path.first_margin, forceAlignment: layer.text_path.force_alignment,
|
|
||||||
isAbsoluteMode: layer.text_path.is_absolute_mode, lastMargin: layer.text_path.last_margin,
|
|
||||||
perpendicular: layer.text_path.perpendicular, points: layer.text_path.points,
|
|
||||||
reversed: layer.text_path.reversed, verbs: layer.text_path.verbs,
|
|
||||||
} } : {}),
|
|
||||||
verticalAlign: layer.vertical_align, width: layer.width, x: layer.x, y: layer.y,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const P0A_TEXT_TEMPLATES: readonly TextTemplateDefinition[] = P0A_TEXT_TEMPLATE_IDS.map((templateId, catalogOrder) => {
|
export const P0A_TEXT_TEMPLATES: readonly TextTemplateDefinition[] = P0A_TEXT_TEMPLATE_IDS.map((templateId, catalogOrder) => {
|
||||||
const item = textCatalogById.get(templateId);
|
const seed = seedById.get(templateId);
|
||||||
if (!item) throw new Error(`missing text template definition ${templateId}`);
|
if (!seed) throw new Error(`missing text template definition ${templateId}`);
|
||||||
return {
|
return {
|
||||||
available: item.available,
|
available: seed[5] === true,
|
||||||
catalogOrder,
|
catalogOrder,
|
||||||
category: item.category as TextTemplateCategory,
|
category: seed[1],
|
||||||
defaultFontId: item.default_font_id,
|
defaultFontId: seed[4],
|
||||||
defaultFontSize: item.default_font_size,
|
defaultFontSize: 48,
|
||||||
defaultText: item.default_text,
|
defaultText: seed[3],
|
||||||
displayName: item.display_name,
|
displayName: seed[2],
|
||||||
fontUrl: publicTextAssetUrl(item.default_font_id),
|
...(seed[5] === true ? { fontUrl: `/api/v1/assets/public/${resourceVersion}/${seed[4]}` } : {}),
|
||||||
...(item.preview_asset_id ? { previewUrl: publicTextAssetUrl(item.preview_asset_id) } : {}),
|
resourceClass: seed[6] ?? "zip_template",
|
||||||
renderModel: renderModel(item),
|
|
||||||
resourceClass: item.resource_class as "parameter_only" | "zip_template",
|
|
||||||
resourceVersion,
|
resourceVersion,
|
||||||
templateId,
|
templateId,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const fontCatalogById = new Map(complexAssetCatalog.font_panel_items.map((item) => [item.font_id, item]));
|
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) => {
|
export const P0A_FONT_OPTIONS: readonly FontOption[] = P0A_REQUIRED_FONT_PANEL_IDS.map((fontId) => ({
|
||||||
const item = fontCatalogById.get(fontId);
|
displayName: fontOptionDefinitions[fontId],
|
||||||
if (!item) throw new Error(`missing font panel definition ${fontId}`);
|
fontId,
|
||||||
return {
|
url: `/api/v1/assets/public/${resourceVersion}/${fontId}`,
|
||||||
displayName: item.display_name,
|
}));
|
||||||
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) {
|
export function fontOption(fontId: string) {
|
||||||
return allFontOptions.get(fontId);
|
return P0A_FONT_OPTIONS.find((option) => option.fontId === 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) {
|
export function fontIdForTextElement(element: CanvasElement) {
|
||||||
@@ -326,19 +162,7 @@ function isStep(value: number, minimum: number, step: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function templateStyle(template: TextTemplateDefinition): Record<string, string | number | boolean | null> {
|
function templateStyle(template: TextTemplateDefinition): Record<string, string | number | boolean | null> {
|
||||||
const primary = template.renderModel.textLayers.find((layer) => layer.editable) ?? template.renderModel.textLayers[0];
|
return { ...defaults, default_font_id: template.defaultFontId };
|
||||||
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(
|
export function searchTextTemplates(
|
||||||
@@ -414,10 +238,7 @@ export class TextEditSession {
|
|||||||
|
|
||||||
setStyle(patch: TextStylePatch) {
|
setStyle(patch: TextStylePatch) {
|
||||||
const style = { ...(this.draft.style_parameters ?? {}) };
|
const style = { ...(this.draft.style_parameters ?? {}) };
|
||||||
if (patch.fillColor !== undefined) {
|
if (patch.fillColor !== undefined) style.fill_color = checkedColor(patch.fillColor);
|
||||||
style.fill_color = checkedColor(patch.fillColor);
|
|
||||||
style.template_fill_overridden = true;
|
|
||||||
}
|
|
||||||
if (patch.strokeColor !== undefined) style.stroke_color = checkedColor(patch.strokeColor);
|
if (patch.strokeColor !== undefined) style.stroke_color = checkedColor(patch.strokeColor);
|
||||||
if (patch.backgroundColor !== undefined) style.background_color = checkedColor(patch.backgroundColor);
|
if (patch.backgroundColor !== undefined) style.background_color = checkedColor(patch.backgroundColor);
|
||||||
if (patch.strokeEnabled !== undefined) style.stroke_enabled = patch.strokeEnabled;
|
if (patch.strokeEnabled !== undefined) style.stroke_enabled = patch.strokeEnabled;
|
||||||
|
|||||||
@@ -1,44 +1,10 @@
|
|||||||
import { useEffect, useRef, type CSSProperties } from "react";
|
import type { ArchivedFontStatus } from "./text-font-loader.js";
|
||||||
import { fontFamilyName, type ArchivedFontStatus } from "./text-font-loader.js";
|
|
||||||
import { searchTextTemplates, type TextTemplateCategory, type TextTemplateDefinition } from "./text-assets.js";
|
import { searchTextTemplates, type TextTemplateCategory, type TextTemplateDefinition } from "./text-assets.js";
|
||||||
|
|
||||||
const categories: Array<{ id?: TextTemplateCategory; label: string }> = [
|
const categories: Array<{ id?: TextTemplateCategory; label: string }> = [
|
||||||
{ label: "全部" }, { id: "flower", label: "花字" }, { id: "title", label: "标题" }, { id: "tag", label: "标签" }, { id: "simple", label: "简约" },
|
{ label: "全部" }, { id: "flower", label: "花字" }, { id: "title", label: "标题" }, { id: "tag", label: "标签" }, { id: "simple", label: "简约" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function LiveTemplatePreview(props: { onEnsure: () => void; template: TextTemplateDefinition }) {
|
|
||||||
const previewRef = useRef<HTMLSpanElement>(null);
|
|
||||||
const layer = props.template.renderModel.textLayers.find((item) => item.editable) ?? props.template.renderModel.textLayers[0]!;
|
|
||||||
useEffect(() => {
|
|
||||||
const node = previewRef.current;
|
|
||||||
if (!node || typeof IntersectionObserver === "undefined") {
|
|
||||||
props.onEnsure();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const observer = new IntersectionObserver((entries) => {
|
|
||||||
if (!entries.some((entry) => entry.isIntersecting)) return;
|
|
||||||
observer.disconnect();
|
|
||||||
props.onEnsure();
|
|
||||||
});
|
|
||||||
observer.observe(node);
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, [props.onEnsure, props.template.templateId]);
|
|
||||||
const scale = Math.min(1, 38 / Math.max(1, layer.height), 112 / Math.max(1, layer.width));
|
|
||||||
const style: CSSProperties = {
|
|
||||||
color: layer.fillColor,
|
|
||||||
fontFamily: `"${fontFamilyName(layer.fontId)}"`,
|
|
||||||
fontSize: `${layer.fontSize * scale}px`,
|
|
||||||
letterSpacing: `${layer.letterSpacing * scale}px`,
|
|
||||||
lineHeight: layer.lineHeight,
|
|
||||||
textShadow: layer.shadowBlur > 0 || layer.shadowOffsetX !== 0 || layer.shadowOffsetY !== 0
|
|
||||||
? `${layer.shadowOffsetX * scale}px ${layer.shadowOffsetY * scale}px ${layer.shadowBlur * scale}px ${layer.shadowColor}`
|
|
||||||
: undefined,
|
|
||||||
transform: `rotate(${layer.rotation}deg) scale(${layer.scaleX}, ${layer.scaleY})`,
|
|
||||||
WebkitTextStroke: layer.strokeWidth > 0 ? `${layer.strokeWidth * scale}px ${layer.strokeColor}` : undefined,
|
|
||||||
};
|
|
||||||
return <span className="editor-template-live-preview" ref={previewRef} style={style}>{layer.text || props.template.defaultText}</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TextTemplatePanel(props: {
|
export function TextTemplatePanel(props: {
|
||||||
canAdd: boolean;
|
canAdd: boolean;
|
||||||
category?: TextTemplateCategory;
|
category?: TextTemplateCategory;
|
||||||
@@ -46,7 +12,6 @@ export function TextTemplatePanel(props: {
|
|||||||
onAdd: (template: TextTemplateDefinition) => void;
|
onAdd: (template: TextTemplateDefinition) => void;
|
||||||
onCategory: (category?: TextTemplateCategory) => void;
|
onCategory: (category?: TextTemplateCategory) => void;
|
||||||
onQuery: (query: string) => void;
|
onQuery: (query: string) => void;
|
||||||
onEnsure: (template: TextTemplateDefinition) => void;
|
|
||||||
onRetry: () => void;
|
onRetry: () => void;
|
||||||
query: string;
|
query: string;
|
||||||
recentIds: readonly string[];
|
recentIds: readonly string[];
|
||||||
@@ -66,15 +31,12 @@ export function TextTemplatePanel(props: {
|
|||||||
<div className="editor-template-grid">
|
<div className="editor-template-grid">
|
||||||
{visible.map((template) => {
|
{visible.map((template) => {
|
||||||
const status = props.fontStatuses[template.defaultFontId] ?? "idle";
|
const status = props.fontStatuses[template.defaultFontId] ?? "idle";
|
||||||
const unavailable = !template.available;
|
const unavailable = !template.available || status === "unavailable";
|
||||||
const retryable = 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">
|
||||||
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">
|
<span className={`editor-template-mark ${template.category}`}>{template.displayName.slice(0, 2)}</span>
|
||||||
{template.previewUrl
|
|
||||||
? <img alt="" className="editor-template-preview" decoding="async" loading="lazy" src={template.previewUrl} />
|
|
||||||
: <LiveTemplatePreview onEnsure={() => props.onEnsure(template)} template={template} />}
|
|
||||||
<strong>{template.templateId}</strong>
|
<strong>{template.templateId}</strong>
|
||||||
<span>{template.displayName}</span>
|
<span>{template.displayName}</span>
|
||||||
{unavailable ? <small>素材暂不可用</small> : retryable ? <small>点击重试原版字体</small> : status === "loading" ? <small>正在加载字体</small> : null}
|
{unavailable ? <small>素材暂不可用</small> : status === "loading" ? <small>正在加载字体</small> : null}
|
||||||
</button>;
|
</button>;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -20,12 +20,9 @@
|
|||||||
"test:performance": "node scripts/run-wp4-07-layer.mjs performance",
|
"test:performance": "node scripts/run-wp4-07-layer.mjs performance",
|
||||||
"test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs",
|
"test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs",
|
||||||
"test:package": "pnpm build:workspace-packages && pnpm run typecheck && node --test tests/package/wp0-09-portable.test.mjs && node scripts/package-smoke.mjs && node scripts/loopback-boundary-smoke.mjs",
|
"test:package": "pnpm build:workspace-packages && pnpm run typecheck && node --test tests/package/wp0-09-portable.test.mjs && node scripts/package-smoke.mjs && node scripts/loopback-boundary-smoke.mjs",
|
||||||
"test:postv1-ui-integration": "node scripts/validate-postv1-ui-lineage.mjs && playwright test tests/e2e/projects-workspace.spec.ts tests/e2e/wp4-01-editor-background.spec.ts tests/e2e/wp4-02-editor-elements.spec.ts tests/e2e/wp4-04-color-dynamic.spec.ts --config playwright.config.ts",
|
|
||||||
"package:portable": "node scripts/build-portable.mjs",
|
"package:portable": "node scripts/build-portable.mjs",
|
||||||
"assets:manifest": "pnpm build:workspace-packages && node scripts/generate-runtime-asset-manifest.mjs",
|
"assets:manifest": "pnpm build:workspace-packages && node scripts/generate-runtime-asset-manifest.mjs",
|
||||||
"assets:deploy": "pnpm build:workspace-packages && node scripts/deploy-runtime-assets.mjs",
|
"assets:deploy": "pnpm build:workspace-packages && node scripts/deploy-runtime-assets.mjs",
|
||||||
"assets:validate-browser-fonts": "node scripts/validate-browser-font-assets.mjs",
|
|
||||||
"assets:browser-catalog": "pnpm build:workspace-packages && node scripts/generate-complex-browser-catalog.mjs",
|
|
||||||
"generate:openapi": "node scripts/generate-openapi.mjs",
|
"generate:openapi": "node scripts/generate-openapi.mjs",
|
||||||
"check:openapi": "node scripts/check-openapi.mjs",
|
"check:openapi": "node scripts/check-openapi.mjs",
|
||||||
"validate:tdd-trace": "node scripts/validate-tdd-trace.mjs",
|
"validate:tdd-trace": "node scripts/validate-tdd-trace.mjs",
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ export interface ColorCardDefinition {
|
|||||||
cardId: typeof P0A_COLOR_CARD_IDS[number];
|
cardId: typeof P0A_COLOR_CARD_IDS[number];
|
||||||
displayName: string;
|
displayName: string;
|
||||||
mappingStatus: "confirmed_native_mapping" | "stable_web_style_native_mapping_provisional";
|
mappingStatus: "confirmed_native_mapping" | "stable_web_style_native_mapping_provisional";
|
||||||
rendererName: string;
|
rendererName: "horizontal_line" | "ticket_strip" | "vertical_stack" | "vertical_ticket";
|
||||||
styleId: string;
|
styleId: "style_01" | "style_02" | "style_08" | "style_16";
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FiveColorPalette = readonly [string, string, string, string, string];
|
export type FiveColorPalette = readonly [string, string, string, string, string];
|
||||||
@@ -17,19 +17,7 @@ export interface ColorCardRenderPlan extends ColorCardDefinition {
|
|||||||
export const P0A_COLOR_CARD_DEFINITIONS: readonly ColorCardDefinition[] = [
|
export const P0A_COLOR_CARD_DEFINITIONS: readonly ColorCardDefinition[] = [
|
||||||
{ cardId: "COLOR001", displayName: "纵向票据", mappingStatus: "confirmed_native_mapping", rendererName: "vertical_ticket", styleId: "style_01" },
|
{ cardId: "COLOR001", displayName: "纵向票据", mappingStatus: "confirmed_native_mapping", rendererName: "vertical_ticket", styleId: "style_01" },
|
||||||
{ cardId: "COLOR002", displayName: "纵向色阶", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "vertical_stack", styleId: "style_02" },
|
{ cardId: "COLOR002", displayName: "纵向色阶", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "vertical_stack", styleId: "style_02" },
|
||||||
{ cardId: "COLOR003", displayName: "纵向色条", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "vertical_strip", styleId: "style_03" },
|
|
||||||
{ cardId: "COLOR004", displayName: "纵向标线", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "vertical_line", styleId: "style_04" },
|
|
||||||
{ cardId: "COLOR005", displayName: "横向标签", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "horizontal_label_strip", styleId: "style_05" },
|
|
||||||
{ cardId: "COLOR006", displayName: "指示色条", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "indicator_strip", styleId: "style_06" },
|
|
||||||
{ cardId: "COLOR007", displayName: "图钉色条", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "pinned_strip", styleId: "style_07" },
|
|
||||||
{ cardId: "COLOR008", displayName: "横向标尺", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "horizontal_line", styleId: "style_08" },
|
{ cardId: "COLOR008", displayName: "横向标尺", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "horizontal_line", styleId: "style_08" },
|
||||||
{ cardId: "COLOR009", displayName: "色彩海报", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "color_poster", styleId: "style_09" },
|
|
||||||
{ cardId: "COLOR010", displayName: "标题海报", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "caption_poster", styleId: "style_10" },
|
|
||||||
{ cardId: "COLOR011", displayName: "边框色条", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "framed_strip", styleId: "style_11" },
|
|
||||||
{ cardId: "COLOR012", displayName: "OTTO 色块", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "otto_blocks", styleId: "style_12" },
|
|
||||||
{ cardId: "COLOR013", displayName: "纵向圆点", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "vertical_dots", styleId: "style_13" },
|
|
||||||
{ cardId: "COLOR014", displayName: "三色圆环", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "three_circles", styleId: "style_14" },
|
|
||||||
{ cardId: "COLOR015", displayName: "描边圆点", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "outlined_dots", styleId: "style_15" },
|
|
||||||
{ cardId: "COLOR016", displayName: "横向票条", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "ticket_strip", styleId: "style_16" },
|
{ cardId: "COLOR016", displayName: "横向票条", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "ticket_strip", styleId: "style_16" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
@@ -3,20 +3,25 @@ import type { StaticStickerCatalog, StaticStickerCatalogItem } from "@dada/stati
|
|||||||
export const P0A_COMPLEX_RELEASE_VERSION = "p0a-complex-v1";
|
export const P0A_COMPLEX_RELEASE_VERSION = "p0a-complex-v1";
|
||||||
export const P0A_STATIC_STICKER_RELEASE_VERSION = "p0a-static-v1";
|
export const P0A_STATIC_STICKER_RELEASE_VERSION = "p0a-static-v1";
|
||||||
|
|
||||||
function numberedIds(prefix: string, count: number) {
|
export const P0A_TEXT_TEMPLATE_IDS = [
|
||||||
return Object.freeze(Array.from({ length: count }, (_, index) => `${prefix}${String(index + 1).padStart(3, "0")}`));
|
"FLOWER001", "FLOWER002", "FLOWER003", "FLOWER004", "FLOWER005", "FLOWER006", "FLOWER007", "FLOWER008",
|
||||||
}
|
"H001", "H002", "H003", "H004", "H005", "H006", "H007", "H008",
|
||||||
|
"TAG001", "TAG002", "TAG003", "TAG004", "TAG005", "TAG006", "TAG007", "TAG051",
|
||||||
|
"SIMPLE001", "SIMPLE002", "SIMPLE003", "SIMPLE004", "SIMPLE005", "SIMPLE006", "SIMPLE007", "SIMPLE008",
|
||||||
|
] as const;
|
||||||
|
|
||||||
export const P0A_TEXT_TEMPLATE_IDS = Object.freeze([
|
// Derived from exact package-hash matches between the 32 frozen templates and the 86-item font panel.
|
||||||
...numberedIds("FLOWER", 145),
|
export const P0A_REQUIRED_FONT_PANEL_IDS = [
|
||||||
...numberedIds("H", 119),
|
"FONT005", "FONT008", "FONT011", "FONT021", "FONT022", "FONT027",
|
||||||
...numberedIds("TAG", 51),
|
"FONT039", "FONT043", "FONT046", "FONT052", "FONT081",
|
||||||
...numberedIds("SIMPLE", 17),
|
] as const;
|
||||||
]);
|
|
||||||
|
|
||||||
export const P0A_REQUIRED_FONT_PANEL_IDS = numberedIds("FONT", 86);
|
export const P0A_COLOR_CARD_IDS = ["COLOR001", "COLOR002", "COLOR008", "COLOR016"] as const;
|
||||||
export const P0A_COLOR_CARD_IDS = numberedIds("COLOR", 16);
|
|
||||||
export const P0A_DYNAMIC_STICKER_IDS = numberedIds("DYN", 35);
|
export const P0A_DYNAMIC_STICKER_IDS = [
|
||||||
|
"DYN001", "DYN002", "DYN003", "DYN004", "DYN007",
|
||||||
|
"DYN008", "DYN011", "DYN012", "DYN015", "DYN016",
|
||||||
|
] as const;
|
||||||
|
|
||||||
export const P0A_DYNAMIC_RUNTIME_FONT_SOURCES = [
|
export const P0A_DYNAMIC_RUNTIME_FONT_SOURCES = [
|
||||||
{ assetId: "15974853bc3294ef68e7e6d58fe74fd7", sourceReference: "fonts/15974853bc3294ef68e7e6d58fe74fd7", templateId: "DYN002" },
|
{ assetId: "15974853bc3294ef68e7e6d58fe74fd7", sourceReference: "fonts/15974853bc3294ef68e7e6d58fe74fd7", templateId: "DYN002" },
|
||||||
@@ -71,12 +76,12 @@ export interface P0aPublicManifest {
|
|||||||
text_templates: PublicComplexAsset[];
|
text_templates: PublicComplexAsset[];
|
||||||
};
|
};
|
||||||
counts: {
|
counts: {
|
||||||
color_cards: 16;
|
color_cards: 4;
|
||||||
dynamic_stickers: 35;
|
dynamic_stickers: 10;
|
||||||
font_panel_items: 86;
|
font_panel_items: 11;
|
||||||
static_parts: 25;
|
static_parts: 25;
|
||||||
static_stickers: 1407;
|
static_stickers: 1407;
|
||||||
text_templates: 332;
|
text_templates: 32;
|
||||||
};
|
};
|
||||||
release_tier: "alpha_whitelist";
|
release_tier: "alpha_whitelist";
|
||||||
release_version: string;
|
release_version: string;
|
||||||
@@ -135,6 +140,15 @@ function validateStaticCatalog(catalog: StaticStickerCatalog) {
|
|||||||
if (Object.keys(catalog.part_counts).length !== 25) throw new Error("static sticker part counts must contain 25 parts");
|
if (Object.keys(catalog.part_counts).length !== 25) throw new Error("static sticker part counts must contain 25 parts");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fontIdsForTemplates(templates: readonly RegisteredComplexAsset[]) {
|
||||||
|
const referenced = new Set<string>();
|
||||||
|
for (const template of templates) {
|
||||||
|
for (const fontId of template.font_panel_references ?? []) referenced.add(fontId);
|
||||||
|
}
|
||||||
|
referenced.add("FONT081");
|
||||||
|
return [...referenced].sort((left, right) => Number(left.slice(4)) - Number(right.slice(4)));
|
||||||
|
}
|
||||||
|
|
||||||
export function createP0aPublicManifest(input: {
|
export function createP0aPublicManifest(input: {
|
||||||
complexManifest: ComplexRegistryManifest;
|
complexManifest: ComplexRegistryManifest;
|
||||||
staticCatalog: StaticStickerCatalog;
|
staticCatalog: StaticStickerCatalog;
|
||||||
@@ -143,7 +157,11 @@ export function createP0aPublicManifest(input: {
|
|||||||
validateStaticCatalog(input.staticCatalog);
|
validateStaticCatalog(input.staticCatalog);
|
||||||
|
|
||||||
const textTemplates = orderedItems(input.complexManifest.items, P0A_TEXT_TEMPLATE_IDS, "text_template");
|
const textTemplates = orderedItems(input.complexManifest.items, P0A_TEXT_TEMPLATE_IDS, "text_template");
|
||||||
const fontPanelItems = orderedItems(input.complexManifest.items, P0A_REQUIRED_FONT_PANEL_IDS, "font_panel");
|
const derivedFontIds = fontIdsForTemplates(textTemplates);
|
||||||
|
if (JSON.stringify(derivedFontIds) !== JSON.stringify(P0A_REQUIRED_FONT_PANEL_IDS)) {
|
||||||
|
throw new Error(`P0-A referenced font panel mismatch: received ${derivedFontIds.join(",")}`);
|
||||||
|
}
|
||||||
|
const fontPanelItems = orderedItems(input.complexManifest.items, derivedFontIds, "font_panel");
|
||||||
const colorCards = orderedItems(input.complexManifest.items, P0A_COLOR_CARD_IDS, "color_card");
|
const colorCards = orderedItems(input.complexManifest.items, P0A_COLOR_CARD_IDS, "color_card");
|
||||||
const dynamicStickers = orderedItems(input.complexManifest.items, P0A_DYNAMIC_STICKER_IDS, "interactive_sticker");
|
const dynamicStickers = orderedItems(input.complexManifest.items, P0A_DYNAMIC_STICKER_IDS, "interactive_sticker");
|
||||||
|
|
||||||
@@ -156,12 +174,12 @@ export function createP0aPublicManifest(input: {
|
|||||||
text_templates: textTemplates.map(publicItem),
|
text_templates: textTemplates.map(publicItem),
|
||||||
},
|
},
|
||||||
counts: {
|
counts: {
|
||||||
color_cards: 16,
|
color_cards: 4,
|
||||||
dynamic_stickers: 35,
|
dynamic_stickers: 10,
|
||||||
font_panel_items: 86,
|
font_panel_items: 11,
|
||||||
static_parts: 25,
|
static_parts: 25,
|
||||||
static_stickers: 1_407,
|
static_stickers: 1_407,
|
||||||
text_templates: 332,
|
text_templates: 32,
|
||||||
},
|
},
|
||||||
release_tier: "alpha_whitelist",
|
release_tier: "alpha_whitelist",
|
||||||
release_version: input.complexManifest.release_version,
|
release_version: input.complexManifest.release_version,
|
||||||
|
|||||||
@@ -90,17 +90,17 @@ const familyCounts = Object.fromEntries(["text_template", "font_panel", "color_c
|
|||||||
]));
|
]));
|
||||||
const fullP0Enabled = complex.manifest.items.filter((item) => item.release_tier === "full_p0" && item.release_status === "enabled").length;
|
const fullP0Enabled = complex.manifest.items.filter((item) => item.release_tier === "full_p0" && item.release_status === "enabled").length;
|
||||||
const publicJson = JSON.stringify(manifest);
|
const publicJson = JSON.stringify(manifest);
|
||||||
const completionIds = ["FLOWER145", "H119", "TAG051", "SIMPLE017", "FONT086", "COLOR016", "DYN035"];
|
const hiddenIds = ["FLOWER009", "H009", "TAG008", "SIMPLE009", "COLOR003", "DYN005"];
|
||||||
const response = {
|
const response = {
|
||||||
counts: manifest.counts,
|
counts: manifest.counts,
|
||||||
full_p0_enabled: fullP0Enabled,
|
full_p0_enabled: fullP0Enabled,
|
||||||
complete_catalog_present: completionIds.every((id) => publicJson.includes(id)),
|
hidden_ids_absent: hiddenIds.every((id) => !publicJson.includes(id)),
|
||||||
no_absolute_paths: !/[A-Za-z]:[\\/]/.test(publicJson),
|
no_absolute_paths: !/[A-Za-z]:[\\/]/.test(publicJson),
|
||||||
release_tier: manifest.release_tier,
|
release_tier: manifest.release_tier,
|
||||||
status: "passed",
|
status: "passed",
|
||||||
};
|
};
|
||||||
const registrationValidation = {
|
const registrationValidation = {
|
||||||
public_catalog: {
|
allowlist: {
|
||||||
color_cards: P0A_COLOR_CARD_IDS,
|
color_cards: P0A_COLOR_CARD_IDS,
|
||||||
dynamic_stickers: P0A_DYNAMIC_STICKER_IDS,
|
dynamic_stickers: P0A_DYNAMIC_STICKER_IDS,
|
||||||
font_panel_items: P0A_REQUIRED_FONT_PANEL_IDS,
|
font_panel_items: P0A_REQUIRED_FONT_PANEL_IDS,
|
||||||
@@ -112,7 +112,7 @@ const registrationValidation = {
|
|||||||
source_mutations: complex.report.source_mutations + staticResult.report.source_mutations,
|
source_mutations: complex.report.source_mutations + staticResult.report.source_mutations,
|
||||||
static_parts: Object.keys(staticResult.catalog.part_counts).length,
|
static_parts: Object.keys(staticResult.catalog.part_counts).length,
|
||||||
static_stickers: staticResult.catalog.count,
|
static_stickers: staticResult.catalog.count,
|
||||||
status: response.complete_catalog_present && response.no_absolute_paths && fullP0Enabled === 0 ? "passed" : "failed",
|
status: response.hidden_ids_absent && response.no_absolute_paths && fullP0Enabled === 0 ? "passed" : "failed",
|
||||||
};
|
};
|
||||||
if (registrationValidation.status !== "passed") throw new Error("P0-A registration validation failed");
|
if (registrationValidation.status !== "passed") throw new Error("P0-A registration validation failed");
|
||||||
|
|
||||||
|
|||||||
@@ -33,5 +33,5 @@ const plan = await buildP0aRuntimeAssetPlan({
|
|||||||
if (serializeRuntimeAssetManifest(plan.manifest) !== serializeRuntimeAssetManifest(trustedManifest)) {
|
if (serializeRuntimeAssetManifest(plan.manifest) !== serializeRuntimeAssetManifest(trustedManifest)) {
|
||||||
throw new Error("runtime_asset_source_does_not_match_trusted_manifest");
|
throw new Error("runtime_asset_source_does_not_match_trusted_manifest");
|
||||||
}
|
}
|
||||||
const result = deployRuntimeAssetPlan({ allowManagedUpdate: true, assetRoot, manifest: trustedManifest, resources: plan.resources });
|
const result = deployRuntimeAssetPlan({ assetRoot, manifest: trustedManifest, resources: plan.resources });
|
||||||
process.stdout.write(`${JSON.stringify({ linked_files: result.linked_files, status: result.status })}\n`);
|
process.stdout.write(`${JSON.stringify({ linked_files: result.linked_files, status: result.status })}\n`);
|
||||||
|
|||||||
@@ -1,133 +0,0 @@
|
|||||||
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
||||||
import { createHash } from "node:crypto";
|
|
||||||
import { homedir } from "node:os";
|
|
||||||
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
||||||
|
|
||||||
import {
|
|
||||||
P0A_DYNAMIC_STICKER_IDS,
|
|
||||||
P0A_REQUIRED_FONT_PANEL_IDS,
|
|
||||||
P0A_TEXT_TEMPLATE_IDS,
|
|
||||||
} from "../packages/template-registry/dist/index.js";
|
|
||||||
import { compileTextTemplateAssets } from "./lib/text-template-assets.mjs";
|
|
||||||
|
|
||||||
function option(name) {
|
|
||||||
const index = process.argv.indexOf(name);
|
|
||||||
return index >= 0 ? process.argv[index + 1] : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readJson(path) {
|
|
||||||
return JSON.parse(readFileSync(path, "utf8"));
|
|
||||||
}
|
|
||||||
|
|
||||||
function oneDirectoryWithPrefix(root, prefix) {
|
|
||||||
const matches = readdirSync(root, { withFileTypes: true })
|
|
||||||
.filter((entry) => entry.isDirectory() && entry.name.startsWith(`${prefix}_`));
|
|
||||||
if (matches.length !== 1) throw new Error(`complex_catalog_directory_invalid:${prefix}`);
|
|
||||||
return join(root, matches[0].name);
|
|
||||||
}
|
|
||||||
|
|
||||||
function textTemplateDirectory(root, templateId) {
|
|
||||||
const family = templateId.startsWith("FLOWER") ? "花字"
|
|
||||||
: templateId.startsWith("SIMPLE") ? "简约"
|
|
||||||
: templateId.startsWith("TAG") ? "标签"
|
|
||||||
: "标题";
|
|
||||||
return join(root, family, "templates", templateId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizedTextCategory(value) {
|
|
||||||
if (value === "花字") return "flower";
|
|
||||||
if (value === "简约") return "simple";
|
|
||||||
if (value === "标签") return "tag";
|
|
||||||
return "title";
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizedDynamicCategory(value) {
|
|
||||||
if (value === "user") return "identity";
|
|
||||||
if (value === "location" || value === "time") return value;
|
|
||||||
return "other";
|
|
||||||
}
|
|
||||||
|
|
||||||
const replicationRoot = resolve(option("--replication-root") ?? join(homedir(), "Desktop", "sticker_web_replication_assets"));
|
|
||||||
if (!isAbsolute(replicationRoot) || !existsSync(replicationRoot)) throw new Error("replication_asset_root_unavailable");
|
|
||||||
const outputPath = resolve(option("--output") ?? "apps/web/src/generated/complex-assets.json");
|
|
||||||
const fontPackagesRoot = join(replicationRoot, "sticker_text", "字体", "面板全量采集", "font_panel_full_20260722", "resources", "font_packages");
|
|
||||||
const textRoot = join(replicationRoot, "sticker_text", "模板", "单模板归档");
|
|
||||||
const dynamicRoot = join(replicationRoot, "sticker_interactive", "单模板归档", "templates");
|
|
||||||
|
|
||||||
const fontPanelItems = P0A_REQUIRED_FONT_PANEL_IDS.map((fontId, displayOrder) => {
|
|
||||||
const metadata = readJson(join(oneDirectoryWithPrefix(fontPackagesRoot, fontId), "metadata.json"));
|
|
||||||
if (typeof metadata.local_sha256 !== "string") throw new Error(`complex_catalog_font_hash_missing:${fontId}`);
|
|
||||||
return {
|
|
||||||
display_name: String(metadata.display_name ?? fontId),
|
|
||||||
display_order: displayOrder,
|
|
||||||
font_id: fontId,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const textResourceRevision = createHash("sha256");
|
|
||||||
const textTemplates = P0A_TEXT_TEMPLATE_IDS.map((templateId, catalogOrder) => {
|
|
||||||
const directory = textTemplateDirectory(textRoot, templateId);
|
|
||||||
const metadata = readJson(join(directory, "metadata.json"));
|
|
||||||
const compiled = compileTextTemplateAssets({ templateDirectory: directory, templateId });
|
|
||||||
if (compiled.diagnostics.unresolved_images !== 0) throw new Error(`text_template_image_reference_unresolved:${templateId}`);
|
|
||||||
for (const resource of compiled.resources) {
|
|
||||||
const bytes = resource.sourceBytes ?? readFileSync(resource.sourcePath);
|
|
||||||
textResourceRevision.update(resource.assetId).update(createHash("sha256").update(bytes).digest());
|
|
||||||
}
|
|
||||||
const previewReference = typeof metadata.files?.preview === "string" ? metadata.files.preview : undefined;
|
|
||||||
const hasPreview = previewReference ? existsSync(join(directory, ...previewReference.split("/"))) : false;
|
|
||||||
return {
|
|
||||||
available: true,
|
|
||||||
catalog_order: catalogOrder,
|
|
||||||
category: normalizedTextCategory(metadata.category),
|
|
||||||
...compiled.catalog,
|
|
||||||
display_name: String(metadata.display_name || metadata.default_text || compiled.catalog.default_text || templateId),
|
|
||||||
...(hasPreview ? { preview_asset_id: `TEXT-PREVIEW-${templateId}` } : {}),
|
|
||||||
resource_class: metadata.resource_class === "parameter_only" ? "parameter_only" : "zip_template",
|
|
||||||
template_id: templateId,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const dynamicStickers = P0A_DYNAMIC_STICKER_IDS.map((templateId, catalogOrder) => {
|
|
||||||
const metadata = readJson(join(dynamicRoot, templateId, "metadata.json"));
|
|
||||||
const requiredFields = Array.isArray(metadata.dynamic_keys) ? metadata.dynamic_keys.map(String) : [];
|
|
||||||
const fontIds = Array.isArray(metadata.files?.fonts)
|
|
||||||
? metadata.files.fonts.map((reference) => basename(reference))
|
|
||||||
: [];
|
|
||||||
return {
|
|
||||||
catalog_order: catalogOrder,
|
|
||||||
category: normalizedDynamicCategory(metadata.category),
|
|
||||||
display_name: String(metadata.display_name ?? templateId),
|
|
||||||
font_ids: fontIds.length > 0 ? fontIds : ["FONT081"],
|
|
||||||
required_fields: requiredFields,
|
|
||||||
requires_location_consent: requiredFields.includes("latitude") || requiredFields.includes("longitude"),
|
|
||||||
source_candidate_id: String(metadata.source_candidate_id ?? metadata.display_name ?? templateId),
|
|
||||||
template_id: templateId,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const catalog = {
|
|
||||||
dynamic_stickers: dynamicStickers,
|
|
||||||
font_panel_items: fontPanelItems,
|
|
||||||
schema_version: "DadaComplexBrowserCatalog/v2",
|
|
||||||
text_resource_revision: textResourceRevision.digest("hex").slice(0, 16),
|
|
||||||
text_templates: textTemplates,
|
|
||||||
};
|
|
||||||
|
|
||||||
const serialized = `${JSON.stringify(catalog, null, 2)}\n`;
|
|
||||||
function containsAbsolutePath(value) {
|
|
||||||
if (typeof value === "string") return /^[A-Z]:[\\/]/i.test(value);
|
|
||||||
if (Array.isArray(value)) return value.some(containsAbsolutePath);
|
|
||||||
return value && typeof value === "object" ? Object.values(value).some(containsAbsolutePath) : false;
|
|
||||||
}
|
|
||||||
const unsafeTemplateIds = textTemplates.filter(containsAbsolutePath).map((item) => item.template_id);
|
|
||||||
if (unsafeTemplateIds.length > 0) throw new Error(`complex_catalog_absolute_path_detected:${unsafeTemplateIds.join(",")}`);
|
|
||||||
mkdirSync(dirname(outputPath), { recursive: true });
|
|
||||||
writeFileSync(outputPath, serialized);
|
|
||||||
process.stdout.write(`${JSON.stringify({
|
|
||||||
dynamic_stickers: dynamicStickers.length,
|
|
||||||
font_panel_items: fontPanelItems.length,
|
|
||||||
output: outputPath,
|
|
||||||
text_previews: textTemplates.filter((item) => item.preview_asset_id).length,
|
|
||||||
text_templates: textTemplates.length,
|
|
||||||
})}\n`);
|
|
||||||
@@ -167,7 +167,6 @@ function copyApplication(source, destination, runtimeDependencies) {
|
|||||||
function buildArtifacts(stagingRoot) {
|
function buildArtifacts(stagingRoot) {
|
||||||
debug("build workspace artifacts");
|
debug("build workspace artifacts");
|
||||||
run("pnpm", ["build:workspace-packages"]);
|
run("pnpm", ["build:workspace-packages"]);
|
||||||
removeTree(join(repositoryRoot, "apps", "web", "dist"));
|
|
||||||
run("pnpm", ["--filter", "@dada/web", "build"]);
|
run("pnpm", ["--filter", "@dada/web", "build"]);
|
||||||
run("pnpm", ["--filter", "@dada/api", "build"]);
|
run("pnpm", ["--filter", "@dada/api", "build"]);
|
||||||
run("pnpm", ["--filter", "@dada/worker", "build"]);
|
run("pnpm", ["--filter", "@dada/worker", "build"]);
|
||||||
|
|||||||
+45
-156
@@ -8,7 +8,6 @@ import {
|
|||||||
readFileSync,
|
readFileSync,
|
||||||
readdirSync,
|
readdirSync,
|
||||||
realpathSync,
|
realpathSync,
|
||||||
renameSync,
|
|
||||||
rmSync,
|
rmSync,
|
||||||
statSync,
|
statSync,
|
||||||
writeFileSync,
|
writeFileSync,
|
||||||
@@ -16,8 +15,6 @@ import {
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { basename, dirname, extname, isAbsolute, join, resolve, sep } from "node:path";
|
import { basename, dirname, extname, isAbsolute, join, resolve, sep } from "node:path";
|
||||||
|
|
||||||
import { compileTextTemplateAssets } from "./text-template-assets.mjs";
|
|
||||||
|
|
||||||
export const P0A_RUNTIME_ASSET_ROOT_REF = "p0a_runtime_assets";
|
export const P0A_RUNTIME_ASSET_ROOT_REF = "p0a_runtime_assets";
|
||||||
export const RUNTIME_ASSET_MANIFEST_SCHEMA = "DadaRuntimeAssets/v1";
|
export const RUNTIME_ASSET_MANIFEST_SCHEMA = "DadaRuntimeAssets/v1";
|
||||||
|
|
||||||
@@ -67,9 +64,6 @@ function derivedCounts(entries) {
|
|||||||
dynamic_images: entries.filter((entry) => entry.resourceVersion === "p0a-complex-v1" && /^DYN\d{3}-/.test(entry.assetId)).length,
|
dynamic_images: entries.filter((entry) => entry.resourceVersion === "p0a-complex-v1" && /^DYN\d{3}-/.test(entry.assetId)).length,
|
||||||
font_panel_items: entries.filter((entry) => entry.resourceVersion === "p0a-complex-v1" && /^FONT\d{3}$/.test(entry.assetId)).length,
|
font_panel_items: entries.filter((entry) => entry.resourceVersion === "p0a-complex-v1" && /^FONT\d{3}$/.test(entry.assetId)).length,
|
||||||
static_stickers: entries.filter((entry) => entry.resourceVersion === "p0a-static-v1" && /^STK\d{3,4}$/.test(entry.assetId)).length,
|
static_stickers: entries.filter((entry) => entry.resourceVersion === "p0a-static-v1" && /^STK\d{3,4}$/.test(entry.assetId)).length,
|
||||||
text_fonts: entries.filter((entry) => entry.resourceVersion === "p0a-complex-v1" && /^TEXT-FONT-/.test(entry.assetId)).length,
|
|
||||||
text_images: entries.filter((entry) => entry.resourceVersion === "p0a-complex-v1" && /^TEXT-IMAGE-/.test(entry.assetId)).length,
|
|
||||||
text_previews: entries.filter((entry) => entry.resourceVersion === "p0a-complex-v1" && /^TEXT-PREVIEW-/.test(entry.assetId)).length,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,42 +130,7 @@ function sameFile(left, right) {
|
|||||||
return leftStat.dev === rightStat.dev && leftStat.ino === rightStat.ino;
|
return leftStat.dev === rightStat.dev && leftStat.ino === rightStat.ino;
|
||||||
}
|
}
|
||||||
|
|
||||||
function installRuntimeAsset(resource, targetPath, expectedSha) {
|
export function deployRuntimeAssetPlan({ assetRoot, manifest, resources }) {
|
||||||
const generatedBytes = Buffer.isBuffer(resource.sourceBytes) ? resource.sourceBytes : undefined;
|
|
||||||
if (generatedBytes) writeFileSync(targetPath, generatedBytes, { flag: "wx" });
|
|
||||||
else {
|
|
||||||
try {
|
|
||||||
linkSync(resource.sourcePath, targetPath);
|
|
||||||
} catch (error) {
|
|
||||||
if (error && typeof error === "object" && "code" in error && error.code === "EXDEV") {
|
|
||||||
throw new Error("asset_hardlink_volume_mismatch");
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (fileSha256(targetPath) !== expectedSha) throw new Error(generatedBytes ? "asset_generated_write_invalid" : "asset_hardlink_verification_failed");
|
|
||||||
if (!generatedBytes && !sameFile(resource.sourcePath, targetPath)) throw new Error("asset_hardlink_verification_failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
function replaceManagedRuntimeAsset(resource, targetPath, expectedSha) {
|
|
||||||
const nextPath = `${targetPath}.dada-next`;
|
|
||||||
const previousPath = `${targetPath}.dada-previous`;
|
|
||||||
if (existsSync(nextPath) || existsSync(previousPath)) throw new Error("asset_update_staging_conflict");
|
|
||||||
installRuntimeAsset(resource, nextPath, expectedSha);
|
|
||||||
renameSync(targetPath, previousPath);
|
|
||||||
try {
|
|
||||||
renameSync(nextPath, targetPath);
|
|
||||||
if (fileSha256(targetPath) !== expectedSha) throw new Error("asset_update_verification_failed");
|
|
||||||
rmSync(previousPath, { force: true });
|
|
||||||
} catch (error) {
|
|
||||||
if (existsSync(targetPath)) renameSync(targetPath, nextPath);
|
|
||||||
renameSync(previousPath, targetPath);
|
|
||||||
rmSync(nextPath, { force: true });
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deployRuntimeAssetPlan({ allowManagedUpdate = false, assetRoot, manifest, resources }) {
|
|
||||||
if (!isAbsolute(assetRoot)) throw new Error("asset_root_must_be_absolute");
|
if (!isAbsolute(assetRoot)) throw new Error("asset_root_must_be_absolute");
|
||||||
const normalizedManifest = createRuntimeAssetManifest({
|
const normalizedManifest = createRuntimeAssetManifest({
|
||||||
counts: manifest.counts,
|
counts: manifest.counts,
|
||||||
@@ -181,35 +140,32 @@ export function deployRuntimeAssetPlan({ allowManagedUpdate = false, assetRoot,
|
|||||||
const entries = new Map(normalizedManifest.entries.map((entry) => [`${entry.resourceVersion}\u0000${entry.assetId}`, entry]));
|
const entries = new Map(normalizedManifest.entries.map((entry) => [`${entry.resourceVersion}\u0000${entry.assetId}`, entry]));
|
||||||
if (resources.length !== entries.size) throw new Error("asset_resource_plan_incomplete");
|
if (resources.length !== entries.size) throw new Error("asset_resource_plan_incomplete");
|
||||||
mkdirSync(assetRoot, { recursive: true });
|
mkdirSync(assetRoot, { recursive: true });
|
||||||
const previousManifestPath = join(assetRoot, "manifest.json");
|
|
||||||
const previousEntries = allowManagedUpdate && existsSync(previousManifestPath)
|
|
||||||
? new Map(readRuntimeAssetManifest(previousManifestPath).entries.map((entry) => [entry.relativePath, entry]))
|
|
||||||
: new Map();
|
|
||||||
for (const resource of resources) {
|
for (const resource of resources) {
|
||||||
const key = `${resource.entry.resourceVersion}\u0000${resource.entry.assetId}`;
|
const key = `${resource.entry.resourceVersion}\u0000${resource.entry.assetId}`;
|
||||||
const entry = entries.get(key);
|
const entry = entries.get(key);
|
||||||
if (!entry || JSON.stringify(entry) !== JSON.stringify({ ...resource.entry, sha256: resource.entry.sha256.toLowerCase() })) {
|
if (!entry || JSON.stringify(entry) !== JSON.stringify({ ...resource.entry, sha256: resource.entry.sha256.toLowerCase() })) {
|
||||||
throw new Error("asset_resource_plan_mismatch");
|
throw new Error("asset_resource_plan_mismatch");
|
||||||
}
|
}
|
||||||
const generatedBytes = Buffer.isBuffer(resource.sourceBytes) ? resource.sourceBytes : undefined;
|
if (!existsSync(resource.sourcePath) || !statSync(resource.sourcePath).isFile() || lstatSync(resource.sourcePath).isSymbolicLink()) {
|
||||||
if (!generatedBytes && (!existsSync(resource.sourcePath) || !statSync(resource.sourcePath).isFile() || lstatSync(resource.sourcePath).isSymbolicLink())) {
|
|
||||||
throw new Error("asset_source_invalid");
|
throw new Error("asset_source_invalid");
|
||||||
}
|
}
|
||||||
if ((generatedBytes ? sha256(generatedBytes) : fileSha256(resource.sourcePath)) !== entry.sha256) throw new Error("asset_source_hash_invalid");
|
if (fileSha256(resource.sourcePath) !== entry.sha256) throw new Error("asset_source_hash_invalid");
|
||||||
const targetPath = targetWithinRoot(assetRoot, entry.relativePath);
|
const targetPath = targetWithinRoot(assetRoot, entry.relativePath);
|
||||||
mkdirSync(dirname(targetPath), { recursive: true });
|
mkdirSync(dirname(targetPath), { recursive: true });
|
||||||
if (existsSync(targetPath)) {
|
if (existsSync(targetPath)) {
|
||||||
const targetSha = fileSha256(targetPath);
|
if (fileSha256(targetPath) !== entry.sha256) throw new Error("asset_target_conflict");
|
||||||
if (targetSha !== entry.sha256) {
|
if (!sameFile(resource.sourcePath, targetPath)) throw new Error("asset_target_not_hardlink");
|
||||||
const previous = previousEntries.get(entry.relativePath);
|
|
||||||
if (!previous || targetSha !== previous.sha256) throw new Error("asset_target_conflict");
|
|
||||||
replaceManagedRuntimeAsset(resource, targetPath, entry.sha256);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!generatedBytes && !sameFile(resource.sourcePath, targetPath)) throw new Error("asset_target_not_hardlink");
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
installRuntimeAsset(resource, targetPath, entry.sha256);
|
try {
|
||||||
|
linkSync(resource.sourcePath, targetPath);
|
||||||
|
} catch (error) {
|
||||||
|
if (error && typeof error === "object" && "code" in error && error.code === "EXDEV") {
|
||||||
|
throw new Error("asset_hardlink_volume_mismatch");
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (!sameFile(resource.sourcePath, targetPath)) throw new Error("asset_hardlink_verification_failed");
|
||||||
}
|
}
|
||||||
writeRuntimeAssetManifest(join(assetRoot, "manifest.json"), normalizedManifest);
|
writeRuntimeAssetManifest(join(assetRoot, "manifest.json"), normalizedManifest);
|
||||||
return { linked_files: resources.length, manifest: normalizedManifest, status: "ready" };
|
return { linked_files: resources.length, manifest: normalizedManifest, status: "ready" };
|
||||||
@@ -240,23 +196,6 @@ function entryFor(sourcePath, assetId, resourceVersion, relativePath, mimeType)
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function entryForBytes(sourceBytes, assetId, resourceVersion, relativePath, mimeType) {
|
|
||||||
return {
|
|
||||||
assetId,
|
|
||||||
mimeType,
|
|
||||||
relativePath,
|
|
||||||
resourceVersion,
|
|
||||||
rootRef: P0A_RUNTIME_ASSET_ROOT_REF,
|
|
||||||
sha256: sha256(sourceBytes),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function safeRuntimeComponent(value) {
|
|
||||||
const normalized = value.normalize("NFKD").replaceAll(/[^A-Za-z0-9_-]+/g, "-").replaceAll(/^-+|-+$/g, "");
|
|
||||||
if (normalized === value) return normalized;
|
|
||||||
return `${normalized || "asset"}-${sha256(value).slice(0, 8).toLowerCase()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function dynamicMetadata(templateRoot, descriptor, field) {
|
function dynamicMetadata(templateRoot, descriptor, field) {
|
||||||
const templateDirectory = join(templateRoot, descriptor.templateId);
|
const templateDirectory = join(templateRoot, descriptor.templateId);
|
||||||
const metadata = JSON.parse(readFileSync(join(templateDirectory, "metadata.json"), "utf8"));
|
const metadata = JSON.parse(readFileSync(join(templateDirectory, "metadata.json"), "utf8"));
|
||||||
@@ -266,14 +205,6 @@ function dynamicMetadata(templateRoot, descriptor, field) {
|
|||||||
return templateDirectory;
|
return templateDirectory;
|
||||||
}
|
}
|
||||||
|
|
||||||
function textTemplateDirectory(root, templateId) {
|
|
||||||
const family = templateId.startsWith("FLOWER") ? "花字"
|
|
||||||
: templateId.startsWith("SIMPLE") ? "简约"
|
|
||||||
: templateId.startsWith("TAG") ? "标签"
|
|
||||||
: "标题";
|
|
||||||
return join(root, family, "templates", templateId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function buildP0aRuntimeAssetPlan({ replicationRoot }) {
|
export async function buildP0aRuntimeAssetPlan({ replicationRoot }) {
|
||||||
const [{ compileStaticStickerCatalog }, registry] = await Promise.all([
|
const [{ compileStaticStickerCatalog }, registry] = await Promise.all([
|
||||||
import("../../packages/asset-compiler/dist/index.js"),
|
import("../../packages/asset-compiler/dist/index.js"),
|
||||||
@@ -299,19 +230,6 @@ export async function buildP0aRuntimeAssetPlan({ replicationRoot }) {
|
|||||||
if (entry.sha256 !== item.sha256.toLowerCase()) throw new Error(`static_sticker_hash_invalid:${item.stable_id}`);
|
if (entry.sha256 !== item.sha256.toLowerCase()) throw new Error(`static_sticker_hash_invalid:${item.stable_id}`);
|
||||||
return { entry, sourcePath };
|
return { entry, sourcePath };
|
||||||
});
|
});
|
||||||
const resourceKeys = new Map(resources.map((resource) => [`${resource.entry.resourceVersion}\u0000${resource.entry.assetId}`, resource]));
|
|
||||||
const addResource = (resource) => {
|
|
||||||
const key = `${resource.entry.resourceVersion}\u0000${resource.entry.assetId}`;
|
|
||||||
const existing = resourceKeys.get(key);
|
|
||||||
if (existing) {
|
|
||||||
if (existing.entry.sha256 !== resource.entry.sha256 || existing.entry.mimeType !== resource.entry.mimeType) {
|
|
||||||
throw new Error(`runtime_asset_duplicate_conflict:${resource.entry.assetId}`);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resourceKeys.set(key, resource);
|
|
||||||
resources.push(resource);
|
|
||||||
};
|
|
||||||
|
|
||||||
const fontPackagesRoot = join(
|
const fontPackagesRoot = join(
|
||||||
replicationRoot,
|
replicationRoot,
|
||||||
@@ -326,7 +244,7 @@ export async function buildP0aRuntimeAssetPlan({ replicationRoot }) {
|
|||||||
const packageDirectory = oneDirectoryWithPrefix(fontPackagesRoot, assetId);
|
const packageDirectory = oneDirectoryWithPrefix(fontPackagesRoot, assetId);
|
||||||
const sourcePath = oneSupportedFont(join(packageDirectory, "font_files"));
|
const sourcePath = oneSupportedFont(join(packageDirectory, "font_files"));
|
||||||
const extension = extname(sourcePath).toLowerCase();
|
const extension = extname(sourcePath).toLowerCase();
|
||||||
addResource({
|
resources.push({
|
||||||
entry: entryFor(
|
entry: entryFor(
|
||||||
sourcePath,
|
sourcePath,
|
||||||
assetId,
|
assetId,
|
||||||
@@ -339,67 +257,33 @@ export async function buildP0aRuntimeAssetPlan({ replicationRoot }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const templateRoot = join(replicationRoot, "sticker_interactive", "单模板归档", "templates");
|
const templateRoot = join(replicationRoot, "sticker_interactive", "单模板归档", "templates");
|
||||||
for (const templateId of registry.P0A_DYNAMIC_STICKER_IDS) {
|
for (const descriptor of registry.P0A_DYNAMIC_RUNTIME_FONT_SOURCES) {
|
||||||
const templateDirectory = join(templateRoot, templateId);
|
const templateDirectory = dynamicMetadata(templateRoot, descriptor, "fonts");
|
||||||
const metadata = JSON.parse(readFileSync(join(templateDirectory, "metadata.json"), "utf8"));
|
const sourcePath = oneSupportedFont(join(templateDirectory, ...descriptor.sourceReference.split("/")));
|
||||||
for (const sourceReference of metadata.files?.fonts ?? []) {
|
const extension = extname(sourcePath).toLowerCase();
|
||||||
const sourcePath = oneSupportedFont(join(templateDirectory, ...sourceReference.split("/")));
|
resources.push({
|
||||||
const extension = extname(sourcePath).toLowerCase();
|
|
||||||
const assetId = basename(sourceReference);
|
|
||||||
addResource({
|
|
||||||
entry: entryFor(
|
|
||||||
sourcePath,
|
|
||||||
assetId,
|
|
||||||
registry.P0A_COMPLEX_RELEASE_VERSION,
|
|
||||||
`${registry.P0A_COMPLEX_RELEASE_VERSION}/${assetId}${extension}`,
|
|
||||||
fontMimeTypes.get(extension),
|
|
||||||
),
|
|
||||||
sourcePath,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
for (const sourceReference of metadata.files?.images ?? []) {
|
|
||||||
const sourcePath = join(templateDirectory, ...sourceReference.split("/"));
|
|
||||||
const extension = extname(sourcePath).toLowerCase();
|
|
||||||
const assetId = `${templateId}-${safeRuntimeComponent(basename(sourceReference, extension))}`;
|
|
||||||
if (!existsSync(sourcePath) || extension !== ".png") throw new Error(`runtime_dynamic_image_invalid:${assetId}`);
|
|
||||||
addResource({
|
|
||||||
entry: entryFor(
|
|
||||||
sourcePath,
|
|
||||||
assetId,
|
|
||||||
registry.P0A_COMPLEX_RELEASE_VERSION,
|
|
||||||
`${registry.P0A_COMPLEX_RELEASE_VERSION}/${assetId}.png`,
|
|
||||||
"image/png",
|
|
||||||
),
|
|
||||||
sourcePath,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const textRoot = join(replicationRoot, "sticker_text", "模板", "单模板归档");
|
|
||||||
for (const templateId of registry.P0A_TEXT_TEMPLATE_IDS) {
|
|
||||||
const templateDirectory = textTemplateDirectory(textRoot, templateId);
|
|
||||||
const metadata = JSON.parse(readFileSync(join(templateDirectory, "metadata.json"), "utf8"));
|
|
||||||
const compiled = compileTextTemplateAssets({ templateDirectory, templateId });
|
|
||||||
for (const resource of compiled.resources) {
|
|
||||||
const relativePath = `${registry.P0A_COMPLEX_RELEASE_VERSION}/${resource.assetId}${resource.extension}`;
|
|
||||||
const entry = resource.sourceBytes
|
|
||||||
? entryForBytes(resource.sourceBytes, resource.assetId, registry.P0A_COMPLEX_RELEASE_VERSION, relativePath, resource.mimeType)
|
|
||||||
: entryFor(resource.sourcePath, resource.assetId, registry.P0A_COMPLEX_RELEASE_VERSION, relativePath, resource.mimeType);
|
|
||||||
addResource({ entry, ...(resource.sourceBytes ? { sourceBytes: resource.sourceBytes } : { sourcePath: resource.sourcePath }) });
|
|
||||||
}
|
|
||||||
const previewReference = metadata.files?.preview;
|
|
||||||
if (typeof previewReference !== "string" || previewReference.length === 0) continue;
|
|
||||||
const sourcePath = join(templateDirectory, ...previewReference.split("/"));
|
|
||||||
if (!existsSync(sourcePath) || extname(sourcePath).toLowerCase() !== ".png") {
|
|
||||||
throw new Error(`runtime_text_preview_invalid:${templateId}`);
|
|
||||||
}
|
|
||||||
const assetId = `TEXT-PREVIEW-${templateId}`;
|
|
||||||
addResource({
|
|
||||||
entry: entryFor(
|
entry: entryFor(
|
||||||
sourcePath,
|
sourcePath,
|
||||||
assetId,
|
descriptor.assetId,
|
||||||
registry.P0A_COMPLEX_RELEASE_VERSION,
|
registry.P0A_COMPLEX_RELEASE_VERSION,
|
||||||
`${registry.P0A_COMPLEX_RELEASE_VERSION}/${assetId}.png`,
|
`${registry.P0A_COMPLEX_RELEASE_VERSION}/${descriptor.assetId}${extension}`,
|
||||||
|
fontMimeTypes.get(extension),
|
||||||
|
),
|
||||||
|
sourcePath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const descriptor of registry.P0A_DYNAMIC_RUNTIME_IMAGE_SOURCES) {
|
||||||
|
const templateDirectory = dynamicMetadata(templateRoot, descriptor, "images");
|
||||||
|
const sourcePath = join(templateDirectory, ...descriptor.sourceReference.split("/"));
|
||||||
|
if (!existsSync(sourcePath) || extname(sourcePath).toLowerCase() !== ".png") {
|
||||||
|
throw new Error(`runtime_dynamic_image_invalid:${descriptor.assetId}`);
|
||||||
|
}
|
||||||
|
resources.push({
|
||||||
|
entry: entryFor(
|
||||||
|
sourcePath,
|
||||||
|
descriptor.assetId,
|
||||||
|
registry.P0A_COMPLEX_RELEASE_VERSION,
|
||||||
|
`${registry.P0A_COMPLEX_RELEASE_VERSION}/${descriptor.assetId}.png`,
|
||||||
"image/png",
|
"image/png",
|
||||||
),
|
),
|
||||||
sourcePath,
|
sourcePath,
|
||||||
@@ -408,7 +292,12 @@ export async function buildP0aRuntimeAssetPlan({ replicationRoot }) {
|
|||||||
|
|
||||||
const manifestPath = join(replicationRoot, "sticker_web_handoff", "sticker_web_catalog_manifest.json");
|
const manifestPath = join(replicationRoot, "sticker_web_handoff", "sticker_web_catalog_manifest.json");
|
||||||
const manifest = createRuntimeAssetManifest({
|
const manifest = createRuntimeAssetManifest({
|
||||||
counts: derivedCounts(resources.map((resource) => resource.entry)),
|
counts: {
|
||||||
|
dynamic_fonts: registry.P0A_DYNAMIC_RUNTIME_FONT_SOURCES.length,
|
||||||
|
dynamic_images: registry.P0A_DYNAMIC_RUNTIME_IMAGE_SOURCES.length,
|
||||||
|
font_panel_items: registry.P0A_REQUIRED_FONT_PANEL_IDS.length,
|
||||||
|
static_stickers: staticResult.catalog.count,
|
||||||
|
},
|
||||||
entries: resources.map((resource) => resource.entry),
|
entries: resources.map((resource) => resource.entry),
|
||||||
sourceManifestSha256: fileSha256(manifestPath),
|
sourceManifestSha256: fileSha256(manifestPath),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,928 +0,0 @@
|
|||||||
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
||||||
import { createHash } from "node:crypto";
|
|
||||||
import { basename, dirname, extname, join, relative } from "node:path";
|
|
||||||
import { inflateRawSync } from "node:zlib";
|
|
||||||
|
|
||||||
const fontMimeTypes = new Map([
|
|
||||||
[".otf", "font/otf"],
|
|
||||||
[".ttf", "font/ttf"],
|
|
||||||
[".woff", "font/woff"],
|
|
||||||
[".woff2", "font/woff2"],
|
|
||||||
]);
|
|
||||||
|
|
||||||
function sfntChecksum(bytes, offset = 0, length = bytes.length) {
|
|
||||||
let checksum = 0;
|
|
||||||
for (let index = 0; index < length; index += 4) {
|
|
||||||
let value = 0;
|
|
||||||
for (let byte = 0; byte < 4; byte += 1) value = (value << 8) | (bytes[offset + index + byte] ?? 0);
|
|
||||||
checksum = (checksum + (value >>> 0)) >>> 0;
|
|
||||||
}
|
|
||||||
return checksum;
|
|
||||||
}
|
|
||||||
|
|
||||||
function rebuildSfnt(source, tables) {
|
|
||||||
const ordered = tables.toSorted((left, right) => (left.tag < right.tag ? -1 : left.tag > right.tag ? 1 : 0));
|
|
||||||
const tableCount = ordered.length;
|
|
||||||
const largestPower = 2 ** Math.floor(Math.log2(tableCount));
|
|
||||||
let outputLength = 12 + tableCount * 16;
|
|
||||||
const records = ordered.map((table) => {
|
|
||||||
const bytes = table.bytes
|
|
||||||
? Buffer.from(table.bytes)
|
|
||||||
: Buffer.from(source.subarray(table.offset, table.offset + table.length));
|
|
||||||
if (table.tag === "head") bytes.writeUInt32BE(0, 8);
|
|
||||||
const record = { ...table, bytes, offset: outputLength };
|
|
||||||
outputLength += Math.ceil(bytes.length / 4) * 4;
|
|
||||||
return record;
|
|
||||||
});
|
|
||||||
const output = Buffer.alloc(outputLength);
|
|
||||||
output.writeUInt32BE(source.readUInt32BE(0), 0);
|
|
||||||
output.writeUInt16BE(tableCount, 4);
|
|
||||||
output.writeUInt16BE(largestPower * 16, 6);
|
|
||||||
output.writeUInt16BE(Math.log2(largestPower), 8);
|
|
||||||
output.writeUInt16BE(tableCount * 16 - largestPower * 16, 10);
|
|
||||||
records.forEach((record, index) => {
|
|
||||||
const directoryOffset = 12 + index * 16;
|
|
||||||
output.write(record.tag, directoryOffset, 4, "ascii");
|
|
||||||
output.writeUInt32BE(sfntChecksum(record.bytes), directoryOffset + 4);
|
|
||||||
output.writeUInt32BE(record.offset, directoryOffset + 8);
|
|
||||||
output.writeUInt32BE(record.bytes.length, directoryOffset + 12);
|
|
||||||
record.bytes.copy(output, record.offset);
|
|
||||||
});
|
|
||||||
const head = records.find((record) => record.tag === "head");
|
|
||||||
if (!head || head.bytes.length < 12) throw new Error("text_font_sfnt_head_invalid");
|
|
||||||
output.writeUInt32BE((0xB1B0AFBA - sfntChecksum(output)) >>> 0, head.offset + 8);
|
|
||||||
return output;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeGlyphBounds(source, tables) {
|
|
||||||
const glyphTable = tables.get("glyf");
|
|
||||||
const headerTable = tables.get("head");
|
|
||||||
const locationTable = tables.get("loca");
|
|
||||||
const maximumProfileTable = tables.get("maxp");
|
|
||||||
if (
|
|
||||||
!glyphTable
|
|
||||||
|| !headerTable
|
|
||||||
|| headerTable.length < 54
|
|
||||||
|| !locationTable
|
|
||||||
|| !maximumProfileTable
|
|
||||||
|| maximumProfileTable.length < 6
|
|
||||||
) return undefined;
|
|
||||||
const glyphCount = source.readUInt16BE(maximumProfileTable.offset + 4);
|
|
||||||
const locationFormat = source.readInt16BE(headerTable.offset + 50);
|
|
||||||
const locationEntrySize = locationFormat === 0 ? 2 : locationFormat === 1 ? 4 : 0;
|
|
||||||
if (locationEntrySize === 0 || locationTable.length < (glyphCount + 1) * locationEntrySize) {
|
|
||||||
throw new Error("text_font_glyph_location_invalid");
|
|
||||||
}
|
|
||||||
const glyphBytes = Buffer.from(source.subarray(glyphTable.offset, glyphTable.offset + glyphTable.length));
|
|
||||||
const glyphOffset = (index) => {
|
|
||||||
const offset = locationTable.offset + index * locationEntrySize;
|
|
||||||
return locationFormat === 0 ? source.readUInt16BE(offset) * 2 : source.readUInt32BE(offset);
|
|
||||||
};
|
|
||||||
let changed = false;
|
|
||||||
let previousEnd = 0;
|
|
||||||
for (let index = 0; index < glyphCount; index += 1) {
|
|
||||||
const start = glyphOffset(index);
|
|
||||||
const end = glyphOffset(index + 1);
|
|
||||||
if (start < previousEnd || end < start || end > glyphBytes.length) throw new Error("text_font_glyph_location_invalid");
|
|
||||||
previousEnd = end;
|
|
||||||
if (end - start < 10) continue;
|
|
||||||
const xMin = glyphBytes.readInt16BE(start + 2);
|
|
||||||
const yMin = glyphBytes.readInt16BE(start + 4);
|
|
||||||
const xMax = glyphBytes.readInt16BE(start + 6);
|
|
||||||
const yMax = glyphBytes.readInt16BE(start + 8);
|
|
||||||
if (xMin > xMax) {
|
|
||||||
glyphBytes.writeInt16BE(xMax, start + 2);
|
|
||||||
glyphBytes.writeInt16BE(xMin, start + 6);
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
if (yMin > yMax) {
|
|
||||||
glyphBytes.writeInt16BE(yMax, start + 4);
|
|
||||||
glyphBytes.writeInt16BE(yMin, start + 8);
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return changed ? glyphBytes : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeBrowserFontBytes(source) {
|
|
||||||
const bytes = Buffer.from(source);
|
|
||||||
if (bytes.length < 12 || bytes.readUInt32BE(0) !== 0x00010000) return undefined;
|
|
||||||
const tableCount = bytes.readUInt16BE(4);
|
|
||||||
if (12 + tableCount * 16 > bytes.length) throw new Error("text_font_sfnt_directory_invalid");
|
|
||||||
const tables = new Map();
|
|
||||||
for (let index = 0; index < tableCount; index += 1) {
|
|
||||||
const recordOffset = 12 + index * 16;
|
|
||||||
const tag = bytes.toString("ascii", recordOffset, recordOffset + 4);
|
|
||||||
const offset = bytes.readUInt32BE(recordOffset + 8);
|
|
||||||
const length = bytes.readUInt32BE(recordOffset + 12);
|
|
||||||
if (offset + length > bytes.length) throw new Error("text_font_sfnt_table_invalid");
|
|
||||||
tables.set(tag, { length, offset, recordOffset, tag });
|
|
||||||
}
|
|
||||||
const head = tables.get("head");
|
|
||||||
const verticalHeader = tables.get("vhea");
|
|
||||||
if (!head || head.length < 12) return undefined;
|
|
||||||
const invalidVerticalVersion = verticalHeader?.length >= 4 && bytes.readUInt32BE(verticalHeader.offset) === 0x00010001;
|
|
||||||
const invalidWholeFontChecksum = sfntChecksum(bytes) !== 0xB1B0AFBA;
|
|
||||||
const normalizedGlyphs = normalizeGlyphBounds(bytes, tables);
|
|
||||||
if (!invalidVerticalVersion && !invalidWholeFontChecksum && !normalizedGlyphs) return undefined;
|
|
||||||
const keptTables = [...tables.values()]
|
|
||||||
.filter((table) => !invalidVerticalVersion || !["vhea", "vmtx"].includes(table.tag))
|
|
||||||
.map((table) => {
|
|
||||||
if (table.tag === "glyf" && normalizedGlyphs) return { ...table, bytes: normalizedGlyphs };
|
|
||||||
if (!invalidVerticalVersion || table.tag !== "post") return table;
|
|
||||||
const post = Buffer.alloc(32);
|
|
||||||
bytes.copy(post, 0, table.offset, table.offset + Math.min(table.length, post.length));
|
|
||||||
post.writeUInt32BE(0x00030000, 0);
|
|
||||||
return { ...table, bytes: post, length: post.length };
|
|
||||||
});
|
|
||||||
if (invalidVerticalVersion && !tables.has("post")) {
|
|
||||||
const post = Buffer.alloc(32);
|
|
||||||
post.writeUInt32BE(0x00030000, 0);
|
|
||||||
keptTables.push({ bytes: post, length: post.length, offset: 0, recordOffset: 0, tag: "post" });
|
|
||||||
}
|
|
||||||
return rebuildSfnt(bytes, keptTables);
|
|
||||||
}
|
|
||||||
|
|
||||||
function filesBelow(root) {
|
|
||||||
const files = [];
|
|
||||||
const visit = (directory) => {
|
|
||||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
||||||
if (entry.name === "__MACOSX" || entry.name === ".DS_Store" || entry.name.startsWith("._")) continue;
|
|
||||||
const path = join(directory, entry.name);
|
|
||||||
if (entry.isDirectory()) visit(path);
|
|
||||||
else if (entry.isFile()) files.push(path);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if (existsSync(root)) visit(root);
|
|
||||||
return files.toSorted((left, right) => left.localeCompare(right));
|
|
||||||
}
|
|
||||||
|
|
||||||
function assetFileType(path) {
|
|
||||||
const extension = extname(path).toLowerCase();
|
|
||||||
if ([".manifest", ".mat", ".png", ".prefab", ".sprite"].includes(extension)) return extension.slice(1);
|
|
||||||
const bytes = readFileSync(path);
|
|
||||||
if (bytes.length >= 24 && bytes.readUInt32BE(12) === 0x49484452) return "png";
|
|
||||||
if (bytes[0] === 0x7b) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(bytes.toString("utf8"));
|
|
||||||
if (["Sprite", "Prefab", "Material"].includes(parsed?.typeId)) return String(parsed.typeId).toLocaleLowerCase("en-US");
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readJson(path) {
|
|
||||||
return JSON.parse(readFileSync(path, "utf8"));
|
|
||||||
}
|
|
||||||
|
|
||||||
function stemKey(path) {
|
|
||||||
return basename(path, extname(path)).normalize("NFKC").toLocaleLowerCase("en-US").replaceAll(/[^\p{L}\p{N}]+/gu, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function resourceStemKey(path, type) {
|
|
||||||
const name = basename(path);
|
|
||||||
const extension = extname(name).toLowerCase();
|
|
||||||
const stem = extension === `.${type}` ? basename(name, extension) : name.replace(new RegExp(`_${type}$`, "i"), "");
|
|
||||||
return stem.normalize("NFKC").toLocaleLowerCase("en-US").replaceAll(/[^\p{L}\p{N}]+/gu, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function zipEntries(path) {
|
|
||||||
const archive = readFileSync(path);
|
|
||||||
let eocd = -1;
|
|
||||||
for (let index = archive.length - 22; index >= Math.max(0, archive.length - 65_557); index -= 1) {
|
|
||||||
if (archive.readUInt32LE(index) === 0x06054b50) {
|
|
||||||
eocd = index;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (eocd < 0) throw new Error(`text_font_zip_invalid:${basename(path)}`);
|
|
||||||
const totalEntries = archive.readUInt16LE(eocd + 10);
|
|
||||||
let cursor = archive.readUInt32LE(eocd + 16);
|
|
||||||
const entries = [];
|
|
||||||
for (let index = 0; index < totalEntries; index += 1) {
|
|
||||||
if (archive.readUInt32LE(cursor) !== 0x02014b50) throw new Error(`text_font_zip_directory_invalid:${basename(path)}`);
|
|
||||||
const compression = archive.readUInt16LE(cursor + 10);
|
|
||||||
const compressedSize = archive.readUInt32LE(cursor + 20);
|
|
||||||
const uncompressedSize = archive.readUInt32LE(cursor + 24);
|
|
||||||
const nameLength = archive.readUInt16LE(cursor + 28);
|
|
||||||
const extraLength = archive.readUInt16LE(cursor + 30);
|
|
||||||
const commentLength = archive.readUInt16LE(cursor + 32);
|
|
||||||
const localOffset = archive.readUInt32LE(cursor + 42);
|
|
||||||
const name = archive.subarray(cursor + 46, cursor + 46 + nameLength).toString("utf8").replaceAll("\\", "/");
|
|
||||||
if (name.startsWith("/") || name.split("/").includes("..")) throw new Error(`text_font_zip_path_invalid:${basename(path)}`);
|
|
||||||
if (!name.endsWith("/")) {
|
|
||||||
if (archive.readUInt32LE(localOffset) !== 0x04034b50) throw new Error(`text_font_zip_entry_invalid:${basename(path)}`);
|
|
||||||
const localNameLength = archive.readUInt16LE(localOffset + 26);
|
|
||||||
const localExtraLength = archive.readUInt16LE(localOffset + 28);
|
|
||||||
const dataOffset = localOffset + 30 + localNameLength + localExtraLength;
|
|
||||||
const compressed = archive.subarray(dataOffset, dataOffset + compressedSize);
|
|
||||||
const bytes = compression === 0 ? Buffer.from(compressed)
|
|
||||||
: compression === 8 ? inflateRawSync(compressed)
|
|
||||||
: undefined;
|
|
||||||
if (!bytes || bytes.length !== uncompressedSize) throw new Error(`text_font_zip_compression_invalid:${basename(path)}`);
|
|
||||||
entries.push({ bytes, name });
|
|
||||||
}
|
|
||||||
cursor += 46 + nameLength + extraLength + commentLength;
|
|
||||||
}
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
function browserFontResource(templateDirectory, templateId, sourceReference, index) {
|
|
||||||
const sourcePath = join(templateDirectory, ...sourceReference.split("/"));
|
|
||||||
if (!existsSync(sourcePath) || !statSync(sourcePath).isFile()) throw new Error(`text_font_source_missing:${templateId}:${index}`);
|
|
||||||
const assetId = `TEXT-FONT-${templateId}-${String(index + 1).padStart(2, "0")}`;
|
|
||||||
const directExtension = extname(sourcePath).toLowerCase();
|
|
||||||
if (fontMimeTypes.has(directExtension)) {
|
|
||||||
return {
|
|
||||||
assetId,
|
|
||||||
extension: directExtension,
|
|
||||||
mimeType: fontMimeTypes.get(directExtension),
|
|
||||||
names: [basename(sourcePath).toLocaleLowerCase("en-US")],
|
|
||||||
sourcePath,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const extracted = filesBelow(dirname(sourcePath)).filter((path) => fontMimeTypes.has(extname(path).toLowerCase()));
|
|
||||||
if (extracted.length === 1) {
|
|
||||||
const extension = extname(extracted[0]).toLowerCase();
|
|
||||||
return {
|
|
||||||
assetId,
|
|
||||||
extension,
|
|
||||||
mimeType: fontMimeTypes.get(extension),
|
|
||||||
names: [basename(extracted[0]).toLocaleLowerCase("en-US"), basename(sourcePath, directExtension).toLocaleLowerCase("en-US")],
|
|
||||||
sourcePath: extracted[0],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const archivedFonts = zipEntries(sourcePath).filter((entry) => fontMimeTypes.has(extname(entry.name).toLowerCase()));
|
|
||||||
if (archivedFonts.length !== 1) throw new Error(`text_font_archive_ambiguous:${templateId}:${index}`);
|
|
||||||
const archived = archivedFonts[0];
|
|
||||||
const extension = extname(archived.name).toLowerCase();
|
|
||||||
return {
|
|
||||||
assetId,
|
|
||||||
extension,
|
|
||||||
mimeType: fontMimeTypes.get(extension),
|
|
||||||
names: [basename(archived.name).toLocaleLowerCase("en-US"), basename(sourcePath, directExtension).toLocaleLowerCase("en-US")],
|
|
||||||
sourceBytes: archived.bytes,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function manifestFileMap(packageFiles) {
|
|
||||||
const filesByName = new Map();
|
|
||||||
const filesByAsciiIdentity = new Map();
|
|
||||||
for (const path of packageFiles) {
|
|
||||||
const key = basename(path).toLocaleLowerCase("en-US");
|
|
||||||
const values = filesByName.get(key) ?? [];
|
|
||||||
values.push(path);
|
|
||||||
filesByName.set(key, values);
|
|
||||||
const asciiIdentity = key.replaceAll(/[^a-z0-9]+/g, "");
|
|
||||||
const asciiValues = filesByAsciiIdentity.get(asciiIdentity) ?? [];
|
|
||||||
asciiValues.push(path);
|
|
||||||
filesByAsciiIdentity.set(asciiIdentity, asciiValues);
|
|
||||||
}
|
|
||||||
const mappings = new Map();
|
|
||||||
const spriteDefinitions = new Map();
|
|
||||||
const manifestEntries = [];
|
|
||||||
for (const path of packageFiles.filter((candidate) => extname(candidate).toLowerCase() === ".manifest")) {
|
|
||||||
let manifest;
|
|
||||||
try {
|
|
||||||
manifest = readJson(path);
|
|
||||||
} catch {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
for (const item of manifest.UUIDToFilePath ?? []) {
|
|
||||||
const uuid = item?.key?.value;
|
|
||||||
const fileName = item?.value?.fileName;
|
|
||||||
if (typeof uuid !== "string" || typeof fileName !== "string") continue;
|
|
||||||
manifestEntries.push({ directories: item.value.directories ?? [], fileName, uuid });
|
|
||||||
const candidate = join(dirname(path), ...(item.value.directories ?? []), fileName);
|
|
||||||
const normalizedName = basename(fileName).toLocaleLowerCase("en-US");
|
|
||||||
const asciiCandidates = filesByAsciiIdentity.get(normalizedName.replaceAll(/[^a-z0-9]+/g, "")) ?? [];
|
|
||||||
const resolved = existsSync(candidate) ? candidate
|
|
||||||
: filesByName.get(normalizedName)?.[0]
|
|
||||||
?? (asciiCandidates.length === 1 ? asciiCandidates[0] : undefined);
|
|
||||||
if (resolved) {
|
|
||||||
mappings.set(uuid, resolved);
|
|
||||||
if (assetFileType(resolved) === "sprite") spriteDefinitions.set(uuid, resolved);
|
|
||||||
}
|
|
||||||
else mappings.set(uuid, fileName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const actualImagesByUuid = new Map();
|
|
||||||
const actualSprites = [];
|
|
||||||
for (const spritePath of packageFiles.filter((candidate) => assetFileType(candidate) === "sprite")) {
|
|
||||||
let sprite;
|
|
||||||
try {
|
|
||||||
sprite = readJson(spritePath);
|
|
||||||
} catch {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const imageUuid = sprite?.object?.m_UUIDList?.[0]?.uuid;
|
|
||||||
const siblingImages = packageFiles.filter((candidate) => dirname(candidate) === dirname(spritePath) && assetFileType(candidate) === "png");
|
|
||||||
const imagePath = siblingImages.find((candidate) => resourceStemKey(candidate, "png") === resourceStemKey(spritePath, "sprite"))
|
|
||||||
?? (siblingImages.length === 1 ? siblingImages[0] : undefined);
|
|
||||||
if (typeof imageUuid === "string") actualSprites.push({ imageUuid, path: spritePath });
|
|
||||||
if (typeof imageUuid === "string" && imagePath) {
|
|
||||||
actualImagesByUuid.set(imageUuid, imagePath);
|
|
||||||
mappings.set(imageUuid, imagePath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const spriteEntry of manifestEntries.filter((entry) => extname(entry.fileName).toLowerCase() === ".sprite")) {
|
|
||||||
const imageEntry = manifestEntries.find((entry) => extname(entry.fileName).toLowerCase() === ".png"
|
|
||||||
&& stemKey(entry.fileName) === stemKey(spriteEntry.fileName)
|
|
||||||
&& JSON.stringify(entry.directories) === JSON.stringify(spriteEntry.directories));
|
|
||||||
const asciiIdentity = basename(spriteEntry.fileName).toLocaleLowerCase("en-US").replaceAll(/[^a-z0-9]+/g, "");
|
|
||||||
const matchingActualSprites = actualSprites.filter((entry) => basename(entry.path).toLocaleLowerCase("en-US").replaceAll(/[^a-z0-9]+/g, "") === asciiIdentity);
|
|
||||||
const matchingImageUuids = [...new Set(matchingActualSprites.map((entry) => entry.imageUuid))];
|
|
||||||
const inferredImageUuid = matchingImageUuids.length === 1 ? matchingImageUuids[0] : undefined;
|
|
||||||
const imagePath = imageEntry ? actualImagesByUuid.get(imageEntry.uuid) ?? mappings.get(imageEntry.uuid)
|
|
||||||
: inferredImageUuid ? actualImagesByUuid.get(inferredImageUuid) ?? mappings.get(inferredImageUuid)
|
|
||||||
: undefined;
|
|
||||||
const spriteDefinition = matchingActualSprites.length === 1 ? matchingActualSprites[0].path : mappings.get(spriteEntry.uuid);
|
|
||||||
if (typeof spriteDefinition === "string" && existsSync(spriteDefinition) && assetFileType(spriteDefinition) === "sprite") {
|
|
||||||
spriteDefinitions.set(spriteEntry.uuid, spriteDefinition);
|
|
||||||
}
|
|
||||||
if (typeof imagePath === "string" && existsSync(imagePath)) mappings.set(spriteEntry.uuid, imagePath);
|
|
||||||
}
|
|
||||||
return { mappings, spriteDefinitions };
|
|
||||||
}
|
|
||||||
|
|
||||||
function colorHex(value, fallback = "#111111") {
|
|
||||||
if (!value || typeof value !== "object") return fallback;
|
|
||||||
const channel = (name) => Math.max(0, Math.min(255, Math.round(Number(value[name] ?? 0) * 255))).toString(16).padStart(2, "0");
|
|
||||||
return `#${channel("r")}${channel("g")}${channel("b")}`.toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function textFillGradient(style) {
|
|
||||||
const colors = style?.vertexColor;
|
|
||||||
const topLeft = colors?.vertexColor0;
|
|
||||||
const topRight = colors?.vertexColor1;
|
|
||||||
const bottomLeft = colors?.vertexColor2;
|
|
||||||
const bottomRight = colors?.vertexColor3;
|
|
||||||
if (![topLeft, topRight, bottomLeft, bottomRight].every((color) => color && typeof color === "object")) return undefined;
|
|
||||||
const top = colorHex(topLeft);
|
|
||||||
const bottom = colorHex(bottomLeft);
|
|
||||||
return top === colorHex(topRight) && bottom === colorHex(bottomRight) && top !== bottom ? { bottom, top } : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function textGlyphColors(textMesh) {
|
|
||||||
const changes = textMesh?.m_textFormatController?.m_textFormatChange;
|
|
||||||
if (!Array.isArray(changes)) return undefined;
|
|
||||||
const colors = changes.filter((change) => (Number(change?.flags ?? 0) & 4) !== 0 && Number(change?.format?.color?.a ?? 0) > 0)
|
|
||||||
.map((change) => ({ color: colorHex(change.format.color), position: Number(change.startPosition) }))
|
|
||||||
.toSorted((left, right) => left.position - right.position);
|
|
||||||
if (colors.length === 0 || colors.some((entry, index) => entry.position !== index)) return undefined;
|
|
||||||
return colors.map((entry) => entry.color);
|
|
||||||
}
|
|
||||||
|
|
||||||
function quaternionDegrees(rotation) {
|
|
||||||
const z = Number(rotation?.z ?? 0);
|
|
||||||
const w = Number(rotation?.w ?? 1);
|
|
||||||
return Math.atan2(2 * w * z, 1 - 2 * z * z) * 180 / Math.PI;
|
|
||||||
}
|
|
||||||
|
|
||||||
function estimatedGlyphWidth(character, fontSize) {
|
|
||||||
if (/\s/u.test(character)) return fontSize * 0.34;
|
|
||||||
if (/^[A-Z]$/u.test(character)) return fontSize * 0.68;
|
|
||||||
if (/^[a-z0-9]$/u.test(character)) return fontSize * 0.56;
|
|
||||||
if (/^[.,:;!?@©()()\-_'’]$/u.test(character)) return fontSize * 0.42;
|
|
||||||
return fontSize;
|
|
||||||
}
|
|
||||||
|
|
||||||
function inferredTextContentSize(text, style) {
|
|
||||||
const fontSize = Math.max(1, Number(style.fontSize ?? 48));
|
|
||||||
const characterSpacing = Number(style.characterSpacing ?? 0);
|
|
||||||
const lineSpacing = Math.max(0.1, Number(style.lineSpacing ?? 1));
|
|
||||||
const lines = String(text ?? "").split("\n");
|
|
||||||
const width = Math.max(1, ...lines.map((line) => Array.from(line).reduce(
|
|
||||||
(total, character, index) => total + estimatedGlyphWidth(character, fontSize) + (index === 0 ? 0 : characterSpacing),
|
|
||||||
0,
|
|
||||||
)));
|
|
||||||
const height = fontSize + Math.max(0, lines.length - 1) * fontSize * lineSpacing;
|
|
||||||
return { height, width };
|
|
||||||
}
|
|
||||||
|
|
||||||
function textPathModel(object, transform, contentSize) {
|
|
||||||
const option = object?.m_textPathOption;
|
|
||||||
const info = option?.pk?.mPathInfo;
|
|
||||||
if (!Array.isArray(info?.mPathVerbs) || info.mPathVerbs.length === 0 || !Array.isArray(info?.mPoints)) return undefined;
|
|
||||||
const sourceAnchorX = Number(object.m_anchorPoint?.x ?? 0.5);
|
|
||||||
const sourceAnchorY = Number(object.m_anchorPoint?.y ?? 0.5);
|
|
||||||
const circle = info.mIsCircle === true;
|
|
||||||
const scaleX = Math.abs(transform.scaleX);
|
|
||||||
const scaleY = Math.abs(transform.scaleY);
|
|
||||||
return {
|
|
||||||
circle,
|
|
||||||
conic_weights: Array.isArray(info.mConicWeights) ? info.mConicWeights.map(Number) : [],
|
|
||||||
first_margin: Number(option.firstMargin ?? 0),
|
|
||||||
force_alignment: option.forceAlignment === true,
|
|
||||||
is_absolute_mode: option.isAbsoluteMode === true,
|
|
||||||
last_margin: Number(option.lastMargin ?? 0),
|
|
||||||
perpendicular: option.perpendicularToPath !== false,
|
|
||||||
points: info.mPoints.map((point) => ({
|
|
||||||
x: (Number(point?.x ?? 0) + (circle ? (0.5 - sourceAnchorX) * contentSize.width : -sourceAnchorX * contentSize.width)) * scaleX,
|
|
||||||
y: (-Number(point?.y ?? 0) + (circle ? (sourceAnchorY - 0.5) * contentSize.height : sourceAnchorY * contentSize.height)) * scaleY,
|
|
||||||
})),
|
|
||||||
reversed: option.reversedPath === true,
|
|
||||||
verbs: info.mPathVerbs.map(Number),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function spriteNinePatch(sprite) {
|
|
||||||
const object = sprite?.object;
|
|
||||||
if (Number(object?.m_type) !== 3) return undefined;
|
|
||||||
const sourceWidth = Number(object.m_width);
|
|
||||||
const sourceHeight = Number(object.m_height);
|
|
||||||
const left = Number(object.m_startW);
|
|
||||||
const rightEdge = Number(object.m_endW);
|
|
||||||
const top = Number(object.m_startH);
|
|
||||||
const bottomEdge = Number(object.m_endH);
|
|
||||||
if (![sourceWidth, sourceHeight, left, rightEdge, top, bottomEdge].every(Number.isFinite)
|
|
||||||
|| sourceWidth <= 0 || sourceHeight <= 0
|
|
||||||
|| left < 0 || rightEdge < left || rightEdge > sourceWidth
|
|
||||||
|| top < 0 || bottomEdge < top || bottomEdge > sourceHeight) return undefined;
|
|
||||||
return {
|
|
||||||
bottom: sourceHeight - bottomEdge,
|
|
||||||
left,
|
|
||||||
right: sourceWidth - rightEdge,
|
|
||||||
source_height: sourceHeight,
|
|
||||||
source_width: sourceWidth,
|
|
||||||
top,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function pngDimensions(path) {
|
|
||||||
const bytes = readFileSync(path);
|
|
||||||
if (bytes.length < 24 || bytes.readUInt32BE(12) !== 0x49484452) return undefined;
|
|
||||||
return { height: bytes.readUInt32BE(20), width: bytes.readUInt32BE(16) };
|
|
||||||
}
|
|
||||||
|
|
||||||
function firstMaterialTextureAssetId(renderer, input) {
|
|
||||||
const materialUuids = (renderer?.m_Materials ?? []).map((item) => item?.uuid?.uuid).filter((uuid) => typeof uuid === "string");
|
|
||||||
for (const materialUuid of materialUuids) {
|
|
||||||
const materialPath = input.manifestMappings.get(materialUuid);
|
|
||||||
if (typeof materialPath !== "string" || extname(materialPath).toLowerCase() !== ".mat" || !existsSync(materialPath)) continue;
|
|
||||||
let material;
|
|
||||||
try {
|
|
||||||
material = readJson(materialPath);
|
|
||||||
} catch {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const pending = [material];
|
|
||||||
while (pending.length > 0) {
|
|
||||||
const value = pending.pop();
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
pending.push(...value);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!value || typeof value !== "object") continue;
|
|
||||||
const textureUuid = value?.uuid?.uuid;
|
|
||||||
if (typeof textureUuid === "string") {
|
|
||||||
const texturePath = input.manifestMappings.get(textureUuid);
|
|
||||||
const assetId = typeof texturePath === "string" ? input.imageIds.get(texturePath) : undefined;
|
|
||||||
if (assetId) return assetId;
|
|
||||||
}
|
|
||||||
pending.push(...Object.values(value));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function prefabResolver(prefab) {
|
|
||||||
const instances = new Map();
|
|
||||||
for (const item of prefab?.instance_map ?? []) {
|
|
||||||
if (Number.isInteger(item?.instance_type) && Number.isInteger(item?.instance_id)) {
|
|
||||||
instances.set(`${item.instance_type}:${item.instance_id}`, item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const resolve = (value) => {
|
|
||||||
let current = value?.internalObject ?? value;
|
|
||||||
const visited = new Set();
|
|
||||||
while (current && typeof current === "object" && !current.object) {
|
|
||||||
if (current.internalObject) {
|
|
||||||
current = current.internalObject;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (current.inner_ptr) {
|
|
||||||
current = current.inner_ptr;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const key = Number.isInteger(current.instance_type) && Number.isInteger(current.instance_id)
|
|
||||||
? `${current.instance_type}:${current.instance_id}`
|
|
||||||
: undefined;
|
|
||||||
if (!key || visited.has(key) || !instances.has(key)) break;
|
|
||||||
visited.add(key);
|
|
||||||
current = instances.get(key);
|
|
||||||
}
|
|
||||||
if (current?.inner_ptr && !current.object) return resolve(current.inner_ptr);
|
|
||||||
return current;
|
|
||||||
};
|
|
||||||
return resolve;
|
|
||||||
}
|
|
||||||
|
|
||||||
function components(object, resolve) {
|
|
||||||
return (object?.m_Components ?? []).map(resolve).filter((item) => item?.object);
|
|
||||||
}
|
|
||||||
|
|
||||||
function prefabLayers(prefab, input) {
|
|
||||||
const layers = [];
|
|
||||||
let order = 0;
|
|
||||||
const resolve = prefabResolver(prefab);
|
|
||||||
const root = resolve(prefab?.object?.m_RootSo)?.object;
|
|
||||||
const visit = (wrapped, parent, ignorePosition = false, anchorXOverride) => {
|
|
||||||
const typed = resolve(wrapped);
|
|
||||||
const object = typed?.object;
|
|
||||||
if (!object) return;
|
|
||||||
const local = object.m_LocalTfrm ?? {};
|
|
||||||
const localPosition = local.m_Position ?? {};
|
|
||||||
const localScale = local.m_Scale ?? {};
|
|
||||||
// The first UI group may use a negative scale to bridge the source editor's
|
|
||||||
// coordinate system. Browser-space Y is already inverted below, so keeping
|
|
||||||
// that sign would mirror every glyph and decoration a second time.
|
|
||||||
const scaleX = parent.scaleX * (ignorePosition ? Math.abs(Number(localScale.x ?? 1)) : Number(localScale.x ?? 1));
|
|
||||||
const scaleY = parent.scaleY * (ignorePosition ? Math.abs(Number(localScale.y ?? 1)) : Number(localScale.y ?? 1));
|
|
||||||
const localX = ignorePosition ? 0 : Number(localPosition.x ?? 0) * parent.scaleX;
|
|
||||||
const localY = ignorePosition ? 0 : Number(localPosition.y ?? 0) * parent.scaleY;
|
|
||||||
const parentRadians = parent.rotation * Math.PI / 180;
|
|
||||||
const transform = {
|
|
||||||
alpha: parent.alpha * Math.max(0, Math.min(1, Number(object.m_CustomAlpha ?? 1))),
|
|
||||||
anchorX: Math.max(0, Math.min(1, Number(anchorXOverride ?? object.m_anchorPoint?.x ?? 0.5))),
|
|
||||||
anchorY: Math.max(0, Math.min(1, Number(object.m_anchorPoint?.y ?? 0.5))),
|
|
||||||
rotation: parent.rotation + quaternionDegrees(local.m_Rotation),
|
|
||||||
scaleX,
|
|
||||||
scaleY,
|
|
||||||
x: parent.x + localX * Math.cos(parentRadians) - localY * Math.sin(parentRadians),
|
|
||||||
y: parent.y + localX * Math.sin(parentRadians) + localY * Math.cos(parentRadians),
|
|
||||||
};
|
|
||||||
const nodeComponents = components(object, resolve);
|
|
||||||
const localUnderlines = [
|
|
||||||
...(parent.underlines ?? []),
|
|
||||||
...nodeComponents.filter((item) => item.typeId === "UnderLineBehavior").flatMap((item) => item.object?.m_UnderLineConfig ?? []),
|
|
||||||
];
|
|
||||||
transform.underlines = localUnderlines;
|
|
||||||
const textMesh = nodeComponents.find((item) => item.typeId === "TextMesh")?.object;
|
|
||||||
if (textMesh) {
|
|
||||||
const textRenderer = nodeComponents.find((item) => item.typeId === "TextRenderer")?.object;
|
|
||||||
const style = textMesh.m_fontStyleInfo ?? {};
|
|
||||||
const inferredSize = inferredTextContentSize(textMesh.m_text, style);
|
|
||||||
const contentSize = {
|
|
||||||
height: Number(object.m_contentSize?.height ?? 0) > 0 ? Number(object.m_contentSize.height) : inferredSize.height,
|
|
||||||
width: Number(object.m_contentSize?.width ?? 0) > 0 ? Number(object.m_contentSize.width) : inferredSize.width,
|
|
||||||
};
|
|
||||||
const textPath = textPathModel(object, transform, contentSize);
|
|
||||||
const fillGradient = textFillGradient(style);
|
|
||||||
const glyphColors = textGlyphColors(textMesh);
|
|
||||||
const outline = style.outlineInfo?.outlineSize > 0 ? style.outlineInfo : undefined;
|
|
||||||
const shadow = style.shadowInfos?.find((item) => Number(item?.offset?.x ?? 0) !== 0 || Number(item?.offset?.y ?? 0) !== 0);
|
|
||||||
const fontUuid = textMesh.m_font?.uuid?.uuid;
|
|
||||||
const fillPatternAssetId = firstMaterialTextureAssetId(textRenderer, input);
|
|
||||||
const sourceTextureLayout = textMesh.m_textureLayoutInfo;
|
|
||||||
const fillTextureLayout = fillPatternAssetId
|
|
||||||
&& Number(sourceTextureLayout?.rows) > 0
|
|
||||||
&& Number(sourceTextureLayout?.columns) > 0
|
|
||||||
&& Array.isArray(sourceTextureLayout?.idList)
|
|
||||||
&& sourceTextureLayout.idList.length > 0
|
|
||||||
? {
|
|
||||||
columns: Math.max(1, Number(sourceTextureLayout.columns)),
|
|
||||||
id_list: sourceTextureLayout.idList.map(Number),
|
|
||||||
rows: Math.max(1, Number(sourceTextureLayout.rows)),
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
layers.push({
|
|
||||||
align: Number(style.alignment ?? 0) === 2 ? "right" : Number(style.alignment ?? 0) === 1 ? "center" : "left",
|
|
||||||
...(transform.alpha === 1 ? {} : { alpha: transform.alpha }),
|
|
||||||
...(transform.anchorX === 0.5 ? {} : { anchor_x: transform.anchorX }),
|
|
||||||
...(transform.anchorY === 0.5 ? {} : { anchor_y: 1 - transform.anchorY }),
|
|
||||||
fill_color: colorHex(style.color),
|
|
||||||
...(fillPatternAssetId ? { fill_pattern_asset_id: fillPatternAssetId } : {}),
|
|
||||||
...(fillTextureLayout ? { fill_texture_layout: fillTextureLayout } : {}),
|
|
||||||
...(fillGradient ? { fill_gradient: fillGradient } : {}),
|
|
||||||
...(glyphColors ? { glyph_colors: glyphColors } : {}),
|
|
||||||
font_file: typeof fontUuid === "string" ? input.manifestMappings.get(fontUuid) : undefined,
|
|
||||||
font_md5: typeof object.m_fontMd5Value === "string" ? object.m_fontMd5Value.toLocaleLowerCase("en-US") : undefined,
|
|
||||||
font_size: Math.max(1, Number(style.fontSize ?? 48) * Math.abs(scaleY)),
|
|
||||||
height: Math.max(1, contentSize.height * Math.abs(scaleY)),
|
|
||||||
letter_spacing: Number(style.characterSpacing ?? 1),
|
|
||||||
line_height: Number(style.lineSpacing ?? 1),
|
|
||||||
order: order++,
|
|
||||||
rotation: -transform.rotation,
|
|
||||||
scale_x: Math.sign(scaleX) || 1,
|
|
||||||
scale_y: Math.sign(scaleY) || 1,
|
|
||||||
shadow_blur: Math.max(0, Number(shadow?.blur ?? shadow?.SDFFontBorder ?? 0)),
|
|
||||||
shadow_color: colorHex(shadow?.color, "#000000"),
|
|
||||||
shadow_offset_x: Number(shadow?.offset?.x ?? 0),
|
|
||||||
shadow_offset_y: -Number(shadow?.offset?.y ?? 0),
|
|
||||||
skew_x: Number(textMesh.m_skewValue?.x ?? 0),
|
|
||||||
skew_y: Number(textMesh.m_skewValue?.y ?? 0),
|
|
||||||
stroke_color: colorHex(outline?.outlineColor, "#000000"),
|
|
||||||
stroke_width: Math.max(0, Number(outline?.outlineSize ?? 0)),
|
|
||||||
text: String(textMesh.m_text ?? ""),
|
|
||||||
...(textPath ? { text_path: textPath } : {}),
|
|
||||||
type: "text",
|
|
||||||
vertical_align: Number(style.vAlignment ?? 1) === 0 ? "top"
|
|
||||||
: Number(style.vAlignment ?? 1) === 2 ? "bottom"
|
|
||||||
: "middle",
|
|
||||||
width: Math.max(1, contentSize.width * Math.abs(scaleX)),
|
|
||||||
x: transform.x,
|
|
||||||
y: -transform.y,
|
|
||||||
});
|
|
||||||
for (const underline of localUnderlines.filter((item) => item?.p1?.enable !== false && item?.p0 === object.m_Name)) {
|
|
||||||
const config = underline.p1?.exportParams ?? {};
|
|
||||||
const ninePatch = config.ninePatchInfos?.find((item) => item?.enable !== false && typeof item?.texture?.uuid?.uuid === "string");
|
|
||||||
const textureUuid = ninePatch?.texture?.uuid?.uuid ?? config.texture?.uuid?.uuid;
|
|
||||||
const texturePath = typeof textureUuid === "string" ? input.manifestMappings.get(textureUuid) : undefined;
|
|
||||||
const assetId = typeof texturePath === "string" ? input.imageIds.get(texturePath) : undefined;
|
|
||||||
if (!assetId || typeof texturePath !== "string") continue;
|
|
||||||
const dimensions = pngDimensions(texturePath);
|
|
||||||
const targetWidth = Math.max(1, Number(object.m_contentSize?.width ?? 1) * Math.abs(scaleX)
|
|
||||||
* (config.enableUnderLineSizeWithText === false ? 1 : Number(config.underLineSize ?? 100) / 100));
|
|
||||||
const naturalRatio = dimensions ? dimensions.height / Math.max(1, dimensions.width) : 0.15;
|
|
||||||
const targetHeight = Math.max(2, Math.min(Number(object.m_contentSize?.height ?? 48) * 0.65, targetWidth * naturalRatio));
|
|
||||||
layers.push({
|
|
||||||
...(transform.alpha === 1 ? {} : { alpha: transform.alpha }),
|
|
||||||
asset_id: assetId,
|
|
||||||
height: targetHeight,
|
|
||||||
order: order++,
|
|
||||||
rotation: -transform.rotation,
|
|
||||||
scale_x: Math.sign(scaleX) || 1,
|
|
||||||
scale_y: Math.sign(scaleY) || 1,
|
|
||||||
type: "image",
|
|
||||||
width: targetWidth,
|
|
||||||
x: transform.x,
|
|
||||||
y: -transform.y + Number(object.m_contentSize?.height ?? 48) * Math.abs(scaleY) / 2
|
|
||||||
+ Number(config.relativeDistance ?? 0) + targetHeight / 2,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
for (const particleComponent of nodeComponents.filter((item) => item.typeId === "ParticlesText2D").map((item) => item.object)) {
|
|
||||||
if (particleComponent?.m_isEnabled === false) continue;
|
|
||||||
const textureUuid = particleComponent?.m_altasTexUUID?.uuid;
|
|
||||||
const texturePath = typeof textureUuid === "string" ? input.manifestMappings.get(textureUuid) : undefined;
|
|
||||||
const assetId = typeof texturePath === "string" ? input.imageIds.get(texturePath) : undefined;
|
|
||||||
if (!assetId) continue;
|
|
||||||
layers.push({
|
|
||||||
alpha: transform.alpha * Math.max(0, Math.min(1, Number(particleComponent.m_AlphaIdensity ?? 1))),
|
|
||||||
anchor_x: transform.anchorX,
|
|
||||||
anchor_y: 1 - transform.anchorY,
|
|
||||||
asset_id: assetId,
|
|
||||||
atlas_columns: Math.max(1, Number(particleComponent.m_altasUcount ?? 1)),
|
|
||||||
atlas_rows: Math.max(1, Number(particleComponent.m_altasVcount ?? 1)),
|
|
||||||
color: colorHex(particleComponent.m_ParticleColor, "#FFFFFF"),
|
|
||||||
density: Math.max(1, Number(particleComponent.m_particlesDensity ?? 1)),
|
|
||||||
height: Math.max(1, contentSize.height * Math.abs(scaleY)),
|
|
||||||
order: order++,
|
|
||||||
particle_height: Math.max(1, Number(particleComponent.m_particlesRenderSize?.y ?? 8)),
|
|
||||||
particle_width: Math.max(1, Number(particleComponent.m_particlesRenderSize?.x ?? 8)),
|
|
||||||
randomize_angle: Number(particleComponent.m_particlesRandomizeAngle ?? 0),
|
|
||||||
randomize_position: Number(particleComponent.m_particlesRandomizePosition ?? 0),
|
|
||||||
rotation: -transform.rotation,
|
|
||||||
scale_x: Math.sign(scaleX) || 1,
|
|
||||||
scale_y: Math.sign(scaleY) || 1,
|
|
||||||
type: "particles",
|
|
||||||
width: Math.max(1, contentSize.width * Math.abs(scaleX)),
|
|
||||||
x: transform.x,
|
|
||||||
y: -transform.y,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const spriteRenderer = nodeComponents.find((item) => item.typeId === "SpriteRenderer")?.object;
|
|
||||||
if (spriteRenderer && spriteRenderer.m_isEnabled !== false) {
|
|
||||||
const spriteUuid = spriteRenderer?.m_sprite?.uuid?.uuid;
|
|
||||||
const spritePath = typeof spriteUuid === "string" ? input.manifestMappings.get(spriteUuid) : undefined;
|
|
||||||
const spriteDefinitionPath = typeof spriteUuid === "string" ? input.spriteDefinitions.get(spriteUuid) : undefined;
|
|
||||||
let imagePath;
|
|
||||||
let ninePatch;
|
|
||||||
if (typeof spriteDefinitionPath === "string" && existsSync(spriteDefinitionPath)) {
|
|
||||||
const sprite = readJson(spriteDefinitionPath);
|
|
||||||
const imageUuid = sprite?.object?.m_UUIDList?.[0]?.uuid;
|
|
||||||
imagePath = typeof spritePath === "string" && assetFileType(spritePath) === "png"
|
|
||||||
? spritePath
|
|
||||||
: typeof imageUuid === "string" ? input.manifestMappings.get(imageUuid) : undefined;
|
|
||||||
ninePatch = spriteNinePatch(sprite);
|
|
||||||
} else if (typeof spritePath === "string" && assetFileType(spritePath) === "sprite" && existsSync(spritePath)) {
|
|
||||||
const sprite = readJson(spritePath);
|
|
||||||
const imageUuid = sprite?.object?.m_UUIDList?.[0]?.uuid;
|
|
||||||
if (typeof imageUuid === "string") imagePath = input.manifestMappings.get(imageUuid);
|
|
||||||
ninePatch = spriteNinePatch(sprite);
|
|
||||||
} else if (typeof spritePath === "string" && assetFileType(spritePath) === "png") imagePath = spritePath;
|
|
||||||
const assetId = typeof imagePath === "string" ? input.imageIds.get(imagePath) : undefined;
|
|
||||||
if (assetId) {
|
|
||||||
layers.push({
|
|
||||||
...(transform.alpha === 1 ? {} : { alpha: transform.alpha }),
|
|
||||||
...(transform.anchorX === 0.5 ? {} : { anchor_x: transform.anchorX }),
|
|
||||||
...(transform.anchorY === 0.5 ? {} : { anchor_y: 1 - transform.anchorY }),
|
|
||||||
asset_id: assetId,
|
|
||||||
height: Math.max(1, Number(object.m_contentSize?.height ?? 1) * Math.abs(scaleY)),
|
|
||||||
order: order++,
|
|
||||||
rotation: -transform.rotation,
|
|
||||||
scale_x: Math.sign(scaleX) || 1,
|
|
||||||
scale_y: Math.sign(scaleY) || 1,
|
|
||||||
type: "image",
|
|
||||||
width: Math.max(1, Number(object.m_contentSize?.width ?? 1) * Math.abs(scaleX)),
|
|
||||||
x: transform.x,
|
|
||||||
y: -transform.y,
|
|
||||||
...(ninePatch ? { nine_patch: ninePatch } : {}),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
input.unresolvedImages.push({ spriteUuid, spritePath });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const children = (object.m_Children ?? []).map((child) => ({ child, typed: resolve(child) }));
|
|
||||||
// Linked text backgrounds can store their image position at the text box's left edge.
|
|
||||||
const linkedTextLeftEdges = children
|
|
||||||
.filter(({ typed }) => typed?.typeId === "TextView" && Number(typed.object?.m_UseLink ?? 0) === 1)
|
|
||||||
.map(({ typed }) => {
|
|
||||||
const textObject = typed.object;
|
|
||||||
return Number(textObject.m_LocalTfrm?.m_Position?.x ?? 0)
|
|
||||||
- Number(textObject.m_anchorPoint?.x ?? 0.5) * Number(textObject.m_contentSize?.width ?? 0);
|
|
||||||
});
|
|
||||||
for (const { child, typed } of children) {
|
|
||||||
const childX = Number(typed?.object?.m_LocalTfrm?.m_Position?.x ?? 0);
|
|
||||||
const usesLinkedTextLeftEdge = typed?.typeId === "ImageView"
|
|
||||||
&& linkedTextLeftEdges.some((leftEdge) => Math.abs(childX - leftEdge) < 0.01);
|
|
||||||
visit(child, transform, false, usesLinkedTextLeftEdge ? 0 : undefined);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
for (const child of root?.m_Children ?? []) visit(child, { alpha: 1, rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, true);
|
|
||||||
return layers;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fontIdForFile(fontResources, file, contentMd5) {
|
|
||||||
if (typeof contentMd5 === "string") {
|
|
||||||
const hashMatch = fontResources.find((resource) => resource.contentMd5 === contentMd5.toLocaleLowerCase("en-US"));
|
|
||||||
if (hashMatch) return hashMatch.assetId;
|
|
||||||
}
|
|
||||||
if (typeof file !== "string") return fontResources[0]?.assetId;
|
|
||||||
const name = basename(file).toLocaleLowerCase("en-US");
|
|
||||||
return fontResources.find((resource) => resource.names.includes(name))?.assetId ?? fontResources[0]?.assetId;
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultText(metadata, layers) {
|
|
||||||
const candidates = [metadata.default_text, metadata.runtime?.layer?.default_text, ...(metadata.runtime?.human_strings ?? [])]
|
|
||||||
.filter((value) => typeof value === "string" && value.trim());
|
|
||||||
return String(candidates[0] ?? layers.find((layer) => layer.type === "text" && layer.text.trim())?.text ?? metadata.display_name ?? metadata.canonical_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeModel(layers, defaultValue, fontResources) {
|
|
||||||
const textLayers = layers.filter((layer) => layer.type === "text");
|
|
||||||
const primary = textLayers.find((layer) => layer.text.trim().toLocaleLowerCase("zh-CN") === defaultValue.trim().toLocaleLowerCase("zh-CN")) ?? textLayers[0];
|
|
||||||
if (!primary) return undefined;
|
|
||||||
for (const layer of textLayers) {
|
|
||||||
layer.content_linked = layer.text === primary.text;
|
|
||||||
layer.editable = layer === primary;
|
|
||||||
layer.font_id = fontIdForFile(fontResources, layer.font_file, layer.font_md5);
|
|
||||||
delete layer.font_file;
|
|
||||||
delete layer.font_md5;
|
|
||||||
}
|
|
||||||
const bounds = layers.map((layer) => {
|
|
||||||
const anchorX = Number(layer.anchor_x ?? 0.5);
|
|
||||||
const anchorY = Number(layer.anchor_y ?? 0.5);
|
|
||||||
const radians = Number(layer.rotation ?? 0) * Math.PI / 180;
|
|
||||||
const cosine = Math.cos(radians);
|
|
||||||
const sine = Math.sin(radians);
|
|
||||||
const corners = [
|
|
||||||
[-anchorX * layer.width, -anchorY * layer.height],
|
|
||||||
[(1 - anchorX) * layer.width, -anchorY * layer.height],
|
|
||||||
[-anchorX * layer.width, (1 - anchorY) * layer.height],
|
|
||||||
[(1 - anchorX) * layer.width, (1 - anchorY) * layer.height],
|
|
||||||
].map(([x, y]) => ({
|
|
||||||
x: layer.x + x * Number(layer.scale_x ?? 1) * cosine - y * Number(layer.scale_y ?? 1) * sine,
|
|
||||||
y: layer.y + x * Number(layer.scale_x ?? 1) * sine + y * Number(layer.scale_y ?? 1) * cosine,
|
|
||||||
}));
|
|
||||||
return {
|
|
||||||
bottom: Math.max(...corners.map((item) => item.y)),
|
|
||||||
left: Math.min(...corners.map((item) => item.x)),
|
|
||||||
right: Math.max(...corners.map((item) => item.x)),
|
|
||||||
top: Math.min(...corners.map((item) => item.y)),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const left = Math.min(...bounds.map((item) => item.left));
|
|
||||||
const right = Math.max(...bounds.map((item) => item.right));
|
|
||||||
const top = Math.min(...bounds.map((item) => item.top));
|
|
||||||
const bottom = Math.max(...bounds.map((item) => item.bottom));
|
|
||||||
const centerX = (left + right) / 2;
|
|
||||||
const centerY = (top + bottom) / 2;
|
|
||||||
const normalization = Math.min(1, 360 / Math.max(1, right - left), 260 / Math.max(1, bottom - top));
|
|
||||||
for (const layer of layers) {
|
|
||||||
layer.x = Number(((layer.x - centerX) * normalization).toFixed(3));
|
|
||||||
layer.y = Number(((layer.y - centerY) * normalization).toFixed(3));
|
|
||||||
layer.width = Number((layer.width * normalization).toFixed(3));
|
|
||||||
layer.height = Number((layer.height * normalization).toFixed(3));
|
|
||||||
if (layer.type === "text") {
|
|
||||||
layer.font_size = Number((layer.font_size * normalization).toFixed(3));
|
|
||||||
layer.letter_spacing = Number((layer.letter_spacing * normalization).toFixed(3));
|
|
||||||
layer.stroke_width = Number((layer.stroke_width * normalization).toFixed(3));
|
|
||||||
layer.shadow_blur = Number((layer.shadow_blur * normalization).toFixed(3));
|
|
||||||
layer.shadow_offset_x = Number((layer.shadow_offset_x * normalization).toFixed(3));
|
|
||||||
layer.shadow_offset_y = Number((layer.shadow_offset_y * normalization).toFixed(3));
|
|
||||||
if (layer.text_path) {
|
|
||||||
layer.text_path.points = layer.text_path.points.map((point) => ({
|
|
||||||
x: Number((point.x * normalization).toFixed(3)),
|
|
||||||
y: Number((point.y * normalization).toFixed(3)),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
} else if (layer.type === "image" && layer.nine_patch) {
|
|
||||||
layer.nine_patch.left = Number((layer.nine_patch.left * normalization).toFixed(3));
|
|
||||||
layer.nine_patch.right = Number((layer.nine_patch.right * normalization).toFixed(3));
|
|
||||||
layer.nine_patch.top = Number((layer.nine_patch.top * normalization).toFixed(3));
|
|
||||||
layer.nine_patch.bottom = Number((layer.nine_patch.bottom * normalization).toFixed(3));
|
|
||||||
} else if (layer.type === "particles") {
|
|
||||||
layer.particle_height = Number((layer.particle_height * normalization).toFixed(3));
|
|
||||||
layer.particle_width = Number((layer.particle_width * normalization).toFixed(3));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
half_size: {
|
|
||||||
height: Number(((bottom - top) * normalization / 2).toFixed(3)),
|
|
||||||
width: Number(((right - left) * normalization / 2).toFixed(3)),
|
|
||||||
},
|
|
||||||
image_layers: layers.filter((layer) => layer.type === "image"),
|
|
||||||
particle_layers: layers.filter((layer) => layer.type === "particles"),
|
|
||||||
text_layers: textLayers,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function compileTextTemplateAssets({ templateDirectory, templateId }) {
|
|
||||||
const metadata = readJson(join(templateDirectory, "metadata.json"));
|
|
||||||
const fontResources = (metadata.files?.fonts ?? []).map((reference, index) => {
|
|
||||||
const resource = browserFontResource(templateDirectory, templateId, reference, index);
|
|
||||||
const normalized = normalizeBrowserFontBytes(resource.sourceBytes ?? readFileSync(resource.sourcePath));
|
|
||||||
const browserBytes = normalized ?? resource.sourceBytes ?? readFileSync(resource.sourcePath);
|
|
||||||
return {
|
|
||||||
...resource,
|
|
||||||
contentMd5: createHash("md5").update(browserBytes).digest("hex"),
|
|
||||||
...(normalized ? { sourceBytes: normalized, sourcePath: undefined } : {}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
if (fontResources.length === 0) throw new Error(`text_template_font_missing:${templateId}`);
|
|
||||||
const packageRoot = join(templateDirectory, "package");
|
|
||||||
const packageFiles = filesBelow(packageRoot);
|
|
||||||
const imagePaths = packageFiles.filter((path) => assetFileType(path) === "png");
|
|
||||||
const imageResources = imagePaths.map((sourcePath, index) => ({
|
|
||||||
assetId: `TEXT-IMAGE-${templateId}-${String(index + 1).padStart(3, "0")}`,
|
|
||||||
extension: ".png",
|
|
||||||
mimeType: "image/png",
|
|
||||||
sourcePath,
|
|
||||||
}));
|
|
||||||
const imageIds = new Map(imagePaths.map((path, index) => [path, imageResources[index].assetId]));
|
|
||||||
const { mappings: manifestMappings, spriteDefinitions } = manifestFileMap(packageFiles);
|
|
||||||
const unresolvedImages = [];
|
|
||||||
const prefabCandidates = packageFiles.filter((path) => extname(path).toLowerCase() === ".prefab").flatMap((path) => {
|
|
||||||
let prefab;
|
|
||||||
try {
|
|
||||||
prefab = readJson(path);
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const layers = prefabLayers(prefab, { imageIds, manifestMappings, spriteDefinitions, unresolvedImages });
|
|
||||||
const texts = layers.filter((layer) => layer.type === "text").map((layer) => layer.text.trim().toLocaleLowerCase("zh-CN"));
|
|
||||||
const expected = [metadata.default_text, metadata.runtime?.layer?.default_text, ...(metadata.runtime?.human_strings ?? [])]
|
|
||||||
.filter((value) => typeof value === "string" && value.trim()).map((value) => value.trim().toLocaleLowerCase("zh-CN"));
|
|
||||||
const match = expected.some((value) => texts.includes(value));
|
|
||||||
return [{ layers, path, score: (match ? 10_000 : 0) + texts.length * 100 + layers.length }];
|
|
||||||
}).filter((candidate) => candidate.layers.some((layer) => layer.type === "text"));
|
|
||||||
const chosen = prefabCandidates.toSorted((left, right) => right.score - left.score || left.path.localeCompare(right.path))[0];
|
|
||||||
const value = defaultText(metadata, chosen?.layers ?? []);
|
|
||||||
const fallbackLayers = [{
|
|
||||||
align: "center",
|
|
||||||
editable: true,
|
|
||||||
fill_color: "#111111",
|
|
||||||
font_id: fontResources[0].assetId,
|
|
||||||
font_size: 48,
|
|
||||||
height: 58,
|
|
||||||
letter_spacing: 1,
|
|
||||||
line_height: 1.2,
|
|
||||||
order: 0,
|
|
||||||
rotation: 0,
|
|
||||||
scale_x: 1,
|
|
||||||
scale_y: 1,
|
|
||||||
shadow_blur: 0,
|
|
||||||
shadow_color: "#000000",
|
|
||||||
shadow_offset_x: 0,
|
|
||||||
shadow_offset_y: 0,
|
|
||||||
stroke_color: "#000000",
|
|
||||||
stroke_width: 0,
|
|
||||||
text: value,
|
|
||||||
type: "text",
|
|
||||||
vertical_align: "middle",
|
|
||||||
width: Math.max(96, Array.from(value).length * 52),
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
}];
|
|
||||||
const renderModel = normalizeModel(chosen?.layers ?? fallbackLayers, value, fontResources);
|
|
||||||
if (!renderModel || renderModel.text_layers.some((layer) => !layer.font_id)) throw new Error(`text_template_render_model_invalid:${templateId}`);
|
|
||||||
return {
|
|
||||||
catalog: {
|
|
||||||
default_font_id: renderModel.text_layers.find((layer) => layer.editable)?.font_id ?? fontResources[0].assetId,
|
|
||||||
default_font_size: renderModel.text_layers.find((layer) => layer.editable)?.font_size ?? 48,
|
|
||||||
default_text: value,
|
|
||||||
font_match_status: "template_package",
|
|
||||||
render_model: renderModel,
|
|
||||||
},
|
|
||||||
diagnostics: {
|
|
||||||
package_images: imageResources.length,
|
|
||||||
prefab_candidates: prefabCandidates.length,
|
|
||||||
unresolved_images: unresolvedImages.length,
|
|
||||||
},
|
|
||||||
resources: [...fontResources, ...imageResources].map(({ contentMd5: _contentMd5, names: _names, ...resource }) => resource),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -112,7 +112,7 @@ export function validateWp5FinalManifest(path) {
|
|||||||
const raw = readFileSync(path, "utf8");
|
const raw = readFileSync(path, "utf8");
|
||||||
if (raw.includes(WP4_07_RED_RESOURCE_VERSION) || raw.includes("fixture-v1")) throw new Error("WP4_07_PLACEHOLDER_ASSET_REJECTED");
|
if (raw.includes(WP4_07_RED_RESOURCE_VERSION) || raw.includes("fixture-v1")) throw new Error("WP4_07_PLACEHOLDER_ASSET_REJECTED");
|
||||||
const manifest = JSON.parse(raw);
|
const manifest = JSON.parse(raw);
|
||||||
const expectedCounts = { color_cards: 16, dynamic_stickers: 35, font_panel_items: 86, static_parts: 25, static_stickers: 1_407, text_templates: 332 };
|
const expectedCounts = { color_cards: 4, dynamic_stickers: 10, font_panel_items: 11, static_parts: 25, static_stickers: 1_407, text_templates: 32 };
|
||||||
for (const [key, expected] of Object.entries(expectedCounts)) {
|
for (const [key, expected] of Object.entries(expectedCounts)) {
|
||||||
if (manifest.counts?.[key] !== expected) throw new Error(`WP4_07_FINAL_MANIFEST_COUNT_MISMATCH:${key}`);
|
if (manifest.counts?.[key] !== expected) throw new Error(`WP4_07_FINAL_MANIFEST_COUNT_MISMATCH:${key}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ function findFiles(directory, name) {
|
|||||||
|
|
||||||
if (phase === "green") {
|
if (phase === "green") {
|
||||||
const traces = findFiles(outputDirectory, "trace.zip");
|
const traces = findFiles(outputDirectory, "trace.zip");
|
||||||
const whiteTrace = traces.find((path) => path.toLowerCase().includes("wp5-white-001") || path.toLowerCase().includes("complete-complex-asset-catalog"));
|
const whiteTrace = traces.find((path) => path.toLowerCase().includes("wp5-white-001") || path.toLowerCase().includes("p0-a-public-allowlist"));
|
||||||
const colorTrace = traces.find((path) => path.toLowerCase().includes("wp5-col-001") || path.toLowerCase().includes("shared-five-color"));
|
const colorTrace = traces.find((path) => path.toLowerCase().includes("wp5-col-001") || path.toLowerCase().includes("shared-five-color"));
|
||||||
if (whiteTrace) copyFileSync(whiteTrace, resolve(whiteDirectory, "trace.zip"));
|
if (whiteTrace) copyFileSync(whiteTrace, resolve(whiteDirectory, "trace.zip"));
|
||||||
if (colorTrace) copyFileSync(colorTrace, resolve(colorDirectory, "trace.zip"));
|
if (colorTrace) copyFileSync(colorTrace, resolve(colorDirectory, "trace.zip"));
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { createServer } from "node:http";
|
|
||||||
import { isAbsolute, join, resolve } from "node:path";
|
|
||||||
|
|
||||||
import { chromium } from "@playwright/test";
|
|
||||||
|
|
||||||
function option(name) {
|
|
||||||
const index = process.argv.indexOf(name);
|
|
||||||
return index >= 0 ? process.argv[index + 1] : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultConfigPath() {
|
|
||||||
if (!process.env.LOCALAPPDATA || !isAbsolute(process.env.LOCALAPPDATA)) {
|
|
||||||
throw new Error("local_app_data_unavailable");
|
|
||||||
}
|
|
||||||
return join(process.env.LOCALAPPDATA, "Dada", "P0A", "config", "instance.json");
|
|
||||||
}
|
|
||||||
|
|
||||||
const manifestPath = resolve(option("--manifest") ?? "config/runtime-assets-manifest.json");
|
|
||||||
const configPath = resolve(option("--config") ?? process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultConfigPath());
|
|
||||||
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
||||||
const configuration = JSON.parse(readFileSync(configPath, "utf8"));
|
|
||||||
if (typeof configuration.asset_root !== "string" || !isAbsolute(configuration.asset_root)) {
|
|
||||||
throw new Error("asset_root_configuration_invalid");
|
|
||||||
}
|
|
||||||
|
|
||||||
const fontEntries = manifest.entries
|
|
||||||
.filter((entry) => entry.assetId.startsWith("TEXT-FONT-") && entry.mimeType.startsWith("font/"))
|
|
||||||
.toSorted((left, right) => left.assetId.localeCompare(right.assetId));
|
|
||||||
const uniqueEntries = [...new Map(fontEntries.map((entry) => [entry.sha256, entry])).values()];
|
|
||||||
const fontPaths = new Map(
|
|
||||||
uniqueEntries.map((entry) => [
|
|
||||||
`/${encodeURIComponent(entry.assetId)}`,
|
|
||||||
{ mimeType: entry.mimeType, path: join(configuration.asset_root, entry.relativePath) },
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const server = createServer((request, response) => {
|
|
||||||
const font = fontPaths.get(request.url ?? "");
|
|
||||||
if (!font) {
|
|
||||||
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
||||||
response.end("<!doctype html><title>Dada browser font validation</title>");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const bytes = readFileSync(font.path);
|
|
||||||
response.writeHead(200, { "Content-Length": bytes.length, "Content-Type": font.mimeType });
|
|
||||||
response.end(bytes);
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise((resolveReady) => server.listen(0, "127.0.0.1", resolveReady));
|
|
||||||
const address = server.address();
|
|
||||||
if (!address || typeof address === "string") throw new Error("browser_font_probe_server_unavailable");
|
|
||||||
const origin = `http://127.0.0.1:${address.port}`;
|
|
||||||
const browser = await chromium.launch({ channel: option("--channel") ?? "msedge", headless: true });
|
|
||||||
const failures = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
let page = await browser.newPage();
|
|
||||||
await page.goto(origin);
|
|
||||||
for (let index = 0; index < uniqueEntries.length; index += 1) {
|
|
||||||
if (index > 0 && index % 25 === 0) {
|
|
||||||
await page.close();
|
|
||||||
page = await browser.newPage();
|
|
||||||
await page.goto(origin);
|
|
||||||
}
|
|
||||||
const entry = uniqueEntries[index];
|
|
||||||
const result = await page.evaluate(async ({ family, url }) => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(url);
|
|
||||||
if (!response.ok) return `http_${response.status}`;
|
|
||||||
await new FontFace(family, await response.arrayBuffer()).load();
|
|
||||||
return "loaded";
|
|
||||||
} catch (error) {
|
|
||||||
return error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
|
||||||
}
|
|
||||||
}, { family: `DadaFontProbe${index}`, url: `${origin}/${encodeURIComponent(entry.assetId)}` });
|
|
||||||
if (result !== "loaded") failures.push({ asset_id: entry.assetId, error: result });
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
await new Promise((resolveClosed) => server.close(resolveClosed));
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = {
|
|
||||||
checked_entries: fontEntries.length,
|
|
||||||
failed_fonts: failures,
|
|
||||||
status: failures.length === 0 ? "passed" : "failed",
|
|
||||||
unique_fonts: uniqueEntries.length,
|
|
||||||
};
|
|
||||||
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
||||||
if (failures.length > 0) process.exitCode = 1;
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { execFileSync } from "node:child_process";
|
|
||||||
|
|
||||||
const requiredSubjects = [
|
|
||||||
"fix(POSTV1-07): 固定画布布局与文字贴纸选择",
|
|
||||||
"fix(POSTV1-07): 自动关闭编辑器操作提示",
|
|
||||||
"fix(POSTV1-08): 修复文字拖动闪烁与自动保存",
|
|
||||||
"fix(POSTV1-09): 展示项目生成图片",
|
|
||||||
"feat(POSTV1-10): 增加统一交互反馈",
|
|
||||||
"fix(POSTV1-11): 修复生成提交的会话令牌轮换",
|
|
||||||
"fix(POSTV1-runtime): 改善后台启动与状态窗口可见性",
|
|
||||||
"fix(POSTV1-browser): 同时支持 Chrome 150 与 151",
|
|
||||||
"fix(POSTV1-editor): 修复多选拖动与底图调整",
|
|
||||||
];
|
|
||||||
|
|
||||||
const subjects = new Set(execFileSync("git", ["log", "--format=%s", "HEAD"], { encoding: "utf8" })
|
|
||||||
.split(/\r?\n/u)
|
|
||||||
.filter(Boolean));
|
|
||||||
const missing = requiredSubjects.filter((subject) => !subjects.has(subject));
|
|
||||||
|
|
||||||
if (missing.length > 0) {
|
|
||||||
throw new Error(`postv1_ui_lineage_incomplete:${missing.join("|")}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(JSON.stringify({ required_commit_count: requiredSubjects.length, status: "passed" }));
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { readFileSync } from "node:fs";
|
|
||||||
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
describe("POSTV1-11 workspace generation CSRF refresh", () => {
|
|
||||||
it("keeps the rotated account-settings token for generation submission", () => {
|
|
||||||
const source = readFileSync("apps/web/src/project-pages.tsx", "utf8");
|
|
||||||
|
|
||||||
expect(source).toContain("csrf_token: string;");
|
|
||||||
expect(source).toContain("csrf_token: settings.csrf_token");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -32,7 +32,7 @@ function fixture() {
|
|||||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||||
};
|
};
|
||||||
const manifest = {
|
const manifest = {
|
||||||
counts: { dynamic_fonts: 0, dynamic_images: 0, font_panel_items: 0, static_stickers: 1, text_previews: 0 },
|
counts: { dynamic_fonts: 0, dynamic_images: 0, font_panel_items: 0, static_stickers: 1 },
|
||||||
entries: [entry],
|
entries: [entry],
|
||||||
root_ref: "p0a_runtime_assets",
|
root_ref: "p0a_runtime_assets",
|
||||||
schema_version: "DadaRuntimeAssets/v1",
|
schema_version: "DadaRuntimeAssets/v1",
|
||||||
|
|||||||
@@ -33,10 +33,6 @@ function routeSession(page: Page) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function generatedImageSvg(label: string) {
|
|
||||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="300" height="400"><rect width="300" height="400" fill="#d9f24f"/><text x="150" y="210" text-anchor="middle">${label}</text></svg>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function captureEvidence(page: Page, caseId: string, name: string) {
|
async function captureEvidence(page: Page, caseId: string, name: string) {
|
||||||
const root = process.env.DADA_EVIDENCE_DIR_PROJECTS;
|
const root = process.env.DADA_EVIDENCE_DIR_PROJECTS;
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
@@ -79,12 +75,6 @@ test("TDD-WP2-PROJ-005 limits failed-empty selection on the project list", async
|
|||||||
],
|
],
|
||||||
}), contentType: "application/json", status: 200,
|
}), contentType: "application/json", status: 200,
|
||||||
}));
|
}));
|
||||||
await page.route(`**/api/v1/private-assets/projects/${successId}/images/*`, (route) => route.fulfill({
|
|
||||||
body: generatedImageSvg("城市工作室"),
|
|
||||||
contentType: "image/svg+xml",
|
|
||||||
headers: { "Content-Disposition": "attachment; filename=\"dada-original.png\"" },
|
|
||||||
status: 200,
|
|
||||||
}));
|
|
||||||
let batchPayload: unknown;
|
let batchPayload: unknown;
|
||||||
await page.route("**/api/v1/projects/failed-empty/trash", async (route) => {
|
await page.route("**/api/v1/projects/failed-empty/trash", async (route) => {
|
||||||
batchPayload = route.request().postDataJSON();
|
batchPayload = route.request().postDataJSON();
|
||||||
@@ -96,30 +86,12 @@ test("TDD-WP2-PROJ-005 limits failed-empty selection on the project list", async
|
|||||||
|
|
||||||
await expect(page.getByRole("heading", { name: "项目" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "项目" })).toBeVisible();
|
||||||
await expect(page.getByText("2 / 20 active")).toBeVisible();
|
await expect(page.getByText("2 / 20 active")).toBeVisible();
|
||||||
const projectPreview = page.getByRole("img", { name: "城市工作室预览图" });
|
|
||||||
await expect(projectPreview).toHaveAttribute(
|
|
||||||
"src",
|
|
||||||
`/api/v1/private-assets/projects/${successId}/images/00000000-0000-4000-8000-000000000213`,
|
|
||||||
);
|
|
||||||
await expect(projectPreview).toHaveCSS("object-fit", "cover");
|
|
||||||
await expect(page.getByLabel("选择失败草稿:失败草稿")).toBeVisible();
|
await expect(page.getByLabel("选择失败草稿:失败草稿")).toBeVisible();
|
||||||
await expect(page.getByLabel("选择失败草稿:城市工作室")).toHaveCount(0);
|
await expect(page.getByLabel("选择失败草稿:城市工作室")).toHaveCount(0);
|
||||||
await page.getByLabel("选择失败草稿:失败草稿").check();
|
await page.getByLabel("选择失败草稿:失败草稿").check();
|
||||||
const batchTrashButton = page.getByRole("button", { name: "批量移入回收站" });
|
await page.getByRole("button", { name: "批量移入回收站" }).click();
|
||||||
await batchTrashButton.hover();
|
|
||||||
await expect(batchTrashButton).toHaveCSS("transform", "matrix(1, 0, 0, 1, 0, -1)");
|
|
||||||
const buttonBounds = await batchTrashButton.boundingBox();
|
|
||||||
if (!buttonBounds) throw new Error("Batch trash button geometry is unavailable.");
|
|
||||||
await page.mouse.move(buttonBounds.x + buttonBounds.width / 2, buttonBounds.y + buttonBounds.height / 2);
|
|
||||||
await page.mouse.down();
|
|
||||||
await expect(batchTrashButton).toHaveCSS("transform", "matrix(1, 0, 0, 1, 0, 1)");
|
|
||||||
await page.mouse.move(0, 0);
|
|
||||||
await page.mouse.up();
|
|
||||||
await batchTrashButton.click();
|
|
||||||
expect(batchPayload).toEqual({ project_ids: [failedId] });
|
expect(batchPayload).toEqual({ project_ids: [failedId] });
|
||||||
await expect(page.getByText("失败草稿已移入回收站")).toBeVisible();
|
await expect(page.getByText("失败草稿已移入回收站")).toBeVisible();
|
||||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
|
||||||
await expect(page.getByRole("link", { name: "打开项目:城市工作室" })).toHaveCSS("transition-duration", "0s");
|
|
||||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(375);
|
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(375);
|
||||||
await captureEvidence(page, "TDD-WP2-PROJ-005-failed-draft-retry", "projects-mobile.png");
|
await captureEvidence(page, "TDD-WP2-PROJ-005-failed-draft-retry", "projects-mobile.png");
|
||||||
});
|
});
|
||||||
@@ -127,10 +99,9 @@ test("TDD-WP2-PROJ-005 limits failed-empty selection on the project list", async
|
|||||||
test("TDD-WP2-PROJ-001 keeps ratio fixed and blocks an eleventh image in project detail", async ({ page }) => {
|
test("TDD-WP2-PROJ-001 keeps ratio fixed and blocks an eleventh image in project detail", async ({ page }) => {
|
||||||
await routeSession(page);
|
await routeSession(page);
|
||||||
const projectId = "00000000-0000-4000-8000-000000000221";
|
const projectId = "00000000-0000-4000-8000-000000000221";
|
||||||
const currentImageId = "00000000-0000-4000-8000-000000000222";
|
|
||||||
await page.route(`**/api/v1/projects/${projectId}`, (route) => route.fulfill({
|
await page.route(`**/api/v1/projects/${projectId}`, (route) => route.fulfill({
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
created_at: "2026-07-28T08:00:00.000Z", current_image_id: currentImageId,
|
created_at: "2026-07-28T08:00:00.000Z", current_image_id: "00000000-0000-4000-8000-000000000222",
|
||||||
draft_prompt: "城市工作室", generations: [], images: Array.from({ length: 10 }, (_, index) => ({
|
draft_prompt: "城市工作室", generations: [], images: Array.from({ length: 10 }, (_, index) => ({
|
||||||
created_at: `2026-07-28T08:${String(index).padStart(2, "0")}:00.000Z`,
|
created_at: `2026-07-28T08:${String(index).padStart(2, "0")}:00.000Z`,
|
||||||
generation_id: `00000000-0000-4000-8000-${String(223 + index).padStart(12, "0")}`,
|
generation_id: `00000000-0000-4000-8000-${String(223 + index).padStart(12, "0")}`,
|
||||||
@@ -140,25 +111,11 @@ test("TDD-WP2-PROJ-001 keeps ratio fixed and blocks an eleventh image in project
|
|||||||
status: "active", successful_image_count: 10, updated_at: "2026-07-28T08:10:00.000Z",
|
status: "active", successful_image_count: 10, updated_at: "2026-07-28T08:10:00.000Z",
|
||||||
}), contentType: "application/json", status: 200,
|
}), contentType: "application/json", status: 200,
|
||||||
}));
|
}));
|
||||||
await page.route(`**/api/v1/private-assets/projects/${projectId}/images/*`, (route) => route.fulfill({
|
|
||||||
body: generatedImageSvg("生成结果"),
|
|
||||||
contentType: "image/svg+xml",
|
|
||||||
headers: { "Content-Disposition": "attachment; filename=\"dada-original.png\"" },
|
|
||||||
status: 200,
|
|
||||||
}));
|
|
||||||
await page.goto(`${webUrl}/app/projects/${projectId}`);
|
await page.goto(`${webUrl}/app/projects/${projectId}`);
|
||||||
|
|
||||||
await expect(page.getByRole("heading", { name: "城市工作室" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "城市工作室" })).toBeVisible();
|
||||||
await expect(page.getByText("固定比例 3:4")).toBeVisible();
|
await expect(page.getByText("固定比例 3:4")).toBeVisible();
|
||||||
await expect(page.getByText("10 / 10 张成功图")).toBeVisible();
|
await expect(page.getByText("10 / 10 张成功图")).toBeVisible();
|
||||||
const currentImage = page.getByRole("img", { name: "城市工作室当前底图" });
|
|
||||||
await expect(currentImage).toHaveAttribute(
|
|
||||||
"src",
|
|
||||||
`/api/v1/private-assets/projects/${projectId}/images/${currentImageId}`,
|
|
||||||
);
|
|
||||||
await expect(currentImage).toHaveAttribute("loading", "eager");
|
|
||||||
await expect(currentImage).toHaveCSS("object-fit", "contain");
|
|
||||||
await expect(page.getByRole("img", { name: "生成结果 10" })).toBeVisible();
|
|
||||||
await expect(page.getByRole("button", { name: "继续生成" })).toBeDisabled();
|
await expect(page.getByRole("button", { name: "继续生成" })).toBeDisabled();
|
||||||
await expect(page.getByText("请先删除一张非当前底图的历史图")).toBeVisible();
|
await expect(page.getByText("请先删除一张非当前底图的历史图")).toBeVisible();
|
||||||
await expect(page.getByRole("radio")).toHaveCount(0);
|
await expect(page.getByRole("radio")).toHaveCount(0);
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import { homedir } from "node:os";
|
|||||||
import { join, resolve } from "node:path";
|
import { join, resolve } from "node:path";
|
||||||
import type { CanvasState } from "@dada/shared-contracts";
|
import type { CanvasState } from "@dada/shared-contracts";
|
||||||
|
|
||||||
import { P0A_TEXT_TEMPLATES, createTextTemplateElement } from "../../apps/web/src/text-assets.js";
|
|
||||||
|
|
||||||
let vite: ViteDevServer;
|
let vite: ViteDevServer;
|
||||||
let webUrl: string;
|
let webUrl: string;
|
||||||
|
|
||||||
@@ -234,89 +232,3 @@ test("TDD-WP4-STK-001 transforms, cycles, selects and reopens ordinary stickers"
|
|||||||
writeEvidence("TDD-WP4-STK-001-transform-sticker", "pixel-diff.json", { canvas_and_saved_state_match: true, export_source_canvas_state_stable: true });
|
writeEvidence("TDD-WP4-STK-001-transform-sticker", "pixel-diff.json", { canvas_and_saved_state_match: true, export_source_canvas_state_stable: true });
|
||||||
if (process.env.DADA_EVIDENCE_DIR_EDITOR_ELEMENTS) await page.screenshot({ fullPage: true, path: resolve(process.env.DADA_EVIDENCE_DIR_EDITOR_ELEMENTS, "TDD-WP4-STK-001-transform-sticker", "transformed-sticker.png") });
|
if (process.env.DADA_EVIDENCE_DIR_EDITOR_ELEMENTS) await page.screenshot({ fullPage: true, path: resolve(process.env.DADA_EVIDENCE_DIR_EDITOR_ELEMENTS, "TDD-WP4-STK-001-transform-sticker", "transformed-sticker.png") });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("POSTV1-08 keeps the canvas frame stable and previews drag before pointer release", async ({ page }) => {
|
|
||||||
await page.addInitScript(() => {
|
|
||||||
const counters = { height: 0, width: 0 };
|
|
||||||
Object.defineProperty(window, "__dadaCanvasDimensionWrites", { value: counters });
|
|
||||||
for (const key of ["height", "width"] as const) {
|
|
||||||
const descriptor = Object.getOwnPropertyDescriptor(HTMLCanvasElement.prototype, key);
|
|
||||||
if (!descriptor?.get || !descriptor.set) throw new Error(`Canvas ${key} descriptor unavailable.`);
|
|
||||||
Object.defineProperty(HTMLCanvasElement.prototype, key, {
|
|
||||||
configurable: descriptor.configurable,
|
|
||||||
enumerable: descriptor.enumerable,
|
|
||||||
get: descriptor.get,
|
|
||||||
set(value: number) {
|
|
||||||
if (this.classList.contains("editor-canvas")) counters[key] += 1;
|
|
||||||
descriptor.set!.call(this, value);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const projectId = uuid(530);
|
|
||||||
const text = createTextTemplateElement(P0A_TEXT_TEMPLATES[0]!, {
|
|
||||||
createdAt: "2026-08-03T08:00:00.000Z",
|
|
||||||
elementId: uuid(630),
|
|
||||||
}, 0, { position: { x: 0.5, y: 0.5 } });
|
|
||||||
const backend = { canvas: canvas([text]), saves: 0, version: 6 };
|
|
||||||
await routeEditor(page, projectId, backend);
|
|
||||||
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
|
||||||
const stage = page.getByLabel("编辑画布");
|
|
||||||
const bounds = await stage.boundingBox();
|
|
||||||
if (!bounds) throw new Error("Canvas bounds unavailable.");
|
|
||||||
const center = { x: bounds.x + bounds.width * 0.5, y: bounds.y + bounds.height * 0.5 };
|
|
||||||
await page.mouse.click(center.x, center.y);
|
|
||||||
await expect(page.getByLabel("文字内容")).toBeVisible();
|
|
||||||
await page.getByLabel("文字内容").fill("拖动中的文字");
|
|
||||||
await page.getByRole("spinbutton", { name: "有效字号", exact: true }).fill("64");
|
|
||||||
await page.getByLabel("文字填充色").fill("#FA5751");
|
|
||||||
await expect.poll(() => backend.saves, { timeout: 4_000 }).toBe(1);
|
|
||||||
expect(backend.canvas.elements[0]).toMatchObject({
|
|
||||||
content: "拖动中的文字",
|
|
||||||
scale: { x: 64 / 48, y: 64 / 48 },
|
|
||||||
style_parameters: { fill_color: "#FA5751" },
|
|
||||||
});
|
|
||||||
const savesBeforeDrag = backend.saves;
|
|
||||||
|
|
||||||
const before = await page.evaluate(() => {
|
|
||||||
return structuredClone((window as typeof window & { __dadaCanvasDimensionWrites: { height: number; width: number } }).__dadaCanvasDimensionWrites);
|
|
||||||
});
|
|
||||||
await page.mouse.move(center.x, center.y);
|
|
||||||
await page.mouse.down();
|
|
||||||
await page.waitForTimeout(650);
|
|
||||||
await expect(page.getByRole("menu")).toBeVisible();
|
|
||||||
await page.mouse.move(bounds.x + bounds.width * 0.68, center.y);
|
|
||||||
await expect(page.getByRole("menu")).toBeHidden();
|
|
||||||
await expect(page.getByLabel("文字内容")).toBeVisible();
|
|
||||||
|
|
||||||
const preview = await stage.evaluate((canvas: HTMLCanvasElement) => {
|
|
||||||
const context = canvas.getContext("2d");
|
|
||||||
if (!context) throw new Error("Canvas context unavailable.");
|
|
||||||
const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data;
|
|
||||||
let count = 0;
|
|
||||||
let totalX = 0;
|
|
||||||
for (let y = 0; y < canvas.height; y += 1) {
|
|
||||||
for (let x = 0; x < canvas.width; x += 1) {
|
|
||||||
const offset = (y * canvas.width + x) * 4;
|
|
||||||
if ((pixels[offset] ?? 255) < 20 && (pixels[offset + 1] ?? 0) >= 75 && (pixels[offset + 1] ?? 255) <= 120 && (pixels[offset + 2] ?? 0) >= 180) {
|
|
||||||
count += 1;
|
|
||||||
totalX += x;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { blue_pixel_count: count, blue_x: count > 0 ? totalX / count / canvas.width : 0 };
|
|
||||||
});
|
|
||||||
const during = await page.evaluate(() => {
|
|
||||||
return structuredClone((window as typeof window & { __dadaCanvasDimensionWrites: { height: number; width: number } }).__dadaCanvasDimensionWrites);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(during).toEqual(before);
|
|
||||||
expect(preview.blue_pixel_count).toBeGreaterThan(100);
|
|
||||||
expect(preview.blue_x).toBeGreaterThan(0.60);
|
|
||||||
await page.mouse.up();
|
|
||||||
await expect.poll(() => backend.saves, { timeout: 4_000 }).toBeGreaterThan(savesBeforeDrag);
|
|
||||||
expect(backend.canvas.elements[0]?.position.x).toBeCloseTo(0.68, 2);
|
|
||||||
expect(backend.canvas.elements[0]?.content).toBe("拖动中的文字");
|
|
||||||
await expect(page.getByLabel("文字内容")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -43,11 +43,6 @@ interface Backend {
|
|||||||
version: number;
|
version: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EditorRouteOptions {
|
|
||||||
assetRequests?: string[];
|
|
||||||
failFontOnce?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeEvidence(caseId: string, name: string, value: unknown) {
|
function writeEvidence(caseId: string, name: string, value: unknown) {
|
||||||
const root = process.env.DADA_EVIDENCE_DIR_TEXT_EDITOR;
|
const root = process.env.DADA_EVIDENCE_DIR_TEXT_EDITOR;
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
@@ -56,7 +51,7 @@ function writeEvidence(caseId: string, name: string, value: unknown) {
|
|||||||
writeFileSync(resolve(directory, name), `${JSON.stringify(value, null, 2)}\n`);
|
writeFileSync(resolve(directory, name), `${JSON.stringify(value, null, 2)}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function routeEditor(page: Page, projectId: string, backend: Backend, options: EditorRouteOptions = {}) {
|
async function routeEditor(page: Page, projectId: string, backend: Backend) {
|
||||||
const windowsFont = join(process.env.WINDIR ?? "C:\\Windows", "Fonts", "arial.ttf");
|
const windowsFont = join(process.env.WINDIR ?? "C:\\Windows", "Fonts", "arial.ttf");
|
||||||
if (!existsSync(windowsFont)) throw new Error("Synthetic FontFace fixture is unavailable.");
|
if (!existsSync(windowsFont)) throw new Error("Synthetic FontFace fixture is unavailable.");
|
||||||
const fontBytes = readFileSync(windowsFont);
|
const fontBytes = readFileSync(windowsFont);
|
||||||
@@ -81,56 +76,29 @@ async function routeEditor(page: Page, projectId: string, backend: Backend, opti
|
|||||||
backend.recent = [item, ...backend.recent.filter((entry) => entry.asset_id !== item.asset_id)];
|
backend.recent = [item, ...backend.recent.filter((entry) => entry.asset_id !== item.asset_id)];
|
||||||
await route.fulfill({ body: JSON.stringify({ status: "recorded" }), contentType: "application/json", status: 200 });
|
await route.fulfill({ body: JSON.stringify({ status: "recorded" }), contentType: "application/json", status: 200 });
|
||||||
});
|
});
|
||||||
const imageFixture = '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="#ff00ff"/></svg>';
|
await page.route("**/api/v1/assets/public/p0a-complex-v1/*", (route) => route.fulfill({ body: fontBytes, contentType: "font/ttf", status: 200 }));
|
||||||
let failedFontRequests = 0;
|
|
||||||
await page.route("**/api/v1/assets/public/p0a-complex-v1/*", (route) => {
|
|
||||||
const assetId = new URL(route.request().url()).pathname.split("/").at(-1) ?? "";
|
|
||||||
options.assetRequests?.push(assetId);
|
|
||||||
if (options.failFontOnce === assetId && failedFontRequests++ === 0) return route.fulfill({ status: 503 });
|
|
||||||
const image = assetId.startsWith("TEXT-PREVIEW-") || assetId.startsWith("TEXT-IMAGE-");
|
|
||||||
return route.fulfill(image
|
|
||||||
? { body: imageFixture, contentType: "image/svg+xml", status: 200 }
|
|
||||||
: { body: fontBytes, contentType: "font/ttf", status: 200 });
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function magentaPixels(page: Page) {
|
test("TDD-WP4-TXT-003 exposes the frozen catalog, display-name search and account recent use", async ({ page }) => {
|
||||||
return page.getByLabel("编辑画布").evaluate((canvas: HTMLCanvasElement) => {
|
|
||||||
const context = canvas.getContext("2d");
|
|
||||||
if (!context) throw new Error("Canvas context unavailable.");
|
|
||||||
const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data;
|
|
||||||
let count = 0;
|
|
||||||
for (let index = 0; index < pixels.length; index += 4) {
|
|
||||||
if ((pixels[index] ?? 0) > 240 && (pixels[index + 1] ?? 255) < 20 && (pixels[index + 2] ?? 0) > 240) count += 1;
|
|
||||||
}
|
|
||||||
return count;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test("TDD-WP4-TXT-003 exposes the complete catalog, display-name search and account recent use", async ({ page }) => {
|
|
||||||
const projectId = uuid(730);
|
const projectId = uuid(730);
|
||||||
const backend: Backend = { canvas: emptyCanvas(), recent: [], saves: 0, version: 2 };
|
const backend: Backend = { canvas: emptyCanvas(), recent: [], saves: 0, version: 2 };
|
||||||
await routeEditor(page, projectId, backend);
|
await routeEditor(page, projectId, backend);
|
||||||
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
||||||
await page.getByRole("button", { name: "文字模板", exact: true }).click();
|
await page.getByRole("button", { name: "文字模板", exact: true }).click();
|
||||||
await expect(page.locator(".editor-template-grid button")).toHaveCount(332);
|
await expect(page.locator(".editor-template-grid button")).toHaveCount(32);
|
||||||
for (const [label, count] of [["花字", 145], ["标题", 119], ["标签", 51], ["简约", 17]] as const) {
|
for (const [label, count] of [["花字", 8], ["标题", 8], ["标签", 8], ["简约", 8]] as const) {
|
||||||
await page.getByRole("button", { name: label, exact: true }).click();
|
await page.getByRole("button", { name: label, exact: true }).click();
|
||||||
await expect(page.locator(".editor-template-grid button")).toHaveCount(count);
|
await expect(page.locator(".editor-template-grid button")).toHaveCount(count);
|
||||||
}
|
}
|
||||||
await page.getByRole("button", { name: "全部", exact: true }).click();
|
await page.getByRole("button", { name: "全部", exact: true }).click();
|
||||||
const search = page.getByPlaceholder("搜索文字模板显示名称");
|
const search = page.getByPlaceholder("搜索文字模板显示名称");
|
||||||
await search.fill("生活");
|
await search.fill("生活");
|
||||||
await expect(page.locator(".editor-template-grid button")).toHaveCount(9);
|
await expect(page.locator(".editor-template-grid button")).toHaveCount(5);
|
||||||
await search.fill("FLOWER001");
|
await search.fill("FLOWER001");
|
||||||
await expect(page.locator(".editor-template-grid button")).toHaveCount(0);
|
await expect(page.locator(".editor-template-grid button")).toHaveCount(0);
|
||||||
await search.fill("");
|
await search.fill("");
|
||||||
expect(page.getByText("添加普通文字", { exact: true })).toHaveCount(0);
|
expect(page.getByText("添加普通文字", { exact: true })).toHaveCount(0);
|
||||||
const flowerTemplate = page.getByRole("button", { name: /FLOWER001 春日计划/ });
|
await page.getByRole("button", { name: /FLOWER001 春日计划/ }).click();
|
||||||
await flowerTemplate.hover();
|
|
||||||
await expect(flowerTemplate).toHaveCSS("background-color", "rgb(255, 255, 214)");
|
|
||||||
await expect(flowerTemplate.locator(".editor-template-preview")).toHaveCSS("transform", "matrix(1.03, 0, 0, 1.03, 0, 0)");
|
|
||||||
await flowerTemplate.click();
|
|
||||||
await expect(page.getByText("对象 1 / 50")).toBeVisible();
|
await expect(page.getByText("对象 1 / 50")).toBeVisible();
|
||||||
await expect.poll(() => backend.recent).toEqual([{ asset_id: "FLOWER001", asset_kind: "text_template", resource_version: "p0a-complex-v1" }]);
|
await expect.poll(() => backend.recent).toEqual([{ asset_id: "FLOWER001", asset_kind: "text_template", resource_version: "p0a-complex-v1" }]);
|
||||||
await page.reload();
|
await page.reload();
|
||||||
@@ -138,93 +106,11 @@ test("TDD-WP4-TXT-003 exposes the complete catalog, display-name search and acco
|
|||||||
await expect(page.getByLabel("最近使用文字模板").getByText("FLOWER001", { exact: true })).toBeVisible();
|
await expect(page.getByLabel("最近使用文字模板").getByText("FLOWER001", { exact: true })).toBeVisible();
|
||||||
await page.getByRole("button", { name: "普通贴纸", exact: true }).click();
|
await page.getByRole("button", { name: "普通贴纸", exact: true }).click();
|
||||||
expect(page.getByPlaceholder("搜索普通贴纸")).toHaveCount(0);
|
expect(page.getByPlaceholder("搜索普通贴纸")).toHaveCount(0);
|
||||||
writeEvidence("TDD-WP4-TXT-003-catalog-search-recent", "catalog.json", { categories: { flower: 145, simple: 17, tag: 51, title: 119 }, count: 332, first: "FLOWER001", last: "SIMPLE017" });
|
writeEvidence("TDD-WP4-TXT-003-catalog-search-recent", "catalog.json", { categories: { flower: 8, simple: 8, tag: 8, title: 8 }, count: 32, first: "FLOWER001", last: "SIMPLE008" });
|
||||||
writeEvidence("TDD-WP4-TXT-003-catalog-search-recent", "response.json", { public_count: 332, recent: backend.recent, unavailable_count: 0 });
|
writeEvidence("TDD-WP4-TXT-003-catalog-search-recent", "response.json", { public_count: 32, recent: backend.recent, unavailable_is_disabled: true });
|
||||||
writeEvidence("TDD-WP4-TXT-003-catalog-search-recent", "db-diff.json", { account_user_id: userId, recent: backend.recent, search_did_not_write: true });
|
writeEvidence("TDD-WP4-TXT-003-catalog-search-recent", "db-diff.json", { account_user_id: userId, recent: backend.recent, search_did_not_write: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("POSTV1-ASSET-ALL-16 retries a transient archived font failure without permanently disabling the template", async ({ page }) => {
|
|
||||||
const projectId = uuid(735);
|
|
||||||
const backend: Backend = { canvas: emptyCanvas(), recent: [], saves: 0, version: 2 };
|
|
||||||
const failedFont = "TEXT-FONT-FLOWER001-01";
|
|
||||||
const assetRequests: string[] = [];
|
|
||||||
await routeEditor(page, projectId, backend, { assetRequests, failFontOnce: failedFont });
|
|
||||||
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
|
||||||
await page.getByRole("button", { name: "文字模板", exact: true }).click();
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: /FLOWER001 春日计划/ }).click();
|
|
||||||
await expect(page.getByText("素材暂不可用,未使用系统字体替代。", { exact: true })).toBeVisible();
|
|
||||||
await expect(page.getByRole("button", { name: /FLOWER001 春日计划 字体待重试/ })).toBeEnabled();
|
|
||||||
expect(backend.canvas.elements).toHaveLength(0);
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: /FLOWER001 春日计划 字体待重试/ }).click();
|
|
||||||
await expect.poll(() => backend.canvas.elements.length).toBe(1);
|
|
||||||
expect(assetRequests.filter((assetId) => assetId === failedFont)).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("POSTV1-TEMPLATE-FIDELITY-17 renders archived styles instead of two-character preview placeholders", async ({ page }) => {
|
|
||||||
const projectId = uuid(737);
|
|
||||||
const backend: Backend = { canvas: emptyCanvas(), recent: [], saves: 0, version: 2 };
|
|
||||||
await routeEditor(page, projectId, backend);
|
|
||||||
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
|
||||||
await page.getByRole("button", { name: "文字模板", exact: true }).click();
|
|
||||||
|
|
||||||
const template = page.getByRole("button", { name: /FLOWER003 人生照片/ });
|
|
||||||
const preview = template.locator(".editor-template-live-preview");
|
|
||||||
await expect(preview).toBeVisible();
|
|
||||||
await expect(template.locator(".editor-template-mark")).toHaveCount(0);
|
|
||||||
await expect(preview).toContainText("#人生照片");
|
|
||||||
await expect(preview).toHaveCSS("font-family", /Dada_TEXT_FONT_FLOWER003_01/);
|
|
||||||
await expect(preview).toHaveCSS("color", "rgb(255, 255, 248)");
|
|
||||||
await expect.poll(() => page.evaluate(() => document.fonts.check('16px "Dada_TEXT_FONT_FLOWER003_01"'))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("POSTV1-TEMPLATE-FIDELITY-17 isolates manual stroke state between templates", async ({ page }) => {
|
|
||||||
const projectId = uuid(738);
|
|
||||||
const backend: Backend = { canvas: emptyCanvas(), recent: [], saves: 0, version: 2 };
|
|
||||||
await routeEditor(page, projectId, backend);
|
|
||||||
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
|
||||||
await page.getByRole("button", { name: "文字模板", exact: true }).click();
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: /H003 生活分享家/ }).click();
|
|
||||||
await expect(page.getByLabel("启用描边")).not.toBeChecked();
|
|
||||||
await page.getByLabel("启用描边").check();
|
|
||||||
await expect.poll(() => backend.canvas.elements[0]?.style_parameters?.stroke_enabled).toBe(true);
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: /FLOWER002 笑不活了/ }).click();
|
|
||||||
await expect.poll(() => backend.canvas.elements).toHaveLength(2);
|
|
||||||
await expect(page.getByLabel("启用描边")).not.toBeChecked();
|
|
||||||
expect(backend.canvas.elements[0]?.style_parameters?.stroke_enabled).toBe(true);
|
|
||||||
expect(backend.canvas.elements[1]?.style_parameters?.stroke_enabled).toBe(false);
|
|
||||||
|
|
||||||
await page.getByLabel("文字模板切换").selectOption("H003");
|
|
||||||
await expect(page.getByLabel("启用描边")).not.toBeChecked();
|
|
||||||
await expect.poll(() => backend.canvas.elements[1]?.template_or_asset_id).toBe("H003");
|
|
||||||
expect(backend.canvas.elements[1]?.style_parameters?.stroke_enabled).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("POSTV1-ASSET-ALL-16 renders captured image, material, particle and underline decorations", async ({ page }) => {
|
|
||||||
const projectId = uuid(736);
|
|
||||||
const backend: Backend = { canvas: emptyCanvas(), recent: [], saves: 0, version: 2 };
|
|
||||||
const assetRequests: string[] = [];
|
|
||||||
await routeEditor(page, projectId, backend, { assetRequests });
|
|
||||||
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
|
||||||
await page.getByRole("button", { name: "文字模板", exact: true }).click();
|
|
||||||
|
|
||||||
for (const template of [
|
|
||||||
{ id: "FLOWER001", name: "春日计划", image: "TEXT-IMAGE-FLOWER001-001" },
|
|
||||||
{ id: "FLOWER048", name: "糖", image: "TEXT-IMAGE-FLOWER048-001" },
|
|
||||||
{ id: "H013", name: "周末俱乐部", image: "TEXT-IMAGE-H013-001" },
|
|
||||||
{ id: "FLOWER121", name: "厨房和生活", image: "TEXT-IMAGE-FLOWER121-001" },
|
|
||||||
]) {
|
|
||||||
await page.getByRole("button", { name: new RegExp(`${template.id} ${template.name}`) }).click();
|
|
||||||
await expect.poll(() => assetRequests.includes(template.image)).toBe(true);
|
|
||||||
await expect.poll(() => magentaPixels(page)).toBeGreaterThan(20);
|
|
||||||
await page.getByRole("button", { name: "撤销" }).click();
|
|
||||||
await expect.poll(() => backend.canvas.elements.length).toBe(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("TDD-WP4-TXT-001 preserves multiline content and transforms across a template switch", async ({ page }) => {
|
test("TDD-WP4-TXT-001 preserves multiline content and transforms across a template switch", async ({ page }) => {
|
||||||
const projectId = uuid(740);
|
const projectId = uuid(740);
|
||||||
const backend: Backend = { canvas: emptyCanvas(), recent: [], saves: 0, version: 3 };
|
const backend: Backend = { canvas: emptyCanvas(), recent: [], saves: 0, version: 3 };
|
||||||
@@ -311,103 +197,18 @@ test("TDD-WP4-TXT-002 waits for the archived font and commits exact style ranges
|
|||||||
const element = backend.canvas.elements[0]!;
|
const element = backend.canvas.elements[0]!;
|
||||||
expect(element.opacity).toBe(1);
|
expect(element.opacity).toBe(1);
|
||||||
expect(element.font_override).toBe("FONT081");
|
expect(element.font_override).toBe("FONT081");
|
||||||
expect(element.scale).toEqual({ x: 96 / 50, y: 96 / 50 });
|
expect(element.scale).toEqual({ x: 2, y: 2 });
|
||||||
expect(element.style_parameters).toMatchObject({ background_opacity: 0.35, letter_spacing: 20, line_height: 1.9, stroke_width: 12, text_align: "right" });
|
expect(element.style_parameters).toMatchObject({ background_opacity: 0.35, letter_spacing: 20, line_height: 1.9, stroke_width: 12, text_align: "right" });
|
||||||
await expect.poll(() => page.evaluate(() => document.fonts.check('16px "Dada_FONT081"'))).toBe(true);
|
await expect.poll(() => page.evaluate(() => document.fonts.check('16px "Dada_FONT081"'))).toBe(true);
|
||||||
await page.getByRole("button", { name: "撤销" }).click();
|
await page.getByRole("button", { name: "撤销" }).click();
|
||||||
await expect(page.getByRole("spinbutton", { name: "有效字号", exact: true })).toHaveValue("50");
|
await expect(page.getByRole("spinbutton", { name: "有效字号", exact: true })).toHaveValue("48");
|
||||||
await page.getByRole("button", { name: "重做" }).click();
|
await page.getByRole("button", { name: "重做" }).click();
|
||||||
await expect(page.getByRole("spinbutton", { name: "有效字号", exact: true })).toHaveValue("96");
|
await expect(page.getByRole("spinbutton", { name: "有效字号", exact: true })).toHaveValue("96");
|
||||||
await page.reload();
|
await page.reload();
|
||||||
const reopenedStage = page.getByLabel("编辑画布");
|
await page.getByLabel("编辑画布").click({ position: { x: 270, y: 360 } });
|
||||||
const reopenedBounds = await reopenedStage.boundingBox();
|
|
||||||
const reopenedText = backend.canvas.elements[0];
|
|
||||||
if (!reopenedBounds || !reopenedText) throw new Error("Reopened text geometry is unavailable.");
|
|
||||||
await reopenedStage.click({ position: {
|
|
||||||
x: reopenedBounds.width * reopenedText.position.x,
|
|
||||||
y: reopenedBounds.height * reopenedText.position.y,
|
|
||||||
} });
|
|
||||||
await expect(page.getByLabel("字体覆盖")).toHaveValue("FONT081");
|
await expect(page.getByLabel("字体覆盖")).toHaveValue("FONT081");
|
||||||
writeEvidence("TDD-WP4-TXT-002-font-metrics-ranges", "font-load.json", { fallback: null, font_id: "FONT081", ready: true, source: "public_release_fixture" });
|
writeEvidence("TDD-WP4-TXT-002-font-metrics-ranges", "font-load.json", { fallback: null, font_id: "FONT081", ready: true, source: "public_release_fixture" });
|
||||||
writeEvidence("TDD-WP4-TXT-002-font-metrics-ranges", "canvas-state.json", backend.canvas);
|
writeEvidence("TDD-WP4-TXT-002-font-metrics-ranges", "canvas-state.json", backend.canvas);
|
||||||
writeEvidence("TDD-WP4-TXT-002-font-metrics-ranges", "pixel-diff.json", { background_alpha_separate: true, clipped_visible_text: false, effective_font_size: 96 });
|
writeEvidence("TDD-WP4-TXT-002-font-metrics-ranges", "pixel-diff.json", { background_alpha_separate: true, clipped_visible_text: false, effective_font_size: 96 });
|
||||||
if (process.env.DADA_EVIDENCE_DIR_TEXT_EDITOR) await page.screenshot({ fullPage: true, path: resolve(process.env.DADA_EVIDENCE_DIR_TEXT_EDITOR, "TDD-WP4-TXT-002-font-metrics-ranges", "font-styles.png") });
|
if (process.env.DADA_EVIDENCE_DIR_TEXT_EDITOR) await page.screenshot({ fullPage: true, path: resolve(process.env.DADA_EVIDENCE_DIR_TEXT_EDITOR, "TDD-WP4-TXT-002-font-metrics-ranges", "font-styles.png") });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("POSTV1-07 keeps the canvas anchored when the text template panel opens", async ({ page }) => {
|
|
||||||
const projectId = uuid(760);
|
|
||||||
const backend: Backend = { canvas: emptyCanvas(), recent: [], saves: 0, version: 5 };
|
|
||||||
await routeEditor(page, projectId, backend);
|
|
||||||
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
|
||||||
|
|
||||||
const stage = page.getByLabel("编辑画布");
|
|
||||||
const before = await stage.boundingBox();
|
|
||||||
if (!before) throw new Error("Canvas geometry is unavailable before opening text templates.");
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "文字模板", exact: true }).click();
|
|
||||||
await expect(page.locator(".editor-template-grid button")).toHaveCount(332);
|
|
||||||
const after = await stage.boundingBox();
|
|
||||||
if (!after) throw new Error("Canvas geometry is unavailable after opening text templates.");
|
|
||||||
const assetsPanelScroll = await page.getByLabel("素材与底图来源").evaluate((panel) => ({
|
|
||||||
clientHeight: panel.clientHeight,
|
|
||||||
overflowY: getComputedStyle(panel).overflowY,
|
|
||||||
scrollHeight: panel.scrollHeight,
|
|
||||||
}));
|
|
||||||
|
|
||||||
expect(Math.abs(after.x - before.x)).toBeLessThanOrEqual(1);
|
|
||||||
expect(Math.abs(after.y - before.y)).toBeLessThanOrEqual(1);
|
|
||||||
expect(Math.abs(after.width - before.width)).toBeLessThanOrEqual(1);
|
|
||||||
expect(Math.abs(after.height - before.height)).toBeLessThanOrEqual(1);
|
|
||||||
expect(assetsPanelScroll.overflowY).toBe("auto");
|
|
||||||
expect(assetsPanelScroll.scrollHeight).toBeGreaterThan(assetsPanelScroll.clientHeight);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("POSTV1-07 keeps text selection stable and dismisses move feedback", async ({ page }) => {
|
|
||||||
const projectId = uuid(770);
|
|
||||||
const backend: Backend = { canvas: emptyCanvas(), recent: [], saves: 0, version: 6 };
|
|
||||||
await routeEditor(page, projectId, backend);
|
|
||||||
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
|
||||||
await page.getByRole("button", { name: "文字模板", exact: true }).click();
|
|
||||||
await page.getByRole("button", { name: /H003 生活分享家/ }).click();
|
|
||||||
await expect.poll(() => backend.canvas.elements.length).toBe(1);
|
|
||||||
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
await page.reload();
|
|
||||||
const stage = page.getByLabel("编辑画布");
|
|
||||||
const bounds = await stage.boundingBox();
|
|
||||||
const element = backend.canvas.elements[0];
|
|
||||||
if (!bounds || !element) throw new Error("Text selection geometry is unavailable.");
|
|
||||||
const positionBefore = structuredClone(element.position);
|
|
||||||
const savesBefore = backend.saves;
|
|
||||||
const clientX = bounds.x + bounds.width * element.position.x;
|
|
||||||
const clientY = bounds.y + bounds.height * element.position.y;
|
|
||||||
|
|
||||||
await page.mouse.move(clientX, clientY);
|
|
||||||
await page.mouse.down();
|
|
||||||
await expect(page.getByLabel("字体覆盖")).toBeVisible();
|
|
||||||
const selectedBounds = await stage.boundingBox();
|
|
||||||
const inspectorScroll = await page.getByLabel("对象参数").evaluate((panel) => ({
|
|
||||||
clientHeight: panel.clientHeight,
|
|
||||||
overflowY: getComputedStyle(panel).overflowY,
|
|
||||||
scrollHeight: panel.scrollHeight,
|
|
||||||
}));
|
|
||||||
if (!selectedBounds) throw new Error("Canvas geometry is unavailable after selecting text.");
|
|
||||||
expect(Math.abs(selectedBounds.y - bounds.y)).toBeLessThanOrEqual(1);
|
|
||||||
expect(inspectorScroll.overflowY).toBe("auto");
|
|
||||||
expect(inspectorScroll.scrollHeight).toBeGreaterThan(inspectorScroll.clientHeight);
|
|
||||||
await page.mouse.move(clientX + 1, clientY);
|
|
||||||
await page.mouse.up();
|
|
||||||
|
|
||||||
await expect(page.getByLabel("字体覆盖")).toBeVisible();
|
|
||||||
await page.waitForTimeout(800);
|
|
||||||
expect(backend.canvas.elements[0]?.position).toEqual(positionBefore);
|
|
||||||
expect(backend.saves).toBe(savesBefore);
|
|
||||||
|
|
||||||
await page.mouse.move(clientX, clientY);
|
|
||||||
await page.mouse.down();
|
|
||||||
await page.mouse.move(clientX + 12, clientY);
|
|
||||||
await page.mouse.up();
|
|
||||||
await expect(page.getByText("对象位置已提交", { exact: true })).toBeVisible();
|
|
||||||
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBeGreaterThan(savesBefore);
|
|
||||||
await expect(page.getByText("对象位置已提交", { exact: true })).toBeHidden({ timeout: 5_000 });
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -29,9 +29,8 @@ const rawImages: Record<string, string[]> = {
|
|||||||
"00000000-0000-4000-8000-000000000812": ["#102030", "#405060", "#708090", "#A0B0C0", "#D0E0F0"],
|
"00000000-0000-4000-8000-000000000812": ["#102030", "#405060", "#708090", "#A0B0C0", "#D0E0F0"],
|
||||||
};
|
};
|
||||||
|
|
||||||
const assetRoot = join(homedir(), "Desktop", "sticker_web_replication_assets");
|
const dynamicRoot = process.env.DADA_DYNAMIC_ASSET_ROOT ?? join(homedir(), "Desktop", "sticker_interactive", "单模板归档", "templates");
|
||||||
const dynamicRoot = process.env.DADA_DYNAMIC_ASSET_ROOT ?? join(assetRoot, "sticker_interactive", "单模板归档", "templates");
|
const textRoot = process.env.DADA_TEXT_ASSET_ROOT ?? join(homedir(), "Desktop", "sticker_text");
|
||||||
const textRoot = process.env.DADA_TEXT_ASSET_ROOT ?? join(assetRoot, "sticker_text");
|
|
||||||
const dynamicSourceAssets: Readonly<Record<string, { contentType: string; path: string }>> = {
|
const dynamicSourceAssets: Readonly<Record<string, { contentType: string; path: string }>> = {
|
||||||
"15974853bc3294ef68e7e6d58fe74fd7": { contentType: "font/ttf", path: join(dynamicRoot, "DYN002", "fonts", "15974853bc3294ef68e7e6d58fe74fd7", "0202b90o6r57rxed4027b5689e0dxe7e142r0yho9d0lljuqj.ttf") },
|
"15974853bc3294ef68e7e6d58fe74fd7": { contentType: "font/ttf", path: join(dynamicRoot, "DYN002", "fonts", "15974853bc3294ef68e7e6d58fe74fd7", "0202b90o6r57rxed4027b5689e0dxe7e142r0yho9d0lljuqj.ttf") },
|
||||||
"46f8336813e4c48d06a1aef294fdccf6": { contentType: "font/ttf", path: join(dynamicRoot, "DYN016", "fonts", "46f8336813e4c48d06a1aef294fdccf6", "9fbfbb420cea1df916d7c7c7ac90b1c88b61e117-PingFang-SC-Semibold-2.ttf") },
|
"46f8336813e4c48d06a1aef294fdccf6": { contentType: "font/ttf", path: join(dynamicRoot, "DYN016", "fonts", "46f8336813e4c48d06a1aef294fdccf6", "9fbfbb420cea1df916d7c7c7ac90b1c88b61e117-PingFang-SC-Semibold-2.ttf") },
|
||||||
@@ -137,7 +136,7 @@ test("TDD-WP4-COL-001 extracts once from raw pixels and refreshes only for a new
|
|||||||
await routeEditor(page, projectId, backend);
|
await routeEditor(page, projectId, backend);
|
||||||
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
||||||
await page.getByRole("button", { name: "色卡", exact: true }).click();
|
await page.getByRole("button", { name: "色卡", exact: true }).click();
|
||||||
await expect(page.getByRole("button", { name: /^添加色卡/ })).toHaveCount(16);
|
await expect(page.getByRole("button", { name: /^添加色卡/ })).toHaveCount(4);
|
||||||
await expect(page.getByRole("button", { name: "色卡说明" })).toHaveAttribute("title", "色卡基于原始底图,更换底图时更新,不随裁剪、调色和滤镜变化");
|
await expect(page.getByRole("button", { name: "色卡说明" })).toHaveAttribute("title", "色卡基于原始底图,更换底图时更新,不随裁剪、调色和滤镜变化");
|
||||||
const placements = [
|
const placements = [
|
||||||
[{ key: "ArrowLeft", times: 15 }, { key: "ArrowUp", times: 14 }],
|
[{ key: "ArrowLeft", times: 15 }, { key: "ArrowUp", times: 14 }],
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ async function routeEditor(page: Page, projectId: string, backend: Backend, opti
|
|||||||
await page.route("**/api/v1/assets/public/p0a-complex-v1/*", (route) => route.fulfill({ body: readFileSync(windowsFont), contentType: "font/ttf" }));
|
await page.route("**/api/v1/assets/public/p0a-complex-v1/*", (route) => route.fulfill({ body: readFileSync(windowsFont), contentType: "font/ttf" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
test("TDD-WP4-EXP-001 cancel keeps automatically saved text outside the export", async ({ page }) => {
|
test("TDD-WP4-EXP-001 cancel keeps a pending text edit outside history and export", async ({ page }) => {
|
||||||
const projectId = "00000000-0000-4000-8000-000000000920";
|
const projectId = "00000000-0000-4000-8000-000000000920";
|
||||||
const assetId = "00000000-0000-4000-8000-000000000921";
|
const assetId = "00000000-0000-4000-8000-000000000921";
|
||||||
const backend: Backend = { canvas: canvasForRatio("3:4", assetId), latestBodies: [], saves: 0, version: 2 };
|
const backend: Backend = { canvas: canvasForRatio("3:4", assetId), latestBodies: [], saves: 0, version: 2 };
|
||||||
@@ -87,29 +87,27 @@ test("TDD-WP4-EXP-001 cancel keeps automatically saved text outside the export",
|
|||||||
await page.getByRole("button", { name: "文字模板", exact: true }).click();
|
await page.getByRole("button", { name: "文字模板", exact: true }).click();
|
||||||
await page.getByRole("button", { name: /FLOWER001 春日计划/ }).click();
|
await page.getByRole("button", { name: /FLOWER001 春日计划/ }).click();
|
||||||
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBe(1);
|
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBe(1);
|
||||||
await page.getByLabel("文字内容").fill("自动保存的导出文字");
|
await page.getByLabel("文字内容").fill("尚未提交的导出文字");
|
||||||
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBe(2);
|
|
||||||
const downloads: string[] = [];
|
const downloads: string[] = [];
|
||||||
page.on("download", (download) => downloads.push(download.suggestedFilename()));
|
page.on("download", (download) => downloads.push(download.suggestedFilename()));
|
||||||
await page.getByRole("button", { name: "导出", exact: true }).click();
|
await page.getByRole("button", { name: "导出", exact: true }).click();
|
||||||
const dialog = page.getByRole("dialog", { name: "导出成品" });
|
const dialog = page.getByRole("dialog", { name: "导出成品" });
|
||||||
await expect(dialog).not.toContainText("导出前需要提交当前修改");
|
await expect(dialog).toContainText("导出前需要提交当前修改");
|
||||||
await expect(dialog.getByRole("checkbox", { name: "将应用当前修改并导出" })).toHaveCount(0);
|
await expect(dialog.getByText("将应用当前修改并导出", { exact: true })).toBeVisible();
|
||||||
await dialog.getByRole("button", { name: "取消" }).click();
|
await dialog.getByRole("button", { name: "取消" }).click();
|
||||||
await expect(dialog).toHaveCount(0);
|
await expect(dialog).toHaveCount(0);
|
||||||
await expect(page.getByLabel("文字内容")).toHaveValue("自动保存的导出文字");
|
await expect(page.getByLabel("文字内容")).toHaveValue("尚未提交的导出文字");
|
||||||
expect(backend.saves).toBe(2);
|
expect(backend.saves).toBe(1);
|
||||||
expect(backend.canvas.elements[0]?.content).toBe("自动保存的导出文字");
|
|
||||||
expect(backend.latestBodies).toHaveLength(0);
|
expect(backend.latestBodies).toHaveLength(0);
|
||||||
expect(downloads).toHaveLength(0);
|
expect(downloads).toHaveLength(0);
|
||||||
const beforeUndo = { download_count: 0, latest_count: 0, save_count_after_cancel: backend.saves, state_version: backend.version };
|
const beforeUndo = { download_count: 0, latest_count: 0, save_count_after_cancel: backend.saves, state_version: backend.version };
|
||||||
await page.getByRole("button", { name: "撤销" }).click();
|
await page.getByRole("button", { name: "撤销" }).click();
|
||||||
await expect(page.getByLabel("文字内容")).toHaveValue("春日计划");
|
await expect(page.getByLabel("文字内容")).toHaveCount(0);
|
||||||
evidence("TDD-WP4-EXP-001-cancel-pending-edit", "network-timeline.json", { ...beforeUndo, compose_calls: 0, export_save_calls: 0 });
|
evidence("TDD-WP4-EXP-001-cancel-pending-edit", "network-timeline.json", { ...beforeUndo, compose_calls: 0, export_save_calls: 0 });
|
||||||
evidence("TDD-WP4-EXP-001-cancel-pending-edit", "db-diff.json", { committed_text_after_cancel: "自动保存的导出文字", first_undo_restored_initial_text: true, latest_exports_changed: false });
|
evidence("TDD-WP4-EXP-001-cancel-pending-edit", "db-diff.json", { committed_text_after_cancel: "春日计划", first_undo_removed_initial_element: true, latest_exports_changed: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("TDD-WP4-EXP-001 exports automatically saved text and saves the same bytes", async ({ page }) => {
|
test("TDD-WP4-EXP-001 confirm commits once, downloads, and saves the same bytes", async ({ page }) => {
|
||||||
const projectId = "00000000-0000-4000-8000-000000000930";
|
const projectId = "00000000-0000-4000-8000-000000000930";
|
||||||
const assetId = "00000000-0000-4000-8000-000000000931";
|
const assetId = "00000000-0000-4000-8000-000000000931";
|
||||||
const backend: Backend = { canvas: canvasForRatio("3:4", assetId), latestBodies: [], saves: 0, version: 4 };
|
const backend: Backend = { canvas: canvasForRatio("3:4", assetId), latestBodies: [], saves: 0, version: 4 };
|
||||||
@@ -119,7 +117,6 @@ test("TDD-WP4-EXP-001 exports automatically saved text and saves the same bytes"
|
|||||||
await page.getByRole("button", { name: /FLOWER001 春日计划/ }).click();
|
await page.getByRole("button", { name: /FLOWER001 春日计划/ }).click();
|
||||||
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBe(1);
|
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBe(1);
|
||||||
await page.getByLabel("文字内容").fill("确认后进入导出");
|
await page.getByLabel("文字内容").fill("确认后进入导出");
|
||||||
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBe(2);
|
|
||||||
await page.getByRole("button", { name: "导出", exact: true }).click();
|
await page.getByRole("button", { name: "导出", exact: true }).click();
|
||||||
const dialog = page.getByRole("dialog", { name: "导出成品" });
|
const dialog = page.getByRole("dialog", { name: "导出成品" });
|
||||||
if (process.env.DADA_EVIDENCE_DIR_EXPORT) {
|
if (process.env.DADA_EVIDENCE_DIR_EXPORT) {
|
||||||
@@ -127,14 +124,14 @@ test("TDD-WP4-EXP-001 exports automatically saved text and saves the same bytes"
|
|||||||
mkdirSync(dirname(screenshot), { recursive: true });
|
mkdirSync(dirname(screenshot), { recursive: true });
|
||||||
await page.screenshot({ fullPage: true, path: screenshot });
|
await page.screenshot({ fullPage: true, path: screenshot });
|
||||||
}
|
}
|
||||||
await expect(dialog.getByRole("checkbox", { name: "将应用当前修改并导出" })).toHaveCount(0);
|
await dialog.getByRole("checkbox", { name: "将应用当前修改并导出" }).check();
|
||||||
const downloadPromise = page.waitForEvent("download");
|
const downloadPromise = page.waitForEvent("download");
|
||||||
await dialog.getByRole("button", { name: "导出并下载" }).click();
|
await dialog.getByRole("button", { name: "导出并下载" }).click();
|
||||||
const download = await downloadPromise;
|
const download = await downloadPromise;
|
||||||
const downloadPath = await download.path();
|
const downloadPath = await download.path();
|
||||||
if (!downloadPath) throw new Error("Browser download did not expose a local path.");
|
if (!downloadPath) throw new Error("Browser download did not expose a local path.");
|
||||||
await expect(dialog.getByRole("status")).toHaveText("已下载并保存为最新成品");
|
await expect(dialog.getByRole("status")).toHaveText("已下载并保存为最新成品");
|
||||||
expect(backend.saves).toBe(2);
|
await expect.poll(() => backend.saves).toBe(2);
|
||||||
expect(backend.canvas.elements[0]?.content).toBe("确认后进入导出");
|
expect(backend.canvas.elements[0]?.content).toBe("确认后进入导出");
|
||||||
expect(backend.latestBodies).toHaveLength(1);
|
expect(backend.latestBodies).toHaveLength(1);
|
||||||
const downloaded = readFileSync(downloadPath);
|
const downloaded = readFileSync(downloadPath);
|
||||||
|
|||||||
@@ -64,10 +64,7 @@ async function routeEditor(page: Page, backend: Backend) {
|
|||||||
});
|
});
|
||||||
await page.route("**/api/v1/assets/recent?asset_kind=text_template", (route) => route.fulfill({ body: JSON.stringify({ items: [] }), contentType: "application/json" }));
|
await page.route("**/api/v1/assets/recent?asset_kind=text_template", (route) => route.fulfill({ body: JSON.stringify({ items: [] }), contentType: "application/json" }));
|
||||||
await page.route("**/api/v1/assets/recent", (route) => route.fulfill({ body: JSON.stringify({ status: "recorded" }), contentType: "application/json" }));
|
await page.route("**/api/v1/assets/recent", (route) => route.fulfill({ body: JSON.stringify({ status: "recorded" }), contentType: "application/json" }));
|
||||||
await page.route("**/api/v1/assets/public/p0a-complex-v1/*", (route) => {
|
await page.route("**/api/v1/assets/public/p0a-complex-v1/*", (route) => route.fulfill({ body: fontBytes, contentType: "font/ttf" }));
|
||||||
const preview = new URL(route.request().url()).pathname.includes("TEXT-PREVIEW-");
|
|
||||||
return route.fulfill(preview ? { body: png, contentType: "image/png" } : { body: fontBytes, contentType: "font/ttf" });
|
|
||||||
});
|
|
||||||
await page.route("**/api/v1/assets/public/p0a-static-v1/*", (route) => route.fulfill({ body: png, contentType: "image/png" }));
|
await page.route("**/api/v1/assets/public/p0a-static-v1/*", (route) => route.fulfill({ body: png, contentType: "image/png" }));
|
||||||
await page.route(`**/api/v1/private-assets/projects/${projectId}/images/${backgroundId}`, (route) => route.fulfill({ body: rawSvg(), contentType: "image/svg+xml" }));
|
await page.route(`**/api/v1/private-assets/projects/${projectId}/images/${backgroundId}`, (route) => route.fulfill({ body: rawSvg(), contentType: "image/svg+xml" }));
|
||||||
}
|
}
|
||||||
@@ -94,7 +91,7 @@ test.beforeAll(async () => {
|
|||||||
|
|
||||||
test.afterAll(async () => vite.close());
|
test.afterAll(async () => vite.close());
|
||||||
|
|
||||||
test("POSTV1-ASSET-ALL-16 exposes the complete complex asset catalog", async ({ page }) => {
|
test("TDD-WP5-WHITE-001 exposes only the P0-A public allowlist", async ({ page }) => {
|
||||||
const backend: Backend = { canvas: canvas(), saves: 0, version: 1 };
|
const backend: Backend = { canvas: canvas(), saves: 0, version: 1 };
|
||||||
await routeEditor(page, backend);
|
await routeEditor(page, backend);
|
||||||
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
||||||
@@ -102,6 +99,7 @@ test("POSTV1-ASSET-ALL-16 exposes the complete complex asset catalog", async ({
|
|||||||
await page.getByRole("button", { name: "文字模板", exact: true }).click();
|
await page.getByRole("button", { name: "文字模板", exact: true }).click();
|
||||||
const textIds = await page.locator(".editor-template-grid button strong").allTextContents();
|
const textIds = await page.locator(".editor-template-grid button strong").allTextContents();
|
||||||
expect(textIds).toEqual(P0A_TEXT_TEMPLATE_IDS);
|
expect(textIds).toEqual(P0A_TEXT_TEMPLATE_IDS);
|
||||||
|
expect(textIds).not.toContain("FLOWER009");
|
||||||
await page.getByRole("button", { name: /FLOWER001 春日计划/ }).click();
|
await page.getByRole("button", { name: /FLOWER001 春日计划/ }).click();
|
||||||
await expect(page.getByText("对象 1 / 50")).toBeVisible();
|
await expect(page.getByText("对象 1 / 50")).toBeVisible();
|
||||||
await expect(page.getByLabel("字体覆盖")).toBeVisible();
|
await expect(page.getByLabel("字体覆盖")).toBeVisible();
|
||||||
@@ -111,12 +109,12 @@ test("POSTV1-ASSET-ALL-16 exposes the complete complex asset catalog", async ({
|
|||||||
await page.getByRole("button", { name: "色卡", exact: true }).click();
|
await page.getByRole("button", { name: "色卡", exact: true }).click();
|
||||||
const colorIds = await page.locator(".editor-provider-grid button strong").allTextContents();
|
const colorIds = await page.locator(".editor-provider-grid button strong").allTextContents();
|
||||||
expect(colorIds).toEqual(P0A_COLOR_CARD_IDS);
|
expect(colorIds).toEqual(P0A_COLOR_CARD_IDS);
|
||||||
|
expect(colorIds).not.toContain("COLOR003");
|
||||||
|
|
||||||
await page.getByRole("button", { name: "动态贴纸", exact: true }).click();
|
await page.getByRole("button", { name: "动态贴纸", exact: true }).click();
|
||||||
const dynamicIds = await page.locator(".editor-provider-grid button strong").allTextContents();
|
const dynamicIds = await page.locator(".editor-provider-grid button strong").allTextContents();
|
||||||
expect([...dynamicIds].sort()).toEqual([...P0A_DYNAMIC_STICKER_IDS].sort());
|
expect([...dynamicIds].sort()).toEqual([...P0A_DYNAMIC_STICKER_IDS].sort());
|
||||||
await page.getByRole("button", { name: /添加动态贴纸 DYN035/ }).click();
|
expect(dynamicIds).not.toContain("DYN005");
|
||||||
await expect.poll(() => backend.canvas.elements.some((element) => element.template_or_asset_id === "DYN035")).toBe(true);
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "普通贴纸", exact: true }).click();
|
await page.getByRole("button", { name: "普通贴纸", exact: true }).click();
|
||||||
await expect(page.getByText("共 1,407 张", { exact: true })).toBeVisible();
|
await expect(page.getByText("共 1,407 张", { exact: true })).toBeVisible();
|
||||||
@@ -132,22 +130,27 @@ test("POSTV1-ASSET-ALL-16 exposes the complete complex asset catalog", async ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("POSTV1-ASSET-ALL-16 renders sixteen layouts from one shared five-color snapshot", async ({ page }) => {
|
test("TDD-WP5-COL-001 renders four layouts from one shared five-color snapshot", async ({ page }) => {
|
||||||
test.setTimeout(60_000);
|
|
||||||
const backend: Backend = { canvas: canvas(), saves: 0, version: 1 };
|
const backend: Backend = { canvas: canvas(), saves: 0, version: 1 };
|
||||||
await routeEditor(page, backend);
|
await routeEditor(page, backend);
|
||||||
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
||||||
await page.getByRole("button", { name: "色卡", exact: true }).click();
|
await page.getByRole("button", { name: "色卡", exact: true }).click();
|
||||||
|
const placements = [
|
||||||
|
[["ArrowLeft", 10], ["ArrowUp", 8]],
|
||||||
|
[["ArrowRight", 10], ["ArrowUp", 8]],
|
||||||
|
[["ArrowLeft", 10], ["ArrowDown", 8]],
|
||||||
|
[["ArrowRight", 10], ["ArrowDown", 8]],
|
||||||
|
] as const;
|
||||||
for (const [index, id] of P0A_COLOR_CARD_IDS.entries()) {
|
for (const [index, id] of P0A_COLOR_CARD_IDS.entries()) {
|
||||||
await page.getByRole("button", { name: new RegExp(`添加色卡 ${id}`) }).click();
|
await page.getByRole("button", { name: new RegExp(`添加色卡 ${id}`) }).click();
|
||||||
await expect(page.getByText(`对象 ${index + 1} / 50`)).toBeVisible();
|
await expect.poll(() => backend.canvas.elements.length).toBe(index + 1);
|
||||||
|
for (const [key, times] of placements[index]!) {
|
||||||
|
for (let press = 0; press < times; press += 1) await page.getByLabel("编辑画布").press(`Shift+${key}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await expect.poll(() => backend.canvas.elements.length, { timeout: 10_000 }).toBe(16);
|
|
||||||
const palettes = backend.canvas.elements.map((element) => element.colors);
|
const palettes = backend.canvas.elements.map((element) => element.colors);
|
||||||
expect(palettes.every((palette) => JSON.stringify(palette) === JSON.stringify(palettes[0]))).toBe(true);
|
expect(palettes.every((palette) => JSON.stringify(palette) === JSON.stringify(palettes[0]))).toBe(true);
|
||||||
expect(backend.canvas.elements.map((element) => element.style_id)).toEqual(
|
expect(backend.canvas.elements.map((element) => element.style_id)).toEqual(["style_01", "style_02", "style_08", "style_16"]);
|
||||||
Array.from({ length: 16 }, (_, index) => `style_${String(index + 1).padStart(2, "0")}`),
|
|
||||||
);
|
|
||||||
const pixels = await page.getByLabel("编辑画布").evaluate((stage: HTMLCanvasElement) => {
|
const pixels = await page.getByLabel("编辑画布").evaluate((stage: HTMLCanvasElement) => {
|
||||||
const context = stage.getContext("2d");
|
const context = stage.getContext("2d");
|
||||||
if (!context) throw new Error("canvas context unavailable");
|
if (!context) throw new Error("canvas context unavailable");
|
||||||
@@ -158,7 +161,7 @@ test("POSTV1-ASSET-ALL-16 renders sixteen layouts from one shared five-color sna
|
|||||||
});
|
});
|
||||||
mergeEvidence(evidencePath("color", "palette.json"), { browser_palettes: palettes, same_palette_snapshot: true });
|
mergeEvidence(evidencePath("color", "palette.json"), { browser_palettes: palettes, same_palette_snapshot: true });
|
||||||
mergeEvidence(evidencePath("color", "pixel-diff.json"), {
|
mergeEvidence(evidencePath("color", "pixel-diff.json"), {
|
||||||
...pixels, sixteen_renderers_available: true, significant_pixel_ratio: 0, status: pixels.opaque_pixels > 0 ? "passed" : "failed",
|
...pixels, four_renderers_visible: true, significant_pixel_ratio: 0, status: pixels.opaque_pixels > 0 ? "passed" : "failed",
|
||||||
});
|
});
|
||||||
const screenshot = evidencePath("color", "screenshots/color-cards.png");
|
const screenshot = evidencePath("color", "screenshots/color-cards.png");
|
||||||
if (screenshot) {
|
if (screenshot) {
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import test from "node:test";
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
test("POSTV1-11 release accepts the Codex embedded Chrome major", () => {
|
|
||||||
const release = JSON.parse(
|
|
||||||
fs.readFileSync(path.resolve("RELEASE.json"), "utf8"),
|
|
||||||
);
|
|
||||||
const chrome = release.browsers.find(({ brand }) => brand === "Google Chrome");
|
|
||||||
|
|
||||||
assert.ok(chrome, "release must include Google Chrome");
|
|
||||||
assert.equal(Number.parseInt(chrome.fullVersion.split(".")[0], 10), 151);
|
|
||||||
});
|
|
||||||
@@ -5,8 +5,6 @@ import { tmpdir } from "node:os";
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
|
||||||
import complexAssetCatalog from "../../apps/web/src/generated/complex-assets.json" with { type: "json" };
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createRuntimeAssetManifest,
|
createRuntimeAssetManifest,
|
||||||
deployRuntimeAssetPlan,
|
deployRuntimeAssetPlan,
|
||||||
@@ -16,29 +14,13 @@ import {
|
|||||||
|
|
||||||
test("committed P0-A runtime manifest covers the frozen first-version binary assets", () => {
|
test("committed P0-A runtime manifest covers the frozen first-version binary assets", () => {
|
||||||
const manifest = readRuntimeAssetManifest("config/runtime-assets-manifest.json");
|
const manifest = readRuntimeAssetManifest("config/runtime-assets-manifest.json");
|
||||||
assert.equal(manifest.counts.dynamic_fonts, 18);
|
assert.deepEqual(manifest.counts, {
|
||||||
assert.equal(manifest.counts.dynamic_images, 43);
|
dynamic_fonts: 7,
|
||||||
assert.equal(manifest.counts.font_panel_items, 86);
|
dynamic_images: 8,
|
||||||
assert.equal(manifest.counts.static_stickers, 1407);
|
font_panel_items: 11,
|
||||||
assert.equal(manifest.counts.text_fonts >= 332, true);
|
static_stickers: 1407,
|
||||||
assert.equal(manifest.counts.text_images >= 300, true);
|
});
|
||||||
assert.equal(manifest.counts.text_previews, 261);
|
assert.equal(manifest.entries.length, 1433);
|
||||||
assert.equal(manifest.entries.length, Object.values(manifest.counts).reduce((sum, count) => sum + count, 0));
|
|
||||||
const publicAssetKeys = new Set(manifest.entries.map((entry) => `${entry.resourceVersion}\u0000${entry.assetId}`));
|
|
||||||
for (const template of complexAssetCatalog.text_templates) {
|
|
||||||
for (const fontId of template.render_model.text_layers.map((layer) => layer.font_id)) {
|
|
||||||
assert.equal(publicAssetKeys.has(`p0a-complex-v1\u0000${fontId}`), true, `${template.template_id}:${fontId}`);
|
|
||||||
}
|
|
||||||
for (const imageId of template.render_model.image_layers.map((layer) => layer.asset_id)) {
|
|
||||||
assert.equal(publicAssetKeys.has(`p0a-complex-v1\u0000${imageId}`), true, `${template.template_id}:${imageId}`);
|
|
||||||
}
|
|
||||||
for (const imageId of template.render_model.particle_layers.map((layer) => layer.asset_id)) {
|
|
||||||
assert.equal(publicAssetKeys.has(`p0a-complex-v1\u0000${imageId}`), true, `${template.template_id}:${imageId}`);
|
|
||||||
}
|
|
||||||
for (const imageId of template.render_model.text_layers.flatMap((layer) => layer.fill_pattern_asset_id ? [layer.fill_pattern_asset_id] : [])) {
|
|
||||||
assert.equal(publicAssetKeys.has(`p0a-complex-v1\u0000${imageId}`), true, `${template.template_id}:${imageId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert.doesNotMatch(serializeRuntimeAssetManifest(manifest), /[A-Za-z]:[\\/]/);
|
assert.doesNotMatch(serializeRuntimeAssetManifest(manifest), /[A-Za-z]:[\\/]/);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -60,7 +42,7 @@ test("runtime asset deployment creates verified hardlinks and a path-free manife
|
|||||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||||
};
|
};
|
||||||
const manifest = createRuntimeAssetManifest({
|
const manifest = createRuntimeAssetManifest({
|
||||||
counts: { dynamic_fonts: 0, dynamic_images: 0, font_panel_items: 0, static_stickers: 1, text_fonts: 0, text_images: 0, text_previews: 0 },
|
counts: { dynamic_fonts: 0, dynamic_images: 0, font_panel_items: 0, static_stickers: 1 },
|
||||||
entries: [entry],
|
entries: [entry],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -93,7 +75,7 @@ test("runtime asset deployment refuses a mismatched existing target", async (t)
|
|||||||
sha256: createHash("sha256").update("expected").digest("hex"),
|
sha256: createHash("sha256").update("expected").digest("hex"),
|
||||||
};
|
};
|
||||||
const manifest = createRuntimeAssetManifest({
|
const manifest = createRuntimeAssetManifest({
|
||||||
counts: { dynamic_fonts: 0, dynamic_images: 0, font_panel_items: 0, static_stickers: 1, text_fonts: 0, text_images: 0, text_previews: 0 },
|
counts: { dynamic_fonts: 0, dynamic_images: 0, font_panel_items: 0, static_stickers: 1 },
|
||||||
entries: [entry],
|
entries: [entry],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -102,32 +84,3 @@ test("runtime asset deployment refuses a mismatched existing target", async (t)
|
|||||||
/asset_target_conflict/,
|
/asset_target_conflict/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("runtime asset deployment upgrades only an unchanged file covered by its previous manifest", async (t) => {
|
|
||||||
const root = await mkdtemp(join(tmpdir(), "dada-runtime-assets-upgrade-"));
|
|
||||||
t.after(() => rm(root, { force: true, recursive: true }));
|
|
||||||
const assetRoot = join(root, "assets");
|
|
||||||
const firstSourcePath = join(root, "source-v1.ttf");
|
|
||||||
const secondSourcePath = join(root, "source-v2.ttf");
|
|
||||||
const entry = (bytes) => ({
|
|
||||||
assetId: "TEXT-FONT-FIXTURE-01",
|
|
||||||
mimeType: "font/ttf",
|
|
||||||
relativePath: "p0a-complex-v1/TEXT-FONT-FIXTURE-01.ttf",
|
|
||||||
resourceVersion: "p0a-complex-v1",
|
|
||||||
rootRef: "p0a_runtime_assets",
|
|
||||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
||||||
});
|
|
||||||
const counts = { dynamic_fonts: 0, dynamic_images: 0, font_panel_items: 0, static_stickers: 0, text_fonts: 1, text_images: 0, text_previews: 0 };
|
|
||||||
await writeFile(firstSourcePath, "version one");
|
|
||||||
const firstEntry = entry("version one");
|
|
||||||
const firstManifest = createRuntimeAssetManifest({ counts, entries: [firstEntry] });
|
|
||||||
deployRuntimeAssetPlan({ assetRoot, manifest: firstManifest, resources: [{ entry: firstEntry, sourcePath: firstSourcePath }] });
|
|
||||||
|
|
||||||
await writeFile(secondSourcePath, "version two");
|
|
||||||
const secondEntry = entry("version two");
|
|
||||||
const secondManifest = createRuntimeAssetManifest({ counts, entries: [secondEntry] });
|
|
||||||
deployRuntimeAssetPlan({ allowManagedUpdate: true, assetRoot, manifest: secondManifest, resources: [{ entry: secondEntry, sourcePath: secondSourcePath }] });
|
|
||||||
|
|
||||||
assert.equal(await readFile(join(assetRoot, secondEntry.relativePath), "utf8"), "version two");
|
|
||||||
assert.deepEqual(readRuntimeAssetManifest(join(assetRoot, "manifest.json")), secondManifest);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -114,13 +114,12 @@ test("portable package serves the product and keeps SQLite data across API resta
|
|||||||
assert.ok(existsSync(join(packageRoot, "web", "index.html")));
|
assert.ok(existsSync(join(packageRoot, "web", "index.html")));
|
||||||
const runtimeAssetManifest = JSON.parse(await readFile(join(packageRoot, "asset-metadata", "manifest.json"), "utf8"));
|
const runtimeAssetManifest = JSON.parse(await readFile(join(packageRoot, "asset-metadata", "manifest.json"), "utf8"));
|
||||||
assert.deepEqual(runtimeAssetManifest.counts, {
|
assert.deepEqual(runtimeAssetManifest.counts, {
|
||||||
dynamic_fonts: 18,
|
dynamic_fonts: 7,
|
||||||
dynamic_images: 43,
|
dynamic_images: 8,
|
||||||
font_panel_items: 86,
|
font_panel_items: 11,
|
||||||
static_stickers: 1407,
|
static_stickers: 1407,
|
||||||
text_previews: 261,
|
|
||||||
});
|
});
|
||||||
assert.equal(runtimeAssetManifest.entries.length, 1815);
|
assert.equal(runtimeAssetManifest.entries.length, 1433);
|
||||||
const packagedWorker = await readFile(join(packageRoot, "server", "worker", "dist", "worker.js"), "utf8");
|
const packagedWorker = await readFile(join(packageRoot, "server", "worker", "dist", "worker.js"), "utf8");
|
||||||
const packagedOneApiAdapter = await readFile(join(packageRoot, "server", "worker", "dist", "oneapi-generation-adapter.js"), "utf8");
|
const packagedOneApiAdapter = await readFile(join(packageRoot, "server", "worker", "dist", "oneapi-generation-adapter.js"), "utf8");
|
||||||
assert.match(packagedWorker, /GenerationProcessor/);
|
assert.match(packagedWorker, /GenerationProcessor/);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import { join, resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
import { buildAndValidatePortablePackage } from "../../scripts/lib/portable-package.mjs";
|
import { buildAndValidatePortablePackage } from "../../scripts/lib/portable-package.mjs";
|
||||||
|
|
||||||
@@ -23,12 +22,4 @@ test("builds an isolated candidate portable package", async () => {
|
|||||||
assert.equal(result.processTree.native.status, "passed");
|
assert.equal(result.processTree.native.status, "passed");
|
||||||
assert.equal(result.processTree.supervisor.credential_store_access, false);
|
assert.equal(result.processTree.supervisor.credential_store_access, false);
|
||||||
assert.equal(result.processTree.supervisor.status, "passed");
|
assert.equal(result.processTree.supervisor.status, "passed");
|
||||||
|
|
||||||
const packageDirectory = join(outputRoot, result.packageManifest.package_name);
|
|
||||||
const webIndex = readFileSync(join(packageDirectory, "web", "index.html"), "utf8");
|
|
||||||
const startupBundles = result.packageManifest.files
|
|
||||||
.map((entry) => entry.path)
|
|
||||||
.filter((path) => /^web\/assets\/index-[^/]+\.js$/u.test(path));
|
|
||||||
assert.equal(startupBundles.length, 1);
|
|
||||||
assert.match(webIndex, new RegExp(`/${startupBundles[0].slice("web/".length).replace(".", "\\.")}`));
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,246 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import complexAssetCatalog from "../../apps/web/src/generated/complex-assets.json";
|
|
||||||
import { P0A_DYNAMIC_STICKERS } from "../../apps/web/src/dynamic-provider.js";
|
|
||||||
import { DYNAMIC_RENDER_MODELS } from "../../apps/web/src/dynamic-render-models.js";
|
|
||||||
import { P0A_COLOR_CARDS } from "../../apps/web/src/palette-provider.js";
|
|
||||||
import { P0A_FONT_OPTIONS, P0A_TEXT_TEMPLATES } from "../../apps/web/src/text-assets.js";
|
|
||||||
import { boundedTextTemplateWidth, ninePatchSlices, textTemplateBoxLayout, textTextureCell } from "../../apps/web/src/editor-stage.js";
|
|
||||||
import {
|
|
||||||
P0A_COLOR_CARD_IDS,
|
|
||||||
P0A_DYNAMIC_STICKER_IDS,
|
|
||||||
P0A_REQUIRED_FONT_PANEL_IDS,
|
|
||||||
P0A_TEXT_TEMPLATE_IDS,
|
|
||||||
} from "../../packages/template-registry/src/index.js";
|
|
||||||
|
|
||||||
function containsAbsolutePath(value: unknown): boolean {
|
|
||||||
if (typeof value === "string") return /^[A-Z]:[\\/]/i.test(value);
|
|
||||||
if (Array.isArray(value)) return value.some(containsAbsolutePath);
|
|
||||||
return value !== null && typeof value === "object" && Object.values(value).some(containsAbsolutePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("POSTV1-ASSET-ALL-16 complete asset catalog", () => {
|
|
||||||
it("publishes every normalized complex asset instead of the original alpha subset", () => {
|
|
||||||
expect(P0A_TEXT_TEMPLATE_IDS).toHaveLength(332);
|
|
||||||
expect(P0A_TEXT_TEMPLATE_IDS[0]).toBe("FLOWER001");
|
|
||||||
expect(P0A_TEXT_TEMPLATE_IDS.at(-1)).toBe("SIMPLE017");
|
|
||||||
|
|
||||||
expect(P0A_REQUIRED_FONT_PANEL_IDS).toHaveLength(86);
|
|
||||||
expect(P0A_REQUIRED_FONT_PANEL_IDS[0]).toBe("FONT001");
|
|
||||||
expect(P0A_REQUIRED_FONT_PANEL_IDS.at(-1)).toBe("FONT086");
|
|
||||||
|
|
||||||
expect(P0A_COLOR_CARD_IDS).toHaveLength(16);
|
|
||||||
expect(P0A_COLOR_CARD_IDS[0]).toBe("COLOR001");
|
|
||||||
expect(P0A_COLOR_CARD_IDS.at(-1)).toBe("COLOR016");
|
|
||||||
|
|
||||||
expect(P0A_DYNAMIC_STICKER_IDS).toHaveLength(35);
|
|
||||||
expect(P0A_DYNAMIC_STICKER_IDS[0]).toBe("DYN001");
|
|
||||||
expect(P0A_DYNAMIC_STICKER_IDS.at(-1)).toBe("DYN035");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("generates a sanitized browser catalog for every text, font, and dynamic definition", () => {
|
|
||||||
expect(complexAssetCatalog.schema_version).toBe("DadaComplexBrowserCatalog/v2");
|
|
||||||
expect(complexAssetCatalog.text_resource_revision).toMatch(/^[a-f0-9]{16}$/);
|
|
||||||
expect(complexAssetCatalog.text_templates).toHaveLength(332);
|
|
||||||
expect(complexAssetCatalog.font_panel_items).toHaveLength(86);
|
|
||||||
expect(complexAssetCatalog.dynamic_stickers).toHaveLength(35);
|
|
||||||
expect(complexAssetCatalog.text_templates.filter((item) => item.preview_asset_id)).toHaveLength(261);
|
|
||||||
expect(complexAssetCatalog.text_templates.every((item) => item.available)).toBe(true);
|
|
||||||
expect(complexAssetCatalog.text_templates.every((item) => item.render_model.text_layers.length > 0)).toBe(true);
|
|
||||||
expect(complexAssetCatalog.text_templates.filter((item) => item.resource_class === "zip_template")).toHaveLength(330);
|
|
||||||
expect(complexAssetCatalog.text_templates.reduce((count, item) => count + item.render_model.image_layers.length, 0)).toBe(316);
|
|
||||||
expect(complexAssetCatalog.text_templates.reduce((count, item) => count + item.render_model.particle_layers.length, 0)).toBe(9);
|
|
||||||
expect(complexAssetCatalog.text_templates.reduce((count, item) => count + item.render_model.text_layers.length, 0)).toBe(490);
|
|
||||||
expect(complexAssetCatalog.text_templates.filter((item) => item.render_model.text_layers.some((layer) => "fill_pattern_asset_id" in layer))).toHaveLength(14);
|
|
||||||
expect(containsAbsolutePath(complexAssetCatalog)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the captured template font, default text, style, and decoration layers", () => {
|
|
||||||
const byId = new Map(complexAssetCatalog.text_templates.map((item) => [item.template_id, item]));
|
|
||||||
const flower001 = byId.get("FLOWER001")!;
|
|
||||||
expect(flower001.default_font_id).toMatch(/^TEXT-FONT-FLOWER001-/);
|
|
||||||
expect(flower001.font_match_status).toBe("template_package");
|
|
||||||
expect(flower001.render_model.image_layers).toHaveLength(1);
|
|
||||||
expect(flower001.render_model.text_layers[0]).toMatchObject({
|
|
||||||
fill_color: "#FFC5D4",
|
|
||||||
stroke_color: "#FF69A6",
|
|
||||||
stroke_width: 5,
|
|
||||||
});
|
|
||||||
|
|
||||||
const flower008 = byId.get("FLOWER008")!;
|
|
||||||
expect(flower008.default_text).toBe("Vlog.");
|
|
||||||
expect(flower008.default_font_id).toMatch(/^TEXT-FONT-FLOWER008-/);
|
|
||||||
|
|
||||||
const heading001 = byId.get("H001")!;
|
|
||||||
expect(heading001.render_model.image_layers).toHaveLength(5);
|
|
||||||
expect(heading001.default_font_id).toMatch(/^TEXT-FONT-H001-/);
|
|
||||||
|
|
||||||
const materialText = byId.get("FLOWER048")!;
|
|
||||||
expect(materialText.render_model.text_layers[0]).toHaveProperty("fill_pattern_asset_id");
|
|
||||||
|
|
||||||
expect(byId.get("FLOWER037")!.render_model.text_layers[0]).toMatchObject({
|
|
||||||
fill_gradient: { bottom: "#FC686F", top: "#FFEB7B" },
|
|
||||||
});
|
|
||||||
expect(byId.get("FLOWER077")!.render_model.text_layers[0]).toMatchObject({ skew_x: 0, skew_y: 11 });
|
|
||||||
expect(byId.get("FLOWER125")!.render_model.text_layers[0]).toMatchObject({
|
|
||||||
fill_texture_layout: { columns: 1, id_list: [0, 0, 0, 0, 0, 0], rows: 1 },
|
|
||||||
glyph_colors: ["#FC7878", "#F0F054", "#F28FDE", "#47BAFF", "#FFB80F", "#4ADB91"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const underlinedText = byId.get("FLOWER121")!;
|
|
||||||
expect(underlinedText.render_model.image_layers).toHaveLength(1);
|
|
||||||
|
|
||||||
const particleText = byId.get("H013")!;
|
|
||||||
expect(particleText.render_model.particle_layers).toHaveLength(3);
|
|
||||||
|
|
||||||
const ninePatchLayers = complexAssetCatalog.text_templates.flatMap((item) => item.render_model.image_layers)
|
|
||||||
.filter((layer) => "nine_patch" in layer);
|
|
||||||
expect(ninePatchLayers.length).toBeGreaterThanOrEqual(100);
|
|
||||||
expect(byId.get("FLOWER023")!.render_model.image_layers[0]).toMatchObject({
|
|
||||||
nine_patch: { bottom: 16, left: 53, right: 25, source_height: 63, source_width: 159, top: 12 },
|
|
||||||
});
|
|
||||||
const motherBackground = byId.get("FLOWER077")!.render_model.image_layers[0]!;
|
|
||||||
expect(motherBackground.nine_patch).toMatchObject({
|
|
||||||
bottom: 0.407, left: 19.108, right: 19.514, source_height: 224, source_width: 445, top: 8.944,
|
|
||||||
});
|
|
||||||
expect(motherBackground.nine_patch!.left + motherBackground.nine_patch!.right).toBeLessThan(motherBackground.width);
|
|
||||||
expect(motherBackground.nine_patch!.top + motherBackground.nine_patch!.bottom).toBeLessThan(motherBackground.height);
|
|
||||||
expect(byId.get("H001")!.render_model.image_layers).toEqual(expect.arrayContaining([
|
|
||||||
expect.objectContaining({ anchor_x: 0 }),
|
|
||||||
expect.objectContaining({ anchor_x: 1 }),
|
|
||||||
]));
|
|
||||||
expect(byId.get("H079")!.render_model.text_layers.slice(0, 3).every((layer) => Math.abs(layer.rotation + 10) < 0.001)).toBe(true);
|
|
||||||
expect(byId.get("H005")!.render_model.text_layers[1]).toMatchObject({ anchor_y: 0 });
|
|
||||||
expect(byId.get("H005")!.render_model.text_layers[0]).toMatchObject({ vertical_align: "middle" });
|
|
||||||
expect(byId.get("H005")!.render_model.text_layers[1]).toMatchObject({ vertical_align: "top" });
|
|
||||||
expect(byId.get("H004")!.render_model.text_layers[0]).toMatchObject({ align: "center" });
|
|
||||||
expect(byId.get("H009")!.render_model.image_layers.find((layer) => layer.asset_id === "TEXT-IMAGE-H009-003"))
|
|
||||||
.toMatchObject({ anchor_x: 0 });
|
|
||||||
|
|
||||||
const heading016Text = byId.get("H016")!.render_model.text_layers;
|
|
||||||
expect(heading016Text).toHaveLength(3);
|
|
||||||
expect(heading016Text.every((layer) => layer.vertical_align === "bottom")).toBe(true);
|
|
||||||
|
|
||||||
const heading041 = byId.get("H041")!;
|
|
||||||
const heading041Text = heading041.render_model.text_layers[0]!;
|
|
||||||
const heading041Underline = heading041.render_model.image_layers.find((layer) => layer.asset_id === "TEXT-IMAGE-H041-004")!;
|
|
||||||
expect(heading041Text.stroke_width).toBe(0);
|
|
||||||
expect(heading041Text.letter_spacing).toBeCloseTo(2.714, 3);
|
|
||||||
expect(heading041Underline.y).toBeGreaterThan(heading041Text.y);
|
|
||||||
expect(byId.get("FLOWER024")!.render_model.image_layers[0]).toMatchObject({ alpha: 0.4000000059604645 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves source path, coordinate, and auto-layout semantics for fidelity regressions", () => {
|
|
||||||
const byId = new Map(complexAssetCatalog.text_templates.map((item) => [item.template_id, item]));
|
|
||||||
|
|
||||||
const summer = byId.get("H015")!.render_model.text_layers[0] as unknown as {
|
|
||||||
fill_texture_layout?: { columns: number; id_list: number[]; rows: number };
|
|
||||||
text_path?: { circle: boolean; points: Array<{ x: number; y: number }>; verbs: number[] };
|
|
||||||
};
|
|
||||||
expect(summer.text_path).toMatchObject({ circle: false, verbs: [0, 4, 4] });
|
|
||||||
expect(summer.text_path?.points).toHaveLength(7);
|
|
||||||
expect(summer.fill_texture_layout).toEqual({ columns: 2, id_list: [0, 1, 2, 3], rows: 2 });
|
|
||||||
|
|
||||||
const doubleSeventh = byId.get("H017")!.render_model.text_layers[0] as unknown as {
|
|
||||||
text_path?: { circle: boolean; points: Array<{ x: number; y: number }>; verbs: number[] };
|
|
||||||
};
|
|
||||||
expect(doubleSeventh.text_path).toMatchObject({ circle: true, verbs: [0, 3, 3, 3, 3, 5] });
|
|
||||||
expect(doubleSeventh.text_path?.points).toHaveLength(9);
|
|
||||||
|
|
||||||
const relax = byId.get("H022")!.render_model;
|
|
||||||
expect([...relax.text_layers, ...relax.particle_layers].every((layer) => layer.scale_y === 1)).toBe(true);
|
|
||||||
expect(relax.text_layers[0]!.y).toBeLessThan(relax.text_layers.at(-1)!.y);
|
|
||||||
expect(relax.text_layers.at(-1)!.font_id).toBe("TEXT-FONT-H022-03");
|
|
||||||
expect(relax.text_layers.slice(0, 3).map((layer) => layer.content_linked)).toEqual([true, true, true]);
|
|
||||||
|
|
||||||
const sightseeing = byId.get("H023")!.render_model;
|
|
||||||
expect(sightseeing.half_size.width).toBeGreaterThan(80);
|
|
||||||
expect(sightseeing.half_size.height).toBeGreaterThan(50);
|
|
||||||
expect(sightseeing.text_layers.every((layer) => layer.width > 1 && layer.height > 1)).toBe(true);
|
|
||||||
expect(sightseeing.particle_layers.every((layer) => layer.width > 1 && layer.height > 1)).toBe(true);
|
|
||||||
expect(sightseeing.text_layers.map((layer) => layer.font_id)).toEqual([
|
|
||||||
"TEXT-FONT-H023-03", "TEXT-FONT-H023-03", "TEXT-FONT-H023-03", "TEXT-FONT-H023-01", "TEXT-FONT-H023-02",
|
|
||||||
]);
|
|
||||||
expect(sightseeing.text_layers.slice(0, 3).map((layer) => layer.content_linked)).toEqual([true, true, true]);
|
|
||||||
|
|
||||||
const mother = byId.get("FLOWER077")!.render_model;
|
|
||||||
const motherText = mother.text_layers[0]!;
|
|
||||||
const motherBackground = mother.image_layers[0]!;
|
|
||||||
expect(motherText.width).toBeLessThan(motherBackground.width);
|
|
||||||
expect(Math.abs(motherText.x - motherBackground.x)).toBeLessThan(motherBackground.width * 0.05);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("maps each glyph to the source texture-atlas cell", () => {
|
|
||||||
const layout = { columns: 2, idList: [0, 1, 2, 3], rows: 2 };
|
|
||||||
expect(textTextureCell(layout, 0)).toEqual({ column: 0, row: 0 });
|
|
||||||
expect(textTextureCell(layout, 1)).toEqual({ column: 1, row: 0 });
|
|
||||||
expect(textTextureCell(layout, 2)).toEqual({ column: 0, row: 1 });
|
|
||||||
expect(textTextureCell(layout, 3)).toEqual({ column: 1, row: 1 });
|
|
||||||
expect(textTextureCell(layout, 7)).toEqual({ column: 1, row: 1 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("positions template text inside the captured fixed box instead of its measured glyph width", () => {
|
|
||||||
const shortText = textTemplateBoxLayout({
|
|
||||||
align: "center", anchorX: 0, anchorY: 1, boxHeight: 70, boxWidth: 517,
|
|
||||||
fontSize: 70, lineCount: 1, lineHeight: 1, verticalAlign: "bottom",
|
|
||||||
});
|
|
||||||
const longText = textTemplateBoxLayout({
|
|
||||||
align: "center", anchorX: 0, anchorY: 1, boxHeight: 70, boxWidth: 517,
|
|
||||||
fontSize: 70, lineCount: 1, lineHeight: 1, verticalAlign: "bottom",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(shortText.x).toBe(258.5);
|
|
||||||
expect(longText.x).toBe(shortText.x);
|
|
||||||
expect(shortText.firstY).toBe(-35);
|
|
||||||
expect(boundedTextTemplateWidth(640, 180)).toBe(180);
|
|
||||||
expect(boundedTextTemplateWidth(120, 180)).toBe(120);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("honors source top, middle, and bottom vertical alignment inside fixed text boxes", () => {
|
|
||||||
const layout = (verticalAlign: "top" | "middle" | "bottom") => textTemplateBoxLayout({
|
|
||||||
align: "left", anchorX: 0, anchorY: 0, boxHeight: 120, boxWidth: 200,
|
|
||||||
fontSize: 40, lineCount: 1, lineHeight: 1, verticalAlign,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(layout("top").firstY).toBe(20);
|
|
||||||
expect(layout("middle").firstY).toBe(60);
|
|
||||||
expect(layout("bottom").firstY).toBe(100);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves nine-patch edges while stretching only the decoration center", () => {
|
|
||||||
const slices = ninePatchSlices(231, 63, {
|
|
||||||
bottom: 16, left: 53, right: 25, sourceHeight: 63, sourceWidth: 159, top: 12,
|
|
||||||
});
|
|
||||||
expect(slices).toHaveLength(9);
|
|
||||||
expect(slices[0]).toEqual({ destination: { height: 12, width: 53, x: 0, y: 0 }, source: { height: 12, width: 53, x: 0, y: 0 } });
|
|
||||||
expect(slices[4]).toEqual({ destination: { height: 35, width: 153, x: 53, y: 12 }, source: { height: 35, width: 81, x: 53, y: 12 } });
|
|
||||||
expect(slices[8]).toEqual({ destination: { height: 16, width: 25, x: 206, y: 47 }, source: { height: 16, width: 25, x: 134, y: 47 } });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("scales nine-patch source slices to the deployed bitmap without changing layout edges", () => {
|
|
||||||
const slices = ninePatchSlices(231, 63, {
|
|
||||||
bottom: 16, left: 53, right: 25, sourceHeight: 63, sourceWidth: 159, top: 12,
|
|
||||||
}, { height: 199, width: 509 });
|
|
||||||
|
|
||||||
expect(slices[0]!.destination).toEqual({ height: 12, width: 53, x: 0, y: 0 });
|
|
||||||
expect(slices[4]!.destination).toEqual({ height: 35, width: 153, x: 53, y: 12 });
|
|
||||||
expect(slices[8]!.destination).toEqual({ height: 16, width: 25, x: 206, y: 47 });
|
|
||||||
expect(slices[4]!.source.x).toBeCloseTo(169.667, 3);
|
|
||||||
expect(slices[4]!.source.y).toBeCloseTo(37.905, 3);
|
|
||||||
expect(slices[4]!.source.width).toBeCloseTo(259.302, 3);
|
|
||||||
expect(slices[4]!.source.height).toBeCloseTo(110.556, 3);
|
|
||||||
expect(slices[8]!.source.x).toBeCloseTo(428.969, 3);
|
|
||||||
expect(slices[8]!.source.y).toBeCloseTo(148.46, 2);
|
|
||||||
expect(slices[8]!.source.width).toBeCloseTo(80.031, 3);
|
|
||||||
expect(slices[8]!.source.height).toBeCloseTo(50.54, 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("exposes every generated definition to the editor", () => {
|
|
||||||
expect(P0A_TEXT_TEMPLATES).toHaveLength(332);
|
|
||||||
expect(P0A_TEXT_TEMPLATES.every((item) => item.available && item.fontUrl)).toBe(true);
|
|
||||||
expect(P0A_FONT_OPTIONS).toHaveLength(86);
|
|
||||||
expect(P0A_COLOR_CARDS).toHaveLength(16);
|
|
||||||
expect(P0A_DYNAMIC_STICKERS).toHaveLength(35);
|
|
||||||
expect(Object.keys(DYNAMIC_RENDER_MODELS)).toHaveLength(35);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import { normalizeBrowserFontBytes } from "../../scripts/lib/text-template-assets.mjs";
|
|
||||||
|
|
||||||
function checksum(bytes: Buffer) {
|
|
||||||
let value = 0;
|
|
||||||
for (let index = 0; index < bytes.length; index += 4) {
|
|
||||||
let word = 0;
|
|
||||||
for (let byte = 0; byte < 4; byte += 1) word = (word << 8) | (bytes[index + byte] ?? 0);
|
|
||||||
value = (value + (word >>> 0)) >>> 0;
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fontFixture(verticalVersion: number) {
|
|
||||||
const bytes = Buffer.alloc(80);
|
|
||||||
bytes.writeUInt32BE(0x00010000, 0);
|
|
||||||
bytes.writeUInt16BE(3, 4);
|
|
||||||
bytes.write("head", 12, "ascii");
|
|
||||||
bytes.writeUInt32BE(60, 20);
|
|
||||||
bytes.writeUInt32BE(12, 24);
|
|
||||||
bytes.write("post", 28, "ascii");
|
|
||||||
bytes.writeUInt32BE(72, 36);
|
|
||||||
bytes.writeUInt32BE(4, 40);
|
|
||||||
bytes.write("vhea", 44, "ascii");
|
|
||||||
bytes.writeUInt32BE(76, 52);
|
|
||||||
bytes.writeUInt32BE(4, 56);
|
|
||||||
bytes.writeUInt32BE(0x00030000, 72);
|
|
||||||
bytes.writeUInt32BE(verticalVersion, 76);
|
|
||||||
return bytes;
|
|
||||||
}
|
|
||||||
|
|
||||||
function validFontFixture(verticalVersion: number) {
|
|
||||||
const bytes = fontFixture(verticalVersion);
|
|
||||||
bytes.writeUInt32BE((0xB1B0AFBA - checksum(bytes)) >>> 0, 68);
|
|
||||||
return bytes;
|
|
||||||
}
|
|
||||||
|
|
||||||
function tableRecord(bytes: Buffer, wantedTag: string) {
|
|
||||||
const tableCount = bytes.readUInt16BE(4);
|
|
||||||
for (let index = 0; index < tableCount; index += 1) {
|
|
||||||
const recordOffset = 12 + index * 16;
|
|
||||||
const tag = bytes.toString("ascii", recordOffset, recordOffset + 4);
|
|
||||||
if (tag === wantedTag) {
|
|
||||||
return {
|
|
||||||
length: bytes.readUInt32BE(recordOffset + 12),
|
|
||||||
offset: bytes.readUInt32BE(recordOffset + 8),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function invalidGlyphBoundsFixture() {
|
|
||||||
const bytes = Buffer.alloc(156);
|
|
||||||
bytes.writeUInt32BE(0x00010000, 0);
|
|
||||||
bytes.writeUInt16BE(4, 4);
|
|
||||||
const records = [
|
|
||||||
{ length: 10, offset: 76, tag: "glyf" },
|
|
||||||
{ length: 54, offset: 88, tag: "head" },
|
|
||||||
{ length: 4, offset: 144, tag: "loca" },
|
|
||||||
{ length: 6, offset: 148, tag: "maxp" },
|
|
||||||
];
|
|
||||||
records.forEach((record, index) => {
|
|
||||||
const directoryOffset = 12 + index * 16;
|
|
||||||
bytes.write(record.tag, directoryOffset, 4, "ascii");
|
|
||||||
bytes.writeUInt32BE(record.offset, directoryOffset + 8);
|
|
||||||
bytes.writeUInt32BE(record.length, directoryOffset + 12);
|
|
||||||
});
|
|
||||||
bytes.writeInt16BE(1, 76);
|
|
||||||
bytes.writeInt16BE(43, 78);
|
|
||||||
bytes.writeInt16BE(757, 80);
|
|
||||||
bytes.writeInt16BE(246, 82);
|
|
||||||
bytes.writeInt16BE(17, 84);
|
|
||||||
bytes.writeInt16BE(0, 138);
|
|
||||||
bytes.writeUInt16BE(0, 144);
|
|
||||||
bytes.writeUInt16BE(5, 146);
|
|
||||||
bytes.writeUInt32BE(0x00010000, 148);
|
|
||||||
bytes.writeUInt16BE(1, 152);
|
|
||||||
bytes.writeUInt32BE((0xB1B0AFBA - checksum(bytes)) >>> 0, 96);
|
|
||||||
return bytes;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("POSTV1-ASSET-ALL-16 browser font normalization", () => {
|
|
||||||
it("removes the captured malformed vertical metrics and recalculates SFNT checksums", () => {
|
|
||||||
const original = fontFixture(0x00010001);
|
|
||||||
const normalized = normalizeBrowserFontBytes(original);
|
|
||||||
expect(normalized).toBeDefined();
|
|
||||||
expect(original.readUInt32BE(76)).toBe(0x00010001);
|
|
||||||
expect(tableRecord(normalized!, "vhea")).toBeUndefined();
|
|
||||||
expect(tableRecord(normalized!, "vmtx")).toBeUndefined();
|
|
||||||
expect(tableRecord(normalized!, "head")).toEqual({ length: 12, offset: 44 });
|
|
||||||
expect(tableRecord(normalized!, "post")).toEqual({ length: 32, offset: 56 });
|
|
||||||
expect(normalized!.readUInt32BE(56)).toBe(0x00030000);
|
|
||||||
expect(checksum(normalized!)).toBe(0xB1B0AFBA);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not rewrite fonts whose vertical header is already valid", () => {
|
|
||||||
expect(normalizeBrowserFontBytes(validFontFixture(0x00010000))).toBeUndefined();
|
|
||||||
expect(normalizeBrowserFontBytes(validFontFixture(0x00011000))).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("repairs a stale whole-font checksum without changing a valid vertical header", () => {
|
|
||||||
const normalized = normalizeBrowserFontBytes(fontFixture(0x00011000));
|
|
||||||
expect(normalized).toBeDefined();
|
|
||||||
const verticalHeader = tableRecord(normalized!, "vhea");
|
|
||||||
expect(verticalHeader).toBeDefined();
|
|
||||||
expect(normalized!.readUInt32BE(verticalHeader!.offset)).toBe(0x00011000);
|
|
||||||
expect(checksum(normalized!)).toBe(0xB1B0AFBA);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("repairs reversed glyph bounds without changing the glyph outline payload", () => {
|
|
||||||
const normalized = normalizeBrowserFontBytes(invalidGlyphBoundsFixture());
|
|
||||||
expect(normalized).toBeDefined();
|
|
||||||
const glyphs = tableRecord(normalized!, "glyf");
|
|
||||||
expect(glyphs).toBeDefined();
|
|
||||||
expect(normalized!.readInt16BE(glyphs!.offset + 2)).toBe(43);
|
|
||||||
expect(normalized!.readInt16BE(glyphs!.offset + 4)).toBe(17);
|
|
||||||
expect(normalized!.readInt16BE(glyphs!.offset + 6)).toBe(246);
|
|
||||||
expect(normalized!.readInt16BE(glyphs!.offset + 8)).toBe(757);
|
|
||||||
expect(checksum(normalized!)).toBe(0xB1B0AFBA);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -3,11 +3,9 @@ import type { CanvasState } from "@dada/shared-contracts";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
P0A_TEXT_TEMPLATES,
|
P0A_TEXT_TEMPLATES,
|
||||||
P0A_FONT_OPTIONS,
|
|
||||||
TextEditSession,
|
TextEditSession,
|
||||||
createTextTemplateElement,
|
createTextTemplateElement,
|
||||||
effectiveFontSize,
|
effectiveFontSize,
|
||||||
fontOption,
|
|
||||||
searchTextTemplates,
|
searchTextTemplates,
|
||||||
} from "../../apps/web/src/text-assets.js";
|
} from "../../apps/web/src/text-assets.js";
|
||||||
import { elementHalfExtents } from "../../apps/web/src/editor-elements.js";
|
import { elementHalfExtents } from "../../apps/web/src/editor-elements.js";
|
||||||
@@ -15,15 +13,19 @@ import { elementHalfExtents } from "../../apps/web/src/editor-elements.js";
|
|||||||
const identity = { createdAt: "2026-08-03T03:00:00.000Z", elementId: "00000000-0000-4000-8000-000000000701" };
|
const identity = { createdAt: "2026-08-03T03:00:00.000Z", elementId: "00000000-0000-4000-8000-000000000701" };
|
||||||
|
|
||||||
describe("TASK-WP4-03 text templates and properties", () => {
|
describe("TASK-WP4-03 text templates and properties", () => {
|
||||||
it("keeps the complete 332-template catalog order and searches display names only", () => {
|
it("keeps the frozen 32-template allowlist in catalog order and searches display names only", () => {
|
||||||
expect(P0A_TEXT_TEMPLATES).toHaveLength(332);
|
expect(P0A_TEXT_TEMPLATES).toHaveLength(32);
|
||||||
expect(P0A_TEXT_TEMPLATES[0]?.templateId).toBe("FLOWER001");
|
expect(P0A_TEXT_TEMPLATES.map((template) => template.templateId)).toEqual([
|
||||||
expect(P0A_TEXT_TEMPLATES.at(-1)?.templateId).toBe("SIMPLE017");
|
"FLOWER001", "FLOWER002", "FLOWER003", "FLOWER004", "FLOWER005", "FLOWER006", "FLOWER007", "FLOWER008",
|
||||||
|
"H001", "H002", "H003", "H004", "H005", "H006", "H007", "H008",
|
||||||
|
"TAG001", "TAG002", "TAG003", "TAG004", "TAG005", "TAG006", "TAG007", "TAG051",
|
||||||
|
"SIMPLE001", "SIMPLE002", "SIMPLE003", "SIMPLE004", "SIMPLE005", "SIMPLE006", "SIMPLE007", "SIMPLE008",
|
||||||
|
]);
|
||||||
expect(P0A_TEXT_TEMPLATES.reduce<Record<string, number>>((counts, template) => {
|
expect(P0A_TEXT_TEMPLATES.reduce<Record<string, number>>((counts, template) => {
|
||||||
counts[template.category] = (counts[template.category] ?? 0) + 1;
|
counts[template.category] = (counts[template.category] ?? 0) + 1;
|
||||||
return counts;
|
return counts;
|
||||||
}, {})).toEqual({ flower: 145, simple: 17, tag: 51, title: 119 });
|
}, {})).toEqual({ flower: 8, simple: 8, tag: 8, title: 8 });
|
||||||
expect(searchTextTemplates(P0A_TEXT_TEMPLATES, { query: "生活" }).map((item) => item.templateId)).toEqual(expect.arrayContaining(["FLOWER004", "FLOWER005", "H001", "H003", "H006"]));
|
expect(searchTextTemplates(P0A_TEXT_TEMPLATES, { query: "生活" }).map((item) => item.templateId)).toEqual(["FLOWER004", "FLOWER005", "H001", "H003", "H006"]);
|
||||||
expect(searchTextTemplates(P0A_TEXT_TEMPLATES, { category: "tag", query: "TAG006" })).toEqual([]);
|
expect(searchTextTemplates(P0A_TEXT_TEMPLATES, { category: "tag", query: "TAG006" })).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -52,7 +54,7 @@ describe("TASK-WP4-03 text templates and properties", () => {
|
|||||||
template_or_asset_id: "H003",
|
template_or_asset_id: "H003",
|
||||||
});
|
});
|
||||||
expect(switched.style_parameters).toMatchObject({
|
expect(switched.style_parameters).toMatchObject({
|
||||||
fill_color: "#FFFFFF", letter_spacing: 0, line_height: 1, stroke_enabled: false,
|
fill_color: "#111111", letter_spacing: 1, line_height: 1.2, stroke_enabled: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -74,12 +76,12 @@ describe("TASK-WP4-03 text templates and properties", () => {
|
|||||||
|
|
||||||
it("synchronizes numeric font size with the canvas scale", () => {
|
it("synchronizes numeric font size with the canvas scale", () => {
|
||||||
const element = createTextTemplateElement(P0A_TEXT_TEMPLATES[0]!, identity, 0, { scale: { x: 1.5, y: 1.5 } });
|
const element = createTextTemplateElement(P0A_TEXT_TEMPLATES[0]!, identity, 0, { scale: { x: 1.5, y: 1.5 } });
|
||||||
expect(effectiveFontSize(element)).toBe(P0A_TEXT_TEMPLATES[0]!.defaultFontSize * 1.5);
|
expect(effectiveFontSize(element)).toBe(72);
|
||||||
const edit = new TextEditSession(element, P0A_TEXT_TEMPLATES);
|
const edit = new TextEditSession(element, P0A_TEXT_TEMPLATES);
|
||||||
edit.setEffectiveFontSize(96);
|
edit.setEffectiveFontSize(96);
|
||||||
const resized = edit.complete();
|
const resized = edit.complete();
|
||||||
expect(resized.font_size).toBe(P0A_TEXT_TEMPLATES[0]!.defaultFontSize);
|
expect(resized.font_size).toBe(48);
|
||||||
expect(resized.scale).toEqual({ x: 96 / P0A_TEXT_TEMPLATES[0]!.defaultFontSize, y: 96 / P0A_TEXT_TEMPLATES[0]!.defaultFontSize });
|
expect(resized.scale).toEqual({ x: 2, y: 2 });
|
||||||
expect(effectiveFontSize(resized)).toBe(96);
|
expect(effectiveFontSize(resized)).toBe(96);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -98,12 +100,11 @@ describe("TASK-WP4-03 text templates and properties", () => {
|
|||||||
expect(bounds.y).toBeGreaterThan(0.16);
|
expect(bounds.y).toBeGreaterThan(0.16);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("adds every template with its captured default font", () => {
|
it("does not add unavailable templates or silently replace their archived font", () => {
|
||||||
expect(P0A_TEXT_TEMPLATES.every((template) => template.available && template.fontUrl)).toBe(true);
|
const unavailable = P0A_TEXT_TEMPLATES.find((template) => !template.available)!;
|
||||||
expect(P0A_TEXT_TEMPLATES.every((template) => fontOption(template.defaultFontId)?.url === template.fontUrl)).toBe(true);
|
expect(unavailable).toBeDefined();
|
||||||
expect(P0A_TEXT_TEMPLATES.filter((template) => template.defaultFontId.startsWith("TEXT-FONT-"))).toHaveLength(332);
|
expect(() => createTextTemplateElement(unavailable, identity, 0)).toThrowError("text_template_unavailable");
|
||||||
expect(P0A_FONT_OPTIONS).toHaveLength(86);
|
const available = P0A_TEXT_TEMPLATES.find((template) => template.available)!;
|
||||||
const available = P0A_TEXT_TEMPLATES[0]!;
|
|
||||||
const element = createTextTemplateElement(available, identity, 0);
|
const element = createTextTemplateElement(available, identity, 0);
|
||||||
expect(element.font_override).toBeUndefined();
|
expect(element.font_override).toBeUndefined();
|
||||||
expect((element.style_parameters as Record<string, unknown>).default_font_id).toBe(available.defaultFontId);
|
expect((element.style_parameters as Record<string, unknown>).default_font_id).toBe(available.defaultFontId);
|
||||||
|
|||||||
@@ -45,10 +45,8 @@ describe("TASK-WP4-04 deterministic color cards", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses all sixteen color-card layouts and produces a stable five-color MMCQ palette", () => {
|
it("uses the four-item P0-A allowlist and produces a stable five-color MMCQ palette", () => {
|
||||||
expect(P0A_COLOR_CARDS).toHaveLength(16);
|
expect(P0A_COLOR_CARDS.map((item) => item.cardId)).toEqual(["COLOR001", "COLOR002", "COLOR008", "COLOR016"]);
|
||||||
expect(P0A_COLOR_CARDS[0]?.cardId).toBe("COLOR001");
|
|
||||||
expect(P0A_COLOR_CARDS.at(-1)?.cardId).toBe("COLOR016");
|
|
||||||
const pixels = colorPixels([
|
const pixels = colorPixels([
|
||||||
{ count: 50, rgb: [244, 32, 32] }, { count: 40, rgb: [32, 210, 96] }, { count: 30, rgb: [24, 96, 220] },
|
{ count: 50, rgb: [244, 32, 32] }, { count: 40, rgb: [32, 210, 96] }, { count: 30, rgb: [24, 96, 220] },
|
||||||
{ count: 20, rgb: [248, 210, 48] }, { count: 10, rgb: [120, 64, 180] },
|
{ count: 20, rgb: [248, 210, 48] }, { count: 10, rgb: [120, 64, 180] },
|
||||||
@@ -80,10 +78,10 @@ describe("TASK-WP4-04 dynamic providers", () => {
|
|||||||
profile: { creatorName: "Dada Creator", socialId: "@@dada" },
|
profile: { creatorName: "Dada Creator", socialId: "@@dada" },
|
||||||
};
|
};
|
||||||
|
|
||||||
it("exposes all thirty-five providers and snapshots time and identity", () => {
|
it("exposes exactly ten P0-A providers and snapshots time and identity", () => {
|
||||||
expect(P0A_DYNAMIC_STICKERS).toHaveLength(35);
|
expect(P0A_DYNAMIC_STICKERS.map((item) => item.templateId)).toEqual([
|
||||||
expect(P0A_DYNAMIC_STICKERS[0]?.templateId).toBe("DYN001");
|
"DYN001", "DYN002", "DYN003", "DYN004", "DYN007", "DYN008", "DYN011", "DYN012", "DYN015", "DYN016",
|
||||||
expect(P0A_DYNAMIC_STICKERS.at(-1)?.templateId).toBe("DYN035");
|
]);
|
||||||
const time = createDynamicStickerElement("DYN012", context, identity, 0);
|
const time = createDynamicStickerElement("DYN012", context, identity, 0);
|
||||||
expect(time.formatted_value).toBe("09:07");
|
expect(time.formatted_value).toBe("09:07");
|
||||||
expect(time.dynamic_fields).toMatchObject({ font_substitution: "FONT081", hour: "09", minute: "07" });
|
expect(time.dynamic_fields).toMatchObject({ font_substitution: "FONT081", hour: "09", minute: "07" });
|
||||||
@@ -94,10 +92,11 @@ describe("TASK-WP4-04 dynamic providers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("uses the archived template categories and only the fields visibly consumed by each source", () => {
|
it("uses the archived template categories and only the fields visibly consumed by each source", () => {
|
||||||
expect(P0A_DYNAMIC_STICKERS.reduce<Record<string, number>>((counts, item) => {
|
expect(P0A_DYNAMIC_STICKERS.map(({ category, templateId }) => [templateId, category])).toEqual([
|
||||||
counts[item.category] = (counts[item.category] ?? 0) + 1;
|
["DYN001", "location"], ["DYN002", "location"], ["DYN003", "location"], ["DYN004", "location"],
|
||||||
return counts;
|
["DYN007", "other"], ["DYN008", "time"], ["DYN011", "time"], ["DYN012", "time"],
|
||||||
}, {})).toEqual({ identity: 21, location: 6, other: 1, time: 7 });
|
["DYN015", "identity"], ["DYN016", "identity"],
|
||||||
|
]);
|
||||||
const other = createDynamicStickerElement("DYN007", context, identity, 0);
|
const other = createDynamicStickerElement("DYN007", context, identity, 0);
|
||||||
expect(other.dynamic_fields).toEqual({ nickname: "@dada" });
|
expect(other.dynamic_fields).toEqual({ nickname: "@dada" });
|
||||||
expect(other.formatted_value).toBe("@dada");
|
expect(other.formatted_value).toBe("@dada");
|
||||||
@@ -106,10 +105,11 @@ describe("TASK-WP4-04 dynamic providers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("maps every enabled dynamic sticker to its archived source candidate and original resource version", () => {
|
it("maps every enabled dynamic sticker to its archived source candidate and original resource version", () => {
|
||||||
expect(Object.keys(DYNAMIC_RENDER_MODELS)).toHaveLength(35);
|
expect(Object.entries(DYNAMIC_RENDER_MODELS).map(([id, model]) => [id, model.sourceCandidateId])).toEqual([
|
||||||
expect(DYNAMIC_RENDER_MODELS.DYN001?.sourceCandidateId).toBe("l_POI01");
|
["DYN001", "l_POI01"], ["DYN002", "l_POI02"], ["DYN003", "l_POI03"], ["DYN004", "l_POI04"],
|
||||||
expect(DYNAMIC_RENDER_MODELS.DYN012?.sourceCandidateId).toBe("l_shijian7");
|
["DYN007", "diaoyu"], ["DYN008", "l_shijian2"], ["DYN011", "l_shijian6"], ["DYN012", "l_shijian7"],
|
||||||
expect(DYNAMIC_RENDER_MODELS.DYN035?.sourceCandidateId).toBe("l_username23");
|
["DYN015", "0721userna"], ["DYN016", "l_username00"],
|
||||||
|
]);
|
||||||
const element = createDynamicStickerElement("DYN001", { ...context, location: { formattedValue: "温州" } }, identity, 0);
|
const element = createDynamicStickerElement("DYN001", { ...context, location: { formattedValue: "温州" } }, identity, 0);
|
||||||
expect(element.resource_version).toBe(DYNAMIC_RESOURCE_VERSION);
|
expect(element.resource_version).toBe(DYNAMIC_RESOURCE_VERSION);
|
||||||
expect(DYNAMIC_RENDER_MODELS.DYN001.imageLayers).toEqual([
|
expect(DYNAMIC_RENDER_MODELS.DYN001.imageLayers).toEqual([
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import type { StaticStickerCatalog } from "../../packages/static-sticker-catalog
|
|||||||
import {
|
import {
|
||||||
P0A_COLOR_CARD_IDS,
|
P0A_COLOR_CARD_IDS,
|
||||||
P0A_DYNAMIC_STICKER_IDS,
|
P0A_DYNAMIC_STICKER_IDS,
|
||||||
P0A_REQUIRED_FONT_PANEL_IDS,
|
|
||||||
P0A_TEXT_TEMPLATE_IDS,
|
P0A_TEXT_TEMPLATE_IDS,
|
||||||
createP0aPublicManifest,
|
createP0aPublicManifest,
|
||||||
} from "../../packages/template-registry/src/index.js";
|
} from "../../packages/template-registry/src/index.js";
|
||||||
@@ -117,27 +116,29 @@ function evidence(name: string, value: unknown) {
|
|||||||
writeFileSync(resolve(root, name), `${JSON.stringify(value, null, 2)}\n`);
|
writeFileSync(resolve(root, name), `${JSON.stringify(value, null, 2)}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("TDD-WP5-WHITE-001 complete public catalog", () => {
|
describe("TDD-WP5-WHITE-001 P0-A public allowlist", () => {
|
||||||
it("registers and publishes the complete normalized archive", () => {
|
it("registers the complete archive while publishing only the exact P0-A allowlist", () => {
|
||||||
const source = fullComplexManifest();
|
const source = fullComplexManifest();
|
||||||
const before = structuredClone(source);
|
const before = structuredClone(source);
|
||||||
const manifest = createP0aPublicManifest({ complexManifest: source, staticCatalog: staticCatalog() });
|
const manifest = createP0aPublicManifest({ complexManifest: source, staticCatalog: staticCatalog() });
|
||||||
|
|
||||||
expect(source).toEqual(before);
|
expect(source).toEqual(before);
|
||||||
expect(P0A_TEXT_TEMPLATE_IDS).toHaveLength(332);
|
expect(P0A_TEXT_TEMPLATE_IDS).toHaveLength(32);
|
||||||
expect(P0A_REQUIRED_FONT_PANEL_IDS).toHaveLength(86);
|
expect(P0A_COLOR_CARD_IDS).toEqual(["COLOR001", "COLOR002", "COLOR008", "COLOR016"]);
|
||||||
expect(P0A_COLOR_CARD_IDS).toHaveLength(16);
|
expect(P0A_DYNAMIC_STICKER_IDS).toEqual([
|
||||||
expect(P0A_DYNAMIC_STICKER_IDS).toHaveLength(35);
|
"DYN001", "DYN002", "DYN003", "DYN004", "DYN007",
|
||||||
|
"DYN008", "DYN011", "DYN012", "DYN015", "DYN016",
|
||||||
|
]);
|
||||||
expect(manifest.counts).toEqual({
|
expect(manifest.counts).toEqual({
|
||||||
color_cards: 16,
|
color_cards: 4,
|
||||||
dynamic_stickers: 35,
|
dynamic_stickers: 10,
|
||||||
font_panel_items: 86,
|
font_panel_items: 11,
|
||||||
static_parts: 25,
|
static_parts: 25,
|
||||||
static_stickers: 1_407,
|
static_stickers: 1_407,
|
||||||
text_templates: 332,
|
text_templates: 32,
|
||||||
});
|
});
|
||||||
expect(manifest.assets.text_templates.map((item) => item.canonical_id)).toEqual(P0A_TEXT_TEMPLATE_IDS);
|
expect(manifest.assets.text_templates.map((item) => item.canonical_id)).toEqual(P0A_TEXT_TEMPLATE_IDS);
|
||||||
expect(manifest.assets.font_panel_items.map((item) => item.canonical_id)).toEqual(P0A_REQUIRED_FONT_PANEL_IDS);
|
expect(manifest.assets.font_panel_items.map((item) => item.canonical_id)).toEqual([...referencedFontIds, "FONT081"]);
|
||||||
expect(manifest.assets.color_cards.map((item) => item.canonical_id)).toEqual(P0A_COLOR_CARD_IDS);
|
expect(manifest.assets.color_cards.map((item) => item.canonical_id)).toEqual(P0A_COLOR_CARD_IDS);
|
||||||
expect(manifest.assets.dynamic_stickers.map((item) => item.canonical_id)).toEqual(P0A_DYNAMIC_STICKER_IDS);
|
expect(manifest.assets.dynamic_stickers.map((item) => item.canonical_id)).toEqual(P0A_DYNAMIC_STICKER_IDS);
|
||||||
expect(manifest.assets.static_stickers).toHaveLength(1_407);
|
expect(manifest.assets.static_stickers).toHaveLength(1_407);
|
||||||
@@ -153,10 +154,10 @@ describe("TDD-WP5-WHITE-001 complete public catalog", () => {
|
|||||||
&& item.release_tier === "alpha_whitelist"
|
&& item.release_tier === "alpha_whitelist"
|
||||||
&& item.validation_status === "passed")).toBe(true);
|
&& item.validation_status === "passed")).toBe(true);
|
||||||
const serialized = JSON.stringify(manifest);
|
const serialized = JSON.stringify(manifest);
|
||||||
expect(serialized).toContain("FLOWER145");
|
expect(serialized).not.toContain("FLOWER009");
|
||||||
expect(serialized).toContain("COLOR016");
|
expect(serialized).not.toContain("COLOR003");
|
||||||
expect(serialized).toContain("DYN035");
|
expect(serialized).not.toContain("DYN005");
|
||||||
evidence("unit-full-catalog.json", { counts: manifest.counts, status: "passed" });
|
evidence("unit-allowlist.json", { counts: manifest.counts, hidden: ["FLOWER009", "COLOR003", "DYN005"], status: "passed" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects incomplete registration and any early full_p0 enablement", () => {
|
it("rejects incomplete registration and any early full_p0 enablement", () => {
|
||||||
@@ -171,12 +172,12 @@ describe("TDD-WP5-WHITE-001 complete public catalog", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("TDD-WP5-COL-001 shared five-color renderer input", () => {
|
describe("TDD-WP5-COL-001 shared five-color renderer input", () => {
|
||||||
it("binds all sixteen layouts to one immutable palette snapshot", () => {
|
it("binds the four enabled layouts to one immutable palette snapshot", () => {
|
||||||
const palette = ["#102030", "#405060", "#708090", "#A0B0C0", "#D0E0F0"] as const;
|
const palette = ["#102030", "#405060", "#708090", "#A0B0C0", "#D0E0F0"] as const;
|
||||||
const plans = createP0aColorCardRenderPlans(palette);
|
const plans = createP0aColorCardRenderPlans(palette);
|
||||||
expect(P0A_COLOR_CARD_DEFINITIONS.map((item) => [item.cardId, item.styleId])).toEqual(
|
expect(P0A_COLOR_CARD_DEFINITIONS.map((item) => [item.cardId, item.styleId])).toEqual([
|
||||||
P0A_COLOR_CARD_IDS.map((cardId, index) => [cardId, `style_${String(index + 1).padStart(2, "0")}`]),
|
["COLOR001", "style_01"], ["COLOR002", "style_02"], ["COLOR008", "style_08"], ["COLOR016", "style_16"],
|
||||||
);
|
]);
|
||||||
expect(plans.map((plan) => plan.cardId)).toEqual(P0A_COLOR_CARD_IDS);
|
expect(plans.map((plan) => plan.cardId)).toEqual(P0A_COLOR_CARD_IDS);
|
||||||
expect(plans.every((plan) => plan.palette === plans[0]!.palette)).toBe(true);
|
expect(plans.every((plan) => plan.palette === plans[0]!.palette)).toBe(true);
|
||||||
expect(Object.isFrozen(plans[0]!.palette)).toBe(true);
|
expect(Object.isFrozen(plans[0]!.palette)).toBe(true);
|
||||||
|
|||||||
Reference in New Issue
Block a user