fix(canvas): 画布版本 - 合并 PR #528 防止旧内容覆盖并支持历史恢复

将画布 revision CAS 与历史快照接到 schema v23,保留当前顶栏积分弹窗和已有菜单入口。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ddCat
2026-09-18 10:40:23 +08:00
69 changed files with 3571 additions and 293 deletions

View File

@@ -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, Home, LayoutGrid, LoaderCircle, Menu, Pencil, Plus, Redo2, Save, Search, Share2, Trash2, Undo2, Upload } from "lucide-react";
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 { Button, Dropdown, Tooltip } from "antd";
import { WorkspaceCreditGiftMark } from "@/components/layout/workspace-credit-gift-mark";
@@ -16,6 +16,9 @@ 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;
@@ -45,6 +48,9 @@ type CanvasTopBarProps = {
};
export function CanvasTopBar({
syncStatus,
versionsOpen,
onToggleVersions,
title,
titleDraft,
isTitleEditing,
@@ -112,7 +118,7 @@ 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 },
{ 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 },
@@ -187,9 +193,10 @@ export function CanvasTopBar({
</div>
) : null}
</div>
{syncStatus}
</div>
<div className="canvas-topbar-cluster pointer-events-auto flex items-center gap-1.5" style={dockStyle}>
<div className="canvas-topbar-cluster canvas-topbar-tools-cluster pointer-events-auto flex items-center gap-1.5" style={dockStyle}>
<CanvasTopBarTooltip label="搜索画布节点">
<Button
type="text"
@@ -265,6 +272,19 @@ 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>

View File

@@ -0,0 +1,137 @@
import { App, Button, Dropdown, Popover } from "antd";
import { CloudCheck, CloudOff, LoaderCircle } from "lucide-react";
import { useState } from "react";
import { exportCanvasProjects } from "@/lib/canvas/canvas-export";
import { readAllCanvasSyncDrafts, readCanvasSyncDrafts, type CanvasSyncDraft } from "@/services/canvas-sync-drafts";
import { getActiveUserScope } from "@/lib/user-scope";
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { useSyncProgressStore } from "@/stores/use-sync-progress-store";
export function CanvasSyncStatus({ projectId, onLoadLatest, onOpenVersions }: { projectId: string; onLoadLatest: () => Promise<void>; onOpenVersions?: () => void }) {
const { message } = App.useApp();
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 failed = phase === "error" || conflict || !phase;
const saving = phase === "pending" || phase === "saving" || phase === "uploading";
const label = conflict ? "版本冲突 · 未同步" : !phase ? "尚未同步" : failed ? "云端未保存" : saving ? "正在保存" : "已保存";
const run = async (operation: () => Promise<unknown>) => {
setBusy(true);
try {
await operation();
} catch (error) {
message.error(error instanceof Error ? error.message : "操作失败,请重试");
} finally {
setBusy(false);
}
};
return (
<>
<Popover
trigger="click"
placement="bottom"
open={statusOpen}
onOpenChange={setStatusOpen}
content={
<div className="max-w-80 space-y-3" data-canvas-no-zoom>
<p role="status" className="text-sm">
{progress?.message || (phase === "done" ? "画布已同步到云端" : "尚未确认云端保存,请保留本地内容")}
</p>
{conflict ? <p className="text-xs text-muted-foreground">此画布的自动提交已暂停。加载最新版前会保留本地草稿,可下载后从画布列表导入为副本。</p> : null}
<div className="flex flex-wrap gap-2">
<Button size="small" loading={busy} onClick={() => void run(onLoadLatest)}>
加载云端最新版本
</Button>
<Button
size="small"
disabled={busy}
onClick={() =>
void run(async () => {
const project = useCanvasStore.getState().openProject(projectId);
if (project) await exportCanvasProjects([project], `${project.title}-本地草稿`);
})
}
>
下载当前内容
</Button>
{onOpenVersions ? (
<Button
size="small"
onClick={() => {
setStatusOpen(false);
onOpenVersions();
}}
>
版本记录{progress?.draftCount ? ` · ${progress.draftCount} 份草稿` : ""}
</Button>
) : null}
</div>
</div>
}
>
<Button
type="text"
size="small"
danger={failed}
aria-label={`画布保存状态:${label}`}
icon={saving ? <LoaderCircle className="size-3.5 animate-spin motion-reduce:animate-none" /> : failed ? <CloudOff className="size-3.5" /> : <CloudCheck className="size-3.5" />}
>
<span className="canvas-sync-status-label text-xs">{label}</span>
</Button>
</Popover>
</>
);
}
export function CanvasSyncDraftMenu({ projectId }: { projectId?: string }) {
const { message } = App.useApp();
const [drafts, setDrafts] = useState<CanvasSyncDraft[]>([]);
const [draftScope, setDraftScope] = useState("");
const [loading, setLoading] = useState(false);
const [exporting, setExporting] = useState(false);
return (
<Dropdown
trigger={["click"]}
onOpenChange={(open) => {
if (!open) return;
const scope = getActiveUserScope();
setDraftScope(scope);
setDrafts([]);
setLoading(true);
void (projectId ? readCanvasSyncDrafts(projectId, scope) : readAllCanvasSyncDrafts(scope))
.then((items) => {
if (getActiveUserScope() === scope) setDrafts(items.reverse());
})
.catch(() => message.error("读取本地草稿失败"))
.finally(() => setLoading(false));
}}
menu={{
items: drafts.length
? drafts.map((draft) => ({
key: draft.id,
label: `${draft.project.title} · ${new Date(draft.savedAt).toLocaleString()}`,
onClick: () => {
if (getActiveUserScope() !== draftScope) {
setDrafts([]);
message.error("账号已切换,请重新打开本地草稿");
return;
}
setExporting(true);
void exportCanvasProjects([draft.project], `${draft.project.title}-本地草稿`, { includeLocalDrawings: false })
.then(() => message.success("草稿已下载,可从画布列表导入为新画布"))
.catch(() => message.error("草稿下载失败,请重试"))
.finally(() => setExporting(false));
},
}))
: [{ key: "empty", label: loading ? "正在读取草稿…" : "暂无本地草稿", disabled: true }],
}}
>
<Button size={projectId ? "small" : "middle"} loading={exporting}>
本地草稿
</Button>
</Dropdown>
);
}

View File

@@ -0,0 +1,250 @@
.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;
}

