Files
Endoleap/src/components/canvas/use-canvas-tree.ts
T

584 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 旧版画布树的状态与操作协调Hook。documents按boardId保存已加载快照,views把嵌套子画布投影为当前ReactFlow场景。
* 每个board有独立CanvasSync writer,本地历史和服务端结构事务共用两个栈,成员迁移另用串行内存队列。
* 该实现保留旧快照协议与本地草稿恢复,不等同于新版engine的统一提交根、持久ledger和恢复状态机。
* 调用方应按根/工作区身份重挂载;refs提供异步操作读取的最新状态,React state负责发布界面变化。
*/
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { EdgeChange, NodeChange } from "@xyflow/react";
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, 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";
/** 命令返回的快照额外携带board_id,用于把多画布结果安装回各自writer。 */
type OwnedSnapshot = CanvasSnapshot & { board_id: string };
/** 兼容多个旧命令端点的结果包络,可能返回父/子/多快照、事务身份、归档列表和版本;各使用路径再解码快照。 */
export type CanvasCommandResult = { transaction_id?: string; parent?: OwnedSnapshot; child?: OwnedSnapshot; snapshots?: OwnedSnapshot[]; children?: OwnedSnapshot[]; canvas_version?: number; applied_versions?: Record<string, number>; archived_board_ids?: string[]; undo_receipt?: { expires_at: string }; [key: string]: unknown };
/** 本地历史保存操作前整份画布,可附关联画布;pendingMove标记尚未上传图片的跨画布移动,便于后来按服务端归属撤销。 */
type LocalHistory = { boardId: string; before: CanvasDocument; related?: Map<string, CanvasDocument>; pendingMove?: { id: string; source: string; target: string } };
/** 服务端结构事务历史保存可重放的路径/负载、事务ID和参与画布;撤销请求服务端undo,重做重新执行原命令。 */
type TransactionHistory = { boardId: string; path: string; payload: Record<string, unknown>; id: string; participants: string[] };
/** 两种历史条目共用undo/redo栈,通过是否有id字段区分;不是每次pointermove都直接创建条目。 */
type Entry = LocalHistory | TransactionHistory;
/** 成员归属迁移的FIFO任务;run在准备后捕获固定body/key供不确定失败重试,refresh表示确定冲突后需刷新基线。 */
type MemberJob = { source: string; target: string; ids: string[]; parent: string; frame: string; participants: string[]; error?: string; refresh?: boolean; run?: () => Promise<void>; resolve: () => void };
/** 只为一次reducer运算建立临时空历史,借其past是否变化判断该动作是否应进入本Hook自己的历史栈。 */
const history = (document: CanvasDocument): CanvasHistory => ({ past: [], present: document, future: [] });
/** initial建立根画布,snapshot决定是否连接服务端writer;paused阻止保存/独占事务等准入,migration额外限制旧写入。
* parentBoardId用于补读祖先快照;导出、跨分区移动和事务可能按需加载更多画布。 */
export function useCanvasTree(root: string, workspace: string, initial: CanvasDocument, snapshot?: CanvasSnapshot, paused = false, parentBoardId?: string, migration?: {accountId:string;isCurrentIdentity():boolean}) {
// 有迁移上下文时先关旧编辑入口,等迁移协调组件取得共享租约再开放;flush只有迁移准备阶段临时启用。
const migrationGate=useRef(!!migration);
const migrationFlush=useRef(false);
const [migrationHeld,setMigrationState]=useState(!!migration);
/** 同步检查门闩和账号身份,事件处理无需等待React的migrationHeld重新渲染。 */
const migrationBlocked=()=>migrationGate.current||!!migration&&!migration.isCurrentIdentity();
/** 同时更新同步门闩与界面状态;只更新state会留下同一事件轮次中的旧写入窗口。 */
function setMigrationHeld(value:boolean){migrationGate.current=value;setMigrationState(value);}
/** 旧接口请求护栏:await前后检查账号,写请求受迁移门闩限制;迁移排空时只放行canvas PATCH。
* X-Expected-Account要求代理校验目标账号,避免切号后旧请求误用新会话。 */
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;
};
// documents是已加载board的模型表,current立即跟随发布供异步回调读取;status/error是旧writer汇总显示值。
const [documents, setDocuments] = useState(new Map([[root, initial]]));
const current = useRef(documents); current.current = documents;
const [status, setStatus] = useState("saved");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [memberJobs, setMemberJobs] = useState<MemberJob[]>([]);
// capability缓存查询Promise;members是实际内存队列,memberJobs只是供React显示的数组副本。
const memberCapability = useRef<Promise<boolean> | null>(null);
const members = useRef<MemberJob[]>([]), memberRunning = useRef(false), dragging = useRef(false);
/** 仅挂载期间发布队列副本;任务对象仍共享引用,修改error等字段后需显式通知。 */
const showMembers = () => { if (mounted.current) setMemberJobs([...members.current]); };
/** 拖动期间延后自动保存,结束时发布新Map触发防抖重新评估;不在这里执行成员归属变化。 */
function setDragging(value: boolean) { dragging.current = value; if (!value) publish(new Map(current.current)); }
/** 成员任务涉及的节点暂不能再被普通动作改动;队列非空时也保护子图入口几何及结构,避免请求基准漂移。 */
function protectedNodes(boardId: string) {
const ids = new Set(members.current.flatMap((job) => [job.source, job.target].includes(boardId) ? job.ids : []));
if (members.current.length) for (const node of current.current.get(boardId)?.nodes || []) if (node.data.subgraph) ids.add(node.id);
return ids;
}
const [pendingCommand, setPendingCommand] = useState(false);
// 不确定命令失败时保留同一execute闭包,pendingCommand驱动UI;locked只管当前独占异步操作。
const retryOperation = useRef<(() => Promise<CanvasCommandResult>) | null>(null);
const locked = useRef(false), mounted = useRef(true), pause = useRef(paused); pause.current = paused;
// writer持每画布ACK基线与保存队列,loading去重子画布读取;二者都不是documents的替代存储。
const writers = useRef(new Map<string, CanvasSync>());
const loading = useRef(new Set<string>());
const [retryLoad, setRetryLoad] = useState(0);
const [recoveries, setRecoveries] = useState(new Map<string, CanvasDocument>());
const pendingRecovery = useRef(recoveries); pendingRecovery.current = recoveries;
/** 以当前服务端快照筛选有内容差异的本地恢复候选,按board记录,等待用户决定;不证明可安全覆盖模型。 */
function inspectRecovery(boardId: string, value: CanvasSnapshot) {
const local = readLocalRecovery(localStorage, `endoleap_canvas_v1:${workspace}:${boardId}`, value);
if (local) { const next = new Map(pendingRecovery.current).set(boardId, local); pendingRecovery.current = next; setRecoveries(next); }
}
/** 可编辑时按选择替换本地文档或采用现状,移除恢复候选和草稿记录;远端写入仍走后续writer。 */
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);
localStorage.removeItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`);
}
// 两个ref数组作为历史栈,boardAliases记录重做子图创建后新旧boardID对应;tick用于仅栈变化时通知UI。
const undo = useRef<Entry[]>([]), redo = useRef<Entry[]>([]);
const boardAliases = useRef(new Map<string, string>());
const [, tick] = useState(0);
// 根据根与documents派生投影索引,nodeRefs/edgeRefs把场景ID映射回所属board、本地ID和坐标变换。
const views = useMemo(() => projectCanvasTree(root, documents), [root, documents]);
const view = useRef(views); view.current = views;
/** 先更新同步ref,再在挂载期间发布React state;等待中的操作可立即读取新Map。 */
function publish(next: Map<string, CanvasDocument>) { current.current = next; if (mounted.current) setDocuments(next); }
/** 将服务端快照作为writer基线:已有writer采用新ACK,否则在真实快照模式建立writer;这里只安装,不发保存。 */
function install(boardId: string, value: CanvasSnapshot) {
const writer = writers.current.get(boardId);
if (writer) writer.adopt(value);
else if (snapshot) writers.current.set(boardId, new CanvasSync(api, canvasPath(workspace, boardId), value, (state) => {
if (!mounted.current) return;
setStatus(state.status); if (state.error) setError(state.error);
}));
}
// Initialization never writes a snapshot to the server.
if (snapshot && !writers.current.has(root)) install(root, snapshot);
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
/** 普通单画布动作入口:检查门闩,经reducer计算新文档,保留成员迁移保护字段,再按需要记历史并发布。 */
const update = (boardId: string, action: CanvasAction) => {
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);
// 迁移中的普通节点只允许选中变化;未作为移动目标的子图入口保留原几何、subgraph/ports/presentation。
// reducer删掉的受保护节点也补回,避免异步成员请求到来时对象已被本地删除。
if (protectedIDs.size) result.present = { ...result.present, nodes: result.present.nodes.map((node) => {
const prior = before.nodes.find((item) => item.id === node.id);
if (!prior || !protectedIDs.has(node.id)) return node;
const moving = members.current.some((job) => [job.source, job.target].includes(boardId) && job.ids.includes(node.id));
return prior.data.subgraph && !moving ? { ...node, position: prior.position, width: prior.width, height: prior.height, data: { ...node.data, subgraph: prior.data.subgraph, ports: prior.data.ports, presentation: prior.data.presentation } } : { ...prior, selected: node.selected };
}).concat(before.nodes.filter((node) => protectedIDs.has(node.id) && !result.present.nodes.some((item) => item.id === node.id))) };
// reducer产生历史才入undo,普通更新路径最多留40项并清redo;其它事务/刷新路径不一定使用同一截断逻辑。
if (result.past.length) { undo.current.push({ boardId, before }); undo.current = undo.current.slice(-40); redo.current = []; }
publish(new Map(current.current).set(boardId, result.present));
};
/** 默认对根发动作,也允许调用方显式指定已加载board。 */
const dispatch = (action: CanvasAction, boardId = root) => update(boardId, action);
/** 从投影节点ID解析实际owner,再用原始节点ID构造动作,避免把场景复合ID写回快照。 */
const nodeAction = (id: string, action: (id: string) => CanvasAction) => { const ref = view.current.nodeRefs.get(id); if (ref) update(ref.boardId, action(ref.id)); };
/** 按owner分组ReactFlow节点变化;忽略add/replace,将场景位置和尺寸还原为所属board局部坐标。 */
const nodesChanged = (changes: NodeChange<CanvasNode>[]) => {
if (migrationBlocked() || locked.current || retryOperation.current) return;
const groups = new Map<string, NodeChange<CanvasNode>[]>();
// 同批父分区也在移动时先计算调整后的原点,避免对子节点重复施加父位移;尺寸再除投影scale。
const moves = new Map(changes.flatMap((change) => change.type === "position" && change.position ? [[change.id, change.position] as const] : []));
for (const change of changes) {
if (change.type === "add" || change.type === "replace") continue;
const ref = view.current.nodeRefs.get(change.id); if (!ref) continue;
const adjusted = referenceAfterMoves(root, current.current, view.current.boards, ref.boardId, moves);
const next = { ...change, id: ref.id };
if (next.type === "position" && next.position) next.position = { x: (next.position.x - adjusted.origin.x) / ref.scale, y: (next.position.y - adjusted.origin.y) / ref.scale };
// ResizeObserver只更新测量信息;非显式resizing不得把测量宽高当模型尺寸写回。
if (next.type === "dimensions") {
// ResizeObserver measurements are editor state. Only an explicit resize
// gesture may write node dimensions back to the owning snapshot.
if (!next.resizing) next.setAttributes = false;
if (next.dimensions) next.dimensions = { width: next.dimensions.width / ref.scale, height: next.dimensions.height / ref.scale };
}
groups.set(ref.boardId, [...(groups.get(ref.boardId) || []), next]);
}
for (const [boardId, changes] of groups) update(boardId, { type: "nodes", changes });
};
/** 按edgeRefs分组选择/删除等变化,忽略add/replace;最终update仍统一检查写入准入。 */
const edgesChanged = (changes: EdgeChange[]) => {
const groups = new Map<string, EdgeChange[]>();
for (const change of changes) {
if (change.type === "add" || change.type === "replace") continue;
const ref = view.current.edgeRefs.get(change.id); if (ref) groups.set(ref.boardId, [...(groups.get(ref.boardId) || []), { ...change, id: ref.id }]);
}
for (const [boardId, changes] of groups) update(boardId, { type: "edges", changes });
};
/** 曲线路径从场景坐标反变换回owner局部空间,再派发edge-data;transient交reducer决定历史语义。 */
const patchEdge = (id: string, data: EdgeData, transient = false) => {
const ref = view.current.edgeRefs.get(id); if (ref) update(ref.boardId, { type: "edge-data", id: ref.id, data: transformEdgeData(data, ref, true)!, transient });
};
/** 遍历已加载文档,只有目标owner/localID被选中,其余节点取消选择;不直接操作投影数组。 */
const select = (id: string) => {
for (const [boardId, document] of current.current) update(boardId, { type: "nodes", changes: document.nodes.map((node) => ({ type: "select", id: node.id, selected: view.current.nodeRefs.get(id)?.boardId === boardId && view.current.nodeRefs.get(id)?.id === node.id })) });
};
/** 读取并解码一个board的旧v2快照,可传取消信号;api层仍检查迁移账号身份。 */
const read = async (boardId: string, signal?: AbortSignal) => decodeSnapshot(await api<CanvasSnapshot>(canvasPath(workspace, boardId), { headers: { Accept: CANVAS_ACCEPT }, signal }));
/** 查询服务端是否支持成员迁移同时提交坐标;演示为false,成功结果复用,失败清缓存供按需重试。 */
function supportsMemberPositions(): Promise<boolean> {
if (!snapshot) return Promise.resolve(false);
if (!memberCapability.current) {
const capability: Promise<boolean> = api<{ subgraphs?: { member_positions?: boolean } }>(canvasPath(workspace, root) + "/capabilities", { headers: { Accept: CANVAS_ACCEPT } })
.then((value) => value.subgraphs?.member_positions === true)
.catch((error) => { if (memberCapability.current === capability) memberCapability.current = null; throw error; });
memberCapability.current = capability;
}
return memberCapability.current;
}
// 能力预取失败不视作确定不支持,真正成员操作仍可重新请求。
useEffect(() => {
void supportsMemberPositions().catch(() => { /* A failed prefetch is retried on demand, never cached as unsupported. */ });
}, [root, workspace, !!snapshot]);
/** 按需加载真实快照或演示本地草稿,装writer并检查恢复候选;recursive递归加载子图,祖先集合防环且限制深度。 */
async function ensure(boardId: string, recursive = false, ancestors = new Set<string>()): Promise<void> {
if (ancestors.has(boardId) || ancestors.size > 8) throw new Error("subgraph_cycle");
let document = current.current.get(boardId);
if (!document) {
const value = snapshot ? await read(boardId) : (() => { const raw = localStorage.getItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`); return { version: 1, document: raw ? decodeCanvas(JSON.stringify(JSON.parse(raw).document)) : { schema_version: 2 as const, nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } } }; })();
install(boardId, value); if (snapshot) inspectRecovery(boardId, value); document = value.document; publish(new Map(current.current).set(boardId, document));
}
if (recursive) { const next = new Set(ancestors); next.add(boardId); for (const node of document.nodes) if (node.data.subgraph) await ensure(node.data.subgraph.boardId, true, next); }
}
/** 独占期间完整加载根下可达子图,然后收集导出Map;不会把已加载但不可达的祖先或其它分支混入导出。 */
async function exportDocuments() {
return exclusive(async () => {
await ensure(root, true);
const reachable = new Map<string, CanvasDocument>();
/** 可达集合兼作去重;ensure已验证递归关系,此处只遍历当前已加载文档。 */
const collect = (id: string) => { const document = current.current.get(id)!; reachable.set(id, document); for (const node of document.nodes) if (node.data.subgraph && !reachable.has(node.data.subgraph.boardId)) collect(node.data.subgraph.boardId); };
collect(root); return reachable;
});
}
// 打开子画布时向上读取元数据与快照,补齐祖先关系;alive阻止离页后的采用,不在本effect中abort请求。
useEffect(() => {
if (!snapshot || !parentBoardId) return;
let alive = true;
void (async () => {
let id: string | undefined = parentBoardId; const seen = new Set<string>([root]);
while (id && alive) {
if (seen.has(id) || seen.size > 8) throw new Error("subgraph_cycle"); seen.add(id);
const [metadata, value]: [{ parent_board_id?: string }, CanvasSnapshot] = await Promise.all([api<{ parent_board_id?: string }>(`workspaces/${workspace}/materials/${id}`), read(id)]);
if (!alive) return;
if (!current.current.has(id)) { install(id, value); publish(new Map(current.current).set(id, value.document)); }
id = metadata.parent_board_id;
}
})().catch((error) => { if (alive) setError(error.message); });
return () => { alive = false; };
// Root identity is stable until this editor unmounts.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [parentBoardId, root, workspace]);
// 场景可见且未折叠的子图按需异步读取,loading去重;回包时重新确认仍被当前文档引用。
useEffect(() => {
for (const [boardId, document] of documents) for (const node of document.nodes) {
if (!view.current.boards.has(boardId)) continue;
const child = node.data.subgraph?.boardId;
if (!child || node.data.presentation?.collapsed || documents.has(child) || loading.current.has(child)) continue;
loading.current.add(child);
read(child).then((value) => {
if (!mounted.current || current.current.has(child) || ![...current.current.values()].some((doc) => doc.nodes.some((node) => node.data.subgraph?.boardId === child))) return;
install(child, value); inspectRecovery(child, value); publish(new Map(current.current).set(child, value.document));
}).catch((error) => { if (mounted.current) setError(error.message); }).finally(() => loading.current.delete(child));
}
// Values and writers are scoped to this mounted root/workspace.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [documents, retryLoad]);
// 文档变化防抖:250ms本地备份、800ms远端保存。恢复待决画布跳过,防止覆盖用户尚未选择的草稿。
// 远端另等独占锁、命令重试、成员任务和拖动结束;本地备份不需要等这些普通操作全部结束。
useEffect(() => {
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);
localStorage.setItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`, JSON.stringify({ document: canvasPayload(document), baseVersion: writer?.version || 1, dirty: writer ? writer.needsSave(document) : false }));
} catch { setError("local_backup_failed"); }
}, 250);
const save = setTimeout(() => {
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, migrationHeld]);
// pagehide/卸载尽量做本地同步备份,不等待远端flush;暂停/迁移仍禁止写,存储满时保留已有记录。
useEffect(() => {
/** 遍历最新内存文档,备份规范化payload与ACK版本/dirty;页面离开时错误不再触发界面更新。 */
const backup = () => {
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. */ }
}
};
window.addEventListener("pagehide", backup);
return () => { window.removeEventListener("pagehide", backup); backup(); };
}, [workspace]);
/** 串行排空各writer,任一恢复待决或writer错误就失败;deferMemberPositions保留待移动节点原ACK坐标。
* 支持原子坐标的成员接口会在归属事务里提交新位置,避免先把新位置单独写入旧owner。 */
async function flush(deferMemberPositions = false) {
for (const [boardId, document] of current.current) {
if (pendingRecovery.current.has(boardId)) throw new Error("draft_recovery_required");
const writer = writers.current.get(boardId); if (!writer) continue;
const deferred = deferMemberPositions ? new Set(members.current.filter((job) => job.source === boardId).flatMap((job) => job.ids)) : new Set<string>();
const saving = deferred.size ? { ...document, nodes: document.nodes.map((node) => deferred.has(node.id) ? { ...node, position: writer.document.nodes.find((item) => item.id === node.id)?.position || node.position } : node) } : document;
await writer.enqueue(saving); await writer.settled(); if (writer.error) throw new Error(writer.error);
}
}
/** 迁移前必须已关门,无事务/成员/拖动/加载,且恢复已处理;加载整棵迁移树并核查上传占位。
* 先备份本页,再临时放行PATCH排空旧writer;所有writer确认无待保存内容后把备份标为clean。 */
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}));
}
// 只在flush的try/finally窗口放开旧PATCH,其它写命令仍被api护栏拒绝。
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}));
}
}
/** 旧版内存独占执行器,阻断冲突准入并发布busy;异常记error后继续抛出,finally可靠释放锁。
* 它不是跨标签页锁,也不提供操作的整批服务端回滚。 */
async function exclusive<T>(operation: () => Promise<T>) {
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); }
}
/** 服务端快照更新时恢复本地选中标记,避免结构事务结果抹掉当前视图选择。 */
function keepSelection(document: CanvasDocument, local?: CanvasDocument): CanvasDocument {
return { ...document, nodes: document.nodes.map((node) => ({ ...node, selected: local?.nodes.find((item) => item.id === node.id)?.selected })), edges: document.edges.map((edge) => ({ ...edge, selected: local?.edges.find((item) => item.id === edge.id)?.selected })) };
}
/** 汇总命令返回的各类快照,按boardID去重,基于旧ACK做三方合并再安装新基线。 */
function accept(result: CanvasCommandResult) {
const next = new Map(current.current);
const values = [...new Map([...(result.parent ? [result.parent] : []), ...(result.child ? [result.child] : []), ...(result.snapshots || []), ...(result.children || [])].map((value)=>[value.board_id,value])).values()];
for (const value of values) {
const decoded=decodeSnapshot(value),local=current.current.get(value.board_id),base=writers.current.get(value.board_id)?.document;
const merged=local&&base?mergeCanvasDocuments(base,local,decoded.document):decoded.document;
install(value.board_id,decoded);next.set(value.board_id,keepSelection(merged,local));
}
// 子图归档后,将仍在内存上传的节点及相关边投到父画布,避免服务端快照中没有的占位被丢弃。
// 其余已归档文档及writer从当前表移除,不在这里删除上传任务的原文件。
for (const id of result.archived_board_ids || []) {
const local=current.current.get(id);
const owner=[...current.current].find(([,doc])=>doc.nodes.some((node)=>node.data.subgraph?.boardId===id));
if(local&&owner){
const frame=owner[1].nodes.find((node)=>node.data.subgraph?.boardId===id)!,target=next.get(owner[0])!;
const scale=frame.data.presentation?.contentScale||1,ids=new Set(local.nodes.filter((node)=>node.pendingUpload).map((node)=>node.id));
const moved=local.nodes.filter((node)=>ids.has(node.id)).map((node)=>({...node,position:{x:frame.position.x+24+node.position.x*scale,y:frame.position.y+36+node.position.y*scale},width:node.width!*scale,height:node.height!*scale}));
next.set(owner[0],{...target,nodes:[...target.nodes,...moved],edges:[...target.edges,...local.edges.filter((edge)=>ids.has(edge.source)||ids.has(edge.target)).map((edge)=>({...edge,data:transformEdgeData(edge.data,{boardId:id,id:edge.id,origin:{x:frame.position.x+24,y:frame.position.y+36},scale})}))]});
}
next.delete(id);writers.current.delete(id);
}
publish(next);
return [...new Set(values.map((value) => value.board_id))];
}
/** 结构/AI等旧命令入口:加载参与writer、flush,再冻结expected_versions、If-Match和幂等key。
* execute闭包在不确定失败后复用同一body/key/version;这些恢复信息只在内存,不保证刷新后继续。 */
async function command(boardId: string, path: string, payload: Record<string, unknown>, participants?: string[], record = true, versionBoard = boardId) {
if (retryOperation.current) throw new Error("canvas_busy");
return exclusive(async () => {
for (const id of participants || []) if (!writers.current.has(id)) {
const value = await read(id); install(id, value); publish(new Map(current.current).set(id, value.document));
}
await flush();
const body = { ...payload, ...(participants ? { expected_versions: Object.fromEntries(participants.map((id) => [id, writers.current.get(id)?.version])) } : {}) };
const key = crypto.randomUUID(), version = writers.current.get(versionBoard)?.version || 1;
/** 发送、采用回执、按需补读目标快照,并把有transaction_id的结果记入服务端事务历史。 */
const execute = async (): Promise<CanvasCommandResult> => {
try {
const result = await api<CanvasCommandResult>(canvasPath(workspace, boardId) + "/" + path, { method: "POST", headers: { Accept: CANVAS_ACCEPT, "If-Match": `"${version}"`, "Idempotency-Key": key }, body: JSON.stringify(body) });
const changed = accept(result);
// 改变子图/事务后通知侧栏刷新目录;没有快照但有版本结果时补读并保留期间的本地编辑。
if (path.startsWith("subgraphs") || path.startsWith("transactions/")) window.dispatchEvent(new window.CustomEvent("endoleap:board-directory-changed", { detail: { workspace } }));
if (!changed.length && result.canvas_version) {
const target=typeof result.board_id === "string"?result.board_id:boardId,remote=await read(target),local=current.current.get(target),base=writers.current.get(target)?.document;
const merged=base&&local?mergeCanvasDocuments(base,local,remote.document):remote.document;
install(target,remote);publish(new Map(current.current).set(target,keepSelection(merged,local)));
}
// record=false用于undo/redo等已有历史流程,避免将历史遍历再次当新操作压栈。
if (record && result.transaction_id) { undo.current.push({ boardId, path, payload, id: result.transaction_id, participants: changed }); redo.current = []; tick((v) => v + 1); }
retryOperation.current = null; setPendingCommand(false); setError("");
return result;
} catch (error) {
const code = error instanceof Error ? error.message : "request_failed";
// 网络/上游/解码等不确定失败保留execute并阻断普通编辑;确定拒绝清重试入口,错误仍交调用方处理。
if (error instanceof TypeError || error instanceof ApiError && error.status >= 500 || ["upstream_timeout", "upstream_unavailable", "request_failed", "Failed to fetch", "internal_error", "invalid_canvas_snapshot"].includes(code)) { retryOperation.current = execute; setPendingCommand(true); }
else { retryOperation.current = null; setPendingCommand(false); }
throw error;
}
};
return execute();
});
}
// Ownership changes pause autosaves, not unrelated local editing. Server
// acknowledgements merge against the persisted base, preserving intervening edits.
/** 成员队列只跑队首;归属移动期间暂停autosave,允许不相关本地编辑,结果按捕获ACK合并。
* 只有成功才shift并resolve;失败任务留队首供重试,Promise不在失败时假装完成。 */
async function runMembers() {
if (migrationBlocked() || memberRunning.current || !members.current.length) return;
const job = members.current[0]; memberRunning.current = true; job.error = undefined; showMembers();
try {
if (!job.run) {
for (const id of job.participants) if (!writers.current.has(id)) await ensure(id);
// 412等确定冲突后的重新准备:读参与快照,先三方合并本地变化,再更新各writer基线。
if (job.refresh) {
const refreshed = await Promise.all(job.participants.map(async (id) => [id, await read(id)] as const));
const next = new Map(current.current);
for (const [id, remote] of refreshed) {
const base = writers.current.get(id)!.document, local = current.current.get(id)!;
next.set(id, mergeCanvasDocuments(base, local, remote.document));
}
for (const [id, remote] of refreshed) install(id, remote);
publish(next); job.refresh = false;
}
// 支持坐标时flush延后待移动节点位置;否则先按旧保存行为排空,再捕获参与文档作为合并基线。
const atomic = await supportsMemberPositions();
await flush(atomic === true);
const bases = new Map(job.participants.map((id) => [id, writers.current.get(id)!.document]));
const source = current.current.get(job.source)!;
const positions = Object.fromEntries(job.ids.map((id) => {
const node = source.nodes.find((node) => node.id === id); if (!node) throw new Error("invalid_reference");
return [id, node.position];
}));
// 请求内容含参与版本和可选局部坐标;创建job.run后重试不得换body或幂等key,除非确定原请求未应用。
const body = JSON.stringify({ source_board_id: job.source, target_board_id: job.target, node_ids: job.ids, ...(atomic ? { positions } : {}),
expected_versions: Object.fromEntries(job.participants.map((id) => [id, writers.current.get(id)!.version])) });
const key = crypto.randomUUID(), historyIndex = undo.current.length;
/** 一次准备生成的可重试闭包;要求回执覆盖所有参与画布,不能凭缺失快照移动本地owner。 */
job.run = async () => {
const result = await api<CanvasCommandResult>(canvasPath(workspace, job.parent) + `/subgraphs/${job.frame}/members`, {
method: "POST", headers: { Accept: CANVAS_ACCEPT, "Idempotency-Key": key }, body });
const snapshots = [...(result.parent ? [result.parent] : []), ...(result.snapshots || [])];
const values = new Map(snapshots.map((value) => [value.board_id, { ...decodeSnapshot(value), board_id: value.board_id }]));
if (!job.participants.every((id) => values.has(id))) throw new Error("invalid_canvas_snapshot");
/** 移动结果已含新坐标,合并前只把源节点暂存位置还原到基线,避免重复施加坐标变化。 */
const normalize = (id: string, local: CanvasDocument) => id !== job.source ? local : { ...local, nodes: local.nodes.map((node) => job.ids.includes(node.id) ? { ...node, position: bases.get(id)!.nodes.find((item) => item.id === node.id)!.position } : node) };
/** 参与画布基于原ACK+归一化本地内容+返回快照重放,未参与文档原样保留。 */
const rebaseLocal=(id:string,doc:CanvasDocument)=>values.has(id)?mergeCanvasDocuments(bases.get(id)!,normalize(id,doc),values.get(id)!.document):doc;
// 请求期间新增本地历史也基于新owner结果调整;开始前历史和服务端事务条目保持原语义。
const rebasedHistory = undo.current.map((entry,index)=>index<historyIndex||"id" in entry?entry:{...entry,before:rebaseLocal(entry.boardId,entry.before),...(entry.related?{related:new Map([...entry.related].map(([id,doc])=>[id,rebaseLocal(id,doc)]))}:{})});
const next = new Map(current.current);
for (const [id, value] of values) {
const base = bases.get(id)!; if (!base) throw new Error("invalid_canvas_snapshot");
let local = current.current.get(id)!;
// The server owns these nodes after the move. Ignore only their
// staged coordinates; all other local edits participate in the merge.
if (id === job.source) local = { ...local, nodes: local.nodes.map((node) => job.ids.includes(node.id) ? { ...node, position: base.nodes.find((item) => item.id === node.id)!.position } : node) };
const merged = keepSelection(mergeCanvasDocuments(base, local, value.document), local);
next.set(id, id !== job.target ? merged : { ...merged, nodes: merged.nodes.map((node) => job.ids.includes(node.id) ? { ...node, selected: current.current.get(job.source)?.nodes.find((item) => item.id === node.id)?.selected } : node) });
}
for (const [id, value] of values) install(id, value);
publish(next);
undo.current = rebasedHistory;
// 将迁移事务插回开始时的historyIndex,保留请求期间后来编辑的顺序,并清除旧redo。
if (result.transaction_id) { undo.current.splice(historyIndex, 0, { boardId: job.parent, path: `subgraphs/${job.frame}/members`, payload: { source_board_id: job.source, target_board_id: job.target, node_ids: job.ids, positions }, id: result.transaction_id, participants: [...values.keys()] }); redo.current = []; }
};
}
await job.run();
members.current.shift(); showMembers(); job.resolve();
} catch (error) {
job.error = error instanceof Error ? error.message : "request_failed";
// A definite rejection performed zero writes. A retry may prepare fresh
// versions; ambiguous transport failures must retain the exact body/key.
// 确定拒绝可清冻结闭包重新准备;412还需刷新版本。不确定传输失败继续保留原job.run。
if (error instanceof ApiError && error.status < 500) { job.run = undefined; job.refresh = error.status === 412; }
showMembers();
} finally {
memberRunning.current = false;
if (members.current.length && !members.current[0].error) void runMembers();
}
}
/** 检查准入和重叠成员任务后入FIFO队列;返回Promise由成功出队时resolve,错误在memberJobs中供UI重试。 */
function moveMembers(parent: string, frame: string, source: string, target: string, ids: string[], participants: string[]) {
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();
});
}
/** 将上传资源/尺寸绑定到所有当前文档及本地历史,避免撤销恢复旧Blob占位。
* 无asset时保留pendingUpload;尺寸仅在纯图片仍为320×240默认值时采用,不覆盖用户已调整大小。 */
function resolveImageUpload(id: string, asset?: { url: string; id: string }, size?: {width:number;height:number}) {
if(migrationBlocked())return;
/** 只匹配pendingUpload身份,移除或保留占位标记,并按文档schema选择是否写assetId。 */
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;
const {pendingUpload,...ready}=node;
return { ...ready, ...(!asset?{pendingUpload}:{}), ...(fitted?{...fitted,style:{...ready.style,...fitted}}:{}), data: { ...ready.data, ...(asset?{src:asset.url,...(document.schema_version===2?{assetId:asset.id}:{})}:{}) } };
}) });
/** 仅改本地快照历史(含related),服务端事务条目仍由服务端重放解释。 */
const rebase = (entry: Entry): Entry => "id" in entry ? entry : { ...entry, before: resolve(entry.before), ...(entry.related ? {related:new Map([...entry.related].map(([id,doc])=>[id,resolve(doc)]))} : {}) };
undo.current = undo.current.map(rebase); redo.current = redo.current.map(rebase);
publish(new Map([...current.current].map(([board,doc])=>[board,resolve(doc)])));
}
/** 未上传且无关联边的图片可先在内存跨分区移动;校验容量/ID后按两owner原点和scale换算。
* 保存双画布before及pendingMove历史,上传完成后若进入服务端,撤销可能需要真实成员事务。 */
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)!;
if (!a || !b || from.edges.some((edge)=>edge.source===id||edge.target===id)) throw new Error("图片上传完成后即可跨分区移动连线。");
const moved={...node,position:{x:(a.origin.x+node.position.x*a.scale-b.origin.x)/b.scale,y:(a.origin.y+node.position.y*a.scale-b.origin.y)/b.scale},width:node.width!*a.scale/b.scale,height:node.height!*a.scale/b.scale};
undo.current.push({boardId:source,before:from,related:new Map([[target,to]]),pendingMove:{id,source,target}});redo.current=[];
publish(new Map(current.current).set(source,{...from,nodes:from.nodes.filter((node)=>node.id!==id)}).set(target,{...to,nodes:[...to.nodes,moved]}));
}
/** 独占等待writer停止后读新快照并三方合并;内容指纹改变才记本地历史,再安装新ACK。 */
async function refresh(boardId: string) {
return exclusive(async () => {
const writer = writers.current.get(boardId); if (!writer) return;
await writer.settled(); const base = writer.document;
const remote = await read(boardId), local = current.current.get(boardId)!;
const merged = mergeCanvasDocuments(base, local, remote.document);
if (canvasFingerprint(merged) !== canvasFingerprint(local)) { undo.current.push({ boardId, before: local }); redo.current = []; }
install(boardId, remote); publish(new Map(current.current).set(boardId, merged));
});
}
/** 显式采用生成结果前,针对目标节点把基线text/src/assetId/ai对齐到远端,再合并其它本地编辑。
* 合并结果先排空保存,后续apply命令才能在明确版本上覆盖目标;此方法本身不发送生成结果采用请求。 */
async function prepareApply(boardId: string, nodeId: string) {
await exclusive(async () => {
const writer = writers.current.get(boardId); if (!writer) throw new Error("invalid_reference");
await writer.settled(); const remote = await read(boardId), base = structuredClone(writer.document), local = current.current.get(boardId)!;
const before = base.nodes.find((node) => node.id === nodeId), after = remote.document.nodes.find((node) => node.id === nodeId);
if (!before || !after) throw new Error("invalid_reference");
for (const key of ["text", "src", "assetId", "ai"] as const) { if (key in after.data) (before.data as Record<string, unknown>)[key] = after.data[key]; else delete before.data[key]; }
const merged = mergeCanvasDocuments(base, local, remote.document);
install(boardId, remote); publish(new Map(current.current).set(boardId, merged));
await writer.enqueue(merged); await writer.settled(); if (writer.error) throw new Error(writer.error);
setError("");
});
}
/** 查看栈顶并检查准入,成功后才移动栈项;本地条目替换before,事务条目调用服务端undo或重做。
* 重做可能创建新子board,使用alias将后续历史引用转到新身份,避免继续操作已归档旧子图。 */
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 || migrationBlocked() || locked.current || retryOperation.current || members.current.length) return;
/** 沿别名链解析最新boardID并防循环;只用于历史重放,不篡改任意元素ID。 */
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; };
/** 重做本地快照时更新子图board引用,其它节点内容保留。 */
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;
// 待上传图片若已存在于writer基线,纯本地快照切换已不够,需按归属路径逐跳请求成员移动后再转栈。
if (!("id" in entry) && entry.pendingMove) {
const move=entry.pendingMove,desired=alias(isRedo?move.target:move.source);
const owner=[...current.current].find(([,doc])=>doc.nodes.some((node)=>node.id===move.id));
if(!owner)throw new Error("invalid_reference");
const writer=writers.current.get(owner[0]);await writer?.settled();
if(writer?.document.nodes.some((node)=>node.id===move.id)){
if(owner[0]!==desired)for(const hop of subgraphMovePath(current.current,owner[0],desired))await command(hop.parent,`subgraphs/${hop.frame}/members`,{source_board_id:hop.source,target_board_id:hop.target,node_ids:[move.id]},subgraphMoveParticipants(current.current,hop.parent,hop.source,hop.target,[move.id]),false);
stack.pop();destination.push(entry);tick((v)=>v+1);return;
}
}
// 服务端事务撤销调用transactions/{id}/undo;重做执行原命令并保存新的事务ID/参与快照身份。
if ("id" in entry) {
if (isRedo) {
const result = await command(entry.boardId, entry.path, entry.payload, entry.path === "subgraphs" ? undefined : entry.participants, false);
const snapshots = [...(result.parent ? [result.parent] : []), ...(result.child ? [result.child] : []), ...(result.snapshots || [])];
if (entry.path === "subgraphs" && result.child) {
const priorChild = entry.participants.find((id) => id !== entry.boardId); if (priorChild) boardAliases.current.set(priorChild, result.child.board_id);
}
stack.pop(); destination.push({ ...entry, id: result.transaction_id as string, participants: [...new Set(snapshots.map((value) => value.board_id))] });
} else {
await command(entry.boardId, `transactions/${entry.id}/undo`, {}, entry.participants, false);
stack.pop(); destination.push(entry);
}
// 普通本地历史用当前文档作为反向条目,连同related画布一起发布,保证下一次redo可还原。
} else {
const before = current.current.get(entry.boardId); if (!before) return;
const next=new Map(current.current).set(entry.boardId, entry.before), related=new Map<string,CanvasDocument>();
for(const [id,doc] of entry.related || []){const currentDoc=current.current.get(id);if(currentDoc)related.set(id,currentDoc);next.set(id,doc);}
publish(next); stack.pop(); destination.push({ ...entry, before, ...(entry.related?{related}:{}) });
}
tick((v) => v + 1);
}
/** 重试普通writer并触发子图读取effect;不在这里自动重发成员任务或未确认command,它们有各自入口。 */
const retry = async () => { setError(""); setRetryLoad((v) => v + 1); for (const writer of writers.current.values()) await writer.retry(); };
// 对外暴露投影、动作、恢复、事务及writer引用,旧版UI依赖此组合接口;busy合并独占、待确认命令与迁移门闩。
// transactionBusy/canUndo/canRedo反映成员队列与栈是否有项,最终undoAction仍再次检查实际准入。
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 };
}