From dd7a23a2814a0b6689fe85481cd9a0952c2202f7 Mon Sep 17 00:00:00 2001 From: suyx Date: Mon, 3 Aug 2026 11:40:45 +0800 Subject: [PATCH] feat: complete TASK-WP4-03 text templates --- apps/api/src/app.ts | 86 ++++++ apps/api/src/main.ts | 4 + apps/api/src/recent-assets.ts | 67 +++++ apps/web/src/editor-elements.ts | 42 ++- apps/web/src/editor-page.css | 74 +++++ apps/web/src/editor-page.tsx | 216 +++++++++++++- apps/web/src/editor-stage.tsx | 106 ++++++- apps/web/src/generated/api/sdk.gen.ts | 18 +- apps/web/src/generated/api/types.gen.ts | 26 ++ apps/web/src/text-assets.ts | 266 ++++++++++++++++++ apps/web/src/text-font-loader.ts | 75 +++++ apps/web/src/text-inspector.tsx | 53 ++++ apps/web/src/text-template-panel.tsx | 45 +++ openapi/openapi.json | 224 +++++++++++++++ package.json | 6 +- packages/shared-contracts/src/assets.ts | 44 +++ packages/shared-contracts/src/index.ts | 1 + scripts/run-wp4-03-validation.mjs | 113 ++++++++ scripts/verify-wp4-03-font-source.mjs | 40 +++ tests/api/wp4-03-recent-assets.test.ts | 69 +++++ tests/e2e/wp4-03-text-editor.spec.ts | 207 ++++++++++++++ .../integration/wp4-03-recent-assets.test.ts | 26 ++ tests/unit/wp4-03-font-loader.test.ts | 34 +++ tests/unit/wp4-03-text-editor.test.ts | 112 ++++++++ 24 files changed, 1934 insertions(+), 20 deletions(-) create mode 100644 apps/api/src/recent-assets.ts create mode 100644 apps/web/src/text-assets.ts create mode 100644 apps/web/src/text-font-loader.ts create mode 100644 apps/web/src/text-inspector.tsx create mode 100644 apps/web/src/text-template-panel.tsx create mode 100644 packages/shared-contracts/src/assets.ts create mode 100644 scripts/run-wp4-03-validation.mjs create mode 100644 scripts/verify-wp4-03-font-source.mjs create mode 100644 tests/api/wp4-03-recent-assets.test.ts create mode 100644 tests/e2e/wp4-03-text-editor.spec.ts create mode 100644 tests/integration/wp4-03-recent-assets.test.ts create mode 100644 tests/unit/wp4-03-font-loader.test.ts create mode 100644 tests/unit/wp4-03-text-editor.test.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 1abc9d3..fe97b05 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -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", { diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 96a25d0..9072872 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -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({ diff --git a/apps/api/src/recent-assets.ts b/apps/api/src/recent-assets.ts new file mode 100644 index 0000000..37f29f3 --- /dev/null +++ b/apps/api/src/recent-assets.ts @@ -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() }); + } +} diff --git a/apps/web/src/editor-elements.ts b/apps/web/src/editor-elements.ts index 9fe922c..820ec4a 100644 --- a/apps/web/src/editor-elements.ts +++ b/apps/web/src/editor-elements.ts @@ -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({ diff --git a/apps/web/src/editor-page.css b/apps/web/src/editor-page.css index 2a35c36..edeeffb 100644 --- a/apps/web/src/editor-page.css +++ b/apps/web/src/editor-page.css @@ -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; diff --git a/apps/web/src/editor-page.tsx b/apps/web/src/editor-page.tsx index 2946679..7a6699e 100644 --- a/apps/web/src/editor-page.tsx +++ b/apps/web/src/editor-page.tsx @@ -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([]); const [multiMode, setMultiMode] = useState(false); const [candidateMenu, setCandidateMenu] = useState<{ elements: CanvasElement[]; point: CanvasPoint }>(); + const [templateCategory, setTemplateCategory] = useState(); + const [templateQuery, setTemplateQuery] = useState(""); + const [recentTextIds, setRecentTextIds] = useState([]); + const [fontStatuses, setFontStatuses] = useState>({}); + const [textEdit, setTextEdit] = useState(); const queueRef = useRef(undefined); const historyRef = useRef(undefined); const elementControllerRef = useRef(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([]); + const fontLoaderRef = useRef(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
正在加载编辑器
; + 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" ?

