feat(canvas): 画布同步 - 新增操作事务、预演保存与并发恢复能力
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { Clapperboard, CloudDownload, CloudUpload, CopyPlus, Focus, FolderKanban, Gauge, History, Home, LayoutGrid, LoaderCircle, Menu, Pencil, Plus, Redo2, Save, Search, Share2, Trash2, Undo2, Upload } from "lucide-react";
|
||||
import { Clapperboard, CloudDownload, CopyPlus, Focus, FolderKanban, Home, LayoutGrid, LoaderCircle, Menu, Pencil, Plus, Redo2, Save, Search, Share2, Trash2, Undo2, Upload } from "lucide-react";
|
||||
import { Button, Dropdown, Tooltip } from "antd";
|
||||
|
||||
import { WorkspaceCreditGiftMark } from "@/components/layout/workspace-credit-gift-mark";
|
||||
@@ -12,13 +12,9 @@ import type { CanvasShortDramaProgress } from "@/lib/canvas/canvas-short-drama";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { useCanvasThemeStore } from "@/stores/canvas/use-canvas-theme-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import type { CanvasMediaPerformanceMode } from "@/types/canvas";
|
||||
import { CanvasShortcutsModal } from "./canvas-shortcuts-modal";
|
||||
|
||||
type CanvasTopBarProps = {
|
||||
syncStatus?: ReactNode;
|
||||
versionsOpen: boolean;
|
||||
onToggleVersions: () => void;
|
||||
title: string;
|
||||
titleDraft: string;
|
||||
isTitleEditing: boolean;
|
||||
@@ -31,7 +27,6 @@ type CanvasTopBarProps = {
|
||||
onCreateProject: () => void;
|
||||
onDeleteProject: () => void;
|
||||
onSave: () => void | Promise<void>;
|
||||
onForceSave: () => void;
|
||||
onImportImage: () => void;
|
||||
onImportLibTV: () => void;
|
||||
onImportTapNow: () => void;
|
||||
@@ -39,8 +34,7 @@ type CanvasTopBarProps = {
|
||||
onRedo: () => void;
|
||||
onShare: () => void;
|
||||
shortcutRequestNonce: number;
|
||||
mediaPerformanceMode: CanvasMediaPerformanceMode;
|
||||
onMediaPerformanceModeChange: (mode: CanvasMediaPerformanceMode) => void;
|
||||
syncIcon?: ReactNode;
|
||||
onOpenSearch: () => void;
|
||||
projectContext?: CanvasContextSummary & { projectId: string; projectName: string };
|
||||
onEnterFocusMode: () => void;
|
||||
@@ -48,9 +42,6 @@ type CanvasTopBarProps = {
|
||||
};
|
||||
|
||||
export function CanvasTopBar({
|
||||
syncStatus,
|
||||
versionsOpen,
|
||||
onToggleVersions,
|
||||
title,
|
||||
titleDraft,
|
||||
isTitleEditing,
|
||||
@@ -63,7 +54,6 @@ export function CanvasTopBar({
|
||||
onCreateProject,
|
||||
onDeleteProject,
|
||||
onSave,
|
||||
onForceSave,
|
||||
onImportImage,
|
||||
onImportLibTV,
|
||||
onImportTapNow,
|
||||
@@ -71,8 +61,7 @@ export function CanvasTopBar({
|
||||
onRedo,
|
||||
onShare,
|
||||
shortcutRequestNonce,
|
||||
mediaPerformanceMode,
|
||||
onMediaPerformanceModeChange,
|
||||
syncIcon,
|
||||
onOpenSearch,
|
||||
projectContext,
|
||||
onEnterFocusMode,
|
||||
@@ -118,20 +107,9 @@ export function CanvasTopBar({
|
||||
{ key: "new", icon: <Plus className="size-4" />, label: "新建画布", onClick: onCreateProject },
|
||||
{ key: "delete", danger: true, icon: <Trash2 className="size-4" />, label: "删除当前画布", onClick: onDeleteProject },
|
||||
{ key: "save", icon: <Save className="size-4" />, label: <MenuLabel text="保存" shortcut="⌘ S" />, onClick: () => void onSave() },
|
||||
{ key: "force-save", icon: <CloudUpload className="size-4" />, label: "修复素材关联并保存", onClick: onForceSave },
|
||||
{ type: "divider" },
|
||||
{ key: "import", icon: <Upload className="size-4" />, label: "导入素材", onClick: onImportImage },
|
||||
{ key: "search", icon: <Search className="size-4" />, label: <MenuLabel text="搜索节点" shortcut="⌘ F" />, onClick: onOpenSearch },
|
||||
{
|
||||
key: "performance",
|
||||
icon: <Gauge className="size-4" />,
|
||||
label: "媒体性能",
|
||||
children: [
|
||||
{ key: "performance-auto", label: "自动性能", onClick: () => onMediaPerformanceModeChange("auto") },
|
||||
{ key: "performance-quality", label: "画质优先", onClick: () => onMediaPerformanceModeChange("quality") },
|
||||
{ key: "performance-fast", label: "性能优先", onClick: () => onMediaPerformanceModeChange("performance") },
|
||||
],
|
||||
},
|
||||
{ type: "divider" },
|
||||
{ key: "undo", disabled: !canUndo, icon: <Undo2 className="size-4" />, label: <MenuLabel text="撤销" shortcut="⌘ Z" />, onClick: onUndo },
|
||||
{ key: "redo", disabled: !canRedo, icon: <Redo2 className="size-4" />, label: <MenuLabel text="重做" shortcut="⌘ ⇧ Z / ⌘ Y" />, onClick: onRedo },
|
||||
@@ -193,7 +171,7 @@ export function CanvasTopBar({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{syncStatus}
|
||||
{syncIcon}
|
||||
</div>
|
||||
|
||||
<div className="canvas-topbar-cluster canvas-topbar-tools-cluster pointer-events-auto flex items-center gap-1.5" style={dockStyle}>
|
||||
@@ -223,23 +201,6 @@ export function CanvasTopBar({
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</CanvasTopBarTooltip>
|
||||
<CanvasTopBarTooltip label="媒体性能模式">
|
||||
<Dropdown
|
||||
trigger={["click"]}
|
||||
menu={{
|
||||
selectable: true,
|
||||
selectedKeys: [mediaPerformanceMode],
|
||||
onClick: ({ key }) => onMediaPerformanceModeChange(key as CanvasMediaPerformanceMode),
|
||||
items: [
|
||||
{ key: "auto", label: "自动性能" },
|
||||
{ key: "quality", label: "画质优先" },
|
||||
{ key: "performance", label: "性能优先" },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Button type="text" className="canvas-topbar-action !hidden !h-10 !w-10 !min-w-10 !rounded-xl !p-0 lg:!inline-flex" style={{ color: theme.node.text }} icon={<Gauge className="size-4" />} aria-label="媒体性能模式" />
|
||||
</Dropdown>
|
||||
</CanvasTopBarTooltip>
|
||||
{user && creditsEnabled ? (
|
||||
<CanvasTopBarTooltip label="打开积分中心">
|
||||
<button
|
||||
@@ -272,19 +233,6 @@ export function CanvasTopBar({
|
||||
</Button>
|
||||
</CanvasTopBarTooltip>
|
||||
) : null}
|
||||
<CanvasTopBarTooltip label="版本记录与本地草稿">
|
||||
<Button
|
||||
type="text"
|
||||
className="canvas-topbar-action canvas-topbar-version-button !h-10 !rounded-xl !px-2.5 !font-medium"
|
||||
style={{ color: theme.node.text, background: versionsOpen ? theme.toolbar.activeBg : undefined }}
|
||||
icon={<History className="size-4" />}
|
||||
aria-label="版本记录"
|
||||
aria-pressed={versionsOpen}
|
||||
onClick={onToggleVersions}
|
||||
>
|
||||
版本
|
||||
</Button>
|
||||
</CanvasTopBarTooltip>
|
||||
<CanvasTopBarTooltip label="分享画布">
|
||||
<Button type="text" className="canvas-topbar-action !h-10 !w-10 !min-w-10 !rounded-xl !p-0" style={{ color: theme.node.text }} icon={<Share2 className="size-4" />} onClick={onShare} aria-label="分享画布" />
|
||||
</CanvasTopBarTooltip>
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
.canvas-sync-status-trigger:hover,
|
||||
.canvas-sync-status-trigger[aria-expanded="true"] {
|
||||
background: var(--user-surface-hover, var(--accent));
|
||||
color: var(--user-ink, var(--foreground));
|
||||
}
|
||||
|
||||
.canvas-sync-status-trigger--done { color: var(--status-success, #3f8f68); }
|
||||
|
||||
@@ -1,256 +1,37 @@
|
||||
import { App, Button, Popover } from "antd";
|
||||
import { CloudCheck, CloudOff, FileClock, LoaderCircle, RefreshCw, UploadCloud } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { flushCanvasStorePersistence } from "@/stores/canvas/use-canvas-store";
|
||||
import { CloudCheck, CloudOff, LoaderCircle } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSyncProgressStore } from "@/stores/use-sync-progress-store";
|
||||
import { overwriteRemoteCanvasProject, retryRemoteUserDataSync } from "@/services/user-data-sync";
|
||||
import "./canvas-sync-status.css";
|
||||
|
||||
export function CanvasSyncStatus({ projectId, onLoadLatest, onOpenVersions }: { projectId: string; onLoadLatest: () => Promise<void>; onOpenVersions?: () => void }) {
|
||||
const { message } = App.useApp();
|
||||
const ERROR_INDICATOR_DELAY_MS = 4_000;
|
||||
|
||||
export function CanvasSyncStatus({ projectId }: { projectId: string }) {
|
||||
const progress = useSyncProgressStore((state) => state.syncingProjects[projectId]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [statusOpen, setStatusOpen] = useState(false);
|
||||
const phase = progress?.phase;
|
||||
const conflict = phase === "conflict";
|
||||
const cloudError = phase === "error";
|
||||
const localPhase = progress?.localPhase;
|
||||
const localError = localPhase === "error";
|
||||
const localPending = localPhase === "pending";
|
||||
const missingResources = progress?.errorKind === "resources";
|
||||
const retryScheduled = cloudError && progress?.message?.includes("自动重试");
|
||||
const saving = phase === "pending" || phase === "saving" || phase === "uploading";
|
||||
const reconciling = phase === "reconciling";
|
||||
const tone = conflict || cloudError || localError ? (conflict ? "conflict" : "error") : saving || reconciling || localPending ? "pending" : phase === "done" ? "done" : localPhase === "saved" ? "done" : "idle";
|
||||
const label = conflict
|
||||
? "需要处理冲突"
|
||||
: cloudError
|
||||
? "云端保存失败"
|
||||
: localError
|
||||
? "本地保存失败"
|
||||
: reconciling
|
||||
? "正在自动合并"
|
||||
: saving
|
||||
? "正在同步云端"
|
||||
: localPending
|
||||
? "正在保存本地"
|
||||
: phase === "done"
|
||||
? "已保存到云端"
|
||||
: localPhase === "saved"
|
||||
? "已保存在本地"
|
||||
: "尚未同步";
|
||||
const description = conflict
|
||||
? progress?.message || "本地与云端存在冲突,当前编辑内容保持不变。"
|
||||
: cloudError
|
||||
? missingResources
|
||||
? "检测到云端缺失素材;可以先保留原始草稿,再用本地版强制覆盖并移除失效素材引用。"
|
||||
: progress?.message || "本地内容仍然保留,云端暂未确认。"
|
||||
: localError
|
||||
? progress?.localError || "本地保存没有完成,请立即重试;当前编辑内容保持不变。"
|
||||
: reconciling
|
||||
? progress?.message || "正在合并云端与本地修改,完成后会自动继续保存。"
|
||||
: saving
|
||||
? progress?.message || "本地内容已保留,正在同步到云端。"
|
||||
: localPending
|
||||
? "正在把最近编辑写入浏览器本地存储。"
|
||||
: phase === "done"
|
||||
? progress?.message || "当前画布与云端版本一致。"
|
||||
: localPhase === "saved"
|
||||
? "当前编辑已保存到浏览器本地,登录后会继续同步到云端。"
|
||||
: "本地内容会先保存到浏览器,再尝试同步到云端。";
|
||||
const localStatus = localError ? progress?.localError || "保存失败" : localPending ? "正在保存" : localPhase === "saved" ? `已保存${progress?.localSavedAt ? ` · ${new Date(progress.localSavedAt).toLocaleTimeString()}` : ""}` : "尚未确认";
|
||||
const cloudStatus = conflict
|
||||
? "存在冲突"
|
||||
: cloudError
|
||||
? progress?.message || "保存失败"
|
||||
: saving || reconciling
|
||||
? "正在同步"
|
||||
: phase === "done"
|
||||
? `已保存${progress?.cloudRevision !== undefined ? ` · 版本 ${progress.cloudRevision}` : ""}`
|
||||
: "尚未同步";
|
||||
const cloudError = phase === "error";
|
||||
const errorKey = cloudError || localError ? `${projectId}:${cloudError}:${localError}` : "";
|
||||
const [visibleErrorKey, setVisibleErrorKey] = useState("");
|
||||
|
||||
const run = async (operation: () => Promise<unknown>) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await operation();
|
||||
} catch (cause) {
|
||||
message.error(cause instanceof Error ? cause.message : "操作失败,请重试");
|
||||
throw cause;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
useEffect(() => {
|
||||
if (!errorKey) {
|
||||
setVisibleErrorKey("");
|
||||
return;
|
||||
}
|
||||
};
|
||||
const timer = window.setTimeout(() => setVisibleErrorKey(errorKey), ERROR_INDICATOR_DELAY_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [errorKey]);
|
||||
|
||||
const loadLatest = () => {
|
||||
void run(async () => {
|
||||
await onLoadLatest();
|
||||
setStatusOpen(false);
|
||||
message.success("云端版本已加载,本地内容已保留为草稿");
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
|
||||
const overwriteCloud = () => {
|
||||
void run(async () => {
|
||||
await overwriteRemoteCanvasProject(projectId);
|
||||
setStatusOpen(false);
|
||||
message.success("本地版本已覆盖云端");
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
const showErrorIcon = Boolean(errorKey && visibleErrorKey === errorKey);
|
||||
const saving = phase === "pending" || phase === "saving" || phase === "uploading" || localPhase === "pending";
|
||||
const tone = showErrorIcon && localError ? "error" : cloudError || localError || saving ? "pending" : phase === "done" || localPhase === "saved" ? "done" : "idle";
|
||||
const statusIconClass = showErrorIcon ? undefined : "animate-spin motion-reduce:animate-none";
|
||||
const label = cloudError ? "云端暂未同步" : localError ? "本地暂未保存" : saving ? "正在同步画布" : phase === "done" ? "已同步" : localPhase === "saved" ? "已保存" : "尚未同步";
|
||||
|
||||
return (
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement="bottom"
|
||||
open={statusOpen}
|
||||
onOpenChange={setStatusOpen}
|
||||
arrow={false}
|
||||
classNames={{ root: "canvas-sync-popover", container: "canvas-sync-popover-surface", content: "canvas-sync-popover-content" }}
|
||||
content={
|
||||
<div className={`canvas-sync-panel canvas-sync-panel--${tone}`} data-canvas-no-zoom>
|
||||
<div className="canvas-sync-panel__header">
|
||||
<span className="canvas-sync-panel__status-icon" aria-hidden="true">
|
||||
{saving || localPending ? <LoaderCircle className="animate-spin motion-reduce:animate-none" /> : conflict || cloudError || localError ? <CloudOff /> : <CloudCheck />}
|
||||
</span>
|
||||
<div className="canvas-sync-panel__heading">
|
||||
<span className="canvas-sync-panel__eyebrow">云端同步</span>
|
||||
<strong>{label}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<p className="canvas-sync-panel__description" role="status">
|
||||
{description}
|
||||
</p>
|
||||
<div className="canvas-sync-panel__details" aria-label="画布保存分层状态">
|
||||
<div>
|
||||
<span>本地浏览器</span>
|
||||
<strong>{localStatus}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>云端画布</span>
|
||||
<strong>{cloudStatus}</strong>
|
||||
</div>
|
||||
{progress?.pendingWrites ? <small>待写入 {progress.pendingWrites} 项</small> : null}
|
||||
</div>
|
||||
{progress?.draftError ? (
|
||||
<div className="canvas-sync-panel__callout">
|
||||
<strong>本地草稿保存未完成,已暂停版本替换</strong>
|
||||
<span>{progress.draftError}</span>
|
||||
</div>
|
||||
) : conflict ? (
|
||||
<div className="canvas-sync-panel__callout">
|
||||
<strong>请选择保留哪一版</strong>
|
||||
<span>{progress?.draftCount ? `已保留 ${progress.draftCount} 份本地草稿。` : "本地内容仍在编辑器中。"} 加载云端版会替换当前编辑内容;用本地版覆盖会替换云端内容。</span>
|
||||
</div>
|
||||
) : cloudError ? (
|
||||
<div className="canvas-sync-panel__callout">
|
||||
<strong>{retryScheduled ? "本地内容仍在,正在等待重试" : "本地内容仍在,未覆盖云端"}</strong>
|
||||
<span>
|
||||
{missingResources
|
||||
? "可点击“用本地版强制覆盖云端”;操作前会自动保留本地草稿,失效图片节点会保留布局和提示词。"
|
||||
: retryScheduled
|
||||
? "网络恢复后会自动重试;也可以点击“立即重试”。"
|
||||
: "请处理上面的错误后点击“立即重试”;在此之前本地内容不会被覆盖。"}
|
||||
</span>
|
||||
</div>
|
||||
) : localError ? (
|
||||
<div className="canvas-sync-panel__callout">
|
||||
<strong>本地保存没有完成</strong>
|
||||
<span>{progress?.draftError || progress?.localError || "请立即重试;当前编辑内容保持不变。"}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="canvas-sync-panel__actions">
|
||||
{cloudError ? (
|
||||
<>
|
||||
{!missingResources ? (
|
||||
<Button
|
||||
type="primary"
|
||||
block
|
||||
icon={<RefreshCw className="size-3.5" />}
|
||||
loading={busy}
|
||||
onClick={() =>
|
||||
void run(() => retryRemoteUserDataSync(projectId))
|
||||
.then(() => setStatusOpen(false))
|
||||
.catch(() => undefined)
|
||||
}
|
||||
>
|
||||
立即重试云端同步
|
||||
</Button>
|
||||
) : null}
|
||||
{missingResources ? (
|
||||
<Button
|
||||
type="primary"
|
||||
danger
|
||||
block
|
||||
icon={<UploadCloud className="size-3.5" />}
|
||||
loading={busy}
|
||||
onClick={() =>
|
||||
void run(() => overwriteRemoteCanvasProject(projectId))
|
||||
.then(() => setStatusOpen(false))
|
||||
.catch(() => undefined)
|
||||
}
|
||||
>
|
||||
修复并强制覆盖云端
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
) : localError ? (
|
||||
<Button
|
||||
type="primary"
|
||||
block
|
||||
icon={<RefreshCw className="size-3.5" />}
|
||||
loading={busy}
|
||||
onClick={() =>
|
||||
void run(() => flushCanvasStorePersistence())
|
||||
.then(() => setStatusOpen(false))
|
||||
.catch(() => undefined)
|
||||
}
|
||||
>
|
||||
立即重试本地保存
|
||||
</Button>
|
||||
) : null}
|
||||
{conflict ? (
|
||||
<div className="canvas-sync-panel__conflict-actions">
|
||||
<Button block icon={<CloudOff className="size-3.5" />} disabled={busy || saving || localPending || localError} onClick={loadLatest}>
|
||||
加载云端版
|
||||
</Button>
|
||||
<Button type="primary" block icon={<UploadCloud className="size-3.5" />} danger disabled={busy || saving || localPending || localError || Boolean(progress?.draftError)} onClick={overwriteCloud}>
|
||||
用本地版覆盖云端
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button block icon={<CloudOff className="size-3.5" />} disabled={busy || saving || localPending || localError} onClick={loadLatest}>
|
||||
加载云端版
|
||||
</Button>
|
||||
)}
|
||||
<div className="canvas-sync-panel__secondary-actions">
|
||||
{onOpenVersions ? (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
disabled={busy}
|
||||
icon={<FileClock className="size-3.5" />}
|
||||
onClick={() => {
|
||||
setStatusOpen(false);
|
||||
onOpenVersions();
|
||||
}}
|
||||
>
|
||||
版本记录{progress?.draftCount ? ` · ${progress.draftCount} 份草稿` : ""}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
className={`canvas-sync-status-trigger canvas-sync-status-trigger--${tone}`}
|
||||
aria-label={`画布保存状态:${label}`}
|
||||
aria-expanded={statusOpen}
|
||||
icon={saving || localPending ? <LoaderCircle className="size-3.5 animate-spin motion-reduce:animate-none" /> : conflict || cloudError || localError ? <CloudOff className="size-3.5" /> : <CloudCheck className="size-3.5" />}
|
||||
>
|
||||
<span className="canvas-sync-status-label text-xs">{label}</span>
|
||||
</Button>
|
||||
</Popover>
|
||||
<span className={`canvas-sync-status-trigger canvas-sync-status-trigger--${tone} inline-flex size-8 items-center justify-center rounded-full`} aria-label={label} role="status" data-canvas-no-zoom>
|
||||
{showErrorIcon ? <CloudOff className="size-4" /> : saving || cloudError || localError ? <LoaderCircle className={`size-4 ${statusIconClass || ""}`} /> : <CloudCheck className="size-4" />}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
.canvas-version-sidebar {
|
||||
width: 350px;
|
||||
flex: 0 0 350px;
|
||||
min-height: 0;
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
.canvas-editor-shell {
|
||||
container-type: inline-size;
|
||||
}
|
||||
@container (max-width: 1050px) {
|
||||
.canvas-topbar-tools-cluster > :not(:has(.canvas-topbar-version-button)):not(.canvas-topbar-agent-button) {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
@container (max-width: 650px) {
|
||||
.canvas-topbar-project-picker {
|
||||
display: none !important;
|
||||
}
|
||||
.canvas-topbar-project-cluster {
|
||||
max-width: calc(100% - 180px);
|
||||
}
|
||||
.canvas-topbar {
|
||||
gap: 8px;
|
||||
padding-inline: 10px;
|
||||
}
|
||||
.canvas-sync-status-label {
|
||||
display: none;
|
||||
}
|
||||
.canvas-topbar-project-context {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.canvas-version-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--sidebar);
|
||||
color: var(--foreground);
|
||||
font-size: var(--fs-label);
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.canvas-version-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 64px;
|
||||
padding: 0 18px;
|
||||
}
|
||||
.canvas-version-header h2 {
|
||||
flex: 1;
|
||||
font-size: var(--fs-body);
|
||||
font-weight: 600;
|
||||
}
|
||||
.canvas-version-tabs {
|
||||
display: flex;
|
||||
margin: 0 18px 14px;
|
||||
padding: 3px;
|
||||
gap: 3px;
|
||||
background: var(--muted);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
.canvas-version-tabs button {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 9px 4px;
|
||||
color: var(--muted-foreground);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.canvas-version-tabs button[aria-selected="true"] {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
}
|
||||
.canvas-version-count,
|
||||
.canvas-version-tag {
|
||||
font-size: var(--fs-tiny);
|
||||
color: var(--muted-foreground);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.canvas-version-tag {
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--muted);
|
||||
padding: 2px 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.canvas-version-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 0 18px 20px;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.canvas-version-current {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
text-align: left;
|
||||
border: 1px solid var(--border);
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
.canvas-version-current > span:nth-child(2) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.canvas-version-current strong,
|
||||
.canvas-version-current small {
|
||||
display: block;
|
||||
}
|
||||
.canvas-version-dot {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 50%;
|
||||
}
|
||||
.canvas-version-current[aria-pressed="true"],
|
||||
.canvas-version-item[aria-pressed="true"] {
|
||||
background: var(--accent);
|
||||
border-color: var(--control-focus-ring);
|
||||
}
|
||||
.canvas-version-panel small {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--fs-tiny);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.canvas-version-hint {
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--fs-tiny);
|
||||
line-height: 1.8;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.canvas-version-error {
|
||||
color: var(--destructive);
|
||||
line-height: 1.8;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.canvas-version-empty {
|
||||
padding: 35px 0;
|
||||
text-align: center;
|
||||
}
|
||||
.canvas-version-group {
|
||||
margin-top: 15px;
|
||||
}
|
||||
.canvas-version-group summary {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: var(--muted-foreground);
|
||||
padding: 6px 0;
|
||||
}
|
||||
.canvas-version-group summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.canvas-version-group:not([open]) summary > svg {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
.canvas-version-group summary > span:nth-child(2) {
|
||||
flex: 1;
|
||||
}
|
||||
.canvas-version-branches {
|
||||
border-left: 1px solid var(--border);
|
||||
margin: 6px 0 0 7px;
|
||||
padding-left: 16px;
|
||||
}
|
||||
.canvas-version-item {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: block;
|
||||
text-align: left;
|
||||
padding: 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.canvas-version-branches .canvas-version-item::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -21px;
|
||||
top: 19px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border: 1px solid var(--muted-foreground);
|
||||
border-radius: 50%;
|
||||
background: var(--sidebar);
|
||||
}
|
||||
.canvas-version-branches .canvas-version-item[aria-pressed="true"]::before {
|
||||
background: var(--foreground);
|
||||
border-color: var(--foreground);
|
||||
}
|
||||
.canvas-version-item:hover,
|
||||
.canvas-version-current:hover {
|
||||
background: var(--accent);
|
||||
}
|
||||
.canvas-version-item-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.canvas-version-item-title > .canvas-version-tag {
|
||||
margin-left: auto;
|
||||
}
|
||||
.canvas-version-reason {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.canvas-version-draft-note {
|
||||
color: var(--muted-foreground);
|
||||
line-height: 1.8;
|
||||
margin: 0 0 15px;
|
||||
}
|
||||
.canvas-version-draft {
|
||||
border-color: var(--border);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.canvas-version-draft time {
|
||||
display: block;
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--fs-tiny);
|
||||
margin-top: 10px;
|
||||
}
|
||||
.canvas-version-footer {
|
||||
flex-shrink: 0;
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 16px 18px;
|
||||
}
|
||||
.canvas-version-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.canvas-version-panel button:focus-visible,
|
||||
.canvas-version-group summary:focus-visible {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.canvas-version-panel button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
@@ -1,408 +0,0 @@
|
||||
import { AppDrawer } from "@/components/ui/product/app-drawer";
|
||||
import { EmptyState } from "@/components/ui/product/empty-state";
|
||||
import { App, Button, Grid, Spin } from "antd";
|
||||
import { Check, ChevronDown, Cloud, FileClock, History, RefreshCw, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { getCanvasHistoryEntry, listCanvasHistory, type CanvasHistoryEntry } from "@/services/api/user-data";
|
||||
import { readCanvasSyncDrafts, type CanvasSyncDraft } from "@/services/canvas-sync-drafts";
|
||||
import { getActiveUserScope } from "@/lib/user-scope";
|
||||
import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
|
||||
import { useSyncProgressStore } from "@/stores/use-sync-progress-store";
|
||||
import "./canvas-version-history.css";
|
||||
|
||||
export type CanvasVersionPreviewState = {
|
||||
key: string;
|
||||
label: string;
|
||||
date: string;
|
||||
kind: "cloud" | "draft";
|
||||
snapshot?: CanvasHistoryEntry;
|
||||
project?: CanvasProject;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export function useCanvasVersionHistory(projectId: string, onRestore: (snapshotId: string, revision: number) => Promise<void>) {
|
||||
const { message, modal } = App.useApp();
|
||||
const desktop = Boolean(Grid.useBreakpoint().lg);
|
||||
const scope = getActiveUserScope();
|
||||
const contextRef = useRef({ projectId, scope });
|
||||
contextRef.current = { projectId, scope };
|
||||
const isCurrentContext = () => contextRef.current.projectId === projectId && contextRef.current.scope === scope && getActiveUserScope() === scope;
|
||||
const draftCount = useSyncProgressStore((state) => state.syncingProjects[projectId]?.draftCount);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [listOpen, setListOpen] = useState(true);
|
||||
const [tab, setTab] = useState<"cloud" | "draft">("cloud");
|
||||
const [entries, setEntries] = useState<CanvasHistoryEntry[]>([]);
|
||||
const [drafts, setDrafts] = useState<CanvasSyncDraft[]>([]);
|
||||
const [currentRevision, setCurrentRevision] = useState<number>();
|
||||
const [preview, setPreview] = useState<CanvasVersionPreviewState | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [draftLoading, setDraftLoading] = useState(false);
|
||||
const [restoring, setRestoring] = useState(false);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [draftError, setDraftError] = useState("");
|
||||
const [reload, setReload] = useState(0);
|
||||
const previewRequest = useRef<AbortController | null>(null);
|
||||
const returnToCurrent = useCallback(() => {
|
||||
previewRequest.current?.abort();
|
||||
setPreview(null);
|
||||
}, []);
|
||||
const close = useCallback(() => {
|
||||
if (!restoring) {
|
||||
setOpen(false);
|
||||
returnToCurrent();
|
||||
}
|
||||
}, [restoring, returnToCurrent]);
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(false);
|
||||
setTab("cloud");
|
||||
returnToCurrent();
|
||||
}, [projectId, scope, returnToCurrent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setEntries([]);
|
||||
setCurrentRevision(undefined);
|
||||
void listCanvasHistory(projectId, controller.signal)
|
||||
.then((result) => {
|
||||
if (controller.signal.aborted || !isCurrentContext()) return;
|
||||
setEntries(result.snapshots);
|
||||
// Keep the revision observed with this list as the restore precondition.
|
||||
setCurrentRevision(result.currentRevision);
|
||||
})
|
||||
.catch((cause) => {
|
||||
if (!controller.signal.aborted && isCurrentContext()) setError(cause instanceof Error ? cause.message : "历史版本读取失败");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted && isCurrentContext()) setLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [open, projectId, reload, scope]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let active = true;
|
||||
setDraftLoading(true);
|
||||
setDraftError("");
|
||||
setDrafts([]);
|
||||
void readCanvasSyncDrafts(projectId, scope)
|
||||
.then((items) => {
|
||||
if (active && isCurrentContext()) setDrafts(items.sort((a, b) => b.savedAt.localeCompare(a.savedAt)));
|
||||
})
|
||||
.catch(() => {
|
||||
if (active && isCurrentContext()) setDraftError("本地草稿读取失败,请刷新重试");
|
||||
})
|
||||
.finally(() => {
|
||||
if (active && isCurrentContext()) setDraftLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [open, projectId, reload, scope, draftCount]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
previewRequest.current?.abort();
|
||||
},
|
||||
[open, projectId, scope],
|
||||
);
|
||||
|
||||
const selectVersion = (entry: CanvasHistoryEntry) => {
|
||||
previewRequest.current?.abort();
|
||||
const controller = new AbortController();
|
||||
previewRequest.current = controller;
|
||||
const selection: CanvasVersionPreviewState = { key: entry.id, label: `版本 ${entry.revision}`, date: entry.contentUpdatedAt, kind: "cloud", snapshot: entry };
|
||||
setPreview(selection);
|
||||
setListOpen(false);
|
||||
void getCanvasHistoryEntry(projectId, entry.id, controller.signal)
|
||||
.then(({ project }) => {
|
||||
if (!controller.signal.aborted && previewRequest.current === controller && isCurrentContext()) setPreview({ ...selection, project });
|
||||
})
|
||||
.catch((cause) => {
|
||||
if (!controller.signal.aborted && previewRequest.current === controller && isCurrentContext()) setPreview({ ...selection, error: cause instanceof Error ? cause.message : "预览读取失败,请重新选择版本" });
|
||||
});
|
||||
};
|
||||
|
||||
const restore = () => {
|
||||
const selected = preview?.snapshot;
|
||||
if (!selected || !preview.project || currentRevision === undefined || restoring) return;
|
||||
setConfirming(true);
|
||||
modal.confirm({
|
||||
title: `恢复版本 ${selected.revision} 的内容?`,
|
||||
content: "恢复前会备份当前云端内容,并保留本地草稿。恢复后会生成一个新版本,画布的项目归属保持当前设置。",
|
||||
okText: "恢复此版本",
|
||||
cancelText: "取消",
|
||||
afterClose: () => setConfirming(false),
|
||||
onOk: async () => {
|
||||
if (!isCurrentContext()) {
|
||||
message.error("画布或账号已切换,请重新打开版本记录");
|
||||
return;
|
||||
}
|
||||
setRestoring(true);
|
||||
try {
|
||||
await onRestore(selected.id, currentRevision);
|
||||
returnToCurrent();
|
||||
setReload((value) => value + 1);
|
||||
message.success("已恢复内容并保存为新版本");
|
||||
} catch (cause) {
|
||||
const detail = cause instanceof Error ? cause.message : "恢复失败,请重试";
|
||||
setError(detail);
|
||||
message.error(detail);
|
||||
} finally {
|
||||
setRestoring(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
open,
|
||||
listOpen,
|
||||
desktop,
|
||||
tab,
|
||||
entries,
|
||||
drafts,
|
||||
currentRevision,
|
||||
preview,
|
||||
loading,
|
||||
draftLoading,
|
||||
restoring,
|
||||
confirming,
|
||||
error,
|
||||
draftError,
|
||||
show: () => {
|
||||
setOpen(true);
|
||||
setListOpen(true);
|
||||
},
|
||||
toggle: () => {
|
||||
if (open && (desktop || listOpen)) close();
|
||||
else {
|
||||
setOpen(true);
|
||||
setListOpen(true);
|
||||
}
|
||||
},
|
||||
hideList: () => {
|
||||
if (!restoring) {
|
||||
setListOpen(false);
|
||||
if (!preview) setOpen(false);
|
||||
}
|
||||
},
|
||||
close,
|
||||
changeTab: (next: "cloud" | "draft") => {
|
||||
setTab(next);
|
||||
returnToCurrent();
|
||||
},
|
||||
refresh: () => {
|
||||
returnToCurrent();
|
||||
setReload((value) => value + 1);
|
||||
},
|
||||
selectVersion,
|
||||
selectDraft: (draft: CanvasSyncDraft) => {
|
||||
previewRequest.current?.abort();
|
||||
setPreview({ key: draft.id, label: "本地草稿", date: draft.savedAt, kind: "draft", project: draft.project });
|
||||
setListOpen(false);
|
||||
},
|
||||
returnToCurrent,
|
||||
restore,
|
||||
};
|
||||
}
|
||||
|
||||
export type CanvasVersionHistoryController = ReturnType<typeof useCanvasVersionHistory>;
|
||||
|
||||
export function CanvasVersionHistory({ history }: { history: CanvasVersionHistoryController }) {
|
||||
const { open, tab, entries, drafts, currentRevision, preview, loading, draftLoading, restoring, error, draftError } = history;
|
||||
if (!open) return null;
|
||||
const groups = new Map<string, CanvasHistoryEntry[]>();
|
||||
for (const entry of entries) {
|
||||
const day = new Date(entry.contentUpdatedAt).toLocaleDateString("zh-CN", { year: "numeric", month: "long", day: "numeric" });
|
||||
groups.set(day, [...(groups.get(day) || []), entry]);
|
||||
}
|
||||
const content = (
|
||||
<div className="canvas-version-panel" data-canvas-no-zoom data-canvas-wheel-scroll>
|
||||
<header className="canvas-version-header">
|
||||
<History size={17} />
|
||||
<h2>版本记录</h2>
|
||||
<Button type="text" size="small" aria-label="刷新版本记录" icon={<RefreshCw size={15} />} disabled={restoring || loading} onClick={history.refresh} />
|
||||
<Button type="text" size="small" aria-label="关闭版本记录" icon={<X size={17} />} disabled={restoring} onClick={history.close} />
|
||||
</header>
|
||||
<div
|
||||
className="canvas-version-tabs"
|
||||
role="tablist"
|
||||
aria-label="版本来源"
|
||||
onKeyDown={(event) => {
|
||||
if (restoring || !["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const next = event.key === "Home" ? "cloud" : event.key === "End" ? "draft" : tab === "cloud" ? "draft" : "cloud";
|
||||
history.changeTab(next);
|
||||
event.currentTarget.querySelector<HTMLButtonElement>(`[data-tab="${next}"]`)?.focus();
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
id="canvas-cloud-tab"
|
||||
role="tab"
|
||||
data-tab="cloud"
|
||||
aria-selected={tab === "cloud"}
|
||||
aria-controls="canvas-version-list"
|
||||
tabIndex={tab === "cloud" ? 0 : -1}
|
||||
disabled={restoring}
|
||||
onClick={() => history.changeTab("cloud")}
|
||||
>
|
||||
<Cloud size={14} />
|
||||
云端历史
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="canvas-draft-tab"
|
||||
role="tab"
|
||||
data-tab="draft"
|
||||
aria-selected={tab === "draft"}
|
||||
aria-controls="canvas-version-list"
|
||||
tabIndex={tab === "draft" ? 0 : -1}
|
||||
disabled={restoring}
|
||||
onClick={() => history.changeTab("draft")}
|
||||
>
|
||||
<FileClock size={14} />
|
||||
本地草稿 <span className="canvas-version-count">{drafts.length}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="canvas-version-list" role="tabpanel" aria-labelledby={tab === "cloud" ? "canvas-cloud-tab" : "canvas-draft-tab"} className="canvas-version-list">
|
||||
{tab === "cloud" ? (
|
||||
<>
|
||||
<button type="button" className="canvas-version-current" aria-pressed={!preview} disabled={restoring} onClick={history.returnToCurrent}>
|
||||
<span className="canvas-version-dot">
|
||||
<Check size={12} />
|
||||
</span>
|
||||
<span>
|
||||
<strong>当前画布</strong>
|
||||
<small>{currentRevision === undefined ? "云端版本待确认" : `打开记录时云端为 v${currentRevision}`}</small>
|
||||
</span>
|
||||
{!preview ? <span className="canvas-version-tag">当前</span> : null}
|
||||
</button>
|
||||
<p className="canvas-version-hint">查看旧版不会修改当前画布</p>
|
||||
{error ? (
|
||||
<p role="alert" className="canvas-version-error">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
{loading ? (
|
||||
<div className="canvas-version-empty">
|
||||
<Spin size="small" />
|
||||
</div>
|
||||
) : !entries.length && !error ? (
|
||||
<EmptyState size="compact" description="暂无历史版本,后续保存时会自动保留" />
|
||||
) : null}
|
||||
{[...groups].map(([day, items]) => (
|
||||
<details className="canvas-version-group" key={day} open>
|
||||
<summary>
|
||||
<ChevronDown size={14} />
|
||||
<span>{day}</span>
|
||||
<span className="canvas-version-count">{items.length}</span>
|
||||
</summary>
|
||||
<div className="canvas-version-branches">
|
||||
{items.map((entry) => (
|
||||
<button type="button" key={entry.id} className="canvas-version-item" disabled={restoring} aria-pressed={preview?.key === entry.id} onClick={() => history.selectVersion(entry)}>
|
||||
<span className="canvas-version-item-title">
|
||||
<time dateTime={entry.contentUpdatedAt}>{formatTime(entry.contentUpdatedAt)}</time>
|
||||
<span className="canvas-version-tag">v{entry.revision}</span>
|
||||
</span>
|
||||
<span className="canvas-version-reason">{entry.reason === "before_restore" ? "恢复前备份" : "自动保存"}</span>
|
||||
<small>
|
||||
{entry.nodeCount} 个节点 · {entry.connectionCount} 条连线
|
||||
</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="canvas-version-draft-note">发生冲突或恢复版本时,系统自动保留的本地内容,可在此查看。</p>
|
||||
{draftError ? (
|
||||
<p role="alert" className="canvas-version-error">
|
||||
{draftError}
|
||||
</p>
|
||||
) : null}
|
||||
{draftLoading ? (
|
||||
<div className="canvas-version-empty">
|
||||
<Spin size="small" />
|
||||
</div>
|
||||
) : !drafts.length && !draftError ? (
|
||||
<EmptyState size="compact" description="暂无本地草稿" />
|
||||
) : null}
|
||||
{drafts.map((draft) => (
|
||||
<button type="button" key={draft.id} className="canvas-version-item canvas-version-draft" disabled={restoring} aria-pressed={preview?.key === draft.id} onClick={() => history.selectDraft(draft)}>
|
||||
<span className="canvas-version-item-title">
|
||||
<FileClock size={15} />
|
||||
<strong>本机备份</strong>
|
||||
{draft.project.revision !== undefined ? <span className="canvas-version-tag">基于 v{draft.project.revision}</span> : null}
|
||||
</span>
|
||||
<time dateTime={draft.savedAt}>{new Date(draft.savedAt).toLocaleString("zh-CN")}</time>
|
||||
<small>
|
||||
{draft.project.nodes.length} 个节点 · {draft.project.connections.length} 条连线
|
||||
</small>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<footer className="canvas-version-footer">
|
||||
{preview ? (
|
||||
<>
|
||||
<div className="canvas-version-item-title">
|
||||
<strong>{preview.label}</strong>
|
||||
<span className="canvas-version-tag">只读预览</span>
|
||||
</div>
|
||||
<small>{new Date(preview.date).toLocaleString("zh-CN")}</small>
|
||||
{preview.project ? (
|
||||
<small>
|
||||
{preview.project.nodes.length} 个节点 · {preview.project.connections.length} 条连线
|
||||
</small>
|
||||
) : null}
|
||||
{preview.project?.nodes.some((node) => node.type === "drawing") ? <p className="canvas-version-hint">绘图仅保留已上传的预览,不含本机历史笔画。</p> : null}
|
||||
{preview.kind === "cloud" ? (
|
||||
<div className="canvas-version-actions">
|
||||
<Button block type="primary" loading={restoring} disabled={!preview.project || currentRevision === undefined || loading} onClick={history.restore}>
|
||||
恢复此版本
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<p className="canvas-version-hint">{tab === "cloud" ? "内容变化时最多每 5 分钟保留一份,保留最近 20 份。恢复前额外备份。" : "草稿保存在此浏览器中,查看草稿不会修改当前画布。"}</p>
|
||||
)}
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
|
||||
return history.desktop ? (
|
||||
<aside className="canvas-version-sidebar" aria-label="版本记录">
|
||||
{content}
|
||||
</aside>
|
||||
) : (
|
||||
<AppDrawer
|
||||
flush
|
||||
open={history.listOpen}
|
||||
focusable={{ trap: !history.confirming }}
|
||||
placement="right"
|
||||
title={null}
|
||||
closable={false}
|
||||
onClose={history.hideList}
|
||||
maskClosable={!restoring}
|
||||
keyboard={!restoring}
|
||||
size="min(350px, 92vw)"
|
||||
aria-label="版本记录"
|
||||
>
|
||||
{content}
|
||||
</AppDrawer>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(date: string) {
|
||||
return new Date(date).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false });
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
import { EmptyState } from "@/components/ui/product/empty-state";
|
||||
import { Button, Spin } from "antd";
|
||||
import { ArrowLeft, Eye, History, Maximize, Minus, Plus } from "lucide-react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { CanvasViewport } from "@/components/canvas/canvas-viewport";
|
||||
import { CanvasNode } from "@/components/canvas/canvas-node";
|
||||
import { CanvasFrameNode } from "@/components/canvas/canvas-frame-node";
|
||||
import { ConnectionPath } from "@/components/canvas/canvas-connections";
|
||||
import { CanvasNodeActionContext } from "@/components/canvas/canvas-node-action-context";
|
||||
import { CanvasNodeGraphContext } from "@/components/canvas/canvas-node-graph-context";
|
||||
import { isFrameNode, isNodeHiddenByCollapsedFrame, resolveFrameConnection } from "@/lib/canvas/canvas-frame";
|
||||
import { getCanvasNodesBounds, viewportAtScale, viewportForBounds } from "@/lib/canvas/canvas-viewport";
|
||||
import { resolveCanvasAppearance } from "@/lib/canvas/canvas-appearance";
|
||||
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
|
||||
import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
import { resolveMediaUrl } from "@/services/file-storage";
|
||||
import type { CanvasNodeData, ViewportTransform } from "@/types/canvas";
|
||||
import type { CanvasVersionPreviewState } from "./canvas-version-history";
|
||||
|
||||
const noAction = () => undefined;
|
||||
const readOnlyActions = {};
|
||||
const previewNodeId = (id: string) => `version-preview:${id}`;
|
||||
|
||||
export function CanvasVersionPreview({ preview, onReturn, onShowVersions }: { preview: CanvasVersionPreviewState; onReturn: () => void; onShowVersions: () => void }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [viewport, setViewport] = useState<ViewportTransform>({ x: 0, y: 0, k: 1 });
|
||||
const [selectedId, setSelectedId] = useState<string>();
|
||||
const [localMedia, setLocalMedia] = useState<Record<string, string>>({});
|
||||
const [mediaError, setMediaError] = useState("");
|
||||
const theme = useActiveTheme();
|
||||
const project = preview.project;
|
||||
useEffect(() => {
|
||||
const node = project?.nodes.find((item) => item.id === selectedId);
|
||||
const key = node?.metadata?.storageKey;
|
||||
if (!node || !key || resourceIdFromStorageKey(key) || !["video", "audio"].includes(node.type)) return;
|
||||
let active = true;
|
||||
setMediaError("");
|
||||
void resolveMediaUrl(key, node.metadata?.content)
|
||||
.then((url) => {
|
||||
if (active) setLocalMedia((current) => ({ ...current, [node.id]: url }));
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setMediaError("媒体暂时无法读取,请重新打开此版本重试");
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [project, selectedId]);
|
||||
const nodes = useMemo(
|
||||
() =>
|
||||
(project?.nodes || []).map((node) => {
|
||||
const resourceId = resourceIdFromStorageKey(node.metadata?.storageKey);
|
||||
return resourceId && ["image", "video", "audio"].includes(node.type)
|
||||
? { ...node, metadata: { ...node.metadata, content: resourceFileUrl(resourceId) } }
|
||||
: localMedia[node.id]
|
||||
? { ...node, metadata: { ...node.metadata, content: localMedia[node.id] } }
|
||||
: node;
|
||||
}),
|
||||
[project, localMedia],
|
||||
);
|
||||
const visibleNodes = useMemo(() => nodes.filter((node) => !isNodeHiddenByCollapsedFrame(node, nodes)), [nodes]);
|
||||
const connections = useMemo(
|
||||
() =>
|
||||
(project?.connections || []).flatMap((connection) => {
|
||||
const resolved = resolveFrameConnection(connection, nodes);
|
||||
return resolved ? [{ connection, ...resolved }] : [];
|
||||
}),
|
||||
[project, nodes],
|
||||
);
|
||||
const graph = useMemo(() => {
|
||||
const byId = new Map(nodes.map((node) => [node.id, node]));
|
||||
return {
|
||||
getUpstreamNodes: (id: string) =>
|
||||
(project?.connections || [])
|
||||
.filter((connection) => previewNodeId(connection.toNodeId) === id)
|
||||
.flatMap((connection) => {
|
||||
const node = byId.get(connection.fromNodeId);
|
||||
return node ? [node] : [];
|
||||
}),
|
||||
};
|
||||
}, [nodes, project]);
|
||||
const fit = useCallback(() => {
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
const sourceNodes = project?.nodes || [];
|
||||
const bounds = getCanvasNodesBounds(sourceNodes.filter((node) => !isNodeHiddenByCollapsedFrame(node, sourceNodes)));
|
||||
if (rect && bounds) setViewport(viewportForBounds(bounds, rect, { padding: 72 }));
|
||||
}, [project]);
|
||||
useLayoutEffect(() => {
|
||||
fit();
|
||||
}, [fit]);
|
||||
const zoom = (scale: number) => {
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (rect) setViewport((current) => viewportAtScale(current, rect, scale));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex min-h-0 flex-col" data-canvas-version-preview style={{ background: resolveCanvasAppearance(project?.appearance, theme).background }}>
|
||||
<header data-canvas-no-zoom className="grid shrink-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 border-b border-border bg-sidebar px-5 py-3 text-foreground sm:flex">
|
||||
<Eye size={16} className="text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<strong className="text-sm">{preview.label} · 只读预览</strong>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{new Date(preview.date).toLocaleString("zh-CN")}</p>
|
||||
</div>
|
||||
<Button type="text" className="lg:!hidden" icon={<History size={15} />} onClick={onShowVersions}>
|
||||
版本
|
||||
</Button>
|
||||
<Button className="col-span-3" icon={<ArrowLeft size={15} />} onClick={onReturn}>
|
||||
返回当前画布
|
||||
</Button>
|
||||
</header>
|
||||
{preview.error ? (
|
||||
<div role="alert" className="grid flex-1 place-content-center gap-4 p-6 text-center text-sm text-destructive">
|
||||
<p>{preview.error}</p>
|
||||
<Button onClick={onShowVersions}>重新选择版本</Button>
|
||||
</div>
|
||||
) : !project ? (
|
||||
<div className="grid flex-1 place-items-center">
|
||||
<Spin tip="正在读取版本内容">
|
||||
<div className="p-10" />
|
||||
</Spin>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative min-h-0 flex-1">
|
||||
{/* No editor callbacks, drawing cache key, or action context may cross this boundary. */}
|
||||
<CanvasNodeActionContext.Provider value={readOnlyActions}>
|
||||
<CanvasNodeGraphContext.Provider value={graph}>
|
||||
<CanvasViewport
|
||||
containerRef={containerRef}
|
||||
viewport={viewport}
|
||||
appearance={project.appearance}
|
||||
backgroundMode={project.backgroundMode}
|
||||
onViewportChange={setViewport}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
onDrop={(event) => event.preventDefault()}
|
||||
onCanvasDeselect={() => setSelectedId(undefined)}
|
||||
>
|
||||
<svg className="pointer-events-none absolute overflow-visible" width={1} height={1}>
|
||||
{connections.map(({ connection, from, to }) => (
|
||||
<ConnectionPath key={connection.id} connection={{ ...connection, id: previewNodeId(connection.id) }} from={from} to={to} active={false} onSelect={noAction} />
|
||||
))}
|
||||
</svg>
|
||||
{visibleNodes.map((node) =>
|
||||
isFrameNode(node) ? (
|
||||
<CanvasFrameNode
|
||||
key={node.id}
|
||||
data={{ ...node, id: previewNodeId(node.id) }}
|
||||
childNodes={nodes.filter((child) => child.parentId === node.id)}
|
||||
scale={viewport.k}
|
||||
isSelected={false}
|
||||
isDropTarget={false}
|
||||
readOnly
|
||||
onMouseDown={noAction}
|
||||
onResize={noAction}
|
||||
onToggleCollapsed={noAction}
|
||||
onFolderStyleChange={noAction}
|
||||
onTitleChange={noAction}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
/>
|
||||
) : (
|
||||
<CanvasNode
|
||||
key={node.id}
|
||||
data={{ ...node, id: previewNodeId(node.id) }}
|
||||
scale={viewport.k}
|
||||
readOnly
|
||||
isSelected={selectedId === node.id}
|
||||
mediaActive={selectedId === node.id}
|
||||
isRelated={false}
|
||||
isFocusRelated={false}
|
||||
isConnectionTarget={false}
|
||||
showImageInfo={false}
|
||||
reduceMediaEffects
|
||||
renderNodeContent={renderReadOnlyContent}
|
||||
onMouseDown={() => setSelectedId(node.id)}
|
||||
onHoverStart={noAction}
|
||||
onHoverEnd={noAction}
|
||||
onConnectStart={noAction}
|
||||
onResize={noAction}
|
||||
onContentChange={noAction}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</CanvasViewport>
|
||||
</CanvasNodeGraphContext.Provider>
|
||||
</CanvasNodeActionContext.Provider>
|
||||
{!nodes.length ? (
|
||||
<div className="pointer-events-none absolute inset-0 grid place-items-center">
|
||||
<EmptyState description="此版本没有节点" />
|
||||
</div>
|
||||
) : null}
|
||||
{mediaError ? (
|
||||
<p role="alert" className="absolute left-5 top-3 rounded-md bg-sidebar p-2 text-xs text-destructive">
|
||||
{mediaError}
|
||||
</p>
|
||||
) : null}
|
||||
<div data-canvas-no-zoom className="absolute bottom-5 left-5 flex items-center gap-1 rounded-lg border border-border bg-sidebar p-1 text-foreground shadow-sm">
|
||||
<Button type="text" aria-label="缩小预览" icon={<Minus size={15} />} onClick={() => zoom(viewport.k / 1.2)} />
|
||||
<span className="w-12 text-center text-xs tabular-nums" aria-label="预览缩放比例">
|
||||
{Math.round(viewport.k * 100)}%
|
||||
</span>
|
||||
<Button type="text" aria-label="放大预览" icon={<Plus size={15} />} onClick={() => zoom(viewport.k * 1.2)} />
|
||||
<Button type="text" aria-label="适应预览内容" icon={<Maximize size={15} />} onClick={fit} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderReadOnlyContent(node: CanvasNodeData) {
|
||||
const rows = node.metadata?.storyboard?.rows;
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden rounded-lg bg-card text-card-foreground">
|
||||
<strong className="shrink-0 border-b border-border px-4 py-3 text-sm">{node.title}</strong>
|
||||
<div data-canvas-wheel-scroll className="min-h-0 flex-1 overflow-auto whitespace-pre-wrap break-words p-4 text-sm leading-6">
|
||||
{rows?.length
|
||||
? rows.map((row) => (
|
||||
<div key={row.id} className="mb-3 border-b border-border pb-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
镜头 {row.shotNumber} · {row.durationSeconds}s
|
||||
</p>
|
||||
<p>{row.plotDescription}</p>
|
||||
<p>{row.dialogue}</p>
|
||||
</div>
|
||||
))
|
||||
: node.metadata?.content || node.metadata?.composerContent || node.metadata?.prompt || "此节点没有文本内容"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,11 +9,10 @@ import { removeCanvasDrawing } from "@/lib/canvas/canvas-drawing-storage";
|
||||
import { normalizeCanvasNodeTimestamps } from "@/lib/canvas/canvas-node-timestamps";
|
||||
import { hydrateAssistantImages, resetInterruptedGeneration } from "@/lib/canvas/canvas-project-generation";
|
||||
import { listAddedSkills, type Skill } from "@/services/api/skills";
|
||||
import { createCanvasProjectWithRemoteSync, deleteCanvasProjectsWithRemoteSync, forceOverwriteRemoteCanvasSync, loadCanvasProjectForEditing, saveRemoteUserDataNow, subscribeAgentCanvasRefresh } from "@/services/user-data-sync";
|
||||
import { createCanvasProjectWithRemoteSync, deleteCanvasProjectsWithRemoteSync, loadCanvasProjectForEditing, saveRemoteUserDataNow, subscribeAgentCanvasRefresh } from "@/services/user-data-sync";
|
||||
import { flushCanvasStorePersistence, useCanvasStore, type CanvasProject } from "@/stores/canvas/use-canvas-store";
|
||||
import { useCanvasThemeStore } from "@/stores/canvas/use-canvas-theme-store";
|
||||
import { useSyncProgressStore } from "@/stores/use-sync-progress-store";
|
||||
import { readCanvasSyncDrafts } from "@/services/canvas-sync-drafts";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import type { CanvasAssistantSession, CanvasConnection, CanvasNodeData, ViewportTransform } from "@/types/canvas";
|
||||
import type { CanvasHistorySnapshot } from "./use-canvas-history";
|
||||
@@ -94,9 +93,6 @@ export function useCanvasProjectLifecycle({
|
||||
const viewportSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const observedContentRef = useRef<CanvasHistorySnapshot | null>(null);
|
||||
const loadLatestRef = useRef(false);
|
||||
const historyRestoreRef = useRef<{ snapshotId: string; revision: number; resolve: () => void; reject: (error: unknown) => void } | null>(null);
|
||||
const pendingReloadRef = useRef<{ promise: Promise<void>; resolve: () => void; reject: (error: unknown) => void } | null>(null);
|
||||
const editorReadyRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -105,10 +101,7 @@ export function useCanvasProjectLifecycle({
|
||||
// Keep load intent on the refs until this attempt finishes. React Strict
|
||||
// Mode remounts the effect; consuming the flags here would turn "load
|
||||
// latest" into a normal open and immediately recreate the conflict.
|
||||
const latest = loadLatestRef.current;
|
||||
const historyRestore = historyRestoreRef.current;
|
||||
const pendingReload = pendingReloadRef.current;
|
||||
const keepEditor = editorReadyRef.current && (latest || Boolean(historyRestore));
|
||||
const keepEditor = editorReadyRef.current;
|
||||
if (!keepEditor) {
|
||||
editorReadyRef.current = false;
|
||||
setProjectLoaded(false);
|
||||
@@ -156,16 +149,12 @@ export function useCanvasProjectLifecycle({
|
||||
|
||||
const load = async () => {
|
||||
const cachedProject = useCanvasStore.getState().projects.find((p) => p.id === projectId);
|
||||
if (!latest && !historyRestore && cachedProject && cachedProject.nodes?.length) {
|
||||
if (cachedProject && cachedProject.nodes?.length) {
|
||||
// 本地已有该画布的持久化缓存:先以本地数据秒开渲染,彻底消除白屏与等待
|
||||
applyRestoredProject(cachedProject);
|
||||
}
|
||||
const loadedProject = await loadCanvasProjectForEditing(projectId, { latest, historyRestore: historyRestore || undefined, onLoad: applyRestoredProject });
|
||||
const loadedProject = await loadCanvasProjectForEditing(projectId, { onLoad: applyRestoredProject });
|
||||
if (cancelled) return;
|
||||
if (historyRestoreRef.current === historyRestore) {
|
||||
historyRestoreRef.current = null;
|
||||
historyRestore?.resolve();
|
||||
}
|
||||
if (!loadedProject) {
|
||||
if (!cachedProject) navigate("/canvas", { replace: true });
|
||||
return;
|
||||
@@ -188,24 +177,10 @@ export function useCanvasProjectLifecycle({
|
||||
void load()
|
||||
.then(() => {
|
||||
if (cancelled) return;
|
||||
loadLatestRef.current = false;
|
||||
if (pendingReloadRef.current === pendingReload) {
|
||||
pendingReloadRef.current = null;
|
||||
pendingReload?.resolve();
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) return;
|
||||
loadLatestRef.current = false;
|
||||
if (historyRestoreRef.current === historyRestore) {
|
||||
historyRestoreRef.current = null;
|
||||
historyRestore?.reject(error);
|
||||
}
|
||||
if (pendingReloadRef.current === pendingReload) {
|
||||
pendingReloadRef.current = null;
|
||||
pendingReload?.reject(error);
|
||||
}
|
||||
if (useSyncProgressStore.getState().syncingProjects[projectId]?.phase !== "conflict") useSyncProgressStore.getState().setProjectProgress(projectId, { phase: "error", message: error instanceof Error ? error.message : "读取云端版本失败" });
|
||||
useSyncProgressStore.getState().setProjectProgress(projectId, { phase: "error", message: error instanceof Error ? error.message : "读取云端版本失败" });
|
||||
const detail = error instanceof Error ? error.message : "读取画布失败,请重试";
|
||||
if (keepEditor) message.error(detail);
|
||||
else setLoadError(detail);
|
||||
@@ -289,11 +264,8 @@ export function useCanvasProjectLifecycle({
|
||||
}, [message, navigate]);
|
||||
|
||||
const deleteCurrentProject = useCallback(async () => {
|
||||
let drawingIds = nodesRef.current.flatMap((node) => node.type === "drawing" && node.metadata?.drawingId ? [node.metadata.drawingId] : []);
|
||||
const drawingIds = nodesRef.current.flatMap((node) => node.type === "drawing" && node.metadata?.drawingId ? [node.metadata.drawingId] : []);
|
||||
try {
|
||||
const drafts = await readCanvasSyncDrafts(projectId);
|
||||
const preserved = new Set(drafts.flatMap((draft) => draft.project.nodes.flatMap((node) => node.metadata?.drawingId ? [node.metadata.drawingId] : [])));
|
||||
drawingIds = drawingIds.filter((id) => !preserved.has(id));
|
||||
await deleteCanvasProjectsWithRemoteSync([projectId]);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? `删除画布失败:${error.message}` : "删除画布失败,请稍后重试");
|
||||
@@ -335,41 +307,6 @@ export function useCanvasProjectLifecycle({
|
||||
if (options.flush !== false) await flushCanvasStorePersistence();
|
||||
}, [activeChatId, backgroundMode, canvasAppearance, chatSessions, connectionsRef, nodesRef, projectId, showImageInfo, updateProject, viewportRef]);
|
||||
|
||||
const reloadLatestCanvasProject = useCallback(async () => {
|
||||
const pending = pendingReloadRef.current;
|
||||
if (pending) return pending.promise;
|
||||
|
||||
// The load path writes a durable draft before replacing the editor. Waiting
|
||||
// for the ordinary canvas queue here makes a viewport drag able to starve
|
||||
// the cloud read, so only bring the latest editor snapshot into memory.
|
||||
useSyncProgressStore.getState().setProjectProgress(projectId, { phase: "reconciling", message: "正在加载云端最新版本" });
|
||||
try {
|
||||
await persistLocalEdits({ flush: false });
|
||||
} catch (error) {
|
||||
useSyncProgressStore.getState().setProjectProgress(projectId, { phase: "error", message: error instanceof Error ? error.message : "准备加载云端版本失败" });
|
||||
throw error;
|
||||
}
|
||||
let resolve!: () => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<void>((done, fail) => {
|
||||
resolve = done;
|
||||
reject = fail;
|
||||
});
|
||||
pendingReloadRef.current = { promise, resolve, reject };
|
||||
loadLatestRef.current = true;
|
||||
setLoadAttempt((value) => value + 1);
|
||||
return promise;
|
||||
}, [persistLocalEdits, projectId]);
|
||||
|
||||
const restoreCanvasProjectVersion = useCallback(async (snapshotId: string, revision: number) => {
|
||||
await persistLocalEdits();
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
historyRestoreRef.current?.reject(new Error("已有新的恢复请求"));
|
||||
historyRestoreRef.current = { snapshotId, revision, resolve, reject };
|
||||
setLoadAttempt((value) => value + 1);
|
||||
});
|
||||
}, [persistLocalEdits]);
|
||||
|
||||
const saveCanvasProject = useCallback(async (options: { requireRemote?: boolean } = {}): Promise<boolean> => {
|
||||
try {
|
||||
await persistLocalEdits();
|
||||
@@ -390,18 +327,6 @@ export function useCanvasProjectLifecycle({
|
||||
return true;
|
||||
}, [message, persistLocalEdits, projectId]);
|
||||
|
||||
const forceSaveCanvasProject = useCallback(async (): Promise<boolean> => {
|
||||
try { await persistLocalEdits(); } catch { message.error("本地保存失败,请重试"); return false; }
|
||||
try {
|
||||
const result = await forceOverwriteRemoteCanvasSync(projectId);
|
||||
message.success(result.clearedResources > 0 ? `已强制保存,移除了 ${result.clearedResources} 个失效素材引用;本地草稿已保留` : result.reboundNodes > 0 ? `已保存,并修复 ${result.reboundNodes} 处媒体与素材的绑定` : "素材关联已核对,画布已保存");
|
||||
} catch (error) {
|
||||
message.error(`修复并保存失败:${error instanceof Error ? error.message : "未知错误"}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}, [message, persistLocalEdits]);
|
||||
|
||||
const clearCanvasFiles = useCallback(() => {
|
||||
cleanupCanvasFiles({ projectId, nodes: [], chatSessions: [] });
|
||||
}, [cleanupCanvasFiles, projectId]);
|
||||
@@ -416,10 +341,7 @@ export function useCanvasProjectLifecycle({
|
||||
currentProject,
|
||||
deleteCurrentProject,
|
||||
renameCurrentProject,
|
||||
reloadLatestCanvasProject,
|
||||
restoreCanvasProjectVersion,
|
||||
saveCanvasProject,
|
||||
forceSaveCanvasProject,
|
||||
updateProject,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user