@@ -1,3 +1,9 @@
/**
* 旧版画布树的状态与操作协调Hook。documents按boardId保存已加载快照,views把嵌套子画布投影为当前ReactFlow场景。
* 每个board有独立CanvasSync writer,本地历史和服务端结构事务共用两个栈,成员迁移另用串行内存队列。
* 该实现保留旧快照协议与本地草稿恢复,不等同于新版engine的统一提交根、持久ledger和恢复状态机。
* 调用方应按根/工作区身份重挂载;refs提供异步操作读取的最新状态,React state负责发布界面变化。
*/
"use client" ;
"use client" ;
import { useCallback , useEffect , useMemo , useRef , useState } from "react" ;
import { useCallback , useEffect , useMemo , useRef , useState } from "react" ;
import type { EdgeChange , NodeChange } from "@xyflow/react" ;
import type { EdgeChange , NodeChange } from "@xyflow/react" ;
@@ -8,20 +14,34 @@ import { CANVAS_ACCEPT, type EdgeData } from "@/lib/canvas-v2";
import { subgraphMovePath , subgraphMoveParticipants } from "@/lib/canvas-subgraphs" ;
import { subgraphMovePath , subgraphMoveParticipants } from "@/lib/canvas-subgraphs" ;
import { mergeCanvasDocuments , projectCanvasTree , transformEdgeData , referenceAfterMoves } from "@/lib/canvas-tree" ;
import { mergeCanvasDocuments , projectCanvasTree , transformEdgeData , referenceAfterMoves } from "@/lib/canvas-tree" ;
/** 命令返回的快照额外携带board_id,用于把多画布结果安装回各自writer。 */
type OwnedSnapshot = CanvasSnapshot & { board_id : string } ;
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 } ;
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 } } ;
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 [ ] } ;
type TransactionHistory = { boardId : string ; path : string ; payload : Record < string , unknown > ; id : string ; participants : string [ ] } ;
/** 两种历史条目共用undo/redo栈,通过是否有id字段区分;不是每次pointermove都直接创建条目。 */
type Entry = LocalHistory | TransactionHistory ;
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 } ;
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 : [ ] } ) ;
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 } ) {
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 migrationGate = useRef ( ! ! migration ) ;
const migrationFlush = useRef ( false ) ;
const migrationFlush = useRef ( false ) ;
const [ migrationHeld , setMigrationState ] = useState ( ! ! migration ) ;
const [ migrationHeld , setMigrationState ] = useState ( ! ! migration ) ;
/** 同步检查门闩和账号身份,事件处理无需等待React的migrationHeld重新渲染。 */
const migrationBlocked = ( ) = > migrationGate . current || ! ! migration && ! migration . isCurrentIdentity ( ) ;
const migrationBlocked = ( ) = > migrationGate . current || ! ! migration && ! migration . isCurrentIdentity ( ) ;
/** 同时更新同步门闩与界面状态;只更新state会留下同一事件轮次中的旧写入窗口。 */
function setMigrationHeld ( value :boolean ) { migrationGate . current = value ; setMigrationState ( value ) ; }
function setMigrationHeld ( value :boolean ) { migrationGate . current = value ; setMigrationState ( value ) ; }
/** 旧接口请求护栏:await前后检查账号,写请求受迁移门闩限制;迁移排空时只放行canvas PATCH。
* X-Expected-Account要求代理校验目标账号,避免切号后旧请求误用新会话。 */
const api :CanvasRequest = async < T , > ( path :string , options? :RequestInit ) = > {
const api :CanvasRequest = async < T , > ( path :string , options? :RequestInit ) = > {
if ( migration && ! migration . isCurrentIdentity ( ) ) throw new Error ( 'identity_changed' ) ;
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' ) ;
if ( migrationBlocked ( ) && options ? . method && options . method !== 'GET' && ! ( migrationFlush . current && options . method === 'PATCH' && path . endsWith ( '/canvas' ) ) ) throw new Error ( 'canvas_busy' ) ;
@@ -29,33 +49,42 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
const value = await requestAPI < T > ( path , { . . . options , headers :Object.fromEntries ( headers ) } ) ;
const value = await requestAPI < T > ( path , { . . . options , headers :Object.fromEntries ( headers ) } ) ;
if ( migration && ! migration . isCurrentIdentity ( ) ) throw new Error ( 'identity_changed' ) ; return value ;
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 [ documents , setDocuments ] = useState ( new Map ( [ [ root , initial ] ] ) ) ;
const current = useRef ( documents ) ; current . current = documents ;
const current = useRef ( documents ) ; current . current = documents ;
const [ status , setStatus ] = useState ( "saved" ) ;
const [ status , setStatus ] = useState ( "saved" ) ;
const [ error , setError ] = useState ( "" ) ;
const [ error , setError ] = useState ( "" ) ;
const [ busy , setBusy ] = useState ( false ) ;
const [ busy , setBusy ] = useState ( false ) ;
const [ memberJobs , setMemberJobs ] = useState < MemberJob [ ] > ( [ ] ) ;
const [ memberJobs , setMemberJobs ] = useState < MemberJob [ ] > ( [ ] ) ;
// capability缓存查询Promise; members是实际内存队列,memberJobs只是供React显示的数组副本。
const memberCapability = useRef < Promise < boolean > | null > ( null ) ;
const memberCapability = useRef < Promise < boolean > | null > ( null ) ;
const members = useRef < MemberJob [ ] > ( [ ] ) , memberRunning = useRef ( false ) , dragging = useRef ( false ) ;
const members = useRef < MemberJob [ ] > ( [ ] ) , memberRunning = useRef ( false ) , dragging = useRef ( false ) ;
/** 仅挂载期间发布队列副本;任务对象仍共享引用,修改error等字段后需显式通知。 */
const showMembers = ( ) = > { if ( mounted . current ) setMemberJobs ( [ . . . members . current ] ) ; } ;
const showMembers = ( ) = > { if ( mounted . current ) setMemberJobs ( [ . . . members . current ] ) ; } ;
/** 拖动期间延后自动保存,结束时发布新Map触发防抖重新评估;不在这里执行成员归属变化。 */
function setDragging ( value : boolean ) { dragging . current = value ; if ( ! value ) publish ( new Map ( current . current ) ) ; }
function setDragging ( value : boolean ) { dragging . current = value ; if ( ! value ) publish ( new Map ( current . current ) ) ; }
/** 成员任务涉及的节点暂不能再被普通动作改动;队列非空时也保护子图入口几何及结构,避免请求基准漂移。 */
function protectedNodes ( boardId : string ) {
function protectedNodes ( boardId : string ) {
const ids = new Set ( members . current . flatMap ( ( job ) = > [ job . source , job . target ] . includes ( boardId ) ? job . ids : [ ] ) ) ;
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 ) ;
if ( members . current . length ) for ( const node of current . current . get ( boardId ) ? . nodes || [ ] ) if ( node . data . subgraph ) ids . add ( node . id ) ;
return ids ;
return ids ;
}
}
const [ pendingCommand , setPendingCommand ] = useState ( false ) ;
const [ pendingCommand , setPendingCommand ] = useState ( false ) ;
// 不确定命令失败时保留同一execute闭包,pendingCommand驱动UI; locked只管当前独占异步操作。
const retryOperation = useRef < ( ( ) = > Promise < CanvasCommandResult > ) | null > ( null ) ;
const retryOperation = useRef < ( ( ) = > Promise < CanvasCommandResult > ) | null > ( null ) ;
const locked = useRef ( false ) , mounted = useRef ( true ) , pause = useRef ( paused ) ; pause . current = paused ;
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 writers = useRef ( new Map < string , CanvasSync > ( ) ) ;
const loading = useRef ( new Set < string > ( ) ) ;
const loading = useRef ( new Set < string > ( ) ) ;
const [ retryLoad , setRetryLoad ] = useState ( 0 ) ;
const [ retryLoad , setRetryLoad ] = useState ( 0 ) ;
const [ recoveries , setRecoveries ] = useState ( new Map < string , CanvasDocument > ( ) ) ;
const [ recoveries , setRecoveries ] = useState ( new Map < string , CanvasDocument > ( ) ) ;
const pendingRecovery = useRef ( recoveries ) ; pendingRecovery . current = recoveries ;
const pendingRecovery = useRef ( recoveries ) ; pendingRecovery . current = recoveries ;
/** 以当前服务端快照筛选有内容差异的本地恢复候选,按board记录,等待用户决定;不证明可安全覆盖模型。 */
function inspectRecovery ( boardId : string , value : CanvasSnapshot ) {
function inspectRecovery ( boardId : string , value : CanvasSnapshot ) {
const local = readLocalRecovery ( localStorage , ` endoleap_canvas_v1: ${ workspace } : ${ boardId } ` , value ) ;
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 ) ; }
if ( local ) { const next = new Map ( pendingRecovery . current ) . set ( boardId , local ) ; pendingRecovery . current = next ; setRecoveries ( next ) ; }
}
}
/** 可编辑时按选择替换本地文档或采用现状,移除恢复候选和草稿记录;远端写入仍走后续writer。 */
function resolveRecovery ( boardId : string , restore : boolean ) {
function resolveRecovery ( boardId : string , restore : boolean ) {
if ( migrationBlocked ( ) ) return ;
if ( migrationBlocked ( ) ) return ;
const draft = pendingRecovery . current . get ( boardId ) ;
const draft = pendingRecovery . current . get ( boardId ) ;
@@ -63,12 +92,16 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
const next = new Map ( pendingRecovery . current ) ; next . delete ( boardId ) ; pendingRecovery . current = next ; setRecoveries ( next ) ;
const next = new Map ( pendingRecovery . current ) ; next . delete ( boardId ) ; pendingRecovery . current = next ; setRecoveries ( next ) ;
localStorage . removeItem ( ` endoleap_canvas_v1: ${ workspace } : ${ boardId } :recovery ` ) ;
localStorage . removeItem ( ` endoleap_canvas_v1: ${ workspace } : ${ boardId } :recovery ` ) ;
}
}
// 两个ref数组作为历史栈,boardAliases记录重做子图创建后新旧boardID对应;tick用于仅栈变化时通知UI。
const undo = useRef < Entry [ ] > ( [ ] ) , redo = useRef < Entry [ ] > ( [ ] ) ;
const undo = useRef < Entry [ ] > ( [ ] ) , redo = useRef < Entry [ ] > ( [ ] ) ;
const boardAliases = useRef ( new Map < string , string > ( ) ) ;
const boardAliases = useRef ( new Map < string , string > ( ) ) ;
const [ , tick ] = useState ( 0 ) ;
const [ , tick ] = useState ( 0 ) ;
// 根据根与documents派生投影索引,nodeRefs/edgeRefs把场景ID映射回所属board、本地ID和坐标变换。
const views = useMemo ( ( ) = > projectCanvasTree ( root , documents ) , [ root , documents ] ) ;
const views = useMemo ( ( ) = > projectCanvasTree ( root , documents ) , [ root , documents ] ) ;
const view = useRef ( views ) ; view . current = views ;
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 ) ; }
function publish ( next : Map < string , CanvasDocument > ) { current . current = next ; if ( mounted . current ) setDocuments ( next ) ; }
/** 将服务端快照作为writer基线:已有writer采用新ACK,否则在真实快照模式建立writer;这里只安装,不发保存。 */
function install ( boardId : string , value : CanvasSnapshot ) {
function install ( boardId : string , value : CanvasSnapshot ) {
const writer = writers . current . get ( boardId ) ;
const writer = writers . current . get ( boardId ) ;
if ( writer ) writer . adopt ( value ) ;
if ( writer ) writer . adopt ( value ) ;
@@ -80,25 +113,33 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
// Initialization never writes a snapshot to the server.
// Initialization never writes a snapshot to the server.
if ( snapshot && ! writers . current . has ( root ) ) install ( root , snapshot ) ;
if ( snapshot && ! writers . current . has ( root ) ) install ( root , snapshot ) ;
useEffect ( ( ) = > { mounted . current = true ; return ( ) = > { mounted . current = false ; } ; } , [ ] ) ;
useEffect ( ( ) = > { mounted . current = true ; return ( ) = > { mounted . current = false ; } ; } , [ ] ) ;
/** 普通单画布动作入口:检查门闩,经reducer计算新文档,保留成员迁移保护字段,再按需要记历史并发布。 */
const update = ( boardId : string , action : CanvasAction ) = > {
const update = ( boardId : string , action : CanvasAction ) = > {
if ( migrationBlocked ( ) || locked . current || retryOperation . current ) return ;
if ( migrationBlocked ( ) || locked . current || retryOperation . current ) return ;
const before = current . current . get ( boardId ) ; if ( ! before ) return ;
const before = current . current . get ( boardId ) ; if ( ! before ) return ;
const result = canvasReducer ( history ( before ) , action ) ;
const result = canvasReducer ( history ( before ) , action ) ;
const protectedIDs = protectedNodes ( boardId ) ;
const protectedIDs = protectedNodes ( boardId ) ;
// 迁移中的普通节点只允许选中变化;未作为移动目标的子图入口保留原几何、subgraph/ports/presentation。
// reducer删掉的受保护节点也补回,避免异步成员请求到来时对象已被本地删除。
if ( protectedIDs . size ) result . present = { . . . result . present , nodes : result.present.nodes.map ( ( node ) = > {
if ( protectedIDs . size ) result . present = { . . . result . present , nodes : result.present.nodes.map ( ( node ) = > {
const prior = before . nodes . find ( ( item ) = > item . id === node . id ) ;
const prior = before . nodes . find ( ( item ) = > item . id === node . id ) ;
if ( ! prior || ! protectedIDs . has ( node . id ) ) return node ;
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 ) ) ;
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 } ;
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 ) ) ) } ;
} ) . 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 = [ ] ; }
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 ) ) ;
publish ( new Map ( current . current ) . set ( boardId , result . present ) ) ;
} ;
} ;
/** 默认对根发动作,也允许调用方显式指定已加载board。 */
const dispatch = ( action : CanvasAction , boardId = root ) = > update ( boardId , action ) ;
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 ) ) ; } ;
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 > [ ] ) = > {
const nodesChanged = ( changes : NodeChange < CanvasNode > [ ] ) = > {
if ( migrationBlocked ( ) || locked . current || retryOperation . current ) return ;
if ( migrationBlocked ( ) || locked . current || retryOperation . current ) return ;
const groups = new Map < string , NodeChange < CanvasNode > [ ] > ( ) ;
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 ] : [ ] ) ) ;
const moves = new Map ( changes . flatMap ( ( change ) = > change . type === "position" && change . position ? [ [ change . id , change . position ] as const ] : [ ] ) ) ;
for ( const change of changes ) {
for ( const change of changes ) {
if ( change . type === "add" || change . type === "replace" ) continue ;
if ( change . type === "add" || change . type === "replace" ) continue ;
@@ -106,6 +147,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
const adjusted = referenceAfterMoves ( root , current . current , view . current . boards , ref . boardId , moves ) ;
const adjusted = referenceAfterMoves ( root , current . current , view . current . boards , ref . boardId , moves ) ;
const next = { . . . change , id : ref.id } ;
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 } ;
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" ) {
if ( next . type === "dimensions" ) {
// ResizeObserver measurements are editor state. Only an explicit resize
// ResizeObserver measurements are editor state. Only an explicit resize
// gesture may write node dimensions back to the owning snapshot.
// gesture may write node dimensions back to the owning snapshot.
@@ -116,6 +158,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
}
}
for ( const [ boardId , changes ] of groups ) update ( boardId , { type : "nodes" , changes } ) ;
for ( const [ boardId , changes ] of groups ) update ( boardId , { type : "nodes" , changes } ) ;
} ;
} ;
/** 按edgeRefs分组选择/删除等变化,忽略add/replace;最终update仍统一检查写入准入。 */
const edgesChanged = ( changes : EdgeChange [ ] ) = > {
const edgesChanged = ( changes : EdgeChange [ ] ) = > {
const groups = new Map < string , EdgeChange [ ] > ( ) ;
const groups = new Map < string , EdgeChange [ ] > ( ) ;
for ( const change of changes ) {
for ( const change of changes ) {
@@ -124,13 +167,17 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
}
}
for ( const [ boardId , changes ] of groups ) update ( boardId , { type : "edges" , changes } ) ;
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 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 } ) ;
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 ) = > {
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 } ) ) } ) ;
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 } ) ) ;
const read = async ( boardId : string , signal? : AbortSignal ) = > decodeSnapshot ( await api < CanvasSnapshot > ( canvasPath ( workspace , boardId ) , { headers : { Accept : CANVAS_ACCEPT } , signal } ) ) ;
/** 查询服务端是否支持成员迁移同时提交坐标;演示为false,成功结果复用,失败清缓存供按需重试。 */
function supportsMemberPositions ( ) : Promise < boolean > {
function supportsMemberPositions ( ) : Promise < boolean > {
if ( ! snapshot ) return Promise . resolve ( false ) ;
if ( ! snapshot ) return Promise . resolve ( false ) ;
if ( ! memberCapability . current ) {
if ( ! memberCapability . current ) {
@@ -141,9 +188,11 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
}
}
return memberCapability . current ;
return memberCapability . current ;
}
}
// 能力预取失败不视作确定不支持,真正成员操作仍可重新请求。
useEffect ( ( ) = > {
useEffect ( ( ) = > {
void supportsMemberPositions ( ) . catch ( ( ) = > { /* A failed prefetch is retried on demand, never cached as unsupported. */ } ) ;
void supportsMemberPositions ( ) . catch ( ( ) = > { /* A failed prefetch is retried on demand, never cached as unsupported. */ } ) ;
} , [ root , workspace , ! ! snapshot ] ) ;
} , [ root , workspace , ! ! snapshot ] ) ;
/** 按需加载真实快照或演示本地草稿,装writer并检查恢复候选;recursive递归加载子图,祖先集合防环且限制深度。 */
async function ensure ( boardId : string , recursive = false , ancestors = new Set < string > ( ) ) : Promise < void > {
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" ) ;
if ( ancestors . has ( boardId ) || ancestors . size > 8 ) throw new Error ( "subgraph_cycle" ) ;
let document = current . current . get ( boardId ) ;
let document = current . current . get ( boardId ) ;
@@ -153,14 +202,17 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
}
}
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 ) ; }
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() {
async function exportDocuments() {
return exclusive ( async ( ) = > {
return exclusive ( async ( ) = > {
await ensure ( root , true ) ;
await ensure ( root , true ) ;
const reachable = new Map < string , CanvasDocument > ( ) ;
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 ) ; } ;
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 ;
collect ( root ) ; return reachable ;
} ) ;
} ) ;
}
}
// 打开子画布时向上读取元数据与快照,补齐祖先关系;alive阻止离页后的采用,不在本effect中abort请求。
useEffect ( ( ) = > {
useEffect ( ( ) = > {
if ( ! snapshot || ! parentBoardId ) return ;
if ( ! snapshot || ! parentBoardId ) return ;
let alive = true ;
let alive = true ;
@@ -178,6 +230,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
// Root identity is stable until this editor unmounts.
// Root identity is stable until this editor unmounts.
// eslint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps
} , [ parentBoardId , root , workspace ] ) ;
} , [ parentBoardId , root , workspace ] ) ;
// 场景可见且未折叠的子图按需异步读取,loading去重;回包时重新确认仍被当前文档引用。
useEffect ( ( ) = > {
useEffect ( ( ) = > {
for ( const [ boardId , document ] of documents ) for ( const node of document . nodes ) {
for ( const [ boardId , document ] of documents ) for ( const node of document . nodes ) {
if ( ! view . current . boards . has ( boardId ) ) continue ;
if ( ! view . current . boards . has ( boardId ) ) continue ;
@@ -192,6 +245,8 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
// Values and writers are scoped to this mounted root/workspace.
// Values and writers are scoped to this mounted root/workspace.
// eslint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps
} , [ documents , retryLoad ] ) ;
} , [ documents , retryLoad ] ) ;
// 文档变化防抖:250ms本地备份、800ms远端保存。恢复待决画布跳过,防止覆盖用户尚未选择的草稿。
// 远端另等独占锁、命令重试、成员任务和拖动结束;本地备份不需要等这些普通操作全部结束。
useEffect ( ( ) = > {
useEffect ( ( ) = > {
if ( paused || migrationBlocked ( ) ) return ;
if ( paused || migrationBlocked ( ) ) return ;
const backup = setTimeout ( ( ) = > {
const backup = setTimeout ( ( ) = > {
@@ -208,7 +263,9 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
} , 800 ) ;
} , 800 ) ;
return ( ) = > { clearTimeout ( backup ) ; clearTimeout ( save ) ; } ;
return ( ) = > { clearTimeout ( backup ) ; clearTimeout ( save ) ; } ;
} , [ documents , paused , workspace , memberJobs , migrationHeld ] ) ;
} , [ documents , paused , workspace , memberJobs , migrationHeld ] ) ;
// pagehide/卸载尽量做本地同步备份,不等待远端flush;暂停/迁移仍禁止写,存储满时保留已有记录。
useEffect ( ( ) = > {
useEffect ( ( ) = > {
/** 遍历最新内存文档,备份规范化payload与ACK版本/dirty;页面离开时错误不再触发界面更新。 */
const backup = ( ) = > {
const backup = ( ) = > {
if ( pause . current || migrationBlocked ( ) ) return ;
if ( pause . current || migrationBlocked ( ) ) return ;
for ( const [ boardId , document ] of current . current ) {
for ( const [ boardId , document ] of current . current ) {
@@ -219,6 +276,8 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
window . addEventListener ( "pagehide" , backup ) ;
window . addEventListener ( "pagehide" , backup ) ;
return ( ) = > { window . removeEventListener ( "pagehide" , backup ) ; backup ( ) ; } ;
return ( ) = > { window . removeEventListener ( "pagehide" , backup ) ; backup ( ) ; } ;
} , [ workspace ] ) ;
} , [ workspace ] ) ;
/** 串行排空各writer,任一恢复待决或writer错误就失败;deferMemberPositions保留待移动节点原ACK坐标。
* 支持原子坐标的成员接口会在归属事务里提交新位置,避免先把新位置单独写入旧owner。 */
async function flush ( deferMemberPositions = false ) {
async function flush ( deferMemberPositions = false ) {
for ( const [ boardId , document ] of current . current ) {
for ( const [ boardId , document ] of current . current ) {
if ( pendingRecovery . current . has ( boardId ) ) throw new Error ( "draft_recovery_required" ) ;
if ( pendingRecovery . current . has ( boardId ) ) throw new Error ( "draft_recovery_required" ) ;
@@ -228,6 +287,8 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
await writer . enqueue ( saving ) ; await writer . settled ( ) ; if ( writer . error ) throw new Error ( writer . error ) ;
await writer . enqueue ( saving ) ; await writer . settled ( ) ; if ( writer . error ) throw new Error ( writer . error ) ;
}
}
}
}
/** 迁移前必须已关门,无事务/成员/拖动/加载,且恢复已处理;加载整棵迁移树并核查上传占位。
* 先备份本页,再临时放行PATCH排空旧writer;所有writer确认无待保存内容后把备份标为clean。 */
async function prepareMigration ( projectRoot :string ) {
async function prepareMigration ( projectRoot :string ) {
if ( ! migrationGate . current ) throw new Error ( 'migration_gate_required' ) ;
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 ( locked . current || retryOperation . current || members . current . length || dragging . current || loading . current . size ) throw new Error ( 'canvas_busy' ) ;
@@ -240,12 +301,14 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
for ( const [ boardId , document ] of current . current ) {
for ( const [ boardId , document ] of current . current ) {
const writer = writers . current . get ( boardId ) ;
const writer = writers . current . get ( boardId ) ;
const local = readLocalRecovery ( localStorage , ` endoleap_canvas_v1: ${ workspace } : ${ boardId } ` , writer ? { version :writer.version , document : writer . document } : undefined ) ;
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 ) ) {
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' ) ;
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.
// 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 } ) ) ;
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 ;
migrationFlush . current = true ;
try { await flush ( ) ; } finally { migrationFlush . current = false ; }
try { await flush ( ) ; } finally { migrationFlush . current = false ; }
if ( ! mounted . current || migration && ! migration . isCurrentIdentity ( ) ) throw new Error ( 'identity_changed' ) ;
if ( ! mounted . current || migration && ! migration . isCurrentIdentity ( ) ) throw new Error ( 'identity_changed' ) ;
@@ -255,15 +318,19 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
localStorage . setItem ( ` endoleap_canvas_v1: ${ workspace } : ${ boardId } :recovery ` , JSON . stringify ( { document : canvasPayload ( document ) , baseVersion :writer?.version || 1 , dirty :false } ) ) ;
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 > ) {
async function exclusive < T > ( operation : ( ) = > Promise < T > ) {
if ( migrationBlocked ( ) || 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 ) ;
locked . current = true ; setBusy ( true ) ;
try { return await operation ( ) ; } catch ( error ) { setError ( error instanceof Error ? error . message : "request_failed" ) ; throw error ; }
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 ) ; }
finally { locked . current = false ; if ( mounted . current ) setBusy ( false ) ; }
}
}
/** 服务端快照更新时恢复本地选中标记,避免结构事务结果抹掉当前视图选择。 */
function keepSelection ( document : CanvasDocument , local? : CanvasDocument ) : CanvasDocument {
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 } ) ) } ;
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 ) {
function accept ( result : CanvasCommandResult ) {
const next = new Map ( current . current ) ;
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 ( ) ] ;
const values = [ . . . new Map ( [ . . . ( result . parent ? [ result . parent ] : [ ] ) , . . . ( result . child ? [ result . child ] : [ ] ) , . . . ( result . snapshots || [ ] ) , . . . ( result . children || [ ] ) ] . map ( ( value ) = > [ value . board_id , value ] ) ) . values ( ) ] ;
@@ -272,6 +339,8 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
const merged = local && base ? mergeCanvasDocuments ( base , local , decoded . document ) : decoded . 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 ) ) ;
install ( value . board_id , decoded ) ; next . set ( value . board_id , keepSelection ( merged , local ) ) ;
}
}
// 子图归档后,将仍在内存上传的节点及相关边投到父画布,避免服务端快照中没有的占位被丢弃。
// 其余已归档文档及writer从当前表移除,不在这里删除上传任务的原文件。
for ( const id of result . archived_board_ids || [ ] ) {
for ( const id of result . archived_board_ids || [ ] ) {
const local = current . current . get ( id ) ;
const local = current . current . get ( id ) ;
const owner = [ . . . current . current ] . find ( ( [ , doc ] ) = > doc . nodes . some ( ( node ) = > node . data . subgraph ? . boardId === id ) ) ;
const owner = [ . . . current . current ] . find ( ( [ , doc ] ) = > doc . nodes . some ( ( node ) = > node . data . subgraph ? . boardId === id ) ) ;
@@ -286,6 +355,8 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
publish ( next ) ;
publish ( next ) ;
return [ . . . new Set ( values . map ( ( value ) = > value . board_id ) ) ] ;
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 ) {
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" ) ;
if ( retryOperation . current ) throw new Error ( "canvas_busy" ) ;
return exclusive ( async ( ) = > {
return exclusive ( async ( ) = > {
@@ -295,21 +366,25 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
await flush ( ) ;
await flush ( ) ;
const body = { . . . payload , . . . ( participants ? { expected_versions : Object.fromEntries ( participants . map ( ( id ) = > [ id , writers . current . get ( id ) ? . version ] ) ) } : { } ) } ;
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 ;
const key = crypto . randomUUID ( ) , version = writers . current . get ( versionBoard ) ? . version || 1 ;
/** 发送、采用回执、按需补读目标快照,并把有transaction_id的结果记入服务端事务历史。 */
const execute = async ( ) : Promise < CanvasCommandResult > = > {
const execute = async ( ) : Promise < CanvasCommandResult > = > {
try {
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 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 ) ;
const changed = accept ( result ) ;
// 改变子图/事务后通知侧栏刷新目录;没有快照但有版本结果时补读并保留期间的本地编辑。
if ( path . startsWith ( "subgraphs" ) || path . startsWith ( "transactions/" ) ) window . dispatchEvent ( new window . CustomEvent ( "endoleap:board-directory-changed" , { detail : { workspace } } ) ) ;
if ( path . startsWith ( "subgraphs" ) || path . startsWith ( "transactions/" ) ) window . dispatchEvent ( new window . CustomEvent ( "endoleap:board-directory-changed" , { detail : { workspace } } ) ) ;
if ( ! changed . length && result . canvas_version ) {
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 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 ;
const merged = base && local ? mergeCanvasDocuments ( base , local , remote . document ) : remote . document ;
install ( target , remote ) ; publish ( new Map ( current . current ) . set ( target , keepSelection ( merged , local ) ) ) ;
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 ) ; }
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 ( "" ) ;
retryOperation . current = null ; setPendingCommand ( false ) ; setError ( "" ) ;
return result ;
return result ;
} catch ( error ) {
} catch ( error ) {
const code = error instanceof Error ? error . message : "request_failed" ;
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 ) ; }
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 ) ; }
else { retryOperation . current = null ; setPendingCommand ( false ) ; }
throw error ;
throw error ;
@@ -320,12 +395,15 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
}
}
// Ownership changes pause autosaves, not unrelated local editing. Server
// Ownership changes pause autosaves, not unrelated local editing. Server
// acknowledgements merge against the persisted base, preserving intervening edits.
// acknowledgements merge against the persisted base, preserving intervening edits.
/** 成员队列只跑队首;归属移动期间暂停autosave,允许不相关本地编辑,结果按捕获ACK合并。
* 只有成功才shift并resolve;失败任务留队首供重试,Promise不在失败时假装完成。 */
async function runMembers() {
async function runMembers() {
if ( migrationBlocked ( ) || 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 ( ) ;
const job = members . current [ 0 ] ; memberRunning . current = true ; job . error = undefined ; showMembers ( ) ;
try {
try {
if ( ! job . run ) {
if ( ! job . run ) {
for ( const id of job . participants ) if ( ! writers . current . has ( id ) ) await ensure ( id ) ;
for ( const id of job . participants ) if ( ! writers . current . has ( id ) ) await ensure ( id ) ;
// 412等确定冲突后的重新准备:读参与快照,先三方合并本地变化,再更新各writer基线。
if ( job . refresh ) {
if ( job . refresh ) {
const refreshed = await Promise . all ( job . participants . map ( async ( id ) = > [ id , await read ( id ) ] as const ) ) ;
const refreshed = await Promise . all ( job . participants . map ( async ( id ) = > [ id , await read ( id ) ] as const ) ) ;
const next = new Map ( current . current ) ;
const next = new Map ( current . current ) ;
@@ -336,6 +414,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
for ( const [ id , remote ] of refreshed ) install ( id , remote ) ;
for ( const [ id , remote ] of refreshed ) install ( id , remote ) ;
publish ( next ) ; job . refresh = false ;
publish ( next ) ; job . refresh = false ;
}
}
// 支持坐标时flush延后待移动节点位置;否则先按旧保存行为排空,再捕获参与文档作为合并基线。
const atomic = await supportsMemberPositions ( ) ;
const atomic = await supportsMemberPositions ( ) ;
await flush ( atomic === true ) ;
await flush ( atomic === true ) ;
const bases = new Map ( job . participants . map ( ( id ) = > [ id , writers . current . get ( id ) ! . document ] ) ) ;
const bases = new Map ( job . participants . map ( ( id ) = > [ id , writers . current . get ( id ) ! . document ] ) ) ;
@@ -344,17 +423,22 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
const node = source . nodes . find ( ( node ) = > node . id === id ) ; if ( ! node ) throw new Error ( "invalid_reference" ) ;
const node = source . nodes . find ( ( node ) = > node . id === id ) ; if ( ! node ) throw new Error ( "invalid_reference" ) ;
return [ id , node . position ] ;
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 } : { } ) ,
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 ] ) ) } ) ;
expected_versions : Object.fromEntries ( job . participants . map ( ( id ) = > [ id , writers . current . get ( id ) ! . version ] ) ) } ) ;
const key = crypto . randomUUID ( ) , historyIndex = undo . current . length ;
const key = crypto . randomUUID ( ) , historyIndex = undo . current . length ;
/** 一次准备生成的可重试闭包;要求回执覆盖所有参与画布,不能凭缺失快照移动本地owner。 */
job . run = async ( ) = > {
job . run = async ( ) = > {
const result = await api < CanvasCommandResult > ( canvasPath ( workspace , job . parent ) + ` /subgraphs/ ${ job . frame } /members ` , {
const result = await api < CanvasCommandResult > ( canvasPath ( workspace , job . parent ) + ` /subgraphs/ ${ job . frame } /members ` , {
method : "POST" , headers : { Accept : CANVAS_ACCEPT , "Idempotency-Key" : key } , body } ) ;
method : "POST" , headers : { Accept : CANVAS_ACCEPT , "Idempotency-Key" : key } , body } ) ;
const snapshots = [ . . . ( result . parent ? [ result . parent ] : [ ] ) , . . . ( result . snapshots || [ ] ) ] ;
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 } ] ) ) ;
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" ) ;
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 ) } ;
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 ;
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 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 ) ;
const next = new Map ( current . current ) ;
for ( const [ id , value ] of values ) {
for ( const [ id , value ] of values ) {
@@ -369,6 +453,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
for ( const [ id , value ] of values ) install ( id , value ) ;
for ( const [ id , value ] of values ) install ( id , value ) ;
publish ( next ) ;
publish ( next ) ;
undo . current = rebasedHistory ;
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 = [ ] ; }
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 = [ ] ; }
} ;
} ;
}
}
@@ -378,6 +463,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
job . error = error instanceof Error ? error . message : "request_failed" ;
job . error = error instanceof Error ? error . message : "request_failed" ;
// A definite rejection performed zero writes. A retry may prepare fresh
// A definite rejection performed zero writes. A retry may prepare fresh
// versions; ambiguous transport failures must retain the exact body/key.
// 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 ; }
if ( error instanceof ApiError && error . status < 500 ) { job . run = undefined ; job . refresh = error . status === 412 ; }
showMembers ( ) ;
showMembers ( ) ;
} finally {
} finally {
@@ -385,24 +471,31 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
if ( members . current . length && ! members . current [ 0 ] . error ) void runMembers ( ) ;
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 [ ] ) {
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" ) ) ;
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 ) = > {
return new Promise < void > ( ( resolve ) = > {
members . current . push ( { parent , frame , source , target , ids , participants , resolve } ) ; showMembers ( ) ; void runMembers ( ) ;
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 } ) {
function resolveImageUpload ( id : string , asset ? : { url : string ; id : string } , size ? : { width :number ; height :number } ) {
if ( migrationBlocked ( ) ) return ;
if ( migrationBlocked ( ) ) return ;
/** 只匹配pendingUpload身份,移除或保留占位标记,并按文档schema选择是否写assetId。 */
const resolve = ( document : CanvasDocument ) : CanvasDocument = > ( { . . . document , nodes : document.nodes.map ( ( node ) = > {
const resolve = ( document : CanvasDocument ) : CanvasDocument = > ( { . . . document , nodes : document.nodes.map ( ( node ) = > {
if ( node . pendingUpload !== id ) return node ;
if ( node . pendingUpload !== id ) return node ;
const fitted = node . data . kind === "plain-image" && node . width === 320 && node . height === 240?size :undefined ;
const fitted = node . data . kind === "plain-image" && node . width === 320 && node . height === 240?size :undefined ;
const { pendingUpload , . . . ready } = node ;
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 } : { } ) } : { } ) } } ;
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 ) ] ) ) } : { } ) } ;
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 ) ;
undo . current = undo . current . map ( rebase ) ; redo . current = redo . current . map ( rebase ) ;
publish ( new Map ( [ . . . current . current ] . map ( ( [ board , doc ] ) = > [ board , resolve ( doc ) ] ) ) ) ;
publish ( new Map ( [ . . . current . current ] . map ( ( [ board , doc ] ) = > [ board , resolve ( doc ) ] ) ) ) ;
}
}
/** 未上传且无关联边的图片可先在内存跨分区移动;校验容量/ID后按两owner原点和scale换算。
* 保存双画布before及pendingMove历史,上传完成后若进入服务端,撤销可能需要真实成员事务。 */
function movePendingImage ( source : string , target : string , id : string ) {
function movePendingImage ( source : string , target : string , id : string ) {
if ( migrationBlocked ( ) ) throw new Error ( "canvas_busy" ) ;
if ( migrationBlocked ( ) ) throw new Error ( "canvas_busy" ) ;
const from = current . current . get ( source ) ! , to = current . current . get ( target ) ! ;
const from = current . current . get ( source ) ! , to = current . current . get ( target ) ! ;
@@ -413,6 +506,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
undo . current . push ( { boardId :source , before :from , related :new Map ( [ [ target , to ] ] ) , pendingMove : { id , source , target } } ) ; redo . current = [ ] ;
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 ] } ) ) ;
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 ) {
async function refresh ( boardId : string ) {
return exclusive ( async ( ) = > {
return exclusive ( async ( ) = > {
const writer = writers . current . get ( boardId ) ; if ( ! writer ) return ;
const writer = writers . current . get ( boardId ) ; if ( ! writer ) return ;
@@ -423,6 +517,8 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
install ( boardId , remote ) ; publish ( new Map ( current . current ) . set ( boardId , merged ) ) ;
install ( boardId , remote ) ; publish ( new Map ( current . current ) . set ( boardId , merged ) ) ;
} ) ;
} ) ;
}
}
/** 显式采用生成结果前,针对目标节点把基线text/src/assetId/ai对齐到远端,再合并其它本地编辑。
* 合并结果先排空保存,后续apply命令才能在明确版本上覆盖目标;此方法本身不发送生成结果采用请求。 */
async function prepareApply ( boardId : string , nodeId : string ) {
async function prepareApply ( boardId : string , nodeId : string ) {
await exclusive ( async ( ) = > {
await exclusive ( async ( ) = > {
const writer = writers . current . get ( boardId ) ; if ( ! writer ) throw new Error ( "invalid_reference" ) ;
const writer = writers . current . get ( boardId ) ; if ( ! writer ) throw new Error ( "invalid_reference" ) ;
@@ -436,12 +532,17 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
setError ( "" ) ;
setError ( "" ) ;
} ) ;
} ) ;
}
}
/** 查看栈顶并检查准入,成功后才移动栈项;本地条目替换before,事务条目调用服务端undo或重做。
* 重做可能创建新子board,使用alias将后续历史引用转到新身份,避免继续操作已归档旧子图。 */
async function undoAction ( isRedo = false ) {
async function undoAction ( isRedo = false ) {
const stack = isRedo ? redo.current : undo.current , destination = isRedo ? undo.current : redo.current ;
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 ;
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 ; } ;
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 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 ;
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 ) {
if ( ! ( "id" in entry ) && entry . pendingMove ) {
const move = entry . pendingMove , desired = alias ( isRedo?move.target :move.source ) ;
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 ) ) ;
const owner = [ . . . current . current ] . find ( ( [ , doc ] ) = > doc . nodes . some ( ( node ) = > node . id === move . id ) ) ;
@@ -452,6 +553,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
stack . pop ( ) ; destination . push ( entry ) ; tick ( ( v ) = > v + 1 ) ; return ;
stack . pop ( ) ; destination . push ( entry ) ; tick ( ( v ) = > v + 1 ) ; return ;
}
}
}
}
// 服务端事务撤销调用transactions/{id}/undo;重做执行原命令并保存新的事务ID/参与快照身份。
if ( "id" in entry ) {
if ( "id" in entry ) {
if ( isRedo ) {
if ( isRedo ) {
const result = await command ( entry . boardId , entry . path , entry . payload , entry . path === "subgraphs" ? undefined : entry . participants , false ) ;
const result = await command ( entry . boardId , entry . path , entry . payload , entry . path === "subgraphs" ? undefined : entry . participants , false ) ;
@@ -464,6 +566,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
await command ( entry . boardId , ` transactions/ ${ entry . id } /undo ` , { } , entry . participants , false ) ;
await command ( entry . boardId , ` transactions/ ${ entry . id } /undo ` , { } , entry . participants , false ) ;
stack . pop ( ) ; destination . push ( entry ) ;
stack . pop ( ) ; destination . push ( entry ) ;
}
}
// 普通本地历史用当前文档作为反向条目,连同related画布一起发布,保证下一次redo可还原。
} else {
} else {
const before = current . current . get ( entry . boardId ) ; if ( ! before ) return ;
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 > ( ) ;
const next = new Map ( current . current ) . set ( entry . boardId , entry . before ) , related = new Map < string , CanvasDocument > ( ) ;
@@ -472,6 +575,9 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
}
}
tick ( ( v ) = > v + 1 ) ;
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 ( ) ; } ;
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 } ;
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 } ;
}
}