This commit is contained in:
freedakgmail
2025-12-21 19:24:52 +08:00
parent a5145d4d60
commit 3489a10807
387 changed files with 674 additions and 5567 deletions
+281 -1
View File
@@ -1,6 +1,6 @@
"use client"
import { useEffect, useRef, useState, forwardRef, useImperativeHandle, memo } from "react"
import { useEffect, useRef, useState, forwardRef, useImperativeHandle, memo, useCallback } from "react"
import * as d3 from "d3"
// @ts-ignore - d3-org-chart没有TypeScript类型定义
import { OrgChart } from "d3-org-chart"
@@ -951,6 +951,253 @@ const D3OrgChartFlowComponent = forwardRef<D3OrgChartRef, D3OrgChartFlowProps>(
chartInstanceRef.current.fit()
}
// Minimap 状态
const minimapRef = useRef<HTMLCanvasElement>(null)
const [minimapExpanded, setMinimapExpanded] = useState(true)
const minimapDataRef = useRef<{
bbox: { x: number; y: number; width: number; height: number }
mmScale: number
offsetX: number
offsetY: number
} | null>(null)
const isDraggingMinimapRef = useRef(false)
// 更新 minimap(使用 Canvas 绘制)
const updateMinimap = useCallback(() => {
if (!minimapRef.current || !chartRef.current) return
const canvas = minimapRef.current
const ctx = canvas.getContext('2d')
if (!ctx) return
const mainSvg = chartRef.current.querySelector('svg')
if (!mainSvg) return
const mmW = 180, mmH = 130
canvas.width = mmW
canvas.height = mmH
// 清空画布
ctx.fillStyle = '#fafafa'
ctx.fillRect(0, 0, mmW, mmH)
// 直接从 SVG 中查找所有 foreignObject,不管它们在哪个 g 元素中
const foreignObjects = mainSvg.querySelectorAll('foreignObject')
if (foreignObjects.length === 0) return
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
// 收集节点位置数据 - 需要考虑父元素的 transform
const nodePositions: { x: number; y: number; w: number; h: number }[] = []
foreignObjects.forEach((fo) => {
// 获取 foreignObject 的属性
let x = parseFloat(fo.getAttribute('x') || '0')
let y = parseFloat(fo.getAttribute('y') || '0')
const w = parseFloat(fo.getAttribute('width') || '0')
const h = parseFloat(fo.getAttribute('height') || '0')
// 检查父元素是否有 transformd3-org-chart 的节点通常在有 transform 的 g 元素中)
const parentG = fo.parentElement
if (parentG && parentG.tagName === 'g') {
const parentTransform = parentG.getAttribute('transform')
if (parentTransform) {
const translateMatch = parentTransform.match(/translate\(\s*([^,\s]+)[,\s]+([^)\s]+)\s*\)/)
if (translateMatch) {
x += parseFloat(translateMatch[1])
y += parseFloat(translateMatch[2])
}
}
}
if (w > 0 && h > 0) {
nodePositions.push({ x, y, w, h })
minX = Math.min(minX, x)
minY = Math.min(minY, y)
maxX = Math.max(maxX, x + w)
maxY = Math.max(maxY, y + h)
}
})
if (minX === Infinity || minY === Infinity || nodePositions.length === 0) return
// 添加边距
const padding = 20
const contentX = minX - padding
const contentY = minY - padding
const contentW = (maxX - minX) + padding * 2
const contentH = (maxY - minY) + padding * 2
// 计算缩放比例,确保内容完全显示在 minimap 中
const scaleX = (mmW - 8) / contentW
const scaleY = (mmH - 8) / contentH
const mmScale = Math.min(scaleX, scaleY)
// 计算偏移使内容居中
const offsetX = (mmW - contentW * mmScale) / 2 - contentX * mmScale
const offsetY = (mmH - contentH * mmScale) / 2 - contentY * mmScale
// 保存数据供点击使用(使用原始坐标系)
minimapDataRef.current = {
bbox: { x: contentX, y: contentY, width: contentW, height: contentH },
mmScale,
offsetX,
offsetY
}
// 绘制连线(先画连线,再画节点)
ctx.strokeStyle = '#cbd5e1'
ctx.lineWidth = 1
mainSvg.querySelectorAll('path').forEach((path) => {
const d = path.getAttribute('d')
// 跳过没有 d 属性或者是其他类型的 path
if (!d || d.length < 5) return
const path2D = new Path2D()
const commands = d.match(/[MLHVCSQTAZ][^MLHVCSQTAZ]*/gi) || []
commands.forEach(cmd => {
const type = cmd[0].toUpperCase()
const nums = cmd.slice(1).trim().split(/[\s,]+/).map(Number).filter(n => !isNaN(n))
if (type === 'M' && nums.length >= 2) {
path2D.moveTo(nums[0] * mmScale + offsetX, nums[1] * mmScale + offsetY)
} else if (type === 'L' && nums.length >= 2) {
path2D.lineTo(nums[0] * mmScale + offsetX, nums[1] * mmScale + offsetY)
} else if (type === 'C' && nums.length >= 6) {
path2D.bezierCurveTo(
nums[0] * mmScale + offsetX, nums[1] * mmScale + offsetY,
nums[2] * mmScale + offsetX, nums[3] * mmScale + offsetY,
nums[4] * mmScale + offsetX, nums[5] * mmScale + offsetY
)
}
})
ctx.stroke(path2D)
})
// 绘制节点
ctx.fillStyle = '#3b82f6'
nodePositions.forEach(({ x, y, w, h }) => {
const drawX = x * mmScale + offsetX
const drawY = y * mmScale + offsetY
const drawW = Math.max(w * mmScale, 4)
const drawH = Math.max(h * mmScale, 3)
ctx.beginPath()
ctx.roundRect(drawX, drawY, drawW, drawH, 2)
ctx.fill()
})
// 获取当前 transform - d3-org-chart 的 transform 在第一个 g 元素上
const mainG = mainSvg.querySelector('g') as SVGGElement
const transform = mainG?.getAttribute('transform') || ''
const translateMatch = transform.match(/translate\(\s*([^,\s]+)[,\s]+([^)\s]+)\s*\)/)
const scaleMatch = transform.match(/scale\(\s*([^)\s]+)\s*\)/)
const tx = translateMatch ? parseFloat(translateMatch[1]) : 0
const ty = translateMatch ? parseFloat(translateMatch[2]) : 0
const k = scaleMatch ? parseFloat(scaleMatch[1]) : 1
// 计算视口在原始坐标系中的位置
const svgRect = mainSvg.getBoundingClientRect()
const viewX = -tx / k
const viewY = -ty / k
const viewW = svgRect.width / k
const viewH = svgRect.height / k
// 绘制视口框
const vpDrawX = viewX * mmScale + offsetX
const vpDrawY = viewY * mmScale + offsetY
const vpDrawW = viewW * mmScale
const vpDrawH = viewH * mmScale
ctx.strokeStyle = '#ef4444'
ctx.lineWidth = 2
ctx.fillStyle = 'rgba(239, 68, 68, 0.15)'
ctx.beginPath()
ctx.rect(vpDrawX, vpDrawY, vpDrawW, vpDrawH)
ctx.fill()
ctx.stroke()
}, [])
// 定时更新 minimap
useEffect(() => {
if (!minimapExpanded || !isReady) return
const timer = setTimeout(updateMinimap, 500)
const interval = setInterval(updateMinimap, 200)
return () => {
clearTimeout(timer)
clearInterval(interval)
}
}, [minimapExpanded, isReady, updateMinimap])
// 点击/拖拽 minimap 定位到具体位置
const handleMinimapInteraction = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
if (!minimapDataRef.current || !chartRef.current || !chartInstanceRef.current) return
const { mmScale, offsetX, offsetY } = minimapDataRef.current
const canvas = e.currentTarget
const rect = canvas.getBoundingClientRect()
const clickX = e.clientX - rect.left
const clickY = e.clientY - rect.top
// 转换到原始坐标系
const treeX = (clickX - offsetX) / mmScale
const treeY = (clickY - offsetY) / mmScale
// 获取主图
const mainSvg = chartRef.current.querySelector('svg') as SVGSVGElement
if (!mainSvg) return
const mainG = mainSvg.querySelector('g')
if (!mainG) return
const svgRect = mainSvg.getBoundingClientRect()
// 获取当前缩放级别
const transform = mainG.getAttribute('transform') || ''
const scaleMatch = transform.match(/scale\(\s*([^)\s]+)\s*\)/)
const currentK = scaleMatch ? parseFloat(scaleMatch[1]) : 1
// 计算新的 translate,使点击位置移动到视口中心
const newTx = svgRect.width / 2 - treeX * currentK
const newTy = svgRect.height / 2 - treeY * currentK
// 直接修改 g 元素的 transform,不触发 d3-org-chart 的 zoom 事件
// 这样可以避免触发 fit() 等操作
const d3MainG = d3.select(mainG)
if (isDraggingMinimapRef.current) {
d3MainG.attr('transform', `translate(${newTx}, ${newTy}) scale(${currentK})`)
} else {
d3MainG.transition().duration(200)
.attr('transform', `translate(${newTx}, ${newTy}) scale(${currentK})`)
}
// 同步 d3.zoom 的内部状态(重要!避免下次交互时状态不一致)
const d3Svg = d3.select(mainSvg)
const newTransform = d3.zoomIdentity.translate(newTx, newTy).scale(currentK)
// @ts-ignore - 设置 zoom 的 __zoom 属性来同步状态
mainSvg.__zoom = newTransform
}, [])
const handleMinimapMouseDown = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
isDraggingMinimapRef.current = true
handleMinimapInteraction(e)
}, [handleMinimapInteraction])
const handleMinimapMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
if (isDraggingMinimapRef.current) {
handleMinimapInteraction(e)
}
}, [handleMinimapInteraction])
const handleMinimapMouseUp = useCallback(() => {
isDraggingMinimapRef.current = false
}, [])
// 暴露方法给父组件
useImperativeHandle(ref, () => ({
exportPNG: handleExportPNG,
@@ -968,6 +1215,39 @@ const D3OrgChartFlowComponent = forwardRef<D3OrgChartRef, D3OrgChartFlowProps>(
className="w-full h-full"
/>
{/* Minimap */}
{minimapExpanded ? (
<div className="absolute bottom-20 right-4 z-20 md:bottom-4 bg-background/95 backdrop-blur rounded-lg border shadow-lg overflow-hidden">
<div className="flex items-center justify-between px-2 py-1 border-b bg-muted/50">
<span className="text-xs font-medium text-muted-foreground"></span>
<button
className="h-6 w-6 flex items-center justify-center hover:bg-accent rounded"
onClick={() => setMinimapExpanded(false)}
title="收起"
>
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M4 14h6v6"/><path d="M20 10h-6V4"/><path d="m14 10 7-7"/><path d="m3 21 7-7"/></svg>
</button>
</div>
<canvas
ref={minimapRef}
onMouseDown={handleMinimapMouseDown}
onMouseMove={handleMinimapMouseMove}
onMouseUp={handleMinimapMouseUp}
onMouseLeave={handleMinimapMouseUp}
style={{ display: 'block', cursor: 'crosshair' }}
/>
</div>
) : (
<div className="absolute bottom-20 right-4 z-20 md:bottom-4">
<button
className="h-10 w-10 flex items-center justify-center bg-background/90 backdrop-blur shadow-lg border rounded-lg hover:bg-accent"
onClick={() => setMinimapExpanded(true)}
title="显示缩略图"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="3 6 9 3 15 6 21 3 21 18 15 21 9 18 3 21"/><line x1="9" x2="9" y1="3" y2="18"/><line x1="15" x2="15" y1="6" y2="21"/></svg>
</button>
</div>
)}
{/* 自定义右键菜单 */}
{contextMenuNode && (
<div