feat(节点交互): 添加子树折叠功能和对齐辅助线
- 在WindowNode组件中添加子树折叠/展开按钮,显示隐藏子节点数量 - 实现节点拖拽时的对齐辅助线功能,提升布局整齐度 - 添加子树折叠状态管理,自动隐藏/显示子节点和连接线 - 扩展配置选项支持对齐辅助线和网格吸附功能
This commit is contained in:
29
src/App.vue
29
src/App.vue
@@ -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" />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user