@@ -19,7 +19,7 @@ import {
1919 type Edge , type Node , type NodeProps , type OnMoveEnd , type Viewport ,
2020} from "@xyflow/react" ;
2121import "@xyflow/react/dist/style.css" ;
22- import type { KnowledgeConcept , KnowledgeLink , KnowledgeModel , KnowledgeNode } from "../types/knowledge" ;
22+ import type { KnowledgeConcept , KnowledgeLink , KnowledgeModel , KnowledgeNode , CanvasNodePosition } from "../types/knowledge" ;
2323import {
2424 buildCanvasElements , canvasKey , entityIdFromKey ,
2525 type CanvasNodeData , type CanvasSelectKind ,
@@ -40,6 +40,11 @@ interface Props {
4040 /** 当前选中实体 key(`q:1`/`c:2`/`m:3`——与树视图/列表共享选中态) */
4141 selectedKey : string | null ;
4242 onSelectItem : ( kind : CanvasSelectKind , entityId : number ) => void ;
43+ /**
44+ * 位置持久化成功回调(父页合并进 nodes props——重挂载后 props 即为已存位置,
45+ * 杜绝"辐射布局重算覆盖已拖走位置";规格 §4.4 纪律:位置只由用户决定)。
46+ */
47+ onPositionsSaved ?: ( updates : CanvasNodePosition [ ] ) => void ;
4348 /** 返回树视图(§4.5 切换) */
4449 onGoBack : ( ) => void ;
4550}
@@ -71,7 +76,7 @@ export default function KnowledgeCanvasView(props: Props) {
7176}
7277
7378function CanvasFlow ( {
74- systemId, coreQuestion, nodes, concepts, models, links, selectedKey, onSelectItem, onGoBack,
79+ systemId, coreQuestion, nodes, concepts, models, links, selectedKey, onSelectItem, onPositionsSaved , onGoBack,
7580} : Props ) {
7681 const [ rfNodes , setRfNodes , onNodesChange ] = useNodesState < Node < CanvasNodeData > > ( [ ] ) ;
7782 const [ rfEdges , setRfEdges , onEdgesChange ] = useEdgesState < Edge > ( [ ] ) ;
@@ -84,6 +89,10 @@ function CanvasFlow({
8489 rfApi . current . fitView = fitView ;
8590 rfApi . current . setViewport = setViewport ;
8691
92+ // onPositionsSaved 经 ref 透传:防抖/卸载回调在闭包外执行,ref 保证拿到最新回调
93+ const savedRef = useRef ( onPositionsSaved ) ;
94+ savedRef . current = onPositionsSaved ;
95+
8796 // 拖拽落点本地覆盖(props 未刷新前保持视图正确;systemId 切换随组件卸载清空)
8897 const localOverrides = useRef ( new Map < number , CanvasPoint > ( ) ) ;
8998 // 待保存拖拽位置(防抖批量刷 DB;卸载时兜底刷新)
@@ -98,23 +107,30 @@ function CanvasFlow({
98107 }
99108 const entries = [ ...pendingPositions . current . entries ( ) ] ;
100109 pendingPositions . current . clear ( ) ;
101- for ( const [ nodeId , pos ] of entries ) {
102- void invoke ( "update_node_canvas_position" , { nodeId, canvasX : pos . x , canvasY : pos . y } )
103- . catch ( ( e ) => setStatus ( `位置保存失败: ${ e } ` ) ) ;
104- }
110+ if ( entries . length === 0 ) return ;
111+ // 全部成功才回传父页合并(部分失败不污染 props——下次重挂载用 DB 兜底)
112+ void Promise . all (
113+ entries . map ( ( [ nodeId , pos ] ) =>
114+ invoke ( "update_node_canvas_position" , { nodeId, canvasX : pos . x , canvasY : pos . y } ) ,
115+ ) ,
116+ )
117+ . then ( ( ) => savedRef . current ?.( entries . map ( ( [ nodeId , pos ] ) => ( { nodeId, x : pos . x , y : pos . y } ) ) ) )
118+ . catch ( ( e ) => setStatus ( `位置保存失败: ${ e } ` ) ) ;
105119 } , [ ] ) ;
106120
107- // 卸载兜底:清定时器 + 刷新未保存的拖拽落点(防切页丢失)
121+ // 卸载兜底:清定时器 + 刷新未保存的拖拽落点(防切页丢失;父页存活,合并安全 )
108122 useEffect ( ( ) => {
109123 return ( ) => {
110124 if ( dragTimer . current != null ) window . clearTimeout ( dragTimer . current ) ;
111125 if ( vpTimer . current != null ) window . clearTimeout ( vpTimer . current ) ;
112126 if ( pendingPositions . current . size > 0 ) {
113127 const entries = [ ...pendingPositions . current . entries ( ) ] ;
114128 pendingPositions . current . clear ( ) ;
115- for ( const [ nodeId , pos ] of entries ) {
116- void invoke ( "update_node_canvas_position" , { nodeId, canvasX : pos . x , canvasY : pos . y } ) ;
117- }
129+ void Promise . all (
130+ entries . map ( ( [ nodeId , pos ] ) =>
131+ invoke ( "update_node_canvas_position" , { nodeId, canvasX : pos . x , canvasY : pos . y } ) ,
132+ ) ,
133+ ) . then ( ( ) => savedRef . current ?.( entries . map ( ( [ nodeId , pos ] ) => ( { nodeId, x : pos . x , y : pos . y } ) ) ) ) ;
118134 }
119135 } ;
120136 } , [ ] ) ;
@@ -161,6 +177,7 @@ function CanvasFlow({
161177 if ( batchPersist . length > 0 ) {
162178 // 首次打开(或新增未布局节点)→ 批量初始化;失败不阻塞渲染(下次打开重试)
163179 void invoke ( "batch_initialize_canvas_positions" , { systemId, positions : batchPersist } )
180+ . then ( ( ) => savedRef . current ?.( batchPersist ) )
164181 . catch ( ( e ) => setStatus ( `布局初始化失败: ${ e } ` ) ) ;
165182 }
166183
@@ -250,6 +267,7 @@ function CanvasFlow({
250267 setRfNodes ( rfn ) ;
251268 setRfEdges ( edges ) ;
252269 void invoke ( "batch_initialize_canvas_positions" , { systemId, positions : persist } )
270+ . then ( ( ) => savedRef . current ?.( persist ) )
253271 . catch ( ( e ) => setStatus ( `布局保存失败: ${ e } ` ) ) ;
254272 void fitView ( { padding : 0.15 } ) ;
255273 } ;
@@ -279,6 +297,10 @@ function CanvasFlow({
279297 maxZoom = { 2 }
280298 nodesConnectable = { false }
281299 edgesReconnectable = { false }
300+ // §九 不做清单:框选批量移动(RF 内置但本版暂不暴露)——禁用元素选择
301+ // 引擎,杜绝 shift 框选 + 多节点拖拽导致的"其余节点视觉位移不持久"竞态
302+ elementsSelectable = { false }
303+ selectionKeyCode = { null }
282304 deleteKeyCode = { null }
283305 fitView = { false }
284306 >
0 commit comments