@@ -282,23 +282,42 @@ function KnowledgeCanvasInner({
282282 children,
283283} ) {
284284 // === 渲染前 sanitize: 检测 parentNode 引用不存在的节点 ===
285- // React Flow 11 在 child.parentNode 找不到时会直接 throw "Parent node X not found"
286- // 整个画布崩 (用户报告 ErrorBoundary, 图 37)。这里清掉 dangling parentNode,
287- // 让孤儿子节点退化成普通根节点继续渲染 — 协作场景下 yjs 同步顺序 / undo 都可能造成短暂不一致。
285+ // React Flow 11 在 child.parentNode 找不到时直接 throw "Parent node X not found"
286+ // 整个画布崩 (图 37)。
287+ //
288+ // 历经两版 trade-off:
289+ // v1 清 parentNode → 容器内坐标变绝对, 全堆左上 (图 41)
290+ // v2 filter 孤儿 → PDF 等顶层节点偶尔被误判 parent 也消失 (图 42)
291+ // v3 (当前) 给所有孤儿挂一个隐形 fallback group, 不丢节点也不堆叠
292+ const ORPHAN_FALLBACK_ID = '__orphan-fallback-root__'
288293 const nodes = useMemo ( ( ) => {
289294 const idSet = new Set ( rawNodes . map ( ( n ) => n . id ) )
290- let dirty = false
291- const cleaned = rawNodes . map ( ( n ) => {
295+ let hasOrphan = false
296+ const remapped = rawNodes . map ( ( n ) => {
292297 if ( n . parentNode && ! idSet . has ( n . parentNode ) ) {
293- dirty = true
294- // eslint-disable-next-line no-unused-vars
295- const { parentNode, extent, ...rest } = n
296- return rest
298+ hasOrphan = true
299+ return { ...n , parentNode : ORPHAN_FALLBACK_ID , extent : undefined }
297300 }
298301 return n
299302 } )
300- if ( dirty ) console . warn ( '[KnowledgeCanvas] 清理了孤儿 parentNode 引用, count =' , cleaned . length )
301- return cleaned
303+ if ( hasOrphan ) {
304+ // 透明大 group, 不显示边框 / 背景, 不阻挡交互, 让子节点用原相对坐标继续渲染
305+ remapped . unshift ( {
306+ id : ORPHAN_FALLBACK_ID ,
307+ type : 'group' ,
308+ position : { x : 0 , y : 0 } ,
309+ style : {
310+ width : 8000 ,
311+ height : 8000 ,
312+ background : 'transparent' ,
313+ border : 'none' ,
314+ pointerEvents : 'none' ,
315+ } ,
316+ data : { isOrphanFallback : true } ,
317+ } )
318+ console . warn ( '[KnowledgeCanvas] 检测到孤儿子节点, 已挂 fallback root, 等 yjs sync 后实际 parent 到位会自动接管' )
319+ }
320+ return remapped
302321 } , [ rawNodes ] )
303322
304323 const reactFlowInstance = useReactFlow ( )
0 commit comments