View File

@@ -0,0 +1,429 @@
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, Download, 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 { exportCanvasProjects } from "@/lib/canvas/canvas-export";
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 [exporting, setExporting] = 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);
}
},
});
};
const download = async () => {
if (!preview?.project || !isCurrentContext()) return;
setExporting(true);
try {
// Drawing strokes are not versioned; never mix today's local strokes into an old snapshot.
await exportCanvasProjects([preview.project], `${preview.project.title}-${preview.label}`, { includeLocalDrawings: false });
message.success("已下载,可从画布列表导入为新画布");
} catch (cause) {
message.error(cause instanceof Error ? cause.message : "下载失败,请重试");
} finally {
setExporting(false);
}
};
return {
open,
listOpen,
desktop,
tab,
entries,
drafts,
currentRevision,
preview,
loading,
draftLoading,
restoring,
confirming,
exporting,
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,
download,
};
}
export type CanvasVersionHistoryController = ReturnType<typeof useCanvasVersionHistory>;
export function CanvasVersionHistory({ history }: { history: CanvasVersionHistoryController }) {
const { open, tab, entries, drafts, currentRevision, preview, loading, draftLoading, restoring, exporting, 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}
<div className="canvas-version-actions">
{preview.kind === "cloud" ? (
<Button block type="primary" loading={restoring} disabled={!preview.project || currentRevision === undefined || loading} onClick={history.restore}>
恢复此版本
</Button>
) : null}
<Button block type={preview.kind === "draft" ? "primary" : "default"} icon={<Download size={14} />} loading={exporting} disabled={!preview.project || restoring} onClick={() => void history.download()}>
{preview.kind === "draft" ? "下载草稿" : "下载此版本"}
</Button>
</div>
</>
) : (
<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 });
}

View File

@@ -0,0 +1,230 @@
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 { InfiniteCanvas } from "@/components/canvas/infinite-canvas";
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}>
<InfiniteCanvas
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()}
/>
),
)}
</InfiniteCanvas>
</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>
);
}

View File

