feat: complete TASK-WP4-03 text templates
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user