Skip to content

Commit 6af8fa6

Browse files
nieaoclaude
andcommitted
feat(collapse): 主干/全部视图切换 + 节点分级折叠 (Step 1)
借鉴 GoJS SubGraphExpander + Workflowy zoom-in + cytoscape expand-collapse 社区方案. 按节点类型分 3 级: L0 主干: GOAL / ENTITY / SYNTHESIS / CONCLUSION / 文件 / 笔记 L1 支撑: ROLE / AGENT L2 衍生: 反驳 ChallengeNode / 拆解 ontology 子节点 / challengeGroup / decomposeGroup Store: - collapseMode: 'full' (默认兼容老行为) | 'minimal' (仅主干) - expandedSourceIds: 用户展开本支的源节点 ids (Step 2 用) - pinnedNodeIds: 用户 pin 的节点 ids, minimal 模式强制显示 (选项 A) KnowledgeCanvas 渲染前 useMemo: 按 type/variant 算 level → minimal 模式给 L1/L2 打 hidden=true expandedSourceIds 命中放过, pinnedNodeIds 命中放过 SaveExportToolbar 加 CollapseModeSwitch: 主干 / 全部 分段按钮, 全部时显示当前已展开的本支数 + pinned 数, 给 user 状态可见性。 Step 2 待做: ENTITY 节点点击展开本支 + 任意节点 pin 按钮。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b75229d commit 6af8fa6

3 files changed

Lines changed: 165 additions & 6 deletions

File tree

src/components/canvas/KnowledgeCanvas.jsx

Lines changed: 62 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import ReactFlow, {
2020
} from 'reactflow'
2121
import 'reactflow/dist/style.css'
2222

