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(url: string): Promise { 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; } 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
{props.children}
; } export function EditorPage({ projectId }: { projectId: string }) { const [session, setSession] = useState(); const [project, setProject] = useState(); const [canvasState, setCanvasState] = useState(); const [draftAdjustments, setDraftAdjustments] = useState(defaultBackgroundAdjustments); const [saveStatus, setSaveStatus] = useState("saved"); const [pendingBackground, setPendingBackground] = useState(); const [notice, setNotice] = useState(""); const [activePanel, setActivePanel] = useState("background"); const [stickerScrollTop, setStickerScrollTop] = useState(0); const [stickerCatalog, setStickerCatalog] = useState(P0A_STATIC_STICKER_CATALOG); const [selectedIds, setSelectedIds] = useState([]); const [guides, setGuides] = useState([]); const [multiMode, setMultiMode] = useState(false); const [candidateMenu, setCandidateMenu] = useState<{ elements: CanvasElement[]; point: CanvasPoint }>(); const [templateCategory, setTemplateCategory] = useState(); const [templateQuery, setTemplateQuery] = useState(""); const [recentTextIds, setRecentTextIds] = useState([]); const [fontStatuses, setFontStatuses] = useState>({}); const [textEdit, setTextEdit] = useState(); const [locationDialog, setLocationDialog] = useState(); const [exportOpen, setExportOpen] = useState(false); const [exportBusy, setExportBusy] = useState(false); const [exportResult, setExportResult] = useState(); const queueRef = useRef(undefined); const historyRef = useRef(undefined); const elementControllerRef = useRef(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([]); const fontLoaderRef = useRef(undefined); const conflictExportGuardRef = useRef(new ConflictExportGuard()); const candidateMenuRef = useRef(null); const candidateTriggerRef = useRef(null); useEffect(() => { let active = true; Promise.all([ readEditorJson("/api/v1/auth/session"), readEditorJson(`/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('[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((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 = { 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) { if (event.key === "Escape") { event.preventDefault(); closeCandidateMenu(); return; } if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return; const items = [...event.currentTarget.querySelectorAll('[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
正在加载编辑器
; 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 (
跳到主要内容

{project.name}

{project.ratio}
{saveStatusLabel(saveStatus)}
{notice ?

{notice}

: null}
transformSelection((controller) => { controller.moveSelected(delta, { snap: false }); }, "对象位置已提交")} onPaste={pasteSelection} onPointerMoved={() => elementControllerRef.current?.pointerMoved()} onSelect={selectAt} projectId={projectId} selectedIds={selectedIds} /> {candidateMenu ?
{candidateMenu.elements.map((element) => )}
: null}
{guides.length > 0 ? 已吸附 : null}
画布 {canvasState.pixel_width} × {canvasState.pixel_height}对象 {canvasState.elements.length} / 50缩放 100%本机保存 · state version {project.state_version}
{pendingBackground ? setPendingBackground(undefined)}>

CHANGE BACKGROUND

更换底图

覆盖元素会保留,底图编辑参数将重置。

: null} {locationDialog ? { if (!locationDialog.pending) setLocationDialog(undefined); }}>

LOCATION PRIVACY

原始经纬度会保存到当前项目并显示在导出成品中。只有确认后才会请求浏览器定位,并由本地后端调用地点服务。

{locationDialog.error ?

{locationDialog.error}

: null}
: null} {exportOpen ? { 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}
); }