Files
tyx_AI_xhs/apps/web/src/editor-page.tsx
T
suyx e4aec01ea6
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
fix(POSTV1-08): 修复文字拖动闪烁与自动保存
2026-08-05 18:47:11 +08:00

1056 lines
55 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
import type { CanvasState, ProjectEditableState } from "@dada/shared-contracts";
import { ConflictExportGuard, ProjectAutoSaveQueue, type ProjectSaveStatus } from "./project-autosave.js";
import {
CanvasEditHistory,
createEditorCanvasState,
defaultBackgroundAdjustments,
resetBackgroundAdjustments,
switchBackground,
updateBackgroundAdjustments,
} 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 { useDialogFocus } from "./dialog-focus.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 {
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 { ColorCardPanel } from "./color-card-panel.js";
import { DynamicInspector } from "./dynamic-inspector.js";
import { DynamicStickerPanel } from "./dynamic-sticker-panel.js";
import {
LocationConsentGate,
P0A_DYNAMIC_STICKERS,
browserGeolocate,
createDynamicStickerElement,
overrideDynamicStickerValue,
type DynamicTemplateId,
} from "./dynamic-provider.js";
import { dynamicFontOptionsFor } from "./dynamic-render-models.js";
import { P0A_STATIC_STICKER_CATALOG, stickerWindow, type StaticStickerCatalogItem } from "./static-sticker-catalog.js";
import {
createColorCardElement,
extractPaletteFromImage,
type ColorCardDefinition,
} from "./palette-provider.js";
import "./editor-page.css";
type Ratio = CanvasState["ratio"];
type CanvasElement = CanvasState["elements"][number];
type EditorAssetPanel = "background" | "color" | "dynamic" | "history" | "stickers" | "text";
interface TextEditState {
draft: CanvasElement;
elementId: string;
originalTemplateId: string;
}
interface EditorSession {
csrf_token: string;
user: { creator_name: string; social_id: string; user_id: string };
}
interface LocationDialogState {
error?: string;
manualValue: string;
pending: boolean;
}
interface EditorExportResult {
blob: Blob | null;
format: ExportFormat;
quality?: number;
status: ExportFlowStatus;
}
function withTextDraft(canvasState: CanvasState, textEdit: TextEditState | undefined) {
if (!textEdit) return canvasState;
return {
...canvasState,
elements: canvasState.elements.map((element) => element.element_id === textEdit.elementId ? textEdit.draft : element),
};
}
interface EditorProject {
canvas_state?: CanvasState;
created_at: string;
current_image_id: string | null;
images: Array<{ created_at: string; generation_id: string; image_id: string }>;
name: string;
pixel_height?: number;
pixel_width?: number;
project_id: string;
ratio: Ratio;
state_version: number;
}
async function readEditorJson<T>(url: string): Promise<T> {
const response = await fetch(url, { credentials: "same-origin" });
if (response.status === 401) {
window.dispatchEvent(new Event("dada:session-invalid"));
throw new Error("session_invalid");
}
if (!response.ok) throw new Error("editor_request_failed");
return response.json() as Promise<T>;
}
function formatDate(value: string) {
return new Intl.DateTimeFormat("zh-CN", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" }).format(new Date(value));
}
function newElementIdentity(): CanvasElementIdentity {
return { createdAt: new Date().toISOString(), elementId: crypto.randomUUID() };
}
function saveStatusLabel(status: ProjectSaveStatus) {
return { conflicted: "版本冲突", dirty: "有未保存修改", failed: "保存失败", saved: "已保存", saving: "正在保存" }[status];
}
function EditorDialog(props: { children: ReactNode; className?: string; labelledBy: string; onClose: () => void }) {
const dialogRef = useDialogFocus(props.onClose);
return <div className="editor-dialog-backdrop"><section aria-labelledby={props.labelledBy} aria-modal="true" className={props.className ?? "editor-confirm"} ref={dialogRef} role="dialog" tabIndex={-1}>{props.children}</section></div>;
}
export function EditorPage({ projectId }: { projectId: string }) {
const [session, setSession] = useState<EditorSession>();
const [project, setProject] = useState<EditorProject>();
const [canvasState, setCanvasState] = useState<CanvasState>();
const [draftAdjustments, setDraftAdjustments] = useState(defaultBackgroundAdjustments);
const [saveStatus, setSaveStatus] = useState<ProjectSaveStatus>("saved");
const [pendingBackground, setPendingBackground] = useState<string>();
const [notice, setNotice] = useState("");
const [activePanel, setActivePanel] = useState<EditorAssetPanel>("background");
const [stickerScrollTop, setStickerScrollTop] = useState(0);
const [stickerCatalog, setStickerCatalog] = useState<StaticStickerCatalogItem[]>(P0A_STATIC_STICKER_CATALOG);
const [selectedIds, setSelectedIds] = useState<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 [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);
const dragRef = useRef<{ base: CanvasState; last: CanvasState; selectedIds: string[] } | undefined>(undefined);
const opacityGestureRef = useRef<{ base: CanvasState; last: CanvasState; selectedIds: string[] } | undefined>(undefined);
const textHistoryRef = useRef<{ base: CanvasState; elementId: string; last: CanvasState } | undefined>(undefined);
const clipboardRef = useRef<CanvasElement[]>([]);
const fontLoaderRef = useRef<ArchivedFontLoader | undefined>(undefined);
const conflictExportGuardRef = useRef(new ConflictExportGuard());
const candidateMenuRef = useRef<HTMLDivElement | null>(null);
const candidateTriggerRef = useRef<HTMLButtonElement | null>(null);
const noticeTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
function showNotice(message: string) {
if (noticeTimerRef.current) clearTimeout(noticeTimerRef.current);
setNotice(message);
noticeTimerRef.current = setTimeout(() => {
setNotice("");
noticeTimerRef.current = undefined;
}, 3_000);
}
useEffect(() => () => {
if (noticeTimerRef.current) clearTimeout(noticeTimerRef.current);
}, []);
useEffect(() => {
let active = true;
Promise.all([
readEditorJson<EditorSession>("/api/v1/auth/session"),
readEditorJson<EditorProject>(`/api/v1/projects/${projectId}`),
]).then(([nextSession, nextProject]) => {
if (!active) return;
const initial = nextProject.canvas_state ?? createEditorCanvasState({ assetId: nextProject.current_image_id, ratio: nextProject.ratio });
setSession(nextSession);
setProject(nextProject);
setCanvasState(initial);
setDraftAdjustments(initial.background.adjustments);
historyRef.current = new CanvasEditHistory(initial);
elementControllerRef.current = new CanvasElementController(initial);
}).catch(() => { if (active) showNotice("编辑器暂时无法读取项目"); });
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(() => {
let active = true;
readEditorJson<{ items: StaticStickerCatalogItem[] }>("/api/v1/static-stickers/current")
.then((response) => {
if (!active) return;
const uploaded = response.items.filter((item) => item.enabled && item.origin === "admin_uploaded");
setStickerCatalog([...P0A_STATIC_STICKER_CATALOG, ...uploaded].sort((left, right) => left.part - right.part || left.order - right.order || left.stable_id.localeCompare(right.stable_id)));
})
.catch(() => { if (active) setStickerCatalog(P0A_STATIC_STICKER_CATALOG); });
return () => { active = false; };
}, []);
useEffect(() => {
if (!project || !session || !canvasState) return undefined;
const queue = new ProjectAutoSaveQueue({
initialState: { canvas_state: canvasState, name: project.name },
initialVersion: project.state_version,
onStatus: setSaveStatus,
onSaved: (_snapshot, stateVersion) => {
setProject((current) => current ? { ...current, state_version: stateVersion } : current);
},
save: async (snapshot: ProjectEditableState, stateVersion, operationId) => {
const response = await fetch(`/api/v1/projects/${projectId}/state`, {
body: JSON.stringify(snapshot),
credentials: "same-origin",
headers: { "Content-Type": "application/json", "Idempotency-Key": operationId, "If-Match": String(stateVersion), "X-CSRF-Token": session.csrf_token },
method: "PUT",
});
if (response.status === 412) throw new Error("project_state_conflict");
if (!response.ok) throw new Error("project_save_failed");
const saved = await response.json() as { state_version: number };
return { stateVersion: saved.state_version };
},
});
queueRef.current = queue;
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) {
const options = element.type === "text_template"
? [fontIdForTextElement(element)].map((fontId) => fontId ? fontOption(fontId) : undefined).filter((option) => option !== undefined)
: element.type === "dynamic_sticker" ? dynamicFontOptionsFor(element.template_or_asset_id) : [];
for (const option of options) void ensureFont(option.fontId, option.url);
}
}, [canvasState?.elements.map((element) => `${element.element_id}:${element.font_override ?? ""}:${element.template_or_asset_id}`).join("|")]);
useEffect(() => {
if (activePanel !== "dynamic") return;
const options = P0A_DYNAMIC_STICKERS.flatMap((definition) => dynamicFontOptionsFor(definition.templateId));
for (const option of options) void ensureFont(option.fontId, option.url);
}, [activePanel]);
useEffect(() => {
if (candidateMenu) candidateMenuRef.current?.querySelector<HTMLButtonElement>('[role="menuitem"]')?.focus();
}, [candidateMenu]);
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("|")]);
useEffect(() => {
if (textEdit) commitTextDraftAutomatically(textEdit);
}, [textEdit?.draft]);
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 finalizeTextHistory() {
const pending = textHistoryRef.current;
if (!pending) return undefined;
textHistoryRef.current = undefined;
historyRef.current?.commit(pending.last);
return pending.last;
}
function commitCanvas(next: CanvasState, options: { preserveTextEdit?: boolean } = {}) {
if (!project || saveStatus === "conflicted") return;
const finalizedText = finalizeTextHistory();
if (!finalizedText || JSON.stringify(finalizedText) !== JSON.stringify(next)) historyRef.current?.commit(next);
elementControllerRef.current?.replaceState(next);
setSelectedIds(elementControllerRef.current?.selectedIds ?? []);
setCanvasState(next);
setDraftAdjustments(next.background.adjustments);
queueRef.current?.commit({ canvas_state: next, name: project.name });
if (!options.preserveTextEdit) setTextEdit(undefined);
}
function applyPreview() {
if (!canvasState) return;
commitCanvas(updateBackgroundAdjustments(canvasState, draftAdjustments));
showNotice("底图调整已提交");
}
function undo() {
finalizeTextHistory();
const previous = historyRef.current?.undo();
if (previous) {
elementControllerRef.current?.replaceState(previous);
setSelectedIds(elementControllerRef.current?.selectedIds ?? []);
setCanvasState(previous);
setDraftAdjustments(previous.background.adjustments);
if (project) queueRef.current?.commit({ canvas_state: previous, name: project.name });
setTextEdit(undefined);
}
}
function redo() {
finalizeTextHistory();
const next = historyRef.current?.redo();
if (next) {
elementControllerRef.current?.replaceState(next);
setSelectedIds(elementControllerRef.current?.selectedIds ?? []);
setCanvasState(next);
setDraftAdjustments(next.background.adjustments);
if (project) queueRef.current?.commit({ canvas_state: next, name: project.name });
setTextEdit(undefined);
}
}
async function paletteForAsset(assetId: string) {
const image = new Image();
const loaded = new Promise<HTMLImageElement>((resolve, reject) => {
image.onload = () => resolve(image);
image.onerror = () => reject(new Error("palette_image_unavailable"));
});
image.src = `/api/v1/private-assets/projects/${encodeURIComponent(projectId)}/images/${encodeURIComponent(assetId)}`;
return extractPaletteFromImage(await loaded);
}
async function confirmBackground() {
if (!canvasState || !pendingBackground) return;
try {
const palette = await paletteForAsset(pendingBackground);
commitCanvas(switchBackground(canvasState, pendingBackground, palette));
setPendingBackground(undefined);
showNotice("已更换底图,覆盖元素保留,底图处理已重置");
} catch {
showNotice("新底图无法读取,未更换底图或刷新色卡");
}
}
function controllerForCurrent() {
const controller = elementControllerRef.current;
if (!controller) return undefined;
if (controller.selectedIds.length !== selectedIds.length || controller.selectedIds.some((elementId, index) => elementId !== selectedIds[index])) controller.selectIds(selectedIds);
return controller;
}
function commitElementOperation(controller: CanvasElementController, message: string) {
const next = controller.value;
setSelectedIds(controller.selectedIds);
commitCanvas(next);
setGuides([]);
setCandidateMenu(undefined);
showNotice(message);
}
function addSticker(sticker: StaticStickerCatalogItem) {
const controller = controllerForCurrent();
if (!controller || !canvasState) return;
try {
controller.add(createStaticStickerElement({
assetId: sticker.stable_id,
identity: newElementIdentity(),
position: { x: 0.5, y: 0.5 },
resourceVersion: sticker.resource_version,
zIndex: canvasState.elements.length,
}));
commitElementOperation(controller, "贴纸已加入画布");
} catch (error) {
if (error instanceof Error && error.message === "canvas_element_limit_reached") showNotice("画布最多 50 个元素,请先删除现有元素。");
else showNotice("贴纸未能加入画布");
}
}
async function addColorCard(definition: ColorCardDefinition) {
const assetId = canvasState?.background.asset_id;
const controller = controllerForCurrent();
if (!assetId || !canvasState || !controller) return;
try {
const palette = await paletteForAsset(assetId);
controller.add(createColorCardElement(definition, palette, newElementIdentity(), canvasState.elements.length));
commitElementOperation(controller, "色卡已按原始底图加入画布");
} catch (error) {
if (error instanceof Error && error.message === "canvas_element_limit_reached") showNotice("画布最多 50 个元素,请先删除现有元素。");
else showNotice("无法从原始底图稳定提取五色,色卡未加入画布");
}
}
async function addDynamicSticker(templateId: DynamicTemplateId, location?: { formattedValue: string; latitude?: number; longitude?: number }) {
const controller = controllerForCurrent();
if (!canvasState || !controller || !session) return;
if (templateId === "DYN012") {
const font = fontOption("FONT081");
if (!font || await ensureFont(font.fontId, font.url) !== "ready") {
showNotice("DYN012 的 FONT081 替代字体不可用,未使用系统字体替代。");
return;
}
}
try {
controller.add(createDynamicStickerElement(templateId, {
...(location ? { location } : {}),
now: new Date(),
profile: { creatorName: session.user.creator_name, socialId: session.user.social_id },
}, newElementIdentity(), canvasState.elements.length));
commitElementOperation(controller, "动态值已确认并加入画布");
setLocationDialog(undefined);
} catch (error) {
if (error instanceof Error && error.message === "canvas_element_limit_reached") showNotice("画布最多 50 个元素,请先删除现有元素。");
else showNotice("动态贴纸未能加入画布");
}
}
function chooseDynamicSticker(templateId: DynamicTemplateId) {
if (templateId === "DYN004") {
setLocationDialog({ manualValue: "", pending: false });
return;
}
void addDynamicSticker(templateId);
}
async function confirmAutomaticLocation() {
if (!session) return;
setLocationDialog((current) => {
if (!current) return current;
const { error: _error, ...rest } = current;
return { ...rest, pending: true };
});
const gate = new LocationConsentGate({
geolocate: browserGeolocate,
reverseGeocode: async (coordinates) => {
const response = await fetch("/api/v1/location/reverse-geocode", {
body: JSON.stringify(coordinates),
credentials: "same-origin",
headers: { "Content-Type": "application/json", "X-CSRF-Token": session.csrf_token },
method: "POST",
});
if (!response.ok) throw new Error("dynamic_location_service_unavailable");
const payload = await response.json() as { formatted_value: string };
return payload.formatted_value;
},
});
try {
const result = await gate.confirm();
await addDynamicSticker("DYN004", result);
} catch {
setLocationDialog((current) => current ? { ...current, error: "自动定位不可用,请改用手动地点贴纸。", pending: false } : current);
}
}
function commitDynamicOverride(element: CanvasElement, value: string) {
const controller = controllerForCurrent();
if (!controller) return;
try {
controller.replaceElement(overrideDynamicStickerValue(element, value));
commitElementOperation(controller, "动态贴纸显示文字已更新");
} catch {
showNotice("动态贴纸显示文字不能为空");
}
}
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") {
showNotice("素材暂不可用,未使用系统字体替代。");
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") showNotice("画布最多 50 个元素,请先删除现有元素。");
else showNotice("文字模板未能加入画布");
}
}
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 {
showNotice("文字参数不在允许范围内");
return current;
}
});
}
function commitTextDraftAutomatically(editState: TextEditState) {
if (!canvasState || !project || saveStatus === "conflicted") return;
const index = canvasState.elements.findIndex((element) => element.element_id === editState.elementId);
if (index < 0 || JSON.stringify(canvasState.elements[index]) === JSON.stringify(editState.draft)) return;
try {
const complete = new TextEditSession(editState.draft, P0A_TEXT_TEMPLATES).complete();
const next = structuredClone(canvasState);
next.elements[index] = complete;
const history = textHistoryRef.current;
if (!history || history.elementId !== editState.elementId) {
if (history) finalizeTextHistory();
textHistoryRef.current = { base: canvasState, elementId: editState.elementId, last: next };
} else {
history.last = next;
}
elementControllerRef.current?.replaceState(next);
setCanvasState(next);
queueRef.current?.commit({ canvas_state: next, name: project.name });
if (complete.template_or_asset_id !== editState.originalTemplateId) {
void recordRecentTextTemplate(complete.template_or_asset_id, complete.resource_version);
}
setTextEdit((current) => current?.elementId === editState.elementId ? {
...current,
draft: complete,
originalTemplateId: complete.template_or_asset_id,
} : current);
} catch (error) {
if (!(error instanceof Error && error.message === "text_content_required")) showNotice("文字编辑未能自动保存");
}
}
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") {
showNotice("素材暂不可用,未使用系统字体替代。");
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") {
showNotice("字体素材暂不可用,未使用系统字体替代。");
return;
}
updateTextDraft((edit) => edit.setStyle({ fontOverride: fontId }));
}
function completeTextEdit() {
if (!textEdit) return;
if (!pendingTextDraft()) {
finalizeTextHistory();
showNotice("文字修改已进入自动保存");
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") showNotice("请输入文字内容或删除该元素。");
else showNotice("文字编辑未能完成");
}
}
function cancelTextEdit() {
const pendingHistory = textHistoryRef.current;
if (pendingHistory && project) {
textHistoryRef.current = undefined;
elementControllerRef.current?.replaceState(pendingHistory.base);
setCanvasState(pendingHistory.base);
queueRef.current?.commit({ canvas_state: pendingHistory.base, name: project.name });
}
if (canvasState && textEdit) {
const source = pendingHistory?.base ?? canvasState;
const current = source.elements.find((element) => element.element_id === textEdit.elementId);
if (current) setTextEdit({ draft: structuredClone(current), elementId: current.element_id, originalTemplateId: current.template_or_asset_id });
}
showNotice("已取消未提交的文字修改");
}
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) showNotice("版本冲突时仅允许导出本页版本一次");
}
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;
try {
action(controller);
commitElementOperation(controller, message);
} catch (error) {
if (error instanceof Error && error.message === "canvas_element_limit_reached") showNotice("画布最多 50 个元素,请先删除现有元素。");
else showNotice("对象操作未完成");
}
}
function beginOpacityGesture() {
if (!canvasState || selectedIds.length === 0) return;
opacityGestureRef.current = { base: canvasState, last: canvasState, selectedIds: [...selectedIds] };
}
function changeOpacity(value: number) {
const gesture = opacityGestureRef.current;
if (!gesture) {
transformSelection((controller) => { controller.setSelectedOpacity(value); }, "贴纸透明度已提交");
return;
}
const previewController = new CanvasElementController(gesture.base);
previewController.selectIds(gesture.selectedIds);
previewController.setSelectedOpacity(value);
gesture.last = previewController.value;
setCanvasState(gesture.last);
}
function commitOpacityGesture() {
const gesture = opacityGestureRef.current;
if (!gesture) return;
opacityGestureRef.current = undefined;
if (gesture.last === gesture.base) return;
commitCanvas(gesture.last);
showNotice("贴纸透明度已提交");
}
function duplicateSelection() {
transformSelection((controller) => controller.duplicateSelected(() => newElementIdentity()), "已复制选中对象");
}
function changeSelectionLayer(command: CanvasLayerCommand) {
const labels: Record<CanvasLayerCommand, string> = { back: "已置底", backward: "已下移一层", forward: "已上移一层", front: "已置顶" };
transformSelection((controller) => { controller.changeLayer(command); }, labels[command]);
}
function deleteSelection() {
transformSelection((controller) => { controller.deleteSelected(); }, "已删除选中对象");
}
function copySelection() {
const controller = controllerForCurrent();
if (!controller || selectedIds.length === 0) return;
clipboardRef.current = controller.copySelected();
showNotice("已复制到画布剪贴板");
}
function pasteSelection() {
const controller = controllerForCurrent();
if (!controller || clipboardRef.current.length === 0) return;
try {
controller.pasteElements(clipboardRef.current, () => newElementIdentity());
commitElementOperation(controller, "已粘贴画布对象");
} catch (error) {
if (error instanceof Error && error.message === "canvas_element_limit_reached") showNotice("画布最多 50 个元素,请先删除现有元素。");
}
}
function selectAt(point: CanvasPoint, append: boolean) {
finalizeTextHistory();
const controller = controllerForCurrent();
if (!controller || !canvasState) return false;
const candidates = controller.candidatesAt(point);
const selection = controller.selectAt(point, { append: append || multiMode });
setSelectedIds(selection);
setCandidateMenu(undefined);
const dragBase = withTextDraft(canvasState, textEdit);
dragRef.current = { base: dragBase, last: dragBase, selectedIds: selection };
return candidates.length > 0;
}
function previewMove(delta: CanvasPoint) {
const drag = dragRef.current;
if (!drag) return;
const previewController = new CanvasElementController(drag.base);
previewController.selectIds(drag.selectedIds);
const preview = previewController.moveSelected(delta);
drag.last = preview.state;
setCanvasState(preview.state);
setTextEdit((current) => {
if (!current || !drag.selectedIds.includes(current.elementId)) return current;
const movedDraft = preview.state.elements.find((element) => element.element_id === current.elementId);
return movedDraft ? { ...current, draft: movedDraft } : current;
});
setGuides(preview.guides);
}
function commitMove() {
const drag = dragRef.current;
if (!drag) return;
commitCanvas(drag.last, { preserveTextEdit: true });
showNotice("对象位置已提交");
setGuides([]);
dragRef.current = undefined;
}
function marqueeSelect(rectangle: CanvasRect, append: boolean) {
finalizeTextHistory();
const controller = controllerForCurrent();
if (!controller) return;
setSelectedIds(controller.marqueeSelect(rectangle, append || multiMode));
setCandidateMenu(undefined);
}
function showCandidates(point: CanvasPoint) {
const controller = controllerForCurrent();
if (!controller) return;
const elements = controller.candidatesAt(point);
if (elements.length > 0) setCandidateMenu({ elements, point });
}
function showAllCandidates() {
if (!canvasState) return;
const elements = [...canvasState.elements].sort((left, right) => right.z_index - left.z_index);
if (elements.length > 0) setCandidateMenu({ elements, point: { x: 0.5, y: 0.08 } });
}
function chooseCandidate(elementId: string) {
const controller = controllerForCurrent();
if (!controller) return;
setSelectedIds(controller.selectById(elementId, multiMode));
setCandidateMenu(undefined);
requestAnimationFrame(() => candidateTriggerRef.current?.focus());
}
function closeCandidateMenu() {
setCandidateMenu(undefined);
requestAnimationFrame(() => candidateTriggerRef.current?.focus());
}
function handleCandidateMenuKeyDown(event: KeyboardEvent<HTMLDivElement>) {
if (event.key === "Escape") {
event.preventDefault();
closeCandidateMenu();
return;
}
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
const items = [...event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')];
const current = items.indexOf(document.activeElement as HTMLButtonElement);
const delta = event.key === "ArrowDown" ? 1 : -1;
const next = (current + delta + items.length) % items.length;
event.preventDefault();
items[next]?.focus();
}
function clearSelection() {
finalizeTextHistory();
elementControllerRef.current?.clearSelection();
setSelectedIds([]);
setCandidateMenu(undefined);
setGuides([]);
}
if (!project || !canvasState) return <main className="editor-loading" aria-live="polite">正在加载编辑器</main>;
const renderedCanvasState = withTextDraft(canvasState, textEdit);
const imageUrl = `/api/v1/private-assets/projects/${projectId}/images/${canvasState.background.asset_id ?? project.current_image_id ?? ""}`;
const canEdit = saveStatus !== "conflicted";
const selectedElements = renderedCanvasState.elements.filter((element) => selectedIds.includes(element.element_id));
const selectedStickerOpacity = selectedElements.length > 0 && selectedElements.every((element) => element.type !== "text_template")
? Math.round((selectedElements[0]?.opacity ?? 1) * 100)
: undefined;
return (
<div className="editor-page-shell">
<a className="editor-skip-link" href="#editor-main">跳到主要内容</a>
<header aria-label="编辑器顶部区域" className="editor-toolbar">
<div aria-label="编辑器顶部工具栏" className="editor-toolbar-controls" role="toolbar">
<a aria-label="返回项目" className="editor-back" href={`/app/projects/${projectId}`}></a>
<div className="editor-title"><h1>{project.name}</h1><span>{project.ratio}</span></div>
<div className="editor-history-actions">
<button aria-label="撤销" disabled={!historyRef.current?.canUndo || !canEdit} onClick={undo} title="撤销" type="button"></button>
<button aria-label="重做" disabled={!historyRef.current?.canRedo || !canEdit} onClick={redo} title="重做" type="button"></button>
</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={!canvasState.background.asset_id || (saveStatus === "conflicted" && conflictExportGuardRef.current.used)} onClick={openExportDialog} type="button">导出</button>
</div>
</header>
{notice ? <p aria-live="polite" className="editor-notice" role="status">{notice}</p> : null}
<main className="editor-layout" id="editor-main" tabIndex={-1}>
<aside aria-label="素材与底图来源" className="editor-assets-panel">
<nav aria-label="编辑器素材分类" className="editor-asset-tabs">
{([
{ label: "底图", panel: "background" as const },
{ label: "历史", panel: "history" as const },
{ label: "文字模板", panel: "text" as const },
{ label: "普通贴纸", panel: "stickers" as const },
{ label: "色卡", panel: "color" as const },
{ label: "动态贴纸", panel: "dynamic" as const },
]).map((item) => <button aria-current={item.panel === activePanel ? "page" : undefined} key={item.label} onClick={() => { setActivePanel(item.panel); if (item.panel === "stickers") setStickerScrollTop(0); }} type="button">{item.label}</button>)}
</nav>
{activePanel === "background" ? <section><h2>底图</h2><button className="editor-source active" type="button"><span className="editor-thumb" style={{ backgroundImage: `url(${imageUrl})` }} /><span>当前底图</span></button></section> : null}
{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" ? (() => {
const visibleStickers = stickerWindow(stickerCatalog, stickerScrollTop, 280);
return <section><h2>普通贴纸</h2><p aria-live="polite" className="editor-sticker-count"> {stickerCatalog.length.toLocaleString("zh-CN")} </p><div className="editor-sticker-virtual-list" data-testid="static-sticker-list" onScroll={(event) => setStickerScrollTop(event.currentTarget.scrollTop)} role="list">
<div style={{ paddingTop: visibleStickers.top_spacer_px, paddingBottom: visibleStickers.bottom_spacer_px }}>
<div className="editor-sticker-grid">
{visibleStickers.items.map((sticker) => <button aria-label={`添加贴纸 ${sticker.stable_id}`} data-sticker-id={sticker.stable_id} disabled={!canEdit || canvasState.elements.length >= 50} key={sticker.stable_id} onClick={() => addSticker(sticker)} type="button"><img alt="" className="editor-sticker-preview" decoding="async" loading="lazy" src={sticker.thumbnail_reference.url} /><strong>{sticker.stable_id}</strong><span>part{sticker.part} · {sticker.order}</span></button>)}
</div>
</div>
</div>{canvasState.elements.length >= 50 ? <p className="editor-limit" role="status">画布最多 50 个元素,请先删除现有元素。</p> : null}</section>;
})() : null}
{activePanel === "color" ? <ColorCardPanel canAdd={canEdit && canvasState.elements.length < 50} hasBackground={Boolean(canvasState.background.asset_id)} onAdd={(definition) => { void addColorCard(definition); }} /> : null}
{activePanel === "dynamic" ? <DynamicStickerPanel canAdd={canEdit && canvasState.elements.length < 50} fontStatuses={fontStatuses} onAdd={chooseDynamicSticker} /> : null}
</aside>
<section aria-label="画布工作区" className="editor-workspace">
<div className="editor-canvas-tools" role="toolbar" aria-label="画布选择工具">
<button aria-pressed={multiMode} className={multiMode ? "active" : ""} disabled={!canEdit} onClick={() => setMultiMode((current) => !current)} type="button">多选模式</button>
<button disabled={canvasState.elements.length === 0} onClick={showAllCandidates} ref={candidateTriggerRef} type="button">循环选择</button>
<button disabled={selectedElements.length === 0 || !canEdit} onClick={copySelection} title="复制" type="button">复制</button>
<button disabled={clipboardRef.current.length === 0 || !canEdit} onClick={pasteSelection} title="粘贴" type="button">粘贴</button>
</div>
<div className="editor-canvas-frame" style={{
aspectRatio: `${canvasState.pixel_width} / ${canvasState.pixel_height}`,
maxWidth: `min(720px, calc(${(canvasState.pixel_width / canvasState.pixel_height * 100).toFixed(4)}vh - ${(canvasState.pixel_width / canvasState.pixel_height * 168).toFixed(4)}px))`,
}}>
<EditorStage
canvasState={renderedCanvasState}
fontStatuses={fontStatuses}
guides={guides}
onCandidates={showCandidates}
onClearSelection={clearSelection}
onCopy={copySelection}
onDelete={deleteSelection}
onDragStart={() => setCandidateMenu(undefined)}
onMarquee={marqueeSelect}
onMoveCommit={commitMove}
onMovePreview={previewMove}
onNudge={(delta) => transformSelection((controller) => { controller.moveSelected(delta, { snap: false }); }, "对象位置已提交")}
onPaste={pasteSelection}
onPointerMoved={() => elementControllerRef.current?.pointerMoved()}
onSelect={selectAt}
projectId={projectId}
selectedIds={selectedIds}
/>
{candidateMenu ? <div aria-label="画布对象候选" className="editor-candidates" onKeyDown={handleCandidateMenuKeyDown} ref={candidateMenuRef} role="menu" style={{ left: `${candidateMenu.point.x * 100}%`, top: `${candidateMenu.point.y * 100}%` }}>
{candidateMenu.elements.map((element) => <button key={element.element_id} onClick={() => chooseCandidate(element.element_id)} role="menuitem" type="button">{element.content || `${element.type} · ${element.template_or_asset_id}`}</button>)}
</div> : null}
</div>
{guides.length > 0 ? <span aria-live="polite" className="editor-snap-status">已吸附</span> : null}
</section>
<aside aria-label={selectedElements.length > 0 ? "对象参数" : "底图参数"} className="editor-inspector">
{selectedElements.length === 0 ? <>
<header><p>BACKGROUND</p><h2>底图参数</h2></header>
<label>适配方式<select disabled={!canEdit} value={draftAdjustments.fit} onChange={(event) => setDraftAdjustments((current) => ({ ...current, fit: event.target.value as typeof current.fit }))}><option value="fill">填充</option><option value="fit">适配</option><option value="crop">裁剪</option></select></label>
<label>滤镜<select disabled={!canEdit} value={draftAdjustments.filter} onChange={(event) => setDraftAdjustments((current) => ({ ...current, filter: event.target.value }))}><option value="none">无滤镜</option><option value="grayscale">黑白</option><option value="sepia">复古</option></select></label>
{(["brightness", "contrast", "saturation", "temperature", "sharpness"] as const).map((key) => <label className="editor-range" key={key}><span>{({ brightness: "亮度", contrast: "对比度", saturation: "饱和度", temperature: "色温", sharpness: "锐度" } as const)[key]}<output>{draftAdjustments[key]}</output></span><input disabled={!canEdit} max={100} min={key === "sharpness" ? 0 : -100} onChange={(event) => setDraftAdjustments((current) => ({ ...current, [key]: Number(event.target.value) }))} type="range" value={draftAdjustments[key]} /></label>)}
<div className="editor-inspector-actions"><button disabled={!canEdit} onClick={() => { if (canvasState) setDraftAdjustments(resetBackgroundAdjustments(canvasState).background.adjustments); }} type="button">恢复原图</button><button className="editor-primary" disabled={!canEdit} onClick={applyPreview} type="button">应用调整</button></div>
</> : <>
<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}
{selectedElements.length === 1 && selectedElements[0]?.type === "dynamic_sticker" && canEdit
? <DynamicInspector element={selectedElements[0]} onCommit={(value) => commitDynamicOverride(selectedElements[0]!, value)} />
: 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>
<button aria-label="向下移动" disabled={!canEdit} onClick={() => transformSelection((controller) => { controller.moveSelected({ x: 0, y: 0.01 }, { snap: false }); }, "对象位置已提交")} title="向下移动" type="button"></button>
<button aria-label="向右移动" disabled={!canEdit} onClick={() => transformSelection((controller) => { controller.moveSelected({ x: 0.01, y: 0 }, { snap: false }); }, "对象位置已提交")} title="向右移动" type="button"></button>
</div>
<div className="editor-object-tools">
<button disabled={!canEdit} onClick={() => transformSelection((controller) => { controller.scaleSelected(0.9); }, "对象尺寸已提交")} type="button">缩小</button>
<button disabled={!canEdit} onClick={() => transformSelection((controller) => { controller.scaleSelected(1.1); }, "对象尺寸已提交")} type="button">放大</button>
<button disabled={!canEdit} onClick={() => transformSelection((controller) => { controller.rotateSelected(-15); }, "对象旋转已提交")} type="button">逆时针</button>
<button disabled={!canEdit} onClick={() => transformSelection((controller) => { controller.rotateSelected(15); }, "对象旋转已提交")} type="button">顺时针</button>
</div>
{selectedStickerOpacity !== undefined ? <>
<button className="editor-wide-command" disabled={!canEdit} onClick={() => transformSelection((controller) => { controller.flipSelectedHorizontal(); }, "贴纸翻转已提交")} type="button">水平翻转</button>
<label className="editor-range"><span>透明度<output>{selectedStickerOpacity}%</output></span><input disabled={!canEdit} max={100} min={0} onBlur={commitOpacityGesture} onChange={(event) => changeOpacity(Number(event.target.value) / 100)} onPointerCancel={commitOpacityGesture} onPointerDown={beginOpacityGesture} onPointerUp={commitOpacityGesture} type="range" value={selectedStickerOpacity} /></label>
</> : null}
<div className="editor-layer-tools" role="group" aria-label="对象层级">
<button disabled={!canEdit} onClick={() => changeSelectionLayer("front")} type="button">置顶</button>
<button disabled={!canEdit} onClick={() => changeSelectionLayer("forward")} type="button">上移一层</button>
<button disabled={!canEdit} onClick={() => changeSelectionLayer("backward")} type="button">下移一层</button>
<button disabled={!canEdit} onClick={() => changeSelectionLayer("back")} type="button">置底</button>
</div>
<div className="editor-object-actions"><button disabled={!canEdit} onClick={duplicateSelection} type="button">复制</button><button className="editor-danger" disabled={!canEdit} onClick={deleteSelection} type="button">删除</button></div>
</>}
</aside>
</main>
<footer aria-label="画布状态" className="editor-statusbar"><span>画布 {canvasState.pixel_width} × {canvasState.pixel_height}</span><span>对象 {canvasState.elements.length} / 50</span><span>缩放 100%</span><span>本机保存 · state version {project.state_version}</span></footer>
{pendingBackground ? <EditorDialog labelledBy="editor-background-confirm" onClose={() => setPendingBackground(undefined)}><p>CHANGE BACKGROUND</p><h2 id="editor-background-confirm">更换底图</h2><p>覆盖元素会保留,底图编辑参数将重置。</p><div><button className="editor-primary" data-dialog-initial-focus onClick={() => { void confirmBackground(); }} type="button">确认更换</button><button onClick={() => setPendingBackground(undefined)} type="button">取消</button></div></EditorDialog> : null}
{locationDialog ? <EditorDialog className="editor-confirm editor-location-consent" labelledBy="editor-location-consent" onClose={() => { if (!locationDialog.pending) setLocationDialog(undefined); }}>
<p>LOCATION PRIVACY</p><h2 id="editor-location-consent">使用自动定位</h2>
<p>原始经纬度会保存到当前项目并显示在导出成品中。只有确认后才会请求浏览器定位,并由本地后端调用地点服务。</p>
{locationDialog.error ? <p className="editor-limit" role="alert">{locationDialog.error}</p> : null}
<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" data-dialog-initial-focus 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>
</EditorDialog> : 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>
);
}