01211948
This commit is contained in:
parent
c25ddb5476
commit
ae5ab6b649
|
@ -1,45 +0,0 @@
|
||||||
<system>
|
|
||||||
角色定位:
|
|
||||||
- 身份: 高级错误处理与诊断工程师
|
|
||||||
- 专业能力: 深入系统异常分析与解决
|
|
||||||
- 分析维度: 错误类型、根因追踪、修复策略
|
|
||||||
|
|
||||||
错误处理分析要求:
|
|
||||||
1. 错误详细诊断
|
|
||||||
- 精确定位错误来源
|
|
||||||
- 追踪完整错误调用链
|
|
||||||
- 分析潜在影响范围
|
|
||||||
|
|
||||||
2. 错误分类与解析
|
|
||||||
- 错误类型精确分类
|
|
||||||
- 技术根因深度剖析
|
|
||||||
- 系统架构潜在风险评估
|
|
||||||
|
|
||||||
3. 修复方案设计
|
|
||||||
- 提供多层次修复建议
|
|
||||||
- 评估每种方案的优缺点
|
|
||||||
- 给出最优实施路径
|
|
||||||
|
|
||||||
4. 预防性建议
|
|
||||||
- 提出系统防御性编程策略
|
|
||||||
- 设计错误拦截与处理机制
|
|
||||||
- 推荐代码健壮性改进方案
|
|
||||||
|
|
||||||
输出规范:
|
|
||||||
- 错误报告格式化文档
|
|
||||||
- 中英文专业技术术语精准使用
|
|
||||||
- 层次清晰、逻辑严密
|
|
||||||
- 技术性、建设性并重
|
|
||||||
|
|
||||||
报告要素:
|
|
||||||
1. 错误摘要
|
|
||||||
2. 详细诊断报告
|
|
||||||
3. 根因分析
|
|
||||||
4. 修复方案
|
|
||||||
5. 预防建议
|
|
||||||
|
|
||||||
禁止:
|
|
||||||
- 避免泛泛而谈
|
|
||||||
- 不提供无依据的猜测
|
|
||||||
- 严格遵循技术分析逻辑
|
|
||||||
</system>
|
|
|
@ -1,39 +0,0 @@
|
||||||
角色定位:
|
|
||||||
- 身份: 资深前端架构师
|
|
||||||
- 专业能力: React组件设计与重构
|
|
||||||
- 分析维度: 组件性能、可维护性、代码规范
|
|
||||||
|
|
||||||
重构分析要求:
|
|
||||||
1. 组件代码全面评估
|
|
||||||
2. 重构目标:
|
|
||||||
- 提升组件渲染性能
|
|
||||||
- 优化代码结构
|
|
||||||
- 增强组件复用性
|
|
||||||
- 遵循React最佳实践
|
|
||||||
|
|
||||||
重构评估维度:
|
|
||||||
- 状态管理是否合理
|
|
||||||
- 渲染性能分析
|
|
||||||
- Hook使用规范
|
|
||||||
- 组件拆分颗粒度
|
|
||||||
- 依赖管理
|
|
||||||
- 类型安全
|
|
||||||
|
|
||||||
重构输出要求:
|
|
||||||
1. 详细重构方案
|
|
||||||
2. 每个重构点需包含:
|
|
||||||
- 当前问题描述
|
|
||||||
- 重构建议
|
|
||||||
- 重构后代码示例
|
|
||||||
- 性能/架构提升说明
|
|
||||||
|
|
||||||
重构原则:
|
|
||||||
- 保持原有业务逻辑不变
|
|
||||||
- 代码简洁、可读性强
|
|
||||||
- 遵循函数式编程思想
|
|
||||||
- 类型安全优先
|
|
||||||
|
|
||||||
禁止:
|
|
||||||
- 过度工程化
|
|
||||||
- 不切实际的重构
|
|
||||||
- 损害可读性的过度抽象
|
|
|
@ -24,6 +24,9 @@
|
||||||
"@ag-grid-enterprise/set-filter": "~32.3.2",
|
"@ag-grid-enterprise/set-filter": "~32.3.2",
|
||||||
"@ag-grid-enterprise/status-bar": "~32.3.2",
|
"@ag-grid-enterprise/status-bar": "~32.3.2",
|
||||||
"@ant-design/icons": "^5.4.0",
|
"@ant-design/icons": "^5.4.0",
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@floating-ui/react": "^0.26.25",
|
"@floating-ui/react": "^0.26.25",
|
||||||
"@heroicons/react": "^2.2.0",
|
"@heroicons/react": "^2.2.0",
|
||||||
"@hookform/resolvers": "^3.9.1",
|
"@hookform/resolvers": "^3.9.1",
|
||||||
|
@ -38,6 +41,7 @@
|
||||||
"@trpc/client": "11.0.0-rc.456",
|
"@trpc/client": "11.0.0-rc.456",
|
||||||
"@trpc/react-query": "11.0.0-rc.456",
|
"@trpc/react-query": "11.0.0-rc.456",
|
||||||
"@trpc/server": "11.0.0-rc.456",
|
"@trpc/server": "11.0.0-rc.456",
|
||||||
|
"@xyflow/react": "^12.3.6",
|
||||||
"ag-grid-community": "~32.3.2",
|
"ag-grid-community": "~32.3.2",
|
||||||
"ag-grid-enterprise": "~32.3.2",
|
"ag-grid-enterprise": "~32.3.2",
|
||||||
"ag-grid-react": "~32.3.2",
|
"ag-grid-react": "~32.3.2",
|
||||||
|
@ -46,7 +50,10 @@
|
||||||
"browser-image-compression": "^2.0.2",
|
"browser-image-compression": "^2.0.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"d3-dag": "^1.1.0",
|
||||||
|
"d3-hierarchy": "^3.1.2",
|
||||||
"dayjs": "^1.11.12",
|
"dayjs": "^1.11.12",
|
||||||
|
"elkjs": "^0.9.3",
|
||||||
"framer-motion": "^11.15.0",
|
"framer-motion": "^11.15.0",
|
||||||
"hls.js": "^1.5.18",
|
"hls.js": "^1.5.18",
|
||||||
"idb-keyval": "^6.2.1",
|
"idb-keyval": "^6.2.1",
|
||||||
|
@ -55,6 +62,7 @@
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-beautiful-dnd": "^13.1.1",
|
"react-beautiful-dnd": "^13.1.1",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
|
"react-dropzone": "^14.3.5",
|
||||||
"react-hook-form": "^7.54.2",
|
"react-hook-form": "^7.54.2",
|
||||||
"react-hot-toast": "^2.4.1",
|
"react-hot-toast": "^2.4.1",
|
||||||
"react-resizable": "^3.0.5",
|
"react-resizable": "^3.0.5",
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import GraphEditor from '@web/src/components/common/editor/graph/GraphEditor';
|
||||||
import MindMapEditor from '@web/src/components/presentation/mind-map';
|
import MindMapEditor from '@web/src/components/presentation/mind-map';
|
||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
import * as tus from 'tus-js-client';
|
import * as tus from 'tus-js-client';
|
||||||
|
@ -50,10 +51,14 @@ const TusUploader: React.FC<TusUploadProps> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
<div className='w-full' style={{ height: 800 }}>
|
||||||
|
<GraphEditor></GraphEditor>
|
||||||
|
</div>
|
||||||
{/* <div className=' h-screen'>
|
{/* <div className=' h-screen'>
|
||||||
<MindMap></MindMap>
|
<MindMap></MindMap>
|
||||||
</div> */}
|
</div> */}
|
||||||
<MindMapEditor></MindMapEditor>
|
{/* <MindMapEditor></MindMapEditor> */}
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
interface CardProps {
|
interface CardProps {
|
||||||
|
@ -6,23 +5,37 @@ interface CardProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
hover?: boolean;
|
hover?: boolean;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
variant?: 'default' | 'elevated' | 'outlined';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Card = ({ children, className = '', hover = true, onClick }: CardProps) => {
|
export const Card = ({
|
||||||
|
children,
|
||||||
|
className = '',
|
||||||
|
hover = true,
|
||||||
|
onClick,
|
||||||
|
variant = 'default'
|
||||||
|
}: CardProps) => {
|
||||||
|
const baseStyles = 'rounded-xl transition-all duration-300';
|
||||||
|
|
||||||
|
const variantStyles = {
|
||||||
|
default: 'bg-white/90 border border-gray-100 shadow-sm',
|
||||||
|
elevated: 'bg-gradient-to-br from-white/95 to-white/90 shadow-lg border border-gray-50',
|
||||||
|
outlined: 'bg-transparent border-2 border-gray-200 hover:border-gray-300'
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<div
|
||||||
whileHover={hover ? { y: -5, transition: { duration: 0.2 } } : undefined}
|
|
||||||
className={`
|
className={`
|
||||||
bg-white
|
${baseStyles}
|
||||||
rounded-xl shadow-lg
|
${variantStyles[variant]}
|
||||||
overflow-hidden
|
|
||||||
border border-gray-100
|
|
||||||
${hover ? 'cursor-pointer' : ''}
|
${hover ? 'cursor-pointer' : ''}
|
||||||
${className}
|
${className}
|
||||||
`}
|
`}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</motion.div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
|
@ -0,0 +1,105 @@
|
||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { addEdge, ReactFlow, Background, Controls, Edge, Node, ReactFlowProvider, useEdgesState, useNodesState, MiniMap, Panel, BackgroundVariant, ControlButton, applyNodeChanges, applyEdgeChanges, SelectionMode, OnNodesChange, OnEdgesChange, useReactFlow, useOnSelectionChange, useNodesInitialized } from '@xyflow/react';
|
||||||
|
import { Button } from '../../element/Button';
|
||||||
|
import '@xyflow/react/dist/style.css';
|
||||||
|
import { edgeTypes, GraphState, nodeTypes } from './types';
|
||||||
|
import useGraphStore from './store';
|
||||||
|
import { shallow } from 'zustand/shallow';
|
||||||
|
import { useKeyboardCtrl } from './useKeyboardCtrl';
|
||||||
|
import { getMindMapLayout } from './layout';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const selector = (store: GraphState) => ({
|
||||||
|
nodes: store.present.nodes,
|
||||||
|
edges: store.present.edges,
|
||||||
|
setNodes: store.setNodes,
|
||||||
|
setEdges: store.setEdges,
|
||||||
|
record: store.record,
|
||||||
|
onNodesChange: store.onNodesChange,
|
||||||
|
onEdgesChange: store.onEdgesChange,
|
||||||
|
});
|
||||||
|
|
||||||
|
const panOnDrag = [1, 2];
|
||||||
|
|
||||||
|
const Flow: React.FC = () => {
|
||||||
|
|
||||||
|
const store = useGraphStore(selector, shallow);
|
||||||
|
useKeyboardCtrl()
|
||||||
|
const nodesInitialized = useNodesInitialized();
|
||||||
|
const onLayout = useCallback(async () => {
|
||||||
|
const layouted = getMindMapLayout({ nodes: store.nodes, edges: store.edges })
|
||||||
|
store.setNodes(layouted.nodes)
|
||||||
|
store.setEdges(layouted.edges)
|
||||||
|
}, [store.nodes, store.edges]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (nodesInitialized && store.nodes.length) {
|
||||||
|
console.log('layout')
|
||||||
|
onLayout()
|
||||||
|
}
|
||||||
|
}, [nodesInitialized, store.nodes.length]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReactFlow
|
||||||
|
nodesDraggable={true}
|
||||||
|
nodes={store.nodes}
|
||||||
|
edges={store.edges}
|
||||||
|
|
||||||
|
onNodesChange={(changes) => {
|
||||||
|
const recordTypes = new Set(['remove', 'select']);
|
||||||
|
const undoChanges = changes.filter(change => recordTypes.has(change.type))
|
||||||
|
const otherChanges = changes.filter(change => !recordTypes.has(change.type))
|
||||||
|
if (undoChanges.length)
|
||||||
|
store.record(() => {
|
||||||
|
store.onNodesChange(undoChanges);
|
||||||
|
});
|
||||||
|
store.onNodesChange(otherChanges);
|
||||||
|
}}
|
||||||
|
onEdgesChange={(changes) => {
|
||||||
|
const recordTypes = new Set(['remove', 'select']);
|
||||||
|
changes.forEach((change) => {
|
||||||
|
if (recordTypes.has(change.type)) {
|
||||||
|
store.record(() => {
|
||||||
|
store.onEdgesChange([change]);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
store.onEdgesChange([change]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
selectionOnDrag
|
||||||
|
panOnDrag={panOnDrag}
|
||||||
|
nodeTypes={nodeTypes}
|
||||||
|
edgeTypes={edgeTypes}
|
||||||
|
selectionMode={SelectionMode.Partial}
|
||||||
|
fitView
|
||||||
|
minZoom={0.001}
|
||||||
|
maxZoom={1000}
|
||||||
|
>
|
||||||
|
<Panel position="top-right">
|
||||||
|
<div className='flex items-center gap-4'>
|
||||||
|
<Button onClick={onLayout}>自动布局</Button>
|
||||||
|
<span>节点个数{store.nodes.length}</span>
|
||||||
|
<span>边条数{store.edges.length}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</Panel>
|
||||||
|
<Background variant={BackgroundVariant.Dots} />
|
||||||
|
<Controls >
|
||||||
|
<ControlButton>测试</ControlButton>
|
||||||
|
</Controls>
|
||||||
|
<MiniMap pannable zoomable nodeStrokeWidth={3} position='bottom-right'></MiniMap>
|
||||||
|
</ReactFlow>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const GraphEditor: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<ReactFlowProvider>
|
||||||
|
<Flow></Flow>
|
||||||
|
</ReactFlowProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GraphEditor;
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { MarkerType } from "@xyflow/react";
|
||||||
|
|
||||||
|
// 生成思维导图数据的函数
|
||||||
|
function generateMindMapData(levels: number, nodesPerLevel: number) {
|
||||||
|
const nodes = [];
|
||||||
|
const edges = [];
|
||||||
|
|
||||||
|
// 添加根节点
|
||||||
|
nodes.push({
|
||||||
|
id: 'root',
|
||||||
|
data: { label: '核心主题', level: 0 },
|
||||||
|
type: 'graph-node',
|
||||||
|
position: { x: 0, y: 0 }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 为每一层生成节点
|
||||||
|
for (let level = 1; level <= levels; level++) {
|
||||||
|
const angleStep = (2 * Math.PI) / nodesPerLevel;
|
||||||
|
const radius = level * 200; // 每层的半径
|
||||||
|
|
||||||
|
for (let i = 0; i < nodesPerLevel; i++) {
|
||||||
|
const angle = i * angleStep;
|
||||||
|
const nodeId = `node-${level}-${i}`;
|
||||||
|
|
||||||
|
// 计算节点位置
|
||||||
|
const x = Math.cos(angle) * radius;
|
||||||
|
const y = Math.sin(angle) * radius;
|
||||||
|
|
||||||
|
// 添加节点
|
||||||
|
nodes.push({
|
||||||
|
id: nodeId,
|
||||||
|
data: { label: `主题${level}-${i}`, level },
|
||||||
|
type: 'graph-node',
|
||||||
|
position: { x, y }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 添加边
|
||||||
|
// 第一层连接到根节点,其他层连接到上一层的节点
|
||||||
|
const sourceId = level === 1 ? 'root' : `node-${level - 1}-${Math.floor(i / 2)}`;
|
||||||
|
edges.push({
|
||||||
|
id: `edge-${level}-${i}`,
|
||||||
|
source: sourceId,
|
||||||
|
target: nodeId,
|
||||||
|
type: 'graph-edge',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { nodes, edges };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成测试数据 - 可以调整参数来控制规模
|
||||||
|
// 参数1: 层级数量
|
||||||
|
// 参数2: 每层节点数量
|
||||||
|
const { nodes: initialNodes, edges: initialEdges } = generateMindMapData(2, 3);
|
||||||
|
|
||||||
|
export { initialNodes, initialEdges };
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { EdgeProps, getBezierPath, useInternalNode } from '@xyflow/react';
|
||||||
|
import { getEdgeParams } from '../utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FloatingEdge 组件用于渲染图中的浮动边。
|
||||||
|
* 该组件通过计算源节点和目标节点的位置,生成贝塞尔曲线路径,并渲染为SVG路径元素。
|
||||||
|
* 适用于需要自定义边样式的图结构可视化场景。
|
||||||
|
*/
|
||||||
|
function FloatingEdge({ id, source, target, markerEnd, style }: EdgeProps) {
|
||||||
|
// 使用 useInternalNode 钩子获取源节点和目标节点的内部节点信息
|
||||||
|
const sourceNode = useInternalNode(source);
|
||||||
|
const targetNode = useInternalNode(target);
|
||||||
|
|
||||||
|
// 如果源节点或目标节点不存在,则不渲染任何内容
|
||||||
|
if (!sourceNode || !targetNode) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取边的参数,包括源节点和目标节点的坐标及位置信息
|
||||||
|
const { sx, sy, tx, ty, sourcePos, targetPos } = getEdgeParams(
|
||||||
|
sourceNode,
|
||||||
|
targetNode,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 使用 getBezierPath 函数生成贝塞尔曲线路径
|
||||||
|
const [edgePath] = getBezierPath({
|
||||||
|
sourceX: sx,
|
||||||
|
sourceY: sy,
|
||||||
|
sourcePosition: sourcePos,
|
||||||
|
targetPosition: targetPos,
|
||||||
|
targetX: tx,
|
||||||
|
targetY: ty,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 返回 SVG 路径元素,表示图中的边
|
||||||
|
return (
|
||||||
|
<path
|
||||||
|
id={id}
|
||||||
|
className="react-flow__edge-path"
|
||||||
|
d={edgePath}
|
||||||
|
markerEnd={markerEnd}
|
||||||
|
style={style}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FloatingEdge;
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { BaseEdge, Edge, EdgeLabelRenderer, EdgeProps, getBezierPath, getSmoothStepPath, getStraightPath, useReactFlow } from '@xyflow/react';
|
||||||
|
|
||||||
|
export type GraphEdge = Edge<{ text: string }, 'graph-edge'>;
|
||||||
|
|
||||||
|
export const GraphEdge = ({ id, sourceX, sourceY, targetX, targetY, data, ...props }: EdgeProps<GraphEdge>) => {
|
||||||
|
const { setEdges } = useReactFlow();
|
||||||
|
// 使用贝塞尔曲线代替直线,让连线更流畅
|
||||||
|
const [edgePath, labelX, labelY] = getBezierPath({
|
||||||
|
sourceX,
|
||||||
|
sourceY,
|
||||||
|
targetX,
|
||||||
|
targetY,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<BaseEdge
|
||||||
|
path={edgePath}
|
||||||
|
style={{
|
||||||
|
strokeWidth: 2,
|
||||||
|
stroke: '#b1b1b7',
|
||||||
|
transition: 'stroke 0.3s, stroke-width 0.3s',
|
||||||
|
}}
|
||||||
|
className="hover:stroke-blue-500 hover:stroke-[3px]"
|
||||||
|
/>
|
||||||
|
{/* 添加边的标签渲染器 */}
|
||||||
|
<EdgeLabelRenderer>
|
||||||
|
{data?.text && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
|
||||||
|
fontSize: 12,
|
||||||
|
pointerEvents: 'all',
|
||||||
|
}}
|
||||||
|
className="nodrag nopan px-2 py-1 rounded bg-white/80 shadow-sm"
|
||||||
|
>
|
||||||
|
{data.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</EdgeLabelRenderer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,219 @@
|
||||||
|
import { areLinesReverseDirection, areLinesSameDirection } from "../edge";
|
||||||
|
import {
|
||||||
|
ControlPoint,
|
||||||
|
NodeRect,
|
||||||
|
isEqualPoint,
|
||||||
|
isSegmentCrossingRect,
|
||||||
|
} from "../point";
|
||||||
|
|
||||||
|
interface GetAStarPathParams {
|
||||||
|
/**
|
||||||
|
* Collection of potential control points between `sourceOffset` and `targetOffset`, excluding the `source` and `target` points.
|
||||||
|
*/
|
||||||
|
points: ControlPoint[];
|
||||||
|
source: ControlPoint;
|
||||||
|
target: ControlPoint;
|
||||||
|
/**
|
||||||
|
* Node size information for the `source` and `target`, used to optimize edge routing without intersecting nodes.
|
||||||
|
*/
|
||||||
|
sourceRect: NodeRect;
|
||||||
|
targetRect: NodeRect;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utilizes the [A\* search algorithm](https://en.wikipedia.org/wiki/A*_search_algorithm) combined with
|
||||||
|
* [Manhattan Distance](https://simple.wikipedia.org/wiki/Manhattan_distance) to find the optimal path for edges.
|
||||||
|
*
|
||||||
|
* @returns Control points including sourceOffset and targetOffset (not including source and target points).
|
||||||
|
*/
|
||||||
|
export const getAStarPath = ({
|
||||||
|
points,
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
sourceRect,
|
||||||
|
targetRect,
|
||||||
|
}: GetAStarPathParams): ControlPoint[] => {
|
||||||
|
if (points.length < 3) {
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
const start = points[0];
|
||||||
|
const end = points[points.length - 1];
|
||||||
|
const openSet: ControlPoint[] = [start];
|
||||||
|
const closedSet: Set<ControlPoint> = new Set();
|
||||||
|
const cameFrom: Map<ControlPoint, ControlPoint> = new Map();
|
||||||
|
const gScore: Map<ControlPoint, number> = new Map().set(start, 0);
|
||||||
|
const fScore: Map<ControlPoint, number> = new Map().set(
|
||||||
|
start,
|
||||||
|
heuristicCostEstimate({
|
||||||
|
from: start,
|
||||||
|
to: start,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
while (openSet.length) {
|
||||||
|
let current;
|
||||||
|
let currentIdx;
|
||||||
|
let lowestFScore = Infinity;
|
||||||
|
openSet.forEach((p, idx) => {
|
||||||
|
const score = fScore.get(p) ?? 0;
|
||||||
|
if (score < lowestFScore) {
|
||||||
|
lowestFScore = score;
|
||||||
|
current = p;
|
||||||
|
currentIdx = idx;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!current) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current === end) {
|
||||||
|
return buildPath(cameFrom, current);
|
||||||
|
}
|
||||||
|
|
||||||
|
openSet.splice(currentIdx!, 1);
|
||||||
|
closedSet.add(current);
|
||||||
|
|
||||||
|
const curFScore = fScore.get(current) ?? 0;
|
||||||
|
const previous = cameFrom.get(current);
|
||||||
|
const neighbors = getNextNeighborPoints({
|
||||||
|
points,
|
||||||
|
previous,
|
||||||
|
current,
|
||||||
|
sourceRect,
|
||||||
|
targetRect,
|
||||||
|
});
|
||||||
|
for (const neighbor of neighbors) {
|
||||||
|
if (closedSet.has(neighbor)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const neighborGScore = gScore.get(neighbor) ?? 0;
|
||||||
|
const tentativeGScore = curFScore + estimateDistance(current, neighbor);
|
||||||
|
if (openSet.includes(neighbor) && tentativeGScore >= neighborGScore) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
openSet.push(neighbor);
|
||||||
|
cameFrom.set(neighbor, current);
|
||||||
|
gScore.set(neighbor, tentativeGScore);
|
||||||
|
fScore.set(
|
||||||
|
neighbor,
|
||||||
|
neighborGScore +
|
||||||
|
heuristicCostEstimate({
|
||||||
|
from: current,
|
||||||
|
to: neighbor,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [start, end];
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildPath = (
|
||||||
|
cameFrom: Map<ControlPoint, ControlPoint>,
|
||||||
|
current: ControlPoint
|
||||||
|
): ControlPoint[] => {
|
||||||
|
const path = [current];
|
||||||
|
|
||||||
|
let previous = cameFrom.get(current);
|
||||||
|
while (previous) {
|
||||||
|
path.push(previous);
|
||||||
|
previous = cameFrom.get(previous);
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.reverse();
|
||||||
|
};
|
||||||
|
|
||||||
|
interface GetNextNeighborPointsParams {
|
||||||
|
points: ControlPoint[];
|
||||||
|
previous?: ControlPoint;
|
||||||
|
current: ControlPoint;
|
||||||
|
sourceRect: NodeRect;
|
||||||
|
targetRect: NodeRect;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the set of possible neighboring points for the current control point
|
||||||
|
*
|
||||||
|
* - The line is in a horizontal or vertical direction
|
||||||
|
* - The line does not intersect with the two end nodes
|
||||||
|
* - The line does not overlap with the previous line segment in reverse direction
|
||||||
|
*/
|
||||||
|
export const getNextNeighborPoints = ({
|
||||||
|
points,
|
||||||
|
previous,
|
||||||
|
current,
|
||||||
|
sourceRect,
|
||||||
|
targetRect,
|
||||||
|
}: GetNextNeighborPointsParams): ControlPoint[] => {
|
||||||
|
return points.filter((p) => {
|
||||||
|
if (p === current) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// The connection is in the horizontal or vertical direction
|
||||||
|
const rightDirection = p.x === current.x || p.y === current.y;
|
||||||
|
// Reverse direction with the previous line segment (overlap)
|
||||||
|
const reverseDirection = previous
|
||||||
|
? areLinesReverseDirection(previous, current, current, p)
|
||||||
|
: false;
|
||||||
|
return (
|
||||||
|
rightDirection && // The line is in a horizontal or vertical direction
|
||||||
|
!reverseDirection && // The line does not overlap with the previous line segment in reverse direction
|
||||||
|
!isSegmentCrossingRect(p, current, sourceRect) && // Does not intersect with sourceNode
|
||||||
|
!isSegmentCrossingRect(p, current, targetRect) // Does not intersect with targetNode
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
interface HeuristicCostParams {
|
||||||
|
from: ControlPoint;
|
||||||
|
to: ControlPoint;
|
||||||
|
start: ControlPoint;
|
||||||
|
end: ControlPoint;
|
||||||
|
source: ControlPoint;
|
||||||
|
target: ControlPoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connection point distance loss function
|
||||||
|
*
|
||||||
|
* - The smaller the sum of distances, the better
|
||||||
|
* - The closer the start and end line segments are in direction, the better
|
||||||
|
* - The better the inflection point is symmetric or centered in the line segment
|
||||||
|
*/
|
||||||
|
const heuristicCostEstimate = ({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
}: HeuristicCostParams): number => {
|
||||||
|
const base = estimateDistance(to, start) + estimateDistance(to, end);
|
||||||
|
const startCost = isEqualPoint(from, start)
|
||||||
|
? areLinesSameDirection(from, to, source, start)
|
||||||
|
? -base / 2
|
||||||
|
: 0
|
||||||
|
: 0;
|
||||||
|
const endCost = isEqualPoint(to, end)
|
||||||
|
? areLinesSameDirection(from, to, end, target)
|
||||||
|
? -base / 2
|
||||||
|
: 0
|
||||||
|
: 0;
|
||||||
|
return base + startCost + endCost;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the estimated distance between two points
|
||||||
|
*
|
||||||
|
* Manhattan distance: the sum of horizontal and vertical distances, faster calculation speed
|
||||||
|
*/
|
||||||
|
const estimateDistance = (p1: ControlPoint, p2: ControlPoint): number =>
|
||||||
|
Math.abs(p1.x - p2.x) + Math.abs(p1.y - p2.y);
|
|
@ -0,0 +1,127 @@
|
||||||
|
import { areLinesSameDirection, isHorizontalFromPosition } from "../edge";
|
||||||
|
import {
|
||||||
|
ControlPoint,
|
||||||
|
HandlePosition,
|
||||||
|
NodeRect,
|
||||||
|
getCenterPoints,
|
||||||
|
getExpandedRect,
|
||||||
|
getOffsetPoint,
|
||||||
|
getSidesFromPoints,
|
||||||
|
getVerticesFromRectVertex,
|
||||||
|
optimizeInputPoints,
|
||||||
|
reducePoints,
|
||||||
|
} from "../point";
|
||||||
|
import { getAStarPath } from "./a-star";
|
||||||
|
import { getSimplePath } from "./simple";
|
||||||
|
|
||||||
|
export interface GetControlPointsParams {
|
||||||
|
source: HandlePosition;
|
||||||
|
target: HandlePosition;
|
||||||
|
sourceRect: NodeRect;
|
||||||
|
targetRect: NodeRect;
|
||||||
|
/**
|
||||||
|
* Minimum spacing between edges and nodes
|
||||||
|
*/
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate control points on the optimal path of an edge.
|
||||||
|
*
|
||||||
|
* Reference article: https://juejin.cn/post/6942727734518874142
|
||||||
|
*/
|
||||||
|
export const getControlPoints = ({
|
||||||
|
source: oldSource,
|
||||||
|
target: oldTarget,
|
||||||
|
sourceRect,
|
||||||
|
targetRect,
|
||||||
|
offset = 20,
|
||||||
|
}: GetControlPointsParams) => {
|
||||||
|
const source: ControlPoint = oldSource;
|
||||||
|
const target: ControlPoint = oldTarget;
|
||||||
|
let edgePoints: ControlPoint[] = [];
|
||||||
|
let optimized: ReturnType<typeof optimizeInputPoints>;
|
||||||
|
|
||||||
|
// 1. Find the starting and ending points after applying the offset
|
||||||
|
const sourceOffset = getOffsetPoint(oldSource, offset);
|
||||||
|
const targetOffset = getOffsetPoint(oldTarget, offset);
|
||||||
|
const expandedSource = getExpandedRect(sourceRect, offset);
|
||||||
|
const expandedTarget = getExpandedRect(targetRect, offset);
|
||||||
|
|
||||||
|
// 2. Determine if the two Rects are relatively close or should directly connected
|
||||||
|
const minOffset = 2 * offset + 10;
|
||||||
|
const isHorizontalLayout = isHorizontalFromPosition(oldSource.position);
|
||||||
|
const isSameDirection = areLinesSameDirection(
|
||||||
|
source,
|
||||||
|
sourceOffset,
|
||||||
|
targetOffset,
|
||||||
|
target
|
||||||
|
);
|
||||||
|
const sides = getSidesFromPoints([
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
sourceOffset,
|
||||||
|
targetOffset,
|
||||||
|
]);
|
||||||
|
const isTooClose = isHorizontalLayout
|
||||||
|
? sides.right - sides.left < minOffset
|
||||||
|
: sides.bottom - sides.top < minOffset;
|
||||||
|
const isDirectConnect = isHorizontalLayout
|
||||||
|
? isSameDirection && source.x < target.x
|
||||||
|
: isSameDirection && source.y < target.y;
|
||||||
|
|
||||||
|
if (isTooClose || isDirectConnect) {
|
||||||
|
// 3. If the two Rects are relatively close or directly connected, return a simple Path
|
||||||
|
edgePoints = getSimplePath({
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
sourceOffset,
|
||||||
|
targetOffset,
|
||||||
|
isDirectConnect,
|
||||||
|
});
|
||||||
|
optimized = optimizeInputPoints({
|
||||||
|
source: oldSource,
|
||||||
|
target: oldTarget,
|
||||||
|
sourceOffset,
|
||||||
|
targetOffset,
|
||||||
|
edgePoints,
|
||||||
|
});
|
||||||
|
edgePoints = optimized.edgePoints;
|
||||||
|
} else {
|
||||||
|
// 3. Find the vertices of the two expanded Rects
|
||||||
|
edgePoints = [
|
||||||
|
...getVerticesFromRectVertex(expandedSource, targetOffset),
|
||||||
|
...getVerticesFromRectVertex(expandedTarget, sourceOffset),
|
||||||
|
];
|
||||||
|
// 4. Find possible midpoints and intersections
|
||||||
|
edgePoints = edgePoints.concat(
|
||||||
|
getCenterPoints({
|
||||||
|
source: expandedSource,
|
||||||
|
target: expandedTarget,
|
||||||
|
sourceOffset,
|
||||||
|
targetOffset,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
// 5. Merge nearby coordinate points and remove duplicate coordinate points
|
||||||
|
optimized = optimizeInputPoints({
|
||||||
|
source: oldSource,
|
||||||
|
target: oldTarget,
|
||||||
|
sourceOffset,
|
||||||
|
targetOffset,
|
||||||
|
edgePoints,
|
||||||
|
});
|
||||||
|
// 6. Find the optimal path
|
||||||
|
edgePoints = getAStarPath({
|
||||||
|
points: optimized.edgePoints,
|
||||||
|
source: optimized.source,
|
||||||
|
target: optimized.target,
|
||||||
|
sourceRect: getExpandedRect(sourceRect, offset / 2),
|
||||||
|
targetRect: getExpandedRect(targetRect, offset / 2),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
points: reducePoints([optimized.source, ...edgePoints, optimized.target]),
|
||||||
|
inputPoints: optimized.edgePoints,
|
||||||
|
};
|
||||||
|
};
|
|
@ -0,0 +1,113 @@
|
||||||
|
import { uuid } from "@/utils/uuid";
|
||||||
|
|
||||||
|
import { LayoutDirection } from "../../node";
|
||||||
|
import { ControlPoint, isInLine, isOnLine } from "../point";
|
||||||
|
|
||||||
|
interface GetSimplePathParams {
|
||||||
|
isDirectConnect?: boolean;
|
||||||
|
source: ControlPoint;
|
||||||
|
target: ControlPoint;
|
||||||
|
sourceOffset: ControlPoint;
|
||||||
|
targetOffset: ControlPoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getLineDirection = (
|
||||||
|
start: ControlPoint,
|
||||||
|
end: ControlPoint
|
||||||
|
): LayoutDirection => (start.x === end.x ? "vertical" : "horizontal");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When two nodes are too close, use the simple path
|
||||||
|
*
|
||||||
|
* @returns Control points including sourceOffset and targetOffset (not including source and target points).
|
||||||
|
*/
|
||||||
|
export const getSimplePath = ({
|
||||||
|
isDirectConnect,
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
sourceOffset,
|
||||||
|
targetOffset,
|
||||||
|
}: GetSimplePathParams): ControlPoint[] => {
|
||||||
|
const points: ControlPoint[] = [];
|
||||||
|
const sourceDirection = getLineDirection(source, sourceOffset);
|
||||||
|
const targetDirection = getLineDirection(target, targetOffset);
|
||||||
|
const isHorizontalLayout = sourceDirection === "horizontal";
|
||||||
|
if (isDirectConnect) {
|
||||||
|
// Direct connection, return a simple Path
|
||||||
|
if (isHorizontalLayout) {
|
||||||
|
if (sourceOffset.x <= targetOffset.x) {
|
||||||
|
const centerX = (sourceOffset.x + targetOffset.x) / 2;
|
||||||
|
return [
|
||||||
|
{ id: uuid(), x: centerX, y: sourceOffset.y },
|
||||||
|
{ id: uuid(), x: centerX, y: targetOffset.y },
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
const centerY = (sourceOffset.y + targetOffset.y) / 2;
|
||||||
|
return [
|
||||||
|
sourceOffset,
|
||||||
|
{ id: uuid(), x: sourceOffset.x, y: centerY },
|
||||||
|
{ id: uuid(), x: targetOffset.x, y: centerY },
|
||||||
|
targetOffset,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (sourceOffset.y <= targetOffset.y) {
|
||||||
|
const centerY = (sourceOffset.y + targetOffset.y) / 2;
|
||||||
|
return [
|
||||||
|
{ id: uuid(), x: sourceOffset.x, y: centerY },
|
||||||
|
{ id: uuid(), x: targetOffset.x, y: centerY },
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
const centerX = (sourceOffset.x + targetOffset.x) / 2;
|
||||||
|
return [
|
||||||
|
sourceOffset,
|
||||||
|
{ id: uuid(), x: centerX, y: sourceOffset.y },
|
||||||
|
{ id: uuid(), x: centerX, y: targetOffset.y },
|
||||||
|
targetOffset,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sourceDirection === targetDirection) {
|
||||||
|
// Same direction, add two points, two endpoints of parallel lines at half the vertical distance
|
||||||
|
if (source.y === sourceOffset.y) {
|
||||||
|
points.push({
|
||||||
|
id: uuid(),
|
||||||
|
x: sourceOffset.x,
|
||||||
|
y: (sourceOffset.y + targetOffset.y) / 2,
|
||||||
|
});
|
||||||
|
points.push({
|
||||||
|
id: uuid(),
|
||||||
|
x: targetOffset.x,
|
||||||
|
y: (sourceOffset.y + targetOffset.y) / 2,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
points.push({
|
||||||
|
id: uuid(),
|
||||||
|
x: (sourceOffset.x + targetOffset.x) / 2,
|
||||||
|
y: sourceOffset.y,
|
||||||
|
});
|
||||||
|
points.push({
|
||||||
|
id: uuid(),
|
||||||
|
x: (sourceOffset.x + targetOffset.x) / 2,
|
||||||
|
y: targetOffset.y,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Different directions, add one point, ensure it's not on the current line segment (to avoid overlap), and there are no turns
|
||||||
|
let point = { id: uuid(), x: sourceOffset.x, y: targetOffset.y };
|
||||||
|
const inStart = isInLine(point, source, sourceOffset);
|
||||||
|
const inEnd = isInLine(point, target, targetOffset);
|
||||||
|
if (inStart || inEnd) {
|
||||||
|
point = { id: uuid(), x: targetOffset.x, y: sourceOffset.y };
|
||||||
|
} else {
|
||||||
|
const onStart = isOnLine(point, source, sourceOffset);
|
||||||
|
const onEnd = isOnLine(point, target, targetOffset);
|
||||||
|
if (onStart && onEnd) {
|
||||||
|
point = { id: uuid(), x: targetOffset.x, y: sourceOffset.y };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
points.push(point);
|
||||||
|
}
|
||||||
|
return [sourceOffset, ...points, targetOffset];
|
||||||
|
};
|
|
@ -0,0 +1,248 @@
|
||||||
|
import { areLinesReverseDirection, areLinesSameDirection } from "../edge";
|
||||||
|
import {
|
||||||
|
ControlPoint,
|
||||||
|
NodeRect,
|
||||||
|
isEqualPoint,
|
||||||
|
isSegmentCrossingRect,
|
||||||
|
} from "../point";
|
||||||
|
|
||||||
|
interface GetAStarPathParams {
|
||||||
|
/**
|
||||||
|
* Collection of potential control points between `sourceOffset` and `targetOffset`, excluding the `source` and `target` points.
|
||||||
|
*/
|
||||||
|
points: ControlPoint[];
|
||||||
|
source: ControlPoint;
|
||||||
|
target: ControlPoint;
|
||||||
|
/**
|
||||||
|
* Node size information for the `source` and `target`, used to optimize edge routing without intersecting nodes.
|
||||||
|
*/
|
||||||
|
sourceRect: NodeRect;
|
||||||
|
targetRect: NodeRect;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utilizes the [A\* search algorithm](https://en.wikipedia.org/wiki/A*_search_algorithm) combined with
|
||||||
|
* [Manhattan Distance](https://simple.wikipedia.org/wiki/Manhattan_distance) to find the optimal path for edges.
|
||||||
|
*
|
||||||
|
* @returns Control points including sourceOffset and targetOffset (not including source and target points).
|
||||||
|
*/
|
||||||
|
export const getAStarPath = ({
|
||||||
|
points,
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
sourceRect,
|
||||||
|
targetRect,
|
||||||
|
}: GetAStarPathParams): ControlPoint[] => {
|
||||||
|
if (points.length < 3) {
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
const start = points[0];
|
||||||
|
const end = points[points.length - 1];
|
||||||
|
const openSet: ControlPoint[] = [start];
|
||||||
|
const closedSet: Set<ControlPoint> = new Set();
|
||||||
|
const cameFrom: Map<ControlPoint, ControlPoint> = new Map();
|
||||||
|
const gScore: Map<ControlPoint, number> = new Map().set(start, 0);
|
||||||
|
const fScore: Map<ControlPoint, number> = new Map().set(
|
||||||
|
start,
|
||||||
|
heuristicCostEstimate({
|
||||||
|
from: start,
|
||||||
|
to: start,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
while (openSet.length) {
|
||||||
|
let current;
|
||||||
|
let currentIdx;
|
||||||
|
let lowestFScore = Infinity;
|
||||||
|
openSet.forEach((p, idx) => {
|
||||||
|
const score = fScore.get(p) ?? 0;
|
||||||
|
if (score < lowestFScore) {
|
||||||
|
lowestFScore = score;
|
||||||
|
current = p;
|
||||||
|
currentIdx = idx;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!current) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current === end) {
|
||||||
|
return buildPath(cameFrom, current);
|
||||||
|
}
|
||||||
|
|
||||||
|
openSet.splice(currentIdx!, 1);
|
||||||
|
closedSet.add(current);
|
||||||
|
|
||||||
|
const curFScore = fScore.get(current) ?? 0;
|
||||||
|
const previous = cameFrom.get(current);
|
||||||
|
const neighbors = getNextNeighborPoints({
|
||||||
|
points,
|
||||||
|
previous,
|
||||||
|
current,
|
||||||
|
sourceRect,
|
||||||
|
targetRect,
|
||||||
|
});
|
||||||
|
for (const neighbor of neighbors) {
|
||||||
|
if (closedSet.has(neighbor)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const neighborGScore = gScore.get(neighbor) ?? 0;
|
||||||
|
const tentativeGScore = curFScore + estimateDistance(current, neighbor);
|
||||||
|
if (openSet.includes(neighbor) && tentativeGScore >= neighborGScore) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
openSet.push(neighbor);
|
||||||
|
cameFrom.set(neighbor, current);
|
||||||
|
gScore.set(neighbor, tentativeGScore);
|
||||||
|
fScore.set(
|
||||||
|
neighbor,
|
||||||
|
neighborGScore +
|
||||||
|
heuristicCostEstimate({
|
||||||
|
from: current,
|
||||||
|
to: neighbor,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [start, end];
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildPath = (
|
||||||
|
cameFrom: Map<ControlPoint, ControlPoint>,
|
||||||
|
current: ControlPoint
|
||||||
|
): ControlPoint[] => {
|
||||||
|
const path = [current];
|
||||||
|
|
||||||
|
let previous = cameFrom.get(current);
|
||||||
|
while (previous) {
|
||||||
|
path.push(previous);
|
||||||
|
previous = cameFrom.get(previous);
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.reverse();
|
||||||
|
};
|
||||||
|
|
||||||
|
interface GetNextNeighborPointsParams {
|
||||||
|
points: ControlPoint[];
|
||||||
|
previous?: ControlPoint;
|
||||||
|
current: ControlPoint;
|
||||||
|
sourceRect: NodeRect;
|
||||||
|
targetRect: NodeRect;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the set of possible neighboring points for the current control point
|
||||||
|
*
|
||||||
|
* - The line is in a horizontal or vertical direction
|
||||||
|
* - The line does not intersect with the two end nodes
|
||||||
|
* - The line does not overlap with the previous line segment in reverse direction
|
||||||
|
*/
|
||||||
|
export const getNextNeighborPoints = ({
|
||||||
|
points,
|
||||||
|
previous,
|
||||||
|
current,
|
||||||
|
sourceRect,
|
||||||
|
targetRect,
|
||||||
|
}: GetNextNeighborPointsParams): ControlPoint[] => {
|
||||||
|
return points.filter((p) => {
|
||||||
|
if (p === current) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// The connection is in the horizontal or vertical direction
|
||||||
|
const rightDirection = p.x === current.x || p.y === current.y;
|
||||||
|
// Reverse direction with the previous line segment (overlap)
|
||||||
|
const reverseDirection = previous
|
||||||
|
? areLinesReverseDirection(previous, current, current, p)
|
||||||
|
: false;
|
||||||
|
return (
|
||||||
|
rightDirection && // The line is in a horizontal or vertical direction
|
||||||
|
!reverseDirection && // The line does not overlap with the previous line segment in reverse direction
|
||||||
|
!isSegmentCrossingRect(p, current, sourceRect) && // Does not intersect with sourceNode
|
||||||
|
!isSegmentCrossingRect(p, current, targetRect) // Does not intersect with targetNode
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 路径规划所需的启发式代价计算参数接口
|
||||||
|
* 包含了计算路径代价所需的所有控制点信息:
|
||||||
|
* - from/to: 当前路径段的起点和终点
|
||||||
|
* - start/end: 整条路径的起点和终点
|
||||||
|
* - source/target: 连接的源节点和目标节点位置
|
||||||
|
*/
|
||||||
|
interface HeuristicCostParams {
|
||||||
|
from: ControlPoint; // 当前路径段的起点
|
||||||
|
to: ControlPoint; // 当前路径段的终点
|
||||||
|
start: ControlPoint; // 整条路径的起始点
|
||||||
|
end: ControlPoint; // 整条路径的终点
|
||||||
|
source: ControlPoint; // 源节点的连接点
|
||||||
|
target: ControlPoint; // 目标节点的连接点
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启发式路径代价估算函数
|
||||||
|
*
|
||||||
|
* 该函数通过多个因素综合评估路径的优劣程度:
|
||||||
|
* 1. 基础代价: 当前点到起点和终点的曼哈顿距离之和
|
||||||
|
* 2. 起点优化: 如果是起始段,判断方向一致性给予奖励
|
||||||
|
* 3. 终点优化: 如果是结束段,判断方向一致性给予奖励
|
||||||
|
*
|
||||||
|
* 优化目标:
|
||||||
|
* - 减少路径总长度
|
||||||
|
* - 保持路径走向的连续性
|
||||||
|
* - 使拐点在路径中更均匀分布
|
||||||
|
*
|
||||||
|
* @param params 包含所有必要控制点的参数对象
|
||||||
|
* @returns 计算得到的启发式代价值,值越小路径越优
|
||||||
|
*/
|
||||||
|
const heuristicCostEstimate = ({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
}: HeuristicCostParams): number => {
|
||||||
|
// 计算基础代价 - 到起点和终点的距离之和
|
||||||
|
const base = estimateDistance(to, start) + estimateDistance(to, end);
|
||||||
|
|
||||||
|
// 起点方向优化 - 如果是起始段且方向一致,给予奖励
|
||||||
|
const startCost = isEqualPoint(from, start)
|
||||||
|
? areLinesSameDirection(from, to, source, start)
|
||||||
|
? -base / 2 // 方向一致时减少代价
|
||||||
|
: 0
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// 终点方向优化 - 如果是结束段且方向一致,给予奖励
|
||||||
|
const endCost = isEqualPoint(to, end)
|
||||||
|
? areLinesSameDirection(from, to, end, target)
|
||||||
|
? -base / 2 // 方向一致时减少代价
|
||||||
|
: 0
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return base + startCost + endCost;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算两点间的估计距离
|
||||||
|
*
|
||||||
|
* 采用曼哈顿距离(Manhattan distance)计算:
|
||||||
|
* - 只计算水平和垂直方向的距离之和
|
||||||
|
* - 避免使用欧几里得距离的开方运算
|
||||||
|
* - 在网格化的路径规划中性能更优
|
||||||
|
*
|
||||||
|
* @param p1 第一个控制点
|
||||||
|
* @param p2 第二个控制点
|
||||||
|
* @returns 两点间的曼哈顿距离
|
||||||
|
*/
|
||||||
|
const estimateDistance = (p1: ControlPoint, p2: ControlPoint): number =>
|
||||||
|
Math.abs(p1.x - p2.x) + Math.abs(p1.y - p2.y);
|
|
@ -0,0 +1,142 @@
|
||||||
|
import { areLinesSameDirection, isHorizontalFromPosition } from "../edge";
|
||||||
|
import {
|
||||||
|
ControlPoint,
|
||||||
|
HandlePosition,
|
||||||
|
NodeRect,
|
||||||
|
getCenterPoints,
|
||||||
|
getExpandedRect,
|
||||||
|
getOffsetPoint,
|
||||||
|
getSidesFromPoints,
|
||||||
|
getVerticesFromRectVertex,
|
||||||
|
optimizeInputPoints,
|
||||||
|
reducePoints,
|
||||||
|
} from "../point";
|
||||||
|
import { getAStarPath } from "./a-star";
|
||||||
|
import { getSimplePath } from "./simple";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 边缘控制点计算模块
|
||||||
|
* 用于计算图形边缘连接线的控制点,以实现平滑的连接效果
|
||||||
|
* 主要应用于流程图、思维导图等需要节点间连线的场景
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 控制点计算所需的输入参数接口
|
||||||
|
*/
|
||||||
|
export interface GetControlPointsParams {
|
||||||
|
source: HandlePosition; // 起始连接点位置
|
||||||
|
target: HandlePosition; // 目标连接点位置
|
||||||
|
sourceRect: NodeRect; // 起始节点的矩形区域
|
||||||
|
targetRect: NodeRect; // 目标节点的矩形区域
|
||||||
|
/**
|
||||||
|
* 边缘与节点之间的最小间距
|
||||||
|
* @default 20
|
||||||
|
*/
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算两个节点之间连接线的控制点
|
||||||
|
* @param params 控制点计算参数
|
||||||
|
* @returns 返回优化后的路径点和输入点集合
|
||||||
|
*/
|
||||||
|
export const getControlPoints = ({
|
||||||
|
source: oldSource,
|
||||||
|
target: oldTarget,
|
||||||
|
sourceRect,
|
||||||
|
targetRect,
|
||||||
|
offset = 20,
|
||||||
|
}: GetControlPointsParams) => {
|
||||||
|
const source: ControlPoint = oldSource;
|
||||||
|
const target: ControlPoint = oldTarget;
|
||||||
|
let edgePoints: ControlPoint[] = [];
|
||||||
|
let optimized: ReturnType<typeof optimizeInputPoints>;
|
||||||
|
|
||||||
|
// 1. 计算考虑偏移量后的起始和结束点
|
||||||
|
const sourceOffset = getOffsetPoint(oldSource, offset);
|
||||||
|
const targetOffset = getOffsetPoint(oldTarget, offset);
|
||||||
|
const expandedSource = getExpandedRect(sourceRect, offset);
|
||||||
|
const expandedTarget = getExpandedRect(targetRect, offset);
|
||||||
|
|
||||||
|
// 2. 判断两个矩形是否靠得较近或应该直接连接
|
||||||
|
const minOffset = 2 * offset + 10; // 最小间距阈值
|
||||||
|
const isHorizontalLayout = isHorizontalFromPosition(oldSource.position); // 是否为水平布局
|
||||||
|
const isSameDirection = areLinesSameDirection(
|
||||||
|
source,
|
||||||
|
sourceOffset,
|
||||||
|
targetOffset,
|
||||||
|
target
|
||||||
|
); // 判断是否同向
|
||||||
|
const sides = getSidesFromPoints([
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
sourceOffset,
|
||||||
|
targetOffset,
|
||||||
|
]); // 获取边界信息
|
||||||
|
|
||||||
|
// 判断节点是否过近
|
||||||
|
const isTooClose = isHorizontalLayout
|
||||||
|
? sides.right - sides.left < minOffset
|
||||||
|
: sides.bottom - sides.top < minOffset;
|
||||||
|
// 判断是否可以直接连接
|
||||||
|
const isDirectConnect = isHorizontalLayout
|
||||||
|
? isSameDirection && source.x < target.x
|
||||||
|
: isSameDirection && source.y < target.y;
|
||||||
|
|
||||||
|
if (isTooClose || isDirectConnect) {
|
||||||
|
// 3. 如果节点较近或可直接连接,返回简单路径
|
||||||
|
edgePoints = getSimplePath({
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
sourceOffset,
|
||||||
|
targetOffset,
|
||||||
|
isDirectConnect,
|
||||||
|
});
|
||||||
|
// 优化输入点
|
||||||
|
optimized = optimizeInputPoints({
|
||||||
|
source: oldSource,
|
||||||
|
target: oldTarget,
|
||||||
|
sourceOffset,
|
||||||
|
targetOffset,
|
||||||
|
edgePoints,
|
||||||
|
});
|
||||||
|
edgePoints = optimized.edgePoints;
|
||||||
|
} else {
|
||||||
|
// 3. 获取两个扩展矩形的顶点
|
||||||
|
edgePoints = [
|
||||||
|
...getVerticesFromRectVertex(expandedSource, targetOffset),
|
||||||
|
...getVerticesFromRectVertex(expandedTarget, sourceOffset),
|
||||||
|
];
|
||||||
|
// 4. 计算可能的中点和交点
|
||||||
|
edgePoints = edgePoints.concat(
|
||||||
|
getCenterPoints({
|
||||||
|
source: expandedSource,
|
||||||
|
target: expandedTarget,
|
||||||
|
sourceOffset,
|
||||||
|
targetOffset,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
// 5. 合并临近坐标点并去除重复点
|
||||||
|
optimized = optimizeInputPoints({
|
||||||
|
source: oldSource,
|
||||||
|
target: oldTarget,
|
||||||
|
sourceOffset,
|
||||||
|
targetOffset,
|
||||||
|
edgePoints,
|
||||||
|
});
|
||||||
|
// 6. 使用A*算法寻找最优路径
|
||||||
|
edgePoints = getAStarPath({
|
||||||
|
points: optimized.edgePoints,
|
||||||
|
source: optimized.source,
|
||||||
|
target: optimized.target,
|
||||||
|
sourceRect: getExpandedRect(sourceRect, offset / 2),
|
||||||
|
targetRect: getExpandedRect(targetRect, offset / 2),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回简化后的路径点和输入点集合
|
||||||
|
return {
|
||||||
|
points: reducePoints([optimized.source, ...edgePoints, optimized.target]),
|
||||||
|
inputPoints: optimized.edgePoints,
|
||||||
|
};
|
||||||
|
};
|
|
@ -0,0 +1,112 @@
|
||||||
|
import { uuid } from "../../../utils/uuid";
|
||||||
|
import { LayoutDirection } from "../../node";
|
||||||
|
import { ControlPoint, isInLine, isOnLine } from "../point";
|
||||||
|
|
||||||
|
interface GetSimplePathParams {
|
||||||
|
isDirectConnect?: boolean;
|
||||||
|
source: ControlPoint;
|
||||||
|
target: ControlPoint;
|
||||||
|
sourceOffset: ControlPoint;
|
||||||
|
targetOffset: ControlPoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getLineDirection = (
|
||||||
|
start: ControlPoint,
|
||||||
|
end: ControlPoint
|
||||||
|
): LayoutDirection => (start.x === end.x ? "vertical" : "horizontal");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When two nodes are too close, use the simple path
|
||||||
|
*
|
||||||
|
* @returns Control points including sourceOffset and targetOffset (not including source and target points).
|
||||||
|
*/
|
||||||
|
export const getSimplePath = ({
|
||||||
|
isDirectConnect,
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
sourceOffset,
|
||||||
|
targetOffset,
|
||||||
|
}: GetSimplePathParams): ControlPoint[] => {
|
||||||
|
const points: ControlPoint[] = [];
|
||||||
|
const sourceDirection = getLineDirection(source, sourceOffset);
|
||||||
|
const targetDirection = getLineDirection(target, targetOffset);
|
||||||
|
const isHorizontalLayout = sourceDirection === "horizontal";
|
||||||
|
if (isDirectConnect) {
|
||||||
|
// Direct connection, return a simple Path
|
||||||
|
if (isHorizontalLayout) {
|
||||||
|
if (sourceOffset.x <= targetOffset.x) {
|
||||||
|
const centerX = (sourceOffset.x + targetOffset.x) / 2;
|
||||||
|
return [
|
||||||
|
{ id: uuid(), x: centerX, y: sourceOffset.y },
|
||||||
|
{ id: uuid(), x: centerX, y: targetOffset.y },
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
const centerY = (sourceOffset.y + targetOffset.y) / 2;
|
||||||
|
return [
|
||||||
|
sourceOffset,
|
||||||
|
{ id: uuid(), x: sourceOffset.x, y: centerY },
|
||||||
|
{ id: uuid(), x: targetOffset.x, y: centerY },
|
||||||
|
targetOffset,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (sourceOffset.y <= targetOffset.y) {
|
||||||
|
const centerY = (sourceOffset.y + targetOffset.y) / 2;
|
||||||
|
return [
|
||||||
|
{ id: uuid(), x: sourceOffset.x, y: centerY },
|
||||||
|
{ id: uuid(), x: targetOffset.x, y: centerY },
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
const centerX = (sourceOffset.x + targetOffset.x) / 2;
|
||||||
|
return [
|
||||||
|
sourceOffset,
|
||||||
|
{ id: uuid(), x: centerX, y: sourceOffset.y },
|
||||||
|
{ id: uuid(), x: centerX, y: targetOffset.y },
|
||||||
|
targetOffset,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sourceDirection === targetDirection) {
|
||||||
|
// Same direction, add two points, two endpoints of parallel lines at half the vertical distance
|
||||||
|
if (source.y === sourceOffset.y) {
|
||||||
|
points.push({
|
||||||
|
id: uuid(),
|
||||||
|
x: sourceOffset.x,
|
||||||
|
y: (sourceOffset.y + targetOffset.y) / 2,
|
||||||
|
});
|
||||||
|
points.push({
|
||||||
|
id: uuid(),
|
||||||
|
x: targetOffset.x,
|
||||||
|
y: (sourceOffset.y + targetOffset.y) / 2,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
points.push({
|
||||||
|
id: uuid(),
|
||||||
|
x: (sourceOffset.x + targetOffset.x) / 2,
|
||||||
|
y: sourceOffset.y,
|
||||||
|
});
|
||||||
|
points.push({
|
||||||
|
id: uuid(),
|
||||||
|
x: (sourceOffset.x + targetOffset.x) / 2,
|
||||||
|
y: targetOffset.y,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Different directions, add one point, ensure it's not on the current line segment (to avoid overlap), and there are no turns
|
||||||
|
let point = { id: uuid(), x: sourceOffset.x, y: targetOffset.y };
|
||||||
|
const inStart = isInLine(point, source, sourceOffset);
|
||||||
|
const inEnd = isInLine(point, target, targetOffset);
|
||||||
|
if (inStart || inEnd) {
|
||||||
|
point = { id: uuid(), x: targetOffset.x, y: sourceOffset.y };
|
||||||
|
} else {
|
||||||
|
const onStart = isOnLine(point, source, sourceOffset);
|
||||||
|
const onEnd = isOnLine(point, target, targetOffset);
|
||||||
|
if (onStart && onEnd) {
|
||||||
|
point = { id: uuid(), x: targetOffset.x, y: sourceOffset.y };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
points.push(point);
|
||||||
|
}
|
||||||
|
return [sourceOffset, ...points, targetOffset];
|
||||||
|
};
|
|
@ -0,0 +1,389 @@
|
||||||
|
import { Position, XYPosition } from "@xyflow/react";
|
||||||
|
import { ControlPoint, HandlePosition } from "./point";
|
||||||
|
import { uuid } from "../../utils/uuid";
|
||||||
|
|
||||||
|
export interface ILine {
|
||||||
|
start: ControlPoint;
|
||||||
|
end: ControlPoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断给定位置是否为水平方向
|
||||||
|
* @param position - 位置枚举值
|
||||||
|
* @returns 如果是左侧或右侧位置则返回true,否则返回false
|
||||||
|
*/
|
||||||
|
export const isHorizontalFromPosition = (position: Position) => {
|
||||||
|
return [Position.Left, Position.Right].includes(position);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断连接是否为反向
|
||||||
|
* 在图形布局中,通常希望连线从左到右或从上到下。当连线方向与此相反时,即为反向连接
|
||||||
|
* @param props - 包含源点和目标点位置信息的对象
|
||||||
|
* @param props.source - 源节点的位置信息
|
||||||
|
* @param props.target - 目标节点的位置信息
|
||||||
|
* @returns 如果是反向连接则返回true,否则返回false
|
||||||
|
*/
|
||||||
|
export const isConnectionBackward = (props: {
|
||||||
|
source: HandlePosition;
|
||||||
|
target: HandlePosition;
|
||||||
|
}) => {
|
||||||
|
const { source, target } = props;
|
||||||
|
// 判断是水平还是垂直方向的连接
|
||||||
|
const isHorizontal = isHorizontalFromPosition(source.position);
|
||||||
|
let isBackward = false;
|
||||||
|
|
||||||
|
// 水平方向时,如果源点x坐标大于目标点,则为反向
|
||||||
|
if (isHorizontal) {
|
||||||
|
if (source.x > target.x) {
|
||||||
|
isBackward = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 垂直方向时,如果源点y坐标大于目标点,则为反向
|
||||||
|
else {
|
||||||
|
if (source.y > target.y) {
|
||||||
|
isBackward = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return isBackward;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算两点之间的欧几里得距离
|
||||||
|
* 使用勾股定理(Math.hypot)计算两点间的直线距离
|
||||||
|
* @param p1 - 第一个控制点
|
||||||
|
* @param p2 - 第二个控制点
|
||||||
|
* @returns 两点间的距离
|
||||||
|
*/
|
||||||
|
export const distance = (p1: ControlPoint, p2: ControlPoint) => {
|
||||||
|
return Math.hypot(p2.x - p1.x, p2.y - p1.y);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算线段的中点坐标
|
||||||
|
* 通过取两端点坐标的算术平均值来确定中点位置
|
||||||
|
* @param p1 - 第一个控制点
|
||||||
|
* @param p2 - 第二个控制点
|
||||||
|
* @returns 包含中点坐标和唯一标识的控制点对象
|
||||||
|
*/
|
||||||
|
export const getLineCenter = (
|
||||||
|
p1: ControlPoint,
|
||||||
|
p2: ControlPoint
|
||||||
|
): ControlPoint => {
|
||||||
|
return {
|
||||||
|
id: uuid(),
|
||||||
|
x: (p1.x + p2.x) / 2, // x坐标取两端点x坐标的平均值
|
||||||
|
y: (p1.y + p2.y) / 2, // y坐标取两端点y坐标的平均值
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断点是否在线段上
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* 该函数用于检测给定的点是否位于由两个控制点构成的线段上。
|
||||||
|
* 判断逻辑分为两种情况:
|
||||||
|
* 1. 垂直线段: 当起点和终点的x坐标相同时,判断目标点的x坐标是否等于线段x坐标,且y坐标在线段y坐标范围内
|
||||||
|
* 2. 水平线段: 当起点和终点的y坐标相同时,判断目标点的y坐标是否等于线段y坐标,且x坐标在线段x坐标范围内
|
||||||
|
*
|
||||||
|
* @param start - 线段起点坐标
|
||||||
|
* @param end - 线段终点坐标
|
||||||
|
* @param p - 待检测点的坐标
|
||||||
|
* @returns {boolean} 如果点在线段上返回true,否则返回false
|
||||||
|
*/
|
||||||
|
export const isLineContainsPoint = (
|
||||||
|
start: ControlPoint,
|
||||||
|
end: ControlPoint,
|
||||||
|
p: ControlPoint
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
// 判断垂直线段
|
||||||
|
(start.x === end.x && // 起点终点x坐标相同
|
||||||
|
p.x === start.x && // 目标点x坐标与线段相同
|
||||||
|
p.y <= Math.max(start.y, end.y) && // 目标点y坐标不超过线段y坐标最大值
|
||||||
|
p.y >= Math.min(start.y, end.y)) || // 目标点y坐标不小于线段y坐标最小值
|
||||||
|
// 判断水平线段
|
||||||
|
(start.y === end.y && // 起点终点y坐标相同
|
||||||
|
p.y === start.y && // 目标点y坐标与线段相同
|
||||||
|
p.x <= Math.max(start.x, end.x) && // 目标点x坐标不超过线段x坐标最大值
|
||||||
|
p.x >= Math.min(start.x, end.x)) // 目标点x坐标不小于线段x坐标最小值
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
/**
|
||||||
|
* 生成带圆角转角的SVG路径
|
||||||
|
*
|
||||||
|
* 该函数用于在图形编辑器中生成连接两点之间的边线路径。路径具有以下特点:
|
||||||
|
* 1. 两个控制点之间为直线段
|
||||||
|
* 2. 在转折点处生成圆角过渡
|
||||||
|
* 3. 支持垂直和水平方向的转角
|
||||||
|
*
|
||||||
|
* @param points 控制点数组,包含边的起点、终点和中间的转折点
|
||||||
|
* - 至少需要2个点(起点和终点)
|
||||||
|
* - 点的顺序应从输入端点开始到输出端点结束
|
||||||
|
* @param radius 转角处的圆角半径
|
||||||
|
* @returns 返回SVG路径字符串
|
||||||
|
* @throws 当points数组长度小于2时抛出错误
|
||||||
|
*/
|
||||||
|
export function getPathWithRoundCorners(
|
||||||
|
points: ControlPoint[],
|
||||||
|
radius: number
|
||||||
|
): string {
|
||||||
|
if (points.length < 2) {
|
||||||
|
throw new Error("At least 2 points are required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算两条线段交点处的圆角路径
|
||||||
|
* @param center 转折点坐标
|
||||||
|
* @param p1 前一个点的坐标
|
||||||
|
* @param p2 后一个点的坐标
|
||||||
|
* @param radius 圆角半径
|
||||||
|
* @returns SVG路径命令字符串
|
||||||
|
*/
|
||||||
|
function getRoundCorner(
|
||||||
|
center: ControlPoint,
|
||||||
|
p1: ControlPoint,
|
||||||
|
p2: ControlPoint,
|
||||||
|
radius: number
|
||||||
|
) {
|
||||||
|
const { x, y } = center;
|
||||||
|
|
||||||
|
// 如果两条线段不垂直,则直接返回直线路径
|
||||||
|
if (!areLinesPerpendicular(p1, center, center, p2)) {
|
||||||
|
return `L ${x} ${y}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算实际可用的圆角半径,取三个值中的最小值:
|
||||||
|
// 1. 与前一个点的距离的一半
|
||||||
|
// 2. 与后一个点的距离的一半
|
||||||
|
// 3. 传入的目标半径
|
||||||
|
const d1 = distance(center, p1);
|
||||||
|
const d2 = distance(center, p2);
|
||||||
|
radius = Math.min(d1 / 2, d2 / 2, radius);
|
||||||
|
|
||||||
|
// 判断第一条线段是否为水平线
|
||||||
|
const isHorizontal = p1.y === y;
|
||||||
|
|
||||||
|
// 根据点的相对位置确定圆角绘制方向
|
||||||
|
const xDir = isHorizontal ? (p1.x < p2.x ? -1 : 1) : p1.x < p2.x ? 1 : -1;
|
||||||
|
const yDir = isHorizontal ? (p1.y < p2.y ? 1 : -1) : p1.y < p2.y ? -1 : 1;
|
||||||
|
|
||||||
|
// 根据线段方向生成不同的圆角路径
|
||||||
|
if (isHorizontal) {
|
||||||
|
return `L ${x + radius * xDir},${y}Q ${x},${y} ${x},${y + radius * yDir}`;
|
||||||
|
}
|
||||||
|
return `L ${x},${y + radius * yDir}Q ${x},${y} ${x + radius * xDir},${y}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建完整的SVG路径
|
||||||
|
const path: string[] = [];
|
||||||
|
for (let i = 0; i < points.length; i++) {
|
||||||
|
if (i === 0) {
|
||||||
|
// 起点使用移动命令M
|
||||||
|
path.push(`M ${points[i].x} ${points[i].y}`);
|
||||||
|
} else if (i === points.length - 1) {
|
||||||
|
// 终点使用直线命令L
|
||||||
|
path.push(`L ${points[i].x} ${points[i].y}`);
|
||||||
|
} else {
|
||||||
|
// 中间点使用圆角转角
|
||||||
|
path.push(
|
||||||
|
getRoundCorner(points[i], points[i - 1], points[i + 1], radius)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将所有路径命令连接成完整的路径字符串
|
||||||
|
return path.join(" ");
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 获取折线中最长的线段
|
||||||
|
* @param points 控制点数组,每个点包含x和y坐标
|
||||||
|
* @returns 返回最长线段的起点和终点坐标
|
||||||
|
*
|
||||||
|
* 实现原理:
|
||||||
|
* 1. 初始化第一条线段为最长线段
|
||||||
|
* 2. 遍历所有相邻点对,计算线段长度
|
||||||
|
* 3. 如果找到更长的线段,则更新最长线段记录
|
||||||
|
* 4. 返回最长线段的两个端点
|
||||||
|
*/
|
||||||
|
export function getLongestLine(
|
||||||
|
points: ControlPoint[]
|
||||||
|
): [ControlPoint, ControlPoint] {
|
||||||
|
let longestLine: [ControlPoint, ControlPoint] = [points[0], points[1]];
|
||||||
|
let longestDistance = distance(...longestLine);
|
||||||
|
for (let i = 1; i < points.length - 1; i++) {
|
||||||
|
const _distance = distance(points[i], points[i + 1]);
|
||||||
|
if (_distance > longestDistance) {
|
||||||
|
longestDistance = _distance;
|
||||||
|
longestLine = [points[i], points[i + 1]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return longestLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算折线上标签的位置
|
||||||
|
* @param points 控制点数组
|
||||||
|
* @param minGap 最小间隔距离,默认为20
|
||||||
|
* @returns 标签的坐标位置
|
||||||
|
*
|
||||||
|
* 计算逻辑:
|
||||||
|
* 1. 如果折线点数为偶数:
|
||||||
|
* - 取中间两个点
|
||||||
|
* - 如果这两点间距大于最小间隔,返回它们的中点
|
||||||
|
* 2. 如果折线点数为奇数或中间段太短:
|
||||||
|
* - 找出最长的线段
|
||||||
|
* - 返回最长线段的中点作为标签位置
|
||||||
|
*/
|
||||||
|
export function getLabelPosition(
|
||||||
|
points: ControlPoint[],
|
||||||
|
minGap = 20
|
||||||
|
): XYPosition {
|
||||||
|
if (points.length % 2 === 0) {
|
||||||
|
const middleP1 = points[points.length / 2 - 1];
|
||||||
|
const middleP2 = points[points.length / 2];
|
||||||
|
if (distance(middleP1, middleP2) > minGap) {
|
||||||
|
return getLineCenter(middleP1, middleP2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const [start, end] = getLongestLine(points);
|
||||||
|
return {
|
||||||
|
x: (start.x + end.x) / 2,
|
||||||
|
y: (start.y + end.y) / 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断两条线段是否垂直
|
||||||
|
* @param p1,p2 第一条线段的起点和终点
|
||||||
|
* @param p3,p4 第二条线段的起点和终点
|
||||||
|
* @returns 如果两线段垂直则返回true
|
||||||
|
*
|
||||||
|
* 判断依据:
|
||||||
|
* - 假设线段要么水平要么垂直
|
||||||
|
* - 当一条线段水平(y相等)而另一条垂直(x相等)时,两线段垂直
|
||||||
|
*/
|
||||||
|
export function areLinesPerpendicular(
|
||||||
|
p1: ControlPoint,
|
||||||
|
p2: ControlPoint,
|
||||||
|
p3: ControlPoint,
|
||||||
|
p4: ControlPoint
|
||||||
|
): boolean {
|
||||||
|
return (p1.x === p2.x && p3.y === p4.y) || (p1.y === p2.y && p3.x === p4.x);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断两条线段是否平行
|
||||||
|
* @param p1,p2 第一条线段的起点和终点
|
||||||
|
* @param p3,p4 第二条线段的起点和终点
|
||||||
|
* @returns 如果两线段平行则返回true
|
||||||
|
*
|
||||||
|
* 判断依据:
|
||||||
|
* - 假设线段要么水平要么垂直
|
||||||
|
* - 当两条线段都是水平的(x相等)或都是垂直的(y相等)时,两线段平行
|
||||||
|
*/
|
||||||
|
export function areLinesParallel(
|
||||||
|
p1: ControlPoint,
|
||||||
|
p2: ControlPoint,
|
||||||
|
p3: ControlPoint,
|
||||||
|
p4: ControlPoint
|
||||||
|
) {
|
||||||
|
return (p1.x === p2.x && p3.x === p4.x) || (p1.y === p2.y && p3.y === p4.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断两条线段是否同向
|
||||||
|
* @param p1 第一条线段的起点
|
||||||
|
* @param p2 第一条线段的终点
|
||||||
|
* @param p3 第二条线段的起点
|
||||||
|
* @param p4 第二条线段的终点
|
||||||
|
* @returns boolean 如果两线段同向返回true,否则返回false
|
||||||
|
*
|
||||||
|
* 判断逻辑:
|
||||||
|
* 1. 对于水平线段(y坐标相等),判断x方向的变化是否同向
|
||||||
|
* 2. 对于垂直线段(x坐标相等),判断y方向的变化是否同向
|
||||||
|
*/
|
||||||
|
export function areLinesSameDirection(
|
||||||
|
p1: ControlPoint,
|
||||||
|
p2: ControlPoint,
|
||||||
|
p3: ControlPoint,
|
||||||
|
p4: ControlPoint
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
// 判断垂直线段是否同向
|
||||||
|
(p1.x === p2.x && p3.x === p4.x && (p1.y - p2.y) * (p3.y - p4.y) > 0) ||
|
||||||
|
// 判断水平线段是否同向
|
||||||
|
(p1.y === p2.y && p3.y === p4.y && (p1.x - p2.x) * (p3.x - p4.x) > 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断两条线段是否反向
|
||||||
|
* @param p1 第一条线段的起点
|
||||||
|
* @param p2 第一条线段的终点
|
||||||
|
* @param p3 第二条线段的起点
|
||||||
|
* @param p4 第二条线段的终点
|
||||||
|
* @returns boolean 如果两线段反向返回true,否则返回false
|
||||||
|
*
|
||||||
|
* 判断逻辑:
|
||||||
|
* 1. 对于水平线段(y坐标相等),判断x方向的变化是否反向
|
||||||
|
* 2. 对于垂直线段(x坐标相等),判断y方向的变化是否反向
|
||||||
|
*/
|
||||||
|
export function areLinesReverseDirection(
|
||||||
|
p1: ControlPoint,
|
||||||
|
p2: ControlPoint,
|
||||||
|
p3: ControlPoint,
|
||||||
|
p4: ControlPoint
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
// 判断垂直线段是否反向
|
||||||
|
(p1.x === p2.x && p3.x === p4.x && (p1.y - p2.y) * (p3.y - p4.y) < 0) ||
|
||||||
|
// 判断水平线段是否反向
|
||||||
|
(p1.y === p2.y && p3.y === p4.y && (p1.x - p2.x) * (p3.x - p4.x) < 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算两条线段之间的夹角
|
||||||
|
* @param p1 第一条线段的起点
|
||||||
|
* @param p2 第一条线段的终点
|
||||||
|
* @param p3 第二条线段的起点
|
||||||
|
* @param p4 第二条线段的终点
|
||||||
|
* @returns number 两线段之间的夹角(单位:度)
|
||||||
|
*
|
||||||
|
* 计算步骤:
|
||||||
|
* 1. 计算两个向量
|
||||||
|
* 2. 计算向量的点积
|
||||||
|
* 3. 计算向量的模长
|
||||||
|
* 4. 使用反余弦函数计算弧度
|
||||||
|
* 5. 将弧度转换为角度
|
||||||
|
*/
|
||||||
|
export function getAngleBetweenLines(
|
||||||
|
p1: ControlPoint,
|
||||||
|
p2: ControlPoint,
|
||||||
|
p3: ControlPoint,
|
||||||
|
p4: ControlPoint
|
||||||
|
) {
|
||||||
|
// 计算两条线段对应的向量
|
||||||
|
const v1 = { x: p2.x - p1.x, y: p2.y - p1.y };
|
||||||
|
const v2 = { x: p4.x - p3.x, y: p4.y - p3.y };
|
||||||
|
|
||||||
|
// 计算向量的点积
|
||||||
|
const dotProduct = v1.x * v2.x + v1.y * v2.y;
|
||||||
|
|
||||||
|
// 计算两个向量的模长
|
||||||
|
const magnitude1 = Math.sqrt(v1.x ** 2 + v1.y ** 2);
|
||||||
|
const magnitude2 = Math.sqrt(v2.x ** 2 + v2.y ** 2);
|
||||||
|
|
||||||
|
// 计算夹角的余弦值
|
||||||
|
const cosine = dotProduct / (magnitude1 * magnitude2);
|
||||||
|
|
||||||
|
// 使用反余弦函数计算弧度
|
||||||
|
const angleInRadians = Math.acos(cosine);
|
||||||
|
|
||||||
|
// 将弧度转换为角度并返回
|
||||||
|
const angleInDegrees = (angleInRadians * 180) / Math.PI;
|
||||||
|
|
||||||
|
return angleInDegrees;
|
||||||
|
}
|
|
@ -0,0 +1,72 @@
|
||||||
|
import { EdgeLayout } from "../../types";
|
||||||
|
import { getControlPoints, GetControlPointsParams } from "./algorithms";
|
||||||
|
import { getLabelPosition, getPathWithRoundCorners } from "./edge";
|
||||||
|
import { InternalNode, Node } from "@xyflow/react"
|
||||||
|
interface GetBasePathParams extends GetControlPointsParams {
|
||||||
|
borderRadius: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBasePath({
|
||||||
|
id,
|
||||||
|
offset,
|
||||||
|
borderRadius,
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
sourceX,
|
||||||
|
sourceY,
|
||||||
|
targetX,
|
||||||
|
targetY,
|
||||||
|
sourcePosition,
|
||||||
|
targetPosition,
|
||||||
|
}: any) {
|
||||||
|
const sourceNode: InternalNode =
|
||||||
|
kReactFlow.instance!.getNode(source)!;
|
||||||
|
const targetNode: InternalNode =
|
||||||
|
kReactFlow.instance!.getNode(target)!;
|
||||||
|
return getPathWithPoints({
|
||||||
|
offset,
|
||||||
|
borderRadius,
|
||||||
|
source: {
|
||||||
|
id: "source-" + id,
|
||||||
|
x: sourceX,
|
||||||
|
y: sourceY,
|
||||||
|
position: sourcePosition,
|
||||||
|
},
|
||||||
|
target: {
|
||||||
|
id: "target-" + id,
|
||||||
|
x: targetX,
|
||||||
|
y: targetY,
|
||||||
|
position: targetPosition,
|
||||||
|
},
|
||||||
|
sourceRect: {
|
||||||
|
...(sourceNode.internals.positionAbsolute || sourceNode.position),
|
||||||
|
width: sourceNode.width!,
|
||||||
|
height: sourceNode.height!,
|
||||||
|
},
|
||||||
|
targetRect: {
|
||||||
|
...(targetNode.internals.positionAbsolute || targetNode.position),
|
||||||
|
width: targetNode.width!,
|
||||||
|
height: targetNode.height!,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPathWithPoints({
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
sourceRect,
|
||||||
|
targetRect,
|
||||||
|
offset = 20,
|
||||||
|
borderRadius = 16,
|
||||||
|
}: GetBasePathParams): EdgeLayout {
|
||||||
|
const { points, inputPoints } = getControlPoints({
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
offset,
|
||||||
|
sourceRect,
|
||||||
|
targetRect,
|
||||||
|
});
|
||||||
|
const labelPosition = getLabelPosition(points);
|
||||||
|
const path = getPathWithRoundCorners(points, borderRadius);
|
||||||
|
return { path, points, inputPoints, labelPosition };
|
||||||
|
}
|
|
@ -0,0 +1,623 @@
|
||||||
|
import { Position } from "@xyflow/react";
|
||||||
|
import { isHorizontalFromPosition } from "./edge";
|
||||||
|
import { uuid } from "../../utils/uuid";
|
||||||
|
|
||||||
|
export interface ControlPoint {
|
||||||
|
id: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeRect {
|
||||||
|
x: number; // left
|
||||||
|
y: number; // top
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RectSides {
|
||||||
|
top: number;
|
||||||
|
right: number;
|
||||||
|
bottom: number;
|
||||||
|
left: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HandlePosition extends ControlPoint {
|
||||||
|
position: Position;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetVerticesParams {
|
||||||
|
source: NodeRect;
|
||||||
|
target: NodeRect;
|
||||||
|
sourceOffset: ControlPoint;
|
||||||
|
targetOffset: ControlPoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算两个节点之间的控制点位置
|
||||||
|
*
|
||||||
|
* 该函数用于在图形编辑器中确定边的控制点,以实现更自然的边布局。
|
||||||
|
* 主要应用于:
|
||||||
|
* 1. 节点之间连线的路径规划
|
||||||
|
* 2. 边的弯曲程度控制
|
||||||
|
* 3. 避免边与节点重叠
|
||||||
|
*
|
||||||
|
* 实现原理:
|
||||||
|
* 1. 基于源节点和目标节点构建外部边界矩形
|
||||||
|
* 2. 基于偏移点构建内部边界矩形
|
||||||
|
* 3. 在两个矩形的边上生成候选控制点
|
||||||
|
* 4. 过滤掉无效的控制点
|
||||||
|
*
|
||||||
|
* @param {GetVerticesParams} params - 计算所需的参数
|
||||||
|
* @param {Rect} params.source - 源节点的矩形区域,包含x、y、width、height
|
||||||
|
* @param {Rect} params.target - 目标节点的矩形区域
|
||||||
|
* @param {Point} params.sourceOffset - 源节点上的连接点坐标
|
||||||
|
* @param {Point} params.targetOffset - 目标节点上的连接点坐标
|
||||||
|
* @returns {ControlPoint[]} 有效的控制点数组,每个点包含唯一ID和坐标
|
||||||
|
*/
|
||||||
|
export const getCenterPoints = ({
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
sourceOffset,
|
||||||
|
targetOffset,
|
||||||
|
}: GetVerticesParams): ControlPoint[] => {
|
||||||
|
// 特殊情况处理:当源点和目标点在同一直线上时,无法构成有效的控制区域
|
||||||
|
if (sourceOffset.x === targetOffset.x || sourceOffset.y === targetOffset.y) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 步骤1: 获取外部边界
|
||||||
|
// 收集两个节点的所有顶点,用于构建外部最大矩形
|
||||||
|
const vertices = [...getRectVertices(source), ...getRectVertices(target)];
|
||||||
|
const outerSides = getSidesFromPoints(vertices);
|
||||||
|
|
||||||
|
// 步骤2: 获取内部边界
|
||||||
|
// 根据偏移点(实际连接点)计算内部矩形的四条边
|
||||||
|
const { left, right, top, bottom } = getSidesFromPoints([
|
||||||
|
sourceOffset,
|
||||||
|
targetOffset,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 步骤3: 计算中心参考线
|
||||||
|
const centerX = (left + right) / 2; // 水平中心线
|
||||||
|
const centerY = (top + bottom) / 2; // 垂直中心线
|
||||||
|
|
||||||
|
// 步骤4: 生成候选控制点
|
||||||
|
// 在内外两个矩形的边上各生成4个控制点,共8个候选点
|
||||||
|
const points = [
|
||||||
|
{ id: uuid(), x: centerX, y: top }, // 内矩形-上
|
||||||
|
{ id: uuid(), x: right, y: centerY }, // 内矩形-右
|
||||||
|
{ id: uuid(), x: centerX, y: bottom }, // 内矩形-下
|
||||||
|
{ id: uuid(), x: left, y: centerY }, // 内矩形-左
|
||||||
|
{ id: uuid(), x: centerX, y: outerSides.top }, // 外矩形-上
|
||||||
|
{ id: uuid(), x: outerSides.right, y: centerY }, // 外矩形-右
|
||||||
|
{ id: uuid(), x: centerX, y: outerSides.bottom },// 外矩形-下
|
||||||
|
{ id: uuid(), x: outerSides.left, y: centerY }, // 外矩形-左
|
||||||
|
];
|
||||||
|
|
||||||
|
// 步骤5: 过滤无效控制点
|
||||||
|
// 移除落在源节点或目标节点内部的控制点,避免边穿过节点
|
||||||
|
return points.filter((p) => {
|
||||||
|
return !isPointInRect(p, source) && !isPointInRect(p, target);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扩展矩形区域
|
||||||
|
* @param rect 原始矩形区域
|
||||||
|
* @param offset 扩展偏移量
|
||||||
|
* @returns 扩展后的新矩形区域
|
||||||
|
*
|
||||||
|
* 该函数将一个矩形区域向四周扩展指定的偏移量。
|
||||||
|
* 扩展规则:
|
||||||
|
* 1. x和y坐标各向外偏移offset距离
|
||||||
|
* 2. 宽度和高度各增加2*offset
|
||||||
|
*/
|
||||||
|
export const getExpandedRect = (rect: NodeRect, offset: number): NodeRect => {
|
||||||
|
return {
|
||||||
|
x: rect.x - offset,
|
||||||
|
y: rect.y - offset,
|
||||||
|
width: rect.width + 2 * offset,
|
||||||
|
height: rect.height + 2 * offset,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测两个矩形是否重叠
|
||||||
|
* @param rect1 第一个矩形
|
||||||
|
* @param rect2 第二个矩形
|
||||||
|
* @returns 布尔值,true表示重叠,false表示不重叠
|
||||||
|
*
|
||||||
|
* 使用AABB(轴对齐包围盒)碰撞检测算法:
|
||||||
|
* 1. 计算x轴投影是否重叠
|
||||||
|
* 2. 计算y轴投影是否重叠
|
||||||
|
* 两个轴向都重叠则矩形重叠
|
||||||
|
*/
|
||||||
|
export const isRectOverLapping = (rect1: NodeRect, rect2: NodeRect) => {
|
||||||
|
return (
|
||||||
|
Math.abs(rect1.x - rect2.x) < (rect1.width + rect2.width) / 2 &&
|
||||||
|
Math.abs(rect1.y - rect2.y) < (rect1.height + rect2.height) / 2
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断点是否在矩形内
|
||||||
|
* @param p 待检测的控制点
|
||||||
|
* @param box 矩形区域
|
||||||
|
* @returns 布尔值,true表示点在矩形内,false表示点在矩形外
|
||||||
|
*
|
||||||
|
* 点在矩形内的条件:
|
||||||
|
* 1. x坐标在矩形左右边界之间
|
||||||
|
* 2. y坐标在矩形上下边界之间
|
||||||
|
*/
|
||||||
|
export const isPointInRect = (p: ControlPoint, box: NodeRect) => {
|
||||||
|
const sides = getRectSides(box);
|
||||||
|
return (
|
||||||
|
p.x >= sides.left &&
|
||||||
|
p.x <= sides.right &&
|
||||||
|
p.y >= sides.top &&
|
||||||
|
p.y <= sides.bottom
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 矩形顶点计算模块
|
||||||
|
* 用于处理图形编辑器中矩形节点的顶点、边界等几何计算
|
||||||
|
* 主要应用于连线路径规划和节点定位
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据矩形和外部顶点计算包围矩形的顶点坐标
|
||||||
|
* @param box 原始矩形的位置和尺寸信息
|
||||||
|
* @param vertex 外部控制点
|
||||||
|
* @returns 包围矩形的四个顶点坐标
|
||||||
|
* 算法思路:
|
||||||
|
* 1. 合并外部顶点和原矩形的顶点
|
||||||
|
* 2. 计算所有点的边界范围
|
||||||
|
* 3. 根据边界生成新的矩形顶点
|
||||||
|
*/
|
||||||
|
export const getVerticesFromRectVertex = (
|
||||||
|
box: NodeRect,
|
||||||
|
vertex: ControlPoint
|
||||||
|
): ControlPoint[] => {
|
||||||
|
const points = [vertex, ...getRectVertices(box)];
|
||||||
|
const { top, right, bottom, left } = getSidesFromPoints(points);
|
||||||
|
return [
|
||||||
|
{ id: uuid(), x: left, y: top }, // 左上角顶点
|
||||||
|
{ id: uuid(), x: right, y: top }, // 右上角顶点
|
||||||
|
{ id: uuid(), x: right, y: bottom }, // 右下角顶点
|
||||||
|
{ id: uuid(), x: left, y: bottom }, // 左下角顶点
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算一组点的边界范围
|
||||||
|
* @param points 控制点数组
|
||||||
|
* @returns 返回边界的上下左右极值
|
||||||
|
* 实现方式:
|
||||||
|
* - 使用数组map和Math.min/max计算坐标的最值
|
||||||
|
*/
|
||||||
|
export const getSidesFromPoints = (points: ControlPoint[]) => {
|
||||||
|
const left = Math.min(...points.map((p) => p.x)); // 最左侧x坐标
|
||||||
|
const right = Math.max(...points.map((p) => p.x)); // 最右侧x坐标
|
||||||
|
const top = Math.min(...points.map((p) => p.y)); // 最上方y坐标
|
||||||
|
const bottom = Math.max(...points.map((p) => p.y)); // 最下方y坐标
|
||||||
|
return { top, right, bottom, left };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取矩形的四条边界位置
|
||||||
|
* @param box 矩形的位置和尺寸信息
|
||||||
|
* @returns 矩形的上下左右边界坐标
|
||||||
|
* 计算方式:
|
||||||
|
* - 左边界 = x坐标
|
||||||
|
* - 右边界 = x + width
|
||||||
|
* - 上边界 = y坐标
|
||||||
|
* - 下边界 = y + height
|
||||||
|
*/
|
||||||
|
export const getRectSides = (box: NodeRect): RectSides => {
|
||||||
|
const { x: left, y: top, width, height } = box;
|
||||||
|
const right = left + width;
|
||||||
|
const bottom = top + height;
|
||||||
|
return { top, right, bottom, left };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据边界信息生成矩形的四个顶点
|
||||||
|
* @param sides 矩形的上下左右边界坐标
|
||||||
|
* @returns 返回四个顶点的坐标信息
|
||||||
|
* 顶点顺序: 左上 -> 右上 -> 右下 -> 左下
|
||||||
|
*/
|
||||||
|
export const getRectVerticesFromSides = ({
|
||||||
|
top,
|
||||||
|
right,
|
||||||
|
bottom,
|
||||||
|
left,
|
||||||
|
}: RectSides): ControlPoint[] => {
|
||||||
|
return [
|
||||||
|
{ id: uuid(), x: left, y: top }, // 左上角顶点
|
||||||
|
{ id: uuid(), x: right, y: top }, // 右上角顶点
|
||||||
|
{ id: uuid(), x: right, y: bottom }, // 右下角顶点
|
||||||
|
{ id: uuid(), x: left, y: bottom }, // 左下角顶点
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取矩形的四个顶点坐标
|
||||||
|
* @param box 矩形的位置和尺寸信息
|
||||||
|
* @returns 返回矩形四个顶点的坐标
|
||||||
|
* 实现流程:
|
||||||
|
* 1. 先计算矩形的边界
|
||||||
|
* 2. 根据边界生成顶点
|
||||||
|
*/
|
||||||
|
export const getRectVertices = (box: NodeRect) => {
|
||||||
|
const sides = getRectSides(box);
|
||||||
|
return getRectVerticesFromSides(sides);
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* 合并多个矩形区域,返回一个能包含所有输入矩形的最小矩形
|
||||||
|
* @param boxes 需要合并的矩形数组,每个矩形包含 x,y 坐标和宽高信息
|
||||||
|
* @returns 合并后的最小包围矩形
|
||||||
|
*
|
||||||
|
* 实现原理:
|
||||||
|
* 1. 找出所有矩形中最左边的 x 坐标(left)和最右边的 x 坐标(right)
|
||||||
|
* 2. 找出所有矩形中最上边的 y 坐标(top)和最下边的 y 坐标(bottom)
|
||||||
|
* 3. 用这四个边界值构造出新的矩形
|
||||||
|
*/
|
||||||
|
export const mergeRects = (...boxes: NodeRect[]): NodeRect => {
|
||||||
|
// 计算所有矩形的最左边界
|
||||||
|
const left = Math.min(
|
||||||
|
...boxes.reduce((pre, e) => [...pre, e.x, e.x + e.width], [] as number[])
|
||||||
|
);
|
||||||
|
// 计算所有矩形的最右边界
|
||||||
|
const right = Math.max(
|
||||||
|
...boxes.reduce((pre, e) => [...pre, e.x, e.x + e.width], [] as number[])
|
||||||
|
);
|
||||||
|
// 计算所有矩形的最上边界
|
||||||
|
const top = Math.min(
|
||||||
|
...boxes.reduce((pre, e) => [...pre, e.y, e.y + e.height], [] as number[])
|
||||||
|
);
|
||||||
|
// 计算所有矩形的最下边界
|
||||||
|
const bottom = Math.max(
|
||||||
|
...boxes.reduce((pre, e) => [...pre, e.y, e.y + e.height], [] as number[])
|
||||||
|
);
|
||||||
|
|
||||||
|
// 返回能包含所有输入矩形的最小矩形
|
||||||
|
return {
|
||||||
|
x: left, // 左上角 x 坐标
|
||||||
|
y: top, // 左上角 y 坐标
|
||||||
|
width: right - left, // 宽度 = 最右边界 - 最左边界
|
||||||
|
height: bottom - top, // 高度 = 最下边界 - 最上边界
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据给定的位置和偏移量计算控制点坐标
|
||||||
|
* @param box - 起始位置信息,包含x、y坐标和位置类型(上下左右)
|
||||||
|
* @param offset - 偏移距离
|
||||||
|
* @returns 返回计算后的控制点对象,包含唯一id和新的x、y坐标
|
||||||
|
*/
|
||||||
|
export const getOffsetPoint = (
|
||||||
|
box: HandlePosition,
|
||||||
|
offset: number
|
||||||
|
): ControlPoint => {
|
||||||
|
// 根据不同的位置类型计算偏移后的坐标
|
||||||
|
switch (box.position) {
|
||||||
|
case Position.Top: // 顶部位置,y坐标向上偏移
|
||||||
|
return {
|
||||||
|
id: uuid(),
|
||||||
|
x: box.x,
|
||||||
|
y: box.y - offset,
|
||||||
|
};
|
||||||
|
case Position.Bottom: // 底部位置,y坐标向下偏移
|
||||||
|
return { id: uuid(), x: box.x, y: box.y + offset };
|
||||||
|
case Position.Left: // 左侧位置,x坐标向左偏移
|
||||||
|
return { id: uuid(), x: box.x - offset, y: box.y };
|
||||||
|
case Position.Right: // 右侧位置,x坐标向右偏移
|
||||||
|
return { id: uuid(), x: box.x + offset, y: box.y };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断一个点是否在线段上
|
||||||
|
* @param p - 待判断的点
|
||||||
|
* @param p1 - 线段起点
|
||||||
|
* @param p2 - 线段终点
|
||||||
|
* @returns 如果点在线段上返回true,否则返回false
|
||||||
|
*
|
||||||
|
* 判断逻辑:
|
||||||
|
* 1. 点必须在线段所在的直线上(x坐标相等或y坐标相等)
|
||||||
|
* 2. 点的坐标必须在线段两端点坐标范围内
|
||||||
|
*/
|
||||||
|
export const isInLine = (
|
||||||
|
p: ControlPoint,
|
||||||
|
p1: ControlPoint,
|
||||||
|
p2: ControlPoint
|
||||||
|
) => {
|
||||||
|
// 获取x坐标的范围区间[min, max]
|
||||||
|
const xPoints = p1.x < p2.x ? [p1.x, p2.x] : [p2.x, p1.x];
|
||||||
|
// 获取y坐标的范围区间[min, max]
|
||||||
|
const yPoints = p1.y < p2.y ? [p1.y, p2.y] : [p2.y, p1.y];
|
||||||
|
|
||||||
|
return (
|
||||||
|
// 垂直线段:三点x坐标相等,且待判断点的y坐标在范围内
|
||||||
|
(p1.x === p.x && p.x === p2.x && p.y >= yPoints[0] && p.y <= yPoints[1]) ||
|
||||||
|
// 水平线段:三点y坐标相等,且待判断点的x坐标在范围内
|
||||||
|
(p1.y === p.y && p.y === p2.y && p.x >= xPoints[0] && p.x <= xPoints[1])
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断一个点是否在直线上(不考虑线段端点限制)
|
||||||
|
* @param p - 待判断的点
|
||||||
|
* @param p1 - 直线上的点1
|
||||||
|
* @param p2 - 直线上的点2
|
||||||
|
* @returns 如果点在直线上返回true,否则返回false
|
||||||
|
*
|
||||||
|
* 判断逻辑:
|
||||||
|
* 仅判断点是否与直线上的两点共线(x坐标相等或y坐标相等)
|
||||||
|
*/
|
||||||
|
export const isOnLine = (
|
||||||
|
p: ControlPoint,
|
||||||
|
p1: ControlPoint,
|
||||||
|
p2: ControlPoint
|
||||||
|
) => {
|
||||||
|
return (p1.x === p.x && p.x === p2.x) || (p1.y === p.y && p.y === p2.y);
|
||||||
|
};
|
||||||
|
export interface OptimizePointsParams {
|
||||||
|
edgePoints: ControlPoint[];
|
||||||
|
source: HandlePosition;
|
||||||
|
target: HandlePosition;
|
||||||
|
sourceOffset: ControlPoint;
|
||||||
|
targetOffset: ControlPoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 优化边的控制点
|
||||||
|
*
|
||||||
|
* 主要功能:
|
||||||
|
* 1. 合并坐标相近的点
|
||||||
|
* 2. 删除重复的坐标点
|
||||||
|
* 3. 修正起点和终点的位置
|
||||||
|
*
|
||||||
|
* @param p 包含边的起点、终点、偏移点和中间控制点等信息的参数对象
|
||||||
|
* @returns 优化后的控制点信息,包含起点、终点、起点偏移、终点偏移和中间控制点
|
||||||
|
*/
|
||||||
|
export const optimizeInputPoints = (p: OptimizePointsParams) => {
|
||||||
|
// 合并坐标相近的点,将所有点放入一个数组进行处理
|
||||||
|
let edgePoints = mergeClosePoints([
|
||||||
|
p.source,
|
||||||
|
p.sourceOffset,
|
||||||
|
...p.edgePoints,
|
||||||
|
p.targetOffset,
|
||||||
|
p.target,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 从合并后的点中提取起点和终点
|
||||||
|
const source = edgePoints.shift()!;
|
||||||
|
const target = edgePoints.pop()!;
|
||||||
|
const sourceOffset = edgePoints[0];
|
||||||
|
const targetOffset = edgePoints[edgePoints.length - 1];
|
||||||
|
|
||||||
|
// 根据起点和终点的位置类型修正其坐标
|
||||||
|
// 如果是水平方向,则保持x坐标不变;否则保持y坐标不变
|
||||||
|
if (isHorizontalFromPosition(p.source.position)) {
|
||||||
|
source.x = p.source.x;
|
||||||
|
} else {
|
||||||
|
source.y = p.source.y;
|
||||||
|
}
|
||||||
|
if (isHorizontalFromPosition(p.target.position)) {
|
||||||
|
target.x = p.target.x;
|
||||||
|
} else {
|
||||||
|
target.y = p.target.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除重复的坐标点,并为每个点分配唯一ID
|
||||||
|
edgePoints = removeRepeatPoints(edgePoints).map((p, idx) => ({
|
||||||
|
...p,
|
||||||
|
id: `${idx + 1}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { source, target, sourceOffset, targetOffset, edgePoints };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 简化边的控制点
|
||||||
|
*
|
||||||
|
* 主要功能:
|
||||||
|
* 1. 确保直线上只保留两个端点
|
||||||
|
* 2. 移除位于直线内部的控制点
|
||||||
|
*
|
||||||
|
* 实现原理:
|
||||||
|
* - 遍历所有中间点
|
||||||
|
* - 判断每个点是否在其相邻两点形成的直线上
|
||||||
|
* - 如果在直线上则移除该点
|
||||||
|
*
|
||||||
|
* @param points 原始控制点数组
|
||||||
|
* @returns 简化后的控制点数组
|
||||||
|
*/
|
||||||
|
export function reducePoints(points: ControlPoint[]): ControlPoint[] {
|
||||||
|
const optimizedPoints = [points[0]];
|
||||||
|
|
||||||
|
// 遍历除首尾点外的所有点
|
||||||
|
for (let i = 1; i < points.length - 1; i++) {
|
||||||
|
// 判断当前点是否在前后两点形成的直线上
|
||||||
|
const inSegment = isInLine(points[i], points[i - 1], points[i + 1]);
|
||||||
|
// 如果不在直线上,则保留该点
|
||||||
|
if (!inSegment) {
|
||||||
|
optimizedPoints.push(points[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
optimizedPoints.push(points[points.length - 1]);
|
||||||
|
return optimizedPoints;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 坐标点处理工具函数集合
|
||||||
|
* 主要用于图形边缘控制点的坐标处理,包括合并、去重等操作
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合并临近坐标点,同时将坐标值取整
|
||||||
|
* @param points 控制点数组
|
||||||
|
* @param threshold 合并阈值,默认为4个像素
|
||||||
|
* @returns 处理后的控制点数组
|
||||||
|
*
|
||||||
|
* 实现原理:
|
||||||
|
* 1. 分别记录x和y轴上的所有坐标值
|
||||||
|
* 2. 对每个新坐标,在阈值范围内查找已存在的相近值
|
||||||
|
* 3. 如果找到相近值则使用已存在值,否则添加新值
|
||||||
|
*/
|
||||||
|
export function mergeClosePoints(
|
||||||
|
points: ControlPoint[],
|
||||||
|
threshold = 4
|
||||||
|
): ControlPoint[] {
|
||||||
|
// 存储已处理的离散坐标值
|
||||||
|
const positions = { x: [] as number[], y: [] as number[] };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查找或添加坐标值
|
||||||
|
* @param axis 坐标轴('x'|'y')
|
||||||
|
* @param v 待处理的坐标值
|
||||||
|
* @returns 最终使用的坐标值
|
||||||
|
*/
|
||||||
|
const findPosition = (axis: "x" | "y", v: number) => {
|
||||||
|
// 向下取整,确保坐标为整数
|
||||||
|
v = Math.floor(v);
|
||||||
|
const ps = positions[axis];
|
||||||
|
// 在阈值范围内查找已存在的相近值
|
||||||
|
let p = ps.find((e) => Math.abs(v - e) < threshold);
|
||||||
|
// 如果没找到相近值,则添加新值
|
||||||
|
if (p == null) {
|
||||||
|
p = v;
|
||||||
|
positions[axis].push(v);
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理每个控制点的坐标
|
||||||
|
const finalPoints = points.map((point) => {
|
||||||
|
return {
|
||||||
|
...point,
|
||||||
|
x: findPosition("x", point.x),
|
||||||
|
y: findPosition("y", point.y),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return finalPoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断两个控制点是否重合
|
||||||
|
* @param p1 控制点1
|
||||||
|
* @param p2 控制点2
|
||||||
|
* @returns 是否重合
|
||||||
|
*/
|
||||||
|
export function isEqualPoint(p1: ControlPoint, p2: ControlPoint) {
|
||||||
|
return p1.x === p2.x && p1.y === p2.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除重复的控制点,但保留起点和终点
|
||||||
|
* @param points 控制点数组
|
||||||
|
* @returns 去重后的控制点数组
|
||||||
|
*
|
||||||
|
* 实现思路:
|
||||||
|
* 1. 使用Set存储已处理的坐标字符串(格式:"x-y")
|
||||||
|
* 2. 保留最后一个点(终点)
|
||||||
|
* 3. 遍历时跳过重复坐标,但保留第一次出现的点
|
||||||
|
*/
|
||||||
|
export function removeRepeatPoints(points: ControlPoint[]): ControlPoint[] {
|
||||||
|
// 先添加终点坐标,确保终点被保留
|
||||||
|
const lastP = points[points.length - 1];
|
||||||
|
const uniquePoints = new Set([`${lastP.x}-${lastP.y}`]);
|
||||||
|
const finalPoints: ControlPoint[] = [];
|
||||||
|
|
||||||
|
points.forEach((p, idx) => {
|
||||||
|
// 处理终点
|
||||||
|
if (idx === points.length - 1) {
|
||||||
|
return finalPoints.push(p);
|
||||||
|
}
|
||||||
|
// 使用坐标字符串作为唯一标识
|
||||||
|
const key = `${p.x}-${p.y}`;
|
||||||
|
if (!uniquePoints.has(key)) {
|
||||||
|
uniquePoints.add(key);
|
||||||
|
finalPoints.push(p);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return finalPoints;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 判断两条线段是否相交
|
||||||
|
* @param p0 第一条线段的起点
|
||||||
|
* @param p1 第一条线段的终点
|
||||||
|
* @param p2 第二条线段的起点
|
||||||
|
* @param p3 第二条线段的终点
|
||||||
|
* @returns 如果两线段相交返回true,否则返回false
|
||||||
|
*
|
||||||
|
* 实现原理:
|
||||||
|
* 1. 使用向量叉积判断两线段是否平行
|
||||||
|
* 2. 使用参数方程求解交点参数s和t
|
||||||
|
* 3. 判断参数是否在[0,1]区间内来确定是否相交
|
||||||
|
*/
|
||||||
|
const isSegmentsIntersected = (
|
||||||
|
p0: ControlPoint,
|
||||||
|
p1: ControlPoint,
|
||||||
|
p2: ControlPoint,
|
||||||
|
p3: ControlPoint
|
||||||
|
): boolean => {
|
||||||
|
// 计算两条线段的方向向量
|
||||||
|
const s1x = p1.x - p0.x;
|
||||||
|
const s1y = p1.y - p0.y;
|
||||||
|
const s2x = p3.x - p2.x;
|
||||||
|
const s2y = p3.y - p2.y;
|
||||||
|
|
||||||
|
// 使用向量叉积判断两线段是否平行
|
||||||
|
if (s1x * s2y - s1y * s2x === 0) {
|
||||||
|
// 平行线段必不相交
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 求解参数方程,获取交点参数s和t
|
||||||
|
const denominator = -s2x * s1y + s1x * s2y;
|
||||||
|
const s = (s1y * (p2.x - p0.x) - s1x * (p2.y - p0.y)) / denominator;
|
||||||
|
const t = (s2x * (p0.y - p2.y) - s2y * (p0.x - p2.x)) / denominator;
|
||||||
|
|
||||||
|
// 当且仅当s和t都在[0,1]区间内时,两线段相交
|
||||||
|
return s >= 0 && s <= 1 && t >= 0 && t <= 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断线段是否与矩形相交
|
||||||
|
* @param p1 线段起点
|
||||||
|
* @param p2 线段终点
|
||||||
|
* @param box 矩形区域
|
||||||
|
* @returns 如果线段与矩形有交点返回true,否则返回false
|
||||||
|
*
|
||||||
|
* 实现思路:
|
||||||
|
* 1. 首先处理特殊情况:矩形退化为点时必不相交
|
||||||
|
* 2. 将矩形分解为四条边
|
||||||
|
* 3. 判断线段是否与任意一条矩形边相交
|
||||||
|
* 4. 只要与任意一边相交,则与矩形相交
|
||||||
|
*/
|
||||||
|
export const isSegmentCrossingRect = (
|
||||||
|
p1: ControlPoint,
|
||||||
|
p2: ControlPoint,
|
||||||
|
box: NodeRect
|
||||||
|
): boolean => {
|
||||||
|
// 处理特殊情况:矩形退化为点
|
||||||
|
if (box.width === 0 && box.height === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取矩形的四个顶点
|
||||||
|
const [topLeft, topRight, bottomRight, bottomLeft] = getRectVertices(box);
|
||||||
|
|
||||||
|
// 判断线段是否与矩形的任意一条边相交
|
||||||
|
return (
|
||||||
|
isSegmentsIntersected(p1, p2, topLeft, topRight) || // 上边
|
||||||
|
isSegmentsIntersected(p1, p2, topRight, bottomRight) || // 右边
|
||||||
|
isSegmentsIntersected(p1, p2, bottomRight, bottomLeft) || // 下边
|
||||||
|
isSegmentsIntersected(p1, p2, bottomLeft, topLeft) // 左边
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,200 @@
|
||||||
|
import { deepClone, lastOf } from "@/utils/base";
|
||||||
|
import { Position, getBezierPath } from "reactflow";
|
||||||
|
|
||||||
|
import { getBasePath } from ".";
|
||||||
|
import {
|
||||||
|
kBaseMarkerColor,
|
||||||
|
kBaseMarkerColors,
|
||||||
|
kNoMarkerColor,
|
||||||
|
kYesMarkerColor,
|
||||||
|
} from "../../components/Edges/Marker";
|
||||||
|
import { isEqual } from "../../utils/diff";
|
||||||
|
import { EdgeLayout, ReactFlowEdgeWithData } from "../../data/types";
|
||||||
|
import { kReactFlow } from "../../states/reactflow";
|
||||||
|
import { getPathWithRoundCorners } from "./edge";
|
||||||
|
|
||||||
|
interface EdgeStyle {
|
||||||
|
color: string;
|
||||||
|
edgeType: "solid" | "dashed";
|
||||||
|
pathType: "base" | "bezier";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the style of the connection line
|
||||||
|
*
|
||||||
|
* 1. When there are more than 3 edges connecting to both ends of the Node, use multiple colors to distinguish the edges.
|
||||||
|
* 2. When the connection line goes backward or connects to a hub Node, use dashed lines to distinguish the edges.
|
||||||
|
* 3. When the connection line goes from a hub to a Node, use bezier path.
|
||||||
|
*/
|
||||||
|
export const getEdgeStyles = (props: {
|
||||||
|
id: string;
|
||||||
|
isBackward: boolean;
|
||||||
|
}): EdgeStyle => {
|
||||||
|
const { id, isBackward } = props;
|
||||||
|
const idx = parseInt(lastOf(id.split("#")) ?? "0", 10);
|
||||||
|
if (isBackward) {
|
||||||
|
// Use dashed lines to distinguish the edges when the connection line goes backward or connects to a hub Node
|
||||||
|
return { color: kNoMarkerColor, edgeType: "dashed", pathType: "base" };
|
||||||
|
}
|
||||||
|
const edge: ReactFlowEdgeWithData = kReactFlow.instance!.getEdge(id)!;
|
||||||
|
if (edge.data!.targetPort.edges > 2) {
|
||||||
|
// Use dashed bezier path when the connection line connects to a hub Node
|
||||||
|
return {
|
||||||
|
color: kYesMarkerColor,
|
||||||
|
edgeType: "dashed",
|
||||||
|
pathType: "bezier",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (edge.data!.sourcePort.edges > 2) {
|
||||||
|
// Use multiple colors to distinguish the edges when there are more than 3 edges connecting to both ends of the Node
|
||||||
|
return {
|
||||||
|
color: kBaseMarkerColors[idx % kBaseMarkerColors.length],
|
||||||
|
edgeType: "solid",
|
||||||
|
pathType: "base",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { color: kBaseMarkerColor, edgeType: "solid", pathType: "base" };
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ILayoutEdge {
|
||||||
|
id: string;
|
||||||
|
layout?: EdgeLayout;
|
||||||
|
offset: number;
|
||||||
|
borderRadius: number;
|
||||||
|
pathType: EdgeStyle["pathType"];
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
sourceX: number;
|
||||||
|
sourceY: number;
|
||||||
|
targetX: number;
|
||||||
|
targetY: number;
|
||||||
|
sourcePosition: Position;
|
||||||
|
targetPosition: Position;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function layoutEdge({
|
||||||
|
id,
|
||||||
|
layout,
|
||||||
|
offset,
|
||||||
|
borderRadius,
|
||||||
|
pathType,
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
sourceX,
|
||||||
|
sourceY,
|
||||||
|
targetX,
|
||||||
|
targetY,
|
||||||
|
sourcePosition,
|
||||||
|
targetPosition,
|
||||||
|
}: ILayoutEdge): EdgeLayout {
|
||||||
|
const relayoutDeps = [sourceX, sourceY, targetX, targetY];
|
||||||
|
const needRelayout = !isEqual(relayoutDeps, layout?.deps?.relayoutDeps);
|
||||||
|
const reBuildPathDeps = layout?.points;
|
||||||
|
const needReBuildPath = !isEqual(
|
||||||
|
reBuildPathDeps,
|
||||||
|
layout?.deps?.reBuildPathDeps
|
||||||
|
);
|
||||||
|
let newLayout = layout;
|
||||||
|
if (needRelayout) {
|
||||||
|
newLayout = _layoutEdge({
|
||||||
|
id,
|
||||||
|
offset,
|
||||||
|
borderRadius,
|
||||||
|
pathType,
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
sourceX,
|
||||||
|
sourceY,
|
||||||
|
targetX,
|
||||||
|
targetY,
|
||||||
|
sourcePosition,
|
||||||
|
targetPosition,
|
||||||
|
});
|
||||||
|
} else if (needReBuildPath) {
|
||||||
|
newLayout = _layoutEdge({
|
||||||
|
layout,
|
||||||
|
id,
|
||||||
|
offset,
|
||||||
|
borderRadius,
|
||||||
|
pathType,
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
sourceX,
|
||||||
|
sourceY,
|
||||||
|
targetX,
|
||||||
|
targetY,
|
||||||
|
sourcePosition,
|
||||||
|
targetPosition,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
newLayout!.deps = deepClone({ relayoutDeps, reBuildPathDeps });
|
||||||
|
return newLayout!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _layoutEdge({
|
||||||
|
id,
|
||||||
|
layout,
|
||||||
|
offset,
|
||||||
|
borderRadius,
|
||||||
|
pathType,
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
sourceX,
|
||||||
|
sourceY,
|
||||||
|
targetX,
|
||||||
|
targetY,
|
||||||
|
sourcePosition,
|
||||||
|
targetPosition,
|
||||||
|
}: ILayoutEdge): EdgeLayout {
|
||||||
|
const _pathType: EdgeStyle["pathType"] = pathType;
|
||||||
|
if (_pathType === "bezier") {
|
||||||
|
const [path, labelX, labelY] = getBezierPath({
|
||||||
|
sourceX,
|
||||||
|
sourceY,
|
||||||
|
targetX,
|
||||||
|
targetY,
|
||||||
|
sourcePosition,
|
||||||
|
targetPosition,
|
||||||
|
});
|
||||||
|
const points = [
|
||||||
|
{
|
||||||
|
id: "source-" + id,
|
||||||
|
x: sourceX,
|
||||||
|
y: sourceY,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "target-" + id,
|
||||||
|
x: targetX,
|
||||||
|
y: targetY,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
path,
|
||||||
|
points,
|
||||||
|
inputPoints: points,
|
||||||
|
labelPosition: {
|
||||||
|
x: labelX,
|
||||||
|
y: labelY,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((layout?.points?.length ?? 0) > 1) {
|
||||||
|
layout!.path = getPathWithRoundCorners(layout!.points, borderRadius);
|
||||||
|
return layout!;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getBasePath({
|
||||||
|
id,
|
||||||
|
offset,
|
||||||
|
borderRadius,
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
sourceX,
|
||||||
|
sourceY,
|
||||||
|
targetX,
|
||||||
|
targetY,
|
||||||
|
sourcePosition,
|
||||||
|
targetPosition,
|
||||||
|
});
|
||||||
|
}
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { Node, Edge } from "@xyflow/react";
|
||||||
|
|
||||||
|
interface LayoutOptions {
|
||||||
|
nodes: Node[];
|
||||||
|
edges: Edge[];
|
||||||
|
levelSeparation?: number;
|
||||||
|
nodeSeparation?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export function getMindMapLayout(options: LayoutOptions) {
|
||||||
|
return {
|
||||||
|
nodes: options.nodes,
|
||||||
|
edges: options.edges,
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,148 @@
|
||||||
|
import { MarkerType, Position, useInternalNode, Node, Edge } from "@xyflow/react";
|
||||||
|
import { LayoutDirection, LayoutVisibility } from "./node";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取流程图中的根节点
|
||||||
|
* @param nodes - 所有节点数组
|
||||||
|
* @param edges - 所有边的数组
|
||||||
|
* @returns 根节点数组(没有入边的节点)
|
||||||
|
*/
|
||||||
|
export const getRootNodes = (nodes: Node[], edges: Edge[]): Node[] => {
|
||||||
|
// 创建一个Set来存储所有有入边的节点ID
|
||||||
|
const nodesWithIncoming = new Set(
|
||||||
|
edges.map((edge) => edge.target)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 过滤出没有入边的节点
|
||||||
|
const rootNodes = nodes.filter(
|
||||||
|
(node) => !nodesWithIncoming.has(node.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
return rootNodes;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* 获取节点尺寸信息的工具函数
|
||||||
|
* @param node 需要获取尺寸的节点对象
|
||||||
|
* @param defaultSize 默认尺寸配置,包含默认宽度(150px)和高度(36px)
|
||||||
|
* @returns 返回节点的尺寸信息对象,包含:
|
||||||
|
* - hasDimension: 是否已设置实际尺寸
|
||||||
|
* - width: 节点实际宽度
|
||||||
|
* - height: 节点实际高度
|
||||||
|
* - widthWithDefault: 实际宽度或默认宽度
|
||||||
|
* - heightWithDefault: 实际高度或默认高度
|
||||||
|
*/
|
||||||
|
export const getNodeSize = (
|
||||||
|
node: Node,
|
||||||
|
defaultSize = { width: 150, height: 36 }
|
||||||
|
) => {
|
||||||
|
// 获取节点的实际宽高
|
||||||
|
const nodeWith = node?.width;
|
||||||
|
const nodeHeight = node?.height;
|
||||||
|
// 检查节点是否同时设置了宽度和高度
|
||||||
|
const hasDimension = [nodeWith, nodeHeight].every((e) => e != null);
|
||||||
|
|
||||||
|
// 返回包含完整尺寸信息的对象
|
||||||
|
// 使用空值合并运算符(??)在实际尺寸未设置时使用默认值
|
||||||
|
return {
|
||||||
|
hasDimension,
|
||||||
|
width: nodeWith,
|
||||||
|
height: nodeHeight,
|
||||||
|
widthWithDefault: nodeWith ?? defaultSize.width,
|
||||||
|
heightWithDefault: nodeHeight ?? defaultSize.height,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IFixPosition = (pros: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}) => {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* 节点布局计算函数
|
||||||
|
* @description 根据给定的节点信息和布局参数,计算节点的最终布局属性
|
||||||
|
* @param props 布局参数对象
|
||||||
|
* @param props.node 需要布局的节点对象
|
||||||
|
* @param props.position 节点的初始位置坐标
|
||||||
|
* @param props.direction 布局方向,'horizontal'表示水平布局,'vertical'表示垂直布局
|
||||||
|
* @param props.visibility 节点可见性,'visible'表示可见,其他值表示隐藏
|
||||||
|
* @param props.fixPosition 可选的位置修正函数,用于调整最终位置
|
||||||
|
* @returns 返回计算好布局属性的节点对象
|
||||||
|
*/
|
||||||
|
export const getNodeLayouted = (props: {
|
||||||
|
node: Node;
|
||||||
|
position: { x: number; y: number };
|
||||||
|
direction: LayoutDirection;
|
||||||
|
visibility: LayoutVisibility;
|
||||||
|
fixPosition?: IFixPosition;
|
||||||
|
}) => {
|
||||||
|
// 解构布局参数,设置位置修正函数的默认值
|
||||||
|
const {
|
||||||
|
node,
|
||||||
|
position,
|
||||||
|
direction,
|
||||||
|
visibility,
|
||||||
|
fixPosition = (p) => ({ x: p.x, y: p.y }),
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
// 计算节点的显示状态和布局方向
|
||||||
|
const hidden = visibility !== "visible";
|
||||||
|
const isHorizontal = direction === "horizontal";
|
||||||
|
|
||||||
|
// 获取节点尺寸信息
|
||||||
|
const { width, height, widthWithDefault, heightWithDefault } =
|
||||||
|
getNodeSize(node);
|
||||||
|
|
||||||
|
// 根据布局方向设置节点的连接点位置
|
||||||
|
node.targetPosition = isHorizontal ? Position.Left : Position.Top;
|
||||||
|
node.sourcePosition = isHorizontal ? Position.Right : Position.Bottom;
|
||||||
|
|
||||||
|
// 返回带有完整布局属性的节点对象
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
hidden,
|
||||||
|
position: fixPosition({
|
||||||
|
...position,
|
||||||
|
width: widthWithDefault,
|
||||||
|
height: heightWithDefault,
|
||||||
|
}),
|
||||||
|
style: {
|
||||||
|
...node.style,
|
||||||
|
opacity: hidden ? 0 : 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 边布局计算函数
|
||||||
|
* @description 根据给定的边信息和可见性参数,计算边的最终布局属性
|
||||||
|
* @param props 布局参数对象
|
||||||
|
* @param props.edge 需要布局的边对象
|
||||||
|
* @param props.visibility 边的可见性,'visible'表示可见,其他值表示隐藏
|
||||||
|
* @returns 返回计算好布局属性的边对象
|
||||||
|
*/
|
||||||
|
export const getEdgeLayouted = (props: {
|
||||||
|
edge: Edge;
|
||||||
|
visibility: LayoutVisibility;
|
||||||
|
}) => {
|
||||||
|
const { edge, visibility } = props;
|
||||||
|
const hidden = visibility !== "visible";
|
||||||
|
|
||||||
|
// 返回带有完整布局属性的边对象
|
||||||
|
return {
|
||||||
|
...edge,
|
||||||
|
hidden,
|
||||||
|
markerEnd: {
|
||||||
|
type: MarkerType.ArrowClosed, // 设置箭头样式为闭合箭头
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
...edge.style,
|
||||||
|
opacity: hidden ? 0 : 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
|
@ -0,0 +1,121 @@
|
||||||
|
import { graphStratify, sugiyama } from "d3-dag";
|
||||||
|
import { getIncomers, type Node } from "@xyflow/react";
|
||||||
|
import { getEdgeLayouted, getNodeLayouted, getNodeSize } from "../../metadata";
|
||||||
|
import { LayoutAlgorithm, LayoutAlgorithmProps } from "..";
|
||||||
|
type NodeWithPosition = Node & { x: number; y: number };
|
||||||
|
|
||||||
|
// Since d3-dag layout algorithm does not support multiple root nodes,
|
||||||
|
// we attach the sub-workflows to the global rootNode.
|
||||||
|
const rootNode: NodeWithPosition = {
|
||||||
|
id: "#root",
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {} as any,
|
||||||
|
};
|
||||||
|
|
||||||
|
const algorithms = {
|
||||||
|
"d3-dag": "d3-dag",
|
||||||
|
"ds-dag(s)": "ds-dag(s)",
|
||||||
|
};
|
||||||
|
|
||||||
|
export type D3DAGLayoutAlgorithms = "d3-dag" | "ds-dag(s)";
|
||||||
|
|
||||||
|
export const layoutD3DAG = async (
|
||||||
|
props: LayoutAlgorithmProps & { algorithm?: D3DAGLayoutAlgorithms }
|
||||||
|
) => {
|
||||||
|
const {
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
direction,
|
||||||
|
visibility,
|
||||||
|
spacing,
|
||||||
|
algorithm = "d3-dag",
|
||||||
|
} = props;
|
||||||
|
const isHorizontal = direction === "horizontal";
|
||||||
|
|
||||||
|
const initialNodes = [] as NodeWithPosition[];
|
||||||
|
let maxNodeWidth = 0;
|
||||||
|
let maxNodeHeight = 0;
|
||||||
|
for (const node of nodes) {
|
||||||
|
const { widthWithDefault, heightWithDefault } = getNodeSize(node);
|
||||||
|
initialNodes.push({
|
||||||
|
...node,
|
||||||
|
...node.position,
|
||||||
|
width: widthWithDefault,
|
||||||
|
height: heightWithDefault,
|
||||||
|
});
|
||||||
|
maxNodeWidth = Math.max(maxNodeWidth, widthWithDefault);
|
||||||
|
maxNodeHeight = Math.max(maxNodeHeight, heightWithDefault);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Since d3-dag does not support horizontal layout,
|
||||||
|
// we swap the width and height of nodes and interchange x and y mappings based on the layout direction.
|
||||||
|
const nodeSize: any = isHorizontal
|
||||||
|
? [maxNodeHeight + spacing.y, maxNodeWidth + spacing.x]
|
||||||
|
: [maxNodeWidth + spacing.x, maxNodeHeight + spacing.y];
|
||||||
|
|
||||||
|
const getParentIds = (node: Node) => {
|
||||||
|
if (node.id === rootNode.id) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
// Node without input is the root node of sub-workflow, and we should connect it to the rootNode
|
||||||
|
const incomers = getIncomers(node, nodes, edges);
|
||||||
|
if (incomers.length < 1) {
|
||||||
|
return [rootNode.id];
|
||||||
|
}
|
||||||
|
return algorithm === "d3-dag"
|
||||||
|
? [incomers[0]?.id]
|
||||||
|
: incomers.map((e) => e.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stratify = graphStratify();
|
||||||
|
const dag = stratify(
|
||||||
|
[rootNode, ...initialNodes].map((node) => {
|
||||||
|
return {
|
||||||
|
id: node.id,
|
||||||
|
parentIds: getParentIds(node),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const layout = sugiyama().nodeSize(nodeSize);
|
||||||
|
layout(dag);
|
||||||
|
|
||||||
|
const layoutNodes = new Map<string, any>();
|
||||||
|
for (const node of dag.nodes()) {
|
||||||
|
layoutNodes.set(node.data.id, node);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes: nodes.map((node) => {
|
||||||
|
const { x, y } = layoutNodes.get(node.id);
|
||||||
|
// Interchange x and y mappings based on the layout direction.
|
||||||
|
const position = isHorizontal ? { x: y, y: x } : { x, y };
|
||||||
|
return getNodeLayouted({
|
||||||
|
node,
|
||||||
|
position,
|
||||||
|
direction,
|
||||||
|
visibility,
|
||||||
|
fixPosition: ({ x, y, width, height }) => {
|
||||||
|
// This algorithm uses the center coordinate of the node as the reference point,
|
||||||
|
// which needs adjustment for ReactFlow's topLeft coordinate system.
|
||||||
|
return {
|
||||||
|
x: x - width / 2,
|
||||||
|
y: y - height / 2,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
edges: edges.map((edge) => getEdgeLayouted({ edge, visibility })),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const kD3DAGAlgorithms: Record<string, LayoutAlgorithm> = Object.keys(
|
||||||
|
algorithms
|
||||||
|
).reduce((pre, algorithm) => {
|
||||||
|
pre[algorithm] = (props: any) => {
|
||||||
|
return layoutD3DAG({ ...props, algorithm });
|
||||||
|
};
|
||||||
|
return pre;
|
||||||
|
}, {} as any);
|
|
@ -0,0 +1,89 @@
|
||||||
|
// Based on: https://github.com/flanksource/flanksource-ui/blob/75b35591d3bbc7d446fa326d0ca7536790f38d88/src/ui/Graphs/Layouts/algorithms/d3-hierarchy.ts
|
||||||
|
|
||||||
|
import { stratify, tree, type HierarchyPointNode } from "d3-hierarchy";
|
||||||
|
import {getIncomers, Node} from "@xyflow/react"
|
||||||
|
import { LayoutAlgorithm } from "..";
|
||||||
|
import { getEdgeLayouted, getNodeLayouted, getNodeSize } from "../../metadata";
|
||||||
|
type NodeWithPosition = Node & { x: number; y: number };
|
||||||
|
|
||||||
|
const layout = tree<NodeWithPosition>().separation(() => 1);
|
||||||
|
|
||||||
|
// Since d3-hierarchy layout algorithm does not support multiple root nodes,
|
||||||
|
// we attach the sub-workflows to the global rootNode.
|
||||||
|
const rootNode: NodeWithPosition = {
|
||||||
|
id: "#root",
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {} as any,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const layoutD3Hierarchy: LayoutAlgorithm = async (props) => {
|
||||||
|
const { nodes, edges, direction, visibility, spacing } = props;
|
||||||
|
const isHorizontal = direction === "horizontal";
|
||||||
|
|
||||||
|
const initialNodes = [] as NodeWithPosition[];
|
||||||
|
let maxNodeWidth = 0;
|
||||||
|
let maxNodeHeight = 0;
|
||||||
|
for (const node of nodes) {
|
||||||
|
const { widthWithDefault, heightWithDefault } = getNodeSize(node);
|
||||||
|
initialNodes.push({
|
||||||
|
...node,
|
||||||
|
...node.position,
|
||||||
|
width: widthWithDefault,
|
||||||
|
height: heightWithDefault,
|
||||||
|
});
|
||||||
|
maxNodeWidth = Math.max(maxNodeWidth, widthWithDefault);
|
||||||
|
maxNodeHeight = Math.max(maxNodeHeight, heightWithDefault);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Since d3-hierarchy does not support horizontal layout,
|
||||||
|
// we swap the width and height of nodes and interchange x and y mappings based on the layout direction.
|
||||||
|
const nodeSize: [number, number] = isHorizontal
|
||||||
|
? [maxNodeHeight + spacing.y, maxNodeWidth + spacing.x]
|
||||||
|
: [maxNodeWidth + spacing.x, maxNodeHeight + spacing.y];
|
||||||
|
|
||||||
|
layout.nodeSize(nodeSize);
|
||||||
|
|
||||||
|
const getParentId = (node: Node) => {
|
||||||
|
if (node.id === rootNode.id) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
// Node without input is the root node of sub-workflow, and we should connect it to the rootNode
|
||||||
|
const incomers = getIncomers(node, nodes, edges);
|
||||||
|
return incomers[0]?.id || rootNode.id;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hierarchy = stratify<NodeWithPosition>()
|
||||||
|
.id((d) => d.id)
|
||||||
|
.parentId(getParentId)([rootNode, ...initialNodes]);
|
||||||
|
|
||||||
|
const root = layout(hierarchy);
|
||||||
|
const layoutNodes = new Map<string, HierarchyPointNode<NodeWithPosition>>();
|
||||||
|
for (const node of root) {
|
||||||
|
layoutNodes.set(node.id!, node);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes: nodes.map((node) => {
|
||||||
|
const { x, y } = layoutNodes.get(node.id)!;
|
||||||
|
// Interchange x and y mappings based on the layout direction.
|
||||||
|
const position = isHorizontal ? { x: y, y: x } : { x, y };
|
||||||
|
return getNodeLayouted({
|
||||||
|
node,
|
||||||
|
position,
|
||||||
|
direction,
|
||||||
|
visibility,
|
||||||
|
fixPosition: ({ x, y, width, height }) => {
|
||||||
|
// This algorithm uses the center coordinate of the node as the reference point,
|
||||||
|
// which needs adjustment for ReactFlow's topLeft coordinate system.
|
||||||
|
return {
|
||||||
|
x: x - width / 2,
|
||||||
|
y: y - height / 2,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
edges: edges.map((edge) => getEdgeLayouted({ edge, visibility })),
|
||||||
|
};
|
||||||
|
};
|
|
@ -0,0 +1,122 @@
|
||||||
|
import dagre from "@dagrejs/dagre";
|
||||||
|
import { LayoutAlgorithm } from "..";
|
||||||
|
import { getIncomers, Node } from "@xyflow/react";
|
||||||
|
import { getEdgeLayouted, getNodeLayouted, getNodeSize } from "../../metadata";
|
||||||
|
import { randomInt } from "../../../utils/base";
|
||||||
|
|
||||||
|
// 布局配置常量
|
||||||
|
const LAYOUT_CONFIG = {
|
||||||
|
VIRTUAL_ROOT_ID: '#root',
|
||||||
|
VIRTUAL_NODE_SIZE: 1,
|
||||||
|
RANKER: 'tight-tree',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// 创建并配置 dagre 图实例
|
||||||
|
const createDagreGraph = () => {
|
||||||
|
const graph = new dagre.graphlib.Graph();
|
||||||
|
graph.setDefaultEdgeLabel(() => ({}));
|
||||||
|
return graph;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取布局方向配置
|
||||||
|
const getLayoutConfig = (
|
||||||
|
direction: 'horizontal' | 'vertical',
|
||||||
|
spacing: { x: number, y: number },
|
||||||
|
graph: dagre.graphlib.Graph
|
||||||
|
) => ({
|
||||||
|
nodesep: direction === 'horizontal' ? spacing.y : spacing.x,
|
||||||
|
ranksep: direction === 'horizontal' ? spacing.x : spacing.y,
|
||||||
|
ranker: LAYOUT_CONFIG.RANKER,
|
||||||
|
rankdir: direction === 'horizontal' ? 'LR' : 'TB',
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
// 查找根节点
|
||||||
|
const findRootNodes = (nodes: Node[], edges: any[]): Node[] =>
|
||||||
|
nodes.filter(node => getIncomers(node, nodes, edges).length < 1);
|
||||||
|
|
||||||
|
// 计算节点边界
|
||||||
|
const calculateBounds = (nodes: Node[], graph: dagre.graphlib.Graph) => {
|
||||||
|
const bounds = {
|
||||||
|
minX: Number.POSITIVE_INFINITY,
|
||||||
|
minY: Number.POSITIVE_INFINITY,
|
||||||
|
maxX: Number.NEGATIVE_INFINITY,
|
||||||
|
maxY: Number.NEGATIVE_INFINITY,
|
||||||
|
};
|
||||||
|
|
||||||
|
nodes.forEach(node => {
|
||||||
|
const pos = graph.node(node.id);
|
||||||
|
if (pos) {
|
||||||
|
bounds.minX = Math.min(bounds.minX, pos.x);
|
||||||
|
bounds.minY = Math.min(bounds.minY, pos.y);
|
||||||
|
bounds.maxX = Math.max(bounds.maxX, pos.x);
|
||||||
|
bounds.maxY = Math.max(bounds.maxY, pos.y);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return bounds;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const layoutDagreTree: LayoutAlgorithm = async ({
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
direction,
|
||||||
|
visibility,
|
||||||
|
spacing
|
||||||
|
}) => {
|
||||||
|
const dagreGraph = createDagreGraph();
|
||||||
|
|
||||||
|
// 设置图的布局参数
|
||||||
|
dagreGraph.setGraph(getLayoutConfig(direction, spacing, dagreGraph));
|
||||||
|
|
||||||
|
// 添加节点
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
const { widthWithDefault, heightWithDefault } = getNodeSize(node);
|
||||||
|
dagreGraph.setNode(node.id, {
|
||||||
|
width: widthWithDefault,
|
||||||
|
height: heightWithDefault,
|
||||||
|
order: randomInt(0, 10)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 添加边
|
||||||
|
edges.forEach(edge => dagreGraph.setEdge(edge.source, edge.target));
|
||||||
|
|
||||||
|
// 处理多个子工作流的情况
|
||||||
|
const rootNodes = findRootNodes(nodes, edges);
|
||||||
|
if (rootNodes.length > 1) {
|
||||||
|
dagreGraph.setNode(LAYOUT_CONFIG.VIRTUAL_ROOT_ID, {
|
||||||
|
width: LAYOUT_CONFIG.VIRTUAL_NODE_SIZE,
|
||||||
|
height: LAYOUT_CONFIG.VIRTUAL_NODE_SIZE,
|
||||||
|
rank: -1 // 确保虚拟根节点排在最前面
|
||||||
|
});
|
||||||
|
rootNodes.forEach(node =>
|
||||||
|
dagreGraph.setEdge(LAYOUT_CONFIG.VIRTUAL_ROOT_ID, node.id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行布局
|
||||||
|
dagre.layout(dagreGraph);
|
||||||
|
|
||||||
|
// 移除虚拟根节点
|
||||||
|
if (rootNodes.length > 1) {
|
||||||
|
dagreGraph.removeNode(LAYOUT_CONFIG.VIRTUAL_ROOT_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算边界并返回布局结果
|
||||||
|
const bounds = calculateBounds(nodes, dagreGraph);
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes: nodes.map(node => getNodeLayouted({
|
||||||
|
node,
|
||||||
|
position: dagreGraph.node(node.id),
|
||||||
|
direction,
|
||||||
|
visibility,
|
||||||
|
fixPosition: ({ x, y, width, height }) => ({
|
||||||
|
x: x - width / 2 - bounds.minX,
|
||||||
|
y: y - height / 2 - bounds.minY,
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
edges: edges.map(edge => getEdgeLayouted({ edge, visibility })),
|
||||||
|
};
|
||||||
|
};
|
|
@ -0,0 +1,128 @@
|
||||||
|
import ELK, { ElkNode } from "elkjs/lib/elk.bundled.js";
|
||||||
|
import { getIncomers,Node } from "@xyflow/react";
|
||||||
|
import { LayoutAlgorithm, LayoutAlgorithmProps } from "..";
|
||||||
|
import { getEdgeLayouted, getNodeLayouted, getNodeSize } from "../../metadata";
|
||||||
|
|
||||||
|
const algorithms = {
|
||||||
|
"elk-layered": "layered",
|
||||||
|
"elk-mr-tree": "mrtree",
|
||||||
|
};
|
||||||
|
|
||||||
|
const elk = new ELK({ algorithms: Object.values(algorithms) });
|
||||||
|
|
||||||
|
export type ELKLayoutAlgorithms = "elk-layered" | "elk-mr-tree";
|
||||||
|
|
||||||
|
export const layoutELK = async (
|
||||||
|
props: LayoutAlgorithmProps & { algorithm?: ELKLayoutAlgorithms }
|
||||||
|
) => {
|
||||||
|
const {
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
direction,
|
||||||
|
visibility,
|
||||||
|
spacing,
|
||||||
|
algorithm = "elk-mr-tree",
|
||||||
|
} = props;
|
||||||
|
const isHorizontal = direction === "horizontal";
|
||||||
|
|
||||||
|
const subWorkflowRootNodes: Node[] = [];
|
||||||
|
const layoutNodes = nodes.map((node) => {
|
||||||
|
const incomers = getIncomers(node, nodes, edges);
|
||||||
|
if (incomers.length < 1) {
|
||||||
|
// Node without input is the root node of sub-workflow
|
||||||
|
subWorkflowRootNodes.push(node);
|
||||||
|
}
|
||||||
|
const { widthWithDefault, heightWithDefault } = getNodeSize(node);
|
||||||
|
const sourcePorts = node.data.sourceHandles.map((id) => ({
|
||||||
|
id,
|
||||||
|
properties: {
|
||||||
|
side: isHorizontal ? "EAST" : "SOUTH",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
const targetPorts = node.data.targetHandles.map((id) => ({
|
||||||
|
id,
|
||||||
|
properties: {
|
||||||
|
side: isHorizontal ? "WEST" : "NORTH",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
id: node.id,
|
||||||
|
width: widthWithDefault,
|
||||||
|
height: heightWithDefault,
|
||||||
|
ports: [...targetPorts, ...sourcePorts],
|
||||||
|
properties: {
|
||||||
|
"org.eclipse.elk.portConstraints": "FIXED_ORDER",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const layoutEdges = edges.map((edge) => {
|
||||||
|
return {
|
||||||
|
id: edge.id,
|
||||||
|
sources: [edge.sourceHandle || edge.source],
|
||||||
|
targets: [edge.targetHandle || edge.target],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Connect sub-workflows' root nodes to the rootNode
|
||||||
|
const rootNode: any = { id: "#root", width: 1, height: 1 };
|
||||||
|
layoutNodes.push(rootNode);
|
||||||
|
for (const subWorkflowRootNode of subWorkflowRootNodes) {
|
||||||
|
layoutEdges.push({
|
||||||
|
id: `${rootNode.id}-${subWorkflowRootNode.id}`,
|
||||||
|
sources: [rootNode.id],
|
||||||
|
targets: [subWorkflowRootNode.id],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const layouted = await elk
|
||||||
|
.layout({
|
||||||
|
id: "@root",
|
||||||
|
children: layoutNodes,
|
||||||
|
edges: layoutEdges,
|
||||||
|
layoutOptions: {
|
||||||
|
// - https://www.eclipse.org/elk/reference/algorithms.html
|
||||||
|
"elk.algorithm": algorithms[algorithm],
|
||||||
|
"elk.direction": isHorizontal ? "RIGHT" : "DOWN",
|
||||||
|
// - https://www.eclipse.org/elk/reference/options.html
|
||||||
|
"elk.spacing.nodeNode": isHorizontal
|
||||||
|
? spacing.y.toString()
|
||||||
|
: spacing.x.toString(),
|
||||||
|
"elk.layered.spacing.nodeNodeBetweenLayers": isHorizontal
|
||||||
|
? spacing.x.toString()
|
||||||
|
: spacing.y.toString(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.log("❌ ELK layout failed", e);
|
||||||
|
}) as ElkNode
|
||||||
|
|
||||||
|
if (!layouted?.children) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const layoutedNodePositions = layouted.children.reduce((pre, v) => {
|
||||||
|
pre[v.id] = {
|
||||||
|
x: v.x ?? 0,
|
||||||
|
y: v.y ?? 0,
|
||||||
|
};
|
||||||
|
return pre;
|
||||||
|
}, {} as Record<string, { x: number; y: number }>);
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes: nodes.map((node) => {
|
||||||
|
const position = layoutedNodePositions[node.id];
|
||||||
|
return getNodeLayouted({ node, position, direction, visibility });
|
||||||
|
}),
|
||||||
|
edges: edges.map((edge) => getEdgeLayouted({ edge, visibility })),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const kElkAlgorithms: Record<string, LayoutAlgorithm> = Object.keys(
|
||||||
|
algorithms
|
||||||
|
).reduce((pre, algorithm) => {
|
||||||
|
pre[algorithm] = (props: any) => {
|
||||||
|
return layoutELK({ ...props, algorithm });
|
||||||
|
};
|
||||||
|
return pre;
|
||||||
|
}, {} as any);
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { LayoutAlgorithm } from "..";
|
||||||
|
import { getEdgeLayouted, getNodeLayouted } from "../../metadata";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Positions all nodes at the origin (0,0) in the layout.
|
||||||
|
*/
|
||||||
|
export const layoutOrigin: LayoutAlgorithm = async (props) => {
|
||||||
|
const { nodes, edges, direction, visibility } = props;
|
||||||
|
return {
|
||||||
|
nodes: nodes.map((node) => {
|
||||||
|
return getNodeLayouted({
|
||||||
|
node,
|
||||||
|
direction,
|
||||||
|
visibility,
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
edges: edges.map((edge) => getEdgeLayouted({ edge, visibility })),
|
||||||
|
};
|
||||||
|
};
|
|
@ -0,0 +1,149 @@
|
||||||
|
/**
|
||||||
|
* 图形布局模块
|
||||||
|
*
|
||||||
|
* 该模块提供了一系列用于处理 ReactFlow 图形布局的工具和算法。
|
||||||
|
* 支持多种布局算法,包括原始布局、树形布局、层次布局等。
|
||||||
|
* 主要用于自动计算和调整图形中节点和边的位置。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ReactFlowGraph } from "../../types";
|
||||||
|
import { removeEmpty } from "../../utils/base";
|
||||||
|
import { D3DAGLayoutAlgorithms, kD3DAGAlgorithms } from "./algorithms/d3-dag";
|
||||||
|
import { layoutD3Hierarchy } from "./algorithms/d3-hierarchy";
|
||||||
|
import { layoutDagreTree } from "./algorithms/dagre-tree";
|
||||||
|
import { ELKLayoutAlgorithms, kElkAlgorithms } from "./algorithms/elk";
|
||||||
|
import { layoutOrigin } from "./algorithms/origin";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 布局方向类型
|
||||||
|
* vertical: 垂直布局
|
||||||
|
* horizontal: 水平布局
|
||||||
|
*/
|
||||||
|
export type LayoutDirection = "vertical" | "horizontal";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 布局可见性类型
|
||||||
|
* visible: 可见
|
||||||
|
* hidden: 隐藏
|
||||||
|
*/
|
||||||
|
export type LayoutVisibility = "visible" | "hidden";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 布局间距配置接口
|
||||||
|
* x: 水平间距
|
||||||
|
* y: 垂直间距
|
||||||
|
*/
|
||||||
|
export interface LayoutSpacing {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ReactFlow 布局配置接口
|
||||||
|
* 定义了布局所需的各项参数
|
||||||
|
*/
|
||||||
|
export type ReactFlowLayoutConfig = {
|
||||||
|
algorithm: LayoutAlgorithms; // 使用的布局算法
|
||||||
|
direction: LayoutDirection; // 布局方向
|
||||||
|
spacing: LayoutSpacing; // 节点间距
|
||||||
|
/**
|
||||||
|
* 布局可见性配置
|
||||||
|
* 在首次布局时如果节点大小不可用,可能需要隐藏布局
|
||||||
|
*/
|
||||||
|
visibility: LayoutVisibility;
|
||||||
|
/**
|
||||||
|
* 是否反转源节点手柄顺序
|
||||||
|
*/
|
||||||
|
reverseSourceHandles: boolean;
|
||||||
|
autoCenterRoot: boolean
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 布局算法所需的属性类型
|
||||||
|
* 继承自 ReactFlowGraph 并包含布局配置(除算法外)
|
||||||
|
*/
|
||||||
|
export type LayoutAlgorithmProps = ReactFlowGraph &
|
||||||
|
Omit<ReactFlowLayoutConfig, "algorithm">;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 布局算法函数类型定义
|
||||||
|
* 接收布局属性作为参数,返回布局后的图形数据
|
||||||
|
*/
|
||||||
|
export type LayoutAlgorithm = (
|
||||||
|
props: LayoutAlgorithmProps
|
||||||
|
) => Promise<ReactFlowGraph | undefined>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可用的布局算法映射表
|
||||||
|
* 包含所有支持的布局算法实现
|
||||||
|
*/
|
||||||
|
export const layoutAlgorithms: Record<string, LayoutAlgorithm> = {
|
||||||
|
origin: layoutOrigin,
|
||||||
|
"dagre-tree": layoutDagreTree,
|
||||||
|
"d3-hierarchy": layoutD3Hierarchy,
|
||||||
|
...kElkAlgorithms,
|
||||||
|
...kD3DAGAlgorithms,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认布局配置
|
||||||
|
*/
|
||||||
|
export const defaultLayoutConfig: ReactFlowLayoutConfig = {
|
||||||
|
algorithm: "dagre-tree", // 默认使用 elk-mr-tree 算法
|
||||||
|
direction: "horizontal", // 默认垂直布局
|
||||||
|
visibility: "visible", // 默认可见
|
||||||
|
spacing: { x: 120, y: 120 }, // 默认间距
|
||||||
|
reverseSourceHandles: false, // 默认不反转源节点手柄
|
||||||
|
autoCenterRoot: false
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 支持的布局算法类型联合
|
||||||
|
*/
|
||||||
|
export type LayoutAlgorithms =
|
||||||
|
| "origin"
|
||||||
|
| "dagre-tree"
|
||||||
|
| "d3-hierarchy"
|
||||||
|
| ELKLayoutAlgorithms
|
||||||
|
| D3DAGLayoutAlgorithms;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ReactFlow 布局类型
|
||||||
|
* 包含图形数据和可选的布局配置
|
||||||
|
*/
|
||||||
|
export type ReactFlowLayout = ReactFlowGraph & Partial<ReactFlowLayoutConfig>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行 ReactFlow 图形布局的主函数
|
||||||
|
*
|
||||||
|
* @param options - 布局选项,包含图形数据和布局配置
|
||||||
|
* @returns 返回布局后的图形数据
|
||||||
|
*
|
||||||
|
* 函数流程:
|
||||||
|
* 1. 合并默认配置和用户配置
|
||||||
|
* 2. 获取对应的布局算法
|
||||||
|
* 3. 执行布局计算
|
||||||
|
* 4. 如果布局失败,回退到原始布局
|
||||||
|
*/
|
||||||
|
export const layoutReactFlow = async (
|
||||||
|
options: ReactFlowLayout
|
||||||
|
): Promise<ReactFlowGraph> => {
|
||||||
|
// 合并配置,移除空值
|
||||||
|
const config = { ...defaultLayoutConfig, ...removeEmpty(options) };
|
||||||
|
const { nodes = [], edges = [] } = config;
|
||||||
|
|
||||||
|
// 获取并执行布局算法
|
||||||
|
const layout = layoutAlgorithms[config.algorithm];
|
||||||
|
let result = await layout({ ...config, nodes, edges });
|
||||||
|
|
||||||
|
// 布局失败时回退处理
|
||||||
|
if (!result) {
|
||||||
|
result = await layoutReactFlow({
|
||||||
|
...config,
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
algorithm: "origin",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result!;
|
||||||
|
};
|
|
@ -0,0 +1,170 @@
|
||||||
|
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { Handle, Position, NodeProps, Node } from '@xyflow/react';
|
||||||
|
import useGraphStore from '../store';
|
||||||
|
import { shallow } from 'zustand/shallow';
|
||||||
|
import { GraphState } from '../types';
|
||||||
|
|
||||||
|
export type GraphNode = Node<{
|
||||||
|
label: string;
|
||||||
|
color?: string;
|
||||||
|
level?: number;
|
||||||
|
}, 'graph-node'>;
|
||||||
|
|
||||||
|
const getLevelStyles = (level: number = 0) => {
|
||||||
|
const styles = {
|
||||||
|
0: {
|
||||||
|
container: 'bg-[#2B4B6F] text-white',
|
||||||
|
handle: 'bg-[#2B4B6F]',
|
||||||
|
fontSize: 'text-lg'
|
||||||
|
},
|
||||||
|
1: {
|
||||||
|
container: 'bg-blue-300 text-white',
|
||||||
|
handle: 'bg-[#3A5F84]',
|
||||||
|
fontSize: 'text-base'
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
container: 'bg-gray-100',
|
||||||
|
handle: 'bg-[#496F96]',
|
||||||
|
fontSize: 'text-base'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return styles[level as keyof typeof styles]
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const baseTextStyles = `
|
||||||
|
text-center
|
||||||
|
break-words
|
||||||
|
whitespace-pre-wrap
|
||||||
|
`;
|
||||||
|
const handleStyles = `
|
||||||
|
w-2.5 h-2.5
|
||||||
|
border-2 border-white/80
|
||||||
|
rounded-full
|
||||||
|
transition-colors
|
||||||
|
duration-200
|
||||||
|
opacity-80
|
||||||
|
hover:opacity-100
|
||||||
|
`;
|
||||||
|
const selector = (store: GraphState) => ({
|
||||||
|
updateNode: store.updateNode,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const GraphNode = memo(({ id, selected, data, isConnectable }: NodeProps<GraphNode>) => {
|
||||||
|
const { updateNode } = useGraphStore(selector, shallow);
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const levelStyles = getLevelStyles(data.level);
|
||||||
|
const [inputValue, setInputValue] = useState(data.label);
|
||||||
|
const [isComposing, setIsComposing] = useState(false);
|
||||||
|
const updateTextareaHeight = useCallback((element: HTMLTextAreaElement) => {
|
||||||
|
element.style.height = 'auto';
|
||||||
|
element.style.height = `${element.scrollHeight}px`;
|
||||||
|
}, []);
|
||||||
|
const handleChange = useCallback((evt: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
const newValue = evt.target.value;
|
||||||
|
setInputValue(newValue);
|
||||||
|
updateNode(id, { label: newValue });
|
||||||
|
updateTextareaHeight(evt.target);
|
||||||
|
}, [updateNode, id, updateTextareaHeight]);
|
||||||
|
const handleKeyDown = useCallback((evt: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (!isEditing) {
|
||||||
|
if (/^[a-zA-Z0-9]$/.test(evt.key)) {
|
||||||
|
setIsEditing(true);
|
||||||
|
setInputValue(evt.key); // 将第一个字符添加到现有内容后
|
||||||
|
updateNode(id, { label: evt.key });
|
||||||
|
}
|
||||||
|
if (evt.key === ' ') {
|
||||||
|
setIsEditing(true);
|
||||||
|
setInputValue(data.label); // 将第一个字符添加到现有内容后
|
||||||
|
updateNode(id, { label: data.label });
|
||||||
|
}
|
||||||
|
evt.preventDefault(); // 阻止默认行为
|
||||||
|
evt.stopPropagation(); // 阻止事件冒泡
|
||||||
|
} else if (isEditing && evt.key === 'Enter' && !evt.shiftKey && !isComposing) {
|
||||||
|
setIsEditing(false);
|
||||||
|
evt.preventDefault();
|
||||||
|
}
|
||||||
|
}, [isEditing, isComposing, data.label, id, updateNode]);
|
||||||
|
const handleDoubleClick = useCallback(() => {
|
||||||
|
setIsEditing(true);
|
||||||
|
}, []);
|
||||||
|
const handleBlur = useCallback(() => setIsEditing(false), []);
|
||||||
|
// 添加 ref 来获取父元素
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEditing && textareaRef.current) {
|
||||||
|
updateTextareaHeight(textareaRef.current);
|
||||||
|
// 聚焦并将光标移到末尾
|
||||||
|
textareaRef.current.focus();
|
||||||
|
const length = textareaRef.current.value.length;
|
||||||
|
textareaRef.current.setSelectionRange(length, length);
|
||||||
|
}
|
||||||
|
}, [isEditing, updateTextareaHeight]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={`
|
||||||
|
flex items-center justify-center
|
||||||
|
rounded-md
|
||||||
|
|
||||||
|
max-w-64
|
||||||
|
${levelStyles.container}
|
||||||
|
${selected ? 'ring-2 ring-[#3688FF]/30 shadow-lg' : ''}
|
||||||
|
${isEditing ? 'ring-2 ring-white/50' : ''}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className={`p-2 ${baseTextStyles}`}>
|
||||||
|
{data.label}
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
defaultValue={data.label}
|
||||||
|
onChange={(evt) => handleChange(evt as any)}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
value={inputValue}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onCompositionStart={() => setIsComposing(true)}
|
||||||
|
onCompositionEnd={() => setIsComposing(false)}
|
||||||
|
className={`
|
||||||
|
${isEditing ? 'nodrag' : ''}
|
||||||
|
bg-transparent
|
||||||
|
focus:outline-none
|
||||||
|
${baseTextStyles}
|
||||||
|
${levelStyles.fontSize}
|
||||||
|
resize-none
|
||||||
|
overflow-hidden
|
||||||
|
${!isEditing ? 'cursor-default' : ''}
|
||||||
|
`}
|
||||||
|
placeholder={isEditing ? "输入节点内容..." : "双击编辑"}
|
||||||
|
rows={1}
|
||||||
|
readOnly={!isEditing}
|
||||||
|
onDoubleClick={handleDoubleClick}
|
||||||
|
onInput={(e) => {
|
||||||
|
const target = e.target as HTMLTextAreaElement;
|
||||||
|
target.style.height = 'auto';
|
||||||
|
target.style.height = `${target.scrollHeight}px`;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Left}
|
||||||
|
isConnectable={isConnectable}
|
||||||
|
id="target"
|
||||||
|
className={`${handleStyles} -ml-[6px] ${levelStyles.handle}`}
|
||||||
|
/>
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Right}
|
||||||
|
isConnectable={isConnectable}
|
||||||
|
id="source"
|
||||||
|
className={`${handleStyles} -mr-[6px] ${levelStyles.handle}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
GraphNode.displayName = 'GraphNode';
|
|
@ -0,0 +1,158 @@
|
||||||
|
import { addEdge, applyNodeChanges, applyEdgeChanges, Node, Edge, Connection, NodeChange, EdgeChange } from '@xyflow/react';
|
||||||
|
import { createWithEqualityFn } from 'zustand/traditional';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import debounce from 'lodash/debounce';
|
||||||
|
import { GraphState } from './types';
|
||||||
|
import { initialEdges, initialNodes } from './data';
|
||||||
|
|
||||||
|
const MAX_HISTORY_LENGTH = 100;
|
||||||
|
const HISTORY_DEBOUNCE_MS = 100;
|
||||||
|
|
||||||
|
const useGraphStore = createWithEqualityFn<GraphState>((set, get) => {
|
||||||
|
return {
|
||||||
|
past: [],
|
||||||
|
future: [],
|
||||||
|
present: {
|
||||||
|
nodes: initialNodes,
|
||||||
|
edges: initialEdges,
|
||||||
|
},
|
||||||
|
record: (callback: () => void) => {
|
||||||
|
const currentState = get().present;
|
||||||
|
|
||||||
|
console.group('Recording new state');
|
||||||
|
console.log('Current state:', currentState);
|
||||||
|
console.log('Past states count:', get().past.length);
|
||||||
|
console.log('Future states count:', get().future.length);
|
||||||
|
|
||||||
|
set(state => {
|
||||||
|
const newPast = [...state.past.slice(-MAX_HISTORY_LENGTH), currentState];
|
||||||
|
console.log('New past states count:', newPast.length);
|
||||||
|
console.groupEnd();
|
||||||
|
return {
|
||||||
|
past: newPast,
|
||||||
|
future: [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
callback();
|
||||||
|
},
|
||||||
|
|
||||||
|
undo: () => {
|
||||||
|
const { past, present } = get();
|
||||||
|
console.group('Undo operation');
|
||||||
|
console.log('Current state:', present);
|
||||||
|
console.log('Past states count:', past.length);
|
||||||
|
|
||||||
|
if (past.length === 0) {
|
||||||
|
console.warn('Cannot undo - no past states available');
|
||||||
|
console.groupEnd();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previous = past[past.length - 1];
|
||||||
|
const newPast = past.slice(0, past.length - 1);
|
||||||
|
|
||||||
|
console.log('Reverting to previous state:', previous);
|
||||||
|
console.log('New past states count:', newPast.length);
|
||||||
|
console.log('New future states count:', get().future.length + 1);
|
||||||
|
console.groupEnd();
|
||||||
|
|
||||||
|
set({
|
||||||
|
past: newPast,
|
||||||
|
present: previous,
|
||||||
|
future: [present, ...get().future],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
redo: () => {
|
||||||
|
const { future, present } = get();
|
||||||
|
console.group('Redo operation');
|
||||||
|
console.log('Current state:', present);
|
||||||
|
console.log('Future states count:', future.length);
|
||||||
|
|
||||||
|
if (future.length === 0) {
|
||||||
|
console.warn('Cannot redo - no future states available');
|
||||||
|
console.groupEnd();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = future[0];
|
||||||
|
const newFuture = future.slice(1);
|
||||||
|
|
||||||
|
console.log('Moving to next state:', next);
|
||||||
|
console.log('New past states count:', get().past.length + 1);
|
||||||
|
console.log('New future states count:', newFuture.length);
|
||||||
|
console.groupEnd();
|
||||||
|
|
||||||
|
set({
|
||||||
|
past: [...get().past, present],
|
||||||
|
present: next,
|
||||||
|
future: newFuture,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
setNodes: (nodes: Node[]) => {
|
||||||
|
set(state => ({
|
||||||
|
present: {
|
||||||
|
nodes: nodes,
|
||||||
|
edges: state.present.edges
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
setEdges: (edges: Edge[]) => {
|
||||||
|
set(state => ({
|
||||||
|
present: {
|
||||||
|
nodes: state.present.nodes,
|
||||||
|
edges: edges
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
onNodesChange: (changes: NodeChange[]) => {
|
||||||
|
set(state => ({
|
||||||
|
present: {
|
||||||
|
nodes: applyNodeChanges(changes, state.present.nodes),
|
||||||
|
edges: state.present.edges
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
onEdgesChange: (changes: EdgeChange[]) => {
|
||||||
|
|
||||||
|
set(state => ({
|
||||||
|
present: {
|
||||||
|
nodes: state.present.nodes,
|
||||||
|
edges: applyEdgeChanges(changes, state.present.edges)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
canUndo: () => get().past.length > 0,
|
||||||
|
canRedo: () => get().future.length > 0,
|
||||||
|
|
||||||
|
updateNode: (nodeId: string, data: any) => {
|
||||||
|
const newNodes = get().present.nodes.map(node =>
|
||||||
|
node.id === nodeId ? { ...node, data: { ...node.data, ...data } } : node
|
||||||
|
);
|
||||||
|
set({
|
||||||
|
present: {
|
||||||
|
nodes: newNodes,
|
||||||
|
edges: get().present.edges
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
deleteNode: (nodeId: string) => {
|
||||||
|
const newNodes = get().present.nodes.filter(node => node.id !== nodeId);
|
||||||
|
const newEdges = get().present.edges.filter(
|
||||||
|
edge => edge.source !== nodeId && edge.target !== nodeId
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
updateEdge: (edgeId: string, data: any) => {
|
||||||
|
const newEdges = get().present.edges.map(edge =>
|
||||||
|
edge.id === edgeId ? { ...edge, data: { ...edge.data, ...data } } : edge
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export default useGraphStore;
|
|
@ -0,0 +1,94 @@
|
||||||
|
import { Edge, NodeProps, Node, OnConnect, OnEdgesChange, OnNodesChange, Connection, NodeChange, EdgeChange, OnSelectionChangeParams, XYPosition } from "@xyflow/react";
|
||||||
|
import { GraphEdge } from "./edges/GraphEdge";
|
||||||
|
import { GraphNode } from "./nodes/GraphNode";
|
||||||
|
import { ControlPoint } from "./layout/edge/point";
|
||||||
|
import { ReactFlowLayout, ReactFlowLayoutConfig } from "./layout/node";
|
||||||
|
// 添加新的类型定义
|
||||||
|
export type HistoryState = {
|
||||||
|
nodes: Node[];
|
||||||
|
edges: Edge[];
|
||||||
|
type: string; // 记录操作类型
|
||||||
|
timestamp: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GraphState = {
|
||||||
|
past: Array<{ nodes: Node[], edges: Edge[] }>;
|
||||||
|
present: {
|
||||||
|
nodes: Node[];
|
||||||
|
edges: Edge[];
|
||||||
|
};
|
||||||
|
future: Array<{ nodes: Node[], edges: Edge[] }>;
|
||||||
|
canUndo: () => boolean;
|
||||||
|
canRedo: () => boolean;
|
||||||
|
onNodesChange: (changes: NodeChange[]) => void;
|
||||||
|
onEdgesChange: (changes: EdgeChange[]) => void;
|
||||||
|
updateNode: (id: string, data: any) => void;
|
||||||
|
undo: () => void;
|
||||||
|
redo: () => void;
|
||||||
|
setNodes: (nodes: Node[]) => void;
|
||||||
|
setEdges: (edges: Edge[]) => void;
|
||||||
|
record: (callback: () => void) => void
|
||||||
|
};
|
||||||
|
export const nodeTypes = {
|
||||||
|
'graph-node': GraphNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export const edgeTypes = {
|
||||||
|
'graph-edge': GraphEdge
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReactFlowGraph {
|
||||||
|
nodes: Node[]
|
||||||
|
edges: Edge[]
|
||||||
|
}
|
||||||
|
export interface ReactFlowEdgePort {
|
||||||
|
/**
|
||||||
|
* Total number of edges in this direction (source or target).
|
||||||
|
*/
|
||||||
|
edges: number;
|
||||||
|
/**
|
||||||
|
* Number of ports
|
||||||
|
*/
|
||||||
|
portCount: number;
|
||||||
|
/**
|
||||||
|
* Port's index.
|
||||||
|
*/
|
||||||
|
portIndex: number;
|
||||||
|
/**
|
||||||
|
* Total number of Edges under the current port.
|
||||||
|
*/
|
||||||
|
edgeCount: number;
|
||||||
|
/**
|
||||||
|
* Index of the Edge under the current port.
|
||||||
|
*/
|
||||||
|
edgeIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EdgeLayout {
|
||||||
|
/**
|
||||||
|
* SVG path for edge rendering
|
||||||
|
*/
|
||||||
|
path: string;
|
||||||
|
/**
|
||||||
|
* Control points on the edge.
|
||||||
|
*/
|
||||||
|
points: ControlPoint[];
|
||||||
|
labelPosition: XYPosition;
|
||||||
|
/**
|
||||||
|
* Current layout dependent variables (re-layout when changed).
|
||||||
|
*/
|
||||||
|
deps?: any;
|
||||||
|
/**
|
||||||
|
* Potential control points on the edge, for debugging purposes only.
|
||||||
|
*/
|
||||||
|
inputPoints: ControlPoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReactFlowEdgeData {
|
||||||
|
/**
|
||||||
|
* Data related to the current edge's layout, such as control points.
|
||||||
|
*/
|
||||||
|
layout?: EdgeLayout;
|
||||||
|
sourcePort: ReactFlowEdgePort;
|
||||||
|
targetPort: ReactFlowEdgePort;
|
||||||
|
}
|
|
@ -0,0 +1,144 @@
|
||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import { shallow } from 'zustand/shallow';
|
||||||
|
import { throttle } from 'lodash';
|
||||||
|
import { Edge, Node, useReactFlow } from "@xyflow/react";
|
||||||
|
import { GraphState } from "./types";
|
||||||
|
import useGraphStore from "./store";
|
||||||
|
|
||||||
|
// Store selector
|
||||||
|
const selector = (store: GraphState) => ({
|
||||||
|
nodes: store.present.nodes,
|
||||||
|
edges: store.present.edges,
|
||||||
|
setNodes: store.setNodes,
|
||||||
|
setEdges: store.setEdges,
|
||||||
|
record: store.record
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
const createNode = (label: string): Node => ({
|
||||||
|
id: nanoid(6),
|
||||||
|
type: 'graph-node',
|
||||||
|
data: { label },
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const createEdge = (source: string, target: string): Edge => ({
|
||||||
|
id: nanoid(6),
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
type: 'graph-edge',
|
||||||
|
});
|
||||||
|
|
||||||
|
export function useGraphOperation() {
|
||||||
|
const store = useGraphStore(selector, shallow);
|
||||||
|
const { addEdges, addNodes } = useReactFlow();
|
||||||
|
|
||||||
|
const selectedNodes = useMemo(() =>
|
||||||
|
store.nodes.filter(node => node.selected),
|
||||||
|
[store.nodes]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find parent node ID for a given node
|
||||||
|
const findParentId = useCallback((nodeId: string) => {
|
||||||
|
const parentEdge = store.edges.find(edge => edge.target === nodeId);
|
||||||
|
return parentEdge?.source;
|
||||||
|
}, [store.edges]);
|
||||||
|
|
||||||
|
// Update node selection
|
||||||
|
const updateNodeSelection = useCallback((nodeIds: string[]) => {
|
||||||
|
return store.nodes.map(node => ({
|
||||||
|
...node,
|
||||||
|
selected: nodeIds.includes(node.id)
|
||||||
|
}));
|
||||||
|
}, [store.nodes]);
|
||||||
|
|
||||||
|
// Create new node and connect it
|
||||||
|
const createConnectedNode = useCallback((parentId: string, deselectOthers = true) => {
|
||||||
|
const newNode = createNode(`新节点${store.nodes.length}`);
|
||||||
|
const newEdge = createEdge(parentId, newNode.id);
|
||||||
|
|
||||||
|
store.record(() => {
|
||||||
|
addNodes({ ...newNode, selected: true });
|
||||||
|
addEdges(newEdge);
|
||||||
|
|
||||||
|
if (deselectOthers) {
|
||||||
|
store.setNodes(updateNodeSelection([newNode.id]));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [store, addNodes, addEdges, updateNodeSelection]);
|
||||||
|
|
||||||
|
// Handle node creation operations
|
||||||
|
const handleCreateChildNodes = useCallback(() => {
|
||||||
|
if (selectedNodes.length === 0) return;
|
||||||
|
|
||||||
|
throttle(() => {
|
||||||
|
selectedNodes.forEach(node => {
|
||||||
|
if (node.id) createConnectedNode(node.id);
|
||||||
|
});
|
||||||
|
}, 300)();
|
||||||
|
}, [selectedNodes, createConnectedNode]);
|
||||||
|
|
||||||
|
const handleCreateSiblingNodes = useCallback(() => {
|
||||||
|
if (selectedNodes.length === 0) return;
|
||||||
|
|
||||||
|
throttle(() => {
|
||||||
|
selectedNodes.forEach(node => {
|
||||||
|
const parentId = findParentId(node.id) || node.id;
|
||||||
|
createConnectedNode(parentId);
|
||||||
|
});
|
||||||
|
}, 300)();
|
||||||
|
}, [selectedNodes, findParentId, createConnectedNode]);
|
||||||
|
|
||||||
|
const handleDeleteNodes = useCallback(() => {
|
||||||
|
if (selectedNodes.length === 0) return;
|
||||||
|
|
||||||
|
const nodesToDelete = new Set<string>();
|
||||||
|
|
||||||
|
// Collect all nodes to delete including children
|
||||||
|
const collectNodesToDelete = (nodeId: string) => {
|
||||||
|
nodesToDelete.add(nodeId);
|
||||||
|
store.edges
|
||||||
|
.filter(edge => edge.source === nodeId)
|
||||||
|
.forEach(edge => collectNodesToDelete(edge.target));
|
||||||
|
};
|
||||||
|
|
||||||
|
selectedNodes.forEach(node => collectNodesToDelete(node.id));
|
||||||
|
|
||||||
|
store.record(() => {
|
||||||
|
// Filter out deleted nodes and their edges
|
||||||
|
const remainingNodes = store.nodes.filter(node => !nodesToDelete.has(node.id));
|
||||||
|
const remainingEdges = store.edges.filter(edge =>
|
||||||
|
!nodesToDelete.has(edge.source) && !nodesToDelete.has(edge.target)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Select next node (sibling or parent of first deleted node)
|
||||||
|
const firstDeletedNode = selectedNodes[0];
|
||||||
|
const parentId = findParentId(firstDeletedNode.id);
|
||||||
|
|
||||||
|
let nextSelectedId: string | undefined;
|
||||||
|
if (parentId) {
|
||||||
|
const siblingEdge = store.edges.find(edge =>
|
||||||
|
edge.source === parentId &&
|
||||||
|
!nodesToDelete.has(edge.target) &&
|
||||||
|
edge.target !== firstDeletedNode.id
|
||||||
|
);
|
||||||
|
nextSelectedId = siblingEdge?.target || parentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update nodes with new selection and set the remaining nodes
|
||||||
|
const updatedNodes = remainingNodes.map(node => ({
|
||||||
|
...node,
|
||||||
|
selected: node.id === nextSelectedId
|
||||||
|
}));
|
||||||
|
|
||||||
|
store.setNodes(updatedNodes);
|
||||||
|
store.setEdges(remainingEdges);
|
||||||
|
});
|
||||||
|
}, [selectedNodes, store, findParentId]);
|
||||||
|
return {
|
||||||
|
handleCreateChildNodes,
|
||||||
|
handleCreateSiblingNodes,
|
||||||
|
handleDeleteNodes
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
|
import { shallow } from 'zustand/shallow';
|
||||||
|
import { useGraphOperation } from './useGraphOperation';
|
||||||
|
import useGraphStore from './store';
|
||||||
|
import { GraphState } from './types';
|
||||||
|
|
||||||
|
const selector = (store: GraphState) => ({
|
||||||
|
undo: store.undo,
|
||||||
|
redo: store.redo
|
||||||
|
});
|
||||||
|
|
||||||
|
export function useKeyboardCtrl() {
|
||||||
|
const { undo, redo } = useGraphStore(selector, shallow);
|
||||||
|
const {
|
||||||
|
handleCreateChildNodes,
|
||||||
|
handleCreateSiblingNodes,
|
||||||
|
handleDeleteNodes
|
||||||
|
} = useGraphOperation();
|
||||||
|
|
||||||
|
useHotkeys('tab', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleCreateChildNodes();
|
||||||
|
}, [handleCreateChildNodes]);
|
||||||
|
|
||||||
|
useHotkeys('enter', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleCreateSiblingNodes();
|
||||||
|
}, [handleCreateSiblingNodes]);
|
||||||
|
|
||||||
|
useHotkeys('ctrl+z', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
undo();
|
||||||
|
}, [undo]);
|
||||||
|
|
||||||
|
useHotkeys('ctrl+y', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
redo();
|
||||||
|
}, [redo]);
|
||||||
|
|
||||||
|
useHotkeys('delete', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleDeleteNodes();
|
||||||
|
}, [handleDeleteNodes]);
|
||||||
|
}
|
|
@ -0,0 +1,147 @@
|
||||||
|
import { Position, Node, InternalNode } from "@xyflow/react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定义交点的接口类型
|
||||||
|
* 用于表示两条线段相交的坐标点
|
||||||
|
*/
|
||||||
|
interface IntersectionPoint {
|
||||||
|
x: number; // 交点的x坐标
|
||||||
|
y: number; // 交点的y坐标
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定义边缘连接参数的接口类型
|
||||||
|
* 包含源节点和目标节点的连接位置信息
|
||||||
|
*/
|
||||||
|
interface EdgeParams {
|
||||||
|
sx: number; // 源节点连接点x坐标
|
||||||
|
sy: number; // 源节点连接点y坐标
|
||||||
|
tx: number; // 目标节点连接点x坐标
|
||||||
|
ty: number; // 目标节点连接点y坐标
|
||||||
|
sourcePos: Position; // 源节点连接位置(上下左右)
|
||||||
|
targetPos: Position; // 目标节点连接位置(上下左右)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算节点之间的交点坐标
|
||||||
|
*
|
||||||
|
* 功能说明:
|
||||||
|
* 该函数用于计算两个节点之间连线与节点边界的精确交点位置。这在绘制流程图等图形时,
|
||||||
|
* 确保连接线能够准确地从节点边界开始和结束,而不是从节点中心点开始。
|
||||||
|
*
|
||||||
|
* 算法原理:
|
||||||
|
* 1. 首先获取两个节点的位置和尺寸信息
|
||||||
|
* 2. 计算节点的中心点坐标
|
||||||
|
* 3. 使用几何算法计算连线与节点矩形边界的交点
|
||||||
|
* 4. 返回交点的精确坐标
|
||||||
|
*
|
||||||
|
* @param intersectionNode - 起始节点,需要计算交点的源节点
|
||||||
|
* @param targetNode - 目标节点,与源节点相连的终点节点
|
||||||
|
* @returns {IntersectionPoint} 返回交点坐标 {x, y}
|
||||||
|
*/
|
||||||
|
function getNodeIntersection(intersectionNode: InternalNode, targetNode: InternalNode): IntersectionPoint {
|
||||||
|
// 获取起始节点的宽度和高度
|
||||||
|
const { width: intersectionNodeWidth, height: intersectionNodeHeight } = intersectionNode.measured;
|
||||||
|
// 获取两个节点的绝对位置信息
|
||||||
|
const intersectionNodePosition = intersectionNode.internals.positionAbsolute;
|
||||||
|
const targetPosition = targetNode.internals.positionAbsolute;
|
||||||
|
|
||||||
|
// 计算起始节点的半宽和半高,用于后续的坐标计算
|
||||||
|
const w = intersectionNodeWidth / 2;
|
||||||
|
const h = intersectionNodeHeight / 2;
|
||||||
|
|
||||||
|
// 计算两个节点的中心点坐标
|
||||||
|
// (x2,y2)为起始节点的中心点
|
||||||
|
const x2 = intersectionNodePosition.x + w;
|
||||||
|
const y2 = intersectionNodePosition.y + h;
|
||||||
|
// (x1,y1)为目标节点的中心点
|
||||||
|
const x1 = targetPosition.x + targetNode.measured.width / 2;
|
||||||
|
const y1 = targetPosition.y + targetNode.measured.height / 2;
|
||||||
|
|
||||||
|
// 使用数学公式计算交点坐标
|
||||||
|
// 这里使用的是参数化方程,将节点边界视为矩形来计算交点
|
||||||
|
const xx1 = (x1 - x2) / (2 * w) - (y1 - y2) / (2 * h);
|
||||||
|
const yy1 = (x1 - x2) / (2 * w) + (y1 - y2) / (2 * h);
|
||||||
|
// 通过标准化确保交点在节点边界上
|
||||||
|
const a = 1 / (Math.abs(xx1) + Math.abs(yy1));
|
||||||
|
const xx3 = a * xx1;
|
||||||
|
const yy3 = a * yy1;
|
||||||
|
// 计算最终的交点坐标
|
||||||
|
const x = w * (xx3 + yy3) + x2;
|
||||||
|
const y = h * (-xx3 + yy3) + y2;
|
||||||
|
|
||||||
|
return { x, y };
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 确定边缘连接点的位置(上下左右)
|
||||||
|
*
|
||||||
|
* 功能说明:
|
||||||
|
* 根据节点和交点的位置关系,计算边缘线应该连接到节点的哪个位置(上/下/左/右)
|
||||||
|
*
|
||||||
|
* 实现原理:
|
||||||
|
* 1. 获取节点的绝对定位信息和尺寸信息
|
||||||
|
* 2. 将节点四条边界划分为不同区域
|
||||||
|
* 3. 通过比较交点坐标与边界位置,确定最合适的连接点
|
||||||
|
*
|
||||||
|
* @param node - 需要确定连接位置的节点对象
|
||||||
|
* 包含节点的位置信息(x,y)和尺寸信息(width,height)
|
||||||
|
* @param intersectionPoint - 交点坐标对象
|
||||||
|
* 包含交点的x,y坐标值
|
||||||
|
* @returns Position - 返回枚举值,表示连接位置(Top/Right/Bottom/Left)
|
||||||
|
*/
|
||||||
|
function getEdgePosition(node: InternalNode, intersectionPoint: IntersectionPoint): Position {
|
||||||
|
// 合并节点的绝对定位信息,确保获取准确的节点位置
|
||||||
|
const n = { ...node.internals.positionAbsolute, ...node };
|
||||||
|
|
||||||
|
// 对坐标进行取整,避免浮点数计算误差
|
||||||
|
const nx = Math.round(n.x); // 节点左边界x坐标
|
||||||
|
const ny = Math.round(n.y); // 节点上边界y坐标
|
||||||
|
const px = Math.round(intersectionPoint.x); // 交点x坐标
|
||||||
|
const py = Math.round(intersectionPoint.y); // 交点y坐标
|
||||||
|
|
||||||
|
// 判断逻辑:通过比较交点与节点各边界的位置关系确定连接位置
|
||||||
|
// 添加1px的容差值,增强判断的容错性
|
||||||
|
if (px <= nx + 1) {
|
||||||
|
return Position.Left; // 交点在节点左侧
|
||||||
|
}
|
||||||
|
if (px >= nx + n.measured.width - 1) {
|
||||||
|
return Position.Right; // 交点在节点右侧
|
||||||
|
}
|
||||||
|
if (py <= ny + 1) {
|
||||||
|
return Position.Top; // 交点在节点上方
|
||||||
|
}
|
||||||
|
if (py >= n.y + n.measured.height - 1) {
|
||||||
|
return Position.Bottom; // 交点在节点下方
|
||||||
|
}
|
||||||
|
|
||||||
|
// 若都不满足,默认返回顶部位置作为连接点
|
||||||
|
return Position.Top;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算两个节点之间边缘连接的所有必要参数
|
||||||
|
* @param source - 源节点
|
||||||
|
* @param target - 目标节点
|
||||||
|
* @returns 返回包含边缘连接所需所有参数的对象
|
||||||
|
*
|
||||||
|
* 这是主要的导出函数,用于获取创建边缘连接线所需的所有参数
|
||||||
|
*/
|
||||||
|
export function getEdgeParams(source: InternalNode, target: InternalNode): EdgeParams {
|
||||||
|
// 计算源节点和目标节点的交点
|
||||||
|
const sourceIntersectionPoint = getNodeIntersection(source, target);
|
||||||
|
const targetIntersectionPoint = getNodeIntersection(target, source);
|
||||||
|
|
||||||
|
// 确定连接点在各自节点上的位置
|
||||||
|
const sourcePos = getEdgePosition(source, sourceIntersectionPoint);
|
||||||
|
const targetPos = getEdgePosition(target, targetIntersectionPoint);
|
||||||
|
|
||||||
|
// 返回所有必要的参数
|
||||||
|
return {
|
||||||
|
sx: sourceIntersectionPoint.x,
|
||||||
|
sy: sourceIntersectionPoint.y,
|
||||||
|
tx: targetIntersectionPoint.x,
|
||||||
|
ty: targetIntersectionPoint.y,
|
||||||
|
sourcePos,
|
||||||
|
targetPos,
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,115 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
|
export const nextTick = async (frames = 1) => {
|
||||||
|
const _nextTick = async (idx: number) => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
requestAnimationFrame(() => resolve(idx));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
for (let i = 0; i < frames; i++) {
|
||||||
|
await _nextTick(i);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const firstOf = <T = any>(datas?: T[]) =>
|
||||||
|
datas ? (datas.length < 1 ? undefined : datas[0]) : undefined;
|
||||||
|
|
||||||
|
export const lastOf = <T = any>(datas?: T[]) =>
|
||||||
|
datas ? (datas.length < 1 ? undefined : datas[datas.length - 1]) : undefined;
|
||||||
|
|
||||||
|
export const randomInt = (min: number, max?: number) => {
|
||||||
|
if (!max) {
|
||||||
|
max = min;
|
||||||
|
min = 0;
|
||||||
|
}
|
||||||
|
return Math.floor(Math.random() * (max - min + 1) + min);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pickOne = <T = any>(datas: T[]) =>
|
||||||
|
datas.length < 1 ? undefined : datas[randomInt(datas.length - 1)];
|
||||||
|
|
||||||
|
export const range = (start: number, end?: number) => {
|
||||||
|
if (!end) {
|
||||||
|
end = start;
|
||||||
|
start = 0;
|
||||||
|
}
|
||||||
|
return Array.from({ length: end - start }, (_, index) => start + index);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* clamp(-1,0,1)=0
|
||||||
|
*/
|
||||||
|
export function clamp(num: number, min: number, max: number): number {
|
||||||
|
return num < max ? (num > min ? num : min) : max;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toSet = <T = any>(datas: T[], byKey?: (e: T) => any) => {
|
||||||
|
if (byKey) {
|
||||||
|
const keys: Record<string, boolean> = {};
|
||||||
|
const newDatas: T[] = [];
|
||||||
|
datas.forEach((e) => {
|
||||||
|
const key = jsonEncode({ key: byKey(e) }) as any;
|
||||||
|
if (!keys[key]) {
|
||||||
|
newDatas.push(e);
|
||||||
|
keys[key] = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return newDatas;
|
||||||
|
}
|
||||||
|
return Array.from(new Set(datas));
|
||||||
|
};
|
||||||
|
|
||||||
|
export function jsonEncode(obj: any, prettier = false) {
|
||||||
|
try {
|
||||||
|
return prettier ? JSON.stringify(obj, undefined, 4) : JSON.stringify(obj);
|
||||||
|
} catch (error) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function jsonDecode(json: string | undefined) {
|
||||||
|
if (json == undefined) return undefined;
|
||||||
|
try {
|
||||||
|
return JSON.parse(json!);
|
||||||
|
} catch (error) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeEmpty<T = any>(data: T): T {
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
return data.filter((e) => e != undefined) as any;
|
||||||
|
}
|
||||||
|
const res = {} as any;
|
||||||
|
for (const key in data) {
|
||||||
|
if (data[key] != undefined) {
|
||||||
|
res[key] = data[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deepClone = <T>(obj: T): T => {
|
||||||
|
if (obj === null || typeof obj !== "object") {
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
const copy: any[] = [];
|
||||||
|
obj.forEach((item, index) => {
|
||||||
|
copy[index] = deepClone(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
return copy as unknown as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
const copy = {} as T;
|
||||||
|
|
||||||
|
for (const key in obj) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
||||||
|
(copy as any)[key] = deepClone((obj as any)[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return copy;
|
||||||
|
};
|
|
@ -0,0 +1,105 @@
|
||||||
|
// @ts-nocheck
|
||||||
|
|
||||||
|
// Source: https://github.com/AsyncBanana/microdiff
|
||||||
|
|
||||||
|
interface Difference {
|
||||||
|
type: "CREATE" | "REMOVE" | "CHANGE";
|
||||||
|
path: (string | number)[];
|
||||||
|
value?: any;
|
||||||
|
}
|
||||||
|
interface Options {
|
||||||
|
cyclesFix: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = true;
|
||||||
|
const richTypes = { Date: t, RegExp: t, String: t, Number: t };
|
||||||
|
|
||||||
|
export function isEqual(oldObj: any, newObj: any): boolean {
|
||||||
|
return (
|
||||||
|
diff(
|
||||||
|
{
|
||||||
|
obj: oldObj,
|
||||||
|
},
|
||||||
|
{ obj: newObj }
|
||||||
|
).length < 1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isNotEqual = (oldObj: any, newObj: any) =>
|
||||||
|
!isEqual(oldObj, newObj);
|
||||||
|
|
||||||
|
function diff(
|
||||||
|
obj: Record<string, any> | any[],
|
||||||
|
newObj: Record<string, any> | any[],
|
||||||
|
options: Partial<Options> = { cyclesFix: true },
|
||||||
|
_stack: Record<string, any>[] = []
|
||||||
|
): Difference[] {
|
||||||
|
const diffs: Difference[] = [];
|
||||||
|
const isObjArray = Array.isArray(obj);
|
||||||
|
|
||||||
|
for (const key in obj) {
|
||||||
|
const objKey = obj[key];
|
||||||
|
const path = isObjArray ? Number(key) : key;
|
||||||
|
if (!(key in newObj)) {
|
||||||
|
diffs.push({
|
||||||
|
type: "REMOVE",
|
||||||
|
path: [path],
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const newObjKey = newObj[key];
|
||||||
|
const areObjects =
|
||||||
|
typeof objKey === "object" && typeof newObjKey === "object";
|
||||||
|
if (
|
||||||
|
objKey &&
|
||||||
|
newObjKey &&
|
||||||
|
areObjects &&
|
||||||
|
!richTypes[Object.getPrototypeOf(objKey).constructor.name] &&
|
||||||
|
(options.cyclesFix ? !_stack.includes(objKey) : true)
|
||||||
|
) {
|
||||||
|
const nestedDiffs = diff(
|
||||||
|
objKey,
|
||||||
|
newObjKey,
|
||||||
|
options,
|
||||||
|
options.cyclesFix ? _stack.concat([objKey]) : []
|
||||||
|
);
|
||||||
|
// eslint-disable-next-line prefer-spread
|
||||||
|
diffs.push.apply(
|
||||||
|
diffs,
|
||||||
|
nestedDiffs.map((difference) => {
|
||||||
|
difference.path.unshift(path);
|
||||||
|
|
||||||
|
return difference;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
objKey !== newObjKey &&
|
||||||
|
!(
|
||||||
|
areObjects &&
|
||||||
|
(Number.isNaN(objKey)
|
||||||
|
? String(objKey) === String(newObjKey)
|
||||||
|
: Number(objKey) === Number(newObjKey))
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
diffs.push({
|
||||||
|
path: [path],
|
||||||
|
type: "CHANGE",
|
||||||
|
value: newObjKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isNewObjArray = Array.isArray(newObj);
|
||||||
|
|
||||||
|
for (const key in newObj) {
|
||||||
|
if (!(key in obj)) {
|
||||||
|
diffs.push({
|
||||||
|
type: "CREATE",
|
||||||
|
path: [isNewObjArray ? Number(key) : key],
|
||||||
|
value: newObj[key],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return diffs;
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
export function uuid(): string {
|
||||||
|
const uuid = new Array(36);
|
||||||
|
for (let i = 0; i < 36; i++) {
|
||||||
|
uuid[i] = Math.floor(Math.random() * 16);
|
||||||
|
}
|
||||||
|
uuid[14] = 4;
|
||||||
|
uuid[19] = uuid[19] &= ~(1 << 2);
|
||||||
|
uuid[19] = uuid[19] |= 1 << 3;
|
||||||
|
uuid[8] = uuid[13] = uuid[18] = uuid[23] = "-";
|
||||||
|
return uuid.map((x) => x.toString(16)).join("");
|
||||||
|
}
|
|
@ -0,0 +1,42 @@
|
||||||
|
interface QuillCharCounterProps {
|
||||||
|
currentCount: number;
|
||||||
|
maxLength?: number;
|
||||||
|
minLength?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QuillCharCounter: React.FC<QuillCharCounterProps> = ({
|
||||||
|
currentCount,
|
||||||
|
maxLength,
|
||||||
|
minLength = 0
|
||||||
|
}) => {
|
||||||
|
const getStatusColor = () => {
|
||||||
|
if (currentCount > (maxLength || Infinity)) return 'text-red-500';
|
||||||
|
if (currentCount < minLength) return 'text-amber-500';
|
||||||
|
return 'text-gray-500';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`
|
||||||
|
flex items-center justify-end gap-1
|
||||||
|
px-3 py-1.5 text-sm
|
||||||
|
${getStatusColor()}
|
||||||
|
transition-colors duration-200
|
||||||
|
`}>
|
||||||
|
<span className="font-medium tabular-nums">{currentCount}</span>
|
||||||
|
{maxLength && (
|
||||||
|
<>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="tabular-nums">{maxLength}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span>字符</span>
|
||||||
|
{minLength > 0 && currentCount < minLength && (
|
||||||
|
<span className="ml-2 text-amber-500">
|
||||||
|
至少输入 {minLength} 字符
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default QuillCharCounter
|
|
@ -0,0 +1,171 @@
|
||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import Quill from 'quill';
|
||||||
|
import 'quill/dist/quill.snow.css'; // 引入默认样式
|
||||||
|
import QuillCharCounter from './QuillCharCounter';
|
||||||
|
import { defaultModules } from './constants';
|
||||||
|
|
||||||
|
interface QuillEditorProps {
|
||||||
|
value?: string;
|
||||||
|
onChange?: (content: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
|
theme?: 'snow' | 'bubble';
|
||||||
|
modules?: any;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
onFocus?: () => void;
|
||||||
|
onBlur?: () => void;
|
||||||
|
onKeyDown?: (event: KeyboardEvent) => void;
|
||||||
|
onKeyUp?: (event: KeyboardEvent) => void;
|
||||||
|
maxLength?: number;
|
||||||
|
minLength?: number;
|
||||||
|
minRows?: number;
|
||||||
|
maxRows?: number;
|
||||||
|
}
|
||||||
|
const QuillEditor: React.FC<QuillEditorProps> = ({
|
||||||
|
value = '',
|
||||||
|
onChange,
|
||||||
|
placeholder = '请输入内容...',
|
||||||
|
readOnly = false,
|
||||||
|
theme = 'snow',
|
||||||
|
modules = defaultModules,
|
||||||
|
className = '',
|
||||||
|
style = {},
|
||||||
|
onFocus,
|
||||||
|
onBlur,
|
||||||
|
onKeyDown,
|
||||||
|
onKeyUp,
|
||||||
|
maxLength,
|
||||||
|
minLength = 0,
|
||||||
|
minRows = 1,
|
||||||
|
maxRows
|
||||||
|
}) => {
|
||||||
|
const editorRef = useRef<HTMLDivElement>(null);
|
||||||
|
const quillRef = useRef<Quill | null>(null);
|
||||||
|
const isMounted = useRef(false);
|
||||||
|
const [charCount, setCharCount] = useState(0); // 添加字符计数状态
|
||||||
|
const handleTextChange = () => {
|
||||||
|
if (!quillRef.current) return;
|
||||||
|
const editor = quillRef.current;
|
||||||
|
// 获取文本并处理换行符
|
||||||
|
const text = editor.getText().replace(/\n$/, '');
|
||||||
|
const textLength = text.length;
|
||||||
|
|
||||||
|
// 处理最大长度限制
|
||||||
|
if (maxLength && textLength > maxLength) {
|
||||||
|
// 暂时移除事件监听器
|
||||||
|
editor.off('text-change', handleTextChange);
|
||||||
|
|
||||||
|
// 获取当前选区
|
||||||
|
const selection = editor.getSelection();
|
||||||
|
const delta = editor.getContents();
|
||||||
|
let length = 0;
|
||||||
|
const newDelta = delta.ops?.reduce((acc: any, op: any) => {
|
||||||
|
if (typeof op.insert === 'string') {
|
||||||
|
const remainingLength = maxLength - length;
|
||||||
|
if (length < maxLength) {
|
||||||
|
const truncatedText = op.insert.slice(0, remainingLength);
|
||||||
|
length += truncatedText.length;
|
||||||
|
acc.push({ ...op, insert: truncatedText });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
acc.push(op);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
// 更新内容
|
||||||
|
editor.setContents({ ops: newDelta } as any);
|
||||||
|
// 恢复光标位置
|
||||||
|
if (selection) {
|
||||||
|
editor.setSelection(Math.min(selection.index, maxLength));
|
||||||
|
}
|
||||||
|
// 重新计算截断后的实际长度
|
||||||
|
const finalText = editor.getText().replace(/\n$/, '');
|
||||||
|
setCharCount(finalText.length);
|
||||||
|
|
||||||
|
// 重新绑定事件监听器
|
||||||
|
editor.on('text-change', handleTextChange);
|
||||||
|
} else {
|
||||||
|
// 如果没有超出最大长度,直接更新字符计数
|
||||||
|
setCharCount(textLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange?.(quillRef.current.root.innerHTML);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editorRef.current) return;
|
||||||
|
if (!isMounted.current) {
|
||||||
|
// 初始化 Quill 编辑器
|
||||||
|
quillRef.current = new Quill(editorRef.current, {
|
||||||
|
theme,
|
||||||
|
modules,
|
||||||
|
placeholder,
|
||||||
|
readOnly,
|
||||||
|
});
|
||||||
|
// 设置初始内容
|
||||||
|
quillRef.current.root.innerHTML = value;
|
||||||
|
if (onFocus) {
|
||||||
|
quillRef.current.on(Quill.events.SELECTION_CHANGE, (range) => {
|
||||||
|
if (range) {
|
||||||
|
onFocus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (onBlur) {
|
||||||
|
quillRef.current.on(Quill.events.SELECTION_CHANGE, (range) => {
|
||||||
|
if (!range) {
|
||||||
|
onBlur();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
quillRef.current.on(Quill.events.TEXT_CHANGE, handleTextChange);
|
||||||
|
if (onKeyDown) {
|
||||||
|
quillRef.current.root.addEventListener('keydown', onKeyDown);
|
||||||
|
}
|
||||||
|
if (onKeyUp) {
|
||||||
|
quillRef.current.root.addEventListener('keyup', onKeyUp);
|
||||||
|
}
|
||||||
|
isMounted.current = true;
|
||||||
|
}
|
||||||
|
}, [theme, modules, placeholder, readOnly, onFocus, onBlur, onKeyDown, onKeyUp, maxLength, minLength]); // 添加所有相关的依赖
|
||||||
|
useEffect(() => {
|
||||||
|
if (quillRef.current) {
|
||||||
|
const editor = editorRef.current?.querySelector('.ql-editor') as HTMLElement;
|
||||||
|
if (editor) {
|
||||||
|
const lineHeight = parseInt(window.getComputedStyle(editor).lineHeight, 10);
|
||||||
|
const paddingTop = parseInt(window.getComputedStyle(editor).paddingTop, 10);
|
||||||
|
const paddingBottom = parseInt(window.getComputedStyle(editor).paddingBottom, 10);
|
||||||
|
const minHeight = lineHeight * minRows + paddingTop + paddingBottom;
|
||||||
|
editor.style.minHeight = `${minHeight}px`;
|
||||||
|
if (maxRows) {
|
||||||
|
const maxHeight = lineHeight * maxRows + paddingTop + paddingBottom;
|
||||||
|
editor.style.maxHeight = `${maxHeight}px`;
|
||||||
|
editor.style.overflowY = 'auto';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [minRows, maxRows, quillRef.current]);
|
||||||
|
|
||||||
|
// 监听 value 属性变化
|
||||||
|
useEffect(() => {
|
||||||
|
if (quillRef.current && value !== quillRef.current.root.innerHTML) {
|
||||||
|
quillRef.current.root.innerHTML = value;
|
||||||
|
}
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`quill-editor-container ${className}`} style={style}>
|
||||||
|
<div ref={editorRef} />
|
||||||
|
{(maxLength || minLength > 0) && (
|
||||||
|
<QuillCharCounter
|
||||||
|
currentCount={charCount}
|
||||||
|
maxLength={maxLength}
|
||||||
|
minLength={minLength}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default QuillEditor;
|
|
@ -0,0 +1,11 @@
|
||||||
|
export const defaultModules = {
|
||||||
|
toolbar: [
|
||||||
|
[{ 'header': [1, 2, 3, 4, 5, 6, false] }],
|
||||||
|
['bold', 'italic', 'underline', 'strike'],
|
||||||
|
[{ 'list': 'ordered' }, { 'list': 'bullet' }],
|
||||||
|
[{ 'color': [] }, { 'background': [] }],
|
||||||
|
[{ 'align': [] }],
|
||||||
|
['link', 'image'],
|
||||||
|
['clean']
|
||||||
|
]
|
||||||
|
};
|
|
@ -4,22 +4,29 @@ import { cn } from '@web/src/utils/classname';
|
||||||
import { LoadingOutlined } from '@ant-design/icons';
|
import { LoadingOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
export interface ButtonProps extends Omit<HTMLMotionProps<"button">, keyof React.ButtonHTMLAttributes<HTMLButtonElement>> {
|
export interface ButtonProps extends Omit<HTMLMotionProps<"button">, keyof React.ButtonHTMLAttributes<HTMLButtonElement>> {
|
||||||
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger';
|
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger' | 'success' | 'warning' | 'info' | 'light' | 'dark' |
|
||||||
size?: 'sm' | 'md' | 'lg';
|
'soft-primary' | 'soft-secondary' | 'soft-danger' | 'soft-success' | 'soft-warning' | 'soft-info' |
|
||||||
|
'ghost-primary' | 'ghost-secondary' | 'ghost-danger' | 'ghost-success' | 'ghost-warning' | 'ghost-info';
|
||||||
|
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'icon';
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
leftIcon?: React.ReactNode;
|
leftIcon?: React.ReactNode;
|
||||||
rightIcon?: React.ReactNode;
|
rightIcon?: React.ReactNode;
|
||||||
fullWidth?: boolean;
|
fullWidth?: boolean;
|
||||||
|
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
||||||
|
elevation?: 'none' | 'sm' | 'md' | 'lg';
|
||||||
|
glassmorphism?: boolean;
|
||||||
|
animated?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
// 添加常用的按钮事件处理器
|
title?: string;
|
||||||
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
onMouseEnter?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
onMouseEnter?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
onMouseLeave?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
onMouseLeave?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
onFocus?: (event: React.FocusEvent<HTMLButtonElement>) => void;
|
onFocus?: (event: React.FocusEvent<HTMLButtonElement>) => void;
|
||||||
onBlur?: (event: React.FocusEvent<HTMLButtonElement>) => void;
|
onBlur?: (event: React.FocusEvent<HTMLButtonElement>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
|
@ -32,68 +39,181 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
rightIcon,
|
rightIcon,
|
||||||
children,
|
children,
|
||||||
fullWidth,
|
fullWidth,
|
||||||
|
rounded = 'md',
|
||||||
|
elevation = "none",
|
||||||
|
glassmorphism = false,
|
||||||
|
animated = true,
|
||||||
|
title,
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) => {
|
) => {
|
||||||
const variants = {
|
const variants = {
|
||||||
primary: 'bg-blue-600 text-white hover:bg-blue-700 shadow-sm disabled:bg-gray-400',
|
primary: 'bg-blue-600 text-white hover:bg-blue-700 disabled:bg-gray-400',
|
||||||
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200 disabled:bg-gray-200 disabled:text-gray-500',
|
secondary: 'bg-gray-600 text-white hover:bg-gray-700 disabled:bg-gray-400',
|
||||||
outline: 'border-2 border-gray-300 hover:bg-gray-50 disabled:border-gray-200 disabled:text-gray-400',
|
outline: 'border-2 bg-transparent hover:bg-gray-50 ',
|
||||||
ghost: 'hover:bg-gray-100 disabled:bg-transparent disabled:text-gray-400',
|
ghost: 'bg-transparent hover:bg-gray-50 ',
|
||||||
danger: 'bg-red-600 text-white hover:bg-red-700 disabled:bg-gray-400',
|
danger: 'bg-red-600 text-white hover:bg-red-700',
|
||||||
|
success: 'bg-green-600 text-white hover:bg-green-700',
|
||||||
|
warning: 'bg-yellow-600 text-white hover:bg-yellow-700',
|
||||||
|
info: 'bg-cyan-600 text-white hover:bg-cyan-700',
|
||||||
|
light: 'bg-white text-gray-900 hover:bg-gray-50 border border-gray-200',
|
||||||
|
dark: 'bg-gray-900 text-white hover:bg-gray-800',
|
||||||
|
'soft-primary': 'bg-blue-50 text-blue-600 hover:bg-blue-100 disabled:bg-gray-50 disabled:text-gray-400',
|
||||||
|
'soft-secondary': 'bg-gray-50 text-gray-600 hover:bg-gray-100 disabled:bg-gray-50 disabled:text-gray-400',
|
||||||
|
'soft-danger': 'bg-red-50 text-red-600 hover:bg-red-100 disabled:bg-gray-50 disabled:text-gray-400',
|
||||||
|
'soft-success': 'bg-green-50 text-green-600 hover:bg-green-100 disabled:bg-gray-50 disabled:text-gray-400',
|
||||||
|
'soft-warning': 'bg-yellow-50 text-yellow-600 hover:bg-yellow-100 disabled:bg-gray-50 disabled:text-gray-400',
|
||||||
|
'soft-info': 'bg-cyan-50 text-cyan-600 hover:bg-cyan-100 disabled:bg-gray-50 disabled:text-gray-400',
|
||||||
|
'ghost-primary': 'bg-transparent text-blue-600 hover:bg-blue-50',
|
||||||
|
'ghost-secondary': 'bg-transparent text-gray-600 hover:bg-gray-50',
|
||||||
|
'ghost-danger': 'bg-transparent text-red-600 hover:bg-red-50',
|
||||||
|
'ghost-success': 'bg-transparent text-green-600 hover:bg-green-50',
|
||||||
|
'ghost-warning': 'bg-transparent text-yellow-600 hover:bg-yellow-50',
|
||||||
|
'ghost-info': 'bg-transparent text-cyan-600 hover:bg-cyan-50',
|
||||||
|
};
|
||||||
|
const borderColors = {
|
||||||
|
primary: 'border-blue-600',
|
||||||
|
secondary: 'border-gray-600',
|
||||||
|
outline: 'border-gray-300 ',
|
||||||
|
ghost: 'border-transparent',
|
||||||
|
danger: 'border-red-600',
|
||||||
|
success: 'border-green-600',
|
||||||
|
warning: 'border-yellow-600',
|
||||||
|
info: 'border-cyan-600',
|
||||||
|
light: 'border-gray-200',
|
||||||
|
dark: 'border-gray-900',
|
||||||
|
'soft-primary': 'border-transparent',
|
||||||
|
'soft-secondary': 'border-transparent',
|
||||||
|
'soft-danger': 'border-transparent',
|
||||||
|
'soft-success': 'border-transparent',
|
||||||
|
'soft-warning': 'border-transparent',
|
||||||
|
'soft-info': 'border-transparent',
|
||||||
|
'ghost-primary': 'border-transparent',
|
||||||
|
'ghost-secondary': 'border-transparent',
|
||||||
|
'ghost-danger': 'border-transparent',
|
||||||
|
'ghost-success': 'border-transparent',
|
||||||
|
'ghost-warning': 'border-transparent',
|
||||||
|
'ghost-info': 'border-transparent',
|
||||||
};
|
};
|
||||||
|
|
||||||
const ringColors = {
|
const ringColors = {
|
||||||
primary: 'focus:ring-blue-500',
|
primary: 'focus:ring-blue-500/50',
|
||||||
secondary: 'focus:ring-gray-500',
|
secondary: 'focus:ring-gray-500/50',
|
||||||
outline: 'focus:ring-gray-400',
|
outline: 'focus:ring-gray-400/50',
|
||||||
ghost: 'focus:ring-gray-400',
|
ghost: 'focus:ring-gray-400/50',
|
||||||
danger: 'focus:ring-red-500',
|
danger: 'focus:ring-red-500/50',
|
||||||
|
success: 'focus:ring-green-500/50',
|
||||||
|
warning: 'focus:ring-yellow-500/50',
|
||||||
|
info: 'focus:ring-cyan-500/50',
|
||||||
|
light: 'focus:ring-gray-200/50',
|
||||||
|
dark: 'focus:ring-gray-900/50',
|
||||||
|
'soft-primary': 'focus:ring-blue-200/50',
|
||||||
|
'soft-secondary': 'focus:ring-gray-200/50',
|
||||||
|
'soft-danger': 'focus:ring-red-200/50',
|
||||||
|
'soft-success': 'focus:ring-green-200/50',
|
||||||
|
'soft-warning': 'focus:ring-yellow-200/50',
|
||||||
|
'soft-info': 'focus:ring-cyan-200/50',
|
||||||
|
'ghost-primary': 'focus:ring-blue-200/50',
|
||||||
|
'ghost-secondary': 'focus:ring-gray-200/50',
|
||||||
|
'ghost-danger': 'focus:ring-red-200/50',
|
||||||
|
'ghost-success': 'focus:ring-green-200/50',
|
||||||
|
'ghost-warning': 'focus:ring-yellow-200/50',
|
||||||
|
'ghost-info': 'focus:ring-cyan-200/50',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const sizes = {
|
const sizes = {
|
||||||
sm: 'px-3 py-1.5 text-sm',
|
xs: 'px-2.5 py-1.5 text-xs',
|
||||||
|
sm: 'px-3 py-2 text-sm',
|
||||||
md: 'px-4 py-2 text-base',
|
md: 'px-4 py-2 text-base',
|
||||||
lg: 'px-6 py-3 text-lg',
|
lg: 'px-6 py-3 text-lg',
|
||||||
|
xl: 'px-8 py-4 text-xl',
|
||||||
|
icon: 'p-2', // 修改为等边距的 padding
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const iconSizes = {
|
||||||
|
xs: 'h-3 w-3',
|
||||||
|
sm: 'h-4 w-4',
|
||||||
|
md: 'h-5 w-5',
|
||||||
|
lg: 'h-6 w-6',
|
||||||
|
xl: 'h-7 w-7',
|
||||||
|
icon: 'h-4 w-4',
|
||||||
|
};
|
||||||
|
|
||||||
|
const roundedStyles = {
|
||||||
|
none: 'rounded-none',
|
||||||
|
sm: 'rounded-sm',
|
||||||
|
md: 'rounded-md',
|
||||||
|
lg: 'rounded-lg',
|
||||||
|
xl: 'rounded-xl',
|
||||||
|
full: 'rounded-full',
|
||||||
|
};
|
||||||
|
|
||||||
|
const elevationStyles = {
|
||||||
|
none: '',
|
||||||
|
sm: 'shadow-sm',
|
||||||
|
md: 'shadow-md',
|
||||||
|
lg: 'shadow-lg',
|
||||||
|
};
|
||||||
|
|
||||||
|
const isIconOnly = size === 'icon' || (!children && (leftIcon || rightIcon));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.button
|
<button
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
title={title}
|
||||||
disabled={disabled || isLoading}
|
disabled={disabled || isLoading}
|
||||||
className={cn(
|
className={cn(
|
||||||
// Base styles
|
// Base styles
|
||||||
'inline-flex items-center justify-center rounded-md font-medium',
|
'relative inline-flex items-center justify-center font-medium',
|
||||||
'transition-all duration-200 ease-in-out',
|
'transition-all duration-200 ease-in-out',
|
||||||
'focus:outline-none focus:ring-2 focus:ring-offset-2',
|
'focus:outline-none focus:ring-2 focus:ring-offset-2',
|
||||||
'disabled:opacity-75 disabled:cursor-not-allowed disabled:hover:scale-100',
|
'disabled:opacity-75 disabled:cursor-not-allowed',
|
||||||
// Variant styles
|
// Variant styles
|
||||||
variants[variant],
|
variants[variant],
|
||||||
|
borderColors[variant],
|
||||||
|
// Conditional styles
|
||||||
|
glassmorphism && 'backdrop-blur-sm bg-opacity-80',
|
||||||
// Ring color based on variant
|
// Ring color based on variant
|
||||||
ringColors[variant],
|
ringColors[variant],
|
||||||
// Size styles
|
// Size and shape styles
|
||||||
sizes[size],
|
isIconOnly ? sizes.icon : sizes[size],
|
||||||
|
roundedStyles[rounded],
|
||||||
|
elevationStyles[elevation],
|
||||||
// Full width
|
// Full width
|
||||||
fullWidth && 'w-full',
|
fullWidth && 'w-full',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
whileTap={{ scale: 0.98 }}
|
|
||||||
whileHover={{ scale: 1.01 }}
|
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<LoadingOutlined className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
|
<LoadingOutlined
|
||||||
|
className={cn(
|
||||||
|
"animate-spin",
|
||||||
|
!isIconOnly && "mr-2",
|
||||||
|
iconSizes[size]
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{!isLoading && leftIcon && (
|
{!isLoading && leftIcon && (
|
||||||
<span className="mr-2 inline-flex">{leftIcon}</span>
|
<span className={cn(
|
||||||
|
"inline-flex",
|
||||||
|
!isIconOnly && "mr-2",
|
||||||
|
iconSizes[size]
|
||||||
|
)}>
|
||||||
|
{leftIcon}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
{children}
|
{children}
|
||||||
{!isLoading && rightIcon && (
|
{!isLoading && rightIcon && !isIconOnly && (
|
||||||
<span className="ml-2 inline-flex">{rightIcon}</span>
|
<span className={cn(
|
||||||
|
"inline-flex ml-2",
|
||||||
|
iconSizes[size]
|
||||||
|
)}>
|
||||||
|
{rightIcon}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</motion.button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,22 +1,32 @@
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { ExclamationCircleIcon } from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
const ANIMATIONS = {
|
const ANIMATIONS = {
|
||||||
|
tooltip: {
|
||||||
error: {
|
initial: { opacity: 0, y: 10 },
|
||||||
initial: { opacity: 0, y: -8 },
|
animate: { opacity: 1, y: 0 },
|
||||||
animate: { opacity: 1, y: 0 },
|
exit: { opacity: 0, y: 10 },
|
||||||
exit: { opacity: 0, y: -8 },
|
transition: { duration: 0.2 }
|
||||||
transition: { duration: 0.2 }
|
}
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function FormError({ error }: { error: string }) {
|
export default function FormError({ error }: { error: string }) {
|
||||||
return <AnimatePresence>
|
if (!error) return null;
|
||||||
{error && (
|
|
||||||
<motion.span {...ANIMATIONS.error}
|
return (
|
||||||
className="absolute left-0 top-full mt-1 text-sm font-medium text-red-500"
|
<div className="group relative">
|
||||||
>
|
<ExclamationCircleIcon className="h-5 w-5 text-red-500" />
|
||||||
{error}
|
<AnimatePresence>
|
||||||
</motion.span>
|
<motion.div
|
||||||
)}
|
{...ANIMATIONS.tooltip}
|
||||||
</AnimatePresence>
|
className="absolute bottom-full right-0 mb-2 hidden rounded-md bg-red-50 p-2 text-sm
|
||||||
|
text-red-500 shadow-lg group-hover:block whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
{/* 添加小三角形 */}
|
||||||
|
<div className="absolute -bottom-1 right-2 h-2 w-2 rotate-45 bg-red-50" />
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
|
@ -1,12 +1,15 @@
|
||||||
import { useFormContext } from 'react-hook-form';
|
import { useFormContext } from 'react-hook-form';
|
||||||
import { useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { CheckIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
import { CheckIcon, PencilIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||||
import FormError from './FormError';
|
import FormError from './FormError';
|
||||||
|
import { Button } from '../element/Button';
|
||||||
export interface FormInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement>, 'type'> {
|
export interface FormInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement>, 'type'> {
|
||||||
name: string;
|
name: string;
|
||||||
label: string;
|
label?: string;
|
||||||
type?: 'text' | 'textarea' | 'password' | 'email' | 'number' | 'tel' | 'url' | 'search' | 'date' | 'time' | 'datetime-local';
|
type?: 'text' | 'textarea' | 'password' | 'email' | 'number' | 'tel' | 'url' | 'search' | 'date' | 'time' | 'datetime-local';
|
||||||
rows?: number;
|
rows?: number;
|
||||||
|
viewMode?: boolean;
|
||||||
|
|
||||||
}
|
}
|
||||||
export function FormInput({
|
export function FormInput({
|
||||||
name,
|
name,
|
||||||
|
@ -14,9 +17,12 @@ export function FormInput({
|
||||||
type = 'text',
|
type = 'text',
|
||||||
rows = 4,
|
rows = 4,
|
||||||
className,
|
className,
|
||||||
|
viewMode = false, // 默认为编辑模式
|
||||||
...restProps
|
...restProps
|
||||||
}: FormInputProps) {
|
}: FormInputProps) {
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
const [isEditing, setIsEditing] = useState(!viewMode);
|
||||||
|
const inputWrapper = useRef<HTMLDivElement>(null);
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
|
@ -27,57 +33,116 @@ export function FormInput({
|
||||||
const handleBlur = async () => {
|
const handleBlur = async () => {
|
||||||
setIsFocused(false);
|
setIsFocused(false);
|
||||||
await trigger(name); // Trigger validation for this field
|
await trigger(name); // Trigger validation for this field
|
||||||
|
if (viewMode) {
|
||||||
|
setIsEditing(false)
|
||||||
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
const value = watch(name);
|
const value = watch(name);
|
||||||
const error = errors[name]?.message as string;
|
const error = errors[name]?.message as string;
|
||||||
const isValid = value && !error;
|
const isValid = value && !error;
|
||||||
const inputClasses = `
|
const inputClasses = `
|
||||||
w-full rounded-md border bg-white p-2 pr-8 outline-none shadow-sm
|
w-full rounded-lg border bg-white px-4 py-2 outline-none
|
||||||
transition-all duration-300 ease-out placeholder:text-gray-400
|
transition-all duration-200 ease-out placeholder:text-gray-400
|
||||||
${error ? 'border-red-500 focus:border-red-500 focus:ring-red-200' : 'border-gray-300 focus:border-blue-500 focus:ring-blue-200'}
|
${error
|
||||||
|
? 'border-red-500 focus:border-red-500 focus:ring-2 focus:ring-red-200'
|
||||||
|
: 'border-gray-200 hover:border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100'
|
||||||
|
}
|
||||||
${isFocused ? 'ring-2 ring-opacity-50' : ''}
|
${isFocused ? 'ring-2 ring-opacity-50' : ''}
|
||||||
${className || ''}
|
${className || ''}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const viewModeClasses = `
|
||||||
|
w-full text-gray-700 hover:text-blue-600 min-h-[48px]
|
||||||
|
flex items-center gap-2 relative cursor-pointer group
|
||||||
|
transition-all duration-200 ease-out select-none
|
||||||
|
`;
|
||||||
|
|
||||||
const InputElement = type === 'textarea' ? 'textarea' : 'input';
|
const InputElement = type === 'textarea' ? 'textarea' : 'input';
|
||||||
|
|
||||||
|
const renderViewMode = () => (
|
||||||
|
<div
|
||||||
|
className={viewModeClasses}
|
||||||
|
onClick={() => setIsEditing(true)}
|
||||||
|
>
|
||||||
|
<span className="font-medium ">
|
||||||
|
{value || <span className="text-gray-400">点击编辑</span>}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsEditing(true);
|
||||||
|
}}
|
||||||
|
size='xs'
|
||||||
|
variant='ghost'
|
||||||
|
leftIcon={<PencilIcon />}
|
||||||
|
className="absolute -right-10 opacity-0 group-hover:opacity-100 "
|
||||||
|
>
|
||||||
|
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderEditMode = () => (
|
||||||
|
<div className="relative">
|
||||||
|
<InputElement
|
||||||
|
{...register(name)}
|
||||||
|
type={type !== 'textarea' ? type : undefined}
|
||||||
|
rows={type === 'textarea' ? rows : undefined}
|
||||||
|
{...restProps}
|
||||||
|
onFocus={() => setIsFocused(true)}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
className={inputClasses}
|
||||||
|
aria-label={label}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="
|
||||||
|
absolute right-3 top-1/2 -translate-y-1/2
|
||||||
|
flex items-center space-x-2
|
||||||
|
">
|
||||||
|
{value && isFocused && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="
|
||||||
|
p-1.5 rounded-full text-gray-400
|
||||||
|
hover:text-gray-600 hover:bg-gray-100
|
||||||
|
transition-all duration-200 ease-out
|
||||||
|
"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => setValue(name, '')}
|
||||||
|
aria-label={`清除${label}`}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<XMarkIcon className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isValid && (
|
||||||
|
<CheckIcon className="
|
||||||
|
text-green-500 w-4 h-4
|
||||||
|
animate-fade-in duration-200
|
||||||
|
" />
|
||||||
|
)}
|
||||||
|
{error && <FormError error={error} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div ref={inputWrapper} className="flex flex-col gap-2">
|
||||||
<div className="flex justify-between">
|
{label && <div className="flex justify-between items-center px-0.5">
|
||||||
<label className="block text-sm font-medium text-gray-700">{label}</label>
|
<label className="
|
||||||
|
text-sm font-medium text-gray-700
|
||||||
|
transition-colors duration-200
|
||||||
|
group-focus-within:text-blue-600
|
||||||
|
">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
{restProps.maxLength && (
|
{restProps.maxLength && (
|
||||||
<span className="text-sm text-gray-500">
|
<span className="text-sm text-gray-500">
|
||||||
{value?.length || 0}/{restProps.maxLength}
|
{value?.length || 0}/{restProps.maxLength}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>}
|
||||||
<div className="relative">
|
{viewMode && !isEditing ? renderViewMode() : renderEditMode()}
|
||||||
<InputElement
|
|
||||||
{...register(name)}
|
|
||||||
type={type !== 'textarea' ? type : undefined}
|
|
||||||
rows={type === 'textarea' ? rows : undefined}
|
|
||||||
{...restProps}
|
|
||||||
|
|
||||||
onFocus={() => setIsFocused(true)}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
className={inputClasses}
|
|
||||||
aria-label={label}
|
|
||||||
/>
|
|
||||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center space-x-1">
|
|
||||||
{value && isFocused && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
|
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
|
||||||
onClick={() => setValue(name, '')}
|
|
||||||
aria-label={`清除${label}`}
|
|
||||||
tabIndex={-1}
|
|
||||||
>
|
|
||||||
<XMarkIcon className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{isValid && <CheckIcon className="text-green-500 w-4 h-4" />}
|
|
||||||
</div>
|
|
||||||
<FormError error={error}></FormError>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { useFormContext, Controller } from 'react-hook-form';
|
||||||
|
import FormError from './FormError';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import QuillEditor from '../editor/quill/QuillEditor';
|
||||||
|
|
||||||
|
export interface FormQuillInputProps {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
placeholder?: string;
|
||||||
|
maxLength?: number;
|
||||||
|
minLength?: number;
|
||||||
|
className?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
|
maxRows?: number;
|
||||||
|
minRows?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormQuillInput({
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
placeholder,
|
||||||
|
maxLength,
|
||||||
|
minLength,
|
||||||
|
className,
|
||||||
|
readOnly = false,
|
||||||
|
maxRows = 10,
|
||||||
|
minRows = 4
|
||||||
|
}: FormQuillInputProps) {
|
||||||
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
formState: { errors },
|
||||||
|
trigger,
|
||||||
|
} = useFormContext();
|
||||||
|
|
||||||
|
const error = errors[name]?.message as string;
|
||||||
|
|
||||||
|
const handleBlur = async () => {
|
||||||
|
|
||||||
|
setIsFocused(false);
|
||||||
|
await trigger(name);
|
||||||
|
};
|
||||||
|
console.log(isFocused)
|
||||||
|
const containerClasses = `
|
||||||
|
w-full rounded-md border bg-white shadow-sm
|
||||||
|
transition-all duration-300 ease-out
|
||||||
|
${isFocused
|
||||||
|
? `ring-2 ring-opacity-50 ${error ? 'ring-red-200 border-red-500' : 'ring-blue-200 border-blue-500'}`
|
||||||
|
: 'border-gray-300'
|
||||||
|
}
|
||||||
|
${className}
|
||||||
|
`.trim()
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700">{label}</label>
|
||||||
|
<div className={containerClasses}>
|
||||||
|
<Controller
|
||||||
|
name={name}
|
||||||
|
control={control}
|
||||||
|
render={({ field: { value, onChange } }) => (
|
||||||
|
<QuillEditor
|
||||||
|
minRows={minRows}
|
||||||
|
maxRows={maxRows}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
placeholder={placeholder}
|
||||||
|
maxLength={maxLength}
|
||||||
|
minLength={minLength}
|
||||||
|
readOnly={readOnly}
|
||||||
|
onFocus={() => setIsFocused(true)}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<FormError error={error} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -14,6 +14,7 @@ interface FormSelectProps {
|
||||||
label: string;
|
label: string;
|
||||||
options: Option[];
|
options: Option[];
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const ANIMATIONS = {
|
const ANIMATIONS = {
|
||||||
|
@ -31,7 +32,7 @@ const ANIMATIONS = {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export function FormSelect({ name, label, options, placeholder = '请选择' }: FormSelectProps) {
|
export function FormSelect({ name, label, options, placeholder = '请选择', className }: FormSelectProps) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
@ -56,16 +57,17 @@ export function FormSelect({ name, label, options, placeholder = '请选择' }:
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const getInputClasses = (hasError: boolean) => `
|
const containerClasses = `
|
||||||
w-full rounded-md border bg-white
|
w-full rounded-md border bg-white shadow-sm
|
||||||
transition-all duration-300 ease-out
|
transition-all duration-300 ease-out
|
||||||
p-2 pr-8 outline-none cursor-pointer
|
p-2 pr-8 outline-none cursor-pointer
|
||||||
${hasError ? 'border-red-500 focus:border-red-500 focus:ring-red-200'
|
${isOpen
|
||||||
: 'border-gray-300 focus:border-blue-500 focus:ring-blue-200'}
|
? `ring-2 ring-opacity-50 ${error ? 'ring-red-200 border-red-500' : 'ring-blue-200 border-blue-500'}`
|
||||||
${isOpen ? 'ring-2 ring-opacity-50' : ''}
|
: 'border-gray-300'
|
||||||
placeholder:text-gray-400 shadow-sm
|
}
|
||||||
`;
|
placeholder:text-gray-400
|
||||||
|
${className}
|
||||||
|
`;
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
@ -74,7 +76,7 @@ export function FormSelect({ name, label, options, placeholder = '请选择' }:
|
||||||
<div className="relative" ref={dropdownRef}>
|
<div className="relative" ref={dropdownRef}>
|
||||||
<input type="hidden" {...register(name)} />
|
<input type="hidden" {...register(name)} />
|
||||||
<div
|
<div
|
||||||
className={getInputClasses(!!error)}
|
className={containerClasses}
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
>
|
>
|
||||||
{selectedOption?.label || <span className="text-gray-400">{placeholder}</span>}
|
{selectedOption?.label || <span className="text-gray-400">{placeholder}</span>}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { EmptyStateIllustration } from "../EmptyStateIllustration";
|
import { EmptyStateIllustration } from "../../presentation/EmptyStateIllustration";
|
||||||
|
|
||||||
interface EmptyStateProps {
|
interface EmptyStateProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
|
|
|
@ -0,0 +1,211 @@
|
||||||
|
import { useState, useCallback, useRef, memo } from 'react'
|
||||||
|
import { CloudArrowUpIcon, XMarkIcon, DocumentIcon, ExclamationCircleIcon } from '@heroicons/react/24/outline'
|
||||||
|
import * as tus from 'tus-js-client'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { toast } from 'react-hot-toast'
|
||||||
|
interface FileUploaderProps {
|
||||||
|
endpoint?: string
|
||||||
|
onSuccess?: (url: string) => void
|
||||||
|
onError?: (error: Error) => void
|
||||||
|
maxSize?: number
|
||||||
|
allowedTypes?: string[]
|
||||||
|
placeholder?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileItem = memo(({ file, progress, onRemove }: {
|
||||||
|
file: File
|
||||||
|
progress?: number
|
||||||
|
onRemove: (name: string) => void
|
||||||
|
}) => (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -20 }}
|
||||||
|
className="group flex items-center p-4 bg-white rounded-xl border border-gray-100 shadow-sm hover:shadow-md transition-all duration-200"
|
||||||
|
>
|
||||||
|
<DocumentIcon className="w-8 h-8 text-blue-500/80" />
|
||||||
|
<div className="ml-3 flex-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm font-medium text-gray-900 truncate max-w-[200px]">{file.name}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => onRemove(file.name)}
|
||||||
|
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-gray-100 rounded-full"
|
||||||
|
aria-label={`Remove ${file.name}`}
|
||||||
|
>
|
||||||
|
<XMarkIcon className="w-5 h-5 text-gray-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{progress !== undefined && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="bg-gray-100 rounded-full h-1.5 overflow-hidden">
|
||||||
|
<motion.div
|
||||||
|
className="bg-blue-500 h-1.5 rounded-full"
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={{ width: `${progress}%` }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-500 mt-1">{progress}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))
|
||||||
|
|
||||||
|
export default function FileUploader({
|
||||||
|
endpoint='',
|
||||||
|
onSuccess,
|
||||||
|
onError,
|
||||||
|
maxSize = 100,
|
||||||
|
placeholder = '点击或拖拽文件到这里上传',
|
||||||
|
allowedTypes = ['*/*']
|
||||||
|
}: FileUploaderProps) {
|
||||||
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
|
const [files, setFiles] = useState<File[]>([])
|
||||||
|
const [progress, setProgress] = useState<{ [key: string]: number }>({})
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const handleError = useCallback((error: Error) => {
|
||||||
|
toast.error(error.message)
|
||||||
|
onError?.(error)
|
||||||
|
}, [onError])
|
||||||
|
|
||||||
|
const handleDrag = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
if (e.type === 'dragenter' || e.type === 'dragover') {
|
||||||
|
setIsDragging(true)
|
||||||
|
} else if (e.type === 'dragleave') {
|
||||||
|
setIsDragging(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const validateFile = useCallback((file: File) => {
|
||||||
|
if (file.size > maxSize * 1024 * 1024) {
|
||||||
|
throw new Error(`文件大小不能超过 ${maxSize}MB`)
|
||||||
|
}
|
||||||
|
if (!allowedTypes.includes('*/*') && !allowedTypes.includes(file.type)) {
|
||||||
|
throw new Error(`不支持的文件类型。支持的类型: ${allowedTypes.join(', ')}`)
|
||||||
|
}
|
||||||
|
}, [maxSize, allowedTypes])
|
||||||
|
|
||||||
|
const uploadFile = async (file: File) => {
|
||||||
|
try {
|
||||||
|
validateFile(file)
|
||||||
|
|
||||||
|
const upload = new tus.Upload(file, {
|
||||||
|
endpoint,
|
||||||
|
retryDelays: [0, 3000, 5000, 10000, 20000],
|
||||||
|
metadata: {
|
||||||
|
filename: file.name,
|
||||||
|
filetype: file.type
|
||||||
|
},
|
||||||
|
onError: handleError,
|
||||||
|
onProgress: (bytesUploaded, bytesTotal) => {
|
||||||
|
const percentage = (bytesUploaded / bytesTotal * 100).toFixed(2)
|
||||||
|
setProgress(prev => ({
|
||||||
|
...prev,
|
||||||
|
[file.name]: parseFloat(percentage)
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
onSuccess?.(upload.url || '')
|
||||||
|
setProgress(prev => {
|
||||||
|
const newProgress = { ...prev }
|
||||||
|
delete newProgress[file.name]
|
||||||
|
return newProgress
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
upload.start()
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error as Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setIsDragging(false)
|
||||||
|
|
||||||
|
const droppedFiles = Array.from(e.dataTransfer.files)
|
||||||
|
setFiles(prev => [...prev, ...droppedFiles])
|
||||||
|
droppedFiles.forEach(uploadFile)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.target.files) {
|
||||||
|
const selectedFiles = Array.from(e.target.files)
|
||||||
|
setFiles(prev => [...prev, ...selectedFiles])
|
||||||
|
selectedFiles.forEach(uploadFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeFile = (fileName: string) => {
|
||||||
|
setFiles(prev => prev.filter(file => file.name !== fileName))
|
||||||
|
setProgress(prev => {
|
||||||
|
const newProgress = { ...prev }
|
||||||
|
delete newProgress[fileName]
|
||||||
|
return newProgress
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full space-y-4">
|
||||||
|
<motion.div
|
||||||
|
className={`relative border-2 border-dashed rounded-xl p-8 transition-all
|
||||||
|
${isDragging
|
||||||
|
? 'border-blue-500 bg-blue-50/50 ring-4 ring-blue-100'
|
||||||
|
: 'border-gray-200 hover:border-blue-400 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
onDragEnter={handleDrag}
|
||||||
|
onDragLeave={handleDrag}
|
||||||
|
onDragOver={handleDrag}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
aria-label="文件上传区域"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
className="hidden"
|
||||||
|
multiple
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
accept={allowedTypes.join(',')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center justify-center space-y-4">
|
||||||
|
<motion.div
|
||||||
|
animate={{ y: isDragging ? -10 : 0 }}
|
||||||
|
transition={{ type: "spring", stiffness: 300, damping: 20 }}
|
||||||
|
>
|
||||||
|
<CloudArrowUpIcon className="w-16 h-16 text-blue-500/80" />
|
||||||
|
</motion.div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-gray-500">{placeholder}</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400 flex items-center gap-1">
|
||||||
|
<ExclamationCircleIcon className="w-4 h-4" />
|
||||||
|
支持的文件类型: {allowedTypes.join(', ')} · 最大文件大小: {maxSize}MB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{files.map(file => (
|
||||||
|
<FileItem
|
||||||
|
key={file.name}
|
||||||
|
file={file}
|
||||||
|
progress={progress[file.name]}
|
||||||
|
onRemove={removeFile}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,3 +0,0 @@
|
||||||
export default function CourseContentForm() {
|
|
||||||
return <>content</>
|
|
||||||
}
|
|
|
@ -30,7 +30,7 @@ const CourseEditorContext = createContext<CourseEditorContextType | null>(null);
|
||||||
export function CourseFormProvider({ children, editId }: CourseFormProviderProps) {
|
export function CourseFormProvider({ children, editId }: CourseFormProviderProps) {
|
||||||
const { create, update } = useCourse()
|
const { create, update } = useCourse()
|
||||||
const { data: course }: { data: CourseDto } = api.course.findFirst.useQuery({ where: { id: editId } }, { enabled: Boolean(editId) })
|
const { data: course }: { data: CourseDto } = api.course.findFirst.useQuery({ where: { id: editId } }, { enabled: Boolean(editId) })
|
||||||
|
const navigate = useNavigate()
|
||||||
const methods = useForm<CourseFormData>({
|
const methods = useForm<CourseFormData>({
|
||||||
resolver: zodResolver(courseSchema),
|
resolver: zodResolver(courseSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
|
@ -39,7 +39,6 @@ export function CourseFormProvider({ children, editId }: CourseFormProviderProps
|
||||||
objectives: []
|
objectives: []
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const navigate = useNavigate()
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (course) {
|
if (course) {
|
||||||
const formData = {
|
const formData = {
|
||||||
|
@ -57,6 +56,7 @@ export function CourseFormProvider({ children, editId }: CourseFormProviderProps
|
||||||
}, [course, methods]);
|
}, [course, methods]);
|
||||||
const onSubmit: SubmitHandler<CourseFormData> = async (data: CourseFormData) => {
|
const onSubmit: SubmitHandler<CourseFormData> = async (data: CourseFormData) => {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
if (editId) {
|
if (editId) {
|
||||||
await update.mutateAsync({
|
await update.mutateAsync({
|
||||||
where: { id: editId },
|
where: { id: editId },
|
||||||
|
@ -72,17 +72,17 @@ export function CourseFormProvider({ children, editId }: CourseFormProviderProps
|
||||||
...data
|
...data
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
navigate(`/course/${result.id}/editor`, { replace: true })
|
navigate(`/course/${result.id}/editor`, { replace: true })
|
||||||
toast.success('课程创建成功!');
|
toast.success('课程创建成功!');
|
||||||
}
|
}
|
||||||
|
methods.reset(data);
|
||||||
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error submitting form:', error);
|
console.error('Error submitting form:', error);
|
||||||
toast.error('操作失败,请重试!');
|
toast.error('操作失败,请重试!');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CourseEditorContext.Provider value={{ onSubmit, editId, course }}>
|
<CourseEditorContext.Provider value={{ onSubmit, editId, course }}>
|
||||||
<FormProvider {...methods}>
|
<FormProvider {...methods}>
|
|
@ -1,25 +1,21 @@
|
||||||
import { SubmitHandler, useFormContext } from 'react-hook-form';
|
import { SubmitHandler, useFormContext } from 'react-hook-form';
|
||||||
|
|
||||||
import { CourseFormData, useCourseEditor } from './CourseEditorContext';
|
|
||||||
import { CourseLevel, CourseLevelLabel } from '@nice/common';
|
import { CourseLevel, CourseLevelLabel } from '@nice/common';
|
||||||
import { FormInput } from '@web/src/components/common/form/FormInput';
|
import { FormInput } from '@web/src/components/common/form/FormInput';
|
||||||
import { FormSelect } from '@web/src/components/common/form/FormSelect';
|
import { FormSelect } from '@web/src/components/common/form/FormSelect';
|
||||||
import { FormArrayField } from '@web/src/components/common/form/FormArrayField';
|
import { FormArrayField } from '@web/src/components/common/form/FormArrayField';
|
||||||
import { convertToOptions } from '@nice/client';
|
import { convertToOptions } from '@nice/client';
|
||||||
|
import { CourseFormData } from '../context/CourseEditorContext';
|
||||||
|
import QuillEditor from '@web/src/components/common/editor/quill/QuillEditor';
|
||||||
|
import { FormQuillInput } from '@web/src/components/common/form/FormQuillInput';
|
||||||
|
|
||||||
export function CourseBasicForm() {
|
export function CourseBasicForm() {
|
||||||
const { register, formState: { errors }, watch, handleSubmit } = useFormContext<CourseFormData>();
|
const { register, formState: { errors }, watch, handleSubmit } = useFormContext<CourseFormData>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="max-w-2xl mx-auto space-y-6 p-6">
|
<form className="max-w-2xl mx-auto space-y-6 p-6">
|
||||||
<FormInput maxLength={20} name="title" label="课程标题" placeholder="请输入课程标题" />
|
<FormInput viewMode maxLength={20} name="title" label="课程标题" placeholder="请输入课程标题" />
|
||||||
<FormInput maxLength={10} name="subTitle" label="课程副标题" placeholder="请输入课程副标题" />
|
<FormInput maxLength={10} name="subTitle" label="课程副标题" placeholder="请输入课程副标题" />
|
||||||
<FormInput
|
<FormQuillInput maxLength={400} name="description" label="课程描述" placeholder="请输入课程描述"></FormQuillInput>
|
||||||
name="description"
|
|
||||||
label="课程描述"
|
|
||||||
type="textarea"
|
|
||||||
placeholder="请输入课程描述"
|
|
||||||
/>
|
|
||||||
<FormSelect name='level' label='难度等级' options={convertToOptions(CourseLevelLabel)}></FormSelect>
|
<FormSelect name='level' label='难度等级' options={convertToOptions(CourseLevelLabel)}></FormSelect>
|
||||||
|
|
||||||
</form>
|
</form>
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { Button } from "@web/src/components/common/element/Button";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { PlusIcon, } from "@heroicons/react/24/outline";
|
||||||
|
import { Section, UUIDGenerator } from "@nice/common";
|
||||||
|
import SectionFormList from "./SectionFormList";
|
||||||
|
|
||||||
|
const CourseContentFormHeader = () =>
|
||||||
|
<div className="mb-8 bg-blue-50 p-4 rounded-lg">
|
||||||
|
<h2 className="text-xl font-semibold text-blue-800 mb-2">创建您的课程大纲</h2>
|
||||||
|
<p className="text-blue-600">
|
||||||
|
通过组织清晰的章节和课时,帮助学员更好地学习。建议:
|
||||||
|
</p>
|
||||||
|
<ul className="mt-2 text-blue-600 list-disc list-inside">
|
||||||
|
<li>将相关内容组织到章节中</li>
|
||||||
|
<li>每个章节建议包含 3-7 个课时</li>
|
||||||
|
<li>课时可以是视频、文章或测验</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
const CourseSectionEmpty = () => (
|
||||||
|
<div className="text-center py-12 bg-gray-50 rounded-lg border-2 border-dashed">
|
||||||
|
<div className="text-gray-500">
|
||||||
|
<PlusIcon className="mx-auto h-12 w-12 mb-4" />
|
||||||
|
<h3 className="text-lg font-medium mb-2">开始创建您的课程内容</h3>
|
||||||
|
<p className="text-sm">点击下方按钮添加第一个章节</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default function CourseContentForm() {
|
||||||
|
const [sections, setSections] = useState<Section[]>([]);
|
||||||
|
const addSection = () => {
|
||||||
|
setSections(prev => [...prev, {
|
||||||
|
id: UUIDGenerator.generate(),
|
||||||
|
title: '新章节',
|
||||||
|
lectures: []
|
||||||
|
}]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto p-6">
|
||||||
|
<CourseContentFormHeader />
|
||||||
|
<div className="space-y-4">
|
||||||
|
{sections.length === 0 ? (
|
||||||
|
<CourseSectionEmpty />
|
||||||
|
) : (
|
||||||
|
<SectionFormList
|
||||||
|
sections={sections}
|
||||||
|
setSections={setSections}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
size="lg"
|
||||||
|
rounded="xl"
|
||||||
|
onClick={addSection}
|
||||||
|
leftIcon={<PlusIcon></PlusIcon>}
|
||||||
|
className="mt-4 bg-blue-500 hover:bg-blue-600 text-white"
|
||||||
|
>
|
||||||
|
添加新章节
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
import { FormArrayField } from "@web/src/components/common/form/FormArrayField";
|
import { FormArrayField } from "@web/src/components/common/form/FormArrayField";
|
||||||
import { CourseFormData } from "./CourseEditorContext";
|
|
||||||
import { useFormContext } from "react-hook-form";
|
import { useFormContext } from "react-hook-form";
|
||||||
|
import { CourseFormData } from "../context/CourseEditorContext";
|
||||||
|
|
||||||
export function CourseGoalForm() {
|
export function CourseGoalForm() {
|
||||||
const { register, formState: { errors }, watch, handleSubmit } = useFormContext<CourseFormData>();
|
const { register, formState: { errors }, watch, handleSubmit } = useFormContext<CourseFormData>();
|
|
@ -0,0 +1,164 @@
|
||||||
|
import { VideoCameraIcon, DocumentTextIcon, QuestionMarkCircleIcon, Bars3Icon, PencilIcon, TrashIcon, CloudArrowUpIcon, CheckIcon, ChevronUpIcon, ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/24/outline";
|
||||||
|
import { Lecture } from "@nice/common";
|
||||||
|
import { Card } from "@web/src/components/common/container/Card";
|
||||||
|
import { Button } from "@web/src/components/common/element/Button";
|
||||||
|
import { FormInput } from "@web/src/components/common/form/FormInput";
|
||||||
|
import { FormQuillInput } from "@web/src/components/common/form/FormQuillInput";
|
||||||
|
import FileUploader from "@web/src/components/common/uploader/FileUploader";
|
||||||
|
import { useState } from "react";
|
||||||
|
const LectureTypeIcon = ({ type }: { type: string }) => {
|
||||||
|
const iconClass = "h-5 w-5";
|
||||||
|
switch (type) {
|
||||||
|
case 'video':
|
||||||
|
return <VideoCameraIcon className={`${iconClass} text-blue-500`} />;
|
||||||
|
case 'article':
|
||||||
|
return <DocumentTextIcon className={`${iconClass} text-green-500`} />;
|
||||||
|
case 'quiz':
|
||||||
|
return <QuestionMarkCircleIcon className={`${iconClass} text-purple-500`} />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
interface LectureHeaderProps {
|
||||||
|
lecture: Lecture;
|
||||||
|
index: number;
|
||||||
|
isExpanded: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
}
|
||||||
|
export function LectureHeader({
|
||||||
|
lecture,
|
||||||
|
index,
|
||||||
|
isExpanded,
|
||||||
|
onToggle,
|
||||||
|
onDelete
|
||||||
|
}: LectureHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="group/lecture flex items-center gap-4 justify-between"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 flex-1">
|
||||||
|
<Button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggle();
|
||||||
|
}}
|
||||||
|
size="xs"
|
||||||
|
variant="ghost"
|
||||||
|
title={isExpanded ? "收起课时" : "展开课时"}
|
||||||
|
leftIcon={<ChevronRightIcon
|
||||||
|
className={`transform transition-transform duration-200 ease-in-out
|
||||||
|
${isExpanded ? 'rotate-90' : ''}`}
|
||||||
|
/>}
|
||||||
|
>
|
||||||
|
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-3 flex-1">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<LectureTypeIcon type={lecture.type} />
|
||||||
|
</div>
|
||||||
|
<span className="inline-flex items-center justify-center w-5 h-5
|
||||||
|
text-xs rounded-full bg-blue-100 text-blue-500">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<FormInput viewMode name="title"></FormInput>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex space-x-1 opacity-0 group-hover/lecture:opacity-100
|
||||||
|
transition-all duration-200 ease-in-out">
|
||||||
|
<Button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete(lecture.id);
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
variant="ghost-danger"
|
||||||
|
leftIcon={<TrashIcon></TrashIcon>}
|
||||||
|
title="删除课时"
|
||||||
|
>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
interface LectureEditorProps {
|
||||||
|
lecture: Lecture;
|
||||||
|
onUpdate: (lecture: Lecture) => void;
|
||||||
|
|
||||||
|
}
|
||||||
|
export function LectureEditor({ lecture, onUpdate }: LectureEditorProps) {
|
||||||
|
const tabs = [
|
||||||
|
{ key: 'video', icon: VideoCameraIcon, label: '视频' },
|
||||||
|
{ key: 'article', icon: DocumentTextIcon, label: '文章' }
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div className=" pt-6 bg-white" >
|
||||||
|
<div className="flex space-x-4 mb-6 px-6">
|
||||||
|
{tabs.map(({ key, icon: Icon, label }) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => onUpdate({ ...lecture, type: key })}
|
||||||
|
className={`flex items-center space-x-2 px-6 py-2.5 rounded-lg transition-all
|
||||||
|
${lecture.type === key
|
||||||
|
? 'bg-blue-50 text-blue-600 shadow-sm ring-1 ring-blue-100'
|
||||||
|
: 'text-gray-600 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
<span className="font-medium">{label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 pb-6 flex flex-col gap-4">
|
||||||
|
{lecture.type === 'video' && (
|
||||||
|
<div className="relative">
|
||||||
|
<FileUploader placeholder="点击或拖拽视频到这里上传" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{lecture.type === 'article' && (
|
||||||
|
<div>
|
||||||
|
<FormQuillInput minRows={8} label="文章内容" name="content" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="relative">
|
||||||
|
<FileUploader placeholder="点击或拖拽资源到这里上传" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LectureFormItemProps {
|
||||||
|
lecture: Lecture;
|
||||||
|
index: number;
|
||||||
|
onUpdate: (lecture: Lecture) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LectureFormItem(props: LectureFormItemProps) {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
const handleToggle = () => {
|
||||||
|
setIsExpanded(!isExpanded);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
variant="outlined"
|
||||||
|
className="flex-1 group relative flex flex-col p-4 "
|
||||||
|
>
|
||||||
|
<LectureHeader
|
||||||
|
{...props}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
onToggle={handleToggle}
|
||||||
|
/>
|
||||||
|
{isExpanded && (
|
||||||
|
<LectureEditor
|
||||||
|
lecture={props.lecture}
|
||||||
|
onUpdate={props.onUpdate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,145 @@
|
||||||
|
import { Lecture } from "packages/common/dist";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { LectureFormItem } from "./LectureFormItem";
|
||||||
|
import { Bars3Icon } from "@heroicons/react/24/outline";
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
closestCenter,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
DragEndEvent,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
useSortable,
|
||||||
|
} from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
import { Button } from "@web/src/components/common/element/Button";
|
||||||
|
|
||||||
|
interface LectureFormListProps {
|
||||||
|
lectures: Lecture[];
|
||||||
|
sectionId: string;
|
||||||
|
onUpdate: (lectures: Lecture[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SortableItemProps {
|
||||||
|
lecture: Lecture;
|
||||||
|
index: number;
|
||||||
|
onUpdate: (lecture: Lecture) => void;
|
||||||
|
onDelete: (lectureId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortableItem({ lecture, index, onUpdate, onDelete }: SortableItemProps) {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id: lecture.id });
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
zIndex: isDragging ? 1 : 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
|
||||||
|
className={`flex items-center relative ${isDragging ? 'opacity-50' : ''}`}
|
||||||
|
>
|
||||||
|
<LectureFormItem
|
||||||
|
lecture={lecture}
|
||||||
|
index={index}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
onDelete={onDelete}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
leftIcon={
|
||||||
|
|
||||||
|
<Bars3Icon
|
||||||
|
/>
|
||||||
|
} variant="ghost" className="absolute -left-8 cursor-grab active:cursor-grabbing " title="拖拽排序"
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LectureFormList({ lectures, sectionId, onUpdate }: LectureFormListProps) {
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 8,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleUpdate = useCallback((updatedLecture: Lecture) => {
|
||||||
|
onUpdate(lectures.map(l => l.id === updatedLecture.id ? updatedLecture : l));
|
||||||
|
}, [lectures, onUpdate]);
|
||||||
|
|
||||||
|
const handleDelete = useCallback((lectureId: string) => {
|
||||||
|
onUpdate(lectures.filter(l => l.id !== lectureId));
|
||||||
|
}, [lectures, onUpdate]);
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (over && active.id !== over.id) {
|
||||||
|
const oldIndex = lectures.findIndex((lecture) => lecture.id === active.id);
|
||||||
|
const newIndex = lectures.findIndex((lecture) => lecture.id === over.id);
|
||||||
|
const newLectures = [...lectures];
|
||||||
|
const [removed] = newLectures.splice(oldIndex, 1);
|
||||||
|
newLectures.splice(newIndex, 0, removed);
|
||||||
|
onUpdate(newLectures);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (lectures.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="select-none flex items-center justify-center text-gray-500">
|
||||||
|
暂无课程内容
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={lectures.map(lecture => lecture.id)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{lectures.map((lecture, index) => (
|
||||||
|
<SortableItem
|
||||||
|
key={lecture.id}
|
||||||
|
lecture={lecture}
|
||||||
|
index={index}
|
||||||
|
onUpdate={handleUpdate}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,110 @@
|
||||||
|
import {
|
||||||
|
TrashIcon,
|
||||||
|
PlusIcon,
|
||||||
|
ChevronDownIcon
|
||||||
|
} from "@heroicons/react/24/outline";
|
||||||
|
import { Section, Lecture } from "@nice/common";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { LectureFormList } from "./LectureFormList";
|
||||||
|
import { cn } from "@web/src/utils/classname";
|
||||||
|
import { FormInput } from "@web/src/components/common/form/FormInput";
|
||||||
|
import { Button } from "@web/src/components/common/element/Button";
|
||||||
|
import { Card } from "@web/src/components/common/container/Card";
|
||||||
|
interface SectionProps {
|
||||||
|
section: Section;
|
||||||
|
index: number;
|
||||||
|
onUpdate: (section: Section) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SectionFormItem({ section, index, onUpdate, onDelete }: SectionProps) {
|
||||||
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||||
|
const handleAddLecture = () => {
|
||||||
|
const newLecture: Lecture = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
title: '新课时',
|
||||||
|
type: 'video'
|
||||||
|
};
|
||||||
|
onUpdate({
|
||||||
|
...section,
|
||||||
|
lectures: [...section.lectures, newLecture]
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="group/section relative flex-1 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||||
|
title={isCollapsed ? "展开章节" : "收起章节"}
|
||||||
|
size="xs"
|
||||||
|
variant="ghost"
|
||||||
|
leftIcon={<ChevronDownIcon
|
||||||
|
className={cn(
|
||||||
|
"transition-transform duration-300 ease-out",
|
||||||
|
isCollapsed ? "-rotate-90" : "rotate-0"
|
||||||
|
)}
|
||||||
|
/>}
|
||||||
|
>
|
||||||
|
|
||||||
|
</Button>
|
||||||
|
<span className="inline-flex items-center justify-center w-7 h-7
|
||||||
|
bg-blue-50 text-blue-600 text-sm font-semibold rounded">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<FormInput viewMode name="title"></FormInput>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 opacity-0 group-hover/section:opacity-100
|
||||||
|
transition-all duration-200 ease-in-out">
|
||||||
|
<Button
|
||||||
|
onClick={() => onDelete(section.id)}
|
||||||
|
variant="ghost-danger"
|
||||||
|
size="sm"
|
||||||
|
aria-label="删除章节"
|
||||||
|
title="删除章节"
|
||||||
|
leftIcon={<TrashIcon />}
|
||||||
|
>
|
||||||
|
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"grid transition-all duration-300 ease-out",
|
||||||
|
isCollapsed
|
||||||
|
? "grid-rows-[0fr] opacity-0 invisible"
|
||||||
|
: "grid-rows-[1fr] opacity-100 visible"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={cn(
|
||||||
|
"overflow-hidden transition-all duration-300",
|
||||||
|
isCollapsed ? "hidden" : "px-10 py-4"
|
||||||
|
)}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<LectureFormList
|
||||||
|
lectures={section.lectures}
|
||||||
|
sectionId={section.id}
|
||||||
|
onUpdate={(updatedLectures) =>
|
||||||
|
onUpdate({ ...section, lectures: updatedLectures })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleAddLecture}
|
||||||
|
size="md"
|
||||||
|
fullWidth
|
||||||
|
variant="soft-primary"
|
||||||
|
leftIcon={<PlusIcon></PlusIcon>}
|
||||||
|
>
|
||||||
|
|
||||||
|
<span className="font-medium">添加课时</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,141 @@
|
||||||
|
import { Section } from "@nice/common";
|
||||||
|
import { SectionFormItem } from "./SectionFormItem";
|
||||||
|
import { Bars3Icon } from "@heroicons/react/24/outline";
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
closestCenter,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
DragEndEvent,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
useSortable,
|
||||||
|
} from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
import { Button } from "@web/src/components/common/element/Button";
|
||||||
|
|
||||||
|
interface SectionFormListProps {
|
||||||
|
sections: Section[];
|
||||||
|
setSections: React.Dispatch<React.SetStateAction<Section[]>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SortableItemProps {
|
||||||
|
section: Section;
|
||||||
|
index: number;
|
||||||
|
onUpdate: (section: Section) => void;
|
||||||
|
onDelete: (sectionId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortableItem({ section, index, onUpdate, onDelete }: SortableItemProps) {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id: section.id });
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
zIndex: isDragging ? 1 : 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={`flex items-center relative ${isDragging ? 'opacity-50' : ''}`}
|
||||||
|
>
|
||||||
|
<SectionFormItem
|
||||||
|
key={section.id}
|
||||||
|
section={section}
|
||||||
|
index={index}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
onDelete={onDelete}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
leftIcon={
|
||||||
|
|
||||||
|
<Bars3Icon
|
||||||
|
/>
|
||||||
|
} variant="ghost" className="absolute -right-10 cursor-grab active:cursor-grabbing " title="拖拽排序"
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
></Button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SectionFormList({ sections, setSections }: SectionFormListProps) {
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 8, // 8px的移动距离后才开始拖拽
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateSection = (updatedSection: Section) => {
|
||||||
|
setSections(prev => prev.map(section =>
|
||||||
|
section.id === updatedSection.id ? updatedSection : section
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteSection = (sectionId: string) => {
|
||||||
|
setSections(prev => prev.filter(section => section.id !== sectionId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
|
||||||
|
if (over && active.id !== over.id) {
|
||||||
|
setSections((prev) => {
|
||||||
|
const oldIndex = prev.findIndex((section) => section.id === active.id);
|
||||||
|
const newIndex = prev.findIndex((section) => section.id === over.id);
|
||||||
|
|
||||||
|
const newSections = [...prev];
|
||||||
|
const [removed] = newSections.splice(oldIndex, 1);
|
||||||
|
newSections.splice(newIndex, 0, removed);
|
||||||
|
|
||||||
|
return newSections;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={sections.map(section => section.id)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{sections.map((section, index) => (
|
||||||
|
<SortableItem
|
||||||
|
key={section.id}
|
||||||
|
section={section}
|
||||||
|
index={index}
|
||||||
|
onUpdate={updateSection}
|
||||||
|
onDelete={deleteSection}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,10 +1,10 @@
|
||||||
import { ArrowLeftIcon, ClockIcon } from '@heroicons/react/24/outline';
|
import { ArrowLeftIcon, ClockIcon } from '@heroicons/react/24/outline';
|
||||||
import { SubmitHandler, useFormContext } from 'react-hook-form';
|
import { SubmitHandler, useFormContext } from 'react-hook-form';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { CourseFormData, useCourseEditor } from './CourseEditorContext';
|
|
||||||
import { Button } from '@web/src/components/common/element/Button';
|
import { Button } from '@web/src/components/common/element/Button';
|
||||||
import { CourseStatus, CourseStatusLabel } from '@nice/common';
|
import { CourseStatus, CourseStatusLabel } from '@nice/common';
|
||||||
import Tag from '@web/src/components/common/element/Tag';
|
import Tag from '@web/src/components/common/element/Tag';
|
||||||
|
import { CourseFormData, useCourseEditor } from '../context/CourseEditorContext';
|
||||||
const courseStatusVariant: Record<CourseStatus, string> = {
|
const courseStatusVariant: Record<CourseStatus, string> = {
|
||||||
[CourseStatus.DRAFT]: 'default',
|
[CourseStatus.DRAFT]: 'default',
|
||||||
[CourseStatus.UNDER_REVIEW]: 'warning',
|
[CourseStatus.UNDER_REVIEW]: 'warning',
|
|
@ -1,11 +1,12 @@
|
||||||
import { ReactNode, useEffect, useState } from "react";
|
import { ReactNode, useEffect, useState } from "react";
|
||||||
import { Outlet, useNavigate, useParams } from "react-router-dom";
|
import { Outlet, useNavigate, useParams } from "react-router-dom";
|
||||||
import { getNavItems } from "./navItems";
|
|
||||||
import CourseEditorHeader from "./CourseEditorHeader";
|
import CourseEditorHeader from "./CourseEditorHeader";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { NavItem } from "@nice/client"
|
import { NavItem } from "@nice/client"
|
||||||
import CourseEditorSidebar from "./CourseEditorSidebar";
|
import CourseEditorSidebar from "./CourseEditorSidebar";
|
||||||
import { CourseFormProvider } from "./CourseEditorContext";
|
import { CourseFormProvider } from "../context/CourseEditorContext";
|
||||||
|
import { getNavItems } from "../navItems";
|
||||||
|
|
||||||
|
|
||||||
export default function CourseEditorLayout() {
|
export default function CourseEditorLayout() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
|
@ -2,6 +2,19 @@
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
.quill-editor-container .ql-toolbar {
|
||||||
|
border-top-left-radius: 8px;
|
||||||
|
border-top-right-radius: 8px;
|
||||||
|
border-bottom: none;
|
||||||
|
border: none
|
||||||
|
}
|
||||||
|
|
||||||
|
.quill-editor-container .ql-container {
|
||||||
|
border-bottom-left-radius: 8px;
|
||||||
|
border-bottom-right-radius: 8px;
|
||||||
|
border: none
|
||||||
|
}
|
||||||
|
|
||||||
.ag-custom-dragging-class {
|
.ag-custom-dragging-class {
|
||||||
@apply border-b-2 border-primaryHover;
|
@apply border-b-2 border-primaryHover;
|
||||||
}
|
}
|
||||||
|
@ -32,11 +45,11 @@
|
||||||
background-color: transparent !important;
|
background-color: transparent !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-table-thead > tr > th {
|
.ant-table-thead>tr>th {
|
||||||
background-color: transparent !important;
|
background-color: transparent !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-table-tbody > tr > td {
|
.ant-table-tbody>tr>td {
|
||||||
background-color: transparent !important;
|
background-color: transparent !important;
|
||||||
border-bottom-color: transparent !important;
|
border-bottom-color: transparent !important;
|
||||||
}
|
}
|
||||||
|
@ -73,9 +86,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 覆盖 Ant Design 的默认样式 raido button左侧的按钮*/
|
/* 覆盖 Ant Design 的默认样式 raido button左侧的按钮*/
|
||||||
.ant-radio-button-wrapper-checked:not(
|
.ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled)::before {
|
||||||
.ant-radio-button-wrapper-disabled
|
|
||||||
)::before {
|
|
||||||
background-color: unset !important;
|
background-color: unset !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -88,47 +99,28 @@
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-wrap-header .ant-table-thead > tr > th {
|
.no-wrap-header .ant-table-thead>tr>th {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-table .ant-table-cell {
|
.custom-table .ant-table-cell {
|
||||||
white-space: normal; /* 允许换行 */
|
white-space: normal;
|
||||||
word-wrap: break-word; /* 强制单词换行 */
|
/* 允许换行 */
|
||||||
|
word-wrap: break-word;
|
||||||
|
/* 强制单词换行 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-table .ant-table-cell {
|
.custom-table .ant-table-cell {
|
||||||
border: 1px solid #ddd; /* 设置单元格边框 */
|
border: 1px solid #ddd;
|
||||||
|
/* 设置单元格边框 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-table .ant-table-tbody > tr > td {
|
.custom-table .ant-table-tbody>tr>td {
|
||||||
border-bottom: 1px solid #ddd; /* 设置表格行底部边框 */
|
border-bottom: 1px solid #ddd;
|
||||||
|
/* 设置表格行底部边框 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-table .ant-table-tbody > tr:last-child > td {
|
.custom-table .ant-table-tbody>tr:last-child>td {
|
||||||
border-bottom: none; /* 去除最后一行的底部边框 */
|
border-bottom: none;
|
||||||
}
|
/* 去除最后一行的底部边框 */
|
||||||
|
}
|
||||||
|
|
||||||
/* .react-flow__node-mindmap {
|
|
||||||
@apply p-2 rounded hover:ring-2 shadow-lg
|
|
||||||
} */
|
|
||||||
.mindmap-node{
|
|
||||||
@apply p-2 rounded shadow-lg
|
|
||||||
}
|
|
||||||
.mindmap-node.selected{
|
|
||||||
@apply ring-2 ring-blue-500
|
|
||||||
}
|
|
||||||
.react-flow__node-mindmap input{
|
|
||||||
@apply outline-none bg-transparent
|
|
||||||
}
|
|
||||||
.react-flow__handle.target {
|
|
||||||
top: 50%;
|
|
||||||
pointer-events: none;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-flow__handle.source {
|
|
||||||
@apply top-0 left-0 h-full w-full border-none rounded transform-none opacity-0
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
|
@ -18,11 +18,12 @@ import StudentCoursesPage from "../app/main/courses/student/page";
|
||||||
import InstructorCoursesPage from "../app/main/courses/instructor/page";
|
import InstructorCoursesPage from "../app/main/courses/instructor/page";
|
||||||
import HomePage from "../app/main/home/page";
|
import HomePage from "../app/main/home/page";
|
||||||
import { CourseDetailPage } from "../app/main/course/detail/page";
|
import { CourseDetailPage } from "../app/main/course/detail/page";
|
||||||
import CourseEditorLayout from "../components/models/course/editor/CourseEditorLayout";
|
import { CourseBasicForm } from "../components/models/course/editor/form/CourseBasicForm";
|
||||||
import { CourseBasicForm } from "../components/models/course/editor/CourseBasicForm";
|
import CourseContentForm from "../components/models/course/editor/form/CourseContentForm";
|
||||||
import { CourseGoalForm } from "../components/models/course/editor/CourseGoalForm";
|
import { CourseGoalForm } from "../components/models/course/editor/form/CourseGoalForm";
|
||||||
import CourseSettingForm from "../components/models/course/editor/CourseSettingForm";
|
import CourseSettingForm from "../components/models/course/editor/form/CourseSettingForm";
|
||||||
import CourseContentForm from "../components/models/course/editor/CourseContentForm";
|
import CourseEditorLayout from "../components/models/course/editor/layout/CourseEditorLayout";
|
||||||
|
|
||||||
interface CustomIndexRouteObject extends IndexRouteObject {
|
interface CustomIndexRouteObject extends IndexRouteObject {
|
||||||
name?: string;
|
name?: string;
|
||||||
breadcrumb?: string;
|
breadcrumb?: string;
|
||||||
|
|
|
@ -5,7 +5,6 @@ generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
url = env("DATABASE_URL")
|
url = env("DATABASE_URL")
|
||||||
|
@ -197,13 +196,11 @@ model Post {
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
deletedAt DateTime? @map("deleted_at") // 删除时间,可为空
|
deletedAt DateTime? @map("deleted_at") // 删除时间,可为空
|
||||||
|
|
||||||
// 整数类型字段
|
// 整数类型字段
|
||||||
rating Int // 评分(1-5星)
|
rating Int // 评分(1-5星)
|
||||||
|
|
||||||
// 关系类型字段
|
// 关系类型字段
|
||||||
authorId String? @map("author_id")
|
authorId String? @map("author_id")
|
||||||
author Staff? @relation(fields: [authorId], references: [id]) // 帖子作者,关联 Staff 模型
|
author Staff? @relation(fields: [authorId], references: [id]) // 帖子作者,关联 Staff 模型
|
||||||
|
|
||||||
visits Visit[] // 访问记录,关联 Visit 模型
|
visits Visit[] // 访问记录,关联 Visit 模型
|
||||||
|
|
||||||
|
@ -244,132 +241,130 @@ model Message {
|
||||||
visits Visit[]
|
visits Visit[]
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime? @updatedAt @map("updated_at")
|
updatedAt DateTime? @updatedAt @map("updated_at")
|
||||||
|
|
||||||
@@index([type, createdAt])
|
@@index([type, createdAt])
|
||||||
@@map("message")
|
@@map("message")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Visit {
|
model Visit {
|
||||||
id String @id @default(cuid()) @map("id")
|
id String @id @default(cuid()) @map("id")
|
||||||
type String?
|
type String?
|
||||||
views Int @default(1) @map("views")
|
views Int @default(1) @map("views")
|
||||||
sourceIP String? @map("source_ip")
|
// sourceIP String? @map("source_ip")
|
||||||
// 关联关系
|
// 关联关系
|
||||||
visitorId String @map("visitor_id")
|
visitorId String @map("visitor_id")
|
||||||
visitor Staff @relation(fields: [visitorId], references: [id])
|
visitor Staff @relation(fields: [visitorId], references: [id])
|
||||||
postId String? @map("post_id")
|
postId String? @map("post_id")
|
||||||
post Post? @relation(fields: [postId], references: [id])
|
post Post? @relation(fields: [postId], references: [id])
|
||||||
message Message? @relation(fields: [messageId], references: [id])
|
message Message? @relation(fields: [messageId], references: [id])
|
||||||
messageId String? @map("message_id")
|
messageId String? @map("message_id")
|
||||||
enrollment Enrollment? @relation(fields: [enrollmentId], references: [id], onDelete: Cascade)
|
lecture Lecture? @relation(fields: [lectureId], references: [id], onDelete: Cascade)
|
||||||
enrollmentId String? @map("enrollment_id") // 报名记录ID
|
lectureId String? @map("lecture_id") // 课时ID
|
||||||
lecture Lecture? @relation(fields: [lectureId], references: [id], onDelete: Cascade)
|
|
||||||
lectureId String? @map("lecture_id") // 课时ID
|
|
||||||
|
|
||||||
// 学习数据
|
// 学习数据
|
||||||
progress Float? @default(0) @map("progress") // 完成进度(0-100%)
|
// progress Float? @default(0) @map("progress") // 完成进度(0-100%)
|
||||||
isCompleted Boolean? @default(false) @map("is_completed") // 是否完成
|
// isCompleted Boolean? @default(false) @map("is_completed") // 是否完成
|
||||||
lastPosition Int? @default(0) @map("last_position") // 视频播放位置(秒)
|
// lastPosition Int? @default(0) @map("last_position") // 视频播放位置(秒)
|
||||||
totalWatchTime Int? @default(0) @map("total_watch_time") // 总观看时长(秒)
|
// totalWatchTime Int? @default(0) @map("total_watch_time") // 总观看时长(秒)
|
||||||
// 时间记录
|
// // 时间记录
|
||||||
lastWatchedAt DateTime? @map("last_watched_at") // 最后观看时间
|
// lastWatchedAt DateTime? @map("last_watched_at") // 最后观看时间
|
||||||
createdAt DateTime @default(now()) @map("created_at") // 创建时间
|
createdAt DateTime @default(now()) @map("created_at") // 创建时间
|
||||||
updatedAt DateTime @updatedAt @map("updated_at") // 更新时间
|
updatedAt DateTime @updatedAt @map("updated_at") // 更新时间
|
||||||
|
|
||||||
|
meta Json?
|
||||||
|
|
||||||
@@unique([enrollmentId, lectureId]) // 确保每个报名只有一条课时进度
|
|
||||||
@@index([isCompleted]) // 完成状态索引
|
|
||||||
@@index([lastWatchedAt]) // 最后观看时间索引
|
|
||||||
@@index([postId, type, visitorId])
|
@@index([postId, type, visitorId])
|
||||||
@@index([messageId, type, visitorId])
|
@@index([messageId, type, visitorId])
|
||||||
@@map("visit")
|
@@map("visit")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Course {
|
model Course {
|
||||||
id String @id @default(cuid()) @map("id") // 课程唯一标识符
|
id String @id @default(cuid()) @map("id") // 课程唯一标识符
|
||||||
title String? @map("title") // 课程标题
|
title String? @map("title") // 课程标题
|
||||||
subTitle String? @map("sub_title") // 课程副标题(可选)
|
subTitle String? @map("sub_title") // 课程副标题(可选)
|
||||||
description String? @map("description") // 课程详细描述
|
description String? @map("description") // 课程详细描述
|
||||||
thumbnail String? @map("thumbnail") // 课程封面图片URL(可选)
|
thumbnail String? @map("thumbnail") // 课程封面图片URL(可选)
|
||||||
level String? @map("level") // 课程难度等级
|
level String? @map("level") // 课程难度等级
|
||||||
|
|
||||||
// 课程内容组织结构
|
// 课程内容组织结构
|
||||||
terms Term[] @relation("course_term") // 课程学期
|
terms Term[] @relation("course_term") // 课程学期
|
||||||
instructors CourseInstructor[] // 课程讲师团队
|
instructors CourseInstructor[] // 课程讲师团队
|
||||||
sections Section[] // 课程章节结构
|
sections Section[] // 课程章节结构
|
||||||
lectures Lecture[]
|
lectures Lecture[]
|
||||||
enrollments Enrollment[] // 学生报名记录
|
enrollments Enrollment[] // 学生报名记录
|
||||||
reviews Post[] // 学员课程评价
|
reviews Post[] // 学员课程评价
|
||||||
|
|
||||||
// 课程规划与目标设定
|
// 课程规划与目标设定
|
||||||
requirements String[] @map("requirements") // 课程学习前置要求
|
requirements String[] @map("requirements") // 课程学习前置要求
|
||||||
objectives String[] @map("objectives") // 具体的学习目标
|
objectives String[] @map("objectives") // 具体的学习目标
|
||||||
|
|
||||||
// 课程统计指标
|
|
||||||
totalDuration Int? @default(0) @map("total_duration") // 课程总时长(分钟)
|
|
||||||
totalLectures Int? @default(0) @map("total_lectures") // 总课时数
|
|
||||||
averageRating Float? @default(0) @map("average_rating") // 平均评分(1-5分)
|
|
||||||
numberOfReviews Int? @default(0) @map("number_of_reviews") // 评价总数
|
|
||||||
numberOfStudents Int? @default(0) @map("number_of_students") // 学习人数
|
|
||||||
completionRate Float? @default(0) @map("completion_rate") // 完课率(0-100%)
|
|
||||||
|
|
||||||
// 课程状态管理
|
// 课程状态管理
|
||||||
status String? @map("status") // 课程状态(如:草稿/已发布/已归档)
|
status String? @map("status") // 课程状态(如:草稿/已发布/已归档)
|
||||||
isFeatured Boolean? @default(false) @map("is_featured") // 是否为精选推荐课程
|
featured Boolean? @default(false) @map("featured") // 是否为精选推荐课程
|
||||||
|
|
||||||
// 生命周期时间戳
|
// 生命周期时间戳
|
||||||
createdAt DateTime? @default(now()) @map("created_at") // 创建时间
|
createdAt DateTime? @default(now()) @map("created_at") // 创建时间
|
||||||
updatedAt DateTime? @updatedAt @map("updated_at") // 最后更新时间
|
updatedAt DateTime? @updatedAt @map("updated_at") // 最后更新时间
|
||||||
publishedAt DateTime? @map("published_at") // 发布时间
|
publishedAt DateTime? @map("published_at") // 发布时间
|
||||||
archivedAt DateTime? @map("archived_at") // 归档时间
|
|
||||||
deletedAt DateTime? @map("deleted_at") // 软删除时间
|
deletedAt DateTime? @map("deleted_at") // 软删除时间
|
||||||
|
meta Json?
|
||||||
|
|
||||||
|
// 课程统计指标
|
||||||
|
// totalDuration Int? @default(0) @map("total_duration") // 课程总时长(分钟)
|
||||||
|
// totalLectures Int? @default(0) @map("total_lectures") // 总课时数
|
||||||
|
// averageRating Float? @default(0) @map("average_rating") // 平均评分(1-5分)
|
||||||
|
// numberOfReviews Int? @default(0) @map("number_of_reviews") // 评价总数
|
||||||
|
// numberOfStudents Int? @default(0) @map("number_of_students") // 学习人数
|
||||||
|
// completionRate Float? @default(0) @map("completion_rate") // 完课率(0-100%)
|
||||||
// 数据库索引优化
|
// 数据库索引优化
|
||||||
@@index([status]) // 课程状态索引,用于快速筛选
|
@@index([status]) // 课程状态索引,用于快速筛选
|
||||||
@@index([level]) // 难度等级索引,用于分类查询
|
@@index([level]) // 难度等级索引,用于分类查询
|
||||||
@@index([isFeatured]) // 精选标记索引,用于首页推荐
|
@@index([featured]) // 精选标记索引,用于首页推荐
|
||||||
@@map("course")
|
@@map("course")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Section {
|
model Section {
|
||||||
id String @id @default(cuid()) @map("id")
|
id String @id @default(cuid()) @map("id")
|
||||||
title String @map("title")
|
title String @map("title")
|
||||||
description String? @map("description")
|
description String? @map("description")
|
||||||
objectives String[] @map("objectives")
|
objectives String[] @map("objectives")
|
||||||
order Float? @default(0) @map("order")
|
order Float? @default(0) @map("order")
|
||||||
totalDuration Int @default(0) @map("total_duration")
|
|
||||||
totalLectures Int @default(0) @map("total_lectures")
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
|
||||||
deletedAt DateTime? @map("deleted_at")
|
|
||||||
// 关联关系
|
|
||||||
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
|
||||||
courseId String @map("course_id")
|
|
||||||
lectures Lecture[]
|
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
deletedAt DateTime? @map("deleted_at")
|
||||||
|
// 关联关系
|
||||||
|
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||||
|
courseId String @map("course_id")
|
||||||
|
lectures Lecture[]
|
||||||
|
meta Json?
|
||||||
|
|
||||||
|
// totalDuration Int @default(0) @map("total_duration")
|
||||||
|
// totalLectures Int @default(0) @map("total_lectures")
|
||||||
@@index([courseId, order])
|
@@index([courseId, order])
|
||||||
@@map("section")
|
@@map("section")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Lecture {
|
model Lecture {
|
||||||
id String @id @default(cuid()) @map("id")
|
id String @id @default(cuid()) @map("id")
|
||||||
title String @map("title")
|
title String @map("title")
|
||||||
description String? @map("description")
|
description String? @map("description")
|
||||||
order Float? @default(0) @map("order")
|
content String? @map("content")
|
||||||
duration Int @map("duration")
|
order Float? @default(0) @map("order")
|
||||||
type String @map("type")
|
duration Int @map("duration")
|
||||||
content String? @map("content")
|
type String @map("type")
|
||||||
videoUrl String? @map("video_url")
|
|
||||||
videoThumbnail String? @map("video_thumbnail")
|
videoUrl String? @map("video_url")
|
||||||
publishedAt DateTime? @map("published_at")
|
videoThumbnail String? @map("video_thumbnail")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
publishedAt DateTime? @map("published_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
deletedAt DateTime? @map("deleted_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
deletedAt DateTime? @map("deleted_at")
|
||||||
// 关联关系
|
// 关联关系
|
||||||
resources Resource[]
|
resources Resource[]
|
||||||
section Section? @relation(fields: [sectionId], references: [id], onDelete: Cascade)
|
section Section? @relation(fields: [sectionId], references: [id], onDelete: Cascade)
|
||||||
sectionId String? @map("section_id")
|
sectionId String? @map("section_id")
|
||||||
course Course? @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
course Course? @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||||
courseId String? @map("course_id")
|
courseId String? @map("course_id")
|
||||||
comments Post[]
|
comments Post[]
|
||||||
visits Visit[]
|
visits Visit[]
|
||||||
|
|
||||||
@@index([sectionId, order])
|
@@index([sectionId, order])
|
||||||
@@index([type, publishedAt])
|
@@index([type, publishedAt])
|
||||||
|
@ -381,17 +376,15 @@ model Enrollment {
|
||||||
status String @map("status")
|
status String @map("status")
|
||||||
completionRate Float @default(0) @map("completion_rate")
|
completionRate Float @default(0) @map("completion_rate")
|
||||||
|
|
||||||
lastAccessedAt DateTime? @map("last_accessed_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
completedAt DateTime? @map("completed_at")
|
||||||
completedAt DateTime? @map("completed_at")
|
|
||||||
|
|
||||||
// 关联关系
|
// 关联关系
|
||||||
student Staff @relation(fields: [studentId], references: [id])
|
student Staff @relation(fields: [studentId], references: [id])
|
||||||
studentId String @map("student_id")
|
studentId String @map("student_id")
|
||||||
course Course @relation(fields: [courseId], references: [id])
|
course Course @relation(fields: [courseId], references: [id])
|
||||||
courseId String @map("course_id")
|
courseId String @map("course_id")
|
||||||
visits Visit[]
|
|
||||||
|
|
||||||
@@unique([studentId, courseId])
|
@@unique([studentId, courseId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
|
@ -430,12 +423,13 @@ model Resource {
|
||||||
updatedBy String? @map("updated_by")
|
updatedBy String? @map("updated_by")
|
||||||
deletedAt DateTime? @map("deleted_at")
|
deletedAt DateTime? @map("deleted_at")
|
||||||
isPublic Boolean? @default(true) @map("is_public")
|
isPublic Boolean? @default(true) @map("is_public")
|
||||||
owner Staff? @relation(fields: [ownerId], references: [id])
|
owner Staff? @relation(fields: [ownerId], references: [id])
|
||||||
ownerId String? @map("owner_id")
|
ownerId String? @map("owner_id")
|
||||||
post Post? @relation(fields: [postId], references: [id])
|
post Post? @relation(fields: [postId], references: [id])
|
||||||
postId String? @map("post_id")
|
postId String? @map("post_id")
|
||||||
lecture Lecture? @relation(fields: [lectureId], references: [id])
|
lecture Lecture? @relation(fields: [lectureId], references: [id])
|
||||||
lectureId String? @map("lecture_id")
|
lectureId String? @map("lecture_id")
|
||||||
|
|
||||||
// 索引
|
// 索引
|
||||||
@@index([type])
|
@@index([type])
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
|
|
197
pnpm-lock.yaml
197
pnpm-lock.yaml
|
@ -266,6 +266,15 @@ importers:
|
||||||
'@ant-design/icons':
|
'@ant-design/icons':
|
||||||
specifier: ^5.4.0
|
specifier: ^5.4.0
|
||||||
version: 5.5.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
version: 5.5.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||||
|
'@dnd-kit/core':
|
||||||
|
specifier: ^6.3.1
|
||||||
|
version: 6.3.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||||
|
'@dnd-kit/sortable':
|
||||||
|
specifier: ^10.0.0
|
||||||
|
version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0)
|
||||||
|
'@dnd-kit/utilities':
|
||||||
|
specifier: ^3.2.2
|
||||||
|
version: 3.2.2(react@18.2.0)
|
||||||
'@floating-ui/react':
|
'@floating-ui/react':
|
||||||
specifier: ^0.26.25
|
specifier: ^0.26.25
|
||||||
version: 0.26.28(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
version: 0.26.28(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||||
|
@ -308,6 +317,9 @@ importers:
|
||||||
'@trpc/server':
|
'@trpc/server':
|
||||||
specifier: 11.0.0-rc.456
|
specifier: 11.0.0-rc.456
|
||||||
version: 11.0.0-rc.456
|
version: 11.0.0-rc.456
|
||||||
|
'@xyflow/react':
|
||||||
|
specifier: ^12.3.6
|
||||||
|
version: 12.3.6(@types/react@18.2.38)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||||
ag-grid-community:
|
ag-grid-community:
|
||||||
specifier: ~32.3.2
|
specifier: ~32.3.2
|
||||||
version: 32.3.3
|
version: 32.3.3
|
||||||
|
@ -332,9 +344,18 @@ importers:
|
||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
|
d3-dag:
|
||||||
|
specifier: ^1.1.0
|
||||||
|
version: 1.1.0
|
||||||
|
d3-hierarchy:
|
||||||
|
specifier: ^3.1.2
|
||||||
|
version: 3.1.2
|
||||||
dayjs:
|
dayjs:
|
||||||
specifier: ^1.11.12
|
specifier: ^1.11.12
|
||||||
version: 1.11.13
|
version: 1.11.13
|
||||||
|
elkjs:
|
||||||
|
specifier: ^0.9.3
|
||||||
|
version: 0.9.3
|
||||||
framer-motion:
|
framer-motion:
|
||||||
specifier: ^11.15.0
|
specifier: ^11.15.0
|
||||||
version: 11.16.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
version: 11.16.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||||
|
@ -359,6 +380,9 @@ importers:
|
||||||
react-dom:
|
react-dom:
|
||||||
specifier: 18.2.0
|
specifier: 18.2.0
|
||||||
version: 18.2.0(react@18.2.0)
|
version: 18.2.0(react@18.2.0)
|
||||||
|
react-dropzone:
|
||||||
|
specifier: ^14.3.5
|
||||||
|
version: 14.3.5(react@18.2.0)
|
||||||
react-hook-form:
|
react-hook-form:
|
||||||
specifier: ^7.54.2
|
specifier: ^7.54.2
|
||||||
version: 7.54.2(react@18.2.0)
|
version: 7.54.2(react@18.2.0)
|
||||||
|
@ -398,7 +422,7 @@ importers:
|
||||||
version: 18.2.15
|
version: 18.2.15
|
||||||
'@vitejs/plugin-react-swc':
|
'@vitejs/plugin-react-swc':
|
||||||
specifier: ^3.5.0
|
specifier: ^3.5.0
|
||||||
version: 3.7.2(vite@5.4.11(@types/node@20.17.12)(terser@5.37.0))
|
version: 3.7.2(@swc/helpers@0.5.15)(vite@5.4.11(@types/node@20.17.12)(terser@5.37.0))
|
||||||
autoprefixer:
|
autoprefixer:
|
||||||
specifier: ^10.4.20
|
specifier: ^10.4.20
|
||||||
version: 10.4.20(postcss@8.4.49)
|
version: 10.4.20(postcss@8.4.49)
|
||||||
|
@ -1273,6 +1297,28 @@ packages:
|
||||||
resolution: {integrity: sha512-mepCf/e9+SKYy1d02/UkvSy6+6MoyXhVxP8lLDfA7BPE1X1d4dR0sZznmbM8/XVJ1GPM+Svnx7Xj6ZweByWUkw==}
|
resolution: {integrity: sha512-mepCf/e9+SKYy1d02/UkvSy6+6MoyXhVxP8lLDfA7BPE1X1d4dR0sZznmbM8/XVJ1GPM+Svnx7Xj6ZweByWUkw==}
|
||||||
engines: {node: '>17.0.0'}
|
engines: {node: '>17.0.0'}
|
||||||
|
|
||||||
|
'@dnd-kit/accessibility@3.1.1':
|
||||||
|
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8.0'
|
||||||
|
|
||||||
|
'@dnd-kit/core@6.3.1':
|
||||||
|
resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8.0'
|
||||||
|
react-dom: '>=16.8.0'
|
||||||
|
|
||||||
|
'@dnd-kit/sortable@10.0.0':
|
||||||
|
resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@dnd-kit/core': ^6.3.0
|
||||||
|
react: '>=16.8.0'
|
||||||
|
|
||||||
|
'@dnd-kit/utilities@3.2.2':
|
||||||
|
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8.0'
|
||||||
|
|
||||||
'@emnapi/runtime@1.3.1':
|
'@emnapi/runtime@1.3.1':
|
||||||
resolution: {integrity: sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==}
|
resolution: {integrity: sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==}
|
||||||
|
|
||||||
|
@ -2665,6 +2711,9 @@ packages:
|
||||||
'@swc/counter@0.1.3':
|
'@swc/counter@0.1.3':
|
||||||
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
|
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
|
||||||
|
|
||||||
|
'@swc/helpers@0.5.15':
|
||||||
|
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
||||||
|
|
||||||
'@swc/types@0.1.17':
|
'@swc/types@0.1.17':
|
||||||
resolution: {integrity: sha512-V5gRru+aD8YVyCOMAjMpWR1Ui577DD5KSJsHP8RAxopAH22jFz6GZd/qxqjO6MJHQhcsjvjOFXyDhyLQUnMveQ==}
|
resolution: {integrity: sha512-V5gRru+aD8YVyCOMAjMpWR1Ui577DD5KSJsHP8RAxopAH22jFz6GZd/qxqjO6MJHQhcsjvjOFXyDhyLQUnMveQ==}
|
||||||
|
|
||||||
|
@ -3347,6 +3396,10 @@ packages:
|
||||||
asynckit@0.4.0:
|
asynckit@0.4.0:
|
||||||
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
||||||
|
|
||||||
|
attr-accept@2.2.5:
|
||||||
|
resolution: {integrity: sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==}
|
||||||
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
autoprefixer@10.4.20:
|
autoprefixer@10.4.20:
|
||||||
resolution: {integrity: sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==}
|
resolution: {integrity: sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
|
@ -3798,10 +3851,17 @@ packages:
|
||||||
custom-error-instance@2.1.1:
|
custom-error-instance@2.1.1:
|
||||||
resolution: {integrity: sha512-p6JFxJc3M4OTD2li2qaHkDCw9SfMw82Ldr6OC9Je1aXiGfhx2W8p3GaoeaGrPJTUN9NirTM/KTxHWMUdR1rsUg==}
|
resolution: {integrity: sha512-p6JFxJc3M4OTD2li2qaHkDCw9SfMw82Ldr6OC9Je1aXiGfhx2W8p3GaoeaGrPJTUN9NirTM/KTxHWMUdR1rsUg==}
|
||||||
|
|
||||||
|
d3-array@3.2.4:
|
||||||
|
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
d3-color@3.1.0:
|
d3-color@3.1.0:
|
||||||
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
|
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-dag@1.1.0:
|
||||||
|
resolution: {integrity: sha512-N8IxsIHcUaIxLrV3cElTC47kVJGFiY3blqSuJubQhyhYBJs0syfFPTnRSj2Cq0LBxxi4mzJmcqCvHIv9sPdILQ==}
|
||||||
|
|
||||||
d3-dispatch@3.0.1:
|
d3-dispatch@3.0.1:
|
||||||
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
|
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
@ -3814,6 +3874,10 @@ packages:
|
||||||
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
|
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-hierarchy@3.1.2:
|
||||||
|
resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
d3-interpolate@3.0.1:
|
d3-interpolate@3.0.1:
|
||||||
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
|
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
@ -4010,6 +4074,9 @@ packages:
|
||||||
electron-to-chromium@1.5.79:
|
electron-to-chromium@1.5.79:
|
||||||
resolution: {integrity: sha512-nYOxJNxQ9Om4EC88BE4pPoNI8xwSFf8pU/BAeOl4Hh/b/i6V4biTAzwV7pXi3ARKeoYO5JZKMIXTryXSVer5RA==}
|
resolution: {integrity: sha512-nYOxJNxQ9Om4EC88BE4pPoNI8xwSFf8pU/BAeOl4Hh/b/i6V4biTAzwV7pXi3ARKeoYO5JZKMIXTryXSVer5RA==}
|
||||||
|
|
||||||
|
elkjs@0.9.3:
|
||||||
|
resolution: {integrity: sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==}
|
||||||
|
|
||||||
emittery@0.13.1:
|
emittery@0.13.1:
|
||||||
resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==}
|
resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
@ -4320,6 +4387,10 @@ packages:
|
||||||
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
|
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
|
||||||
engines: {node: '>=16.0.0'}
|
engines: {node: '>=16.0.0'}
|
||||||
|
|
||||||
|
file-selector@2.1.2:
|
||||||
|
resolution: {integrity: sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==}
|
||||||
|
engines: {node: '>= 12'}
|
||||||
|
|
||||||
filelist@1.0.4:
|
filelist@1.0.4:
|
||||||
resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==}
|
resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==}
|
||||||
|
|
||||||
|
@ -4462,6 +4533,10 @@ packages:
|
||||||
resolution: {integrity: sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA==}
|
resolution: {integrity: sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
get-own-enumerable-keys@1.0.0:
|
||||||
|
resolution: {integrity: sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA==}
|
||||||
|
engines: {node: '>=14.16'}
|
||||||
|
|
||||||
get-package-type@0.1.0:
|
get-package-type@0.1.0:
|
||||||
resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==}
|
resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==}
|
||||||
engines: {node: '>=8.0.0'}
|
engines: {node: '>=8.0.0'}
|
||||||
|
@ -4628,6 +4703,10 @@ packages:
|
||||||
resolution: {integrity: sha512-vI2w4zl/mDluHt9YEQ/543VTCwPKWiHzKtm9dM2V0NdFcqEexDAjUHzO1oA60HRNaVifGXXM1tRRNluLVHa0Kg==}
|
resolution: {integrity: sha512-vI2w4zl/mDluHt9YEQ/543VTCwPKWiHzKtm9dM2V0NdFcqEexDAjUHzO1oA60HRNaVifGXXM1tRRNluLVHa0Kg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
internmap@2.0.3:
|
||||||
|
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
ioredis@5.4.2:
|
ioredis@5.4.2:
|
||||||
resolution: {integrity: sha512-0SZXGNGZ+WzISQ67QDyZ2x0+wVxjjUndtD8oSeik/4ajifeiRufed8fCb8QW8VMyi4MXcS+UO1k/0NGhvq1PAg==}
|
resolution: {integrity: sha512-0SZXGNGZ+WzISQ67QDyZ2x0+wVxjjUndtD8oSeik/4ajifeiRufed8fCb8QW8VMyi4MXcS+UO1k/0NGhvq1PAg==}
|
||||||
engines: {node: '>=12.22.0'}
|
engines: {node: '>=12.22.0'}
|
||||||
|
@ -4694,6 +4773,10 @@ packages:
|
||||||
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
|
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
|
||||||
engines: {node: '>=0.12.0'}
|
engines: {node: '>=0.12.0'}
|
||||||
|
|
||||||
|
is-obj@3.0.0:
|
||||||
|
resolution: {integrity: sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
is-path-inside@3.0.3:
|
is-path-inside@3.0.3:
|
||||||
resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==}
|
resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
@ -4702,6 +4785,10 @@ packages:
|
||||||
resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
|
resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
is-regexp@3.1.0:
|
||||||
|
resolution: {integrity: sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
is-stream@2.0.1:
|
is-stream@2.0.1:
|
||||||
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
|
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
@ -4767,6 +4854,9 @@ packages:
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
javascript-lp-solver@0.4.24:
|
||||||
|
resolution: {integrity: sha512-5edoDKnMrt/u3M6GnZKDDIPxOyFOg+WrwDv8mjNiMC2DePhy2H9/FFQgf4ggywaXT1utvkxusJcjQUER72cZmA==}
|
||||||
|
|
||||||
jest-changed-files@29.7.0:
|
jest-changed-files@29.7.0:
|
||||||
resolution: {integrity: sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==}
|
resolution: {integrity: sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==}
|
||||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||||
|
@ -5782,6 +5872,10 @@ packages:
|
||||||
resolution: {integrity: sha512-EJPeIn0CYrGu+hli1xilKAPXODtJ12T0sP63Ijx2/khC2JtuaN3JyNIpvmnkmaEtha9ocbG4A4cMcr+TvqvwQg==}
|
resolution: {integrity: sha512-EJPeIn0CYrGu+hli1xilKAPXODtJ12T0sP63Ijx2/khC2JtuaN3JyNIpvmnkmaEtha9ocbG4A4cMcr+TvqvwQg==}
|
||||||
engines: {node: '>=0.6'}
|
engines: {node: '>=0.6'}
|
||||||
|
|
||||||
|
quadprog@1.6.1:
|
||||||
|
resolution: {integrity: sha512-fN5Jkcjlln/b3pJkseDKREf89JkKIyu6cKIVXisgL6ocKPQ0yTp9n6NZUAq3otEPPw78WZMG9K0o9WsfKyMWJw==}
|
||||||
|
engines: {node: '>=8.x'}
|
||||||
|
|
||||||
query-string@7.1.3:
|
query-string@7.1.3:
|
||||||
resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==}
|
resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
@ -6065,6 +6159,12 @@ packages:
|
||||||
react: '>= 16.3.0'
|
react: '>= 16.3.0'
|
||||||
react-dom: '>= 16.3.0'
|
react-dom: '>= 16.3.0'
|
||||||
|
|
||||||
|
react-dropzone@14.3.5:
|
||||||
|
resolution: {integrity: sha512-9nDUaEEpqZLOz5v5SUcFA0CjM4vq8YbqO0WRls+EYT7+DvxUdzDPKNCPLqGfj3YL9MsniCLCD4RFA6M95V6KMQ==}
|
||||||
|
engines: {node: '>= 10.13'}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>= 16.8 || 18.0.0'
|
||||||
|
|
||||||
react-hook-form@7.54.2:
|
react-hook-form@7.54.2:
|
||||||
resolution: {integrity: sha512-eHpAUgUjWbZocoQYUHposymRb4ZP6d0uwUnooL2uOybA9/3tPUvoAKqEWK1WaSiTxxOfTpffNZP7QwlnM3/gEg==}
|
resolution: {integrity: sha512-eHpAUgUjWbZocoQYUHposymRb4ZP6d0uwUnooL2uOybA9/3tPUvoAKqEWK1WaSiTxxOfTpffNZP7QwlnM3/gEg==}
|
||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
|
@ -6502,6 +6602,10 @@ packages:
|
||||||
string_decoder@1.3.0:
|
string_decoder@1.3.0:
|
||||||
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
|
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
|
||||||
|
|
||||||
|
stringify-object@5.0.0:
|
||||||
|
resolution: {integrity: sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg==}
|
||||||
|
engines: {node: '>=14.16'}
|
||||||
|
|
||||||
strip-ansi@5.2.0:
|
strip-ansi@5.2.0:
|
||||||
resolution: {integrity: sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==}
|
resolution: {integrity: sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
@ -8103,6 +8207,31 @@ snapshots:
|
||||||
|
|
||||||
'@dagrejs/graphlib@2.2.4': {}
|
'@dagrejs/graphlib@2.2.4': {}
|
||||||
|
|
||||||
|
'@dnd-kit/accessibility@3.1.1(react@18.2.0)':
|
||||||
|
dependencies:
|
||||||
|
react: 18.2.0
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@dnd-kit/core@6.3.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
|
||||||
|
dependencies:
|
||||||
|
'@dnd-kit/accessibility': 3.1.1(react@18.2.0)
|
||||||
|
'@dnd-kit/utilities': 3.2.2(react@18.2.0)
|
||||||
|
react: 18.2.0
|
||||||
|
react-dom: 18.2.0(react@18.2.0)
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0)':
|
||||||
|
dependencies:
|
||||||
|
'@dnd-kit/core': 6.3.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||||
|
'@dnd-kit/utilities': 3.2.2(react@18.2.0)
|
||||||
|
react: 18.2.0
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@dnd-kit/utilities@3.2.2(react@18.2.0)':
|
||||||
|
dependencies:
|
||||||
|
react: 18.2.0
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@emnapi/runtime@1.3.1':
|
'@emnapi/runtime@1.3.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
@ -8738,7 +8867,7 @@ snapshots:
|
||||||
webpack: 5.97.1(@swc/core@1.10.6)
|
webpack: 5.97.1(@swc/core@1.10.6)
|
||||||
webpack-node-externals: 3.0.0
|
webpack-node-externals: 3.0.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@swc/core': 1.10.6
|
'@swc/core': 1.10.6(@swc/helpers@0.5.15)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- esbuild
|
- esbuild
|
||||||
- uglify-js
|
- uglify-js
|
||||||
|
@ -9494,7 +9623,7 @@ snapshots:
|
||||||
'@swc/core-win32-x64-msvc@1.10.6':
|
'@swc/core-win32-x64-msvc@1.10.6':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@swc/core@1.10.6':
|
'@swc/core@1.10.6(@swc/helpers@0.5.15)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@swc/counter': 0.1.3
|
'@swc/counter': 0.1.3
|
||||||
'@swc/types': 0.1.17
|
'@swc/types': 0.1.17
|
||||||
|
@ -9509,9 +9638,15 @@ snapshots:
|
||||||
'@swc/core-win32-arm64-msvc': 1.10.6
|
'@swc/core-win32-arm64-msvc': 1.10.6
|
||||||
'@swc/core-win32-ia32-msvc': 1.10.6
|
'@swc/core-win32-ia32-msvc': 1.10.6
|
||||||
'@swc/core-win32-x64-msvc': 1.10.6
|
'@swc/core-win32-x64-msvc': 1.10.6
|
||||||
|
'@swc/helpers': 0.5.15
|
||||||
|
|
||||||
'@swc/counter@0.1.3': {}
|
'@swc/counter@0.1.3': {}
|
||||||
|
|
||||||
|
'@swc/helpers@0.5.15':
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@swc/types@0.1.17':
|
'@swc/types@0.1.17':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@swc/counter': 0.1.3
|
'@swc/counter': 0.1.3
|
||||||
|
@ -10055,9 +10190,9 @@ snapshots:
|
||||||
|
|
||||||
'@ungap/structured-clone@1.2.1': {}
|
'@ungap/structured-clone@1.2.1': {}
|
||||||
|
|
||||||
'@vitejs/plugin-react-swc@3.7.2(vite@5.4.11(@types/node@20.17.12)(terser@5.37.0))':
|
'@vitejs/plugin-react-swc@3.7.2(@swc/helpers@0.5.15)(vite@5.4.11(@types/node@20.17.12)(terser@5.37.0))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@swc/core': 1.10.6
|
'@swc/core': 1.10.6(@swc/helpers@0.5.15)
|
||||||
vite: 5.4.11(@types/node@20.17.12)(terser@5.37.0)
|
vite: 5.4.11(@types/node@20.17.12)(terser@5.37.0)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@swc/helpers'
|
- '@swc/helpers'
|
||||||
|
@ -10436,6 +10571,8 @@ snapshots:
|
||||||
|
|
||||||
asynckit@0.4.0: {}
|
asynckit@0.4.0: {}
|
||||||
|
|
||||||
|
attr-accept@2.2.5: {}
|
||||||
|
|
||||||
autoprefixer@10.4.20(postcss@8.4.49):
|
autoprefixer@10.4.20(postcss@8.4.49):
|
||||||
dependencies:
|
dependencies:
|
||||||
browserslist: 4.24.3
|
browserslist: 4.24.3
|
||||||
|
@ -10936,8 +11073,19 @@ snapshots:
|
||||||
|
|
||||||
custom-error-instance@2.1.1: {}
|
custom-error-instance@2.1.1: {}
|
||||||
|
|
||||||
|
d3-array@3.2.4:
|
||||||
|
dependencies:
|
||||||
|
internmap: 2.0.3
|
||||||
|
|
||||||
d3-color@3.1.0: {}
|
d3-color@3.1.0: {}
|
||||||
|
|
||||||
|
d3-dag@1.1.0:
|
||||||
|
dependencies:
|
||||||
|
d3-array: 3.2.4
|
||||||
|
javascript-lp-solver: 0.4.24
|
||||||
|
quadprog: 1.6.1
|
||||||
|
stringify-object: 5.0.0
|
||||||
|
|
||||||
d3-dispatch@3.0.1: {}
|
d3-dispatch@3.0.1: {}
|
||||||
|
|
||||||
d3-drag@3.0.0:
|
d3-drag@3.0.0:
|
||||||
|
@ -10947,6 +11095,8 @@ snapshots:
|
||||||
|
|
||||||
d3-ease@3.0.1: {}
|
d3-ease@3.0.1: {}
|
||||||
|
|
||||||
|
d3-hierarchy@3.1.2: {}
|
||||||
|
|
||||||
d3-interpolate@3.0.1:
|
d3-interpolate@3.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
d3-color: 3.1.0
|
d3-color: 3.1.0
|
||||||
|
@ -11098,6 +11248,8 @@ snapshots:
|
||||||
|
|
||||||
electron-to-chromium@1.5.79: {}
|
electron-to-chromium@1.5.79: {}
|
||||||
|
|
||||||
|
elkjs@0.9.3: {}
|
||||||
|
|
||||||
emittery@0.13.1: {}
|
emittery@0.13.1: {}
|
||||||
|
|
||||||
emoji-regex@7.0.3: {}
|
emoji-regex@7.0.3: {}
|
||||||
|
@ -11542,6 +11694,10 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
flat-cache: 4.0.1
|
flat-cache: 4.0.1
|
||||||
|
|
||||||
|
file-selector@2.1.2:
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
filelist@1.0.4:
|
filelist@1.0.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
minimatch: 5.1.6
|
minimatch: 5.1.6
|
||||||
|
@ -11697,6 +11853,8 @@ snapshots:
|
||||||
hasown: 2.0.2
|
hasown: 2.0.2
|
||||||
math-intrinsics: 1.1.0
|
math-intrinsics: 1.1.0
|
||||||
|
|
||||||
|
get-own-enumerable-keys@1.0.0: {}
|
||||||
|
|
||||||
get-package-type@0.1.0: {}
|
get-package-type@0.1.0: {}
|
||||||
|
|
||||||
get-proto@1.0.1:
|
get-proto@1.0.1:
|
||||||
|
@ -11883,6 +12041,8 @@ snapshots:
|
||||||
strip-ansi: 6.0.1
|
strip-ansi: 6.0.1
|
||||||
wrap-ansi: 6.2.0
|
wrap-ansi: 6.2.0
|
||||||
|
|
||||||
|
internmap@2.0.3: {}
|
||||||
|
|
||||||
ioredis@5.4.2:
|
ioredis@5.4.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@ioredis/commands': 1.2.0
|
'@ioredis/commands': 1.2.0
|
||||||
|
@ -11943,6 +12103,8 @@ snapshots:
|
||||||
|
|
||||||
is-number@7.0.0: {}
|
is-number@7.0.0: {}
|
||||||
|
|
||||||
|
is-obj@3.0.0: {}
|
||||||
|
|
||||||
is-path-inside@3.0.3: {}
|
is-path-inside@3.0.3: {}
|
||||||
|
|
||||||
is-regex@1.2.1:
|
is-regex@1.2.1:
|
||||||
|
@ -11952,6 +12114,8 @@ snapshots:
|
||||||
has-tostringtag: 1.0.2
|
has-tostringtag: 1.0.2
|
||||||
hasown: 2.0.2
|
hasown: 2.0.2
|
||||||
|
|
||||||
|
is-regexp@3.1.0: {}
|
||||||
|
|
||||||
is-stream@2.0.1: {}
|
is-stream@2.0.1: {}
|
||||||
|
|
||||||
is-typed-array@1.1.15:
|
is-typed-array@1.1.15:
|
||||||
|
@ -12028,6 +12192,8 @@ snapshots:
|
||||||
filelist: 1.0.4
|
filelist: 1.0.4
|
||||||
minimatch: 3.1.2
|
minimatch: 3.1.2
|
||||||
|
|
||||||
|
javascript-lp-solver@0.4.24: {}
|
||||||
|
|
||||||
jest-changed-files@29.7.0:
|
jest-changed-files@29.7.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
execa: 5.1.1
|
execa: 5.1.1
|
||||||
|
@ -13231,6 +13397,8 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
side-channel: 1.1.0
|
side-channel: 1.1.0
|
||||||
|
|
||||||
|
quadprog@1.6.1: {}
|
||||||
|
|
||||||
query-string@7.1.3:
|
query-string@7.1.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
decode-uri-component: 0.2.2
|
decode-uri-component: 0.2.2
|
||||||
|
@ -13623,6 +13791,13 @@ snapshots:
|
||||||
react: 18.2.0
|
react: 18.2.0
|
||||||
react-dom: 18.2.0(react@18.2.0)
|
react-dom: 18.2.0(react@18.2.0)
|
||||||
|
|
||||||
|
react-dropzone@14.3.5(react@18.2.0):
|
||||||
|
dependencies:
|
||||||
|
attr-accept: 2.2.5
|
||||||
|
file-selector: 2.1.2
|
||||||
|
prop-types: 15.8.1
|
||||||
|
react: 18.2.0
|
||||||
|
|
||||||
react-hook-form@7.54.2(react@18.2.0):
|
react-hook-form@7.54.2(react@18.2.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 18.2.0
|
react: 18.2.0
|
||||||
|
@ -14135,6 +14310,12 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
safe-buffer: 5.2.1
|
safe-buffer: 5.2.1
|
||||||
|
|
||||||
|
stringify-object@5.0.0:
|
||||||
|
dependencies:
|
||||||
|
get-own-enumerable-keys: 1.0.0
|
||||||
|
is-obj: 3.0.0
|
||||||
|
is-regexp: 3.1.0
|
||||||
|
|
||||||
strip-ansi@5.2.0:
|
strip-ansi@5.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
ansi-regex: 4.1.1
|
ansi-regex: 4.1.1
|
||||||
|
@ -14280,7 +14461,7 @@ snapshots:
|
||||||
terser: 5.37.0
|
terser: 5.37.0
|
||||||
webpack: 5.97.1(@swc/core@1.10.6)
|
webpack: 5.97.1(@swc/core@1.10.6)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@swc/core': 1.10.6
|
'@swc/core': 1.10.6(@swc/helpers@0.5.15)
|
||||||
|
|
||||||
terser@5.37.0:
|
terser@5.37.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
@ -14411,7 +14592,7 @@ snapshots:
|
||||||
v8-compile-cache-lib: 3.0.1
|
v8-compile-cache-lib: 3.0.1
|
||||||
yn: 3.1.1
|
yn: 3.1.1
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@swc/core': 1.10.6
|
'@swc/core': 1.10.6(@swc/helpers@0.5.15)
|
||||||
|
|
||||||
tsconfig-paths-webpack-plugin@4.2.0:
|
tsconfig-paths-webpack-plugin@4.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
@ -14449,7 +14630,7 @@ snapshots:
|
||||||
tinyglobby: 0.2.10
|
tinyglobby: 0.2.10
|
||||||
tree-kill: 1.2.2
|
tree-kill: 1.2.2
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@swc/core': 1.10.6
|
'@swc/core': 1.10.6(@swc/helpers@0.5.15)
|
||||||
postcss: 8.4.49
|
postcss: 8.4.49
|
||||||
typescript: 5.7.2
|
typescript: 5.7.2
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
|
|
Loading…
Reference in New Issue