feat(canvas): gate legacy editing and hand off explicit migration safely
This commit is contained in:
@@ -21,6 +21,10 @@
|
||||
.helpPopup p { margin-top: 9px; }
|
||||
.error { position: absolute; top: 78px; left: 50%; transform: translateX(-50%); max-width: min(500px, calc(100vw - 32px)); padding: 10px 14px; border: 1px solid #e5c7b8; border-radius: 8px; color: #a25439; background: #fff9f3; font-size: 12px; z-index: 15; }
|
||||
.helpToolbar { position: absolute; right: 12px; bottom: 12px; z-index: 10; }
|
||||
.migrationPanel { position: absolute; right: 12px; top: 64px; z-index: 220; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; max-width: 360px; padding: 8px; border: 1px solid #ded7ce; border-radius: 7px; background: #fffefb; color: #78523f; font-size: 12px; }
|
||||
.migrationPanel button { border: 1px solid #ded7ce; border-radius: 5px; padding: 6px 9px; background: white; cursor: pointer; }
|
||||
.migrationPanel button:disabled { cursor: default; opacity: .5; }
|
||||
.migrationSurface { position: absolute; inset: 0; }
|
||||
@media (max-width: 700px) {
|
||||
.topRight { top: 62px; right: 12px; }
|
||||
.bottomToolbar { left: 12px; bottom: 12px; transform: none; max-width: calc(100vw - 92px); overflow-x: auto; overscroll-behavior-x: contain; }
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
import { useImageUploads } from "./use-image-uploads";
|
||||
import {LegacyCanvasMigration} from "./legacy-canvas-migration";
|
||||
import type {LegacyMigrationContext} from "@/lib/browser-canvas-migration";
|
||||
import { notify, dismissNotification } from "@/lib/notifications";
|
||||
import { FeedbackContext } from "@/components/ui/app-feedback";
|
||||
import { ImagePreviews } from "./canvas-image";
|
||||
@@ -43,7 +45,7 @@ const edgeTypes = { default: CanvasEdge };
|
||||
const IconPicker = dynamic(() => import("./icon-picker").then((module) => module.IconPicker), { ssr: false, loading: () => <div role="status" className={styles.iconLoading}>正在加载图标面板…</div> });
|
||||
const nodeTypes = { material: MaterialNode, plainText: PlainTextNode, icon: IconNode, frame: FrameNode, plainImage: PlainImageNode };
|
||||
|
||||
function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnapshot }) {
|
||||
function CanvasEditor({ board, snapshot, migration }: { board: Board; snapshot?: CanvasSnapshot; migration?:LegacyMigrationContext }) {
|
||||
const flow = useReactFlow<CanvasNode>();
|
||||
const { zoom } = useViewport();
|
||||
const reduceMotion = useReducedMotion();
|
||||
@@ -80,7 +82,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
const imageInput = useRef<HTMLInputElement>(null);
|
||||
const projectInput = useRef<HTMLInputElement>(null);
|
||||
const [recovery, setRecovery] = useState(initial.recovery);
|
||||
const tree = useCanvasTree(board.id, board.workspace_id, initial.document, snapshot, Boolean(recovery), board.parent_board_id);
|
||||
const tree = useCanvasTree(board.id, board.workspace_id, initial.document, snapshot, Boolean(recovery), board.parent_board_id, migration);
|
||||
const childRecovery = [...tree.recoveries][0];
|
||||
const downloadDraft = (document: import("@/lib/canvas").CanvasDocument) => { const url = URL.createObjectURL(new Blob([JSON.stringify(document, null, 2)], { type: "application/json" })); const link = window.document.createElement("a"); link.href = url; link.download = "画布本地草稿.json"; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); };
|
||||
const sync = tree;
|
||||
@@ -89,6 +91,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
const treeRef = useRef(tree); treeRef.current = tree;
|
||||
const uploads = useImageUploads(board.workspace_id, (job, asset) => treeRef.current.resolveImageUpload(job.node.id, asset,{width:job.node.width!,height:job.node.height!}));
|
||||
const imageUploading = demoImageUploading;
|
||||
const ioActive=useRef(false);
|
||||
useEffect(() => {
|
||||
const current = treeRef.current;
|
||||
for (const job of uploads.jobs) {
|
||||
@@ -322,6 +325,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) { pendingImage.current = null; return; }
|
||||
if(treeRef.current.isMigrationHeld()){pendingImage.current=null;setError("画布交接期间暂时不能添加图片。");return;}
|
||||
if (!["image/png", "image/jpeg", "image/webp", "image/gif"].includes(file.type) || file.size > (snapshot ? 20 * 1024 * 1024 : 2_000_000)) {
|
||||
pendingImage.current = null; setError(snapshot ? "请选择小于 20 MiB 的 PNG、JPEG、WebP 或 GIF 图片。" : "演示模式请选择小于 2 MB 的图片。"); return;
|
||||
}
|
||||
@@ -370,6 +374,8 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
if(treeRef.current.isMigrationHeld()){setError("画布交接期间暂时不能导入。");return;}
|
||||
ioActive.current=true;
|
||||
try {
|
||||
if (file.size > 5_000_000) throw new Error("请选择小于 5 MB 的图谱文件。");
|
||||
if (tree.rootDocument.nodes.some((node) => node.data.subgraph)) throw new Error("当前画布包含真实子图,请先导出备份并通过子图事务恢复,避免覆盖子图关系。");
|
||||
@@ -379,6 +385,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
void flow.setViewport(document.viewport);
|
||||
setError("");
|
||||
} catch (error) { setError(error instanceof Error ? error.message : "导入失败。"); }
|
||||
finally{ioActive.current=false;}
|
||||
}
|
||||
async function exportProject() {
|
||||
let documents: Map<string, import("@/lib/canvas").CanvasDocument>;
|
||||
@@ -416,6 +423,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
<NodeEditing.Provider value={editing}>
|
||||
<FrameFeedback.Provider value={frameFeedback}>
|
||||
<EdgeEditing.Provider value={{ begin: beginEdit, patch: tree.patchEdge, active: new Set([...tree.views.edgeRefs].filter(([id, ref]) => ai.activeEdges.has(ref.boardId + "/" + ref.id)).map(([id]) => id)) }}>
|
||||
<div className={styles.migrationSurface} inert={tree.migrationHeld}>
|
||||
<ReactFlow<CanvasNode> nodes={nodes} edges={edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes}
|
||||
onBeforeDelete={async ({ nodes: removing }) => {
|
||||
if (tree.busy || tree.transactionBusy) return false;
|
||||
@@ -483,6 +491,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
return owned && <AIPanel key={node.id} id={node.id} node={owned} workspace={board.workspace_id} boardId={ref.boardId} ai={ai} save={(config) => tree.nodeAction(node.id, (id) => ({ type: "node-data", id, patch: { ai: config } }))} />;
|
||||
})}
|
||||
</ReactFlow>
|
||||
</div>
|
||||
</EdgeEditing.Provider>
|
||||
</FrameFeedback.Provider>
|
||||
</NodeEditing.Provider>
|
||||
@@ -500,7 +509,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
<IconButton icon={Download} label="导出图谱" onClick={exportProject} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={`${toolbar.surface} ${toolbar.relaxed} ${styles.toolbar} ${styles.bottomToolbar}`} role="toolbar" aria-label="画布创作工具">
|
||||
<div className={`${toolbar.surface} ${toolbar.relaxed} ${styles.toolbar} ${styles.bottomToolbar}`} role="toolbar" aria-label="画布创作工具" inert={tree.migrationHeld}>
|
||||
<IconButton icon={MousePointer2} label="选择工具(V)" aria-pressed={tool === "select"} onClick={() => setTool("select")} />
|
||||
<IconButton icon={Hand} label="抓手工具(H)" aria-pressed={tool === "hand"} onClick={() => setTool("hand")} />
|
||||
<span className={toolbar.divider} aria-hidden="true" />
|
||||
@@ -542,8 +551,8 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
setTool("select");
|
||||
}} />}
|
||||
{!feedback && <div className={styles.membershipNotice} role="status" aria-live="polite">{membershipNotice}</div>}
|
||||
{recovery && <DraftRecovery canRestore={canRestoreCanvasDraft(recovery, tree.rootDocument)} exportDraft={() => downloadDraft(recovery)} restore={() => { dispatch({ type: "replace", document: recovery }); setRecovery(null); }} discard={() => setRecovery(null)} />}
|
||||
{!recovery && childRecovery && <DraftRecovery canRestore={canRestoreCanvasDraft(childRecovery[1], tree.documents.get(childRecovery[0])!)} exportDraft={() => downloadDraft(childRecovery[1])} restore={() => tree.resolveRecovery(childRecovery[0], true)} discard={() => tree.resolveRecovery(childRecovery[0], false)} />}
|
||||
{!tree.migrationHeld && recovery && <DraftRecovery canRestore={canRestoreCanvasDraft(recovery, tree.rootDocument)} exportDraft={() => downloadDraft(recovery)} restore={() => { if(treeRef.current.isMigrationHeld())return;dispatch({ type: "replace", document: recovery }); setRecovery(null); }} discard={() => {if(!treeRef.current.isMigrationHeld())setRecovery(null);}} />}
|
||||
{!tree.migrationHeld && !recovery && childRecovery && <DraftRecovery canRestore={canRestoreCanvasDraft(childRecovery[1], tree.documents.get(childRecovery[0])!)} exportDraft={() => downloadDraft(childRecovery[1])} restore={() => tree.resolveRecovery(childRecovery[0], true)} discard={() => tree.resolveRecovery(childRecovery[0], false)} />}
|
||||
{snapshot && sync.error && <div className={styles.syncError} role="alert"><span>{sync.error === "local_backup_failed" ? "本地备份失败,请导出 JSON 备份。" : sync.error === "version_conflict" ? "画布已在其他地方修改,自动保存已停止,请导出当前内容或重新读取服务端版本。" : errorText(new Error(sync.error))}</span>
|
||||
{sync.error !== "version_conflict" && <button onClick={() => void sync.retry()}>重试保存</button>}
|
||||
<button onClick={exportProject}>导出当前内容</button><button onClick={() => window.location.reload()}>重新读取</button>
|
||||
@@ -556,7 +565,11 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
</div>
|
||||
</NodeToolbar>;
|
||||
}))}
|
||||
{tree.busy && <div className={styles.canvasBusy} role="status">{tree.pendingCommand ? <div>同步结果尚未确认,为保护内容暂时停止编辑。<button onClick={() => void tree.retryPending().catch((error) => setError(error.message))}>重试同步</button><button onClick={() => window.location.reload()}>重新读取画布</button></div> : "正在同步画布…"}</div>}
|
||||
{migration&&<LegacyCanvasMigration context={migration} workspaceId={board.workspace_id} canvasId={board.id} registry={migration.registry} held={tree.migrationHeld} hold={tree.setMigrationHeld} prepare={tree.prepareMigration}
|
||||
check={()=>{if(recovery||treeRef.current.recoveries.size)throw new Error('draft_recovery_required');if(ioActive.current||uploads.hasPending()||demoImageUploading)throw new Error('migration_pending_uploads');}}
|
||||
canLeave={()=>![...treeRef.current.current.current].some(([id,doc])=>treeRef.current.writers.current.get(id)?.needsSave(doc))}
|
||||
exportDraft={()=>downloadDraft(recovery??treeRef.current.rootDocument)} />}
|
||||
{tree.busy && <div className={styles.canvasBusy} role="status">{tree.pendingCommand ? <div>同步结果尚未确认,为保护内容暂时停止编辑。<button onClick={() => void tree.retryPending().catch((error) => setError(error.message))}>重试同步</button><button onClick={() => window.location.reload()}>重新读取画布</button></div> : tree.migrationHeld?"正在检查或交接画布,编辑暂时暂停。":"正在同步画布…"}</div>}
|
||||
{error && !feedback && <button className={styles.error} role="alert" onClick={() => setError("")} title="点击关闭提示">{error}</button>}
|
||||
<input ref={imageInput} type="file" accept="image/png,image/jpeg,image/webp,image/gif" onChange={readImage} hidden />
|
||||
<input ref={projectInput} type="file" accept="application/json,.json" onChange={importProject} hidden />
|
||||
@@ -564,6 +577,6 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
);
|
||||
}
|
||||
|
||||
export default function GraphCanvas({ board, snapshot }: { board: Board; snapshot?: CanvasSnapshot }) {
|
||||
return <ReactFlowProvider><CanvasEditor board={board} snapshot={snapshot} /></ReactFlowProvider>;
|
||||
export default function GraphCanvas({ board, snapshot, migration }: { board: Board; snapshot?: CanvasSnapshot; migration?:LegacyMigrationContext }) {
|
||||
return <ReactFlowProvider><CanvasEditor board={board} snapshot={snapshot} migration={migration} /></ReactFlowProvider>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import type {Registry} from '@/lib/canvas-core/types';
|
||||
import {claimLegacyCanvasEditing,createBrowserCanvasMigration,type LegacyMigrationContext} from '@/lib/browser-canvas-migration';
|
||||
import {canvasMigrationStorageKey,migrationBlocksCanvas,readCanvasMigration,type CanvasMigrationJob} from '@/lib/canvas-migration';
|
||||
import {errorText} from '@/lib/api';
|
||||
import styles from './canvas.module.css';
|
||||
|
||||
function message(error:unknown){
|
||||
const code=error instanceof Error?error.message:String(error);
|
||||
return ({migration_busy:'其他旧画布页面仍在编辑或升级,请先完成那里的保存和上传,再关闭页面重试。',migration_lock_unavailable:'此浏览器不支持安全升级,请使用支持 Web Locks 的浏览器。',migration_response_unknown:'升级结果尚未确认,编辑已暂停。可继续查询原请求。',invalid_migration_receipt:'升级回执无法确认,已保留原请求并暂停编辑。请继续查询或联系支持。',migration_pending_uploads:'请先完成或取消图片上传,再升级。',draft_recovery_required:'请先处理本地草稿恢复,再升级。',canvas_busy:'请先完成当前操作与同步,再升级。',migration_waiting_for_runs:'请等待生成任务结束,再重新准备升级。',migration_version_conflict:'画布已发生变化,请重新准备升级。',workspace_migration_pending:'本工作区还有另一画布的升级待确认,请先处理原画布。',invalid_migration_record:'无法读取本地升级进度,已保留记录并暂停编辑。',migration_interpreter_unavailable:'本地升级进度需要其他版本解释,已保留记录并暂停编辑。',migration_not_durable:'无法可靠保存升级进度,请保留页面并重试。'} as Record<string,string>)[code]??errorText(error instanceof Error?error:new Error(code));
|
||||
}
|
||||
type Props={context:LegacyMigrationContext;workspaceId:string;canvasId:string;registry:Registry;held:boolean;hold(value:boolean):void;prepare(rootId:string):Promise<void>;check():void;canLeave():boolean;exportDraft():void};
|
||||
export function LegacyCanvasMigration(props:Props){
|
||||
const port=useRef(props);port.current=props;
|
||||
const [job,setJob]=useState<CanvasMigrationJob>(),[error,setError]=useState(''),[checking,setChecking]=useState(true),[running,setRunning]=useState(false),[changedElsewhere,setChangedElsewhere]=useState(false);
|
||||
const lease=useRef<{release():Promise<void>}|undefined>(undefined),life=useRef<AbortController|undefined>(undefined),attempt=useRef(false),finished=useRef(false),previous=useRef<CanvasMigrationJob|undefined>(undefined);
|
||||
const scope={accountId:props.context.accountId,workspaceId:props.workspaceId},key=canvasMigrationStorageKey(scope);
|
||||
const refresh=useRef<()=>Promise<void>>(async()=>{});
|
||||
useEffect(()=>{
|
||||
const controller=new AbortController();life.current=controller;
|
||||
const current=()=>!controller.signal.aborted&&port.current.context.isCurrentIdentity();
|
||||
const read=()=>readCanvasMigration(localStorage,scope);
|
||||
async function inspect(){
|
||||
if(attempt.current||!current())return;
|
||||
setChecking(true);
|
||||
try{
|
||||
const next=read();previous.current=next;setJob(next);
|
||||
if(migrationBlocksCanvas(next,props.canvasId)||finished.current){
|
||||
port.current.hold(true);const held=lease.current;lease.current=undefined;await held?.release();setError('');return;
|
||||
}
|
||||
if(lease.current){port.current.hold(false);setError('');return;}
|
||||
port.current.hold(true);
|
||||
const acquired=await claimLegacyCanvasEditing({scope,canvasId:props.canvasId,signal:controller.signal,isCurrentIdentity:current});
|
||||
if(!current()){await acquired.release();return;}
|
||||
lease.current=acquired;port.current.hold(false);setError('');
|
||||
}catch(error){
|
||||
if(!current())return;
|
||||
port.current.hold(true);const held=lease.current;lease.current=undefined;await held?.release();
|
||||
// Without Web Locks this browser cannot upgrade, but existing legacy
|
||||
// editing remains supported unless there is an unresolved identity job.
|
||||
if(error instanceof Error&&error.message==='migration_lock_unavailable'){
|
||||
try{if(!migrationBlocksCanvas(read(),props.canvasId))port.current.hold(false);}catch{}
|
||||
}
|
||||
setError(message(error));
|
||||
}finally{if(current())setChecking(false);}
|
||||
}
|
||||
refresh.current=inspect;void inspect();
|
||||
const storage=(event:StorageEvent)=>{
|
||||
if(event.key!==key&&event.key!==null||!current()||attempt.current)return;
|
||||
// Never discard another view's in-memory content because a record vanished.
|
||||
if(previous.current?.phase==='pending'&&event.newValue===null){
|
||||
finished.current=true;port.current.hold(true);setChangedElsewhere(true);setError('升级进度已在其他页面改变。本页内容保留,可导出后重新打开。');
|
||||
const held=lease.current;lease.current=undefined;void held?.release();return;
|
||||
}
|
||||
void inspect();
|
||||
};
|
||||
window.addEventListener('storage',storage);
|
||||
return()=>{controller.abort();window.removeEventListener('storage',storage);const held=lease.current;lease.current=undefined;void held?.release();};
|
||||
// The port ref follows renders without re-acquiring a shared editing lease.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
},[key,props.canvasId]);
|
||||
async function upgrade(){
|
||||
const controller=life.current;if(!controller||controller.signal.aborted||attempt.current)return;
|
||||
attempt.current=true;setRunning(true);setError('');
|
||||
const current=()=>!controller.signal.aborted&&port.current.context.isCurrentIdentity();
|
||||
let failure='';
|
||||
try{
|
||||
const pending=readCanvasMigration(localStorage,scope);
|
||||
if(pending?.phase==='pending'&&!migrationBlocksCanvas(pending,props.canvasId))throw new Error('workspace_migration_pending');
|
||||
if(pending?.phase!=='pending')port.current.check();
|
||||
port.current.hold(true);
|
||||
// Drain while still holding our shared lease, so losing the subsequent
|
||||
// exclusive-lock race never abandons a dirty old writer between locks.
|
||||
if(pending?.phase!=='pending'&&props.context.rootId){await port.current.prepare(props.context.rootId);if(!current())return;}
|
||||
const held=lease.current;lease.current=undefined;await held?.release();if(!current())return;
|
||||
const migration=createBrowserCanvasMigration({scope,registry:props.registry,signal:controller.signal,isCurrentIdentity:current});
|
||||
const result=pending?.phase==='pending'?await migration.resume():props.context.rootId?await migration.start(props.context.rootId,async()=>{port.current.check();await port.current.prepare(props.context.rootId!);port.current.check();}):undefined;
|
||||
if(!current())return;
|
||||
if(result?.status==='applied'||result?.status==='already-migrated'){
|
||||
finished.current=true;port.current.context.onApplied();return;
|
||||
}
|
||||
if(result&&'error'in result){failure=message(result.error);setError(failure);}
|
||||
const next=migration.read();previous.current=next;setJob(next);
|
||||
}catch(error){if(current()){failure=message(error);setError(failure);}}
|
||||
finally{
|
||||
attempt.current=false;
|
||||
if(current()){
|
||||
setRunning(false);
|
||||
try{const next=readCanvasMigration(localStorage,scope);previous.current=next;setJob(next);if(!finished.current&&!migrationBlocksCanvas(next,props.canvasId))await refresh.current();}catch(error){port.current.hold(true);failure=message(error);}
|
||||
if(failure)setError(failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
const pending=migrationBlocksCanvas(job,props.canvasId),other=job?.phase==='pending'&&!pending;
|
||||
if(!props.context.rootId&&!pending&&!error&&!props.held)return null;
|
||||
return <div className={styles.migrationPanel} aria-label="画布升级">
|
||||
{checking?<span role="status">正在检查画布编辑权限…</span>:<>
|
||||
{running?<span role="status">正在保存并升级画布…</span>:changedElsewhere?<button onClick={()=>{try{port.current.check();if(!port.current.canLeave())throw new Error('本页有未同步内容,请先导出备份并手动重新打开画布。');port.current.context.onApplied();}catch(error){setError(message(error));}}}>重新打开画布</button>:props.context.rootId||pending?<button disabled={other} title="保存内容并使用新版编辑器;旧撤销记录不会带入" onClick={()=>void upgrade()}>{pending?'继续查询升级':job?.phase==='rejected'?'重新准备升级':'升级画布'}</button>:props.held?<button onClick={()=>void refresh.current()}>重新检查</button>:null}
|
||||
{pending&&!running&&<span role="status">结果待确认,旧编辑已暂停。</span>}
|
||||
{error&&<span role="alert">{error}</span>}
|
||||
{props.held&&!running&&<button onClick={props.exportDraft}>导出本页备份</button>}
|
||||
</>}
|
||||
</div>;
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { EdgeChange, NodeChange } from "@xyflow/react";
|
||||
import { api, ApiError } from "@/lib/api";
|
||||
import { api as requestAPI, ApiError } from "@/lib/api";
|
||||
import { canvasReducer, decodeCanvas, type CanvasAction, type CanvasDocument, type CanvasHistory, type CanvasNode } from "@/lib/canvas";
|
||||
import { CanvasSync, canvasFingerprint, canvasPayload, canvasPath, decodeSnapshot, readLocalRecovery, type CanvasSnapshot } from "@/lib/canvas-sync";
|
||||
import { CanvasSync, canvasFingerprint, canvasPayload, canvasPath, decodeSnapshot, readLocalRecovery, type CanvasSnapshot, type CanvasRequest } from "@/lib/canvas-sync";
|
||||
import { CANVAS_ACCEPT, type EdgeData } from "@/lib/canvas-v2";
|
||||
import { subgraphMovePath, subgraphMoveParticipants } from "@/lib/canvas-subgraphs";
|
||||
import { mergeCanvasDocuments, projectCanvasTree, transformEdgeData, referenceAfterMoves } from "@/lib/canvas-tree";
|
||||
@@ -16,7 +16,19 @@ type Entry = LocalHistory | TransactionHistory;
|
||||
type MemberJob = { source: string; target: string; ids: string[]; parent: string; frame: string; participants: string[]; error?: string; refresh?: boolean; run?: () => Promise<void>; resolve: () => void };
|
||||
const history = (document: CanvasDocument): CanvasHistory => ({ past: [], present: document, future: [] });
|
||||
|
||||
export function useCanvasTree(root: string, workspace: string, initial: CanvasDocument, snapshot?: CanvasSnapshot, paused = false, parentBoardId?: string) {
|
||||
export function useCanvasTree(root: string, workspace: string, initial: CanvasDocument, snapshot?: CanvasSnapshot, paused = false, parentBoardId?: string, migration?: {accountId:string;isCurrentIdentity():boolean}) {
|
||||
const migrationGate=useRef(!!migration);
|
||||
const migrationFlush=useRef(false);
|
||||
const [migrationHeld,setMigrationState]=useState(!!migration);
|
||||
const migrationBlocked=()=>migrationGate.current||!!migration&&!migration.isCurrentIdentity();
|
||||
function setMigrationHeld(value:boolean){migrationGate.current=value;setMigrationState(value);}
|
||||
const api:CanvasRequest=async <T,>(path:string,options?:RequestInit)=>{
|
||||
if(migration&&!migration.isCurrentIdentity())throw new Error('identity_changed');
|
||||
if(migrationBlocked()&&options?.method&&options.method!=='GET'&&!(migrationFlush.current&&options.method==='PATCH'&&path.endsWith('/canvas')))throw new Error('canvas_busy');
|
||||
const headers=new Headers(options?.headers);if(migration)headers.set('X-Expected-Account',migration.accountId);
|
||||
const value=await requestAPI<T>(path,{...options,headers:Object.fromEntries(headers)});
|
||||
if(migration&&!migration.isCurrentIdentity())throw new Error('identity_changed');return value;
|
||||
};
|
||||
const [documents, setDocuments] = useState(new Map([[root, initial]]));
|
||||
const current = useRef(documents); current.current = documents;
|
||||
const [status, setStatus] = useState("saved");
|
||||
@@ -45,6 +57,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
if (local) { const next = new Map(pendingRecovery.current).set(boardId, local); pendingRecovery.current = next; setRecoveries(next); }
|
||||
}
|
||||
function resolveRecovery(boardId: string, restore: boolean) {
|
||||
if(migrationBlocked())return;
|
||||
const draft = pendingRecovery.current.get(boardId);
|
||||
if (restore && draft) update(boardId, { type: "replace", document: draft });
|
||||
const next = new Map(pendingRecovery.current); next.delete(boardId); pendingRecovery.current = next; setRecoveries(next);
|
||||
@@ -68,7 +81,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
if (snapshot && !writers.current.has(root)) install(root, snapshot);
|
||||
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
|
||||
const update = (boardId: string, action: CanvasAction) => {
|
||||
if (locked.current || retryOperation.current) return;
|
||||
if (migrationBlocked() || locked.current || retryOperation.current) return;
|
||||
const before = current.current.get(boardId); if (!before) return;
|
||||
const result = canvasReducer(history(before), action);
|
||||
const protectedIDs = protectedNodes(boardId);
|
||||
@@ -84,7 +97,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
const dispatch = (action: CanvasAction, boardId = root) => update(boardId, action);
|
||||
const nodeAction = (id: string, action: (id: string) => CanvasAction) => { const ref = view.current.nodeRefs.get(id); if (ref) update(ref.boardId, action(ref.id)); };
|
||||
const nodesChanged = (changes: NodeChange<CanvasNode>[]) => {
|
||||
if (locked.current || retryOperation.current) return;
|
||||
if (migrationBlocked() || locked.current || retryOperation.current) return;
|
||||
const groups = new Map<string, NodeChange<CanvasNode>[]>();
|
||||
const moves = new Map(changes.flatMap((change) => change.type === "position" && change.position ? [[change.id, change.position] as const] : []));
|
||||
for (const change of changes) {
|
||||
@@ -180,8 +193,9 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [documents, retryLoad]);
|
||||
useEffect(() => {
|
||||
if (paused) return;
|
||||
if (paused || migrationBlocked()) return;
|
||||
const backup = setTimeout(() => {
|
||||
if(migrationBlocked())return;
|
||||
for (const [boardId, document] of current.current) try {
|
||||
if (pendingRecovery.current.has(boardId)) continue;
|
||||
const writer = writers.current.get(boardId);
|
||||
@@ -189,14 +203,14 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
} catch { setError("local_backup_failed"); }
|
||||
}, 250);
|
||||
const save = setTimeout(() => {
|
||||
if (locked.current || retryOperation.current || pause.current || members.current.length || dragging.current) return;
|
||||
if (migrationBlocked() || locked.current || retryOperation.current || pause.current || members.current.length || dragging.current) return;
|
||||
for (const [boardId, document] of current.current) if (!pendingRecovery.current.has(boardId)) void writers.current.get(boardId)?.enqueue(document);
|
||||
}, 800);
|
||||
return () => { clearTimeout(backup); clearTimeout(save); };
|
||||
}, [documents, paused, workspace, memberJobs]);
|
||||
}, [documents, paused, workspace, memberJobs, migrationHeld]);
|
||||
useEffect(() => {
|
||||
const backup = () => {
|
||||
if (pause.current) return;
|
||||
if (pause.current || migrationBlocked()) return;
|
||||
for (const [boardId, document] of current.current) {
|
||||
if (pendingRecovery.current.has(boardId)) continue;
|
||||
try { const writer = writers.current.get(boardId); localStorage.setItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`, JSON.stringify({ document: canvasPayload(document), baseVersion: writer?.version || 1, dirty: writer ? writer.needsSave(document) : false })); } catch { /* Existing draft is retained if storage is full. */ }
|
||||
@@ -214,8 +228,35 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
await writer.enqueue(saving); await writer.settled(); if (writer.error) throw new Error(writer.error);
|
||||
}
|
||||
}
|
||||
async function prepareMigration(projectRoot:string){
|
||||
if(!migrationGate.current)throw new Error('migration_gate_required');
|
||||
if(locked.current||retryOperation.current||members.current.length||dragging.current||loading.current.size)throw new Error('canvas_busy');
|
||||
if(pause.current||pendingRecovery.current.size)throw new Error('draft_recovery_required');
|
||||
await Promise.all([...writers.current.values()].map(writer=>writer.settled()));
|
||||
await ensure(projectRoot,true);
|
||||
if(!mounted.current||migration&&!migration.isCurrentIdentity())throw new Error('identity_changed');
|
||||
if(pendingRecovery.current.size)throw new Error('draft_recovery_required');
|
||||
if([...current.current.values()].some(document=>document.nodes.some(node=>node.pendingUpload)))throw new Error('migration_pending_uploads');
|
||||
for(const[boardId,document]of current.current){
|
||||
const writer=writers.current.get(boardId);
|
||||
const local=readLocalRecovery(localStorage,`endoleap_canvas_v1:${workspace}:${boardId}`,writer?{version:writer.version,document:writer.document}:undefined);
|
||||
if(local&&canvasFingerprint(local,false)!==canvasFingerprint(document,false)){
|
||||
const next=new Map(pendingRecovery.current).set(boardId,local);pendingRecovery.current=next;setRecoveries(next);throw new Error('draft_recovery_required');
|
||||
}
|
||||
// Preserve the current view even if it closes while PATCH is in flight.
|
||||
localStorage.setItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`,JSON.stringify({document:canvasPayload(document),baseVersion:writer?.version||1,dirty:writer?writer.needsSave(document):false}));
|
||||
}
|
||||
migrationFlush.current=true;
|
||||
try{await flush();}finally{migrationFlush.current=false;}
|
||||
if(!mounted.current||migration&&!migration.isCurrentIdentity())throw new Error('identity_changed');
|
||||
for(const[boardId,document]of current.current){
|
||||
const writer=writers.current.get(boardId);
|
||||
if(writer?.error||writer?.needsSave(document))throw new Error(writer.error||'canvas_busy');
|
||||
localStorage.setItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`,JSON.stringify({document:canvasPayload(document),baseVersion:writer?.version||1,dirty:false}));
|
||||
}
|
||||
}
|
||||
async function exclusive<T>(operation: () => Promise<T>) {
|
||||
if (locked.current || pause.current || members.current.length) throw new Error("canvas_busy");
|
||||
if (migrationBlocked() || locked.current || pause.current || members.current.length) throw new Error("canvas_busy");
|
||||
locked.current = true; setBusy(true);
|
||||
try { return await operation(); } catch (error) { setError(error instanceof Error ? error.message : "request_failed"); throw error; }
|
||||
finally { locked.current = false; if (mounted.current) setBusy(false); }
|
||||
@@ -280,7 +321,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
// Ownership changes pause autosaves, not unrelated local editing. Server
|
||||
// acknowledgements merge against the persisted base, preserving intervening edits.
|
||||
async function runMembers() {
|
||||
if (memberRunning.current || !members.current.length) return;
|
||||
if (migrationBlocked() || memberRunning.current || !members.current.length) return;
|
||||
const job = members.current[0]; memberRunning.current = true; job.error = undefined; showMembers();
|
||||
try {
|
||||
if (!job.run) {
|
||||
@@ -345,12 +386,13 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
}
|
||||
}
|
||||
function moveMembers(parent: string, frame: string, source: string, target: string, ids: string[], participants: string[]) {
|
||||
if (locked.current || retryOperation.current || pause.current || members.current.some((job) => job.ids.some((id) => ids.includes(id)) && [job.source, job.target].includes(source))) return Promise.reject(new Error("canvas_busy"));
|
||||
if (migrationBlocked() || locked.current || retryOperation.current || pause.current || members.current.some((job) => job.ids.some((id) => ids.includes(id)) && [job.source, job.target].includes(source))) return Promise.reject(new Error("canvas_busy"));
|
||||
return new Promise<void>((resolve) => {
|
||||
members.current.push({ parent, frame, source, target, ids, participants, resolve }); showMembers(); void runMembers();
|
||||
});
|
||||
}
|
||||
function resolveImageUpload(id: string, asset?: { url: string; id: string }, size?: {width:number;height:number}) {
|
||||
if(migrationBlocked())return;
|
||||
const resolve = (document: CanvasDocument): CanvasDocument => ({ ...document, nodes: document.nodes.map((node) => {
|
||||
if (node.pendingUpload !== id) return node;
|
||||
const fitted=node.data.kind==="plain-image"&&node.width===320&&node.height===240?size:undefined;
|
||||
@@ -362,6 +404,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
publish(new Map([...current.current].map(([board,doc])=>[board,resolve(doc)])));
|
||||
}
|
||||
function movePendingImage(source: string, target: string, id: string) {
|
||||
if(migrationBlocked())throw new Error("canvas_busy");
|
||||
const from = current.current.get(source)!, to = current.current.get(target)!;
|
||||
const node = from.nodes.find((node)=>node.id===id); if (!node?.pendingUpload || to.nodes.length>=500 || to.nodes.some((item)=>item.id===id)) throw new Error("invalid_reference");
|
||||
const projected=projectCanvasTree(root,current.current), a=projected.boards.get(source)!,b=projected.boards.get(target)!;
|
||||
@@ -395,7 +438,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
}
|
||||
async function undoAction(isRedo = false) {
|
||||
const stack = isRedo ? redo.current : undo.current, destination = isRedo ? undo.current : redo.current;
|
||||
const original = stack.at(-1); if (!original || locked.current || retryOperation.current || members.current.length) return;
|
||||
const original = stack.at(-1); if (!original || migrationBlocked() || locked.current || retryOperation.current || members.current.length) return;
|
||||
const alias = (id: string) => { const seen = new Set<string>(); while (boardAliases.current.has(id) && !seen.has(id)) { seen.add(id); id = boardAliases.current.get(id)!; } return id; };
|
||||
const remapDocument = (document: CanvasDocument) => ({ ...document, nodes: document.nodes.map((node) => node.data.subgraph ? { ...node, data: { ...node.data, subgraph: { boardId: alias(node.data.subgraph.boardId) } } } : node) });
|
||||
const entry: Entry = isRedo ? "id" in original ? { ...original, boardId: alias(original.boardId), participants: original.participants.map(alias), payload: { ...original.payload, ...(typeof original.payload.source_board_id === "string" ? { source_board_id: alias(original.payload.source_board_id) } : {}), ...(typeof original.payload.target_board_id === "string" ? { target_board_id: alias(original.payload.target_board_id) } : {}), ...Object.fromEntries(["source", "target"].flatMap((key) => { const endpoint = original.payload[key] as { board_id?: string } | undefined; return endpoint?.board_id ? [[key, { ...endpoint, board_id: alias(endpoint.board_id) }]] : []; })) } } : { ...original, boardId: alias(original.boardId), before: remapDocument(original.before), ...(original.related?{related:new Map([...original.related].map(([id,doc])=>[alias(id),remapDocument(doc)]))}:{}) } : original;
|
||||
@@ -430,5 +473,5 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
|
||||
tick((v) => v + 1);
|
||||
}
|
||||
const retry = async () => { setError(""); setRetryLoad((v) => v + 1); for (const writer of writers.current.values()) await writer.retry(); };
|
||||
return { documents, current, views, resolveImageUpload, movePendingImage, memberJobs, protectedNodes, setDragging, moveMembers, retryMembers: () => void runMembers(), transactionBusy: !!memberJobs.length, pendingCommand, retryPending: () => retryOperation.current ? exclusive(retryOperation.current) : Promise.resolve(null), prepareApply, recoveries, resolveRecovery, ensure, exportDocuments, dispatch, nodeAction, nodesChanged, edgesChanged, patchEdge, select, flush, command, refresh, read, busy: busy || pendingCommand, error, status, retry, undo: () => undoAction(), redo: () => undoAction(true), canUndo: !memberJobs.length && !!undo.current.length, canRedo: !memberJobs.length && !!redo.current.length, writers, rootDocument: documents.get(root)!, setError };
|
||||
return { migrationHeld,setMigrationHeld,prepareMigration,isMigrationHeld:migrationBlocked, documents, current, views, resolveImageUpload, movePendingImage, memberJobs, protectedNodes, setDragging, moveMembers, retryMembers: () => void runMembers(), transactionBusy: !!memberJobs.length, pendingCommand, retryPending: () => retryOperation.current ? exclusive(retryOperation.current) : Promise.resolve(null), prepareApply, recoveries, resolveRecovery, ensure, exportDocuments, dispatch, nodeAction, nodesChanged, edgesChanged, patchEdge, select, flush, command, refresh, read, busy: busy || pendingCommand || migrationHeld, error, status, retry, undo: () => undoAction(), redo: () => undoAction(true), canUndo: !memberJobs.length && !!undo.current.length, canRedo: !memberJobs.length && !!redo.current.length, writers, rootDocument: documents.get(root)!, setError };
|
||||
}
|
||||
|
||||
@@ -43,5 +43,5 @@ export function useImageUploads(workspace: string, commit: (draft: ImageUploadDr
|
||||
void run(job);
|
||||
return job;
|
||||
}
|
||||
return { jobs, active: jobs.some((job) => job.status === "uploading"), start, retry: (id: string) => { const job = pending.current.get(id); if (job) void run(job); }, remove, loaded: (id: string) => { if (pending.current.get(id)?.status === "uploaded") remove(id); } };
|
||||
return { jobs, hasPending:()=>[...pending.current.values()].some(job=>job.status!=="uploaded"), active: jobs.some((job) => job.status === "uploading"), start, retry: (id: string) => { const job = pending.current.get(id); if (job) void run(job); }, remove, loaded: (id: string) => { if (pending.current.get(id)?.status === "uploaded") remove(id); } };
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import { api, errorText } from "@/lib/api";
|
||||
import { readDemoBoards } from "@/lib/demo-workspace";
|
||||
import { matchesRef, type Board } from "@/lib/workspace";
|
||||
import { loadCanvasEntry } from "@/lib/canvas-entry";
|
||||
import {createBrowserCanvasMigration,type LegacyMigrationContext} from "@/lib/browser-canvas-migration";
|
||||
import {readCanvasMigration} from "@/lib/canvas-migration";
|
||||
import type { ProjectEngine } from "@/lib/canvas-runtime/workspace";
|
||||
import { useCanvasRuntimeHost, useCanvasRenderers } from "./canvas-runtime-host";
|
||||
import type { CanvasSnapshot } from "@/lib/canvas-sync";
|
||||
@@ -18,7 +20,7 @@ const GraphCanvas = dynamic(() => import("@/components/canvas/graph-canvas"), {
|
||||
|
||||
const HeadlessGraphCanvas = dynamic(() => import("@/components/canvas/headless-graph-canvas"), { ssr: false, loading: () => <p role="status">正在准备画布…</p> });
|
||||
const LoadingGraphCanvas=dynamic(()=>import("@/components/canvas/loading-graph-canvas"),{ssr:false});
|
||||
type Loaded = { board: Board; snapshot?: CanvasSnapshot; route: string; engine?: ProjectEngine };
|
||||
type Loaded = { board: Board; snapshot?: CanvasSnapshot; route: string; engine?: ProjectEngine; migration?:LegacyMigrationContext };
|
||||
export default function GraphPage({ workspaceId, boardId }: { workspaceId: string; boardId: string }) {
|
||||
const renderers=useCanvasRenderers();
|
||||
const demo = useSearchParams().get("demo") === "1";
|
||||
@@ -57,7 +59,15 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin
|
||||
readModel:input=>{const identity=runtimeHost.identity();if(identity.authEpoch!==startingIdentity.authEpoch&&identity.accountId!==input.accountId)throw new Error('identity_changed');currentIdentity=runtimeHost.bindAccount(input.accountId);return runtimeHost.loadModel(input.accountId,input.workspaceId,input.canvasId,{signal:input.signal,navigationGeneration:input.navigationGeneration,onPreview:input.onPreview});},
|
||||
}).then(async result => {
|
||||
if (!active||currentIdentity&&!currentIdentity()) return;
|
||||
if (result.mode === 'legacy') { setLoaded({ board: result.board, snapshot: result.snapshot, route });setPreview(null); return; }
|
||||
if (result.mode === 'legacy') {
|
||||
const migration:LegacyMigrationContext={accountId:result.profile.id,rootId:result.migration?.rootId,registry:runtimeHost.registry,isCurrentIdentity:()=>!!currentIdentity?.(),onApplied:()=>{if(active&¤tIdentity?.())setRetry(value=>value+1);}};
|
||||
setLoaded({board:result.board,snapshot:result.snapshot,route,migration});setPreview(null);return;
|
||||
}
|
||||
const pending=readCanvasMigration(localStorage,{accountId:result.profile.id,workspaceId:result.board.workspace_id});
|
||||
if(pending?.phase==='pending'&&pending.rootId===result.model.document.projectId){
|
||||
try{await createBrowserCanvasMigration({scope:{accountId:result.profile.id,workspaceId:result.board.workspace_id},registry:runtimeHost.registry,isCurrentIdentity:()=>active&&!!currentIdentity?.(),signal:controller.signal}).resume();}
|
||||
catch(error){reportBackground(error);}
|
||||
}
|
||||
while(pumping)await pumping;
|
||||
if(!active||!currentIdentity?.())return;
|
||||
const engine = await runtimeHost.workspace(result.profile.id, result.board.workspace_id).open(result.model.document, result.model.versions,{sourceToken:result.model.sourceToken});
|
||||
@@ -75,7 +85,7 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin
|
||||
}, [board, workspaceId, setActiveBoard]);
|
||||
const displayBoard = board && activeBoard?.id === board.id && activeBoard.workspace_id === board.workspace_id ? { ...board, title: activeBoard.title } : board;
|
||||
return <main className={styles.graph} aria-label={displayBoard ? displayBoard.title + " · 无限图谱" : "无限图谱"} aria-busy={!current && !error}>
|
||||
{displayBoard && current?.engine ? <HeadlessGraphCanvas key={displayBoard.workspace_id + ":" + displayBoard.id} board={displayBoard} engine={current.engine} renderers={renderers} initialViewport={previewViewport.current} /> : displayBoard&¤tPreview ? <LoadingGraphCanvas key={displayBoard.workspace_id+":"+displayBoard.id} preview={currentPreview.value} canvasId={displayBoard.id} workspaceId={displayBoard.workspace_id} registry={runtimeHost.registry} renderers={renderers} onViewport={viewport=>{previewViewport.current=viewport;}} /> : displayBoard ? <GraphCanvas key={displayBoard.workspace_id + ":" + displayBoard.id} board={displayBoard} snapshot={current?.snapshot} /> : <div className={styles.graphSurface} aria-label="正在读取画布" />}
|
||||
{displayBoard && current?.engine ? <HeadlessGraphCanvas key={displayBoard.workspace_id + ":" + displayBoard.id} board={displayBoard} engine={current.engine} renderers={renderers} initialViewport={previewViewport.current} /> : displayBoard&¤tPreview ? <LoadingGraphCanvas key={displayBoard.workspace_id+":"+displayBoard.id} preview={currentPreview.value} canvasId={displayBoard.id} workspaceId={displayBoard.workspace_id} registry={runtimeHost.registry} renderers={renderers} onViewport={viewport=>{previewViewport.current=viewport;}} /> : displayBoard ? <GraphCanvas key={(current?.migration?.accountId??"demo")+":"+displayBoard.workspace_id+":"+displayBoard.id} board={displayBoard} snapshot={current?.snapshot} migration={current?.migration} /> : <div className={styles.graphSurface} aria-label="正在读取画布" />}
|
||||
{error ? <div className={styles.graphStatus} role="alert">{error} <button onClick={() => setRetry((value) => value + 1)}>重新加载</button></div> : !board && <p className={styles.graphStatus} role="status">正在打开图谱…</p>}
|
||||
</main>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user