feat(节点交互): 实现联动拖拽功能并优化节点聚焦体验

- 新增联动拖拽功能,支持配置开关,拖动父节点时自动移动子节点
- 优化节点聚焦交互,聚焦时放大节点并居中视图
- 重构后代节点查找逻辑,改用迭代方式提高性能
- 在顶部导航栏添加联动拖拽开关按钮
- 为i18n添加相关翻译字段
- 调整markdown渲染样式增加内边距
This commit is contained in:
liuziting
2026-01-21 23:01:56 +08:00
parent 1b9deed3a7
commit a7f8138b1a
7 changed files with 290 additions and 41 deletions

View File

@@ -91,6 +91,7 @@ const {
fitView,
resetLayout,
centerRoot,
handleNodeDrag,
startNewSession,
executeReset,
generateSummary,
@@ -139,6 +140,7 @@ const fitToView = () => {
:class="{ 'space-pressed': isSpacePressed }"
:pan-on-drag="panOnDrag"
:selection-key-code="'Shift'"
@node-drag="handleNodeDrag"
>
<Background
:variant="config.backgroundVariant"
@@ -160,6 +162,7 @@ const fitToView = () => {
:selected="selected"
:t="t"
:config="config"
:fitView="fitView"
:activeNodeId="activeNodeId"
:activePath="activePath"
:flowNodes="flowNodes"

View File

@@ -6,13 +6,14 @@
*/
// 基础依赖
import { ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
// 背景样式枚举(用于切换 Lines/Dots
import { BackgroundVariant } from '@vue-flow/background'
// 图标:所有按钮与状态展示
import {
ArrowLeftRight,
ChevronDown,
ChevronUp,
Download,
@@ -58,6 +59,74 @@ const emit = defineEmits<{
*/
const isToolsExpanded = ref(false)
const isEdgeTypeMenuOpen = ref(false)
const isBackgroundMenuOpen = ref(false)
const edgeTypeOptions = [
{ value: 'default', labelKey: 'nav.edgeTypes.default' },
{ value: 'straight', labelKey: 'nav.edgeTypes.straight' },
{ value: 'step', labelKey: 'nav.edgeTypes.step' },
{ value: 'smoothstep', labelKey: 'nav.edgeTypes.smoothstep' },
{ value: 'simplebezier', labelKey: 'nav.edgeTypes.simplebezier' }
]
const backgroundOptions = [
{ value: BackgroundVariant.Lines, labelKey: 'nav.lines' },
{ value: BackgroundVariant.Dots, labelKey: 'nav.dots' }
]
const currentEdgeTypeLabel = computed(() => {
const option = edgeTypeOptions.find(o => o.value === props.config.edgeType)
return option ? props.t(option.labelKey) : props.config.edgeType
})
const currentBackgroundLabel = computed(() => {
const option = backgroundOptions.find(o => o.value === props.config.backgroundVariant)
return option ? props.t(option.labelKey) : String(props.config.backgroundVariant)
})
const closeMenus = () => {
isEdgeTypeMenuOpen.value = false
isBackgroundMenuOpen.value = false
}
const toggleEdgeTypeMenu = () => {
isEdgeTypeMenuOpen.value = !isEdgeTypeMenuOpen.value
if (isEdgeTypeMenuOpen.value) isBackgroundMenuOpen.value = false
}
const toggleBackgroundMenu = () => {
isBackgroundMenuOpen.value = !isBackgroundMenuOpen.value
if (isBackgroundMenuOpen.value) isEdgeTypeMenuOpen.value = false
}
const setEdgeType = (value: string) => {
props.config.edgeType = value
closeMenus()
}
const setBackgroundVariant = (value: any) => {
props.config.backgroundVariant = value
closeMenus()
}
const handleDocumentPointerDown = (e: Event) => {
const target = e.target as HTMLElement | null
if (!target) return
if (target.closest('[data-edge-type-menu="true"]')) return
if (target.closest('[data-background-menu="true"]')) return
closeMenus()
}
onMounted(() => {
document.addEventListener('pointerdown', handleDocumentPointerDown)
})
onUnmounted(() => {
document.removeEventListener('pointerdown', handleDocumentPointerDown)
})
/**
* 执行某个工具动作后自动收起移动端面板
*/
@@ -95,6 +164,18 @@ const callAndClose = (fn: () => void) => {
<div class="h-4 w-[1px] bg-slate-100 mx-1 flex-shrink-0"></div>
<button
@click="props.config.hierarchicalDragging = !props.config.hierarchicalDragging"
class="toolbar-btn border-slate-100 flex-shrink-0"
:class="props.config.hierarchicalDragging ? 'text-orange-500 bg-orange-50 border-orange-100' : 'text-slate-400 hover:text-slate-600'"
:title="props.t('nav.hierarchicalDragging')"
>
<ArrowLeftRight class="w-3.5 h-3.5 md:w-4 h-4" />
<span>{{ props.t('nav.hierarchicalDragging') }}</span>
</button>
<div class="h-4 w-[1px] bg-slate-100 mx-1 flex-shrink-0"></div>
<button @click="props.onStartNewSession" class="toolbar-btn text-red-500 hover:bg-red-50 border-red-100 flex-shrink-0" :title="props.t('nav.reset')">
<Trash2 class="w-3.5 h-3.5 md:w-4 h-4" />
<span>{{ props.t('nav.reset') }}</span>
@@ -108,18 +189,49 @@ const callAndClose = (fn: () => void) => {
<span class="text-[9px] md:text-[10px] font-bold text-slate-500 uppercase">{{ props.t('nav.edge') }}</span>
</div>
<select v-model="props.config.edgeType" class="toolbar-select flex-shrink-0">
<option value="default">{{ props.t('nav.edgeTypes.default') }}</option>
<option value="straight">{{ props.t('nav.edgeTypes.straight') }}</option>
<option value="step">{{ props.t('nav.edgeTypes.step') }}</option>
<option value="smoothstep">{{ props.t('nav.edgeTypes.smoothstep') }}</option>
<option value="simplebezier">{{ props.t('nav.edgeTypes.simplebezier') }}</option>
</select>
<div data-edge-type-menu="true" class="relative flex-shrink-0">
<button type="button" class="toolbar-select flex items-center gap-2" @click="toggleEdgeTypeMenu">
<span>{{ currentEdgeTypeLabel }}</span>
<ChevronDown class="w-3 h-3 opacity-60" />
</button>
<div
v-if="isEdgeTypeMenuOpen"
class="absolute top-full left-0 mt-2 min-w-[180px] bg-white border border-slate-200 rounded-lg shadow-xl p-1 z-50"
>
<button
v-for="opt in edgeTypeOptions"
:key="opt.value"
type="button"
class="w-full text-left px-3 py-2 rounded-md text-[10px] font-black tracking-widest uppercase transition-colors"
:class="opt.value === props.config.edgeType ? 'bg-orange-50 text-orange-600' : 'text-slate-600 hover:bg-slate-50'"
@click="setEdgeType(opt.value)"
>
{{ props.t(opt.labelKey) }}
</button>
</div>
</div>
<select v-model="props.config.backgroundVariant" class="toolbar-select flex-shrink-0">
<option :value="BackgroundVariant.Lines">{{ props.t('nav.lines') }}</option>
<option :value="BackgroundVariant.Dots">{{ props.t('nav.dots') }}</option>
</select>
<div data-background-menu="true" class="relative flex-shrink-0">
<button type="button" class="toolbar-select flex items-center gap-2" @click="toggleBackgroundMenu">
<span>{{ currentBackgroundLabel }}</span>
<ChevronDown class="w-3 h-3 opacity-60" />
</button>
<div
v-if="isBackgroundMenuOpen"
class="absolute top-full left-0 mt-2 min-w-[140px] bg-white border border-slate-200 rounded-lg shadow-xl p-1 z-50"
>
<button
v-for="opt in backgroundOptions"
:key="String(opt.value)"
type="button"
class="w-full text-left px-3 py-2 rounded-md text-[10px] font-black tracking-widest uppercase transition-colors"
:class="opt.value === props.config.backgroundVariant ? 'bg-orange-50 text-orange-600' : 'text-slate-600 hover:bg-slate-50'"
@click="setBackgroundVariant(opt.value)"
>
{{ props.t(opt.labelKey) }}
</button>
</div>
</div>
<div class="h-4 w-[1px] bg-slate-100 mx-1 flex-shrink-0"></div>
@@ -212,18 +324,49 @@ const callAndClose = (fn: () => void) => {
<span class="text-[10px] font-bold text-slate-500 uppercase">{{ props.t('nav.edge') }}</span>
</div>
<select v-model="props.config.edgeType" class="toolbar-select">
<option value="default">{{ props.t('nav.edgeTypes.default') }}</option>
<option value="straight">{{ props.t('nav.edgeTypes.straight') }}</option>
<option value="step">{{ props.t('nav.edgeTypes.step') }}</option>
<option value="smoothstep">{{ props.t('nav.edgeTypes.smoothstep') }}</option>
<option value="simplebezier">{{ props.t('nav.edgeTypes.simplebezier') }}</option>
</select>
<div data-edge-type-menu="true" class="relative">
<button type="button" class="toolbar-select flex items-center gap-2" @click="toggleEdgeTypeMenu">
<span>{{ currentEdgeTypeLabel }}</span>
<ChevronDown class="w-3 h-3 opacity-60" />
</button>
<div
v-if="isEdgeTypeMenuOpen"
class="absolute top-full left-0 mt-2 min-w-[180px] bg-white border border-slate-200 rounded-lg shadow-xl p-1 z-50"
>
<button
v-for="opt in edgeTypeOptions"
:key="opt.value"
type="button"
class="w-full text-left px-3 py-2 rounded-md text-[10px] font-black tracking-widest uppercase transition-colors"
:class="opt.value === props.config.edgeType ? 'bg-orange-50 text-orange-600' : 'text-slate-600 hover:bg-slate-50'"
@click="callAndClose(() => setEdgeType(opt.value))"
>
{{ props.t(opt.labelKey) }}
</button>
</div>
</div>
<select v-model="props.config.backgroundVariant" class="toolbar-select">
<option :value="BackgroundVariant.Lines">{{ props.t('nav.lines') }}</option>
<option :value="BackgroundVariant.Dots">{{ props.t('nav.dots') }}</option>
</select>
<div data-background-menu="true" class="relative">
<button type="button" class="toolbar-select flex items-center gap-2" @click="toggleBackgroundMenu">
<span>{{ currentBackgroundLabel }}</span>
<ChevronDown class="w-3 h-3 opacity-60" />
</button>
<div
v-if="isBackgroundMenuOpen"
class="absolute top-full left-0 mt-2 min-w-[140px] bg-white border border-slate-200 rounded-lg shadow-xl p-1 z-50"
>
<button
v-for="opt in backgroundOptions"
:key="String(opt.value)"
type="button"
class="w-full text-left px-3 py-2 rounded-md text-[10px] font-black tracking-widest uppercase transition-colors"
:class="opt.value === props.config.backgroundVariant ? 'bg-orange-50 text-orange-600' : 'text-slate-600 hover:bg-slate-50'"
@click="callAndClose(() => setBackgroundVariant(opt.value))"
>
{{ props.t(opt.labelKey) }}
</button>
</div>
</div>
<button
@click="props.config.showMiniMap = !props.config.showMiniMap"

View File

@@ -41,6 +41,7 @@ const props = defineProps<{
selected: boolean
t: any
config: any
fitView: (options?: any) => void
activeNodeId: string | null
activePath: { nodeIds: Set<string>; edgeIds: Set<string> }
flowNodes: any[]
@@ -72,6 +73,22 @@ const isFocused = ref(false)
* 从 flowNodes 中找到节点当前位置,用于扩展时定位新节点生成的参考坐标
*/
const getNodePosition = (id: string) => props.flowNodes.find(n => n.id === id)?.position
/**
* 输入框聚焦处理:放大节点并居中视图
*/
const handleFocus = () => {
isFocused.value = true
// 聚焦时将视图中心对准该节点,并给予适当的 padding 确保放大后完整可见
props.fitView({ nodes: [props.id], padding: 1.5, duration: 600 })
}
/**
* 输入框失去焦点处理
*/
const handleBlur = () => {
isFocused.value = false
}
</script>
<template>
@@ -79,13 +96,14 @@ const getNodePosition = (id: string) => props.flowNodes.find(n => n.id === id)?.
class="window-node group transition-all duration-500"
:class="{
'opacity-40 grayscale-[0.4] blur-[0.5px] scale-[0.98] pointer-events-none': props.activeNodeId && !props.activePath.nodeIds.has(props.id),
'opacity-100 grayscale-0 blur-0 scale-105 z-50 ring-2 ring-offset-4': props.activePath.nodeIds.has(props.id),
'opacity-100 grayscale-0 blur-0 scale-105 z-50 ring-2 ring-offset-4': props.activePath.nodeIds.has(props.id) && !isFocused,
'opacity-100 grayscale-0 blur-0 scale-110 z-[100] shadow-2xl ring-4 ring-offset-8': isFocused,
'!w-[450px]': props.data.isDetailExpanded
}"
:style="{
borderColor: props.activePath.nodeIds.has(props.id) ? props.config.edgeColor : props.config.edgeColor + '40',
boxShadow: props.activeNodeId === props.id ? `0 20px 50px -12px ${props.config.edgeColor}40` : '',
'--tw-ring-color': props.selected ? props.config.edgeColor + '40' : 'transparent'
borderColor: isFocused || props.activePath.nodeIds.has(props.id) ? props.config.edgeColor : props.config.edgeColor + '40',
boxShadow: isFocused ? `0 25px 50px -12px ${props.config.edgeColor}60` : (props.activeNodeId === props.id ? `0 20px 50px -12px ${props.config.edgeColor}40` : ''),
'--tw-ring-color': isFocused || props.selected ? props.config.edgeColor + '40' : 'transparent'
}"
>
<Handle type="target" :position="Position.Left" class="!bg-transparent !border-none" />
@@ -226,8 +244,8 @@ const getNodePosition = (id: string) => props.flowNodes.find(n => n.id === id)?.
<Terminal v-else class="w-3 h-3" :style="{ color: props.config.edgeColor }" />
<input
v-model="props.data.followUp"
@focus="isFocused = true"
@blur="isFocused = false"
@focus="handleFocus"
@blur="handleBlur"
@keyup.enter="props.expandIdea({ id: props.id, data: props.data, position: getNodePosition(props.id) }, props.data.followUp)"
:placeholder="props.t('node.followUp')"
class="bg-transparent border-none outline-none text-[10px] font-bold text-slate-700 flex-grow placeholder:text-slate-400"

View File

@@ -107,6 +107,7 @@ export function useThinkFlow({ t, locale }: { t: Translate; locale: Ref<string>
removeEdges,
fitView,
onNodeDragStart,
onNodeDrag,
onNodeDragStop
} = useVueFlow()
@@ -122,6 +123,7 @@ export function useThinkFlow({ t, locale }: { t: Translate; locale: Ref<string>
const summaryContent = ref('')
const draggingNodeId = ref<string | null>(null)
const dragLastPositionByNodeId = new Map<string, { x: number; y: number }>()
/**
* 交互:按住 Space 启用“抓手拖拽画布”
@@ -149,10 +151,79 @@ export function useThinkFlow({ t, locale }: { t: Translate; locale: Ref<string>
*/
onNodeDragStart(e => {
draggingNodeId.value = e.node.id
dragLastPositionByNodeId.set(e.node.id, {
x: e.node.position.x,
y: e.node.position.y
})
})
onNodeDragStop(() => {
/**
* 处理节点拖拽事件 (联动移动子节点)
*/
const handleNodeDrag = (payload: any) => {
if (!config.hierarchicalDragging) return
const node = payload?.node ?? payload
if (!node?.id || !node?.position) return
const lastPosition = dragLastPositionByNodeId.get(node.id)
if (!lastPosition) {
const fallbackDelta = payload?.delta
dragLastPositionByNodeId.set(node.id, { x: node.position.x, y: node.position.y })
if (fallbackDelta && typeof fallbackDelta.x === 'number' && typeof fallbackDelta.y === 'number') {
const descendantIds = getDescendantIds(node.id)
if (descendantIds.size === 0) return
const selectedNodeIds = new Set(flowNodes.value.filter(n => n.selected).map(n => n.id))
descendantIds.forEach(id => {
if (!selectedNodeIds.has(id)) {
const targetNode = flowNodes.value.find(n => n.id === id)
if (targetNode) {
targetNode.position = {
x: targetNode.position.x + fallbackDelta.x,
y: targetNode.position.y + fallbackDelta.y
}
}
}
})
}
return
}
const dx = node.position.x - lastPosition.x
const dy = node.position.y - lastPosition.y
if (dx === 0 && dy === 0) return
dragLastPositionByNodeId.set(node.id, { x: node.position.x, y: node.position.y })
const descendantIds = getDescendantIds(node.id)
if (descendantIds.size === 0) return
// 获取当前所有选中的节点,避免重复位移
const selectedNodeIds = new Set(flowNodes.value.filter(n => n.selected).map(n => n.id))
// 批量更新子节点位置
descendantIds.forEach(id => {
if (!selectedNodeIds.has(id)) {
const targetNode = flowNodes.value.find(n => n.id === id)
if (targetNode) {
// 直接更新位置对象,确保 Vue 能够检测到深层变化
targetNode.position = {
x: targetNode.position.x + dx,
y: targetNode.position.y + dy
}
}
}
})
}
onNodeDrag(handleNodeDrag)
onNodeDragStop(e => {
draggingNodeId.value = null
if (e?.node?.id) {
dragLastPositionByNodeId.delete(e.node.id)
}
})
/**
@@ -166,16 +237,25 @@ export function useThinkFlow({ t, locale }: { t: Translate; locale: Ref<string>
})
/**
* 获取节点所有后代节点 id用于激活路径计算
* 获取节点所有后代节点 ID (迭代实现,更健壮)
*/
const getDescendantIds = (nodeId: string, ids: Set<string> = new Set()): Set<string> => {
flowEdges.value.forEach(edge => {
if (edge.source === nodeId) {
ids.add(edge.target)
getDescendantIds(edge.target, ids)
const getDescendantIds = (nodeId: string): Set<string> => {
const descendants = new Set<string>()
const stack = [nodeId]
const edges = flowEdges.value
while (stack.length > 0) {
const currentId = stack.pop()!
for (const edge of edges) {
if (edge.source === currentId) {
if (!descendants.has(edge.target)) {
descendants.add(edge.target)
stack.push(edge.target)
}
})
return ids
}
}
}
return descendants
}
/**
@@ -235,7 +315,8 @@ export function useThinkFlow({ t, locale }: { t: Translate; locale: Ref<string>
edgeType: 'default',
backgroundVariant: BackgroundVariant.Lines,
showControls: true,
showMiniMap: true
showMiniMap: true,
hierarchicalDragging: true
})
const lastAppliedStatus = ref('')
@@ -763,6 +844,7 @@ export function useThinkFlow({ t, locale }: { t: Translate; locale: Ref<string>
fitView,
resetLayout,
centerRoot,
handleNodeDrag,
startNewSession,
executeReset,
generateSummary,

View File

@@ -50,7 +50,8 @@
"summary": "SUMMARY",
"export": "EXPORT",
"fullscreen": "Fullscreen",
"exitFullscreen": "Exit Fullscreen"
"exitFullscreen": "Exit Fullscreen",
"hierarchicalDragging": "Linked Drag"
},
"settings": {
"title": "API Settings",

View File

@@ -50,7 +50,8 @@
"summary": "总结",
"export": "导出",
"fullscreen": "全屏",
"exitFullscreen": "退出全屏"
"exitFullscreen": "退出全屏",
"hierarchicalDragging": "联动拖拽"
},
"settings": {
"title": "API 设置",

View File

@@ -33,6 +33,7 @@ body {
/* Markdown 渲染基础样式 */
.markdown-body {
@apply text-slate-700;
padding: 0 1rem;
}
.markdown-body h1 {