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,
ProjectSummarySchema,
ProjectViewStatusSchema,
RecentAssetItemSchema,
RecentAssetKindSchema,
RecentAssetListResponseSchema,
RecentAssetQuerySchema,
RecentAssetRecordRequestSchema,
RecentAssetRecordResponseSchema,
RegistrationCompleteHeadersSchema,
RegistrationCompleteRequestSchema,
RegistrationCompleteResponseSchema,
@@ -120,6 +126,8 @@ import {
type ProjectRenameRequest,
type ProjectEditableState,
type ProjectStateSaveHeaders,
type RecentAssetQuery,
type RecentAssetRecordRequest,
type RegistrationCompleteRequest,
type RegistrationSendRequest,
} from "@dada/shared-contracts";
@@ -162,6 +170,7 @@ import {
registrationFieldError,
} from "./registration-errors.js";
import type { RegistrationService } from "./registration.js";
import type { RecentAssetService } from "./recent-assets.js";
import { ModelConfigurationError } from "./model-configuration.js";
import type { ModelConfigurationService } from "./model-configuration.js";
@@ -189,6 +198,7 @@ export interface CreateAppOptions {
models?: ModelConfigurationService;
networkBoundary?: NetworkBoundaryOptions;
publicAssets?: PublicAssetResolver;
recentAssets?: RecentAssetService;
projects?: ProjectService;
registration?: RegistrationService;
}
@@ -726,6 +736,12 @@ export async function createApp(options: CreateAppOptions = {}) {
ProjectStateSaveHeadersSchema,
ProjectStateSaveResponseSchema,
ProjectStateConflictResponseSchema,
RecentAssetKindSchema,
RecentAssetItemSchema,
RecentAssetQuerySchema,
RecentAssetListResponseSchema,
RecentAssetRecordRequestSchema,
RecentAssetRecordResponseSchema,
FailedEmptyTrashRequestSchema,
FailedEmptyTrashResponseSchema,
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(
"/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 { ProjectService } from "./projects.js";
import { RegistrationService } from "./registration.js";
import { RecentAssetService } from "./recent-assets.js";
import { MockResendAdapter } from "./resend-adapter.js";
import { readSecureConfigCandidate } from "./secure-config.js";
import { StructuredJsonlLogger } from "./structured-log.js";
@@ -24,6 +25,7 @@ let credits: CreditService | undefined;
let storage: ManagedStorage | undefined;
let latestExports: LatestExportService | undefined;
let models: ModelConfigurationService | undefined;
let recentAssets: RecentAssetService | undefined;
const instanceConfigPath = process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultInstanceConfigPath();
if (credentialChannelEnabled) {
const clients = initializeApiCredentialClients(await receiveApiCredentials());
@@ -47,6 +49,7 @@ if (credentialChannelEnabled) {
storage = new ManagedStorage({ dataRoot, databasePath });
latestExports = new LatestExportService({ databasePath, storage });
models = new ModelConfigurationService({ database: registration.database });
recentAssets = new RecentAssetService({ database: registration.database });
registration.applySecureConfig(readSecureConfigCandidate(instanceConfigPath));
} catch (error) {
latestExports?.close();
@@ -73,6 +76,7 @@ const app = await createApp({
...(models ? { models } : {}),
...(projects ? { projects } : {}),
...(registration ? { registration } : {}),
...(recentAssets ? { recentAssets } : {}),
});
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 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) {
return structuredClone(state);
}
@@ -132,8 +160,7 @@ export class CanvasElementController {
candidatesAt(point: CanvasPoint) {
return this.current.elements
.filter((element) => {
const halfWidth = hitHalfExtent * element.scale.x;
const halfHeight = hitHalfExtent * element.scale.y;
const { x: halfWidth, y: halfHeight } = elementHalfExtents(this.current, element);
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;
})
@@ -186,6 +213,17 @@ export class CanvasElementController {
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) {
const selected = new Set(this.selection);
this.current = requireState({
+74
View File
@@ -312,6 +312,80 @@
.editor-wide-command { width: 100%; margin-top: 18px; }
.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 {
display: flex;
align-items: center;
+211 -5
View File
@@ -12,16 +12,35 @@ import {
} from "./editor-canvas.js";
import { CanvasElementController, createStaticStickerElement, type CanvasElementIdentity, type CanvasLayerCommand, type CanvasPoint, type CanvasRect } from "./editor-elements.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";
type Ratio = CanvasState["ratio"];
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 {
csrf_token: string;
user: { creator_name: string };
user: { creator_name: string; user_id: string };
}
interface EditorProject {
@@ -82,12 +101,18 @@ export function EditorPage({ projectId }: { projectId: string }) {
const [guides, setGuides] = useState<string[]>([]);
const [multiMode, setMultiMode] = useState(false);
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 historyRef = useRef<CanvasEditHistory | undefined>(undefined);
const elementControllerRef = useRef<CanvasElementController | 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 clipboardRef = useRef<CanvasElement[]>([]);
const fontLoaderRef = useRef<ArchivedFontLoader | undefined>(undefined);
useEffect(() => {
let active = true;
@@ -107,6 +132,15 @@ export function EditorPage({ projectId }: { projectId: string }) {
return () => { active = false; };
}, [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(() => {
if (!project || !session || !canvasState) return undefined;
const queue = new ProjectAutoSaveQueue({
@@ -133,6 +167,48 @@ export function EditorPage({ projectId }: { projectId: string }) {
return () => { queue.dispose(); if (queueRef.current === queue) queueRef.current = undefined; };
}, [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) {
if (!project || saveStatus === "conflicted") return;
historyRef.current?.commit(next);
@@ -141,6 +217,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
setCanvasState(next);
setDraftAdjustments(next.background.adjustments);
queueRef.current?.commit({ canvas_state: next, name: project.name });
setTextEdit(undefined);
}
function applyPreview() {
@@ -157,6 +234,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
setCanvasState(previous);
setDraftAdjustments(previous.background.adjustments);
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);
setDraftAdjustments(next.background.adjustments);
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) {
const controller = controllerForCurrent();
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>;
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 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")
? Math.round((selectedElements[0]?.opacity ?? 1) * 100)
: undefined;
@@ -368,7 +550,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
{([
{ label: "底图", panel: "background" as const },
{ label: "历史", panel: "history" as const },
{ label: "文字模板" },
{ label: "文字模板", panel: "text" as const },
{ label: "普通贴纸", panel: "stickers" as const },
{ label: "色卡" },
{ label: "动态贴纸" },
@@ -378,6 +560,18 @@ export function EditorPage({ projectId }: { projectId: string }) {
{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>)}
</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">
{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}
@@ -392,7 +586,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
<div className="editor-canvas-frame" style={{ aspectRatio: `${canvasState.pixel_width} / ${canvasState.pixel_height}` }}>
<EditorStage
assetId={canvasState.background.asset_id}
canvasState={canvasState}
canvasState={renderedCanvasState}
fontStatuses={fontStatuses}
guides={guides}
onCandidates={showCandidates}
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>
{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="移动对象">
<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>
+96 -10
View File
@@ -3,6 +3,8 @@ import type { CanvasState } from "@dada/shared-contracts";
import { cssFilterForBackground } from "./editor-canvas.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 {
append: boolean;
@@ -16,6 +18,7 @@ interface EditorStageProps {
assetId: string | null;
canvasState: CanvasState;
guides: readonly string[];
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>;
onCandidates: (point: CanvasPoint) => void;
onClearSelection: () => 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 y = element.position.y * height;
context.save();
@@ -66,11 +157,7 @@ function drawElement(context: CanvasRenderingContext2D, element: CanvasState["el
context.font = "700 22px Consolas, monospace";
context.textAlign = "center";
context.fillText(element.template_or_asset_id, 0, 8);
} else {
context.fillStyle = "#111111";
context.font = "700 48px Microsoft YaHei, sans-serif";
context.fillText(element.content ?? "DADA", -80, 16);
}
} else if (element.type === "text_template") drawTextElement(context, element, fontStatuses);
context.restore();
}
@@ -94,12 +181,11 @@ export function EditorStage(props: EditorStageProps) {
context.filter = cssFilterForBackground(props.canvasState.background.adjustments);
if (image) context.drawImage(image, 0, 0, canvas.width, canvas.height);
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.strokeStyle = "#005fcc";
for (const element of props.canvasState.elements.filter((entry) => props.selectedIds.includes(entry.element_id))) {
const halfWidth = 78 * element.scale.x;
const halfHeight = 78 * element.scale.y;
const { width: halfWidth, height: halfHeight } = elementSelectionHalfSize(context, element, props.fontStatuses);
context.strokeRect(element.position.x * canvas.width - halfWidth, element.position.y * canvas.height - halfHeight, halfWidth * 2, halfHeight * 2);
}
context.save();
@@ -124,7 +210,7 @@ export function EditorStage(props: EditorStageProps) {
image.onerror = () => render();
image.src = `/api/v1/private-assets/projects/${encodeURIComponent(props.projectId)}/images/${encodeURIComponent(props.assetId)}`;
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); }, []);
+17 -1
View File
@@ -1,6 +1,6 @@
// 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; }
@@ -175,6 +175,13 @@ export async function listProjects(options: ClientOptions = {}): Promise<Project
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> {
const request = options.fetch ?? globalThis.fetch;
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>;
}
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> {
const request = options.fetch ?? globalThis.fetch;
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 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 = {
"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>;
}