feat(节点交互): 实现联动拖拽功能并优化节点聚焦体验
- 新增联动拖拽功能,支持配置开关,拖动父节点时自动移动子节点 - 优化节点聚焦交互,聚焦时放大节点并居中视图 - 重构后代节点查找逻辑,改用迭代方式提高性能 - 在顶部导航栏添加联动拖拽开关按钮 - 为i18n添加相关翻译字段 - 调整markdown渲染样式增加内边距
This commit is contained in:
@@ -91,6 +91,7 @@ const {
|
|||||||
fitView,
|
fitView,
|
||||||
resetLayout,
|
resetLayout,
|
||||||
centerRoot,
|
centerRoot,
|
||||||
|
handleNodeDrag,
|
||||||
startNewSession,
|
startNewSession,
|
||||||
executeReset,
|
executeReset,
|
||||||
generateSummary,
|
generateSummary,
|
||||||
@@ -139,6 +140,7 @@ 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'"
|
||||||
|
@node-drag="handleNodeDrag"
|
||||||
>
|
>
|
||||||
<Background
|
<Background
|
||||||
:variant="config.backgroundVariant"
|
:variant="config.backgroundVariant"
|
||||||
@@ -160,6 +162,7 @@ const fitToView = () => {
|
|||||||
:selected="selected"
|
:selected="selected"
|
||||||
:t="t"
|
:t="t"
|
||||||
:config="config"
|
:config="config"
|
||||||
|
:fitView="fitView"
|
||||||
:activeNodeId="activeNodeId"
|
:activeNodeId="activeNodeId"
|
||||||
:activePath="activePath"
|
:activePath="activePath"
|
||||||
:flowNodes="flowNodes"
|
:flowNodes="flowNodes"
|
||||||
|
|||||||
@@ -6,13 +6,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
// 基础依赖
|
// 基础依赖
|
||||||
import { ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
|
||||||
// 背景样式枚举(用于切换 Lines/Dots)
|
// 背景样式枚举(用于切换 Lines/Dots)
|
||||||
import { BackgroundVariant } from '@vue-flow/background'
|
import { BackgroundVariant } from '@vue-flow/background'
|
||||||
|
|
||||||
// 图标:所有按钮与状态展示
|
// 图标:所有按钮与状态展示
|
||||||
import {
|
import {
|
||||||
|
ArrowLeftRight,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
Download,
|
Download,
|
||||||
@@ -58,6 +59,74 @@ const emit = defineEmits<{
|
|||||||
*/
|
*/
|
||||||
const isToolsExpanded = ref(false)
|
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>
|
<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')">
|
<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" />
|
<Trash2 class="w-3.5 h-3.5 md:w-4 h-4" />
|
||||||
<span>{{ props.t('nav.reset') }}</span>
|
<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>
|
<span class="text-[9px] md:text-[10px] font-bold text-slate-500 uppercase">{{ props.t('nav.edge') }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<select v-model="props.config.edgeType" class="toolbar-select flex-shrink-0">
|
<div data-edge-type-menu="true" class="relative flex-shrink-0">
|
||||||
<option value="default">{{ props.t('nav.edgeTypes.default') }}</option>
|
<button type="button" class="toolbar-select flex items-center gap-2" @click="toggleEdgeTypeMenu">
|
||||||
<option value="straight">{{ props.t('nav.edgeTypes.straight') }}</option>
|
<span>{{ currentEdgeTypeLabel }}</span>
|
||||||
<option value="step">{{ props.t('nav.edgeTypes.step') }}</option>
|
<ChevronDown class="w-3 h-3 opacity-60" />
|
||||||
<option value="smoothstep">{{ props.t('nav.edgeTypes.smoothstep') }}</option>
|
</button>
|
||||||
<option value="simplebezier">{{ props.t('nav.edgeTypes.simplebezier') }}</option>
|
<div
|
||||||
</select>
|
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">
|
<div data-background-menu="true" class="relative flex-shrink-0">
|
||||||
<option :value="BackgroundVariant.Lines">{{ props.t('nav.lines') }}</option>
|
<button type="button" class="toolbar-select flex items-center gap-2" @click="toggleBackgroundMenu">
|
||||||
<option :value="BackgroundVariant.Dots">{{ props.t('nav.dots') }}</option>
|
<span>{{ currentBackgroundLabel }}</span>
|
||||||
</select>
|
<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>
|
<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>
|
<span class="text-[10px] font-bold text-slate-500 uppercase">{{ props.t('nav.edge') }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<select v-model="props.config.edgeType" class="toolbar-select">
|
<div data-edge-type-menu="true" class="relative">
|
||||||
<option value="default">{{ props.t('nav.edgeTypes.default') }}</option>
|
<button type="button" class="toolbar-select flex items-center gap-2" @click="toggleEdgeTypeMenu">
|
||||||
<option value="straight">{{ props.t('nav.edgeTypes.straight') }}</option>
|
<span>{{ currentEdgeTypeLabel }}</span>
|
||||||
<option value="step">{{ props.t('nav.edgeTypes.step') }}</option>
|
<ChevronDown class="w-3 h-3 opacity-60" />
|
||||||
<option value="smoothstep">{{ props.t('nav.edgeTypes.smoothstep') }}</option>
|
</button>
|
||||||
<option value="simplebezier">{{ props.t('nav.edgeTypes.simplebezier') }}</option>
|
<div
|
||||||
</select>
|
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">
|
<div data-background-menu="true" class="relative">
|
||||||
<option :value="BackgroundVariant.Lines">{{ props.t('nav.lines') }}</option>
|
<button type="button" class="toolbar-select flex items-center gap-2" @click="toggleBackgroundMenu">
|
||||||
<option :value="BackgroundVariant.Dots">{{ props.t('nav.dots') }}</option>
|
<span>{{ currentBackgroundLabel }}</span>
|
||||||
</select>
|
<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
|
<button
|
||||||
@click="props.config.showMiniMap = !props.config.showMiniMap"
|
@click="props.config.showMiniMap = !props.config.showMiniMap"
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ const props = defineProps<{
|
|||||||
selected: boolean
|
selected: boolean
|
||||||
t: any
|
t: any
|
||||||
config: any
|
config: any
|
||||||
|
fitView: (options?: any) => void
|
||||||
activeNodeId: string | null
|
activeNodeId: string | null
|
||||||
activePath: { nodeIds: Set<string>; edgeIds: Set<string> }
|
activePath: { nodeIds: Set<string>; edgeIds: Set<string> }
|
||||||
flowNodes: any[]
|
flowNodes: any[]
|
||||||
@@ -72,6 +73,22 @@ const isFocused = ref(false)
|
|||||||
* 从 flowNodes 中找到节点当前位置,用于扩展时定位新节点生成的参考坐标
|
* 从 flowNodes 中找到节点当前位置,用于扩展时定位新节点生成的参考坐标
|
||||||
*/
|
*/
|
||||||
const getNodePosition = (id: string) => props.flowNodes.find(n => n.id === id)?.position
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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="window-node group transition-all duration-500"
|
||||||
:class="{
|
:class="{
|
||||||
'opacity-40 grayscale-[0.4] blur-[0.5px] scale-[0.98] pointer-events-none': props.activeNodeId && !props.activePath.nodeIds.has(props.id),
|
'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
|
'!w-[450px]': props.data.isDetailExpanded
|
||||||
}"
|
}"
|
||||||
:style="{
|
:style="{
|
||||||
borderColor: props.activePath.nodeIds.has(props.id) ? props.config.edgeColor : props.config.edgeColor + '40',
|
borderColor: isFocused || 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` : '',
|
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': props.selected ? props.config.edgeColor + '40' : 'transparent'
|
'--tw-ring-color': isFocused || props.selected ? props.config.edgeColor + '40' : 'transparent'
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<Handle type="target" :position="Position.Left" class="!bg-transparent !border-none" />
|
<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 }" />
|
<Terminal v-else class="w-3 h-3" :style="{ color: props.config.edgeColor }" />
|
||||||
<input
|
<input
|
||||||
v-model="props.data.followUp"
|
v-model="props.data.followUp"
|
||||||
@focus="isFocused = true"
|
@focus="handleFocus"
|
||||||
@blur="isFocused = false"
|
@blur="handleBlur"
|
||||||
@keyup.enter="props.expandIdea({ id: props.id, data: props.data, position: getNodePosition(props.id) }, props.data.followUp)"
|
@keyup.enter="props.expandIdea({ id: props.id, data: props.data, position: getNodePosition(props.id) }, props.data.followUp)"
|
||||||
:placeholder="props.t('node.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"
|
class="bg-transparent border-none outline-none text-[10px] font-bold text-slate-700 flex-grow placeholder:text-slate-400"
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ export function useThinkFlow({ t, locale }: { t: Translate; locale: Ref<string>
|
|||||||
removeEdges,
|
removeEdges,
|
||||||
fitView,
|
fitView,
|
||||||
onNodeDragStart,
|
onNodeDragStart,
|
||||||
|
onNodeDrag,
|
||||||
onNodeDragStop
|
onNodeDragStop
|
||||||
} = useVueFlow()
|
} = useVueFlow()
|
||||||
|
|
||||||
@@ -122,6 +123,7 @@ export function useThinkFlow({ t, locale }: { t: Translate; locale: Ref<string>
|
|||||||
const summaryContent = ref('')
|
const summaryContent = ref('')
|
||||||
|
|
||||||
const draggingNodeId = ref<string | null>(null)
|
const draggingNodeId = ref<string | null>(null)
|
||||||
|
const dragLastPositionByNodeId = new Map<string, { x: number; y: number }>()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 交互:按住 Space 启用“抓手拖拽画布”
|
* 交互:按住 Space 启用“抓手拖拽画布”
|
||||||
@@ -149,10 +151,79 @@ export function useThinkFlow({ t, locale }: { t: Translate; locale: Ref<string>
|
|||||||
*/
|
*/
|
||||||
onNodeDragStart(e => {
|
onNodeDragStart(e => {
|
||||||
draggingNodeId.value = e.node.id
|
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
|
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> => {
|
const getDescendantIds = (nodeId: string): Set<string> => {
|
||||||
flowEdges.value.forEach(edge => {
|
const descendants = new Set<string>()
|
||||||
if (edge.source === nodeId) {
|
const stack = [nodeId]
|
||||||
ids.add(edge.target)
|
const edges = flowEdges.value
|
||||||
getDescendantIds(edge.target, ids)
|
|
||||||
|
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',
|
edgeType: 'default',
|
||||||
backgroundVariant: BackgroundVariant.Lines,
|
backgroundVariant: BackgroundVariant.Lines,
|
||||||
showControls: true,
|
showControls: true,
|
||||||
showMiniMap: true
|
showMiniMap: true,
|
||||||
|
hierarchicalDragging: true
|
||||||
})
|
})
|
||||||
|
|
||||||
const lastAppliedStatus = ref('')
|
const lastAppliedStatus = ref('')
|
||||||
@@ -763,6 +844,7 @@ export function useThinkFlow({ t, locale }: { t: Translate; locale: Ref<string>
|
|||||||
fitView,
|
fitView,
|
||||||
resetLayout,
|
resetLayout,
|
||||||
centerRoot,
|
centerRoot,
|
||||||
|
handleNodeDrag,
|
||||||
startNewSession,
|
startNewSession,
|
||||||
executeReset,
|
executeReset,
|
||||||
generateSummary,
|
generateSummary,
|
||||||
|
|||||||
@@ -50,7 +50,8 @@
|
|||||||
"summary": "SUMMARY",
|
"summary": "SUMMARY",
|
||||||
"export": "EXPORT",
|
"export": "EXPORT",
|
||||||
"fullscreen": "Fullscreen",
|
"fullscreen": "Fullscreen",
|
||||||
"exitFullscreen": "Exit Fullscreen"
|
"exitFullscreen": "Exit Fullscreen",
|
||||||
|
"hierarchicalDragging": "Linked Drag"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "API Settings",
|
"title": "API Settings",
|
||||||
|
|||||||
@@ -50,7 +50,8 @@
|
|||||||
"summary": "总结",
|
"summary": "总结",
|
||||||
"export": "导出",
|
"export": "导出",
|
||||||
"fullscreen": "全屏",
|
"fullscreen": "全屏",
|
||||||
"exitFullscreen": "退出全屏"
|
"exitFullscreen": "退出全屏",
|
||||||
|
"hierarchicalDragging": "联动拖拽"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "API 设置",
|
"title": "API 设置",
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ body {
|
|||||||
/* Markdown 渲染基础样式 */
|
/* Markdown 渲染基础样式 */
|
||||||
.markdown-body {
|
.markdown-body {
|
||||||
@apply text-slate-700;
|
@apply text-slate-700;
|
||||||
|
padding: 0 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-body h1 {
|
.markdown-body h1 {
|
||||||
|
|||||||
Reference in New Issue
Block a user