23+
import useCanvasStore from '../../stores/useCanvasStore'
2324
import ConceptNode from './ConceptNode'
2425
import CategoryNode from './CategoryNode'
2526
import BookmarkNode from './BookmarkNode'
@@ -281,7 +282,12 @@ function KnowledgeCanvasInner({
281282
showMiniMap = true,
282283
children,
283284
}) {
284-
// === 渲染前 sanitize: 检测 parentNode 引用不存在的节点 ===
285+
// === 折叠分级 (subscribe store, 触发重渲) ===
286+
const collapseMode = useCanvasStore((s) => s.collapseMode)
287+
const expandedSourceIds = useCanvasStore((s) => s.expandedSourceIds)
288+
const pinnedNodeIds = useCanvasStore((s) => s.pinnedNodeIds)
289+
290+
// === 渲染前 sanitize: 检测 parentNode 引用不存在的节点 + 折叠分级 ===
285291
// React Flow 11 在 child.parentNode 找不到时直接 throw "Parent node X not found"
286292
// 整个画布崩 (图 37)。
287293
//
@@ -292,16 +298,66 @@ function KnowledgeCanvasInner({
292298
const ORPHAN_FALLBACK_ID = '__orphan-fallback-root__'
293299
const nodes = useMemo(() => {
294300
const idSet = new Set(rawNodes.map((n) => n.id))
301+
const expandedSet = new Set(expandedSourceIds || [])
302+
const pinnedSet = new Set(pinnedNodeIds || [])
295303
let hasOrphan = false
304+
305+
// L0/L1/L2 节点级别判定
306+
// L0 主干 = goal / 概念 ENTITY (顶层骨架) / synthesis / conclusion / 用户上传文件 / 普通笔记
307+
// L1 支撑 = role / agent (对应 ENTITY 内部组件)
308+
// L2 衍生 = challengeNode (反驳) / decompose 容器子节点
309+
const getLevel = (n) => {
310+
if (!n) return 'L0'
311+
// 反驳节点
312+
if (n.type === 'challengeNode') return 'L2'
313+
// role / agent 节点
314+
if (n.type === 'agentRoleNode') return 'L1'
315+
// ontology role variant
316+
if (n.type === 'ontologyNode' && n.data?.variant === 'role') return 'L1'
317+
// 容器: challengeGroup / decomposeGroup 自身按 L2, 让 group 也跟着折
318+
if (n.type === 'group' && (n.data?.isChallengeGroup || n.data?.isDecomposeGroup)) return 'L2'
319+
// 拆解出来的子 ontology 节点 (parent 是 decomposeGroup)
320+
if (n.type === 'ontologyNode' && n.parentNode) {
321+
const parent = rawNodes.find((p) => p.id === n.parentNode)
322+
if (parent?.data?.isDecomposeGroup) return 'L2'
323+
}
324+
// 其他都按 L0 (GOAL / ENTITY / SYNTHESIS / CONCLUSION / 文件 / 笔记 ...)
325+
return 'L0'
326+
}
327+
328+
// 是否显示一个 L1/L2 节点 (在 minimal 模式下)
329+
const shouldShow = (n, level) => {
330+
if (collapseMode !== 'minimal') return true // full 模式都显示
331+
if (level === 'L0') return true
332+
if (pinnedSet.has(n.id)) return true // pinned 强制显示
333+
// 拿"源节点 id" — challenge / agent 等都有 source_node_id 或 parent_node 字段
334+
const srcId = n.data?.source_node_id || n.data?.parent_node
335+
if (srcId && expandedSet.has(srcId)) return true
336+
// 容器节点: 看容器的 sourceNodeId / 是否有任何被展开的关联
337+
if (n.type === 'group' && n.data?.sourceNodeId && expandedSet.has(n.data.sourceNodeId)) return true
338+
return false
339+
}
340+
296341
const remapped = rawNodes.map((n) => {
342+
const level = getLevel(n)
343+
const visible = shouldShow(n, level)
344+
let next = n
297345
if (n.parentNode && !idSet.has(n.parentNode)) {
298346
hasOrphan = true
299-
return { ...n, parentNode: ORPHAN_FALLBACK_ID, extent: undefined }
347+
next = { ...next, parentNode: ORPHAN_FALLBACK_ID, extent: undefined }
300348
}
301-
return n
349+
if (!visible) {
350+
next = { ...next, hidden: true }
351+
} else if (next === n && n.hidden === true) {
352+
// 之前隐藏过, 现在该展示 → 显式取消 hidden
353+
next = { ...next, hidden: false }
354+
} else if (next !== n && n.hidden === true) {
355+
next.hidden = false
356+
}
357+
return next
302358
})
359+
303360
if (hasOrphan) {
304-
// 透明大 group, 不显示边框 / 背景, 不阻挡交互, 让子节点用原相对坐标继续渲染
305361
remapped.unshift({
306362
id: ORPHAN_FALLBACK_ID,
307363
type: 'group',
@@ -315,10 +371,10 @@ function KnowledgeCanvasInner({
315371
},
316372
data: { isOrphanFallback: true },
317373
})
318-
console.warn('[KnowledgeCanvas] 检测到孤儿子节点, 已挂 fallback root, 等 yjs sync 后实际 parent 到位会自动接管')
374+
console.warn('[KnowledgeCanvas] 检测到孤儿子节点, 已挂 fallback root')
319375
}
320376
return remapped
321-
}, [rawNodes])
377+
}, [rawNodes, collapseMode, expandedSourceIds, pinnedNodeIds])
322378

323379
const reactFlowInstance = useReactFlow()
324380
const reactFlowWrapper = useRef(null)

src/pages/panels/SaveExportToolbar.jsx

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,9 @@ function SaveExportToolbar({ canvasRef, nodes, edges, exportCanvasData, importCa
392392
/>
393393
</div>
394394

395+
{/* 折叠分级开关 — 仅主干 / 显示全部 */}
396+
<CollapseModeSwitch />
397+
395398
{/* 横竖切换分段按钮 (像 iOS Segmented Control) */}
396399
<div
397400
className="flex rounded-lg overflow-hidden shadow-sm"
@@ -516,4 +519,65 @@ function SaveExportToolbar({ canvasRef, nodes, edges, exportCanvasData, importCa
516519
)
517520
}
518521

522+
// === 折叠分级开关 — 仅主干 / 显示全部 ===
523+
// 选中"仅主干"时, ROLE/AGENT/反驳/拆解节点折叠, 只看 GOAL/ENTITY/SYNTHESIS/CONCLUSION 主干。
524+
// 用户可在 ENTITY 节点 click 单独展开本支, 或 pin 节点强制显示。
525+
function CollapseModeSwitch() {
526+
const collapseMode = useCanvasStore((s) => s.collapseMode || 'full')
527+
const setCollapseMode = useCanvasStore((s) => s.setCollapseMode)
528+
const collapseAllSources = useCanvasStore((s) => s.collapseAllSources)
529+
const expandedCount = useCanvasStore((s) => (s.expandedSourceIds || []).length)
530+
const pinnedCount = useCanvasStore((s) => (s.pinnedNodeIds || []).length)
531+
532+
return (
533+
<div
534+
className="flex items-center rounded-lg overflow-hidden shadow-sm"
535+
style={{ border: '1px solid var(--gray-100)', background: 'var(--white)' }}
536+
title="折叠分级 — 仅主干视图减少视觉噪音"
537+
>
538+
<button
539+
onClick={() => setCollapseMode('minimal')}
540+
className="px-3 py-2 text-xs font-medium transition-colors duration-200"
541+
style={{
542+
background: collapseMode === 'minimal' ? 'var(--warm-bg)' : 'transparent',
543+
color: collapseMode === 'minimal' ? 'var(--warm)' : 'var(--gray-700)',
544+
borderRight: '1px solid var(--gray-100)',
545+
}}
546+
title="仅显示主干 (GOAL/ENTITY/SYNTHESIS/CONCLUSION), 其他折叠"
547+
>
548+
<span className="flex items-center gap-1">
549+
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
550+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 6h16M7 12h10M10 18h4" />
551+
</svg>
552+
主干
553+
</span>
554+
</button>
555+
<button
556+
onClick={() => {
557+
setCollapseMode('full')
558+
collapseAllSources?.()
559+
}}
560+
className="px-3 py-2 text-xs font-medium transition-colors duration-200"
561+
style={{
562+
background: collapseMode === 'full' ? 'var(--warm-bg)' : 'transparent',
563+
color: collapseMode === 'full' ? 'var(--warm)' : 'var(--gray-700)',
564+
}}
565+
title="显示全部节点"
566+
>
567+
<span className="flex items-center gap-1">
568+
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
569+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 4h16v16H4z M4 10h16 M10 4v16" />
570+
</svg>
571+
全部
572+
{collapseMode === 'minimal' && (expandedCount > 0 || pinnedCount > 0) && (
573+
<span style={{ fontSize: 9, color: 'var(--accent)', marginLeft: 2 }}>
574+
{expandedCount > 0 && `+${expandedCount}支`}{pinnedCount > 0 && ` ${pinnedCount}📌`}
575+
</span>
576+
)}
577+
</span>
578+
</button>
579+
</div>
580+
)
581+
}
582+
519583
export default SaveExportToolbar

src/stores/useCanvasStore.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,45 @@ const useCanvasStore = create(
192192
viewportCenter: { x: 400, y: 300 },
193193
viewportZoom: 1,
194194

195+
// === 折叠分级视图 (按节点类型分 L0/L1/L2) ===
196+
// mode: 'minimal' = 仅主干 (L0 显示, L1/L2 折叠)
197+
// 'full' = 显示全部 (默认 ALETHEIA 老行为, 兼容)
198+
// expandedSourceIds = 用户点击 ENTITY/源节点单独展开本支 (Set 持久不持久无所谓, 重启回 minimal 默认)
199+
// pinnedNodeIds = 用户 pin 的节点, 即使 minimal 模式也强制显示 (选项 A)
200+
collapseMode:
201+
(typeof localStorage !== 'undefined' && localStorage.getItem('know_canvas_collapse_mode')) || 'full',
202+
expandedSourceIds: [],
203+
pinnedNodeIds: [],
204+
205+
setCollapseMode: (mode) => {
206+
const next = mode === 'minimal' ? 'minimal' : 'full'
207+
try { localStorage.setItem('know_canvas_collapse_mode', next) } catch {}
208+
set({ collapseMode: next })
209+
},
210+
211+
// 切换某个 ENTITY/源节点的"展开本支"状态
212+
toggleExpandSource: (sourceId) => {
213+
if (!sourceId) return
214+
set((state) => {
215+
const arr = Array.isArray(state.expandedSourceIds) ? state.expandedSourceIds : []
216+
const idx = arr.indexOf(sourceId)
217+
if (idx >= 0) state.expandedSourceIds = arr.filter((id) => id !== sourceId)
218+
else state.expandedSourceIds = [...arr, sourceId]
219+
})
220+
},
221+
collapseAllSources: () => set({ expandedSourceIds: [] }),
222+
223+
// pin / unpin 一个节点 (强制显示, 不受 minimal 折叠规则约束)
224+
togglePinNode: (nodeId) => {
225+
if (!nodeId) return
226+
set((state) => {
227+
const arr = Array.isArray(state.pinnedNodeIds) ? state.pinnedNodeIds : []
228+
const idx = arr.indexOf(nodeId)
229+
if (idx >= 0) state.pinnedNodeIds = arr.filter((id) => id !== nodeId)
230+
else state.pinnedNodeIds = [...arr, nodeId]
231+
})
232+
},
233+
195234
// 更新视口中心(由画布组件在视口变化时调用)
196235
setViewportCenter: (center, zoom = 1) => {
197236
set({ viewportCenter: center, viewportZoom: zoom })

0 commit comments

Comments
 (0)