历史

{project.images.length === 0 ?

暂无其他成功图

: project.images.map((image) => )}
: null} + {activePanel === "text" ? { void addTextTemplate(template); }} + onCategory={setTemplateCategory} + onQuery={setTemplateQuery} + onRetry={() => { void retryTextFonts(); }} + query={templateQuery} + recentIds={recentTextIds} + templates={P0A_TEXT_TEMPLATES} + {...(templateCategory ? { category: templateCategory } : {})} + /> : null} {activePanel === "stickers" ?

普通贴纸

{stickerFixtures.map((sticker) => )}
{canvasState.elements.length >= 50 ?

画布最多 50 个元素,请先删除现有元素。

: null}
: null} @@ -392,7 +586,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
: <>

{selectedElements.length > 1 ? "MULTI SELECT" : "OBJECT"}

{selectedElements.length > 1 ? `已选 ${selectedElements.length} 个对象` : "对象参数"}

{selectedElements.length === 1 ?

{selectedElements[0]?.template_or_asset_id}

: null} + {textEdit && selectedElements.length === 1 ? canEdit ? 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} + /> :

版本冲突,文字属性只读

: null}
diff --git a/apps/web/src/editor-stage.tsx b/apps/web/src/editor-stage.tsx index ba3601c..8b6b2c3 100644 --- a/apps/web/src/editor-stage.tsx +++ b/apps/web/src/editor-stage.tsx @@ -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>; onCandidates: (point: CanvasPoint) => void; onClearSelection: () => void; onCopy: () => void; @@ -39,7 +42,95 @@ function pointFromEvent(event: PointerEvent): CanvasPoint { }; } -function drawElement(context: CanvasRenderingContext2D, element: CanvasState["elements"][number], width: number, height: number) { +function styleValue(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>) { + 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>, +) { + 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>) { 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); }, []); diff --git a/apps/web/src/generated/api/sdk.gen.ts b/apps/web/src/generated/api/sdk.gen.ts index 37c618d..6a602c5 100644 --- a/apps/web/src/generated/api/sdk.gen.ts +++ b/apps/web/src/generated/api/sdk.gen.ts @@ -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; } +export async function listRecentAssets(options: ClientOptions = {}): Promise { + 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; +} + export async function logoutUser(options: ClientOptions = {}): Promise { 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; } +export async function recordRecentAsset(body: RecentAssetRecordRequest, options: ClientOptions = {}): Promise { + 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; +} + export async function renameProject(body: ProjectRenameRequest, options: ClientOptions = {}): Promise { const request = options.fetch ?? globalThis.fetch; const headers = new Headers(options.headers); diff --git a/apps/web/src/generated/api/types.gen.ts b/apps/web/src/generated/api/types.gen.ts index 3516d73..5f0da16 100644 --- a/apps/web/src/generated/api/types.gen.ts +++ b/apps/web/src/generated/api/types.gen.ts @@ -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; +}; + +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; }; diff --git a/apps/web/src/text-assets.ts b/apps/web/src/text-assets.ts new file mode 100644 index 0000000..a2845b9 --- /dev/null +++ b/apps/web/src/text-assets.ts @@ -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 { + 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> = {}, +): 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; + } +} diff --git a/apps/web/src/text-font-loader.ts b/apps/web/src/text-font-loader.ts new file mode 100644 index 0000000..a2bc44a --- /dev/null +++ b/apps/web/src/text-font-loader.ts @@ -0,0 +1,75 @@ +export type ArchivedFontStatus = "idle" | "loading" | "ready" | "unavailable"; + +interface LoadableFontFace { + load: () => Promise; +} + +interface FontSetPort { + add: (face: unknown) => unknown; + check: (font: string) => boolean; + ready: Promise; +} + +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>(); + private readonly statuses = new Map(); + + 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 { + 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 { + 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, + }); +} diff --git a/apps/web/src/text-inspector.tsx b/apps/web/src/text-inspector.tsx new file mode 100644 index 0000000..fd80def --- /dev/null +++ b/apps/web/src/text-inspector.tsx @@ -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(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 ; +} + +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
+ +