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 { useThinkFlow } from './composables/useThinkFlow'
|
||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
const { t, locale } = useI18n()
|
const { t, locale } = useI18n()
|
||||||
|
|
||||||
@@ -92,6 +92,10 @@ const {
|
|||||||
resetLayout,
|
resetLayout,
|
||||||
centerRoot,
|
centerRoot,
|
||||||
handleNodeDrag,
|
handleNodeDrag,
|
||||||
|
alignmentGuides,
|
||||||
|
viewport,
|
||||||
|
toggleSubtreeCollapse,
|
||||||
|
isSubtreeCollapsed,
|
||||||
startNewSession,
|
startNewSession,
|
||||||
executeReset,
|
executeReset,
|
||||||
generateSummary,
|
generateSummary,
|
||||||
@@ -101,6 +105,20 @@ const {
|
|||||||
expandIdea
|
expandIdea
|
||||||
} = useThinkFlow({ t, locale })
|
} = 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)
|
* 切换语言(zh <-> en)
|
||||||
*/
|
*/
|
||||||
@@ -140,6 +158,8 @@ const fitToView = () => {
|
|||||||
:class="{ 'space-pressed': isSpacePressed }"
|
:class="{ 'space-pressed': isSpacePressed }"
|
||||||
:pan-on-drag="panOnDrag"
|
:pan-on-drag="panOnDrag"
|
||||||
:selection-key-code="'Shift'"
|
:selection-key-code="'Shift'"
|
||||||
|
:snap-to-grid="config.snapToGrid"
|
||||||
|
:snap-grid="config.snapGrid"
|
||||||
@node-drag="handleNodeDrag"
|
@node-drag="handleNodeDrag"
|
||||||
>
|
>
|
||||||
<Background
|
<Background
|
||||||
@@ -170,12 +190,17 @@ const fitToView = () => {
|
|||||||
:deepDive="deepDive"
|
:deepDive="deepDive"
|
||||||
:generateNodeImage="generateNodeImage"
|
:generateNodeImage="generateNodeImage"
|
||||||
:expandIdea="expandIdea"
|
:expandIdea="expandIdea"
|
||||||
|
:toggleSubtreeCollapse="toggleSubtreeCollapse"
|
||||||
|
:isSubtreeCollapsed="isSubtreeCollapsed"
|
||||||
@preview="previewImageUrl = $event"
|
@preview="previewImageUrl = $event"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</VueFlow>
|
</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" />
|
<SettingsModal :show="showSettings" :t="t" :apiConfig="apiConfig" @close="showSettings = false" />
|
||||||
<ImagePreviewModal :url="previewImageUrl" @close="previewImageUrl = null" />
|
<ImagePreviewModal :url="previewImageUrl" @close="previewImageUrl = null" />
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { Handle, Position } from '@vue-flow/core'
|
|||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
Maximize2,
|
Maximize2,
|
||||||
@@ -49,6 +50,8 @@ const props = defineProps<{
|
|||||||
deepDive: (id: string, topic: string) => void
|
deepDive: (id: string, topic: string) => void
|
||||||
generateNodeImage: (id: string, prompt: string) => void
|
generateNodeImage: (id: string, prompt: string) => void
|
||||||
expandIdea: (param?: any, customInput?: string) => void
|
expandIdea: (param?: any, customInput?: string) => void
|
||||||
|
toggleSubtreeCollapse: (id: string) => void
|
||||||
|
isSubtreeCollapsed: (id: string) => boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const md = new MarkdownIt({
|
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 : '' }">
|
<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') }}
|
{{ props.data.type === 'root' ? props.t('node.mainTitle') : props.t('node.moduleTitle') }}
|
||||||
</span>
|
</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>
|
||||||
|
|
||||||
<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">
|
<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,
|
removeNodes,
|
||||||
removeEdges,
|
removeEdges,
|
||||||
fitView,
|
fitView,
|
||||||
|
viewport,
|
||||||
onNodeDragStart,
|
onNodeDragStart,
|
||||||
onNodeDrag,
|
onNodeDrag,
|
||||||
onNodeDragStop
|
onNodeDragStop
|
||||||
@@ -124,6 +125,7 @@ export function useThinkFlow({ t, locale }: { t: Translate; locale: Ref<string>
|
|||||||
|
|
||||||
const draggingNodeId = ref<string | null>(null)
|
const draggingNodeId = ref<string | null>(null)
|
||||||
const dragLastPositionByNodeId = new Map<string, { x: number; y: number }>()
|
const dragLastPositionByNodeId = new Map<string, { x: number; y: number }>()
|
||||||
|
const alignmentGuides = ref<{ x: number | null; y: number | null }>({ x: null, y: null })
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 交互:按住 Space 启用“抓手拖拽画布”
|
* 交互:按住 Space 启用“抓手拖拽画布”
|
||||||
@@ -161,11 +163,78 @@ export function useThinkFlow({ t, locale }: { t: Translate; locale: Ref<string>
|
|||||||
* 处理节点拖拽事件 (联动移动子节点)
|
* 处理节点拖拽事件 (联动移动子节点)
|
||||||
*/
|
*/
|
||||||
const handleNodeDrag = (payload: any) => {
|
const handleNodeDrag = (payload: any) => {
|
||||||
if (!config.hierarchicalDragging) return
|
|
||||||
|
|
||||||
const node = payload?.node ?? payload
|
const node = payload?.node ?? payload
|
||||||
if (!node?.id || !node?.position) return
|
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)
|
const lastPosition = dragLastPositionByNodeId.get(node.id)
|
||||||
if (!lastPosition) {
|
if (!lastPosition) {
|
||||||
const fallbackDelta = payload?.delta
|
const fallbackDelta = payload?.delta
|
||||||
@@ -224,6 +293,7 @@ export function useThinkFlow({ t, locale }: { t: Translate; locale: Ref<string>
|
|||||||
if (e?.node?.id) {
|
if (e?.node?.id) {
|
||||||
dragLastPositionByNodeId.delete(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,
|
backgroundVariant: BackgroundVariant.Lines,
|
||||||
showControls: true,
|
showControls: true,
|
||||||
showMiniMap: 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('')
|
const lastAppliedStatus = ref('')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -845,6 +973,10 @@ export function useThinkFlow({ t, locale }: { t: Translate; locale: Ref<string>
|
|||||||
resetLayout,
|
resetLayout,
|
||||||
centerRoot,
|
centerRoot,
|
||||||
handleNodeDrag,
|
handleNodeDrag,
|
||||||
|
alignmentGuides,
|
||||||
|
viewport,
|
||||||
|
toggleSubtreeCollapse,
|
||||||
|
isSubtreeCollapsed,
|
||||||
startNewSession,
|
startNewSession,
|
||||||
executeReset,
|
executeReset,
|
||||||
generateSummary,
|
generateSummary,
|
||||||
|
|||||||
Reference in New Issue
Block a user