feat: complete TASK-WP4-05 client export

This commit is contained in:
suyx
2026-08-03 14:41:04 +08:00
parent 457e1147e4
commit d09fda13c5
9 changed files with 753 additions and 26 deletions
+107 -2
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from "react";
import type { CanvasState, ProjectEditableState } from "@dada/shared-contracts";
import { ProjectAutoSaveQueue, type ProjectSaveStatus } from "./project-autosave.js";
import { ConflictExportGuard, ProjectAutoSaveQueue, type ProjectSaveStatus } from "./project-autosave.js";
import {
CanvasEditHistory,
createEditorCanvasState,
@@ -12,6 +12,17 @@ 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 { ExportDialog } from "./export-dialog.js";
import {
completePendingTextForExport,
composeCanvasExport,
downloadExportBlob,
exportFilename,
resolveExportSettings,
saveLatestExport,
type ExportFormat,
} from "./export-compositor.js";
import { runExportFlow, type ExportFlowStatus } from "./export-flow.js";
import { TextInspector } from "./text-inspector.js";
import { createBrowserArchivedFontLoader, type ArchivedFontLoader, type ArchivedFontStatus } from "./text-font-loader.js";
import {
@@ -66,6 +77,13 @@ interface LocationDialogState {
pending: boolean;
}
interface EditorExportResult {
blob: Blob | null;
format: ExportFormat;
quality?: number;
status: ExportFlowStatus;
}
interface EditorProject {
canvas_state?: CanvasState;
created_at: string;
@@ -125,6 +143,9 @@ export function EditorPage({ projectId }: { projectId: string }) {
const [fontStatuses, setFontStatuses] = useState<Record<string, ArchivedFontStatus>>({});
const [textEdit, setTextEdit] = useState<TextEditState>();
const [locationDialog, setLocationDialog] = useState<LocationDialogState>();
const [exportOpen, setExportOpen] = useState(false);
const [exportBusy, setExportBusy] = useState(false);
const [exportResult, setExportResult] = useState<EditorExportResult>();
const queueRef = useRef<ProjectAutoSaveQueue | undefined>(undefined);
const historyRef = useRef<CanvasEditHistory | undefined>(undefined);
const elementControllerRef = useRef<CanvasElementController | undefined>(undefined);
@@ -132,6 +153,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
const opacityGestureRef = useRef<{ base: CanvasState; last: CanvasState; selectedIds: string[] } | undefined>(undefined);
const clipboardRef = useRef<CanvasElement[]>([]);
const fontLoaderRef = useRef<ArchivedFontLoader | undefined>(undefined);
const conflictExportGuardRef = useRef(new ConflictExportGuard());
useEffect(() => {
let active = true;
@@ -515,6 +537,79 @@ export function EditorPage({ projectId }: { projectId: string }) {
setNotice("已取消未提交的文字修改");
}
function pendingTextDraft() {
if (!canvasState || !textEdit) return undefined;
const committed = canvasState.elements.find((element) => element.element_id === textEdit.elementId);
return committed && JSON.stringify(committed) !== JSON.stringify(textEdit.draft) ? textEdit : undefined;
}
function openExportDialog() {
setExportResult(undefined);
setExportOpen(true);
}
async function executeExport(options: { format: ExportFormat; quality?: number }, reuseBlob?: Blob) {
if (!canvasState || !project || !session) return;
setExportBusy(true);
try {
let exportState = canvasState;
const pending = reuseBlob ? undefined : pendingTextDraft();
if (pending) {
exportState = completePendingTextForExport(canvasState, pending.draft);
commitCanvas(exportState);
const completed = exportState.elements.find((element) => element.element_id === pending.elementId);
if (completed && completed.template_or_asset_id !== pending.originalTemplateId) {
void recordRecentTextTemplate(completed.template_or_asset_id, completed.resource_version);
}
}
const queue = queueRef.current;
const projectSaved = saveStatus !== "conflicted" && Boolean(queue && await queue.saveNow());
const settings = resolveExportSettings({ format: options.format, ratio: exportState.ratio, ...(options.quality === undefined ? {} : { quality: options.quality }) });
const result = await runExportFlow({
compose: async () => reuseBlob ?? composeCanvasExport({
canvasState: exportState,
fontStatuses,
format: options.format,
projectId,
...(options.quality === undefined ? {} : { quality: options.quality }),
}),
download: (blob) => downloadExportBlob(blob, exportFilename(project.name, options.format)),
persist: async (blob) => {
if (!projectSaved || !queue) throw new Error("project_state_not_saved");
await saveLatestExport({
blob,
csrfToken: session.csrf_token,
format: options.format,
height: settings.height,
projectId,
stateVersion: queue.stateVersion,
width: settings.width,
});
},
});
setExportResult({ blob: result.blob, format: options.format, status: result.status, ...(options.quality === undefined ? {} : { quality: options.quality }) });
} catch {
setExportResult({ blob: null, format: options.format, status: "composition_failed", ...(options.quality === undefined ? {} : { quality: options.quality }) });
} finally {
setExportBusy(false);
}
}
async function startExport(options: { format: ExportFormat; quality?: number }) {
if (saveStatus !== "conflicted") {
await executeExport(options);
return;
}
const ran = await conflictExportGuardRef.current.run(() => executeExport(options));
if (!ran) setNotice("版本冲突时仅允许导出本页版本一次");
}
async function retryExportDownload() {
if (!exportResult?.blob) return;
await executeExport({ format: exportResult.format, ...(exportResult.quality === undefined ? {} : { quality: exportResult.quality }) }, exportResult.blob);
}
function transformSelection(action: (controller: CanvasElementController) => void, message: string) {
const controller = controllerForCurrent();
if (!controller || selectedIds.length === 0) return;
@@ -666,7 +761,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
</div>
<span aria-live={saveStatus === "failed" || saveStatus === "conflicted" ? "assertive" : "polite"} className={`editor-save-status ${saveStatus}`}>{saveStatusLabel(saveStatus)}</span>
<button disabled type="button"></button>
<button disabled type="button"></button>
<button disabled={!canvasState.background.asset_id || (saveStatus === "conflicted" && conflictExportGuardRef.current.used)} onClick={openExportDialog} type="button"></button>
</header>
{notice ? <p aria-live="polite" className="editor-notice" role="status">{notice}</p> : null}
<main className="editor-layout">
@@ -795,6 +890,16 @@ export function EditorPage({ projectId }: { projectId: string }) {
<label><input aria-label="手动地点文字" disabled={locationDialog.pending} onChange={(event) => setLocationDialog((current) => current ? { ...current, manualValue: event.target.value } : current)} value={locationDialog.manualValue} /></label>
<div className="editor-location-actions"><button className="editor-primary" disabled={locationDialog.pending} onClick={() => { void confirmAutomaticLocation(); }} type="button">{locationDialog.pending ? "正在定位" : "同意并自动定位"}</button><button disabled={!locationDialog.manualValue.trim() || locationDialog.pending} onClick={() => { void addDynamicSticker("DYN001", { formattedValue: locationDialog.manualValue.trim() }); }} type="button"></button><button disabled={locationDialog.pending} onClick={() => setLocationDialog(undefined)} type="button"></button></div>
</section></div> : null}
{exportOpen ? <ExportDialog
busy={exportBusy}
onClose={() => { if (!exportBusy) { setExportOpen(false); setExportResult(undefined); } }}
onExport={(options) => { void startExport(options); }}
onRetry={() => { void retryExportDownload(); }}
pendingEdit={Boolean(pendingTextDraft())}
ratio={canvasState.ratio}
{...(exportResult?.blob ? { byteSize: exportResult.blob.size } : {})}
{...(exportResult ? { result: exportResult.status } : {})}
/> : null}
</div>
);
}