@@ -29,6 +29,7 @@ import { resourceFileUrl, resourceStorageKey, uploadResourceFile } from "@/servi
import { primeResourceBlobCache } from "@/services/resource-blob-cache";
import { useSyncProgressStore } from "@/stores/use-sync-progress-store";
import { ensureCanvasNodeAsset } from "@/services/project-asset-sync";
import { CanvasSyncDraftMenu } from "./canvas-sync-status";
import { useAppearanceStore } from "@/stores/use-appearance-store";
const CanvasDeleteProjectsDialog = lazy(() => import("@/components/canvas/canvas-delete-projects-dialog").then((module) => ({ default: module.CanvasDeleteProjectsDialog })));
@@ -358,15 +359,21 @@ export default function CanvasPage() {
await flushCanvasStorePersistence();
if (remoteSyncEnabled) {
try {
await saveRemoteUserDataNow();
await saveRemoteUserDataNow(importedProjectId);
} catch (syncError) {
remoteSyncWarning ||= syncError;
scheduleRemoteUserDataSync();
console.warn("导入画布云端同步失败,等待自动重试", syncError);
}
}
} catch (error) {
useSyncProgressStore.getState().setProjectProgress(importedProjectId, {
phase: "error",
message: error instanceof Error ? error.message : "画布导入未完成",
});
throw error;
} finally {
useSyncProgressStore.getState().setProjectProgress(importedProjectId, null);
if (!remoteSyncEnabled) useSyncProgressStore.getState().setProjectProgress(importedProjectId, null);
}
}
@@ -409,6 +416,7 @@ export default function CanvasPage() {
meta={<span className="app-projects-header-meta">{totalProjects} 个</span>}
actions={
<div className="collection-header-actions">
<CanvasSyncDraftMenu />
<Button type="primary" disabled={!hydrated} icon={<Plus />} onClick={createAndEnter}>
新建画布
</Button>

View File

@@ -100,6 +100,9 @@ import { deriveStoryboardPipelineProgress } from "@/lib/canvas/canvas-storyboard
import { CanvasOperationChangeToast, CanvasMergeStatusToast, CanvasUploadStatusToast } from "./canvas-project-feedback";
import { backendProviderConfig, getGenerationCount } from "@/lib/canvas/canvas-project-generation";
import { cancelGenerationTask } from "@/services/api/task-center";
import { CanvasSyncStatus } from "./canvas-sync-status";
import { CanvasVersionHistory, useCanvasVersionHistory } from "./canvas-version-history";
import { CanvasVersionPreview } from "./canvas-version-preview";
import { CanvasTopBar } from "./canvas-project-top-bar";
import { LibTVImportDialog } from "./components/libtv-import-dialog";
import { TapNowImportDialog } from "./components/tapnow-import-dialog";
@@ -333,17 +336,9 @@ function InfiniteCanvasPage() {
const [titleDraft, setTitleDraft] = useState("");
const [shortcutRequestNonce, setShortcutRequestNonce] = useState(0);
const [cinematicAgentEntry, setCinematicAgentEntry] = useState(false);
const { assistantOpen, closeAgent, openAgent } = useCanvasAssistantVisibility();
const { assistantOpen, closeAgent, openAgent: openAssistant } = useCanvasAssistantVisibility();
const agentMentionReferences = useMemo(() => buildCanvasAgentMentionReferences(nodes), [nodes]);
const sendSelectionToAgent = useCallback((nodeId?: string) => {
const ids = nodeId ? [nodeId] : Array.from(selectedNodeIdsRef.current);
const references = ids.map((id) => agentMentionReferences.find((reference) => reference.nodeId === id)).filter((reference): reference is CanvasResourceReference => Boolean(reference));
if (!references.length) return;
setAgentPrefillPrompt(`${references.map(canvasResourceMentionToken).join(" ")} `);
openAgent();
setContextMenu(null);
}, [agentMentionReferences, openAgent]);
const { tasks: activeTasks } = useCanvasActiveTasks(projectId, projectLoaded);
const { focusMode, enterFocusMode, exitFocusMode, toggleFocusMode } = useFocusMode();
const [focusDockRevealed, setFocusDockRevealed] = useState(false);
@@ -427,7 +422,7 @@ function InfiniteCanvasPage() {
[cleanupAssetImages, getHistoryCleanupContext],
);
const { loadError, retryLoad, addedSkills, agentCreatedNodes, clearCanvasFiles, createAndOpenProject, currentProject, deleteCurrentProject, renameCurrentProject, saveCanvasProject, forceSaveCanvasProject, updateProject } = useCanvasProjectLifecycle({
const { loadError, retryLoad, addedSkills, agentCreatedNodes, clearCanvasFiles, createAndOpenProject, currentProject, deleteCurrentProject, renameCurrentProject, reloadLatestCanvasProject, restoreCanvasProjectVersion, saveCanvasProject, forceSaveCanvasProject, updateProject } = useCanvasProjectLifecycle({
projectId,
projectLoaded,
nodes,
@@ -440,6 +435,8 @@ function InfiniteCanvasPage() {
viewport,
nodesRef,
connectionsRef,
chatSessionsRef,
activeChatIdRef,
viewportRef,
historyPausedRef,
setNodes,
@@ -456,13 +453,24 @@ function InfiniteCanvasPage() {
cleanupCanvasFiles,
});
// 强制覆盖会改写云端版本并重绑媒体素材关联,必须让用户显式确认 destructive 语义。
const versions = useCanvasVersionHistory(projectId, restoreCanvasProjectVersion);
const openVersions = () => { closeAgent(); setVersionCompareRootId(null); versions.show(); };
const openAgent = useCallback(() => { versions.close(); openAssistant(); }, [versions.close, openAssistant]);
const sendSelectionToAgent = useCallback((nodeId?: string) => {
const ids = nodeId ? [nodeId] : Array.from(selectedNodeIdsRef.current);
const references = ids.map((id) => agentMentionReferences.find((reference) => reference.nodeId === id)).filter((reference): reference is CanvasResourceReference => Boolean(reference));
if (!references.length) return;
setAgentPrefillPrompt(`${references.map(canvasResourceMentionToken).join(" ")} `);
openAgent();
setContextMenu(null);
}, [agentMentionReferences, openAgent]);
// 修复素材关联仍遵守当前画布版本,不能替用户确认覆盖云端的新内容。
const confirmForceSaveCanvas = useCallback(() => {
modal.confirm({
title: "用本地内容强制覆盖云端?",
content: "将把当前本地画布保存并覆盖云端版本,同时自动修复画布媒体与素材库的绑定(缺少素材记录时会按节点新建)。云端尚未同步到本地的改动会被覆盖。",
okText: "强制覆盖保存",
okButtonProps: { danger: true },
title: "修复素材关联并保存?",
content: "核对画布媒体与素材库的关联,补齐缺失素材后保存。若云端已有新版本,会保留本地草稿并提示加载最新版。",
okText: "修复并保存",
cancelText: "取消",
onOk: () => forceSaveCanvasProject(),
});
@@ -478,7 +486,7 @@ function InfiniteCanvasPage() {
connectionsRef.current = nextConnections;
setNodes(nextNodes);
setConnections(nextConnections);
const saved = await saveCanvasProject();
const saved = await saveCanvasProject({ requireRemote: false });
if (!saved) {
nodesRef.current = previousNodes;
connectionsRef.current = previousConnections;
@@ -499,7 +507,7 @@ function InfiniteCanvasPage() {
connectionsRef.current = nextConnections;
setNodes(nextNodes);
setConnections(nextConnections);
const saved = await saveCanvasProject();
const saved = await saveCanvasProject({ requireRemote: false });
if (!saved) {
nodesRef.current = previousNodes;
connectionsRef.current = previousConnections;
@@ -1628,6 +1636,7 @@ function InfiniteCanvasPage() {
}, [clearCanvasFiles, deselectCanvas, message, nodesRef, projectId, setEmotionNodeId]);
useCanvasKeyboard({
enabled: projectLoaded && !versions.preview,
nodesRef,
selectedNodeIdsRef,
selectedConnectionId,
@@ -2341,13 +2350,17 @@ function InfiniteCanvasPage() {
跳转到画布主内容
</a>
<main id="canvas-main" tabIndex={-1} className="flex h-full min-h-0 overflow-hidden outline-none" style={{ background: resolvedCanvasAppearance.background, color: theme.node.text }}>
{!focusMode && shortDramaEnabled && currentProject?.projectId ? (
{!focusMode && !versions.preview && shortDramaEnabled && currentProject?.projectId ? (
<CanvasProjectSidebar projectId={currentProject.projectId} detail={linkedProjectQuery.data} onAddChapter={handleProjectChapterInsert} onLocateStyle={locateProjectStyleNode} onOpenAssets={() => openProjectAssets()} />
) : null}
<CanvasOverlayLayerProvider>
<section className="relative min-w-0 flex-1 flex flex-col min-h-0 overflow-hidden">
<div className="canvas-editor-shell relative flex min-w-0 flex-1">
<section data-canvas-editor inert={Boolean(versions.preview)} style={{ visibility: versions.preview ? "hidden" : undefined, opacity: versions.preview ? 0 : undefined }} className="relative min-w-0 flex-1 flex flex-col min-h-0 overflow-hidden">
{!focusMode ? (
<CanvasTopBar
syncStatus={<CanvasSyncStatus projectId={projectId} onLoadLatest={reloadLatestCanvasProject} onOpenVersions={openVersions} />}
versionsOpen={versions.open}
onToggleVersions={() => { closeAgent(); setVersionCompareRootId(null); versions.toggle(); }}
title={currentProject?.title || "未命名画布"}
titleDraft={titleDraft}
isTitleEditing={titleEditing}
@@ -2418,6 +2431,7 @@ function InfiniteCanvasPage() {
<div className="relative flex min-h-0 min-w-0 flex-1">
<div className="relative min-w-0 flex-1 overflow-hidden">
<InfiniteCanvas
interactive={!versions.preview}
containerRef={containerRef}
viewport={viewport}
appearance={canvasAppearance}
@@ -2537,6 +2551,9 @@ function InfiniteCanvasPage() {
{focusMode ? (
<CanvasFocusModeBar
syncStatus={<CanvasSyncStatus projectId={projectId} onLoadLatest={reloadLatestCanvasProject} onOpenVersions={openVersions} />}
versionsOpen={versions.open}
onToggleVersions={() => { closeAgent(); versions.toggle(); }}
dockRevealed={focusDockRevealed}
zoomPercent={viewport.k}
onToggleDock={() => setFocusDockRevealed((value) => !value)}
@@ -2593,10 +2610,12 @@ function InfiniteCanvasPage() {
) : null}
</div>
<div className={versions.open ? "hidden" : "contents"}>
<CanvasCloudAgentPanel canvasId={projectId} domainProjectId={currentProject?.projectId} nodeCount={nodes.length} references={agentMentionReferences} prefillPrompt={agentPrefillPrompt} open={assistantOpen} onOpen={openAgent} onCollapse={closeAgent} onFocusNode={(nodeId) => {
if (!nodesRef.current.some((node) => node.id === nodeId)) { message.info("该节点已删除或尚未同步到画布"); return; }
focusCanvasNode(nodeId);
}} />
</div>
</div>
{angleNode?.metadata?.content ? (
@@ -3104,7 +3123,10 @@ function InfiniteCanvasPage() {
onInsertFolder={projectAssetScope === "canvas" ? handleProjectFolderInsert : undefined}
/>
</section>
{versions.preview ? <CanvasVersionPreview key={versions.preview.key} preview={versions.preview} onReturn={versions.returnToCurrent} onShowVersions={versions.show} /> : null}
</div>
</CanvasOverlayLayerProvider>
<CanvasVersionHistory history={versions} />
</main>
</>
);

View File

@@ -3,6 +3,7 @@ import { useEffect, type Dispatch, type SetStateAction } from "react";
import type { CanvasNodeData, ContextMenuState } from "@/types/canvas";
type UseCanvasKeyboardOptions = {
enabled?: boolean;
nodesRef: { current: CanvasNodeData[] };
selectedNodeIdsRef: { current: Set<string> };
selectedConnectionId: string | null;
@@ -49,6 +50,7 @@ export function hasCanvasTextSelection(selection: TextSelectionLike | null | und
}
export function useCanvasKeyboard({
enabled = true,
nodesRef,
selectedNodeIdsRef,
selectedConnectionId,
@@ -84,6 +86,7 @@ export function useCanvasKeyboard({
beginBatchConnection,
}: UseCanvasKeyboardOptions) {
useEffect(() => {
if (!enabled) return;
const handleKeyDown = (event: KeyboardEvent) => {
const target = event.target instanceof Element ? event.target : null;
if (target?.closest(".canvas-node-toolbar, .canvas-node-toolbar-menu")) return;
@@ -219,5 +222,5 @@ export function useCanvasKeyboard({
window.removeEventListener("keydown", handleKeyDown, true);
window.removeEventListener("paste", handlePaste, true);
};
}, [beginBatchConnection, cancelSelectionBox, copySelectedNodes, deleteConnection, deleteNodes, deselectCanvas, exitFocusMode, fitCanvasContent, fitCanvasSelection, focusMode, nodesRef, onOpenSearch, pasteCopiedNodes, pasteSystemClipboard, redoCanvas, restoreCopiedNodesFromText, saveCanvasProject, selectedConnectionId, selectedNodeIdsRef, setAnnotationNodeId, setContextMenu, setCropNodeId, setInfoNodeId, setMaskEditNodeId, setSelectedConnectionId, setSelectedNodeIds, setShortcutRequestNonce, shouldPreferCopiedNodes, toggleFocusMode, undoCanvas, zoomCanvasIn, zoomCanvasOut, zoomToActualSize]);
}, [enabled, beginBatchConnection, cancelSelectionBox, copySelectedNodes, deleteConnection, deleteNodes, deselectCanvas, exitFocusMode, fitCanvasContent, fitCanvasSelection, focusMode, nodesRef, onOpenSearch, pasteCopiedNodes, pasteSystemClipboard, redoCanvas, restoreCopiedNodesFromText, saveCanvasProject, selectedConnectionId, selectedNodeIdsRef, setAnnotationNodeId, setContextMenu, setCropNodeId, setInfoNodeId, setMaskEditNodeId, setSelectedConnectionId, setSelectedNodeIds, setShortcutRequestNonce, shouldPreferCopiedNodes, toggleFocusMode, undoCanvas, zoomCanvasIn, zoomCanvasOut, zoomToActualSize]);
}

View File

@@ -1,5 +1,5 @@
import { mergeAgentCanvasEditor } from "@/lib/canvas/agent-canvas-patch";
import { startTransition, useCallback, useEffect, useRef, useState, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
import { useCallback, useEffect, useRef, useState, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
import { App } from "antd";
import { useNavigate } from "react-router";
@@ -9,9 +9,11 @@ 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, localSavedRemotePendingMessage, saveRemoteUserDataNow, subscribeAgentCanvasRefresh } from "@/services/user-data-sync";
import { createCanvasProjectWithRemoteSync, deleteCanvasProjectsWithRemoteSync, forceOverwriteRemoteCanvasSync, 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";
@@ -29,6 +31,8 @@ type UseCanvasProjectLifecycleOptions = {
viewport: ViewportTransform;
nodesRef: MutableRefObject<CanvasNodeData[]>;
connectionsRef: MutableRefObject<CanvasConnection[]>;
chatSessionsRef: MutableRefObject<CanvasAssistantSession[]>;
activeChatIdRef: MutableRefObject<string | null>;
viewportRef: MutableRefObject<ViewportTransform>;
historyPausedRef: MutableRefObject<boolean>;
setNodes: Dispatch<SetStateAction<CanvasNodeData[]>>;
@@ -58,6 +62,8 @@ export function useCanvasProjectLifecycle({
viewport,
nodesRef,
connectionsRef,
chatSessionsRef,
activeChatIdRef,
viewportRef,
historyPausedRef,
setNodes,
@@ -87,11 +93,20 @@ export function useCanvasProjectLifecycle({
const [agentCreatedNodes, setAgentCreatedNodes] = useState<{ projectId: string; nodes: CanvasNodeData[] } | null>(null);
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);
useEffect(() => {
if (!hydrated || !sessionHydrated) return;
let cancelled = false;
setProjectLoaded(false);
setLoadError("");
const latest = loadLatestRef.current;
loadLatestRef.current = false;
const historyRestore = historyRestoreRef.current;
historyRestoreRef.current = null;
observedContentRef.current = null;
const applyRestoredProject = (targetProject: CanvasProject) => {
if (cancelled) return;
const fallbackTheme = useCanvasThemeStore.getState().theme;
@@ -111,6 +126,9 @@ export function useCanvasProjectLifecycle({
backgroundMode: targetProject.backgroundMode || DEFAULT_CANVAS_BACKGROUND_MODE,
showImageInfo: targetProject.showImageInfo || false,
};
observedContentRef.current = snapshot;
chatSessionsRef.current = snapshot.chatSessions;
activeChatIdRef.current = snapshot.activeChatId;
nodesRef.current = snapshot.nodes;
connectionsRef.current = snapshot.connections;
viewportRef.current = targetProject.viewport;
@@ -129,33 +147,40 @@ export function useCanvasProjectLifecycle({
const load = async () => {
const cachedProject = useCanvasStore.getState().projects.find((p) => p.id === projectId);
if (cachedProject && cachedProject.nodes?.length) {
if (!latest && !historyRestore && cachedProject && cachedProject.nodes?.length) {
// 本地已有该画布的持久化缓存:先以本地数据秒开渲染,彻底消除白屏与等待
applyRestoredProject(cachedProject);
}
const loadedProject = await loadCanvasProjectForEditing(projectId);
const loadedProject = await loadCanvasProjectForEditing(projectId, { latest, historyRestore: historyRestore || undefined, onLoad: applyRestoredProject });
historyRestore?.resolve();
if (cancelled) return;
if (!loadedProject) {
if (!cachedProject) navigate("/canvas", { replace: true });
return;
}
const project = useCanvasStore.getState().projects.find((p) => p.id === projectId) || loadedProject;
applyRestoredProject(project);
// 画布媒体由节点自己的视口观察器按需加载;打开时遍历并解析全部节点会让大画布形成 N+1 资源读取。
void hydrateAssistantImages(project.chatSessions || [])
.then((hydratedSessions) => {
if (!cancelled) setChatSessions((current) => mergeHydratedSessions(current, hydratedSessions));
if (!cancelled) setChatSessions((current) => {
const merged = mergeHydratedSessions(current, hydratedSessions);
if (observedContentRef.current?.chatSessions === current) observedContentRef.current = { ...observedContentRef.current, chatSessions: merged };
return merged;
});
})
.catch(() => {
if (!cancelled) message.warning("部分助手会话素材恢复失败,已使用项目记录继续打开");
});
};
void load().catch((error) => {
historyRestore?.reject(error);
if (!cancelled && useSyncProgressStore.getState().syncingProjects[projectId]?.phase !== "conflict") useSyncProgressStore.getState().setProjectProgress(projectId, { phase: "error", message: error instanceof Error ? error.message : "读取云端版本失败" });
if (!cancelled) setLoadError(error instanceof Error ? error.message : "读取画布失败,请重试");
});
return () => {
cancelled = true;
historyRestore?.reject(new Error("画布页面已关闭,请重新打开查看恢复结果"));
};
}, [hydrated, sessionHydrated, loadAttempt, message, navigate, openProject, projectId, resetHistory, setActiveChatId, setBackgroundMode, setCanvasAppearance, setChatSessions, setConnections, setNodes, setShowImageInfo, setViewport]);
@@ -179,6 +204,13 @@ export function useCanvasProjectLifecycle({
// Merge only server-changed fields so dragging/editing other nodes can
// continue while Agent media tasks complete. Same-field conflicts fail.
const merged = previous ? mergeAgentCanvasEditor(previous, project, nodesRef.current, connectionsRef.current) : project;
if (observedContentRef.current) {
const observed = observedContentRef.current;
// Advance only the observed server fields; edits in live refs still
// differ from this baseline and must be persisted by the effect below.
const baseline = previous ? mergeAgentCanvasEditor(previous, project, observed.nodes, observed.connections) : project;
observedContentRef.current = { ...observed, nodes: baseline.nodes, connections: baseline.connections };
}
nodesRef.current = merged.nodes;
connectionsRef.current = merged.connections;
setNodes(merged.nodes);
@@ -190,6 +222,9 @@ export function useCanvasProjectLifecycle({
useEffect(() => {
if (!projectLoaded || historyPausedRef.current) return;
const snapshot = { nodes, connections, chatSessions, activeChatId, canvasAppearance, backgroundMode, showImageInfo };
if (!observedContentRef.current || JSON.stringify(observedContentRef.current) === JSON.stringify(snapshot)) return;
observedContentRef.current = snapshot;
const patch = { nodes, connections, chatSessions, activeChatId, appearance: canvasAppearance, backgroundMode, showImageInfo };
const stored = useCanvasStore.getState().projects.find((project) => project.id === projectId);
// 远端结果投影到编辑器不是一次本地编辑,避免改写时间戳并触发反向保存。
@@ -223,8 +258,11 @@ export function useCanvasProjectLifecycle({
}, [message, navigate]);
const deleteCurrentProject = useCallback(async () => {
const drawingIds = nodesRef.current.flatMap((node) => node.type === "drawing" && node.metadata?.drawingId ? [node.metadata.drawingId] : []);
let 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}` : "删除画布失败,请稍后重试");
@@ -242,8 +280,9 @@ export function useCanvasProjectLifecycle({
renameProject(projectId, title);
}, [projectId, renameProject]);
const persistCanvasSnapshot = useCallback(async (): Promise<boolean> => {
try {
const persistLocalEdits = useCallback(async () => {
const snapshot = { nodes: nodesRef.current, connections: connectionsRef.current, chatSessions, activeChatId, canvasAppearance, backgroundMode, showImageInfo };
if (observedContentRef.current && JSON.stringify(observedContentRef.current) !== JSON.stringify(snapshot)) {
updateProject(projectId, {
nodes: nodesRef.current,
connections: connectionsRef.current,
@@ -253,38 +292,58 @@ export function useCanvasProjectLifecycle({
backgroundMode,
showImageInfo,
viewport: viewportRef.current,
directorScenes: currentProject?.directorScenes || [],
});
await flushCanvasStorePersistence();
return true;
observedContentRef.current = snapshot;
}
updateProject(projectId, { viewport: viewportRef.current });
await flushCanvasStorePersistence();
}, [activeChatId, backgroundMode, canvasAppearance, chatSessions, connectionsRef, nodesRef, projectId, showImageInfo, updateProject, viewportRef]);
const reloadLatestCanvasProject = useCallback(async () => {
await persistLocalEdits();
loadLatestRef.current = true;
setLoadAttempt((value) => value + 1);
}, [persistLocalEdits]);
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();
} catch {
message.error("画布保存失败,请稍后重试");
return false;
}
}, [activeChatId, backgroundMode, canvasAppearance, chatSessions, connectionsRef, currentProject?.directorScenes, message, nodesRef, projectId, showImageInfo, updateProject, viewportRef]);
const saveCanvasProject = useCallback(async (): Promise<boolean> => {
if (!(await persistCanvasSnapshot())) return false;
try {
await saveRemoteUserDataNow();
message.success("画布布局和位置已保存");
await saveRemoteUserDataNow(projectId);
message.success("画布已保存到云端");
} catch (error) {
// 本地 IndexedDB 已写入;云端失败只排队重试,不能把导入方的画布状态回滚。
message.warning(localSavedRemotePendingMessage("本地画布布局已保存", error));
const detail = error instanceof Error ? error.message : "未知错误";
message.warning(`本地画布布局已保存,云端同步失败:${detail}`);
// Imports can retain their durable local result; sharing requires cloud success.
return options.requireRemote === false;
}
return true;
}, [message, persistCanvasSnapshot]);
}, [message, persistLocalEdits, projectId]);
const forceSaveCanvasProject = useCallback(async (): Promise<boolean> => {
if (!(await persistCanvasSnapshot())) return false;
try { await persistLocalEdits(); } catch { message.error("本地保存失败,请重试"); return false; }
try {
const result = await forceOverwriteRemoteCanvasSync();
message.success(result.reboundNodes > 0 ? `已用本地内容覆盖云端,并修复 ${result.reboundNodes} 处媒体与素材的绑定` : "已用本地内容覆盖云端画布");
message.success(result.reboundNodes > 0 ? `已保存,并修复 ${result.reboundNodes} 处媒体与素材的绑定` : "素材关联已核对,画布已保存");
} catch (error) {
message.error(`强制覆盖保存失败:${error instanceof Error ? error.message : "未知错误"}`);
message.error(`修复并保存失败:${error instanceof Error ? error.message : "未知错误"}`);
return false;
}
return true;
}, [message, persistCanvasSnapshot]);
}, [message, persistLocalEdits]);
const clearCanvasFiles = useCallback(() => {
cleanupCanvasFiles({ projectId, nodes: [], chatSessions: [] });
@@ -300,6 +359,8 @@ export function useCanvasProjectLifecycle({
currentProject,
deleteCurrentProject,
renameCurrentProject,
reloadLatestCanvasProject,
restoreCanvasProjectVersion,
saveCanvasProject,
forceSaveCanvasProject,
updateProject,