feat: complete TASK-WP4-05 client export
This commit is contained in:
@@ -463,6 +463,44 @@
|
||||
.editor-location-consent input { min-height: 40px; padding: 7px 9px; border: 1px solid #85857f; border-radius: 0; font: inherit; }
|
||||
.editor-location-actions { display: grid !important; grid-template-columns: 1fr; }
|
||||
|
||||
.editor-export-dialog {
|
||||
width: min(640px, 100%);
|
||||
max-height: min(760px, calc(100vh - 48px));
|
||||
overflow: auto;
|
||||
border: 2px solid #111111;
|
||||
background: #ffffff;
|
||||
box-shadow: 8px 8px 0 #111111;
|
||||
}
|
||||
.editor-export-dialog > header { display: flex; min-height: 82px; align-items: center; justify-content: space-between; padding: 18px 22px; border-bottom: 1px solid #b9b9b3; }
|
||||
.editor-export-dialog > header p { margin: 0 0 3px; color: #62625d; font-size: 10px; font-weight: 800; }
|
||||
.editor-export-dialog > header h2 { margin: 0; font-size: 22px; }
|
||||
.editor-export-dialog > header button { width: 36px; height: 36px; border: 1px solid #111111; border-radius: 0; background: #ffffff; font: 24px/1 Arial, sans-serif; }
|
||||
.editor-export-section { padding: 18px 22px; border-bottom: 1px solid #b9b9b3; }
|
||||
.editor-export-section h3 { margin: 0 0 10px; font-size: 13px; }
|
||||
.editor-export-segments { display: grid; grid-template-columns: 1fr 1fr; }
|
||||
.editor-export-segments button { min-height: 42px; border: 1px solid #111111; border-radius: 0; background: #ffffff; font: inherit; font-weight: 800; }
|
||||
.editor-export-segments button + button { border-left: 0; }
|
||||
.editor-export-segments button[aria-pressed="true"] { background: #111111; color: #ffffff; }
|
||||
.editor-export-label { display: flex; align-items: center; justify-content: space-between; }
|
||||
.editor-export-label h3 { margin: 0; }
|
||||
.editor-export-label output { min-width: 36px; font-weight: 800; text-align: right; }
|
||||
.editor-export-quality { display: grid; grid-template-columns: minmax(0, 1fr) 72px; gap: 14px; align-items: center; margin-top: 10px; }
|
||||
.editor-export-quality input[type="number"] { min-width: 0; height: 38px; padding: 5px 7px; border: 1px solid #85857f; border-radius: 0; font: inherit; }
|
||||
.editor-export-summary { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px 20px; padding: 18px 22px; border-bottom: 1px solid #b9b9b3; font-size: 12px; }
|
||||
.editor-export-summary span { color: #62625d; }
|
||||
.editor-export-summary strong { text-align: right; }
|
||||
.editor-export-pending { display: grid; gap: 10px; margin: 18px 22px 0; padding: 14px; border-left: 4px solid #c92a24; background: #f7f7f4; font-size: 12px; }
|
||||
.editor-export-pending span { display: flex; gap: 9px; align-items: center; }
|
||||
.editor-export-pending input { width: 18px; height: 18px; margin: 0; }
|
||||
.editor-export-dialog > footer { display: flex; justify-content: flex-end; gap: 8px; padding: 20px 22px; }
|
||||
.editor-export-dialog > footer button,
|
||||
.editor-export-result button { min-height: 42px; padding: 8px 16px; border: 1px solid #111111; border-radius: 0; background: #ffffff; font: inherit; font-weight: 800; }
|
||||
.editor-export-result { display: grid; gap: 12px; padding: 28px 22px 22px; }
|
||||
.editor-export-result > p { margin: 0; padding: 14px; border-left: 4px solid #12805c; background: #effaf5; font-weight: 800; }
|
||||
.editor-export-result.failed > p { border-left-color: #c92a24; background: #fff2f0; color: #8f1d14; }
|
||||
.editor-export-result small { color: #62625d; }
|
||||
.editor-export-result > div { display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px; }
|
||||
|
||||
.editor-loading { display: grid; min-height: 100vh; place-items: center; background: #e8e8e5; }
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -240,6 +240,76 @@ function loadCanvasImage(url: string) {
|
||||
});
|
||||
}
|
||||
|
||||
function resourceUrlsForCanvas(canvasState: CanvasState) {
|
||||
const imageReferences = new Map<string, string>();
|
||||
for (const element of canvasState.elements) {
|
||||
if (element.type === "static_sticker") imageReferences.set(element.template_or_asset_id, dynamicImageUrl(element.resource_version, element.template_or_asset_id));
|
||||
if (element.type === "dynamic_sticker") {
|
||||
const model = DYNAMIC_RENDER_MODELS[element.template_or_asset_id as DynamicTemplateId];
|
||||
for (const layer of model?.imageLayers ?? []) imageReferences.set(layer.assetId, dynamicImageUrl(element.resource_version, layer.assetId));
|
||||
}
|
||||
}
|
||||
return imageReferences;
|
||||
}
|
||||
|
||||
async function loadSceneResources(canvasState: CanvasState, projectId: string) {
|
||||
const background = canvasState.background.asset_id
|
||||
? loadCanvasImage(`/api/v1/private-assets/projects/${encodeURIComponent(projectId)}/images/${encodeURIComponent(canvasState.background.asset_id)}`)
|
||||
: Promise.resolve(undefined);
|
||||
const resources = Promise.all([...resourceUrlsForCanvas(canvasState)].map(async ([assetId, url]) => [assetId, await loadCanvasImage(url)] as const));
|
||||
const [image, loaded] = await Promise.all([background, resources]);
|
||||
return {
|
||||
background: image,
|
||||
resourceImages: Object.fromEntries(loaded.filter((entry): entry is readonly [string, HTMLImageElement] => entry[1] !== undefined)),
|
||||
};
|
||||
}
|
||||
|
||||
function renderEditorScene(
|
||||
context: CanvasRenderingContext2D,
|
||||
canvasState: CanvasState,
|
||||
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>,
|
||||
background: HTMLImageElement | undefined,
|
||||
resourceImages: Readonly<Record<string, HTMLImageElement>>,
|
||||
) {
|
||||
context.clearRect(0, 0, canvasState.pixel_width, canvasState.pixel_height);
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, canvasState.pixel_width, canvasState.pixel_height);
|
||||
context.filter = cssFilterForBackground(canvasState.background.adjustments);
|
||||
if (background) context.drawImage(background, 0, 0, canvasState.pixel_width, canvasState.pixel_height);
|
||||
context.filter = "none";
|
||||
for (const element of [...canvasState.elements].sort((left, right) => left.z_index - right.z_index)) {
|
||||
drawElement(context, element, canvasState.pixel_width, canvasState.pixel_height, fontStatuses, resourceImages);
|
||||
}
|
||||
}
|
||||
|
||||
function requiredFontIds(canvasState: CanvasState) {
|
||||
return canvasState.elements.flatMap((element) => {
|
||||
if (element.type === "text_template") return [fontIdForTextElement(element)].filter((fontId): fontId is string => Boolean(fontId));
|
||||
if (element.type === "dynamic_sticker") return dynamicFontOptionsFor(element.template_or_asset_id).map((font) => font.fontId);
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
export async function renderCanvasStateForExport(input: {
|
||||
canvasState: CanvasState;
|
||||
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>;
|
||||
projectId: string;
|
||||
}) {
|
||||
const missingFont = requiredFontIds(input.canvasState).find((fontId) => input.fontStatuses[fontId] !== "ready");
|
||||
if (missingFont) throw new Error("export_font_unavailable");
|
||||
await document.fonts.ready;
|
||||
const resources = await loadSceneResources(input.canvasState, input.projectId);
|
||||
if (input.canvasState.background.asset_id && !resources.background) throw new Error("export_background_unavailable");
|
||||
if (Object.keys(resources.resourceImages).length !== resourceUrlsForCanvas(input.canvasState).size) throw new Error("export_asset_unavailable");
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = input.canvasState.pixel_width;
|
||||
canvas.height = input.canvasState.pixel_height;
|
||||
const context = canvas.getContext("2d", { colorSpace: "srgb" });
|
||||
if (!context) throw new Error("export_canvas_unavailable");
|
||||
renderEditorScene(context, input.canvasState, input.fontStatuses, resources.background, resources.resourceImages);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
export function EditorStage(props: EditorStageProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const gestureRef = useRef<Gesture | undefined>(undefined);
|
||||
@@ -255,13 +325,7 @@ export function EditorStage(props: EditorStageProps) {
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return undefined;
|
||||
const render = (image: HTMLImageElement | undefined, resourceImages: Readonly<Record<string, HTMLImageElement>>) => {
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
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, props.fontStatuses, resourceImages);
|
||||
renderEditorScene(context, props.canvasState, props.fontStatuses, image, resourceImages);
|
||||
context.lineWidth = 4;
|
||||
context.strokeStyle = "#005fcc";
|
||||
for (const element of props.canvasState.elements.filter((entry) => props.selectedIds.includes(entry.element_id))) {
|
||||
@@ -281,22 +345,9 @@ export function EditorStage(props: EditorStageProps) {
|
||||
if (marquee) context.strokeRect(marquee.x * canvas.width, marquee.y * canvas.height, marquee.width * canvas.width, marquee.height * canvas.height);
|
||||
context.restore();
|
||||
};
|
||||
const background = props.assetId
|
||||
? loadCanvasImage(`/api/v1/private-assets/projects/${encodeURIComponent(props.projectId)}/images/${encodeURIComponent(props.assetId)}`)
|
||||
: Promise.resolve(undefined);
|
||||
const imageReferences = new Map<string, string>();
|
||||
for (const element of props.canvasState.elements) {
|
||||
if (element.type === "static_sticker") imageReferences.set(element.template_or_asset_id, dynamicImageUrl(element.resource_version, element.template_or_asset_id));
|
||||
if (element.type === "dynamic_sticker") {
|
||||
const model = DYNAMIC_RENDER_MODELS[element.template_or_asset_id as DynamicTemplateId];
|
||||
for (const layer of model?.imageLayers ?? []) imageReferences.set(layer.assetId, dynamicImageUrl(element.resource_version, layer.assetId));
|
||||
}
|
||||
}
|
||||
const dynamic = Promise.all([...imageReferences].map(async ([assetId, url]) => [assetId, await loadCanvasImage(url)] as const));
|
||||
void Promise.all([background, dynamic]).then(([image, loaded]) => {
|
||||
void loadSceneResources(props.canvasState, props.projectId).then(({ background, resourceImages }) => {
|
||||
if (!active) return;
|
||||
const resourceImages = Object.fromEntries(loaded.filter((entry): entry is readonly [string, HTMLImageElement] => entry[1] !== undefined));
|
||||
render(image, resourceImages);
|
||||
render(background, resourceImages);
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, [marquee, props.assetId, props.canvasState, props.fontStatuses, props.guides, props.projectId, props.selectedIds]);
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { CanvasState } from "@dada/shared-contracts";
|
||||
|
||||
import { renderCanvasStateForExport } from "./editor-stage.js";
|
||||
import { P0A_TEXT_TEMPLATES, TextEditSession } from "./text-assets.js";
|
||||
import type { ArchivedFontStatus } from "./text-font-loader.js";
|
||||
|
||||
export type ExportFormat = "jpg" | "png";
|
||||
export type ExportRatio = CanvasState["ratio"];
|
||||
|
||||
export const EXPORT_DIMENSIONS: Readonly<Record<ExportRatio, { height: number; width: number }>> = {
|
||||
"1:1": { height: 1080, width: 1080 },
|
||||
"3:4": { height: 1440, width: 1080 },
|
||||
"4:3": { height: 1080, width: 1440 },
|
||||
"9:16": { height: 1920, width: 1080 },
|
||||
};
|
||||
|
||||
export interface ExportSettings {
|
||||
format: ExportFormat;
|
||||
height: number;
|
||||
mimeType: "image/jpeg" | "image/png";
|
||||
quality?: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export function resolveExportSettings(input: { format: ExportFormat; quality?: number; ratio: ExportRatio }): ExportSettings {
|
||||
const dimensions = EXPORT_DIMENSIONS[input.ratio];
|
||||
if (input.format === "png") return { format: "png", height: dimensions.height, mimeType: "image/png", width: dimensions.width };
|
||||
const quality = input.quality ?? 92;
|
||||
if (!Number.isInteger(quality) || quality < 80 || quality > 100) throw new Error("export_quality_invalid");
|
||||
return { format: "jpg", height: dimensions.height, mimeType: "image/jpeg", quality, width: dimensions.width };
|
||||
}
|
||||
|
||||
export function completePendingTextForExport(canvasState: CanvasState, draft: CanvasState["elements"][number]) {
|
||||
if (draft.type !== "text_template") throw new Error("export_pending_text_invalid");
|
||||
const index = canvasState.elements.findIndex((element) => element.element_id === draft.element_id && element.type === "text_template");
|
||||
if (index < 0) throw new Error("export_pending_text_missing");
|
||||
const complete = new TextEditSession(draft, P0A_TEXT_TEMPLATES).complete();
|
||||
const next = structuredClone(canvasState);
|
||||
next.elements[index] = complete;
|
||||
return next;
|
||||
}
|
||||
|
||||
export async function sha256Blob(blob: Blob) {
|
||||
const digest = await crypto.subtle.digest("SHA-256", await blob.arrayBuffer());
|
||||
return [...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export function encodeCanvasExport(canvas: HTMLCanvasElement, settings: ExportSettings) {
|
||||
if (canvas.width !== settings.width || canvas.height !== settings.height) throw new Error("export_canvas_dimensions_invalid");
|
||||
return new Promise<Blob>((resolve, reject) => {
|
||||
const quality = settings.format === "jpg" ? settings.quality! / 100 : undefined;
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob || blob.type !== settings.mimeType) reject(new Error("export_encode_failed"));
|
||||
else resolve(blob);
|
||||
}, settings.mimeType, quality);
|
||||
});
|
||||
}
|
||||
|
||||
export async function composeCanvasExport(input: {
|
||||
canvasState: CanvasState;
|
||||
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>;
|
||||
format: ExportFormat;
|
||||
projectId: string;
|
||||
quality?: number;
|
||||
}) {
|
||||
const settings = resolveExportSettings({ format: input.format, ratio: input.canvasState.ratio, ...(input.quality === undefined ? {} : { quality: input.quality }) });
|
||||
if (input.canvasState.pixel_width !== settings.width || input.canvasState.pixel_height !== settings.height) throw new Error("export_canvas_dimensions_invalid");
|
||||
const canvas = await renderCanvasStateForExport({ canvasState: input.canvasState, fontStatuses: input.fontStatuses, projectId: input.projectId });
|
||||
return encodeCanvasExport(canvas, settings);
|
||||
}
|
||||
|
||||
export async function downloadExportBlob(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
try {
|
||||
const anchor = document.createElement("a");
|
||||
anchor.download = filename;
|
||||
anchor.href = url;
|
||||
anchor.style.display = "none";
|
||||
document.body.append(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
} finally {
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
}
|
||||
|
||||
export function exportFilename(projectName: string, format: ExportFormat) {
|
||||
const safeName = projectName.trim().replace(/[<>:"/\\|?*\u0000-\u001F]/g, "_").slice(0, 80) || "Dada成品";
|
||||
return `${safeName}.${format === "jpg" ? "jpg" : "png"}`;
|
||||
}
|
||||
|
||||
export async function saveLatestExport(input: {
|
||||
blob: Blob;
|
||||
csrfToken: string;
|
||||
format: ExportFormat;
|
||||
height: number;
|
||||
projectId: string;
|
||||
stateVersion: number;
|
||||
width: number;
|
||||
}) {
|
||||
const sha256 = await sha256Blob(input.blob);
|
||||
const form = new FormData();
|
||||
form.append("format", input.format);
|
||||
form.append("export_id", crypto.randomUUID());
|
||||
form.append("sha256", sha256);
|
||||
form.append("byte_size", String(input.blob.size));
|
||||
form.append("pixel_width", String(input.width));
|
||||
form.append("pixel_height", String(input.height));
|
||||
form.append("state_version", String(input.stateVersion));
|
||||
form.append("export_file", input.blob, `latest.${input.format}`);
|
||||
const response = await fetch(`/api/v1/projects/${encodeURIComponent(input.projectId)}/latest-exports/${input.format}`, {
|
||||
body: form,
|
||||
credentials: "same-origin",
|
||||
headers: { "X-CSRF-Token": input.csrfToken },
|
||||
method: "PUT",
|
||||
});
|
||||
if (!response.ok) throw new Error(response.status === 409 ? "latest_export_conflict" : "latest_export_failed");
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { exportResultCopy, type ExportFlowStatus } from "./export-flow.js";
|
||||
import { EXPORT_DIMENSIONS, type ExportFormat, type ExportRatio } from "./export-compositor.js";
|
||||
|
||||
export function ExportDialog(props: {
|
||||
busy: boolean;
|
||||
byteSize?: number;
|
||||
onClose: () => void;
|
||||
onExport: (input: { format: ExportFormat; quality?: number }) => void;
|
||||
onRetry: () => void;
|
||||
pendingEdit: boolean;
|
||||
ratio: ExportRatio;
|
||||
result?: ExportFlowStatus;
|
||||
}) {
|
||||
const [format, setFormat] = useState<ExportFormat>("jpg");
|
||||
const [quality, setQuality] = useState(92);
|
||||
const [pendingConfirmed, setPendingConfirmed] = useState(false);
|
||||
const dimensions = EXPORT_DIMENSIONS[props.ratio];
|
||||
const failure = props.result && props.result !== "downloaded_and_saved";
|
||||
return <div className="editor-dialog-backdrop"><section aria-labelledby="editor-export-title" aria-modal="true" className="editor-export-dialog" role="dialog">
|
||||
<header><div><p>EXPORT</p><h2 id="editor-export-title">导出成品</h2></div><button aria-label="关闭导出" disabled={props.busy} onClick={props.onClose} title="关闭" type="button">×</button></header>
|
||||
{props.result ? <div className={`editor-export-result ${failure ? "failed" : "succeeded"}`}>
|
||||
<p aria-live={failure ? "assertive" : "polite"} role={failure ? "alert" : "status"}>{exportResultCopy[props.result]}</p>
|
||||
{props.byteSize ? <small>{(props.byteSize / 1024 / 1024).toFixed(2)} MB</small> : null}
|
||||
<div>{props.result === "download_failed" ? <button disabled={props.busy} onClick={props.onRetry} type="button">重新下载同一次合成</button> : null}<button disabled={props.busy} onClick={props.onClose} type="button">关闭</button></div>
|
||||
</div> : <>
|
||||
<section className="editor-export-section"><h3>文件格式</h3><div aria-label="导出格式" className="editor-export-segments" role="group">
|
||||
<button aria-pressed={format === "jpg"} onClick={() => setFormat("jpg")} type="button">JPG</button>
|
||||
<button aria-pressed={format === "png"} onClick={() => setFormat("png")} type="button">PNG</button>
|
||||
</div></section>
|
||||
{format === "jpg" ? <section className="editor-export-section"><div className="editor-export-label"><h3>JPG 质量</h3><output>{quality}</output></div><div className="editor-export-quality"><input aria-label="JPG 质量" max="100" min="80" onChange={(event) => setQuality(Number(event.target.value))} step="1" type="range" value={quality} /><input aria-label="JPG 质量数值" max="100" min="80" onChange={(event) => setQuality(Math.max(80, Math.min(100, Number(event.target.value))))} step="1" type="number" value={quality} /></div></section> : null}
|
||||
<section className="editor-export-summary"><span>输出尺寸</span><strong>{dimensions.width} × {dimensions.height} px</strong><span>色彩空间</span><strong>sRGB</strong><span>预计文件大小</span><strong>合成后显示</strong></section>
|
||||
{props.pendingEdit ? <label className="editor-export-pending"><strong>导出前需要提交当前修改</strong><span><input checked={pendingConfirmed} onChange={(event) => setPendingConfirmed(event.target.checked)} type="checkbox" />将应用当前修改并导出</span></label> : null}
|
||||
<footer><button disabled={props.busy} onClick={props.onClose} type="button">取消</button><button className="editor-primary" disabled={props.busy || (props.pendingEdit && !pendingConfirmed)} onClick={() => props.onExport({ format, ...(format === "jpg" ? { quality } : {}) })} type="button">{props.busy ? "正在导出" : "导出并下载"}</button></footer>
|
||||
</>}
|
||||
</section></div>;
|
||||
}
|
||||
Reference in New Issue
Block a user