feat(节点交互): 添加子树折叠功能和对齐辅助线

- 在WindowNode组件中添加子树折叠/展开按钮,显示隐藏子节点数量
- 实现节点拖拽时的对齐辅助线功能,提升布局整齐度
- 添加子树折叠状态管理,自动隐藏/显示子节点和连接线
- 扩展配置选项支持对齐辅助线和网格吸附功能
This commit is contained in:
liuziting
2026-01-21 23:11:31 +08:00
parent a7f8138b1a
commit 570af3b6d3
3 changed files with 175 additions and 5 deletions

View File

@@ -32,7 +32,7 @@ import WindowNode from './components/WindowNode.vue'
// 业务层:统一的状态与动作入口
import { useThinkFlow } from './composables/useThinkFlow'
import { ref, onMounted, onUnmounted } from 'vue'
import { computed, ref, onMounted, onUnmounted } from 'vue'
const { t, locale } = useI18n()
@@ -92,6 +92,10 @@ const {
resetLayout,
centerRoot,
handleNodeDrag,
alignmentGuides,
viewport,
toggleSubtreeCollapse,
isSubtreeCollapsed,
startNewSession,
executeReset,
generateSummary,
@@ -101,6 +105,20 @@ const {
expandIdea
} = useThinkFlow({ t, locale })
const verticalGuideStyle = computed(() => {
const x = alignmentGuides.value.x
if (x == null) return null
const screenX = x * viewport.value.zoom + viewport.value.x
return { left: `${screenX}px` }
})
const horizontalGuideStyle = computed(() => {
const y = alignmentGuides.value.y
if (y == null) return null
const screenY = y * viewport.value.zoom + viewport.value.y
return { top: `${screenY}px` }
})
/**
* 切换语言zh <-> en
*/
@@ -140,6 +158,8 @@ const fitToView = () => {
:class="{ 'space-pressed': isSpacePressed }"
:pan-on-drag="panOnDrag"
:selection-key-code="'Shift'"
:snap-to-grid="config.snapToGrid"
:snap-grid="config.snapGrid"
@node-drag="handleNodeDrag"
>
<Background
@@ -170,12 +190,17 @@ const fitToView = () => {
:deepDive="deepDive"
:generateNodeImage="generateNodeImage"
:expandIdea="expandIdea"
:toggleSubtreeCollapse="toggleSubtreeCollapse"
:isSubtreeCollapsed="isSubtreeCollapsed"
@preview="previewImageUrl = $event"
/>
</template>
</VueFlow>
<div class="absolute inset-0 pointer-events-none z-10 p-12"></div>
<div class="absolute inset-0 pointer-events-none z-20">
<div v-if="config.showAlignmentGuides && verticalGuideStyle" class="absolute top-0 bottom-0 w-px bg-orange-300/70" :style="verticalGuideStyle"></div>
<div v-if="config.showAlignmentGuides && horizontalGuideStyle" class="absolute left-0 right-0 h-px bg-orange-300/70" :style="horizontalGuideStyle"></div>
</div>
<SettingsModal :show="showSettings" :t="t" :apiConfig="apiConfig" @close="showSettings = false" />
<ImagePreviewModal :url="previewImageUrl" @close="previewImageUrl = null" />

View File

@@ -16,6 +16,7 @@ import { Handle, Position } from '@vue-flow/core'
import {
ArrowRight,
BookOpen,
ChevronDown,
ChevronRight,
Image as ImageIcon,
Maximize2,
@@ -49,6 +50,8 @@ const props = defineProps<{
deepDive: (id: string, topic: string) => void
generateNodeImage: (id: string, prompt: string) => void
expandIdea: (param?: any, customInput?: string) => void
toggleSubtreeCollapse: (id: string) => void
isSubtreeCollapsed: (id: string) => boolean
}>()
const md = new MarkdownIt({
@@ -118,6 +121,16 @@ const handleBlur = () => {
<span class="window-title" :style="{ color: props.activePath.nodeIds.has(props.id) ? props.config.edgeColor : '' }">
{{ props.data.type === 'root' ? props.t('node.mainTitle') : props.t('node.moduleTitle') }}
</span>
<button
v-if="props.data.childrenCount > 0"
type="button"
class="flex items-center gap-1 px-1.5 py-1 rounded-md text-[9px] font-black tracking-widest uppercase transition-colors"
:class="props.isSubtreeCollapsed(props.id) ? 'text-orange-600 bg-orange-50' : 'text-slate-400 hover:bg-slate-100 hover:text-slate-600'"
@click.stop="props.toggleSubtreeCollapse(props.id)"
>
<component :is="props.isSubtreeCollapsed(props.id) ? ChevronRight : ChevronDown" class="w-3 h-3" />
<span v-if="props.isSubtreeCollapsed(props.id) && props.data.hiddenDescendantCount" class="text-[9px] font-black">{{ props.data.hiddenDescendantCount }}</span>
</button>
</div>
<div v-if="props.data.isExpanding" class="absolute inset-0 z-20 flex flex-col items-center justify-center bg-white/60 backdrop-blur-[2px] rounded-2xl transition-all duration-300">

View File

@@ -106,6 +106,7 @@ export function useThinkFlow({ t, locale }: { t: Translate; locale: Ref<string>
removeNodes,
removeEdges,
fitView,
viewport,
onNodeDragStart,
onNodeDrag,
onNodeDragStop
@@ -124,6 +125,7 @@ export function useThinkFlow({ t, locale }: { t: Translate; locale: Ref<string>
const draggingNodeId = ref<string | null>(null)
const dragLastPositionByNodeId = new Map<string, { x: number; y: number }>()
const alignmentGuides = ref<{ x: number | null; y: number | null }>({ x: null, y: null })
/**
* 交互:按住 Space 启用“抓手拖拽画布”
@@ -161,11 +163,78 @@ export function useThinkFlow({ t, locale }: { t: Translate; locale: Ref<string>
* 处理节点拖拽事件 (联动移动子节点)
*/
const handleNodeDrag = (payload: any) => {
if (!config.hierarchicalDragging) return
const node = payload?.node ?? payload
if (!node?.id || !node?.position) return
const draggedStoreNode = flowNodes.value.find(n => n.id === node.id)
if (!draggedStoreNode) return
if (config.snapToAlignment) {
const snapThreshold = 8
const getNodeSize = (n: any) => {
const width = n.dimensions?.width ?? n.measured?.width ?? 280
const height = n.dimensions?.height ?? n.measured?.height ?? 180
return { width, height }
}
const draggedSize = getNodeSize(draggedStoreNode)
const proposedX = node.position.x
const proposedY = node.position.y
const draggedAnchorsX = [proposedX, proposedX + draggedSize.width / 2, proposedX + draggedSize.width]
const draggedAnchorsY = [proposedY, proposedY + draggedSize.height / 2, proposedY + draggedSize.height]
let bestX: { delta: number; guide: number } | null = null
let bestY: { delta: number; guide: number } | null = null
for (const other of flowNodes.value) {
if (other.id === node.id) continue
if (other.hidden) continue
const otherSize = getNodeSize(other)
const otherX = other.position?.x ?? 0
const otherY = other.position?.y ?? 0
const otherAnchorsX = [otherX, otherX + otherSize.width / 2, otherX + otherSize.width]
const otherAnchorsY = [otherY, otherY + otherSize.height / 2, otherY + otherSize.height]
for (const ox of otherAnchorsX) {
for (const ax of draggedAnchorsX) {
const delta = ox - ax
const absDelta = Math.abs(delta)
if (absDelta <= snapThreshold && (!bestX || absDelta < Math.abs(bestX.delta))) {
bestX = { delta, guide: ox }
}
}
}
for (const oy of otherAnchorsY) {
for (const ay of draggedAnchorsY) {
const delta = oy - ay
const absDelta = Math.abs(delta)
if (absDelta <= snapThreshold && (!bestY || absDelta < Math.abs(bestY.delta))) {
bestY = { delta, guide: oy }
}
}
}
}
const snappedX = bestX ? proposedX + bestX.delta : proposedX
const snappedY = bestY ? proposedY + bestY.delta : proposedY
alignmentGuides.value = config.showAlignmentGuides ? { x: bestX?.guide ?? null, y: bestY?.guide ?? null } : { x: null, y: null }
if (snappedX !== proposedX || snappedY !== proposedY) {
draggedStoreNode.position = { x: snappedX, y: snappedY }
node.position = { x: snappedX, y: snappedY }
}
} else {
alignmentGuides.value = { x: null, y: null }
}
if (!config.hierarchicalDragging) return
const lastPosition = dragLastPositionByNodeId.get(node.id)
if (!lastPosition) {
const fallbackDelta = payload?.delta
@@ -224,6 +293,7 @@ export function useThinkFlow({ t, locale }: { t: Translate; locale: Ref<string>
if (e?.node?.id) {
dragLastPositionByNodeId.delete(e.node.id)
}
alignmentGuides.value = { x: null, y: null }
})
/**
@@ -316,9 +386,67 @@ export function useThinkFlow({ t, locale }: { t: Translate; locale: Ref<string>
backgroundVariant: BackgroundVariant.Lines,
showControls: true,
showMiniMap: true,
hierarchicalDragging: true
hierarchicalDragging: true,
snapToGrid: true,
snapGrid: [16, 16] as [number, number],
snapToAlignment: true,
showAlignmentGuides: true
})
const collapsedNodeIds = ref<string[]>([])
const isSubtreeCollapsed = (nodeId: string) => collapsedNodeIds.value.includes(nodeId)
const toggleSubtreeCollapse = (nodeId: string) => {
if (isSubtreeCollapsed(nodeId)) {
collapsedNodeIds.value = collapsedNodeIds.value.filter(id => id !== nodeId)
} else {
collapsedNodeIds.value = [...collapsedNodeIds.value, nodeId]
}
}
const applyCollapsedVisibility = () => {
const hiddenIds = new Set<string>()
const childrenCountById = new Map<string, number>()
for (const e of flowEdges.value) {
childrenCountById.set(e.source, (childrenCountById.get(e.source) ?? 0) + 1)
}
for (const id of collapsedNodeIds.value) {
const descendants = getDescendantIds(id)
descendants.forEach(d => hiddenIds.add(d))
}
setNodes(
flowNodes.value.map(n => {
const isHidden = hiddenIds.has(n.id)
const isCollapsed = isSubtreeCollapsed(n.id)
const hiddenDescendantCount = isCollapsed ? getDescendantIds(n.id).size : 0
const childrenCount = childrenCountById.get(n.id) ?? 0
const nextData =
n.data?.hiddenDescendantCount !== hiddenDescendantCount || n.data?.childrenCount !== childrenCount
? { ...n.data, hiddenDescendantCount, childrenCount }
: n.data
return {
...n,
hidden: isHidden,
data: nextData
}
})
)
setEdges(
flowEdges.value.map(e => ({
...e,
hidden: hiddenIds.has(e.source) || hiddenIds.has(e.target)
}))
)
}
watch([() => collapsedNodeIds.value.join(','), () => flowNodes.value.length, () => flowEdges.value.length], applyCollapsedVisibility, { immediate: true })
const lastAppliedStatus = ref('')
/**
@@ -845,6 +973,10 @@ export function useThinkFlow({ t, locale }: { t: Translate; locale: Ref<string>
resetLayout,
centerRoot,
handleNodeDrag,
alignmentGuides,
viewport,
toggleSubtreeCollapse,
isSubtreeCollapsed,
startNewSession,
executeReset,
generateSummary,