feat: complete TASK-WP4-05 client export
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user