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
+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>