diff --git a/src/App.vue b/src/App.vue index a3a5153..8328d31 100644 --- a/src/App.vue +++ b/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" > { :deepDive="deepDive" :generateNodeImage="generateNodeImage" :expandIdea="expandIdea" + :toggleSubtreeCollapse="toggleSubtreeCollapse" + :isSubtreeCollapsed="isSubtreeCollapsed" @preview="previewImageUrl = $event" /> -
+
+
+
+
diff --git a/src/components/WindowNode.vue b/src/components/WindowNode.vue index c56bbe0..1a9dcba 100644 --- a/src/components/WindowNode.vue +++ b/src/components/WindowNode.vue @@ -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 = () => { {{ props.data.type === 'root' ? props.t('node.mainTitle') : props.t('node.moduleTitle') }} +
diff --git a/src/composables/useThinkFlow.ts b/src/composables/useThinkFlow.ts index 291a956..3b5795b 100644 --- a/src/composables/useThinkFlow.ts +++ b/src/composables/useThinkFlow.ts @@ -106,6 +106,7 @@ export function useThinkFlow({ t, locale }: { t: Translate; locale: Ref removeNodes, removeEdges, fitView, + viewport, onNodeDragStart, onNodeDrag, onNodeDragStop @@ -124,6 +125,7 @@ export function useThinkFlow({ t, locale }: { t: Translate; locale: Ref const draggingNodeId = ref(null) const dragLastPositionByNodeId = new Map() + 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 * 处理节点拖拽事件 (联动移动子节点) */ 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 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 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([]) + + 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() + const childrenCountById = new Map() + + 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 resetLayout, centerRoot, handleNodeDrag, + alignmentGuides, + viewport, + toggleSubtreeCollapse, + isSubtreeCollapsed, startNewSession, executeReset, generateSummary,