1111 * @ai -context: 视口经 knowledge_canvas_states 持久化——切回画布 setViewport 恢复
1212 * (§4.5;无记录时 fitView 按内容兜底)。
1313 */
14- import { useCallback , useEffect , useRef , useState } from "react" ;
14+ import { useCallback , useEffect , useMemo , useRef , useState } from "react" ;
1515import { invoke } from "@tauri-apps/api/core" ;
1616import {
17- Background , BackgroundVariant , Controls , MiniMap , Panel , ReactFlow , ReactFlowProvider ,
17+ Background , BackgroundVariant , Controls , Handle , MiniMap , Panel , Position , ReactFlow , ReactFlowProvider ,
1818 useEdgesState , useNodesState , useReactFlow ,
1919 type Edge , type Node , type NodeProps , type OnMoveEnd , type Viewport ,
2020} from "@xyflow/react" ;
2121import "@xyflow/react/dist/style.css" ;
2222import type { KnowledgeConcept , KnowledgeLink , KnowledgeModel , KnowledgeNode , CanvasNodePosition } from "../types/knowledge" ;
2323import {
24- buildCanvasElements , canvasKey , entityIdFromKey ,
24+ buildCanvasElements , canvasKey , entityIdFromKey , resolveEdgeHandles ,
2525 type CanvasNodeData , type CanvasSelectKind ,
2626} from "../utils/canvasElements" ;
2727import { CANVAS_BBOX , layoutRadial , type CanvasLayoutItem , type CanvasPoint } from "../utils/layoutRadial" ;
@@ -33,6 +33,8 @@ interface Props {
3333 systemId : number ;
3434 /** 体系核心问题——存在时画布圆心渲染核心问题卡(布局圆心被虚拟核心占用) */
3535 coreQuestion : string | null ;
36+ /** v0.13.9:体系名——coreQuestion 为空(领域体系)时圆心渲染体系名卡 */
37+ systemName : string | null ;
3638 nodes : KnowledgeNode [ ] ;
3739 concepts : KnowledgeConcept [ ] ;
3840 models : KnowledgeModel [ ] ;
@@ -76,13 +78,21 @@ export default function KnowledgeCanvasView(props: Props) {
7678}
7779
7880function CanvasFlow ( {
79- systemId, coreQuestion, nodes, concepts, models, links, selectedKey, onSelectItem, onPositionsSaved, onGoBack,
81+ systemId, coreQuestion, systemName , nodes, concepts, models, links, selectedKey, onSelectItem, onPositionsSaved, onGoBack,
8082} : Props ) {
8183 const [ rfNodes , setRfNodes , onNodesChange ] = useNodesState < Node < CanvasNodeData > > ( [ ] ) ;
8284 const [ rfEdges , setRfEdges , onEdgesChange ] = useEdgesState < Edge > ( [ ] ) ;
8385 const [ status , setStatus ] = useState ( "" ) ;
8486 const { fitView, setViewport } = useReactFlow ( ) ;
8587
88+ // v0.13.9:体系根卡(全局=核心问题 / 领域=体系名)——null 时不渲染圆心卡
89+ // (防御:无核心问题也无体系名的异常态保持旧行为,不凭空造根)
90+ const rootCard = useMemo < { title : string ; subtitle : string } | null > ( ( ) => {
91+ if ( coreQuestion ) return { title : coreQuestion , subtitle : "核心问题" } ;
92+ if ( systemName ) return { title : systemName , subtitle : "领域体系" } ;
93+ return null ;
94+ } , [ coreQuestion , systemName ] ) ;
95+
8696 // useReactFlow 返回值身份可能随渲染变化——ref 捕获避免恢复 effect 依赖抖动
8797 // (依赖 fitView/setViewport 身份变化会反复触发恢复循环)
8898 const rfApi = useRef ( { fitView, setViewport } ) ;
@@ -181,22 +191,12 @@ function CanvasFlow({
181191 . catch ( ( e ) => setStatus ( `布局初始化失败: ${ e } ` ) ) ;
182192 }
183193
184- const { nodes : rfn , edges } = buildCanvasElements ( { nodes, concepts, models, links, positions, selectedKey } ) ;
185- const extras : Node < CanvasNodeData > [ ] = [ ] ;
186- if ( coreQuestion ) {
187- extras . push ( {
188- id : "core" ,
189- type : "core" ,
190- position : { x : - 120 , y : - 40 } ,
191- draggable : false ,
192- selectable : false ,
193- focusable : false ,
194- data : { kind : "question" , entityId : 0 , title : coreQuestion , subtitle : null , badges : [ ] , statusText : null , statusColor : null , refCount : 0 } ,
195- } ) ;
196- }
197- setRfNodes ( [ ...rfn , ...extras ] ) ;
194+ const { nodes : rfn , edges } = buildCanvasElements ( { nodes, concepts, models, links, positions, selectedKey, rootCard } ) ;
195+ // v0.13.9:rootCard 由 buildCanvasElements 统一产出(含 core 卡 + 根节点虚线边)——
196+ // 初始布局与「自动排列」同路径,杜绝双份逻辑漂移(此前 autoLayout 漏加 core 卡)
197+ setRfNodes ( rfn ) ;
198198 setRfEdges ( edges ) ;
199- } , [ systemId , coreQuestion , nodes , concepts , models , links , selectedKey , setRfNodes , setRfEdges ] ) ;
199+ } , [ systemId , rootCard , nodes , concepts , models , links , selectedKey , setRfNodes , setRfEdges ] ) ;
200200
201201 // 视口恢复(§4.5):已存 → setViewport;未存 → fitView 按内容兜底
202202 useEffect ( ( ) => {
@@ -248,7 +248,7 @@ function CanvasFlow({
248248 ...concepts . map ( ( c ) => ( { key : canvasKey ( "concept" , c . id ) , kind : "concept" as const , parentKey : null } ) ) ,
249249 ...models . map ( ( m ) => ( { key : canvasKey ( "model" , m . id ) , kind : "model" as const , parentKey : null } ) ) ,
250250 ] ;
251- const layout = layoutRadial ( { hasCore : coreQuestion != null , items : layoutItems } ) ;
251+ const layout = layoutRadial ( { hasCore : rootCard != null , items : layoutItems } ) ;
252252 const positions = new Map < string , CanvasPoint > ( ) ;
253253 const persist : { nodeId : number ; x : number ; y : number } [ ] = [ ] ;
254254 for ( const n of nodes ) {
@@ -263,7 +263,7 @@ function CanvasFlow({
263263 for ( const m of models ) {
264264 positions . set ( canvasKey ( "model" , m . id ) , toTopLeft ( layout . get ( canvasKey ( "model" , m . id ) ) ?? { x : 0 , y : 0 } , CANVAS_BBOX . model ) ) ;
265265 }
266- const { nodes : rfn , edges } = buildCanvasElements ( { nodes, concepts, models, links, positions, selectedKey } ) ;
266+ const { nodes : rfn , edges } = buildCanvasElements ( { nodes, concepts, models, links, positions, selectedKey, rootCard } ) ;
267267 setRfNodes ( rfn ) ;
268268 setRfEdges ( edges ) ;
269269 void invoke ( "batch_initialize_canvas_positions" , { systemId, positions : persist } )
@@ -272,6 +272,20 @@ function CanvasFlow({
272272 void fitView ( { padding : 0.15 } ) ;
273273 } ;
274274
275+ // v0.13.9:接线方向动态化——按源/目标当前中心相对方位选 Handle(拖拽后随
276+ // rfNodes 位置重算;纯函数 resolveEdgeHandles 可单测)。解决接线位置固定:
277+ // 移动节点后边始终从同一侧接入,产生大量回字形折角。
278+ const smartEdges = useMemo ( ( ) => {
279+ const centerOf = ( id : string ) : CanvasPoint => {
280+ const n = rfNodes . find ( ( x ) => x . id === id ) ;
281+ const p = n ?. position ?? { x : 0 , y : 0 } ;
282+ // core 卡无固定尺寸(maxWidth 240,标题 1-2 行)——按布局假定尺寸估算中心
283+ const bbox = n ?. type === "core" ? { w : 240 , h : 80 } : CANVAS_BBOX [ n ?. data . kind ?? "question" ] ;
284+ return { x : p . x + bbox . w / 2 , y : p . y + bbox . h / 2 } ;
285+ } ;
286+ return rfEdges . map ( ( e ) => ( { ...e , ...resolveEdgeHandles ( centerOf ( e . source ) , centerOf ( e . target ) ) } ) ) ;
287+ } , [ rfNodes , rfEdges ] ) ;
288+
275289 return (
276290 < div style = { { display : "flex" , flexDirection : "column" , height : "100%" , minHeight : 0 } } >
277291 { /* 顶部:返回树视图(§4.5 内嵌切换——标签页栏「画布」项与这里互通) */ }
@@ -286,7 +300,7 @@ function CanvasFlow({
286300 < div style = { { flex : 1 , minHeight : 0 } } >
287301 < ReactFlow
288302 nodes = { rfNodes }
289- edges = { rfEdges }
303+ edges = { smartEdges }
290304 nodeTypes = { nodeTypes }
291305 onNodesChange = { onNodesChange }
292306 onEdgesChange = { onEdgesChange }
@@ -330,14 +344,19 @@ function CanvasFlow({
330344 ) ;
331345}
332346
333- /** 核心问题节点 (虚拟中心——不可拖/不可选,仅展示体系核心问题 ) */
347+ /** 体系根卡 (虚拟中心——不可拖/不可选,仅展示体系根:全局=核心问题,领域=体系名 ) */
334348function CanvasCoreNode ( { data } : NodeProps < Node < CanvasNodeData , "core" > > ) {
335349 return (
336350 < div
337351 data-testid = "canvas-core"
338352 style = { { maxWidth : 240 , borderRadius : 10 , padding : "10px 14px" , background : "#0f766e" , color : "#fff" , border : "1px solid #0f766e" , boxShadow : "0 2px 8px rgba(15,118,110,0.25)" } }
339353 >
340- < div style = { { fontSize : 11 , opacity : 0.85 } } > 核心问题</ div >
354+ { /* v0.13.9:四边 source Handle——根节点虚线边接线方向随相对方位动态选择 */ }
355+ < Handle type = "source" id = "source-top" position = { Position . Top } style = { { opacity : 0 , width : 2 , height : 2 } } />
356+ < Handle type = "source" id = "source-bottom" position = { Position . Bottom } style = { { opacity : 0 , width : 2 , height : 2 } } />
357+ < Handle type = "source" id = "source-left" position = { Position . Left } style = { { opacity : 0 , width : 2 , height : 2 } } />
358+ < Handle type = "source" id = "source-right" position = { Position . Right } style = { { opacity : 0 , width : 2 , height : 2 } } />
359+ < div style = { { fontSize : 11 , opacity : 0.85 } } > { data . subtitle ?? "体系" } </ div >
341360 < div style = { { fontSize : 13 , fontWeight : 600 , marginTop : 2 } } > { data . title } </ div >
342361 </ div >
343362 ) ;
0 commit comments