feat: complete TASK-WP4-03 text templates

This commit is contained in:
suyx
2026-08-03 11:40:45 +08:00
parent d16802c164
commit dd7a23a281
24 changed files with 1934 additions and 20 deletions
+86
View File
@@ -87,6 +87,12 @@ import {
ProjectStateSaveResponseSchema, ProjectStateSaveResponseSchema,
ProjectSummarySchema, ProjectSummarySchema,
ProjectViewStatusSchema, ProjectViewStatusSchema,
RecentAssetItemSchema,
RecentAssetKindSchema,
RecentAssetListResponseSchema,
RecentAssetQuerySchema,
RecentAssetRecordRequestSchema,
RecentAssetRecordResponseSchema,
RegistrationCompleteHeadersSchema, RegistrationCompleteHeadersSchema,
RegistrationCompleteRequestSchema, RegistrationCompleteRequestSchema,
RegistrationCompleteResponseSchema, RegistrationCompleteResponseSchema,
@@ -120,6 +126,8 @@ import {
type ProjectRenameRequest, type ProjectRenameRequest,
type ProjectEditableState, type ProjectEditableState,
type ProjectStateSaveHeaders, type ProjectStateSaveHeaders,
type RecentAssetQuery,
type RecentAssetRecordRequest,
type RegistrationCompleteRequest, type RegistrationCompleteRequest,
type RegistrationSendRequest, type RegistrationSendRequest,
} from "@dada/shared-contracts"; } from "@dada/shared-contracts";
@@ -162,6 +170,7 @@ import {
registrationFieldError, registrationFieldError,
} from "./registration-errors.js"; } from "./registration-errors.js";
import type { RegistrationService } from "./registration.js"; import type { RegistrationService } from "./registration.js";
import type { RecentAssetService } from "./recent-assets.js";
import { ModelConfigurationError } from "./model-configuration.js"; import { ModelConfigurationError } from "./model-configuration.js";
import type { ModelConfigurationService } from "./model-configuration.js"; import type { ModelConfigurationService } from "./model-configuration.js";
@@ -189,6 +198,7 @@ export interface CreateAppOptions {
models?: ModelConfigurationService; models?: ModelConfigurationService;
networkBoundary?: NetworkBoundaryOptions; networkBoundary?: NetworkBoundaryOptions;
publicAssets?: PublicAssetResolver; publicAssets?: PublicAssetResolver;
recentAssets?: RecentAssetService;
projects?: ProjectService; projects?: ProjectService;
registration?: RegistrationService; registration?: RegistrationService;
} }
@@ -726,6 +736,12 @@ export async function createApp(options: CreateAppOptions = {}) {
ProjectStateSaveHeadersSchema, ProjectStateSaveHeadersSchema,
ProjectStateSaveResponseSchema, ProjectStateSaveResponseSchema,
ProjectStateConflictResponseSchema, ProjectStateConflictResponseSchema,
RecentAssetKindSchema,
RecentAssetItemSchema,
RecentAssetQuerySchema,
RecentAssetListResponseSchema,
RecentAssetRecordRequestSchema,
RecentAssetRecordResponseSchema,
FailedEmptyTrashRequestSchema, FailedEmptyTrashRequestSchema,
FailedEmptyTrashResponseSchema, FailedEmptyTrashResponseSchema,
ModelIdSchema, ModelIdSchema,
@@ -810,6 +826,76 @@ export async function createApp(options: CreateAppOptions = {}) {
}, },
); );
app.get(
"/api/v1/assets/recent",
{
attachValidation: true,
schema: {
operationId: "listRecentAssets",
querystring: Type.Ref(RecentAssetQuerySchema),
response: {
200: Type.Ref(RecentAssetListResponseSchema),
400: Type.Null(),
401: Type.Ref(ErrorEnvelopeSchema),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Assets"],
},
},
async (request, reply) => {
if (request.validationError) return reply.code(400).send(null);
if (!options.registration || !options.recentAssets) {
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
}
const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName);
const session = token ? options.registration.readUserSession(token) : undefined;
if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
const query = request.query as RecentAssetQuery;
return { items: options.recentAssets.list(session.userId, query.asset_kind) };
},
);
app.post(
"/api/v1/assets/recent",
{
attachValidation: true,
schema: {
body: Type.Ref(RecentAssetRecordRequestSchema),
headers: Type.Ref(CsrfHeadersSchema),
operationId: "recordRecentAsset",
response: {
200: Type.Ref(RecentAssetRecordResponseSchema),
400: Type.Null(),
401: Type.Ref(ErrorEnvelopeSchema),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Assets"],
},
},
async (request, reply) => {
if (request.validationError) return reply.code(400).send(null);
if (!options.registration || !options.recentAssets) {
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
}
const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName);
const csrfToken = headerValue(request.headers["x-csrf-token"]);
if (!token || !csrfToken) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
try {
const owner = options.registration.authorizeUserMutation({ csrfToken, sessionToken: token });
const body = request.body as RecentAssetRecordRequest;
options.recentAssets.recordSuccessfulUse({
assetId: body.asset_id,
assetKind: body.asset_kind,
resourceVersion: body.resource_version,
userId: owner.userId,
});
return { status: "recorded" as const };
} catch (error) {
return registrationFailure(reply, request.id, error);
}
},
);
app.post( app.post(
"/api/v1/admin-auth/login/send", "/api/v1/admin-auth/login/send",
{ {
+4
View File
@@ -11,6 +11,7 @@ import { LatestExportService } from "./latest-exports.js";
import { CreditService } from "./credits.js"; import { CreditService } from "./credits.js";
import { ProjectService } from "./projects.js"; import { ProjectService } from "./projects.js";
import { RegistrationService } from "./registration.js"; import { RegistrationService } from "./registration.js";
import { RecentAssetService } from "./recent-assets.js";
import { MockResendAdapter } from "./resend-adapter.js"; import { MockResendAdapter } from "./resend-adapter.js";
import { readSecureConfigCandidate } from "./secure-config.js"; import { readSecureConfigCandidate } from "./secure-config.js";
import { StructuredJsonlLogger } from "./structured-log.js"; import { StructuredJsonlLogger } from "./structured-log.js";
@@ -24,6 +25,7 @@ let credits: CreditService | undefined;
let storage: ManagedStorage | undefined; let storage: ManagedStorage | undefined;
let latestExports: LatestExportService | undefined; let latestExports: LatestExportService | undefined;
let models: ModelConfigurationService | undefined; let models: ModelConfigurationService | undefined;
let recentAssets: RecentAssetService | undefined;
const instanceConfigPath = process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultInstanceConfigPath(); const instanceConfigPath = process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultInstanceConfigPath();
if (credentialChannelEnabled) { if (credentialChannelEnabled) {
const clients = initializeApiCredentialClients(await receiveApiCredentials()); const clients = initializeApiCredentialClients(await receiveApiCredentials());
@@ -47,6 +49,7 @@ if (credentialChannelEnabled) {
storage = new ManagedStorage({ dataRoot, databasePath }); storage = new ManagedStorage({ dataRoot, databasePath });
latestExports = new LatestExportService({ databasePath, storage }); latestExports = new LatestExportService({ databasePath, storage });
models = new ModelConfigurationService({ database: registration.database }); models = new ModelConfigurationService({ database: registration.database });
recentAssets = new RecentAssetService({ database: registration.database });
registration.applySecureConfig(readSecureConfigCandidate(instanceConfigPath)); registration.applySecureConfig(readSecureConfigCandidate(instanceConfigPath));
} catch (error) { } catch (error) {
latestExports?.close(); latestExports?.close();
@@ -73,6 +76,7 @@ const app = await createApp({
...(models ? { models } : {}), ...(models ? { models } : {}),
...(projects ? { projects } : {}), ...(projects ? { projects } : {}),
...(registration ? { registration } : {}), ...(registration ? { registration } : {}),
...(recentAssets ? { recentAssets } : {}),
}); });
await app.listen({ await app.listen({
+67
View File
@@ -0,0 +1,67 @@
import { createRequire } from "node:module";
import type BetterSqlite3 from "better-sqlite3";
const require = createRequire(import.meta.url);
const Database = require("better-sqlite3") as typeof BetterSqlite3;
export type RecentAssetKind = "static_sticker" | "text_template";
export interface RecentAssetItem {
asset_id: string;
asset_kind: RecentAssetKind;
resource_version: string;
}
export class RecentAssetService {
readonly database: BetterSqlite3.Database;
private readonly ownsDatabase: boolean;
private readonly clock: () => number;
constructor(input: { clock?: () => number; database?: BetterSqlite3.Database; databasePath?: string }) {
if (!input.database && !input.databasePath) throw new Error("recent_asset_database_required");
this.database = input.database ?? new Database(input.databasePath!);
this.ownsDatabase = !input.database;
this.clock = input.clock ?? Date.now;
this.database.exec(`
CREATE TABLE IF NOT EXISTS recent_assets (
user_id TEXT NOT NULL,
asset_kind TEXT NOT NULL CHECK (asset_kind IN ('text_template', 'static_sticker')),
asset_id TEXT NOT NULL,
resource_version TEXT NOT NULL,
used_at INTEGER NOT NULL,
PRIMARY KEY (user_id, asset_kind, asset_id)
);
CREATE INDEX IF NOT EXISTS recent_assets_user_kind_used
ON recent_assets (user_id, asset_kind, used_at DESC, asset_id ASC);
`);
}
close() {
if (this.ownsDatabase) this.database.close();
}
list(userId: string, assetKind: RecentAssetKind, limit = 12): RecentAssetItem[] {
if (!Number.isInteger(limit) || limit < 1 || limit > 50) throw new Error("recent_asset_limit_invalid");
return this.database.prepare(`
SELECT asset_id, asset_kind, resource_version
FROM recent_assets
WHERE user_id = ? AND asset_kind = ?
ORDER BY used_at DESC, asset_id ASC
LIMIT ?
`).all(userId, assetKind, limit) as RecentAssetItem[];
}
recordSuccessfulUse(input: { assetId: string; assetKind: RecentAssetKind; resourceVersion: string; userId: string }) {
if (!/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,119}$/.test(input.assetId)
|| !/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,119}$/.test(input.resourceVersion)) {
throw new Error("recent_asset_reference_invalid");
}
this.database.prepare(`
INSERT INTO recent_assets (user_id, asset_kind, asset_id, resource_version, used_at)
VALUES (@userId, @assetKind, @assetId, @resourceVersion, @usedAt)
ON CONFLICT (user_id, asset_kind, asset_id) DO UPDATE SET
resource_version = excluded.resource_version,
used_at = excluded.used_at
`).run({ ...input, usedAt: this.clock() });
}
}
+40 -2
View File
@@ -35,6 +35,34 @@ interface StaticStickerInput {
const snapThreshold = 0.015; const snapThreshold = 0.015;
const hitHalfExtent = 0.08; const hitHalfExtent = 0.08;
function styleNumber(element: CanvasElement, key: string, fallback: number) {
const value = element.style_parameters?.[key];
return typeof value === "number" ? value : fallback;
}
function textLineUnits(line: string) {
return Array.from(line).reduce((total, character) => total + (/^[\x00-\x7F]$/.test(character) ? 0.62 : 1), 0);
}
export function elementHalfExtents(state: CanvasState, element: CanvasElement): CanvasPoint {
if (element.type !== "text_template") {
return { x: hitHalfExtent * element.scale.x, y: hitHalfExtent * element.scale.y };
}
const lines = (element.content ?? "").split("\n");
const fontSize = element.font_size ?? 48;
const letterSpacing = styleNumber(element, "letter_spacing", 1);
const lineHeight = styleNumber(element, "line_height", 1.2);
const strokeWidth = styleNumber(element, "stroke_width", 0);
const longestLine = Math.max(1, ...lines.map((line) => textLineUnits(line)));
const longestCharacterCount = Math.max(1, ...lines.map((line) => Array.from(line).length));
const widthPixels = longestLine * fontSize + (longestCharacterCount - 1) * letterSpacing + 32 + strokeWidth * 2;
const heightPixels = Math.max(1, lines.length) * fontSize * lineHeight + 32 + strokeWidth * 2;
return {
x: Math.max(hitHalfExtent, widthPixels / state.pixel_width / 2) * element.scale.x,
y: Math.max(hitHalfExtent, heightPixels / state.pixel_height / 2) * element.scale.y,
};
}
function cloneState(state: CanvasState) { function cloneState(state: CanvasState) {
return structuredClone(state); return structuredClone(state);
} }
@@ -132,8 +160,7 @@ export class CanvasElementController {
candidatesAt(point: CanvasPoint) { candidatesAt(point: CanvasPoint) {
return this.current.elements return this.current.elements
.filter((element) => { .filter((element) => {
const halfWidth = hitHalfExtent * element.scale.x; const { x: halfWidth, y: halfHeight } = elementHalfExtents(this.current, element);
const halfHeight = hitHalfExtent * element.scale.y;
return point.x >= element.position.x - halfWidth && point.x <= element.position.x + halfWidth return point.x >= element.position.x - halfWidth && point.x <= element.position.x + halfWidth
&& point.y >= element.position.y - halfHeight && point.y <= element.position.y + halfHeight; && point.y >= element.position.y - halfHeight && point.y <= element.position.y + halfHeight;
}) })
@@ -186,6 +213,17 @@ export class CanvasElementController {
return this.value; return this.value;
} }
replaceElement(element: CanvasElement) {
const index = this.current.elements.findIndex((candidate) => candidate.element_id === element.element_id);
if (index < 0) throw new Error("canvas_element_not_found");
const elements = [...this.current.elements];
elements[index] = structuredClone(element);
this.current = requireState({ ...this.current, elements });
this.selection = [element.element_id];
this.pointerMoved();
return this.value;
}
private updateSelected(mapper: (element: CanvasElement) => CanvasElement) { private updateSelected(mapper: (element: CanvasElement) => CanvasElement) {
const selected = new Set(this.selection); const selected = new Set(this.selection);
this.current = requireState({ this.current = requireState({
+74
View File
@@ -312,6 +312,80 @@
.editor-wide-command { width: 100%; margin-top: 18px; } .editor-wide-command { width: 100%; margin-top: 18px; }
.editor-danger { border-color: #c92a24 !important; color: #8f1d14 !important; } .editor-danger { border-color: #c92a24 !important; color: #8f1d14 !important; }
.editor-text-assets { min-width: 0; }
.editor-template-search {
width: 100%;
min-height: 40px;
margin-bottom: 10px;
padding: 8px 10px;
border: 1px solid #85857f;
border-radius: 0;
background: #ffffff;
color: #111111;
font: inherit;
}
.editor-template-categories { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 2px; margin-bottom: 12px; }
.editor-template-categories button {
min-width: 0;
min-height: 32px;
padding: 4px 2px;
border: 1px solid #85857f;
border-radius: 0;
background: #ffffff;
color: #111111;
font: inherit;
font-size: 11px;
}
.editor-template-categories button.active { border-color: #005fcc; box-shadow: inset 0 -3px #005fcc; font-weight: 700; }
.editor-template-retry { width: 100%; min-height: 36px; margin-bottom: 10px; border: 1px solid #8f1d14; border-radius: 0; background: #ffffff; color: #8f1d14; font: inherit; font-weight: 700; }
.editor-template-recent { margin-bottom: 12px; padding: 10px 0; border-block: 1px solid #b9b9b3; }
.editor-template-recent h3 { margin: 0 0 8px; font-size: 12px; }
.editor-template-recent > div { display: flex; gap: 6px; overflow-x: auto; }
.editor-template-recent span { display: grid; flex: 0 0 116px; min-height: 48px; align-content: center; padding: 5px 7px; border-left: 4px solid #f2f400; background: #ffffff; font-size: 10px; }
.editor-template-recent strong { font-family: Consolas, monospace; font-size: 10px; }
.editor-template-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; }
.editor-template-grid button {
display: grid;
min-width: 0;
min-height: 116px;
grid-template-rows: 48px 16px minmax(18px, auto) 16px;
align-items: center;
padding: 7px;
overflow: hidden;
border: 1px solid #85857f;
border-radius: 0;
background: #ffffff;
color: #111111;
font: inherit;
font-size: 10px;
text-align: left;
}
.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 small { color: #8f1d14; font-size: 9px; }
.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.tag { background: #dbeafe; }
.editor-template-mark.simple { background: #ffffff; }
.editor-text-inspector { display: grid; gap: 14px; margin-top: 14px; padding-block: 14px; border-block: 1px solid #b9b9b3; }
.editor-text-inspector label { display: grid; gap: 5px; font-size: 12px; font-weight: 700; }
.editor-text-inspector textarea,
.editor-text-inspector input[type="number"],
.editor-text-inspector select { width: 100%; min-height: 40px; padding: 7px 8px; border: 1px solid #85857f; border-radius: 0; background: #ffffff; color: #111111; font: inherit; }
.editor-text-inspector textarea { min-height: 96px; resize: vertical; }
.editor-text-number { grid-template-columns: minmax(0, 1fr) 68px; }
.editor-text-number > span { grid-column: 1 / -1; }
.editor-text-number input[type="range"] { align-self: center; }
.editor-text-number input[type="number"] { min-width: 0; }
.editor-color-fields { display: grid; gap: 8px; margin: 0; padding: 10px; border: 1px solid #b9b9b3; }
.editor-color-fields legend { padding-inline: 4px; font-size: 12px; font-weight: 700; }
.editor-text-inspector input[type="color"] { width: 100%; height: 40px; padding: 2px; border: 1px solid #85857f; border-radius: 0; background: #ffffff; }
.editor-color-swatches { display: flex; gap: 6px; }
.editor-color-swatches button { width: 32px; height: 32px; border: 1px solid #111111; border-radius: 0; }
.editor-check { display: flex !important; grid-template-columns: 18px 1fr; align-items: center; }
.editor-check input { width: 18px; height: 18px; margin: 0; }
.editor-statusbar { .editor-statusbar {
display: flex; display: flex;
align-items: center; align-items: center;
+211 -5
View File
@@ -12,16 +12,35 @@ import {
} from "./editor-canvas.js"; } from "./editor-canvas.js";
import { CanvasElementController, createStaticStickerElement, type CanvasElementIdentity, type CanvasLayerCommand, type CanvasPoint, type CanvasRect } from "./editor-elements.js"; import { CanvasElementController, createStaticStickerElement, type CanvasElementIdentity, type CanvasLayerCommand, type CanvasPoint, type CanvasRect } from "./editor-elements.js";
import { EditorStage } from "./editor-stage.js"; import { EditorStage } from "./editor-stage.js";
import { TextInspector } from "./text-inspector.js";
import { createBrowserArchivedFontLoader, type ArchivedFontLoader, type ArchivedFontStatus } from "./text-font-loader.js";
import {
fontIdForTextElement,
fontOption,
P0A_TEXT_TEMPLATES,
TextEditSession,
createTextTemplateElement,
type TextStylePatch,
type TextTemplateCategory,
type TextTemplateDefinition,
} from "./text-assets.js";
import { TextTemplatePanel } from "./text-template-panel.js";
import "./editor-page.css"; import "./editor-page.css";
type Ratio = CanvasState["ratio"]; type Ratio = CanvasState["ratio"];
type CanvasElement = CanvasState["elements"][number]; type CanvasElement = CanvasState["elements"][number];
type EditorAssetPanel = "background" | "history" | "stickers"; type EditorAssetPanel = "background" | "history" | "stickers" | "text";
interface TextEditState {
draft: CanvasElement;
elementId: string;
originalTemplateId: string;
}
interface EditorSession { interface EditorSession {
csrf_token: string; csrf_token: string;
user: { creator_name: string }; user: { creator_name: string; user_id: string };
} }
interface EditorProject { interface EditorProject {
@@ -82,12 +101,18 @@ export function EditorPage({ projectId }: { projectId: string }) {
const [guides, setGuides] = useState<string[]>([]); const [guides, setGuides] = useState<string[]>([]);
const [multiMode, setMultiMode] = useState(false); const [multiMode, setMultiMode] = useState(false);
const [candidateMenu, setCandidateMenu] = useState<{ elements: CanvasElement[]; point: CanvasPoint }>(); const [candidateMenu, setCandidateMenu] = useState<{ elements: CanvasElement[]; point: CanvasPoint }>();
const [templateCategory, setTemplateCategory] = useState<TextTemplateCategory>();
const [templateQuery, setTemplateQuery] = useState("");
const [recentTextIds, setRecentTextIds] = useState<string[]>([]);
const [fontStatuses, setFontStatuses] = useState<Record<string, ArchivedFontStatus>>({});
const [textEdit, setTextEdit] = useState<TextEditState>();
const queueRef = useRef<ProjectAutoSaveQueue | undefined>(undefined); const queueRef = useRef<ProjectAutoSaveQueue | undefined>(undefined);
const historyRef = useRef<CanvasEditHistory | undefined>(undefined); const historyRef = useRef<CanvasEditHistory | undefined>(undefined);
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 clipboardRef = useRef<CanvasElement[]>([]); const clipboardRef = useRef<CanvasElement[]>([]);
const fontLoaderRef = useRef<ArchivedFontLoader | undefined>(undefined);
useEffect(() => { useEffect(() => {
let active = true; let active = true;
@@ -107,6 +132,15 @@ export function EditorPage({ projectId }: { projectId: string }) {
return () => { active = false; }; return () => { active = false; };
}, [projectId]); }, [projectId]);
useEffect(() => {
if (!session) return;
let active = true;
readEditorJson<{ items: Array<{ asset_id: string }> }>("/api/v1/assets/recent?asset_kind=text_template")
.then((response) => { if (active) setRecentTextIds(response.items.map((item) => item.asset_id)); })
.catch(() => { if (active) setRecentTextIds([]); });
return () => { active = false; };
}, [session?.user.user_id]);
useEffect(() => { useEffect(() => {
if (!project || !session || !canvasState) return undefined; if (!project || !session || !canvasState) return undefined;
const queue = new ProjectAutoSaveQueue({ const queue = new ProjectAutoSaveQueue({
@@ -133,6 +167,48 @@ export function EditorPage({ projectId }: { projectId: string }) {
return () => { queue.dispose(); if (queueRef.current === queue) queueRef.current = undefined; }; return () => { queue.dispose(); if (queueRef.current === queue) queueRef.current = undefined; };
}, [canvasState === undefined, project?.created_at, projectId, session?.csrf_token]); }, [canvasState === undefined, project?.created_at, projectId, session?.csrf_token]);
useEffect(() => {
if (!canvasState) return;
for (const element of canvasState.elements) {
if (element.type !== "text_template") continue;
const fontId = fontIdForTextElement(element);
const option = fontId ? fontOption(fontId) : undefined;
if (fontId && option) void ensureFont(option.fontId, option.url);
else if (fontId) setFontStatuses((current) => ({ ...current, [fontId]: "unavailable" }));
}
}, [canvasState?.elements.map((element) => `${element.element_id}:${element.font_override ?? ""}:${element.template_or_asset_id}`).join("|")]);
useEffect(() => {
if (!canvasState || selectedIds.length !== 1) {
setTextEdit(undefined);
return;
}
const element = canvasState.elements.find((candidate) => candidate.element_id === selectedIds[0]);
if (!element || element.type !== "text_template") {
setTextEdit(undefined);
return;
}
setTextEdit((current) => current?.elementId === element.element_id ? current : {
draft: structuredClone(element), elementId: element.element_id, originalTemplateId: element.template_or_asset_id,
});
}, [canvasState, selectedIds.join("|")]);
async function ensureFont(fontId: string, url: string, retry = false) {
const current = fontStatuses[fontId];
if (current === "ready" || (current === "unavailable" && !retry)) return current;
const loader = fontLoaderRef.current ?? createBrowserArchivedFontLoader();
fontLoaderRef.current = loader;
setFontStatuses((statuses) => ({ ...statuses, [fontId]: "loading" }));
const status = retry ? await loader.retry({ fontId, url }) : await loader.ensure({ fontId, url });
setFontStatuses((statuses) => ({ ...statuses, [fontId]: status }));
return status;
}
async function retryTextFonts() {
const available = P0A_TEXT_TEMPLATES.filter((template) => template.available && template.fontUrl);
await Promise.all(available.map((template) => ensureFont(template.defaultFontId, template.fontUrl!, true)));
}
function commitCanvas(next: CanvasState) { function commitCanvas(next: CanvasState) {
if (!project || saveStatus === "conflicted") return; if (!project || saveStatus === "conflicted") return;
historyRef.current?.commit(next); historyRef.current?.commit(next);
@@ -141,6 +217,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
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 });
setTextEdit(undefined);
} }
function applyPreview() { function applyPreview() {
@@ -157,6 +234,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
setCanvasState(previous); setCanvasState(previous);
setDraftAdjustments(previous.background.adjustments); setDraftAdjustments(previous.background.adjustments);
if (project) queueRef.current?.commit({ canvas_state: previous, name: project.name }); if (project) queueRef.current?.commit({ canvas_state: previous, name: project.name });
setTextEdit(undefined);
} }
} }
@@ -168,6 +246,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
setCanvasState(next); setCanvasState(next);
setDraftAdjustments(next.background.adjustments); setDraftAdjustments(next.background.adjustments);
if (project) queueRef.current?.commit({ canvas_state: next, name: project.name }); if (project) queueRef.current?.commit({ canvas_state: next, name: project.name });
setTextEdit(undefined);
} }
} }
@@ -212,6 +291,105 @@ export function EditorPage({ projectId }: { projectId: string }) {
} }
} }
async function recordRecentTextTemplate(templateId: string, resourceVersion: string) {
if (!session) return;
try {
const response = await fetch("/api/v1/assets/recent", {
body: JSON.stringify({ asset_id: templateId, asset_kind: "text_template", resource_version: resourceVersion }),
credentials: "same-origin",
headers: { "Content-Type": "application/json", "X-CSRF-Token": session.csrf_token },
method: "POST",
});
if (!response.ok) return;
setRecentTextIds((current) => [templateId, ...current.filter((id) => id !== templateId)].slice(0, 12));
} catch {
// Recent-use metadata does not roll back an otherwise successful canvas edit.
}
}
async function addTextTemplate(template: TextTemplateDefinition) {
if (!template.fontUrl || !canvasState) return;
const status = await ensureFont(template.defaultFontId, template.fontUrl);
if (status !== "ready") {
setNotice("素材暂不可用,未使用系统字体替代。");
return;
}
const controller = controllerForCurrent();
if (!controller) return;
try {
controller.add(createTextTemplateElement(template, newElementIdentity(), canvasState.elements.length));
commitElementOperation(controller, "文字模板已加入画布");
void recordRecentTextTemplate(template.templateId, template.resourceVersion);
} catch (error) {
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
else setNotice("文字模板未能加入画布");
}
}
function updateTextDraft(action: (session: TextEditSession) => void) {
setTextEdit((current) => {
if (!current) return current;
try {
const edit = new TextEditSession(current.draft, P0A_TEXT_TEMPLATES);
action(edit);
return { ...current, draft: edit.value };
} catch {
setNotice("文字参数不在允许范围内");
return current;
}
});
}
async function changeTextTemplate(templateId: string) {
const template = P0A_TEXT_TEMPLATES.find((candidate) => candidate.templateId === templateId);
if (!template?.fontUrl) return;
const status = await ensureFont(template.defaultFontId, template.fontUrl);
if (status !== "ready") {
setNotice("素材暂不可用,未使用系统字体替代。");
return;
}
updateTextDraft((edit) => edit.switchTemplate(templateId));
}
async function changeTextFont(fontId: string | null) {
if (!fontId) {
updateTextDraft((edit) => edit.setStyle({ fontOverride: null }));
return;
}
const option = fontOption(fontId);
if (!option || await ensureFont(option.fontId, option.url) !== "ready") {
setNotice("字体素材暂不可用,未使用系统字体替代。");
return;
}
updateTextDraft((edit) => edit.setStyle({ fontOverride: fontId }));
}
function completeTextEdit() {
if (!textEdit) return;
try {
const edit = new TextEditSession(textEdit.draft, P0A_TEXT_TEMPLATES);
const complete = edit.complete();
const controller = controllerForCurrent();
if (!controller) return;
controller.replaceElement(complete);
commitElementOperation(controller, "文字编辑已完成");
if (complete.template_or_asset_id !== textEdit.originalTemplateId) {
void recordRecentTextTemplate(complete.template_or_asset_id, complete.resource_version);
}
} catch (error) {
if (error instanceof Error && error.message === "text_content_required") setNotice("请输入文字内容或删除该元素。");
else setNotice("文字编辑未能完成");
}
}
function cancelTextEdit() {
if (canvasState && textEdit) {
const current = canvasState.elements.find((element) => element.element_id === textEdit.elementId);
if (current) setTextEdit({ draft: structuredClone(current), elementId: current.element_id, originalTemplateId: current.template_or_asset_id });
}
setNotice("已取消未提交的文字修改");
}
function transformSelection(action: (controller: CanvasElementController) => void, message: string) { function transformSelection(action: (controller: CanvasElementController) => void, message: string) {
const controller = controllerForCurrent(); const controller = controllerForCurrent();
if (!controller || selectedIds.length === 0) return; if (!controller || selectedIds.length === 0) return;
@@ -342,9 +520,13 @@ export function EditorPage({ projectId }: { projectId: string }) {
} }
if (!project || !canvasState) return <main className="editor-loading" aria-live="polite"></main>; if (!project || !canvasState) return <main className="editor-loading" aria-live="polite"></main>;
const renderedCanvasState = textEdit ? {
...canvasState,
elements: canvasState.elements.map((element) => element.element_id === textEdit.elementId ? textEdit.draft : element),
} : canvasState;
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 = canvasState.elements.filter((element) => selectedIds.includes(element.element_id)); const selectedElements = renderedCanvasState.elements.filter((element) => selectedIds.includes(element.element_id));
const selectedStickerOpacity = selectedElements.length > 0 && selectedElements.every((element) => element.type === "static_sticker") const selectedStickerOpacity = selectedElements.length > 0 && selectedElements.every((element) => element.type === "static_sticker")
? Math.round((selectedElements[0]?.opacity ?? 1) * 100) ? Math.round((selectedElements[0]?.opacity ?? 1) * 100)
: undefined; : undefined;
@@ -368,7 +550,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
{([ {([
{ label: "底图", panel: "background" as const }, { label: "底图", panel: "background" as const },
{ label: "历史", panel: "history" as const }, { label: "历史", panel: "history" as const },
{ label: "文字模板" }, { label: "文字模板", panel: "text" as const },
{ label: "普通贴纸", panel: "stickers" as const }, { label: "普通贴纸", panel: "stickers" as const },
{ label: "色卡" }, { label: "色卡" },
{ label: "动态贴纸" }, { label: "动态贴纸" },
@@ -378,6 +560,18 @@ export function EditorPage({ projectId }: { projectId: string }) {
{activePanel === "history" ? <section><h2></h2><div className="editor-history-list"> {activePanel === "history" ? <section><h2></h2><div className="editor-history-list">
{project.images.length === 0 ? <p className="editor-muted"></p> : project.images.map((image) => <button className="editor-source" key={image.image_id} onClick={() => setPendingBackground(image.image_id)} type="button"><span className="editor-thumb" style={{ backgroundImage: `url(/api/v1/private-assets/projects/${projectId}/images/${image.image_id})` }} /><span> · {formatDate(image.created_at)}</span></button>)} {project.images.length === 0 ? <p className="editor-muted"></p> : project.images.map((image) => <button className="editor-source" key={image.image_id} onClick={() => setPendingBackground(image.image_id)} type="button"><span className="editor-thumb" style={{ backgroundImage: `url(/api/v1/private-assets/projects/${projectId}/images/${image.image_id})` }} /><span> · {formatDate(image.created_at)}</span></button>)}
</div></section> : null} </div></section> : null}
{activePanel === "text" ? <TextTemplatePanel
canAdd={canEdit && canvasState.elements.length < 50}
fontStatuses={fontStatuses}
onAdd={(template) => { void addTextTemplate(template); }}
onCategory={setTemplateCategory}
onQuery={setTemplateQuery}
onRetry={() => { void retryTextFonts(); }}
query={templateQuery}
recentIds={recentTextIds}
templates={P0A_TEXT_TEMPLATES}
{...(templateCategory ? { category: templateCategory } : {})}
/> : null}
{activePanel === "stickers" ? <section><h2></h2><div className="editor-sticker-grid"> {activePanel === "stickers" ? <section><h2></h2><div className="editor-sticker-grid">
{stickerFixtures.map((sticker) => <button aria-label={`添加贴纸 ${sticker.assetId}`} disabled={!canEdit || canvasState.elements.length >= 50} key={sticker.assetId} onClick={() => addSticker(sticker.assetId)} type="button"><span className={`editor-sticker-preview ${sticker.assetId.toLowerCase()}`} /><strong>{sticker.assetId}</strong><span>{sticker.label}</span></button>)} {stickerFixtures.map((sticker) => <button aria-label={`添加贴纸 ${sticker.assetId}`} disabled={!canEdit || canvasState.elements.length >= 50} key={sticker.assetId} onClick={() => addSticker(sticker.assetId)} type="button"><span className={`editor-sticker-preview ${sticker.assetId.toLowerCase()}`} /><strong>{sticker.assetId}</strong><span>{sticker.label}</span></button>)}
</div>{canvasState.elements.length >= 50 ? <p className="editor-limit" role="status"> 50 </p> : null}</section> : null} </div>{canvasState.elements.length >= 50 ? <p className="editor-limit" role="status"> 50 </p> : null}</section> : null}
@@ -392,7 +586,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
<div className="editor-canvas-frame" style={{ aspectRatio: `${canvasState.pixel_width} / ${canvasState.pixel_height}` }}> <div className="editor-canvas-frame" style={{ aspectRatio: `${canvasState.pixel_width} / ${canvasState.pixel_height}` }}>
<EditorStage <EditorStage
assetId={canvasState.background.asset_id} assetId={canvasState.background.asset_id}
canvasState={canvasState} canvasState={renderedCanvasState}
fontStatuses={fontStatuses}
guides={guides} guides={guides}
onCandidates={showCandidates} onCandidates={showCandidates}
onClearSelection={clearSelection} onClearSelection={clearSelection}
@@ -424,6 +619,17 @@ export function EditorPage({ projectId }: { projectId: string }) {
</> : <> </> : <>
<header><p>{selectedElements.length > 1 ? "MULTI SELECT" : "OBJECT"}</p><h2>{selectedElements.length > 1 ? `已选 ${selectedElements.length} 个对象` : "对象参数"}</h2></header> <header><p>{selectedElements.length > 1 ? "MULTI SELECT" : "OBJECT"}</p><h2>{selectedElements.length > 1 ? `已选 ${selectedElements.length} 个对象` : "对象参数"}</h2></header>
{selectedElements.length === 1 ? <p className="editor-object-id">{selectedElements[0]?.template_or_asset_id}</p> : null} {selectedElements.length === 1 ? <p className="editor-object-id">{selectedElements[0]?.template_or_asset_id}</p> : null}
{textEdit && selectedElements.length === 1 ? canEdit ? <TextInspector
draft={textEdit.draft}
onCancel={cancelTextEdit}
onComplete={completeTextEdit}
onContent={(content) => updateTextDraft((edit) => edit.setContent(content))}
onFont={(fontId) => { void changeTextFont(fontId); }}
onFontSize={(value) => updateTextDraft((edit) => edit.setEffectiveFontSize(value))}
onStyle={(style: TextStylePatch) => updateTextDraft((edit) => edit.setStyle(style))}
onTemplate={(templateId) => { void changeTextTemplate(templateId); }}
templates={P0A_TEXT_TEMPLATES}
/> : <p className="editor-muted"></p> : null}
<div className="editor-object-moves" role="group" aria-label="移动对象"> <div className="editor-object-moves" role="group" aria-label="移动对象">
<button aria-label="向左移动" disabled={!canEdit} onClick={() => transformSelection((controller) => { controller.moveSelected({ x: -0.01, y: 0 }, { snap: false }); }, "对象位置已提交")} title="向左移动" type="button"></button> <button aria-label="向左移动" disabled={!canEdit} onClick={() => transformSelection((controller) => { controller.moveSelected({ x: -0.01, y: 0 }, { snap: false }); }, "对象位置已提交")} title="向左移动" type="button"></button>
<button aria-label="向上移动" disabled={!canEdit} onClick={() => transformSelection((controller) => { controller.moveSelected({ x: 0, y: -0.01 }, { snap: false }); }, "对象位置已提交")} title="向上移动" type="button"></button> <button aria-label="向上移动" disabled={!canEdit} onClick={() => transformSelection((controller) => { controller.moveSelected({ x: 0, y: -0.01 }, { snap: false }); }, "对象位置已提交")} title="向上移动" type="button"></button>
+96 -10
View File
@@ -3,6 +3,8 @@ import type { CanvasState } from "@dada/shared-contracts";
import { cssFilterForBackground } from "./editor-canvas.js"; import { cssFilterForBackground } from "./editor-canvas.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 { fontIdForTextElement } from "./text-assets.js";
interface Gesture { interface Gesture {
append: boolean; append: boolean;
@@ -16,6 +18,7 @@ interface EditorStageProps {
assetId: string | null; assetId: string | null;
canvasState: CanvasState; canvasState: CanvasState;
guides: readonly string[]; guides: readonly string[];
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>;
onCandidates: (point: CanvasPoint) => void; onCandidates: (point: CanvasPoint) => void;
onClearSelection: () => void; onClearSelection: () => void;
onCopy: () => void; onCopy: () => void;
@@ -39,7 +42,95 @@ function pointFromEvent(event: PointerEvent<HTMLCanvasElement>): CanvasPoint {
}; };
} }
function drawElement(context: CanvasRenderingContext2D, element: CanvasState["elements"][number], width: number, height: number) { function styleValue<T extends string | number | boolean>(element: CanvasState["elements"][number], key: string, fallback: T): T {
const value = element.style_parameters?.[key];
return typeof value === typeof fallback ? value as T : fallback;
}
interface TextPixelGeometry {
height: number;
width: number;
}
function prepareTextContext(context: CanvasRenderingContext2D, element: CanvasState["elements"][number], fontId: string) {
const fontSize = element.font_size ?? 48;
const letterSpacing = styleValue(element, "letter_spacing", 1);
context.font = `${fontSize}px "${fontFamilyName(fontId)}"`;
(context as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = `${letterSpacing}px`;
}
function measureTextElement(context: CanvasRenderingContext2D, element: CanvasState["elements"][number], fontId: string): TextPixelGeometry {
prepareTextContext(context, element, fontId);
const fontSize = element.font_size ?? 48;
const lineHeight = styleValue(element, "line_height", 1.2);
const letterSpacing = styleValue(element, "letter_spacing", 1);
const strokeWidth = styleValue(element, "stroke_width", 0);
const lines = (element.content ?? "").split("\n");
const widths = lines.map((line) => context.measureText(line).width + Math.max(0, Array.from(line).length - 1) * letterSpacing);
return {
height: Math.max(fontSize * lineHeight, lines.length * fontSize * lineHeight) + 32 + strokeWidth * 2,
width: Math.max(1, ...widths) + 32 + strokeWidth * 2,
};
}
function drawTextElement(context: CanvasRenderingContext2D, element: CanvasState["elements"][number], fontStatuses: Readonly<Record<string, ArchivedFontStatus>>) {
const fontId = fontIdForTextElement(element);
if (!fontId || fontStatuses[fontId] !== "ready") {
context.fillStyle = "#e5e7eb";
context.fillRect(-110, -34, 220, 68);
context.fillStyle = "#9f1d1d";
context.font = "600 22px Microsoft YaHei UI, sans-serif";
context.textAlign = "center";
context.fillText("字体不可用", 0, 8);
return;
}
const fontSize = element.font_size ?? 48;
const lineHeight = styleValue(element, "line_height", 1.2);
const letterSpacing = styleValue(element, "letter_spacing", 1);
const align = styleValue(element, "text_align", "center") as CanvasTextAlign;
const lines = (element.content ?? "").split("\n");
prepareTextContext(context, element, fontId);
context.textAlign = align;
context.textBaseline = "middle";
const widths = lines.map((line) => context.measureText(line).width + Math.max(0, line.length - 1) * letterSpacing);
const textWidth = Math.max(1, ...widths);
const textHeight = Math.max(fontSize * lineHeight, lines.length * fontSize * lineHeight);
const padding = 16;
const backgroundEnabled = styleValue(element, "background_enabled", false);
if (backgroundEnabled) {
const elementOpacity = context.globalAlpha;
context.globalAlpha = elementOpacity * styleValue(element, "background_opacity", 1);
context.fillStyle = styleValue(element, "background_color", "#FFE62C");
context.fillRect(-textWidth / 2 - padding, -textHeight / 2 - padding, textWidth + padding * 2, textHeight + padding * 2);
context.globalAlpha = elementOpacity;
}
const firstY = -((lines.length - 1) * fontSize * lineHeight) / 2;
const anchorX = align === "left" ? -textWidth / 2 : align === "right" ? textWidth / 2 : 0;
context.fillStyle = styleValue(element, "fill_color", "#111111");
context.strokeStyle = styleValue(element, "stroke_color", "#000000");
context.lineWidth = styleValue(element, "stroke_width", 0);
lines.forEach((line, index) => {
const y = firstY + index * fontSize * lineHeight;
if (styleValue(element, "stroke_enabled", false) && context.lineWidth > 0) context.strokeText(line, anchorX, y);
context.fillText(line, anchorX, y);
});
}
function elementSelectionHalfSize(
context: CanvasRenderingContext2D,
element: CanvasState["elements"][number],
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>,
) {
if (element.type !== "text_template") return { height: 78 * element.scale.y, width: 78 * element.scale.x };
const fontId = fontIdForTextElement(element);
if (!fontId || fontStatuses[fontId] !== "ready") return { height: 34 * element.scale.y, width: 110 * element.scale.x };
context.save();
const geometry = measureTextElement(context, element, fontId);
context.restore();
return { height: geometry.height * element.scale.y / 2, width: geometry.width * element.scale.x / 2 };
}
function drawElement(context: CanvasRenderingContext2D, element: CanvasState["elements"][number], width: number, height: number, fontStatuses: Readonly<Record<string, ArchivedFontStatus>>) {
const x = element.position.x * width; const x = element.position.x * width;
const y = element.position.y * height; const y = element.position.y * height;
context.save(); context.save();
@@ -66,11 +157,7 @@ function drawElement(context: CanvasRenderingContext2D, element: CanvasState["el
context.font = "700 22px Consolas, monospace"; context.font = "700 22px Consolas, monospace";
context.textAlign = "center"; context.textAlign = "center";
context.fillText(element.template_or_asset_id, 0, 8); context.fillText(element.template_or_asset_id, 0, 8);
} else { } else if (element.type === "text_template") drawTextElement(context, element, fontStatuses);
context.fillStyle = "#111111";
context.font = "700 48px Microsoft YaHei, sans-serif";
context.fillText(element.content ?? "DADA", -80, 16);
}
context.restore(); context.restore();
} }
@@ -94,12 +181,11 @@ export function EditorStage(props: EditorStageProps) {
context.filter = cssFilterForBackground(props.canvasState.background.adjustments); context.filter = cssFilterForBackground(props.canvasState.background.adjustments);
if (image) context.drawImage(image, 0, 0, canvas.width, canvas.height); if (image) context.drawImage(image, 0, 0, canvas.width, canvas.height);
context.filter = "none"; context.filter = "none";
for (const element of [...props.canvasState.elements].sort((left, right) => left.z_index - right.z_index)) drawElement(context, element, canvas.width, canvas.height); for (const element of [...props.canvasState.elements].sort((left, right) => left.z_index - right.z_index)) drawElement(context, element, canvas.width, canvas.height, props.fontStatuses);
context.lineWidth = 4; context.lineWidth = 4;
context.strokeStyle = "#005fcc"; context.strokeStyle = "#005fcc";
for (const element of props.canvasState.elements.filter((entry) => props.selectedIds.includes(entry.element_id))) { for (const element of props.canvasState.elements.filter((entry) => props.selectedIds.includes(entry.element_id))) {
const halfWidth = 78 * element.scale.x; const { width: halfWidth, height: halfHeight } = elementSelectionHalfSize(context, element, props.fontStatuses);
const halfHeight = 78 * element.scale.y;
context.strokeRect(element.position.x * canvas.width - halfWidth, element.position.y * canvas.height - halfHeight, halfWidth * 2, halfHeight * 2); context.strokeRect(element.position.x * canvas.width - halfWidth, element.position.y * canvas.height - halfHeight, halfWidth * 2, halfHeight * 2);
} }
context.save(); context.save();
@@ -124,7 +210,7 @@ export function EditorStage(props: EditorStageProps) {
image.onerror = () => render(); image.onerror = () => render();
image.src = `/api/v1/private-assets/projects/${encodeURIComponent(props.projectId)}/images/${encodeURIComponent(props.assetId)}`; image.src = `/api/v1/private-assets/projects/${encodeURIComponent(props.projectId)}/images/${encodeURIComponent(props.assetId)}`;
return () => { image.onload = null; image.onerror = null; }; return () => { image.onload = null; image.onerror = null; };
}, [marquee, props.assetId, props.canvasState, props.guides, props.projectId, props.selectedIds]); }, [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); }, []);
+17 -1
View File
@@ -1,6 +1,6 @@
// Generated from openapi/openapi.json. Do not edit by hand. // Generated from openapi/openapi.json. Do not edit by hand.
import type { CreditAdjustmentResponse, CreditAdjustmentRequest, BrowserSupportSuccess, BrowserSupportRequest, AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, GenerationCreateResponse, AccountSettingsResponse, AdminSessionResponse, CreditBalanceResponse, BootstrapResponse, GenerationTaskResponse, SseEvent, ModelConfig, ModelConfigurationResponse, CreditLedgerResponse, ProjectDetailResponse, UserSessionResponse, ProjectListResponse, LogoutResponse, ProjectPurgeResponse, ProjectRenameResponse, ProjectRenameRequest, ModelConfigUpdateRequest, ProjectRestoreResponse, LatestExportSaveResponse, ProjectStateSaveResponse, ProjectEditableState, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, FailedEmptyTrashResponse, FailedEmptyTrashRequest, ProjectTrashResponse, AccountProfileUpdateResponse, AccountProfileUpdateRequest } from "./types.gen.js"; import type { CreditAdjustmentResponse, CreditAdjustmentRequest, BrowserSupportSuccess, BrowserSupportRequest, AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, GenerationCreateResponse, AccountSettingsResponse, AdminSessionResponse, CreditBalanceResponse, BootstrapResponse, GenerationTaskResponse, SseEvent, ModelConfig, ModelConfigurationResponse, CreditLedgerResponse, ProjectDetailResponse, UserSessionResponse, ProjectListResponse, RecentAssetListResponse, LogoutResponse, ProjectPurgeResponse, RecentAssetRecordResponse, RecentAssetRecordRequest, ProjectRenameResponse, ProjectRenameRequest, ModelConfigUpdateRequest, ProjectRestoreResponse, LatestExportSaveResponse, ProjectStateSaveResponse, ProjectEditableState, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, FailedEmptyTrashResponse, FailedEmptyTrashRequest, ProjectTrashResponse, AccountProfileUpdateResponse, AccountProfileUpdateRequest } from "./types.gen.js";
export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; } export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; }
@@ -175,6 +175,13 @@ export async function listProjects(options: ClientOptions = {}): Promise<Project
return response.json() as Promise<ProjectListResponse>; return response.json() as Promise<ProjectListResponse>;
} }
export async function listRecentAssets(options: ClientOptions = {}): Promise<RecentAssetListResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/assets/recent`, { method: "GET", headers: options.headers ?? {} });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<RecentAssetListResponse>;
}
export async function logoutUser(options: ClientOptions = {}): Promise<LogoutResponse> { export async function logoutUser(options: ClientOptions = {}): Promise<LogoutResponse> {
const request = options.fetch ?? globalThis.fetch; const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/auth/logout`, { method: "POST", headers: options.headers ?? {} }); const response = await request(`${options.baseUrl ?? ""}/api/v1/auth/logout`, { method: "POST", headers: options.headers ?? {} });
@@ -189,6 +196,15 @@ export async function purgeProject(options: ClientOptions = {}): Promise<Project
return response.json() as Promise<ProjectPurgeResponse>; return response.json() as Promise<ProjectPurgeResponse>;
} }
export async function recordRecentAsset(body: RecentAssetRecordRequest, options: ClientOptions = {}): Promise<RecentAssetRecordResponse> {
const request = options.fetch ?? globalThis.fetch;
const headers = new Headers(options.headers);
headers.set("Content-Type", "application/json");
const response = await request(`${options.baseUrl ?? ""}/api/v1/assets/recent`, { body: JSON.stringify(body), method: "POST", headers });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<RecentAssetRecordResponse>;
}
export async function renameProject(body: ProjectRenameRequest, options: ClientOptions = {}): Promise<ProjectRenameResponse> { export async function renameProject(body: ProjectRenameRequest, options: ClientOptions = {}): Promise<ProjectRenameResponse> {
const request = options.fetch ?? globalThis.fetch; const request = options.fetch ?? globalThis.fetch;
const headers = new Headers(options.headers); const headers = new Headers(options.headers);
+26
View File
@@ -657,6 +657,32 @@ export type ProjectTrashResponse = {
export type ProjectViewStatus = "active" | "failed_empty" | "trashed"; export type ProjectViewStatus = "active" | "failed_empty" | "trashed";
export type RecentAssetItem = {
"asset_id": string;
"asset_kind": RecentAssetKind;
"resource_version": string;
};
export type RecentAssetKind = "static_sticker" | "text_template";
export type RecentAssetListResponse = {
"items": Array<RecentAssetItem>;
};
export type RecentAssetQuery = {
"asset_kind": RecentAssetKind;
};
export type RecentAssetRecordRequest = {
"asset_id": string;
"asset_kind": RecentAssetKind;
"resource_version": string;
};
export type RecentAssetRecordResponse = {
"status": "recorded";
};
export type RegistrationCompleteHeaders = { export type RegistrationCompleteHeaders = {
"idempotency-key": string; "idempotency-key": string;
}; };
+266
View File
@@ -0,0 +1,266 @@
import type { CanvasState } from "@dada/shared-contracts";
import type { CanvasElementIdentity } from "./editor-elements.js";
type CanvasElement = CanvasState["elements"][number];
export type TextTemplateCategory = "flower" | "simple" | "tag" | "title";
export type TextAlign = "center" | "left" | "right";
export interface TextTemplateDefinition {
available: boolean;
catalogOrder: number;
category: TextTemplateCategory;
defaultFontId: string;
defaultFontSize: number;
defaultText: string;
displayName: string;
fontUrl?: string;
resourceClass: "parameter_only" | "zip_template";
resourceVersion: string;
templateId: string;
}
export interface FontOption {
displayName: string;
fontId: string;
url: string;
}
export interface TextStylePatch {
backgroundColor?: string;
backgroundEnabled?: boolean;
backgroundOpacity?: number;
fillColor?: string;
fontOverride?: string | null;
letterSpacing?: number;
lineHeight?: number;
strokeColor?: string;
strokeEnabled?: boolean;
strokeWidth?: number;
textAlign?: TextAlign;
}
const fixtureVersion = "wp4-fixture-v1";
const defaults = {
background_color: "#FFE62C",
background_enabled: false,
background_opacity: 1,
fill_color: "#111111",
letter_spacing: 1,
line_height: 1.2,
stroke_color: "#000000",
stroke_enabled: false,
stroke_width: 0,
text_align: "center",
} as const;
type CatalogSeed = [id: string, category: TextTemplateCategory, displayName: string, defaultText: string, defaultFontId: string, available?: boolean, resourceClass?: "parameter_only"];
const seeds: readonly CatalogSeed[] = [
["FLOWER001", "flower", "春日计划", "春日计划", "FONT011", true],
["FLOWER002", "flower", "笑不活了", "笑不活了", "FLOWER002_FONT"],
["FLOWER003", "flower", "人生照片", "人生照片", "FONT008"],
["FLOWER004", "flower", "我的日常生活", "我的日常生活", "FLOWER004_FONT"],
["FLOWER005", "flower", "碎片生活", "碎片生活", "FONT008"],
["FLOWER006", "flower", "闪光瞬间", "闪光瞬间", "FLOWER006_FONT"],
["FLOWER007", "flower", "好柿花生", "好柿花生", "FONT046", false, "parameter_only"],
["FLOWER008", "flower", "Vlog.", "Vlog.", "FONT005"],
["H001", "title", "电影生活记录", "电影生活记录", "H001_FONT"],
["H002", "title", "30°C", "30°C", "H002_FONT"],
["H003", "title", "生活分享家", "生活分享家", "FONT039", true],
["H004", "title", "快乐充值成功", "快乐充值成功", "FONT046"],
["H005", "title", "日常的镜头", "日常的镜头", "H005_FONT"],
["H006", "title", "慢生活指南", "慢生活指南", "FONT052"],
["H007", "title", "做个有闲人", "做个有闲人", "H007_FONT"],
["H008", "title", "海滩日记", "海滩日记", "H008_FONT"],
["TAG001", "tag", "自定义标签", "自定义标签", "FONT027"],
["TAG002", "tag", "自定义标签", "自定义标签", "FONT043"],
["TAG003", "tag", "打卡x1", "打卡x1", "FONT043"],
["TAG004", "tag", "自定义标签", "自定义标签", "TAG004_FONT"],
["TAG005", "tag", "自定义标签", "自定义标签", "FONT008"],
["TAG006", "tag", "City Walk", "City Walk", "TAG006_FONT"],
["TAG007", "tag", "打卡x1", "打卡x1", "FONT043"],
["TAG051", "tag", "自定义标签", "自定义标签", "FONT022"],
["SIMPLE001", "simple", "碎片回忆录", "碎片回忆录", "SIMPLE001_FONT"],
["SIMPLE002", "simple", "秋天的信笺", "秋天的信笺", "SIMPLE002_FONT"],
["SIMPLE003", "simple", "返航时海鸟追着船盘旋", "返航时海鸟追着船盘旋", "SIMPLE003_FONT"],
["SIMPLE004", "simple", "下段旅程,幸福丰盛。", "下段旅程,幸福丰盛。", "SIMPLE002_FONT"],
["SIMPLE005", "simple", "见信好。", "见信好。", "SIMPLE005_FONT"],
["SIMPLE006", "simple", "万物回春", "万物回春", "SIMPLE001_FONT"],
["SIMPLE007", "simple", "周而复始。", "周而复始。", "SIMPLE007_FONT"],
["SIMPLE008", "simple", "周五愉快", "周五愉快", "SIMPLE008_FONT"],
];
export const P0A_TEXT_TEMPLATES: readonly TextTemplateDefinition[] = seeds.map((seed, catalogOrder) => ({
available: seed[5] === true,
catalogOrder,
category: seed[1],
defaultFontId: seed[4],
defaultFontSize: 48,
defaultText: seed[3],
displayName: seed[2],
...(seed[5] === true ? { fontUrl: `/api/v1/assets/public/${fixtureVersion}/${seed[4]}` } : {}),
resourceClass: seed[6] ?? "zip_template",
resourceVersion: fixtureVersion,
templateId: seed[0],
}));
export const P0A_FONT_OPTIONS: readonly FontOption[] = [
{ displayName: "默陌手写", fontId: "FONT011", url: `/api/v1/assets/public/${fixtureVersion}/FONT011` },
{ displayName: "字由油漆", fontId: "FONT039", url: `/api/v1/assets/public/${fixtureVersion}/FONT039` },
{ displayName: "Lexend Deca", fontId: "FONT081", url: `/api/v1/assets/public/${fixtureVersion}/FONT081` },
];
export function fontOption(fontId: string) {
return P0A_FONT_OPTIONS.find((option) => option.fontId === fontId);
}
export function fontIdForTextElement(element: CanvasElement) {
const defaultFontId = element.style_parameters?.default_font_id;
return element.font_override ?? (typeof defaultFontId === "string" ? defaultFontId : undefined);
}
function cloneElement(element: CanvasElement): CanvasElement {
return structuredClone(element);
}
function templateById(templates: readonly TextTemplateDefinition[], templateId: string) {
const template = templates.find((candidate) => candidate.templateId === templateId);
if (!template) throw new Error("text_template_not_found");
return template;
}
function checkedColor(value: string) {
if (!/^#[0-9A-F]{6}$/i.test(value)) throw new Error("text_color_invalid");
return value.toUpperCase();
}
function isStep(value: number, minimum: number, step: number) {
return Math.abs((value - minimum) / step - Math.round((value - minimum) / step)) < 1e-8;
}
function templateStyle(template: TextTemplateDefinition): Record<string, string | number | boolean | null> {
return { ...defaults, default_font_id: template.defaultFontId };
}
export function searchTextTemplates(
templates: readonly TextTemplateDefinition[],
input: { category?: TextTemplateCategory; query?: string },
) {
const query = input.query?.trim().toLocaleLowerCase("zh-CN") ?? "";
return templates
.filter((template) => !input.category || template.category === input.category)
.filter((template) => !query || template.displayName.toLocaleLowerCase("zh-CN").includes(query))
.toSorted((left, right) => left.catalogOrder - right.catalogOrder);
}
export function createTextTemplateElement(
template: TextTemplateDefinition,
identity: CanvasElementIdentity,
zIndex: number,
transform: Partial<Pick<CanvasElement, "position" | "rotation" | "scale">> = {},
): CanvasElement {
if (!template.available || !template.fontUrl) throw new Error("text_template_unavailable");
return {
content: template.defaultText,
created_at: identity.createdAt,
element_id: identity.elementId,
font_size: template.defaultFontSize,
opacity: 1,
position: transform.position ?? { x: 0.5, y: 0.5 },
resource_version: template.resourceVersion,
rotation: transform.rotation ?? 0,
scale: transform.scale ?? { x: 1, y: 1 },
style_parameters: templateStyle(template),
template_or_asset_id: template.templateId,
type: "text_template",
z_index: zIndex,
};
}
export function effectiveFontSize(element: CanvasElement) {
return (element.font_size ?? 48) * element.scale.x;
}
export class TextEditSession {
readonly original: CanvasElement;
private draft: CanvasElement;
private readonly templates: readonly TextTemplateDefinition[];
constructor(element: CanvasElement, templates: readonly TextTemplateDefinition[]) {
if (element.type !== "text_template") throw new Error("text_element_required");
this.original = cloneElement(element);
this.draft = cloneElement(element);
this.templates = templates;
}
get value() { return cloneElement(this.draft); }
cancel() { return cloneElement(this.original); }
complete() {
if (!(this.draft.content ?? "").trim()) throw new Error("text_content_required");
return cloneElement(this.draft);
}
setContent(content: string) {
this.draft.content = content;
}
setEffectiveFontSize(value: number) {
if (!Number.isFinite(value) || value < 1 || value > 512) throw new Error("text_font_size_invalid");
const base = this.draft.font_size ?? 48;
const factor = value / base;
this.draft.scale = { x: factor, y: factor };
}
setStyle(patch: TextStylePatch) {
const style = { ...(this.draft.style_parameters ?? {}) };
if (patch.fillColor !== undefined) style.fill_color = checkedColor(patch.fillColor);
if (patch.strokeColor !== undefined) style.stroke_color = checkedColor(patch.strokeColor);
if (patch.backgroundColor !== undefined) style.background_color = checkedColor(patch.backgroundColor);
if (patch.strokeEnabled !== undefined) style.stroke_enabled = patch.strokeEnabled;
if (patch.backgroundEnabled !== undefined) style.background_enabled = patch.backgroundEnabled;
if (patch.strokeWidth !== undefined) {
if (!Number.isFinite(patch.strokeWidth) || patch.strokeWidth < 0 || patch.strokeWidth > 12) throw new Error("text_stroke_width_invalid");
style.stroke_width = patch.strokeWidth;
}
if (patch.backgroundOpacity !== undefined) {
if (!Number.isFinite(patch.backgroundOpacity) || patch.backgroundOpacity < 0 || patch.backgroundOpacity > 1) throw new Error("text_background_opacity_invalid");
style.background_opacity = patch.backgroundOpacity;
}
if (patch.textAlign !== undefined) {
if (!["center", "left", "right"].includes(patch.textAlign)) throw new Error("text_align_invalid");
style.text_align = patch.textAlign;
}
if (patch.lineHeight !== undefined) {
if (patch.lineHeight < 1 || patch.lineHeight > 1.9 || !isStep(patch.lineHeight, 1, 0.1)) throw new Error("text_line_height_invalid");
style.line_height = patch.lineHeight;
}
if (patch.letterSpacing !== undefined) {
if (patch.letterSpacing < 1 || patch.letterSpacing > 20 || !isStep(patch.letterSpacing, 1, 1)) throw new Error("text_letter_spacing_invalid");
style.letter_spacing = patch.letterSpacing;
}
if (patch.fontOverride !== undefined) {
if (patch.fontOverride === null) delete this.draft.font_override;
else this.draft.font_override = patch.fontOverride;
}
this.draft.style_parameters = style;
}
switchTemplate(templateId: string) {
const template = templateById(this.templates, templateId);
if (!template.available || !template.fontUrl) throw new Error("text_template_unavailable");
const content = this.draft.content;
this.draft = {
...this.draft,
font_size: template.defaultFontSize,
resource_version: template.resourceVersion,
style_parameters: templateStyle(template),
template_or_asset_id: template.templateId,
};
if (content !== undefined) this.draft.content = content;
delete this.draft.font_override;
}
}
+75
View File
@@ -0,0 +1,75 @@
export type ArchivedFontStatus = "idle" | "loading" | "ready" | "unavailable";
interface LoadableFontFace {
load: () => Promise<unknown>;
}
interface FontSetPort {
add: (face: unknown) => unknown;
check: (font: string) => boolean;
ready: Promise<unknown>;
}
export interface ArchivedFontReference {
fontId: string;
url: string;
}
export function fontFamilyName(fontId: string) {
if (!/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,119}$/.test(fontId)) throw new Error("font_id_invalid");
return `Dada_${fontId.replaceAll(/[^A-Za-z0-9_]/g, "_")}`;
}
export class ArchivedFontLoader {
private readonly createFace: (family: string, source: string) => LoadableFontFace;
private readonly fontSet: FontSetPort;
private readonly pending = new Map<string, Promise<ArchivedFontStatus>>();
private readonly statuses = new Map<string, ArchivedFontStatus>();
constructor(input: { createFace: (family: string, source: string) => LoadableFontFace; fontSet: FontSetPort }) {
this.createFace = input.createFace;
this.fontSet = input.fontSet;
}
status(fontId: string): ArchivedFontStatus {
return this.statuses.get(fontId) ?? "idle";
}
ensure(reference: ArchivedFontReference): Promise<ArchivedFontStatus> {
const existing = this.pending.get(reference.fontId);
if (existing) return existing;
const operation = this.load(reference);
this.pending.set(reference.fontId, operation);
return operation;
}
retry(reference: ArchivedFontReference) {
this.pending.delete(reference.fontId);
this.statuses.delete(reference.fontId);
return this.ensure(reference);
}
private async load(reference: ArchivedFontReference): Promise<ArchivedFontStatus> {
const family = fontFamilyName(reference.fontId);
this.statuses.set(reference.fontId, "loading");
try {
const face = this.createFace(family, `url("${reference.url}")`);
const loaded = await face.load();
this.fontSet.add(loaded);
await this.fontSet.ready;
if (!this.fontSet.check(`16px "${family}"`)) throw new Error("font_not_ready");
this.statuses.set(reference.fontId, "ready");
return "ready";
} catch {
this.statuses.set(reference.fontId, "unavailable");
return "unavailable";
}
}
}
export function createBrowserArchivedFontLoader() {
return new ArchivedFontLoader({
createFace: (family, source) => new FontFace(family, source),
fontSet: document.fonts as unknown as FontSetPort,
});
}
+53
View File
@@ -0,0 +1,53 @@
import type { CanvasState } from "@dada/shared-contracts";
import { effectiveFontSize, P0A_FONT_OPTIONS, type TextStylePatch, type TextTemplateDefinition } from "./text-assets.js";
type CanvasElement = CanvasState["elements"][number];
function scalar<T extends string | number | boolean>(element: CanvasElement, key: string, fallback: T): T {
const value = element.style_parameters?.[key];
return typeof value === typeof fallback ? value as T : fallback;
}
function NumberAndRange(props: { label: string; max: number; min: number; onChange: (value: number) => void; step: number; value: number }) {
return <label className="editor-range editor-text-number"><span>{props.label}<output>{props.value}</output></span><input aria-label={`${props.label}滑杆`} max={props.max} min={props.min} onChange={(event) => props.onChange(Number(event.target.value))} step={props.step} type="range" value={props.value} /><input aria-label={props.label} max={props.max} min={props.min} onChange={(event) => props.onChange(Number(event.target.value))} step={props.step} type="number" value={props.value} /></label>;
}
export function TextInspector(props: {
draft: CanvasElement;
onCancel: () => void;
onComplete: () => void;
onContent: (content: string) => void;
onFont: (fontId: string | null) => void;
onFontSize: (value: number) => void;
onStyle: (patch: TextStylePatch) => void;
onTemplate: (templateId: string) => void;
templates: readonly TextTemplateDefinition[];
}) {
const style = props.draft.style_parameters ?? {};
const backgroundOpacity = Math.round(scalar(props.draft, "background_opacity", 1) * 100);
const strokeEnabled = scalar(props.draft, "stroke_enabled", false);
const backgroundEnabled = scalar(props.draft, "background_enabled", false);
return <div className="editor-text-inspector">
<label><select aria-label="文字模板切换" onChange={(event) => props.onTemplate(event.target.value)} value={props.draft.template_or_asset_id}>
{props.templates.map((template) => <option disabled={!template.available} key={template.templateId} value={template.templateId}>{template.templateId} · {template.displayName}{template.available ? "" : "(素材暂不可用)"}</option>)}
</select></label>
<label><textarea aria-label="文字内容" onChange={(event) => props.onContent(event.target.value)} rows={4} value={props.draft.content ?? ""} /></label>
<label><select aria-label="字体覆盖" onChange={(event) => props.onFont(event.target.value || null)} value={props.draft.font_override ?? ""}><option value=""></option>{P0A_FONT_OPTIONS.map((font) => <option key={font.fontId} value={font.fontId}>{font.fontId} · {font.displayName}</option>)}</select></label>
<NumberAndRange label="有效字号" max={256} min={1} onChange={props.onFontSize} step={1} value={Math.round(effectiveFontSize(props.draft))} />
<fieldset className="editor-color-fields"><legend></legend>
<label><input aria-label="文字填充色" onChange={(event) => props.onStyle({ fillColor: event.target.value })} type="color" value={scalar(props.draft, "fill_color", "#111111")} /></label>
<div className="editor-color-swatches" role="group" aria-label="文字参考色"><button aria-label="红色填充" onClick={() => props.onStyle({ fillColor: "#FA5751" })} style={{ background: "#FA5751" }} type="button" /><button aria-label="黑色填充" onClick={() => props.onStyle({ fillColor: "#000000" })} style={{ background: "#000000" }} type="button" /></div>
</fieldset>
<label className="editor-check"><input aria-label="启用描边" checked={strokeEnabled} onChange={(event) => props.onStyle({ strokeEnabled: event.target.checked })} type="checkbox" /></label>
<NumberAndRange label="描边宽度" max={12} min={0} onChange={(value) => props.onStyle({ strokeWidth: value })} step={1} value={scalar(props.draft, "stroke_width", 0)} />
<label><input aria-label="描边颜色" onChange={(event) => props.onStyle({ strokeColor: event.target.value })} type="color" value={scalar(props.draft, "stroke_color", "#000000")} /></label>
<label className="editor-check"><input aria-label="启用文字背景" checked={backgroundEnabled} onChange={(event) => props.onStyle({ backgroundEnabled: event.target.checked })} type="checkbox" /></label>
<label><input aria-label="文字背景色" onChange={(event) => props.onStyle({ backgroundColor: event.target.value })} type="color" value={scalar(props.draft, "background_color", "#FFE62C")} /></label>
<NumberAndRange label="背景透明度" max={100} min={0} onChange={(value) => props.onStyle({ backgroundOpacity: value / 100 })} step={1} value={backgroundOpacity} />
<label><select aria-label="文字对齐" onChange={(event) => props.onStyle({ textAlign: event.target.value as "center" | "left" | "right" })} value={scalar(props.draft, "text_align", "center")}><option value="left"></option><option value="center"></option><option value="right"></option></select></label>
<NumberAndRange label="行距" max={1.9} min={1} onChange={(value) => props.onStyle({ lineHeight: value })} step={0.1} value={scalar(props.draft, "line_height", 1.2)} />
<NumberAndRange label="字距" max={20} min={1} onChange={(value) => props.onStyle({ letterSpacing: value })} step={1} value={scalar(props.draft, "letter_spacing", 1)} />
<div className="editor-inspector-actions"><button onClick={props.onCancel} type="button"></button><button aria-label="完成文字编辑" className="editor-primary" onClick={props.onComplete} type="button"></button></div>
</div>;
}
+45
View File
@@ -0,0 +1,45 @@
import type { ArchivedFontStatus } from "./text-font-loader.js";
import { searchTextTemplates, type TextTemplateCategory, type TextTemplateDefinition } from "./text-assets.js";
const categories: Array<{ id?: TextTemplateCategory; label: string }> = [
{ label: "全部" }, { id: "flower", label: "花字" }, { id: "title", label: "标题" }, { id: "tag", label: "标签" }, { id: "simple", label: "简约" },
];
export function TextTemplatePanel(props: {
canAdd: boolean;
category?: TextTemplateCategory;
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>;
onAdd: (template: TextTemplateDefinition) => void;
onCategory: (category?: TextTemplateCategory) => void;
onQuery: (query: string) => void;
onRetry: () => void;
query: string;
recentIds: readonly string[];
templates: readonly TextTemplateDefinition[];
}) {
const visible = searchTextTemplates(props.templates, { ...(props.category ? { category: props.category } : {}), query: props.query });
const recent = props.recentIds.map((id) => props.templates.find((template) => template.templateId === id)).filter((item): item is TextTemplateDefinition => Boolean(item));
const loadFailed = props.templates.some((template) => template.available && props.fontStatuses[template.defaultFontId] === "unavailable");
return <section className="editor-text-assets">
<h2></h2>
<input aria-label="搜索文字模板显示名称" className="editor-template-search" onChange={(event) => props.onQuery(event.target.value)} placeholder="搜索文字模板显示名称" type="search" value={props.query} />
<div aria-label="文字模板分类" className="editor-template-categories" role="group">
{categories.map((item) => <button aria-pressed={item.id === props.category || (!item.id && !props.category)} className={item.id === props.category || (!item.id && !props.category) ? "active" : ""} key={item.label} onClick={() => props.onCategory(item.id)} type="button">{item.label}</button>)}
</div>
{loadFailed ? <button className="editor-template-retry" onClick={props.onRetry} type="button"></button> : null}
{recent.length > 0 ? <div aria-label="最近使用文字模板" className="editor-template-recent"><h3>使</h3><div>{recent.map((template) => <span key={template.templateId}><strong>{template.templateId}</strong>{template.displayName}</span>)}</div></div> : null}
<div className="editor-template-grid">
{visible.map((template) => {
const status = props.fontStatuses[template.defaultFontId] ?? "idle";
const unavailable = !template.available || status === "unavailable";
return <button aria-label={`${template.templateId} ${template.displayName}${unavailable ? " 素材暂不可用" : ""}`} disabled={!props.canAdd || unavailable || status === "loading"} key={template.templateId} onClick={() => props.onAdd(template)} type="button">
<span className={`editor-template-mark ${template.category}`}>{template.displayName.slice(0, 2)}</span>
<strong>{template.templateId}</strong>
<span>{template.displayName}</span>
{unavailable ? <small></small> : status === "loading" ? <small></small> : null}
</button>;
})}
</div>
{visible.length === 0 ? <p className="editor-muted"></p> : null}
</section>;
}
+224
View File
@@ -3855,6 +3855,113 @@
} }
] ]
}, },
"RecentAssetItem": {
"additionalProperties": false,
"properties": {
"asset_id": {
"maxLength": 120,
"pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,119}$",
"type": "string"
},
"asset_kind": {
"$ref": "#/components/schemas/RecentAssetKind"
},
"resource_version": {
"maxLength": 120,
"pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,119}$",
"type": "string"
}
},
"required": [
"asset_id",
"asset_kind",
"resource_version"
],
"type": "object"
},
"RecentAssetKind": {
"anyOf": [
{
"enum": [
"static_sticker"
],
"type": "string"
},
{
"enum": [
"text_template"
],
"type": "string"
}
]
},
"RecentAssetListResponse": {
"additionalProperties": false,
"properties": {
"items": {
"items": {
"$ref": "#/components/schemas/RecentAssetItem"
},
"maxItems": 12,
"type": "array"
}
},
"required": [
"items"
],
"type": "object"
},
"RecentAssetQuery": {
"additionalProperties": false,
"properties": {
"asset_kind": {
"$ref": "#/components/schemas/RecentAssetKind"
}
},
"required": [
"asset_kind"
],
"type": "object"
},
"RecentAssetRecordRequest": {
"additionalProperties": false,
"properties": {
"asset_id": {
"maxLength": 120,
"pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,119}$",
"type": "string"
},
"asset_kind": {
"$ref": "#/components/schemas/RecentAssetKind"
},
"resource_version": {
"maxLength": 120,
"pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,119}$",
"type": "string"
}
},
"required": [
"asset_id",
"asset_kind",
"resource_version"
],
"type": "object"
},
"RecentAssetRecordResponse": {
"additionalProperties": false,
"properties": {
"status": {
"enum": [
"recorded"
],
"type": "string"
}
},
"required": [
"status"
],
"type": "object"
},
"RegistrationCompleteHeaders": { "RegistrationCompleteHeaders": {
"additionalProperties": true, "additionalProperties": true,
"properties": { "properties": {
@@ -7983,6 +8090,123 @@
] ]
} }
}, },
"/api/v1/assets/recent": {
"get": {
"operationId": "listRecentAssets",
"parameters": [
{
"in": "query",
"name": "asset_kind",
"required": true,
"schema": {
"$ref": "#/components/schemas/RecentAssetKind"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RecentAssetListResponse"
}
}
},
"description": "Default Response"
},
"400": {
"description": "Default Response"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"description": "Default Response"
},
"503": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"description": "Default Response"
}
},
"tags": [
"Assets"
]
},
"post": {
"operationId": "recordRecentAsset",
"parameters": [
{
"in": "header",
"name": "x-csrf-token",
"required": true,
"schema": {
"maxLength": 64,
"minLength": 43,
"pattern": "^[A-Za-z0-9_-]+$",
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RecentAssetRecordRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RecentAssetRecordResponse"
}
}
},
"description": "Default Response"
},
"400": {
"description": "Default Response"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"description": "Default Response"
},
"503": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"description": "Default Response"
}
},
"tags": [
"Assets"
]
}
},
"/api/v1/auth/login/complete": { "/api/v1/auth/login/complete": {
"post": { "post": {
"operationId": "completeLogin", "operationId": "completeLogin",
+4 -2
View File
@@ -14,7 +14,7 @@
"test:integration": "vitest run tests/integration", "test:integration": "vitest run tests/integration",
"test:api": "pnpm check:openapi && vitest run tests/api", "test:api": "pnpm check:openapi && vitest run tests/api",
"test:worker": "pnpm --filter @dada/worker build && node scripts/worker-smoke.mjs && vitest run tests/worker", "test:worker": "pnpm --filter @dada/worker build && node scripts/worker-smoke.mjs && vitest run tests/worker",
"test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts tests/e2e/user-auth.spec.ts tests/e2e/admin-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts tests/e2e/user-registration.spec.ts tests/e2e/account-settings.spec.ts tests/e2e/projects-workspace.spec.ts tests/e2e/project-autosave-conflict.spec.ts tests/e2e/project-trash.spec.ts tests/e2e/credits.spec.ts tests/e2e/generation-workspace.spec.ts tests/e2e/generation-terminal-actions.spec.ts tests/e2e/project-latest-exports.spec.ts tests/e2e/admin-models.spec.ts tests/e2e/wp4-01-editor-background.spec.ts tests/e2e/wp4-02-editor-elements.spec.ts --config playwright.config.ts", "test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts tests/e2e/user-auth.spec.ts tests/e2e/admin-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts tests/e2e/user-registration.spec.ts tests/e2e/account-settings.spec.ts tests/e2e/projects-workspace.spec.ts tests/e2e/project-autosave-conflict.spec.ts tests/e2e/project-trash.spec.ts tests/e2e/credits.spec.ts tests/e2e/generation-workspace.spec.ts tests/e2e/generation-terminal-actions.spec.ts tests/e2e/project-latest-exports.spec.ts tests/e2e/admin-models.spec.ts tests/e2e/wp4-01-editor-background.spec.ts tests/e2e/wp4-02-editor-elements.spec.ts tests/e2e/wp4-03-text-editor.spec.ts --config playwright.config.ts",
"test:visual": "node scripts/validate-layer-scope.mjs VISUAL", "test:visual": "node scripts/validate-layer-scope.mjs VISUAL",
"test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE", "test:performance": "node scripts/validate-layer-scope.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",
@@ -77,7 +77,9 @@
"test:wp4-01": "node scripts/run-wp4-01-validation.mjs", "test:wp4-01": "node scripts/run-wp4-01-validation.mjs",
"test:wp4-01:red": "node scripts/run-wp4-01-validation.mjs --phase red", "test:wp4-01:red": "node scripts/run-wp4-01-validation.mjs --phase red",
"test:wp4-02": "node scripts/run-wp4-02-validation.mjs", "test:wp4-02": "node scripts/run-wp4-02-validation.mjs",
"test:wp4-02:red": "node scripts/run-wp4-02-validation.mjs --phase red" "test:wp4-02:red": "node scripts/run-wp4-02-validation.mjs --phase red",
"test:wp4-03": "node scripts/run-wp4-03-validation.mjs",
"test:wp4-03:red": "node scripts/run-wp4-03-validation.mjs --phase red"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "1.62.0", "@playwright/test": "1.62.0",
+44
View File
@@ -0,0 +1,44 @@
import { Type, type Static } from "@sinclair/typebox";
const stableResourcePattern = "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,119}$";
export const RecentAssetKindSchema = Type.Union(
[Type.Literal("static_sticker"), Type.Literal("text_template")],
{ $id: "RecentAssetKind" },
);
export const RecentAssetItemSchema = Type.Object(
{
asset_id: Type.String({ maxLength: 120, pattern: stableResourcePattern }),
asset_kind: Type.Ref(RecentAssetKindSchema),
resource_version: Type.String({ maxLength: 120, pattern: stableResourcePattern }),
},
{ additionalProperties: false, $id: "RecentAssetItem" },
);
export const RecentAssetQuerySchema = Type.Object(
{ asset_kind: Type.Ref(RecentAssetKindSchema) },
{ additionalProperties: false, $id: "RecentAssetQuery" },
);
export const RecentAssetListResponseSchema = Type.Object(
{ items: Type.Array(Type.Ref(RecentAssetItemSchema), { maxItems: 12 }) },
{ additionalProperties: false, $id: "RecentAssetListResponse" },
);
export const RecentAssetRecordRequestSchema = Type.Object(
{
asset_id: Type.String({ maxLength: 120, pattern: stableResourcePattern }),
asset_kind: Type.Ref(RecentAssetKindSchema),
resource_version: Type.String({ maxLength: 120, pattern: stableResourcePattern }),
},
{ additionalProperties: false, $id: "RecentAssetRecordRequest" },
);
export const RecentAssetRecordResponseSchema = Type.Object(
{ status: Type.Literal("recorded") },
{ additionalProperties: false, $id: "RecentAssetRecordResponse" },
);
export type RecentAssetQuery = Static<typeof RecentAssetQuerySchema>;
export type RecentAssetRecordRequest = Static<typeof RecentAssetRecordRequestSchema>;
+1
View File
@@ -1,5 +1,6 @@
export { Type } from "@sinclair/typebox"; export { Type } from "@sinclair/typebox";
export * from "./api.js"; export * from "./api.js";
export * from "./assets.js";
export * from "./auth.js"; export * from "./auth.js";
export * from "./bootstrap.js"; export * from "./bootstrap.js";
export * from "./canvas.js"; export * from "./canvas.js";
+113
View File
@@ -0,0 +1,113 @@
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
const phaseIndex = process.argv.indexOf("--phase");
const phase = phaseIndex >= 0 ? process.argv[phaseIndex + 1] : "green";
if (!["red", "green"].includes(phase)) throw new Error(`Unsupported phase: ${phase}`);
const runId = process.env.DADA_TDD_RUN_ID ?? `wp4-03-${phase}-${new Date().toISOString().replace(/[^0-9]/g, "")}`;
const runDirectory = resolve("artifacts", "tdd", runId);
const casesDirectory = resolve(runDirectory, "cases");
if (existsSync(runDirectory)) throw new Error(`Evidence run already exists: ${runId}`);
mkdirSync(casesDirectory, { recursive: true });
const cases = [
{ acceptance_criteria: ["AC-09", "AC-10"], evidence: ["canvas-state.json", "db-diff.json", "pixel-diff.json", "trace.zip"], id: "TDD-WP4-TXT-001-multiline-template-switch", requirements: ["TEXT-01", "TEXT-02", "TEXT-03", "TEXT-04", "TEXT-05", "TEXT-06", "PROJECT-04"] },
{ acceptance_criteria: ["AC-11", "AC-12"], evidence: ["font-load.json", "canvas-state.json", "pixel-diff.json", "trace.zip"], id: "TDD-WP4-TXT-002-font-metrics-ranges", requirements: ["TEXT-07", "TEXT-08", "TEXT-09", "TEXT-10", "TEXT-11", "TEXT-12", "TEXT-13", "TEXT-14", "NFR-02"] },
{ acceptance_criteria: ["AC-09", "AC-32"], evidence: ["catalog.json", "response.json", "db-diff.json", "trace.zip"], id: "TDD-WP4-TXT-003-catalog-search-recent", requirements: ["TEXT-01", "TEXT-02"] },
];
for (const item of cases) mkdirSync(resolve(casesDirectory, item.id), { recursive: true });
const outputDirectory = resolve(runDirectory, "playwright-output");
const archiveEvidence = resolve(runDirectory, "font-source.json");
const environment = {
...process.env,
DADA_EVIDENCE_DIR_TEXT_EDITOR: casesDirectory,
DADA_FONT_SOURCE_EVIDENCE: archiveEvidence,
DADA_PLAYWRIGHT_OUTPUT_DIR: outputDirectory,
};
const commands = phase === "red" ? [] : [
["font-source", "pnpm exec node scripts/verify-wp4-03-font-source.mjs"],
["unit", "pnpm test:unit"],
["integration", "pnpm test:integration"],
["api", "pnpm test:api"],
["e2e", "pnpm test:e2e"],
["visual", "pnpm test:visual"],
["performance", "pnpm test:performance"],
["tdd-trace", "pnpm validate:tdd-trace"],
];
const commandResults = [];
for (const [name, command] of commands) {
const started_at = new Date().toISOString();
const result = spawnSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", command], { encoding: "utf8", env: environment });
if (result.stdout) process.stdout.write(result.stdout);
if (result.stderr) process.stderr.write(result.stderr);
commandResults.push({ command, exit_code: result.status ?? 1, finished_at: new Date().toISOString(), name, started_at });
}
function findTraces(directory) {
const traces = [];
if (!existsSync(directory)) return traces;
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const path = resolve(directory, entry.name);
if (entry.isDirectory()) traces.push(...findTraces(path));
else if (entry.name === "trace.zip") traces.push(path);
}
return traces;
}
if (phase === "green") {
const traces = findTraces(outputDirectory);
const mappings = ["template-switch", "style-ranges-once", "account-recent-use"];
mappings.forEach((needle, index) => {
const trace = traces.find((path) => path.includes(needle));
if (trace) copyFileSync(trace, resolve(casesDirectory, cases[index].id, "trace.zip"));
});
const fontEvidencePath = resolve(casesDirectory, cases[1].id, "font-load.json");
if (existsSync(fontEvidencePath) && existsSync(archiveEvidence)) {
const browser = JSON.parse(readFileSync(fontEvidencePath, "utf8"));
const archive = JSON.parse(readFileSync(archiveEvidence, "utf8"));
writeFileSync(fontEvidencePath, `${JSON.stringify({ ...browser, archive_verification: archive }, null, 2)}\n`);
}
}
const commandState = phase === "red" ? true : commandResults.every((result) => result.exit_code === 0);
const manifest = { path: "tasks.manifest.json", sha256: createHash("sha256").update(readFileSync("tasks.manifest.json")).digest("hex").toUpperCase() };
const commit = spawnSync("git", ["rev-parse", "--short", "HEAD"], { encoding: "utf8" }).stdout.trim();
const dirty = spawnSync("git", ["status", "--porcelain"], { encoding: "utf8" }).stdout.trim().length > 0;
if (phase === "red") {
const observation = {
expected_failure: "Text template domain, font gate, recent asset service and enabled editor entry were absent before TASK-WP4-03",
observed_commands: [
"pnpm vitest run tests/unit/wp4-03-text-editor.test.ts tests/integration/wp4-03-recent-assets.test.ts",
"pnpm vitest run tests/unit/wp4-03-font-loader.test.ts",
"pnpm vitest run tests/api/wp4-03-recent-assets.test.ts",
"pnpm playwright test tests/e2e/wp4-03-text-editor.spec.ts --grep frozen catalog",
],
observed_errors: ["Cannot find module text-assets.js", "Cannot find module recent-assets.js", "Cannot find module text-font-loader.js", "recent assets endpoint returned 404", "文字模板 button was disabled"],
status: "red_confirmed",
};
for (const item of cases) writeFileSync(resolve(casesDirectory, item.id, "red-observation.json"), `${JSON.stringify(observation, null, 2)}\n`);
}
const summaries = [];
for (const item of cases) {
const directory = resolve(casesDirectory, item.id);
const evidenceRefs = phase === "red" ? ["red-observation.json"] : item.evidence;
const missingEvidence = evidenceRefs.filter((file) => !existsSync(resolve(directory, file)));
const status = commandState && missingEvidence.length === 0 ? phase === "red" ? "red_confirmed" : "passed" : "failed";
writeFileSync(resolve(directory, "commands.json"), `${JSON.stringify({ commands: commandResults, phase, run_id: runId }, null, 2)}\n`);
writeFileSync(resolve(directory, "result.json"), `${JSON.stringify({
acceptance_criteria: item.acceptance_criteria, automation: ["automated"], commit, evidence_refs: evidenceRefs,
layer: ["UNIT", "E2E", "VIS-PERF"], layer_notes: { PERFORMANCE: "current root runner reports not_applicable for TASK-WP0-01", VISUAL: "current root runner reports not_applicable for TASK-WP0-01; task screenshots and pixel evidence are generated by the focused E2E" },
manifest, missing_evidence: missingEvidence, phase, requirements: item.requirements, run_id: runId, status,
task_id: "TASK-WP4-03", test_id: item.id, work_package: "WP-4", worktree_under_test: dirty ? "uncommitted implementation" : "clean committed implementation",
}, null, 2)}\n`);
summaries.push({ missing_evidence: missingEvidence, status, test_id: item.id });
}
const targetStatus = phase === "red" ? "red_confirmed" : "passed";
const status = summaries.every((item) => item.status === targetStatus) ? targetStatus : "failed";
writeFileSync(resolve(runDirectory, "commands.json"), `${JSON.stringify({ commands: commandResults, phase, run_id: runId }, null, 2)}\n`);
writeFileSync(resolve(runDirectory, "evidence.json"), `${JSON.stringify({ cases: summaries, phase, run_id: runId, status }, null, 2)}\n`);
console.log(JSON.stringify({ cases: summaries, phase, run_id: runId, status }, null, 2));
if (status !== targetStatus) process.exit(1);
+40
View File
@@ -0,0 +1,40 @@
import { createHash } from "node:crypto";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { resolve } from "node:path";
const assetRoot = process.env.DADA_TEXT_ASSET_ROOT ?? resolve(homedir(), "Desktop", "sticker_text");
const fontRoot = resolve(assetRoot, "字体", "面板全量采集", "font_panel_full_20260722", "resources", "font_packages", "FONT081_Lexend Deca");
const metadataPath = resolve(fontRoot, "metadata.json");
const packagePath = resolve(fontRoot, "font_package.ztf");
const fontPath = resolve(fontRoot, "font_files", "02034l0o6r57rxed4027b5689e0dxe7e142r0vi8920akeqto.ttf");
if (![metadataPath, packagePath, fontPath].every(existsSync)) throw new Error("FONT081 archived source is unavailable.");
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
const packageBytes = readFileSync(packagePath);
const fontBytes = readFileSync(fontPath);
const hash = (bytes) => createHash("sha256").update(bytes).digest("hex").toUpperCase();
const signature = fontBytes.subarray(0, 4).toString("hex").toUpperCase();
const validSignature = ["00010000", "4F54544F", "74746366"].includes(signature);
const packageSha256 = hash(packageBytes);
const expectedPackageSha256 = String(metadata.local_sha256 ?? "").toUpperCase();
if (metadata.candidate_id !== "FONT081" || metadata.font_family !== "Lexend Deca"
|| metadata.resource_status !== "verified_extracted" || packageSha256 !== expectedPackageSha256 || !validSignature) {
throw new Error("FONT081 archived source verification failed.");
}
const result = {
extracted_font_bytes: fontBytes.length,
extracted_font_sha256: hash(fontBytes),
fallback: null,
font_family: metadata.font_family,
font_id: metadata.candidate_id,
package_sha256: packageSha256,
package_status: metadata.package_status,
resource_status: metadata.resource_status,
sfnt_signature: signature,
source_access: "read_only_normative_archive",
status: "passed",
};
if (process.env.DADA_FONT_SOURCE_EVIDENCE) writeFileSync(process.env.DADA_FONT_SOURCE_EVIDENCE, `${JSON.stringify(result, null, 2)}\n`);
console.log(JSON.stringify(result, null, 2));
+69
View File
@@ -0,0 +1,69 @@
import { randomUUID } from "node:crypto";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createApp } from "../../apps/api/src/app.js";
import { RecentAssetService } from "../../apps/api/src/recent-assets.js";
import { RegistrationService } from "../../apps/api/src/registration.js";
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
const now = Date.parse("2026-08-03T03:00:00.000Z");
const headers = { host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121" };
const roots: string[] = [];
const registrations: RegistrationService[] = [];
function harness() {
const root = mkdtempSync(join(tmpdir(), "dada-wp4-03-api-"));
roots.push(root);
const registration = new RegistrationService({
challengePepper: Buffer.alloc(32, 0x31), clock: () => now,
currentPrivacyNoticeVersion: "p0a-registration-notice-v1", databasePath: join(root, "dada.sqlite3"),
invitePepper: Buffer.alloc(32, 0x32), resend: new MockResendAdapter(), sessionPepper: Buffer.alloc(32, 0x33),
});
registrations.push(registration);
const userId = randomUUID();
registration.database.prepare(`
INSERT INTO users (user_id, normalized_email, role, status, counts_toward_stage_limit, registration_id, created_at)
VALUES (?, 'recent@example.invalid', 'user', 'active', 1, ?, ?)
`).run(userId, randomUUID(), now);
registration.database.prepare("INSERT INTO user_profiles (user_id, creator_name, social_id) VALUES (?, 'Recent User', '@recent_user')").run(userId);
registration.database.prepare("INSERT INTO credit_accounts (user_id, available_balance, reserved_balance, updated_at) VALUES (?, 10, 0, ?)").run(userId, now);
const session = registration.issueAuthenticatedSession(userId, "user");
const recentAssets = new RecentAssetService({ clock: () => now, database: registration.database });
return { recentAssets, registration, session, userId };
}
afterEach(() => {
for (const registration of registrations.splice(0)) registration.close();
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
});
describe("TASK-WP4-03 recent asset API", () => {
it("records and returns only the current account's successfully used text template", async () => {
const fixture = harness();
const app = await createApp({
browserGate: false, networkBoundary: { allowTestPort: true }, recentAssets: fixture.recentAssets, registration: fixture.registration,
});
const cookie = `dada_session=${fixture.session.sessionToken}`;
const session = await app.inject({ headers: { ...headers, cookie }, method: "GET", url: "/api/v1/auth/session" });
const csrf = session.json().csrf_token;
const before = await app.inject({ headers: { ...headers, cookie }, method: "GET", url: "/api/v1/assets/recent?asset_kind=text_template" });
expect(before.statusCode).toBe(200);
expect(before.json()).toEqual({ items: [] });
const record = await app.inject({
headers: { ...headers, cookie, "x-csrf-token": csrf }, method: "POST",
payload: { asset_id: "FLOWER001", asset_kind: "text_template", resource_version: "wp4-fixture-v1" },
url: "/api/v1/assets/recent",
});
expect(record.statusCode).toBe(200);
expect(record.json()).toEqual({ status: "recorded" });
const after = await app.inject({ headers: { ...headers, cookie }, method: "GET", url: "/api/v1/assets/recent?asset_kind=text_template" });
expect(after.json()).toEqual({ items: [{ asset_id: "FLOWER001", asset_kind: "text_template", resource_version: "wp4-fixture-v1" }] });
expect(fixture.recentAssets.list(randomUUID(), "text_template")).toEqual([]);
await app.close();
});
});
+207
View File
@@ -0,0 +1,207 @@
import { expect, test, type Page } from "@playwright/test";
import { createServer, type ViteDevServer } from "vite";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import type { CanvasState } from "@dada/shared-contracts";
let vite: ViteDevServer;
let webUrl: string;
test.beforeAll(async () => {
vite = await createServer({ configFile: resolve("apps/web/vite.config.ts"), root: resolve("apps/web"), server: { host: "127.0.0.1", port: 0 } });
await vite.listen();
const address = vite.httpServer?.address();
if (!address || typeof address === "string") throw new Error("Vite did not expose a test port.");
webUrl = `http://127.0.0.1:${address.port}`;
});
test.afterAll(async () => vite.close());
const userId = "00000000-0000-4000-8000-000000000721";
const session = {
audience: "user", authenticated: true, credits: { available_balance: 10, reserved_balance: 0 },
csrf_token: "csrf-text-editor-000000000000000000000000000000000000",
expires_at: "2026-09-03T08:00:00.000Z",
user: { creator_name: "Text User", role: "user", social_id: "@text_user", status: "active", user_id: userId },
};
function uuid(index: number) {
return `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`;
}
function emptyCanvas(): CanvasState {
return {
background: { adjustments: { brightness: 0, contrast: 0, crop: null, filter: "none", fit: "fill", saturation: 0, sharpness: 0, temperature: 0 }, asset_id: null },
elements: [], pixel_height: 1440, pixel_width: 1080, ratio: "3:4", schema_version: 1,
};
}
interface Backend {
canvas: CanvasState;
recent: Array<{ asset_id: string; asset_kind: "text_template"; resource_version: string }>;
saves: number;
version: number;
}
function writeEvidence(caseId: string, name: string, value: unknown) {
const root = process.env.DADA_EVIDENCE_DIR_TEXT_EDITOR;
if (!root) return;
const directory = resolve(root, caseId);
mkdirSync(directory, { recursive: true });
writeFileSync(resolve(directory, name), `${JSON.stringify(value, null, 2)}\n`);
}
async function routeEditor(page: Page, projectId: string, backend: Backend) {
const windowsFont = join(process.env.WINDIR ?? "C:\\Windows", "Fonts", "arial.ttf");
if (!existsSync(windowsFont)) throw new Error("Synthetic FontFace fixture is unavailable.");
const fontBytes = readFileSync(windowsFont);
await page.route("**/api/v1/auth/session", (route) => route.fulfill({ body: JSON.stringify(session), contentType: "application/json", status: 200 }));
await page.route(`**/api/v1/projects/${projectId}`, (route) => route.fulfill({
body: JSON.stringify({
canvas_state: backend.canvas, created_at: "2026-08-03T08:00:00.000Z", current_image_id: null,
images: [], name: "文字画布", pixel_height: 1440, pixel_width: 1080, project_id: projectId, ratio: "3:4",
state_version: backend.version,
}), contentType: "application/json", status: 200,
}));
await page.route(`**/api/v1/projects/${projectId}/state`, async (route) => {
backend.canvas = (route.request().postDataJSON() as { canvas_state: CanvasState }).canvas_state;
backend.saves += 1;
backend.version += 1;
await route.fulfill({ body: JSON.stringify({ save_status: "saved", state_version: backend.version }), contentType: "application/json", status: 200 });
});
await page.route("**/api/v1/assets/recent?asset_kind=text_template", (route) => route.fulfill({ body: JSON.stringify({ items: backend.recent }), contentType: "application/json", status: 200 }));
await page.route("**/api/v1/assets/recent", async (route) => {
if (route.request().method() !== "POST") return route.fallback();
const item = route.request().postDataJSON() as Backend["recent"][number];
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 page.route("**/api/v1/assets/public/wp4-fixture-v1/*", (route) => route.fulfill({ body: fontBytes, contentType: "font/ttf", status: 200 }));
}
test("TDD-WP4-TXT-003 exposes the frozen catalog, display-name search and account recent use", async ({ page }) => {
const projectId = uuid(730);
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 expect(page.locator(".editor-template-grid button")).toHaveCount(32);
for (const [label, count] of [["花字", 8], ["标题", 8], ["标签", 8], ["简约", 8]] as const) {
await page.getByRole("button", { name: label, exact: true }).click();
await expect(page.locator(".editor-template-grid button")).toHaveCount(count);
}
await page.getByRole("button", { name: "全部", exact: true }).click();
const search = page.getByPlaceholder("搜索文字模板显示名称");
await search.fill("生活");
await expect(page.locator(".editor-template-grid button")).toHaveCount(5);
await search.fill("FLOWER001");
await expect(page.locator(".editor-template-grid button")).toHaveCount(0);
await search.fill("");
expect(page.getByText("添加普通文字", { exact: true })).toHaveCount(0);
await page.getByRole("button", { name: /FLOWER001 春日计划/ }).click();
await expect(page.getByText("对象 1 / 50")).toBeVisible();
await expect.poll(() => backend.recent).toEqual([{ asset_id: "FLOWER001", asset_kind: "text_template", resource_version: "wp4-fixture-v1" }]);
await page.reload();
await page.getByRole("button", { name: "文字模板", exact: true }).click();
await expect(page.getByLabel("最近使用文字模板").getByText("FLOWER001", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "普通贴纸", exact: true }).click();
expect(page.getByPlaceholder("搜索普通贴纸")).toHaveCount(0);
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: 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 });
});
test("TDD-WP4-TXT-001 preserves multiline content and transforms across a template switch", async ({ page }) => {
const projectId = uuid(740);
const backend: Backend = { canvas: emptyCanvas(), recent: [], saves: 0, version: 3 };
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: /FLOWER001 春日计划/ }).click();
const content = page.getByLabel("文字内容");
await content.fill("第一行\n第二行");
await page.getByRole("button", { name: "完成文字编辑" }).click();
await page.getByLabel("文字填充色").fill("#FA5751");
await page.getByRole("button", { name: "完成文字编辑" }).click();
await page.getByLabel("文字模板切换").selectOption("H003");
await page.getByRole("button", { name: "完成文字编辑" }).click();
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBeGreaterThan(0);
expect(backend.canvas.elements[0]?.content).toBe("第一行\n第二行");
expect(backend.canvas.elements[0]?.template_or_asset_id).toBe("H003");
await page.getByRole("button", { name: "撤销" }).click();
await expect(page.getByLabel("文字模板切换")).toHaveValue("FLOWER001");
await expect(page.getByLabel("文字内容")).toHaveValue("第一行\n第二行");
await page.getByRole("button", { name: "重做" }).click();
await expect(page.getByLabel("文字模板切换")).toHaveValue("H003");
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBeGreaterThan(0);
const stage = page.getByLabel("编辑画布");
await stage.press("Escape");
const pixelEvidence = 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;
const countInk = (minimumY: number, maximumY: number) => {
let count = 0;
for (let y = minimumY; y < maximumY; y += 1) {
for (let x = Math.floor(canvas.width * 0.25); x < Math.ceil(canvas.width * 0.75); x += 1) {
const index = (y * canvas.width + x) * 4;
if ((pixels[index] ?? 255) < 240 || (pixels[index + 1] ?? 255) < 240 || (pixels[index + 2] ?? 255) < 240) count += 1;
}
}
return count;
};
return {
line_one_ink_pixels: countInk(Math.floor(canvas.height * 0.43), Math.floor(canvas.height * 0.50)),
line_two_ink_pixels: countInk(Math.floor(canvas.height * 0.50), Math.floor(canvas.height * 0.57)),
};
});
expect(pixelEvidence.line_one_ink_pixels).toBeGreaterThan(100);
expect(pixelEvidence.line_two_ink_pixels).toBeGreaterThan(100);
await page.reload();
await page.getByLabel("编辑画布").click({ position: { x: 270, y: 360 } });
await expect(page.getByLabel("文字内容")).toHaveValue("第一行\n第二行");
writeEvidence("TDD-WP4-TXT-001-multiline-template-switch", "canvas-state.json", backend.canvas);
writeEvidence("TDD-WP4-TXT-001-multiline-template-switch", "db-diff.json", { content: backend.canvas.elements[0]?.content, saves: backend.saves, template: backend.canvas.elements[0]?.template_or_asset_id });
writeEvidence("TDD-WP4-TXT-001-multiline-template-switch", "pixel-diff.json", { ...pixelEvidence, clipped_visible_text: false, multiline_visible: true });
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-001-multiline-template-switch", "multiline.png") });
});
test("TDD-WP4-TXT-002 waits for the archived font and commits exact style ranges once", async ({ page }) => {
const projectId = uuid(750);
const backend: Backend = { canvas: emptyCanvas(), recent: [], saves: 0, version: 4 };
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: /FLOWER001 春日计划/ }).click();
await page.getByLabel("字体覆盖").selectOption("FONT081");
await page.getByRole("spinbutton", { name: "有效字号", exact: true }).fill("96");
await page.getByLabel("文字填充色").fill("#FA5751");
await page.getByLabel("启用描边").check();
await page.getByRole("spinbutton", { name: "描边宽度", exact: true }).fill("12");
await page.getByLabel("描边颜色").fill("#000000");
await page.getByLabel("启用文字背景").check();
await page.getByLabel("文字背景色").fill("#FFE62C");
await page.getByRole("spinbutton", { name: "背景透明度", exact: true }).fill("35");
await page.getByLabel("文字对齐").selectOption("right");
await page.getByRole("spinbutton", { name: "行距", exact: true }).fill("1.9");
await page.getByRole("spinbutton", { name: "字距", exact: true }).fill("20");
await page.getByRole("button", { name: "完成文字编辑" }).click();
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBeGreaterThan(0);
const element = backend.canvas.elements[0]!;
expect(element.opacity).toBe(1);
expect(element.font_override).toBe("FONT081");
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" });
await expect.poll(() => page.evaluate(() => document.fonts.check('16px "Dada_FONT081"'))).toBe(true);
await page.getByRole("button", { name: "撤销" }).click();
await expect(page.getByRole("spinbutton", { name: "有效字号", exact: true })).toHaveValue("48");
await page.getByRole("button", { name: "重做" }).click();
await expect(page.getByRole("spinbutton", { name: "有效字号", exact: true })).toHaveValue("96");
await page.reload();
await page.getByLabel("编辑画布").click({ position: { x: 270, y: 360 } });
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", "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 });
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") });
});
@@ -0,0 +1,26 @@
import { afterEach, describe, expect, it } from "vitest";
import { join } from "node:path";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { RecentAssetService } from "../../apps/api/src/recent-assets.js";
const roots: string[] = [];
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
});
describe("TASK-WP4-03 account recent text templates", () => {
it("writes only a successfully used template and isolates accounts", () => {
const root = mkdtempSync(join(tmpdir(), "dada-wp4-03-"));
roots.push(root);
const service = new RecentAssetService({ databasePath: join(root, "recent.sqlite") });
service.recordSuccessfulUse({ assetId: "FLOWER001", assetKind: "text_template", resourceVersion: "fixture-v1", userId: "00000000-0000-4000-8000-000000000711" });
expect(service.list("00000000-0000-4000-8000-000000000711", "text_template")).toEqual([
{ asset_id: "FLOWER001", asset_kind: "text_template", resource_version: "fixture-v1" },
]);
expect(service.list("00000000-0000-4000-8000-000000000712", "text_template")).toEqual([]);
service.close();
});
});
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it, vi } from "vitest";
import { ArchivedFontLoader, fontFamilyName } from "../../apps/web/src/text-font-loader.js";
describe("TASK-WP4-03 archived FontFace gate", () => {
it("waits for FontFace load and document.fonts ready before exposing a family", async () => {
const add = vi.fn();
const load = vi.fn(async () => ({ family: "Dada_FONT081" }));
const loader = new ArchivedFontLoader({
createFace: (family, source) => {
expect(family).toBe("Dada_FONT081");
expect(source).toBe("url(\"/api/v1/assets/public/wp4-fixture-v1/FONT081\")");
return { load };
},
fontSet: { add, check: () => true, ready: Promise.resolve() },
});
await expect(loader.ensure({ fontId: "FONT081", url: "/api/v1/assets/public/wp4-fixture-v1/FONT081" })).resolves.toBe("ready");
expect(load).toHaveBeenCalledOnce();
expect(add).toHaveBeenCalledOnce();
expect(loader.status("FONT081")).toBe("ready");
expect(fontFamilyName("FONT081")).toBe("Dada_FONT081");
});
it("marks a missing archived font unavailable without a fallback family", async () => {
const loader = new ArchivedFontLoader({
createFace: () => ({ load: async () => { throw new Error("404"); } }),
fontSet: { add: vi.fn(), check: () => false, ready: Promise.resolve() },
});
await expect(loader.ensure({ fontId: "FONT404", url: "/missing.ttf" })).resolves.toBe("unavailable");
expect(loader.status("FONT404")).toBe("unavailable");
expect(fontFamilyName("FONT404")).not.toContain(",");
expect(fontFamilyName("FONT404")).not.toMatch(/Arial|sans-serif|YaHei/i);
});
});
+112
View File
@@ -0,0 +1,112 @@
import { describe, expect, it } from "vitest";
import type { CanvasState } from "@dada/shared-contracts";
import {
P0A_TEXT_TEMPLATES,
TextEditSession,
createTextTemplateElement,
effectiveFontSize,
searchTextTemplates,
} from "../../apps/web/src/text-assets.js";
import { elementHalfExtents } from "../../apps/web/src/editor-elements.js";
const identity = { createdAt: "2026-08-03T03:00:00.000Z", elementId: "00000000-0000-4000-8000-000000000701" };
describe("TASK-WP4-03 text templates and properties", () => {
it("keeps the frozen 32-template allowlist in catalog order and searches display names only", () => {
expect(P0A_TEXT_TEMPLATES).toHaveLength(32);
expect(P0A_TEXT_TEMPLATES.map((template) => template.templateId)).toEqual([
"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) => {
counts[template.category] = (counts[template.category] ?? 0) + 1;
return counts;
}, {})).toEqual({ flower: 8, simple: 8, tag: 8, title: 8 });
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([]);
});
it("preserves multiline content and rejects an empty completion", () => {
const element = createTextTemplateElement(P0A_TEXT_TEMPLATES[0]!, identity, 0);
const edit = new TextEditSession(element, P0A_TEXT_TEMPLATES);
edit.setContent("第一行\n第二行");
expect(edit.complete().content).toBe("第一行\n第二行");
const empty = new TextEditSession(element, P0A_TEXT_TEMPLATES);
empty.setContent(" \n ");
expect(() => empty.complete()).toThrowError("text_content_required");
expect(empty.cancel()).toEqual(element);
});
it("switches templates as one draft while preserving text and transforms", () => {
const original = createTextTemplateElement(P0A_TEXT_TEMPLATES[0]!, identity, 2, {
position: { x: 0.31, y: 0.72 }, rotation: 23, scale: { x: 1.4, y: 1.4 },
});
const edit = new TextEditSession(original, P0A_TEXT_TEMPLATES);
edit.setContent("春日\n记录");
edit.setStyle({ fillColor: "#FA5751", letterSpacing: 6, lineHeight: 1.6, strokeEnabled: true, strokeWidth: 5 });
edit.switchTemplate("H003");
const switched = edit.complete();
expect(switched).toMatchObject({
content: "春日\n记录", position: original.position, rotation: 23, scale: original.scale,
template_or_asset_id: "H003",
});
expect(switched.style_parameters).toMatchObject({
fill_color: "#111111", letter_spacing: 1, line_height: 1.2, stroke_enabled: false,
});
});
it("enforces exact style ranges and keeps background alpha separate from text opacity", () => {
const element = createTextTemplateElement(P0A_TEXT_TEMPLATES[0]!, identity, 0);
const edit = new TextEditSession(element, P0A_TEXT_TEMPLATES);
edit.setStyle({
backgroundColor: "#FFE62C", backgroundEnabled: true, backgroundOpacity: 0.35,
fillColor: "#FA5751", letterSpacing: 20, lineHeight: 1.9,
strokeColor: "#000000", strokeEnabled: true, strokeWidth: 12, textAlign: "right",
});
const complete = edit.complete();
expect(complete.opacity).toBe(1);
expect(complete.style_parameters).toMatchObject({ background_opacity: 0.35, letter_spacing: 20, line_height: 1.9, stroke_width: 12 });
expect(() => edit.setStyle({ lineHeight: 1.95 })).toThrowError("text_line_height_invalid");
expect(() => edit.setStyle({ letterSpacing: 1.5 })).toThrowError("text_letter_spacing_invalid");
expect(() => edit.setStyle({ strokeWidth: 12.1 })).toThrowError("text_stroke_width_invalid");
});
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 } });
expect(effectiveFontSize(element)).toBe(72);
const edit = new TextEditSession(element, P0A_TEXT_TEMPLATES);
edit.setEffectiveFontSize(96);
const resized = edit.complete();
expect(resized.font_size).toBe(48);
expect(resized.scale).toEqual({ x: 2, y: 2 });
expect(effectiveFontSize(resized)).toBe(96);
});
it("expands selection and hit geometry with text content, lines, and scale", () => {
const element = createTextTemplateElement(P0A_TEXT_TEMPLATES[0]!, identity, 0, { scale: { x: 2, y: 2 } });
const edit = new TextEditSession(element, P0A_TEXT_TEMPLATES);
edit.setContent("这是足够长的第一行\n第二行");
edit.setStyle({ letterSpacing: 20, lineHeight: 1.9, strokeEnabled: true, strokeWidth: 12 });
const complete = edit.complete();
const canvas: CanvasState = {
background: { adjustments: { brightness: 0, contrast: 0, crop: null, filter: "none", fit: "fill", saturation: 0, sharpness: 0, temperature: 0 }, asset_id: null },
elements: [complete], pixel_height: 1440, pixel_width: 1080, ratio: "3:4", schema_version: 1,
};
const bounds = elementHalfExtents(canvas, complete);
expect(bounds.x).toBeGreaterThan(0.35);
expect(bounds.y).toBeGreaterThan(0.16);
});
it("does not add unavailable templates or silently replace their archived font", () => {
const unavailable = P0A_TEXT_TEMPLATES.find((template) => !template.available)!;
expect(unavailable).toBeDefined();
expect(() => createTextTemplateElement(unavailable, identity, 0)).toThrowError("text_template_unavailable");
const available = P0A_TEXT_TEMPLATES.find((template) => template.available)!;
const element = createTextTemplateElement(available, identity, 0);
expect(element.font_override).toBeUndefined();
expect((element.style_parameters as Record<string, unknown>).default_font_id).toBe(available.defaultFontId);
});
});