fix(canvas): 画布版本 - 合并 PR #528 防止旧内容覆盖并支持历史恢复
将画布 revision CAS 与历史快照接到 schema v23,保留当前顶栏积分弹窗和已有菜单入口。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
|
||||
import type { CanvasConnection, CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
type Change<T> = { before: T | null; after: T };
|
||||
type Change<T> = { before: T | null; after: T | null };
|
||||
export type AgentCanvasPatch = {
|
||||
canvasId: string;
|
||||
baseRevision?: number;
|
||||
revision?: number;
|
||||
updatedAt: string;
|
||||
nodes: Change<CanvasNodeData>[];
|
||||
connections: Change<CanvasConnection>[];
|
||||
@@ -44,8 +46,11 @@ function mergeItems<T extends { id: string }>(items: T[], changes: Change<T>[]):
|
||||
if (!changes.length) return items;
|
||||
const next = new Map(items.map((item) => [item.id, item]));
|
||||
for (const { before, after } of changes) {
|
||||
if (!after?.id || (before && before.id !== after.id)) throw new Error("无效的画布增量节点");
|
||||
next.set(after.id, mergeValue(next.get(after.id) ?? null, before, after) as T);
|
||||
const id = after?.id || before?.id;
|
||||
if (!id || (before && after && before.id !== after.id)) throw new Error("无效的画布增量节点");
|
||||
const merged = mergeValue(next.get(id) ?? null, before, after) as T | null;
|
||||
if (merged === null) next.delete(id);
|
||||
else next.set(id, merged);
|
||||
}
|
||||
const result = [...next.values()];
|
||||
return result.length === items.length && result.every((item, index) => item === items[index]) ? items : result;
|
||||
@@ -55,6 +60,7 @@ export function applyAgentCanvasPatch(project: CanvasProject, patch: AgentCanvas
|
||||
if (patch.canvasId !== project.id || !Array.isArray(patch.nodes) || !Array.isArray(patch.connections)) throw new Error("画布增量不属于当前画布或格式无效");
|
||||
const byId = new Map(project.nodes.map((node) => [node.id, node]));
|
||||
const nodeChanges = patch.nodes.map((change) => {
|
||||
if (!change.after) return change;
|
||||
const current = byId.get(change.after.id);
|
||||
const beforeTask = change.before?.metadata?.taskId;
|
||||
const afterTask = change.after.metadata?.taskId;
|
||||
@@ -81,8 +87,10 @@ export function mergeAgentCanvasEditor(previous: CanvasProject, incoming: Canvas
|
||||
const changes = <T extends { id: string }>(before: T[], after: T[]): Change<T>[] => {
|
||||
const byId = new Map(before.map((item) => [item.id, item]));
|
||||
const afterIds = new Set(after.map((item) => item.id));
|
||||
if (before.some((item) => !afterIds.has(item.id))) throw new Error("画布节点已删除,请先同步本地编辑");
|
||||
return after.filter((item) => !equal(item, byId.get(item.id))).map((item) => ({ before: byId.get(item.id) ?? null, after: item }));
|
||||
return [
|
||||
...after.filter((item) => !equal(item, byId.get(item.id))).map((item) => ({ before: byId.get(item.id) ?? null, after: item })),
|
||||
...before.filter((item) => !afterIds.has(item.id)).map((item) => ({ before: item, after: null })),
|
||||
];
|
||||
};
|
||||
return applyAgentCanvasPatch({ ...previous, nodes, connections }, {
|
||||
canvasId: previous.id,
|
||||
|
||||
26
web/src/lib/canvas/canvas-content.ts
Normal file
26
web/src/lib/canvas/canvas-content.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { canonicalize } from "json-canonicalize";
|
||||
import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
|
||||
|
||||
// These fields describe local viewing / synchronization, not an edit to the document.
|
||||
export function canvasContentSnapshot(project: CanvasProject) {
|
||||
const { viewport: _viewport, updatedAt: _updatedAt, revision: _revision, remoteContentHash: _hash, ...content } = project;
|
||||
return { ...content, projectId: content.projectId || undefined };
|
||||
}
|
||||
|
||||
export function sameCanvasContent(left: CanvasProject | undefined, right: CanvasProject | undefined) {
|
||||
if (left === right) return true;
|
||||
if (!left || !right) return false;
|
||||
const a = canvasContentSnapshot(left) as Record<string, unknown>;
|
||||
const b = canvasContentSnapshot(right) as Record<string, unknown>;
|
||||
return [...new Set([...Object.keys(a), ...Object.keys(b)])].every((key) => a[key] === b[key] || canonicalize(a[key]) === canonicalize(b[key]));
|
||||
}
|
||||
|
||||
export async function canvasContentHash(project: CanvasProject) {
|
||||
const serialized = canonicalize(canvasContentSnapshot(project));
|
||||
// LAN HTTP deployments may lack Web Crypto. Keep an exact baseline there;
|
||||
// a lossy checksum could incorrectly discard an unsaved draft during login.
|
||||
if (!globalThis.crypto?.subtle) return `json:${serialized}`;
|
||||
const bytes = new TextEncoder().encode(serialized);
|
||||
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
||||
return Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
|
||||
import { loadCanvasDrawing, loadCanvasDrawingPreview, loadCanvasDrawingRender } from "@/lib/canvas/canvas-drawing-storage";
|
||||
import type { CanvasDrawingExport } from "@/types/canvas-export";
|
||||
|
||||
export async function exportCanvasProjects(projects: CanvasProject[], fileName = "画布") {
|
||||
export async function exportCanvasProjects(projects: CanvasProject[], fileName = "画布", options: { includeLocalDrawings?: boolean } = {}) {
|
||||
const zipFiles: { name: string; data: BlobPart }[] = [];
|
||||
const exportedProjects = await Promise.all(
|
||||
projects.map(async (project) => {
|
||||
@@ -22,7 +22,7 @@ export async function exportCanvasProjects(projects: CanvasProject[], fileName =
|
||||
zipFiles.push({ name: path, data: blob });
|
||||
}),
|
||||
);
|
||||
const drawingDocuments = (await Promise.all(project.nodes.filter((node) => node.type === "drawing" && node.metadata?.drawingId).map(async (node): Promise<CanvasDrawingExport | null> => {
|
||||
const drawingDocuments = (await Promise.all(project.nodes.filter((node) => options.includeLocalDrawings !== false && node.type === "drawing" && node.metadata?.drawingId).map(async (node): Promise<CanvasDrawingExport | null> => {
|
||||
const drawingId = node.metadata?.drawingId;
|
||||
if (!drawingId) return null;
|
||||
const [saved, preview, render] = await Promise.all([
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
|
||||
import type { CanvasAssistantSession, CanvasConnection, CanvasNodeData } from "@/types/canvas";
|
||||
import { normalizeAssetCategory } from "@/lib/asset-category";
|
||||
import { sameCanvasContent } from "@/lib/canvas/canvas-content";
|
||||
|
||||
export type CanvasStorageTombstones = {
|
||||
projects: Record<string, number>;
|
||||
@@ -302,6 +303,12 @@ function mergeSessions(input: {
|
||||
}
|
||||
|
||||
function mergeProject(base: CanvasProject | undefined, local: CanvasProject, durable: CanvasProject, document: CanvasStorageDocument, baseRevision: number, nextRevision: number, conflicts: CanvasStorageConflict[]) {
|
||||
if (local.revision !== durable.revision) {
|
||||
// Never attach another tab's newer server revision to this tab's stale edits.
|
||||
// Preserve the whole edited branch; the server will reject its stale revision.
|
||||
if (local.revision === base?.revision && sameCanvasContent(base, local)) return { ...durable, viewport: local.viewport };
|
||||
return local;
|
||||
}
|
||||
const common = {
|
||||
projectId: local.id,
|
||||
tombstones: document.tombstones,
|
||||
|
||||
Reference in New Issue
Block a user