968 lines
51 KiB
TypeScript
968 lines
51 KiB
TypeScript
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;
|
||
}
|
||
|
||
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 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);
|
||
|
||
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) setNotice("编辑器暂时无法读取项目"); });
|
||
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("|")]);
|
||
|
||
async function ensureFont(fontId: string, url: string, retry = false) {
|
||
const current = fontStatuses[fontId];
|
||
if (current === "ready" || (current === "unavailable" && !retry)) return current;
|
||
const loader = fontLoaderRef.current ?? createBrowserArchivedFontLoader();
|
||
fontLoaderRef.current = loader;
|
||
setFontStatuses((statuses) => ({ ...statuses, [fontId]: "loading" }));
|
||
const status = retry ? await loader.retry({ fontId, url }) : await loader.ensure({ fontId, url });
|
||
setFontStatuses((statuses) => ({ ...statuses, [fontId]: status }));
|
||
return status;
|
||
}
|
||
|
||
async function retryTextFonts() {
|
||
const available = P0A_TEXT_TEMPLATES.filter((template) => template.available && template.fontUrl);
|
||
await Promise.all(available.map((template) => ensureFont(template.defaultFontId, template.fontUrl!, true)));
|
||
}
|
||
|
||
function commitCanvas(next: CanvasState) {
|
||
if (!project || saveStatus === "conflicted") return;
|
||
historyRef.current?.commit(next);
|
||
elementControllerRef.current?.replaceState(next);
|
||
setSelectedIds(elementControllerRef.current?.selectedIds ?? []);
|
||
setCanvasState(next);
|
||
setDraftAdjustments(next.background.adjustments);
|
||
queueRef.current?.commit({ canvas_state: next, name: project.name });
|
||
setTextEdit(undefined);
|
||
}
|
||
|
||
function applyPreview() {
|
||
if (!canvasState) return;
|
||
commitCanvas(updateBackgroundAdjustments(canvasState, draftAdjustments));
|
||
setNotice("底图调整已提交");
|
||
}
|
||
|
||
function undo() {
|
||
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() {
|
||
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);
|
||
setNotice("已更换底图,覆盖元素保留,底图处理已重置");
|
||
} catch {
|
||
setNotice("新底图无法读取,未更换底图或刷新色卡");
|
||
}
|
||
}
|
||
|
||
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);
|
||
setNotice(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") setNotice("画布最多 50 个元素,请先删除现有元素。");
|
||
else setNotice("贴纸未能加入画布");
|
||
}
|
||
}
|
||
|
||
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") setNotice("画布最多 50 个元素,请先删除现有元素。");
|
||
else setNotice("无法从原始底图稳定提取五色,色卡未加入画布");
|
||
}
|
||
}
|
||
|
||
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") {
|
||
setNotice("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") setNotice("画布最多 50 个元素,请先删除现有元素。");
|
||
else setNotice("动态贴纸未能加入画布");
|
||
}
|
||
}
|
||
|
||
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 {
|
||
setNotice("动态贴纸显示文字不能为空");
|
||
}
|
||
}
|
||
|
||
async function recordRecentTextTemplate(templateId: string, resourceVersion: string) {
|
||
if (!session) return;
|
||
try {
|
||
const response = await fetch("/api/v1/assets/recent", {
|
||
body: JSON.stringify({ asset_id: templateId, asset_kind: "text_template", resource_version: resourceVersion }),
|
||
credentials: "same-origin",
|
||
headers: { "Content-Type": "application/json", "X-CSRF-Token": session.csrf_token },
|
||
method: "POST",
|
||
});
|
||
if (!response.ok) return;
|
||
setRecentTextIds((current) => [templateId, ...current.filter((id) => id !== templateId)].slice(0, 12));
|
||
} catch {
|
||
// Recent-use metadata does not roll back an otherwise successful canvas edit.
|
||
}
|
||
}
|
||
|
||
async function addTextTemplate(template: TextTemplateDefinition) {
|
||
if (!template.fontUrl || !canvasState) return;
|
||
const status = await ensureFont(template.defaultFontId, template.fontUrl);
|
||
if (status !== "ready") {
|
||
setNotice("素材暂不可用,未使用系统字体替代。");
|
||
return;
|
||
}
|
||
const controller = controllerForCurrent();
|
||
if (!controller) return;
|
||
try {
|
||
controller.add(createTextTemplateElement(template, newElementIdentity(), canvasState.elements.length));
|
||
commitElementOperation(controller, "文字模板已加入画布");
|
||
void recordRecentTextTemplate(template.templateId, template.resourceVersion);
|
||
} catch (error) {
|
||
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
|
||
else setNotice("文字模板未能加入画布");
|
||
}
|
||
}
|
||
|
||
function updateTextDraft(action: (session: TextEditSession) => void) {
|
||
setTextEdit((current) => {
|
||
if (!current) return current;
|
||
try {
|
||
const edit = new TextEditSession(current.draft, P0A_TEXT_TEMPLATES);
|
||
action(edit);
|
||
return { ...current, draft: edit.value };
|
||
} catch {
|
||
setNotice("文字参数不在允许范围内");
|
||
return current;
|
||
}
|
||
});
|
||
}
|
||
|
||
async function changeTextTemplate(templateId: string) {
|
||
const template = P0A_TEXT_TEMPLATES.find((candidate) => candidate.templateId === templateId);
|
||
if (!template?.fontUrl) return;
|
||
const status = await ensureFont(template.defaultFontId, template.fontUrl);
|
||
if (status !== "ready") {
|
||
setNotice("素材暂不可用,未使用系统字体替代。");
|
||
return;
|
||
}
|
||
updateTextDraft((edit) => edit.switchTemplate(templateId));
|
||
}
|
||
|
||
async function changeTextFont(fontId: string | null) {
|
||
if (!fontId) {
|
||
updateTextDraft((edit) => edit.setStyle({ fontOverride: null }));
|
||
return;
|
||
}
|
||
const option = fontOption(fontId);
|
||
if (!option || await ensureFont(option.fontId, option.url) !== "ready") {
|
||
setNotice("字体素材暂不可用,未使用系统字体替代。");
|
||
return;
|
||
}
|
||
updateTextDraft((edit) => edit.setStyle({ fontOverride: fontId }));
|
||
}
|
||
|
||
function completeTextEdit() {
|
||
if (!textEdit) return;
|
||
try {
|
||
const edit = new TextEditSession(textEdit.draft, P0A_TEXT_TEMPLATES);
|
||
const complete = edit.complete();
|
||
const controller = controllerForCurrent();
|
||
if (!controller) return;
|
||
controller.replaceElement(complete);
|
||
commitElementOperation(controller, "文字编辑已完成");
|
||
if (complete.template_or_asset_id !== textEdit.originalTemplateId) {
|
||
void recordRecentTextTemplate(complete.template_or_asset_id, complete.resource_version);
|
||
}
|
||
} catch (error) {
|
||
if (error instanceof Error && error.message === "text_content_required") setNotice("请输入文字内容或删除该元素。");
|
||
else setNotice("文字编辑未能完成");
|
||
}
|
||
}
|
||
|
||
function cancelTextEdit() {
|
||
if (canvasState && textEdit) {
|
||
const current = canvasState.elements.find((element) => element.element_id === textEdit.elementId);
|
||
if (current) setTextEdit({ draft: structuredClone(current), elementId: current.element_id, originalTemplateId: current.template_or_asset_id });
|
||
}
|
||
setNotice("已取消未提交的文字修改");
|
||
}
|
||
|
||
function 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;
|
||
try {
|
||
action(controller);
|
||
commitElementOperation(controller, message);
|
||
} catch (error) {
|
||
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
|
||
else setNotice("对象操作未完成");
|
||
}
|
||
}
|
||
|
||
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);
|
||
setNotice("贴纸透明度已提交");
|
||
}
|
||
|
||
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();
|
||
setNotice("已复制到画布剪贴板");
|
||
}
|
||
|
||
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") setNotice("画布最多 50 个元素,请先删除现有元素。");
|
||
}
|
||
}
|
||
|
||
function selectAt(point: CanvasPoint, append: boolean) {
|
||
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);
|
||
dragRef.current = { base: canvasState, last: canvasState, 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);
|
||
setGuides(preview.guides);
|
||
}
|
||
|
||
function commitMove() {
|
||
const drag = dragRef.current;
|
||
if (!drag) return;
|
||
commitCanvas(drag.last);
|
||
setNotice("对象位置已提交");
|
||
setGuides([]);
|
||
dragRef.current = undefined;
|
||
}
|
||
|
||
function marqueeSelect(rectangle: CanvasRect, append: boolean) {
|
||
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() {
|
||
elementControllerRef.current?.clearSelection();
|
||
setSelectedIds([]);
|
||
setCandidateMenu(undefined);
|
||
setGuides([]);
|
||
}
|
||
|
||
if (!project || !canvasState) return <main className="editor-loading" aria-live="polite">正在加载编辑器</main>;
|
||
const renderedCanvasState = textEdit ? {
|
||
...canvasState,
|
||
elements: canvasState.elements.map((element) => element.element_id === textEdit.elementId ? textEdit.draft : element),
|
||
} : canvasState;
|
||
const imageUrl = `/api/v1/private-assets/projects/${projectId}/images/${canvasState.background.asset_id ?? project.current_image_id ?? ""}`;
|
||
const canEdit = saveStatus !== "conflicted";
|
||
const selectedElements = 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
|
||
assetId={canvasState.background.asset_id}
|
||
canvasState={renderedCanvasState}
|
||
fontStatuses={fontStatuses}
|
||
guides={guides}
|
||
onCandidates={showCandidates}
|
||
onClearSelection={clearSelection}
|
||
onCopy={copySelection}
|
||
onDelete={deleteSelection}
|
||
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>
|
||
);
|
||
}
|