feat(canvas): 画布同步 - 新增操作事务、预演保存与并发恢复能力
This commit is contained in:
@@ -1,45 +0,0 @@
|
||||
import { Button, Modal } from "antd";
|
||||
import { Check, Star } from "lucide-react";
|
||||
|
||||
import { CanvasVideoPreviewImage } from "@/components/canvas/canvas-video-preview-image";
|
||||
import { producedModelLabel } from "@/lib/canvas/produced-model";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { useEffectiveConfig } from "@/stores/use-config-store";
|
||||
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
export function CanvasVersionCompareModal({ open, versions, onClose, onSetPrimary, onFocus }: { open: boolean; versions: CanvasNodeData[]; onClose: () => void; onSetPrimary: (nodeId: string) => void; onFocus: (nodeId: string) => void }) {
|
||||
const theme = canvasThemes[useActiveTheme()];
|
||||
const config = useEffectiveConfig();
|
||||
const modalWidth = Math.min(1180, Math.max(440, 112 + versions.length * 340));
|
||||
return (
|
||||
<Modal title="版本对比" open={open} footer={null} width={modalWidth} centered onCancel={onClose} styles={{ body: { overflow: "hidden" } }}>
|
||||
<div className="thin-scrollbar grid max-h-[70vh] grid-flow-col auto-cols-[328px] gap-3 overflow-x-auto pb-2">
|
||||
{versions.map((node) => {
|
||||
const producedModelText = producedModelLabel(config, node.metadata?.producedModel);
|
||||
return (
|
||||
<article key={node.id} className="overflow-hidden rounded-[var(--r-lg)] border" style={{ borderColor: node.metadata?.versionPrimary ? theme.accent.primary : theme.node.stroke, background: theme.node.panel, boxShadow: node.metadata?.versionPrimary ? "0 0 0 2px " + theme.accent.primarySoft : undefined }}>
|
||||
<div className="flex h-11 items-center justify-between gap-3 border-b px-3" style={{ borderColor: theme.node.stroke }}>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2"><span className="grid size-7 shrink-0 place-items-center rounded-[var(--r-full)] border text-[var(--node-badge-fs)] font-semibold leading-none" style={{ background: node.metadata?.versionPrimary ? theme.accent.primarySoft : theme.toolbar.itemHover, borderColor: node.metadata?.versionPrimary ? theme.accent.primary : theme.node.stroke, color: node.metadata?.versionPrimary ? theme.accent.primary : theme.node.text }}>{node.metadata?.versionLabel || "-"}</span><span className="min-w-0 truncate text-xs font-semibold" title={node.title}>{node.title}</span></div>
|
||||
{node.metadata?.versionPrimary ? <span className="inline-flex shrink-0 items-center gap-1 whitespace-nowrap text-[var(--fs-tiny)] font-medium" style={{ color: theme.accent.primary }}><Check className="size-3" />主版本</span> : null}
|
||||
</div>
|
||||
<button type="button" className="block h-52 w-full overflow-hidden" style={{ background: theme.node.fill }} onClick={() => onFocus(node.id)}>
|
||||
{node.type === CanvasNodeType.Image && node.metadata?.content ? <img src={node.metadata.content} alt={node.title || "版本图片"} className="size-full object-contain" /> : node.type === CanvasNodeType.Video ? <CanvasVideoPreviewImage node={node} alt={node.title || "版本视频"} className="size-full object-contain" loading="lazy" decoding="async" fallback={<span className="grid size-full place-items-center px-4 text-center text-xs" style={{ color: theme.node.muted }}>暂无视频首帧</span>} /> : <span className="grid size-full place-items-center px-4 text-center text-xs" style={{ color: theme.node.muted }}>点击定位到画布节点</span>}
|
||||
</button>
|
||||
<div className="space-y-2 p-3 text-[var(--fs-label)]">
|
||||
{producedModelText ? <Info label="产出模型" value={producedModelText} /> : null}
|
||||
<Info label="尺寸" value={node.metadata?.size || "默认尺寸"} />
|
||||
<div><div className="whitespace-nowrap opacity-45">提示词</div><div className="mt-1 line-clamp-5 whitespace-pre-wrap break-words leading-5">{node.metadata?.composerContent || node.metadata?.prompt || "未填写"}</div></div>
|
||||
<Button block size="small" className="whitespace-nowrap" type={node.metadata?.versionPrimary ? "default" : "primary"} disabled={node.metadata?.versionPrimary} icon={<Star className="size-3.5" />} onClick={() => onSetPrimary(node.id)}>{node.metadata?.versionPrimary ? "当前主版本" : "设为主版本"}</Button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function Info({ label, value }: { label: string; value: string }) {
|
||||
return <div className="flex min-w-0 items-center gap-3"><span className="w-16 shrink-0 whitespace-nowrap opacity-45">{label}</span><span className="min-w-0 flex-1 truncate text-right font-medium" title={value}>{value}</span></div>;
|
||||
}
|
||||
@@ -24,7 +24,7 @@ import { type PrevisObject, type PrevisLight, type PrevisShot, type PrevisHumano
|
||||
import { resolvePrevisPlacementAnchor, resolvePrevisPlacement, type PrevisGroundPoint } from "@/lib/canvas/previs/previs-placement";
|
||||
import { uploadMediaFile } from "@/services/file-storage";
|
||||
import { saveRemoteUserDataNow, localSavedRemotePendingMessage } from "@/services/user-data-sync";
|
||||
import { Camera, Lightbulb, LampDesk, UserRound, Box, Circle, Cuboid, Square, FileUp, X, Undo2, Redo2, RotateCcw, Video, Save, Plus, WandSparkles, BoxSelect, Image as ImageIcon, Users, Route, Search } from "lucide-react";
|
||||
import { Camera, Lightbulb, LampDesk, UserRound, Box, Circle, Cuboid, Square, FileUp, X, Undo2, Redo2, Video, Save, Plus, WandSparkles, BoxSelect, Image as ImageIcon, Users, Route, Search } from "lucide-react";
|
||||
import { PrevisCanvasDock } from "./previs-canvas-dock";
|
||||
import { Dropdown } from "antd";
|
||||
import { nanoid } from "nanoid";
|
||||
@@ -45,7 +45,7 @@ import { buildPrevisPathKeyframes } from "@/lib/canvas/previs/previs-path";
|
||||
import { recordPrevisDiagnostic } from "@/lib/canvas/previs/previs-diagnostics-recorder";
|
||||
import { shouldReinitializePrevisSession, isPrevisOutputSnapshotCurrent } from "@/lib/canvas/previs/previs-session";
|
||||
import { isPrevisPreviewRequestForWorkbench, previsPreviewRequestKey, type PrevisPreviewRequest } from "@/lib/canvas/previs/previs-preview";
|
||||
import { describePrevisSaveStatus, shouldOfferPrevisDraftRecovery, shouldBlockPrevisUnload, resolvePrevisCloseOutcome } from "@/lib/canvas/previs/previs-save-wiring";
|
||||
import { describePrevisSaveStatus, shouldBlockPrevisUnload, resolvePrevisCloseOutcome } from "@/lib/canvas/previs/previs-save-wiring";
|
||||
import { usePrevisSaveCoordinator } from "@/components/canvas/previs/use-previs-save-coordinator";
|
||||
import "./canvas-previs-workbench.css";
|
||||
import { useAssetStore, type ModelAsset } from "@/stores/use-asset-store";
|
||||
@@ -79,7 +79,7 @@ export function CanvasPrevisWorkbench({
|
||||
onDeleteImageNode: (nodeId: string) => void;
|
||||
onFlush?: () => void | Promise<void>;
|
||||
}) {
|
||||
const { message, modal } = App.useApp();
|
||||
const { message } = App.useApp();
|
||||
const theme = canvasThemes[useActiveTheme()];
|
||||
const effectiveCanvasId = useMemo(() => {
|
||||
const explicit = canvasId?.trim();
|
||||
@@ -183,11 +183,7 @@ export function CanvasPrevisWorkbench({
|
||||
}, [onChange, onFlush]);
|
||||
|
||||
const closingRef = useRef(false);
|
||||
const recoveryPromptedRef = useRef<string | null>(null);
|
||||
const [retrying, setRetrying] = useState(false);
|
||||
const handledPreviewRequestsRef = useRef(new Set<string>());
|
||||
|
||||
// 按 scene.id 持有唯一 coordinator:flush 先把 request.scene 写回项目,再等持久化完成。
|
||||
const saveController = usePrevisSaveCoordinator({
|
||||
sceneId: scene?.id ?? null,
|
||||
initialScene: scene,
|
||||
@@ -198,25 +194,6 @@ export function CanvasPrevisWorkbench({
|
||||
saveControllerRef.current = saveController;
|
||||
const saveIndicator = describePrevisSaveStatus(saveController.progress);
|
||||
|
||||
const retrySave = async () => {
|
||||
setRetrying(true);
|
||||
try {
|
||||
if (await saveController.retry()) {
|
||||
recordPrevisDiagnostic("PREVIS_SAVE_RETRY_RECOVERED", { sceneId: draftRef.current?.id, revision: saveController.progress.revision, userInitiated: true });
|
||||
message.success("已保存到项目");
|
||||
return;
|
||||
}
|
||||
// 只有真的存在合法本地候选才敢说草稿已保留。
|
||||
const draftStored = Boolean(saveController.restoreCandidate());
|
||||
recordPrevisDiagnostic("PREVIS_SAVE_RETRY_FAILED", { sceneId: draftRef.current?.id, revision: saveController.progress.revision, draftStored, userInitiated: true });
|
||||
if (!draftStored) recordPrevisDiagnostic("PREVIS_SAVE_DRAFT_UNAVAILABLE", { sceneId: draftRef.current?.id, revision: saveController.progress.revision });
|
||||
if (draftStored) message.error("远端保存失败,本地草稿已保留,可稍后重试");
|
||||
else message.error("远端和本地都未保存,请不要关闭预演台并继续重试");
|
||||
} finally {
|
||||
setRetrying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const writeDraft = useCallback((next: PrevisScene | null) => {
|
||||
draftRef.current = next;
|
||||
setDraft(next);
|
||||
@@ -232,7 +209,7 @@ export function CanvasPrevisWorkbench({
|
||||
onChangeRef.current(current);
|
||||
}, []);
|
||||
|
||||
/** 真实 canonical 提交:先交给 coordinator(本地草稿 + 远端保存),再镜像到项目。 */
|
||||
/** 真实 canonical 提交:先交给 coordinator,再镜像到项目。 */
|
||||
const commitDraft = useCallback(() => {
|
||||
const current = draftRef.current;
|
||||
if (!current || initializedSceneIdRef.current !== current.id) return;
|
||||
@@ -282,40 +259,6 @@ export function CanvasPrevisWorkbench({
|
||||
message.info("Agent 已更新预演场景");
|
||||
}, [message, open, scene, setPlayhead, setSelectedBone, setSelectedLightId, setSelectedObjectId, writeDraft]);
|
||||
|
||||
// 打开会话时检查合法本地恢复候选:同一场景只提示一次,恢复/放弃都必须有明确结果。
|
||||
useEffect(() => {
|
||||
if (!open || !scene) return;
|
||||
if (recoveryPromptedRef.current === scene.id) return;
|
||||
recoveryPromptedRef.current = scene.id;
|
||||
|
||||
const controller = saveControllerRef.current;
|
||||
const candidate = controller?.restoreCandidate() ?? null;
|
||||
if (!controller || !candidate) return;
|
||||
if (!shouldOfferPrevisDraftRecovery({ candidate, authoritativeScene: scene })) return;
|
||||
|
||||
modal.confirm({
|
||||
title: "发现未保存的本地草稿",
|
||||
content: `这个镜头存在一份比项目更新的本地草稿(修订 ${candidate.revision})。恢复后会立即写回项目并保存。`,
|
||||
okText: "恢复草稿",
|
||||
cancelText: "放弃草稿",
|
||||
closable: false,
|
||||
mask: { closable: false },
|
||||
keyboard: false,
|
||||
onOk: () => {
|
||||
if (!controller.restoreDraft(candidate)) {
|
||||
message.error("草稿恢复失败,已保留当前场景");
|
||||
return;
|
||||
}
|
||||
writeDraft(candidate.scene);
|
||||
message.success("已恢复本地草稿并写回项目");
|
||||
},
|
||||
onCancel: () => {
|
||||
if (controller.discardDraft()) message.success("已放弃本地草稿");
|
||||
else message.error("草稿删除失败,下次打开可能仍会提示");
|
||||
},
|
||||
});
|
||||
}, [message, modal, open, scene, writeDraft]);
|
||||
|
||||
const activeShot = draft?.shots?.find((item) => item.id === draft.activeShotId) || draft?.shots?.[0] || null;
|
||||
const activeCamera = draft?.cameras?.find((item) => item.id === activeShot?.cameraId) || draft?.cameras?.[0] || null;
|
||||
useEffect(() => {
|
||||
@@ -522,31 +465,9 @@ export function CanvasPrevisWorkbench({
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (decision.kind === "blocked") {
|
||||
recordPrevisDiagnostic("PREVIS_CLOSE_BLOCKED", { sceneId: draftRef.current?.id, saveOutcome: "stay", revision: saveController.progress.revision, draftStored: saveController.progress.draftStored });
|
||||
message.error(decision.message);
|
||||
closingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 确认框存续期间保持上锁,否则重复点击会叠出多个弹窗。
|
||||
modal.confirm({
|
||||
title: "远端保存失败",
|
||||
content: decision.message,
|
||||
okText: "仍然离开",
|
||||
cancelText: "留在预演台",
|
||||
closable: false,
|
||||
mask: { closable: false },
|
||||
keyboard: false,
|
||||
// 预演视口持续占用主线程时,zoom 离场要等 rAF 和 transitionend。
|
||||
// 动画停在可见态后,点「留在预演台」确认框也不会消失。
|
||||
transitionName: "",
|
||||
maskTransitionName: "",
|
||||
onOk: () => onClose(),
|
||||
onCancel: () => {
|
||||
closingRef.current = false;
|
||||
},
|
||||
});
|
||||
recordPrevisDiagnostic("PREVIS_CLOSE_BLOCKED", { sceneId: draftRef.current?.id, saveOutcome: "stay", revision: saveController.progress.revision });
|
||||
message.error(decision.message);
|
||||
closingRef.current = false;
|
||||
})();
|
||||
};
|
||||
|
||||
@@ -1325,12 +1246,6 @@ export function CanvasPrevisWorkbench({
|
||||
<span className={`previs-desk-save ${saveStatusClass}`} aria-live="polite">
|
||||
{saveIndicator.label}
|
||||
</span>
|
||||
{saveIndicator.retryable ? (
|
||||
<button type="button" className="previs-desk-header-action" aria-label="重试保存" disabled={retrying || saveIndicator.busy} onClick={() => void retrySave()}>
|
||||
<RotateCcw className="size-3.5" />
|
||||
重试保存
|
||||
</button>
|
||||
) : null}
|
||||
{onboardingScope ? (
|
||||
<button type="button" className="previs-desk-icon-button" aria-label="重新开始引导" title="重新开始引导" onClick={() => setOnboardingRestartSignal((value) => value + 1)}>
|
||||
<Lightbulb className="size-4" />
|
||||
|
||||
@@ -1,41 +1,25 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { createPrevisSaveCoordinator, type PrevisCloseDecision, type PrevisDraftEnvelope, type PrevisSaveCoordinator } from "@/lib/canvas/previs/previs-save";
|
||||
import { previsSaveProgress, idlePrevisSaveProgress, resolvePrevisDraftStorage, type PrevisSaveProgress } from "@/lib/canvas/previs/previs-save-wiring";
|
||||
import { createPrevisSaveCoordinator, type PrevisCloseDecision, type PrevisSaveCoordinator } from "@/lib/canvas/previs/previs-save";
|
||||
import { idlePrevisSaveProgress, previsSaveProgress, type PrevisSaveProgress } from "@/lib/canvas/previs/previs-save-wiring";
|
||||
import { recordPrevisDiagnostic } from "@/lib/canvas/previs/previs-diagnostics-recorder";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import type { PrevisScene } from "@/types/previs";
|
||||
|
||||
export type PrevisSaveController = {
|
||||
progress: PrevisSaveProgress;
|
||||
/** canonical 提交入口:预览态与「仅镜像」路径都不得调用。 */
|
||||
commitScene: (scene: PrevisScene) => void;
|
||||
retry: () => Promise<boolean>;
|
||||
restoreCandidate: () => PrevisDraftEnvelope | null;
|
||||
restoreDraft: (candidate: PrevisDraftEnvelope) => boolean;
|
||||
discardDraft: () => boolean;
|
||||
prepareClose: () => Promise<PrevisCloseDecision>;
|
||||
handlePageHide: () => Promise<boolean>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 按 scene.id 持有唯一 coordinator:切换场景或卸载时 dispose。
|
||||
* persistScene 必须把 request.scene 落到项目状态,flushPersistence 负责真正落盘。
|
||||
*/
|
||||
export function usePrevisSaveCoordinator(input: { sceneId: string | null; initialScene: PrevisScene | null; persistScene: (scene: PrevisScene) => void; flushPersistence: () => void | Promise<void> }): PrevisSaveController {
|
||||
const { sceneId, initialScene, persistScene, flushPersistence } = input;
|
||||
|
||||
const coordinatorRef = useRef<PrevisSaveCoordinator | null>(null);
|
||||
const persistSceneRef = useRef(persistScene);
|
||||
const flushPersistenceRef = useRef(flushPersistence);
|
||||
const initialSceneRef = useRef(initialScene);
|
||||
const [progress, setProgress] = useState<PrevisSaveProgress>(idlePrevisSaveProgress);
|
||||
|
||||
// scope 取响应式 user id:user-session 先切 active scope 再 setUser,
|
||||
// 因此这里能在用户变化时重建 coordinator,且不会在播放时每帧读 localStorage。
|
||||
const scope = useUserStore((state) => state.user?.id) ?? "guest";
|
||||
|
||||
// 依赖为空的 effect 每次 render 后都跑,保证 coordinator effect 读到的是最新回调。
|
||||
useEffect(() => {
|
||||
persistSceneRef.current = persistScene;
|
||||
flushPersistenceRef.current = flushPersistence;
|
||||
@@ -52,15 +36,11 @@ export function usePrevisSaveCoordinator(input: { sceneId: string | null; initia
|
||||
let active = true;
|
||||
const coordinator = createPrevisSaveCoordinator({
|
||||
initialScene: base,
|
||||
scope,
|
||||
storage: resolvePrevisDraftStorage(() => globalThis.localStorage),
|
||||
flush: async (request) => {
|
||||
try {
|
||||
// 先把这次 flush 对应的场景写进项目状态,再等持久化真正完成。
|
||||
persistSceneRef.current(request.scene);
|
||||
await flushPersistenceRef.current();
|
||||
} catch (error) {
|
||||
// 只记录稳定码与安全字段;原始 error 继续上抛给 coordinator 走 error/draft 语义。
|
||||
recordPrevisDiagnostic("PREVIS_SAVE_FLUSH_FAILED", { sceneId: request.scene.id, revision: request.revision });
|
||||
throw error;
|
||||
}
|
||||
@@ -69,39 +49,28 @@ export function usePrevisSaveCoordinator(input: { sceneId: string | null; initia
|
||||
|
||||
coordinatorRef.current = coordinator;
|
||||
setProgress(previsSaveProgress(coordinator.getSnapshot()));
|
||||
|
||||
// 通知里必须读完整快照:只搬 status 会让 confirmedRevision/draftStored 停在旧值,
|
||||
// UI 继续显示未保存、beforeunload 也会误拦。
|
||||
const unsubscribe = coordinator.onStatusChange(() => {
|
||||
if (!active) return;
|
||||
setProgress(previsSaveProgress(coordinator.getSnapshot()));
|
||||
if (active) setProgress(previsSaveProgress(coordinator.getSnapshot()));
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
unsubscribe();
|
||||
coordinatorRef.current = null;
|
||||
|
||||
// 卸载前把未确认改动推完再 dispose,否则 debounce 里的最后一次编辑只剩本地草稿。
|
||||
const pending = coordinator.getSnapshot();
|
||||
if (pending.confirmedRevision < pending.revision) {
|
||||
void coordinator
|
||||
.flushLatest()
|
||||
.catch(() => undefined)
|
||||
.finally(() => coordinator.dispose());
|
||||
void coordinator.flushLatest().catch(() => undefined).finally(() => coordinator.dispose());
|
||||
return;
|
||||
}
|
||||
coordinator.dispose();
|
||||
};
|
||||
}, [sceneId, scope]);
|
||||
}, [sceneId]);
|
||||
|
||||
/** dispose 后 coordinatorRef 为 null,因此卸载期的调用不会再 setState。 */
|
||||
const syncProgress = useCallback(() => {
|
||||
const coordinator = coordinatorRef.current;
|
||||
if (coordinator) setProgress(previsSaveProgress(coordinator.getSnapshot()));
|
||||
}, []);
|
||||
|
||||
/** canonical 提交唯一入口:预览态和「仅镜像当前 draft」都不得调用。 */
|
||||
const commitScene = useCallback(
|
||||
(scene: PrevisScene) => {
|
||||
if (!coordinatorRef.current?.edit(scene)) return;
|
||||
@@ -110,35 +79,6 @@ export function usePrevisSaveCoordinator(input: { sceneId: string | null; initia
|
||||
[syncProgress],
|
||||
);
|
||||
|
||||
const retry = useCallback(async () => {
|
||||
const coordinator = coordinatorRef.current;
|
||||
if (!coordinator) return false;
|
||||
const result = await coordinator.retry();
|
||||
syncProgress();
|
||||
return result;
|
||||
}, [syncProgress]);
|
||||
|
||||
const restoreCandidate = useCallback(() => coordinatorRef.current?.restoreCandidate() ?? null, []);
|
||||
|
||||
const restoreDraft = useCallback(
|
||||
(candidate: PrevisDraftEnvelope) => {
|
||||
const coordinator = coordinatorRef.current;
|
||||
if (!coordinator?.explicitRestore(candidate)) return false;
|
||||
// 恢复后必须回到项目状态并触发可靠保存,否则草稿只活在本地。
|
||||
persistSceneRef.current(candidate.scene);
|
||||
void coordinator.flushLatest().finally(syncProgress);
|
||||
syncProgress();
|
||||
return true;
|
||||
},
|
||||
[syncProgress],
|
||||
);
|
||||
|
||||
const discardDraft = useCallback(() => {
|
||||
const discarded = coordinatorRef.current?.discardDraft() ?? false;
|
||||
syncProgress();
|
||||
return discarded;
|
||||
}, [syncProgress]);
|
||||
|
||||
const prepareClose = useCallback(async () => {
|
||||
const coordinator = coordinatorRef.current;
|
||||
if (!coordinator) return "close" as PrevisCloseDecision;
|
||||
@@ -147,11 +87,6 @@ export function usePrevisSaveCoordinator(input: { sceneId: string | null; initia
|
||||
return decision;
|
||||
}, [syncProgress]);
|
||||
|
||||
/**
|
||||
* pagehide:dirty 时只能走 coordinator.handlePageHide —— 它的 flush 已经
|
||||
* persist(request.scene) + await flushPersistence,组件再叠一次就是重复落盘。
|
||||
* clean 时 coordinator 不会 flush,因此这里显式落盘,保留原有「离开页面必落盘」语义。
|
||||
*/
|
||||
const handlePageHide = useCallback(async () => {
|
||||
const coordinator = coordinatorRef.current;
|
||||
try {
|
||||
@@ -172,14 +107,5 @@ export function usePrevisSaveCoordinator(input: { sceneId: string | null; initia
|
||||
}
|
||||
}, [syncProgress]);
|
||||
|
||||
return {
|
||||
progress,
|
||||
commitScene,
|
||||
retry,
|
||||
restoreCandidate,
|
||||
restoreDraft,
|
||||
discardDraft,
|
||||
prepareClose,
|
||||
handlePageHide,
|
||||
};
|
||||
return { progress, commitScene, prepareClose, handlePageHide };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user