Merge branch 'main' of http://113.45.157.195:3003/insiinc/re-mooc
This commit is contained in:
commit
0048468c8d
|
@ -0,0 +1,19 @@
|
|||
temperature: 0.5
|
||||
maxTokens: 8192
|
||||
---
|
||||
<system>
|
||||
请扮演一名经验丰富的高级软件开发工程师,根据用户提供的指令创建、改进或扩展代码功能。
|
||||
输入要求:
|
||||
1. 用户将提供目标文件名或需要实现的功能描述。
|
||||
2. 输入中可能包括文件路径、代码风格要求,以及与功能相关的具体业务逻辑或技术细节。
|
||||
任务描述:
|
||||
1. 根据提供的文件名或功能需求,编写符合规范的代码文件或代码片段。
|
||||
2. 如果已有文件,检查并基于现有实现完善功能或修复问题。
|
||||
3. 遵循约定的开发框架、语言标准和最佳实践。
|
||||
4. 注重代码可维护性,添加适当的注释,确保逻辑清晰。
|
||||
输出要求:
|
||||
1. 仅返回生成的代码或文件内容。
|
||||
2. 全程使用中文注释
|
||||
3. 尽量避免硬编码和不必要的复杂性,以提高代码的可重用性和效率。
|
||||
4. 如功能涉及外部接口或工具调用,请确保通过注释给出清晰的说明和依赖。
|
||||
</system>
|
|
@ -4,11 +4,9 @@ maxTokens: 8192
|
|||
<system>
|
||||
角色定位:
|
||||
- 高级软件开发工程师
|
||||
- 代码文档化与知识传播专家
|
||||
注释目标:
|
||||
1. 顶部注释
|
||||
- 模块/文件整体功能描述
|
||||
- 使用场景
|
||||
2. 类注释
|
||||
- 核心功能概述
|
||||
- 设计模式解析
|
||||
|
@ -22,12 +20,9 @@ maxTokens: 8192
|
|||
- 逐行解释代码意图
|
||||
- 关键语句原理阐述
|
||||
- 高级语言特性解读
|
||||
- 潜在的设计考量
|
||||
注释风格要求:
|
||||
- 全程使用中文
|
||||
- 专业、清晰、通俗易懂
|
||||
- 面向初学者的知识传递
|
||||
- 保持技术严谨性
|
||||
输出约束:
|
||||
- 仅返回添加注释后的代码
|
||||
- 注释与代码完美融合
|
||||
|
|
|
@ -32,10 +32,11 @@
|
|||
"@hookform/resolvers": "^3.9.1",
|
||||
"@nice/client": "workspace:^",
|
||||
"@nice/common": "workspace:^",
|
||||
"@nice/config": "workspace:^",
|
||||
"@nice/iconer": "workspace:^",
|
||||
"@nice/mindmap": "workspace:^",
|
||||
"@nice/ui": "workspace:^",
|
||||
"@nice/utils": "workspace:^",
|
||||
"mind-elixir": "workspace:^",
|
||||
"@nice/ui": "workspace:^",
|
||||
"@tanstack/query-async-storage-persister": "^5.51.9",
|
||||
"@tanstack/react-query": "^5.51.21",
|
||||
"@tanstack/react-query-persist-client": "^5.51.9",
|
||||
|
@ -61,9 +62,7 @@
|
|||
"mitt": "^3.0.1",
|
||||
"quill": "2.0.3",
|
||||
"react": "18.2.0",
|
||||
"react-beautiful-dnd": "^13.1.1",
|
||||
"react-dom": "18.2.0",
|
||||
"react-dropzone": "^14.3.5",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-resizable": "^3.0.5",
|
||||
|
@ -90,4 +89,4 @@
|
|||
"typescript-eslint": "^8.0.1",
|
||||
"vite": "^5.4.1"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -24,7 +24,7 @@ function App() {
|
|||
theme={{
|
||||
algorithm: theme.defaultAlgorithm,
|
||||
token: {
|
||||
colorPrimary: "#2e75b6",
|
||||
colorPrimary: "#0088E8",
|
||||
},
|
||||
components: {},
|
||||
}}>
|
||||
|
|
|
@ -0,0 +1,48 @@
|
|||
import { Card, Rate, Tag } from 'antd';
|
||||
import { Course } from '../mockData';
|
||||
import { UserOutlined, ClockCircleOutlined } from '@ant-design/icons';
|
||||
|
||||
interface CourseCardProps {
|
||||
course: Course;
|
||||
}
|
||||
|
||||
export default function CourseCard({ course }: CourseCardProps) {
|
||||
return (
|
||||
<Card
|
||||
hoverable
|
||||
className="w-full h-full transition-all duration-300 hover:shadow-lg"
|
||||
cover={
|
||||
<img
|
||||
alt={course.title}
|
||||
src={course.thumbnail}
|
||||
className="object-cover w-full h-40"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-lg font-semibold line-clamp-2 hover:text-blue-600 transition-colors">
|
||||
{course.title}
|
||||
</h3>
|
||||
<p className="text-gray-500 text-sm">{course.instructor}</p>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Rate disabled defaultValue={course.rating} className="text-sm" />
|
||||
<span className="text-gray-500 text-sm">{course.rating}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm text-gray-500">
|
||||
<div className="flex items-center space-x-1">
|
||||
<UserOutlined className="text-gray-400" />
|
||||
<span>{course.enrollments} 人在学</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<ClockCircleOutlined className="text-gray-400" />
|
||||
<span>{course.duration}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
<Tag color="blue" className="rounded-full px-3">{course.category}</Tag>
|
||||
<Tag color="green" className="rounded-full px-3">{course.level}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
import { Pagination, Empty } from 'antd';
|
||||
import { Course } from '../mockData';
|
||||
import CourseCard from './CourseCard';
|
||||
|
||||
interface CourseListProps {
|
||||
courses: Course[];
|
||||
total: number;
|
||||
pageSize: number;
|
||||
currentPage: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export default function CourseList({
|
||||
courses,
|
||||
total,
|
||||
pageSize,
|
||||
currentPage,
|
||||
onPageChange,
|
||||
}: CourseListProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{courses.length > 0 ? (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{courses.map(course => (
|
||||
<CourseCard key={course.id} course={course} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-center mt-8">
|
||||
<Pagination
|
||||
current={currentPage}
|
||||
total={total}
|
||||
pageSize={pageSize}
|
||||
onChange={onPageChange}
|
||||
showSizeChanger={false}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Empty description="暂无相关课程" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
import { Checkbox, Divider, Radio, Space } from 'antd';
|
||||
import { categories, levels } from '../mockData';
|
||||
|
||||
interface FilterSectionProps {
|
||||
selectedCategory: string;
|
||||
selectedLevel: string;
|
||||
onCategoryChange: (category: string) => void;
|
||||
onLevelChange: (level: string) => void;
|
||||
}
|
||||
|
||||
export default function FilterSection({
|
||||
selectedCategory,
|
||||
selectedLevel,
|
||||
onCategoryChange,
|
||||
onLevelChange,
|
||||
}: FilterSectionProps) {
|
||||
return (
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium mb-4">课程分类</h3>
|
||||
<Radio.Group
|
||||
value={selectedCategory}
|
||||
onChange={(e) => onCategoryChange(e.target.value)}
|
||||
className="flex flex-col space-y-3"
|
||||
>
|
||||
<Radio value="">全部课程</Radio>
|
||||
{categories.map(category => (
|
||||
<Radio key={category} value={category}>
|
||||
{category}
|
||||
</Radio>
|
||||
))}
|
||||
</Radio.Group>
|
||||
</div>
|
||||
|
||||
<Divider className="my-6" />
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-medium mb-4">难度等级</h3>
|
||||
<Radio.Group
|
||||
value={selectedLevel}
|
||||
onChange={(e) => onLevelChange(e.target.value)}
|
||||
className="flex flex-col space-y-3"
|
||||
>
|
||||
<Radio value="">全部难度</Radio>
|
||||
{levels.map(level => (
|
||||
<Radio key={level} value={level}>
|
||||
{level}
|
||||
</Radio>
|
||||
))}
|
||||
</Radio.Group>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
export interface Course {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
instructor: string;
|
||||
price: number;
|
||||
originalPrice: number;
|
||||
category: string;
|
||||
level: string;
|
||||
thumbnail: string;
|
||||
rating: number;
|
||||
enrollments: number;
|
||||
duration: string;
|
||||
}
|
||||
|
||||
export const categories = [
|
||||
"计算机科学",
|
||||
"数据科学",
|
||||
"商业管理",
|
||||
"人工智能",
|
||||
"软件开发",
|
||||
"网络安全",
|
||||
"云计算",
|
||||
"前端开发",
|
||||
"后端开发",
|
||||
"移动开发"
|
||||
];
|
||||
|
||||
export const levels = ["入门", "初级", "中级", "高级"];
|
||||
|
||||
export const mockCourses: Course[] = Array.from({ length: 50 }, (_, i) => ({
|
||||
id: `course-${i + 1}`,
|
||||
title: `${categories[i % categories.length]}课程 ${i + 1}`,
|
||||
description: "本课程将带你深入了解该领域的核心概念和实践应用,通过实战项目提升你的专业技能。",
|
||||
instructor: `讲师 ${i + 1}`,
|
||||
price: Math.floor(Math.random() * 500 + 99),
|
||||
originalPrice: Math.floor(Math.random() * 1000 + 299),
|
||||
category: categories[i % categories.length],
|
||||
level: levels[i % levels.length],
|
||||
thumbnail: `/api/placeholder/280/160`,
|
||||
rating: Number((Math.random() * 2 + 3).toFixed(1)),
|
||||
enrollments: Math.floor(Math.random() * 10000),
|
||||
duration: `${Math.floor(Math.random() * 20 + 10)}小时`
|
||||
}));
|
|
@ -0,0 +1,73 @@
|
|||
import { useState, useMemo } from 'react';
|
||||
import { mockCourses } from './mockData';
|
||||
import FilterSection from './components/FilterSection';
|
||||
import CourseList from './components/CourseList';
|
||||
|
||||
export default function CoursesPage() {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [selectedCategory, setSelectedCategory] = useState('');
|
||||
const [selectedLevel, setSelectedLevel] = useState('');
|
||||
const pageSize = 12;
|
||||
|
||||
const filteredCourses = useMemo(() => {
|
||||
return mockCourses.filter(course => {
|
||||
const matchCategory = !selectedCategory || course.category === selectedCategory;
|
||||
const matchLevel = !selectedLevel || course.level === selectedLevel;
|
||||
return matchCategory && matchLevel;
|
||||
});
|
||||
}, [selectedCategory, selectedLevel]);
|
||||
|
||||
const paginatedCourses = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
return filteredCourses.slice(startIndex, startIndex + pageSize);
|
||||
}, [filteredCourses, currentPage]);
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex gap-4">
|
||||
{/* 左侧筛选区域 */}
|
||||
<div className="lg:w-1/5">
|
||||
<div className="sticky top-24">
|
||||
<FilterSection
|
||||
selectedCategory={selectedCategory}
|
||||
selectedLevel={selectedLevel}
|
||||
onCategoryChange={category => {
|
||||
setSelectedCategory(category);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
onLevelChange={level => {
|
||||
setSelectedLevel(level);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧课程列表区域 */}
|
||||
<div className="lg:w-4/5">
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<span className="text-gray-600">
|
||||
共找到 {filteredCourses.length} 门课程
|
||||
</span>
|
||||
</div>
|
||||
<CourseList
|
||||
courses={paginatedCourses}
|
||||
total={filteredCourses.length}
|
||||
pageSize={pageSize}
|
||||
currentPage={currentPage}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,162 @@
|
|||
import React, { useState, useCallback } from 'react';
|
||||
import { Typography, Button } from 'antd';
|
||||
import { stringToColor } from '@nice/common';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
interface CourseCategory {
|
||||
name: string;
|
||||
count: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const courseCategories: CourseCategory[] = [
|
||||
{
|
||||
name: '计算机基础',
|
||||
count: 120,
|
||||
description: '计算机组成原理、操作系统、网络等基础知识'
|
||||
},
|
||||
{
|
||||
name: '编程语言',
|
||||
count: 85,
|
||||
description: 'Python、Java、JavaScript等主流编程语言'
|
||||
},
|
||||
{
|
||||
name: '人工智能',
|
||||
count: 65,
|
||||
description: '机器学习、深度学习、自然语言处理等前沿技术'
|
||||
},
|
||||
{
|
||||
name: '数据科学',
|
||||
count: 45,
|
||||
description: '数据分析、数据可视化、商业智能等'
|
||||
},
|
||||
{
|
||||
name: '云计算',
|
||||
count: 38,
|
||||
description: '云服务、容器化、微服务架构等'
|
||||
},
|
||||
{
|
||||
name: '网络安全',
|
||||
count: 42,
|
||||
description: '网络安全基础、渗透测试、安全防护等'
|
||||
}
|
||||
];
|
||||
|
||||
const CategorySection = () => {
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
|
||||
const handleMouseEnter = useCallback((index: number) => {
|
||||
setHoveredIndex(index);
|
||||
}, []);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setHoveredIndex(null);
|
||||
}, []);
|
||||
|
||||
const displayedCategories = showAll
|
||||
? courseCategories
|
||||
: courseCategories.slice(0, 8);
|
||||
|
||||
return (
|
||||
<section className="py-32 relative overflow-hidden">
|
||||
<div className="max-w-screen-2xl mx-auto px-4 relative">
|
||||
<div className="text-center mb-24">
|
||||
<Title level={2} className="font-bold text-5xl mb-6 bg-gradient-to-r from-gray-900 via-gray-700 to-gray-800 bg-clip-text text-transparent motion-safe:animate-gradient-x">
|
||||
探索课程分类
|
||||
</Title>
|
||||
<Text type="secondary" className="text-xl font-light">
|
||||
选择你感兴趣的方向,开启学习之旅
|
||||
</Text>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{displayedCategories.map((category, index) => {
|
||||
const categoryColor = stringToColor(category.name);
|
||||
const isHovered = hoveredIndex === index;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="group relative rounded-2xl transition-all duration-700 ease-out cursor-pointer will-change-transform hover:-translate-y-2"
|
||||
onMouseEnter={() => handleMouseEnter(index)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`查看${category.name}课程类别`}
|
||||
>
|
||||
<div className="absolute -inset-0.5 bg-gradient-to-r from-gray-200 to-gray-300 opacity-50 rounded-2xl transition-all duration-700 group-hover:opacity-75" />
|
||||
<div
|
||||
className={`absolute inset-0 rounded-2xl bg-gradient-to-br from-white to-gray-50 shadow-lg transition-all duration-700 ease-out ${
|
||||
isHovered ? 'scale-[1.02] bg-opacity-95' : 'scale-100 bg-opacity-90'
|
||||
}`}
|
||||
/>
|
||||
<div
|
||||
className={`absolute inset-0 rounded-2xl transition-all duration-700 ease-out ${
|
||||
isHovered ? 'shadow-[0_8px_30px_rgb(0,0,0,0.12)]' : 'shadow-none opacity-0'
|
||||
}`}
|
||||
/>
|
||||
<div
|
||||
className={`absolute top-0 left-1/2 -translate-x-1/2 h-1 rounded-full transition-all duration-500 ease-out ${
|
||||
isHovered ? 'w-36 opacity-90' : 'w-24 opacity-60'
|
||||
}`}
|
||||
style={{ backgroundColor: categoryColor }}
|
||||
/>
|
||||
<div className="relative p-6">
|
||||
<div className="flex flex-col space-y-4 mb-4">
|
||||
<Text strong className="text-xl font-semibold tracking-tight">
|
||||
{category.name}
|
||||
</Text>
|
||||
<span
|
||||
className={`px-3 py-1 rounded-full text-sm w-fit font-medium transition-all duration-500 ease-out ${
|
||||
isHovered ? 'shadow-md scale-105' : ''
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: `${categoryColor}15`,
|
||||
color: categoryColor
|
||||
}}
|
||||
>
|
||||
{category.count} 门课程
|
||||
</span>
|
||||
</div>
|
||||
<Text type="secondary" className="block text-sm leading-relaxed opacity-90">
|
||||
{category.description}
|
||||
</Text>
|
||||
<div
|
||||
className={`mt-6 text-sm font-medium flex items-center space-x-2 transition-all duration-500 ease-out ${
|
||||
isHovered ? 'translate-x-2' : ''
|
||||
}`}
|
||||
style={{ color: categoryColor }}
|
||||
>
|
||||
<span>了解更多</span>
|
||||
<span
|
||||
className={`transform transition-all duration-500 ease-out ${
|
||||
isHovered ? 'translate-x-2' : ''
|
||||
}`}
|
||||
>
|
||||
→
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{courseCategories.length > 8 && (
|
||||
<div className="flex justify-center mt-12">
|
||||
<Button
|
||||
type="default"
|
||||
size="large"
|
||||
className="px-8 h-12 text-base font-medium hover:shadow-md transition-all duration-300"
|
||||
onClick={() => setShowAll(!showAll)}
|
||||
>
|
||||
{showAll ? '收起' : '查看更多分类'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategorySection;
|
|
@ -0,0 +1,206 @@
|
|||
import React, { useState, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button, Card, Typography, Tag, Progress } from 'antd';
|
||||
import {
|
||||
PlayCircleOutlined,
|
||||
UserOutlined,
|
||||
ClockCircleOutlined,
|
||||
TeamOutlined,
|
||||
StarOutlined,
|
||||
ArrowRightOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
interface Course {
|
||||
id: number;
|
||||
title: string;
|
||||
instructor: string;
|
||||
students: number;
|
||||
rating: number;
|
||||
level: string;
|
||||
duration: string;
|
||||
category: string;
|
||||
progress: number;
|
||||
thumbnail: string;
|
||||
}
|
||||
|
||||
interface CoursesSectionProps {
|
||||
title: string;
|
||||
description: string;
|
||||
courses: Course[];
|
||||
initialVisibleCoursesCount?: number;
|
||||
}
|
||||
|
||||
const CoursesSection: React.FC<CoursesSectionProps> = ({
|
||||
title,
|
||||
description,
|
||||
courses,
|
||||
initialVisibleCoursesCount = 8,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('全部');
|
||||
const [visibleCourses, setVisibleCourses] = useState(initialVisibleCoursesCount);
|
||||
|
||||
const categories = useMemo(() => {
|
||||
const allCategories = courses.map((course) => course.category);
|
||||
return ['全部', ...Array.from(new Set(allCategories))];
|
||||
}, [courses]);
|
||||
|
||||
const filteredCourses = useMemo(() => {
|
||||
return selectedCategory === '全部'
|
||||
? courses
|
||||
: courses.filter((course) => course.category === selectedCategory);
|
||||
}, [selectedCategory, courses]);
|
||||
|
||||
const displayedCourses = filteredCourses.slice(0, visibleCourses);
|
||||
|
||||
return (
|
||||
<section className="relative py-16 overflow-hidden">
|
||||
<div className="max-w-screen-2xl mx-auto px-4 relative">
|
||||
<div className="flex justify-between items-end mb-12">
|
||||
<div>
|
||||
<Title
|
||||
level={2}
|
||||
className="font-bold text-5xl mb-6 bg-gradient-to-r from-gray-900 via-blue-600 to-gray-800 bg-clip-text text-transparent motion-safe:animate-gradient-x"
|
||||
>
|
||||
{title}
|
||||
</Title>
|
||||
<Text type="secondary" className="text-xl font-light">
|
||||
{description}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 flex flex-wrap gap-3">
|
||||
{categories.map((category) => (
|
||||
<Tag
|
||||
key={category}
|
||||
color={selectedCategory === category ? 'blue' : 'default'}
|
||||
onClick={() => setSelectedCategory(category)}
|
||||
className={`px-4 py-2 text-base cursor-pointer hover:scale-105 transform transition-all duration-300 ${selectedCategory === category
|
||||
? 'shadow-[0_2px_8px_-4px_rgba(59,130,246,0.5)]'
|
||||
: 'hover:shadow-md'
|
||||
}`}
|
||||
>
|
||||
{category}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{displayedCourses.map((course) => (
|
||||
<Card
|
||||
key={course.id}
|
||||
hoverable
|
||||
className="group overflow-hidden rounded-2xl border-0 bg-white/70 backdrop-blur-sm
|
||||
shadow-[0_10px_40px_-15px_rgba(0,0,0,0.1)] hover:shadow-[0_20px_50px_-15px_rgba(0,0,0,0.15)]
|
||||
transition-all duration-700 ease-out transform hover:-translate-y-1 will-change-transform"
|
||||
cover={
|
||||
<div className="relative h-48 bg-gradient-to-br from-gray-900 to-gray-800 overflow-hidden">
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center transform transition-all duration-1000 ease-out group-hover:scale-110"
|
||||
style={{ backgroundImage: `url(${course.thumbnail})` }}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-tr from-black/60 via-black/40 to-transparent opacity-60 group-hover:opacity-40 transition-opacity duration-700" />
|
||||
<PlayCircleOutlined className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-6xl text-white/90 opacity-0 group-hover:opacity-100 transition-all duration-500 ease-out transform group-hover:scale-110 drop-shadow-lg" />
|
||||
{course.progress > 0 && (
|
||||
<div className="absolute bottom-0 left-0 right-0 backdrop-blur-md bg-black/20">
|
||||
<Progress
|
||||
percent={course.progress}
|
||||
showInfo={false}
|
||||
strokeColor={{
|
||||
from: '#3b82f6',
|
||||
to: '#60a5fa',
|
||||
}}
|
||||
className="m-0"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="px-2">
|
||||
<div className="flex gap-2 mb-4">
|
||||
<Tag
|
||||
color="blue"
|
||||
className="px-3 py-1 rounded-full border-0 shadow-[0_2px_8px_-4px_rgba(59,130,246,0.5)] transition-all duration-300 hover:shadow-[0_4px_12px_-4px_rgba(59,130,246,0.6)]"
|
||||
>
|
||||
{course.category}
|
||||
</Tag>
|
||||
<Tag
|
||||
color={
|
||||
course.level === '入门'
|
||||
? 'green'
|
||||
: course.level === '中级'
|
||||
? 'blue'
|
||||
: 'purple'
|
||||
}
|
||||
className="px-3 py-1 rounded-full border-0 shadow-sm transition-all duration-300 hover:shadow-md"
|
||||
>
|
||||
{course.level}
|
||||
</Tag>
|
||||
</div>
|
||||
<Title
|
||||
level={4}
|
||||
className="mb-4 line-clamp-2 font-bold leading-snug transition-colors duration-300 group-hover:text-blue-600"
|
||||
>
|
||||
{course.title}
|
||||
</Title>
|
||||
<div className="flex items-center mb-4 transition-all duration-300 group-hover:text-blue-500">
|
||||
<UserOutlined className="mr-2 text-blue-500" />
|
||||
<Text className="text-gray-600 font-medium group-hover:text-blue-500">
|
||||
{course.instructor}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex justify-between items-center mb-4 text-gray-500 text-sm">
|
||||
<span className="flex items-center">
|
||||
<ClockCircleOutlined className="mr-1.5" />
|
||||
{course.duration}
|
||||
</span>
|
||||
<span className="flex items-center">
|
||||
<TeamOutlined className="mr-1.5" />
|
||||
{course.students.toLocaleString()}
|
||||
</span>
|
||||
<span className="flex items-center text-yellow-500">
|
||||
<StarOutlined className="mr-1.5" />
|
||||
{course.rating}
|
||||
</span>
|
||||
</div>
|
||||
<div className="pt-4 border-t border-gray-100 text-center">
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
className="w-full shadow-[0_8px_20px_-6px_rgba(59,130,246,0.5)] hover:shadow-[0_12px_24px_-6px_rgba(59,130,246,0.6)]
|
||||
transform hover:translate-y-[-2px] transition-all duration-500 ease-out"
|
||||
>
|
||||
立即学习
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredCourses.length >= visibleCourses && (
|
||||
<div className=' flex items-center gap-4 justify-between mt-6'>
|
||||
<div className='h-[1px] flex-grow bg-gray-200'></div>
|
||||
<div className="flex justify-end ">
|
||||
<div
|
||||
onClick={() => navigate('/courses')}
|
||||
className="cursor-pointer tracking-widest text-gray-500 hover:text-primary font-medium flex items-center gap-2 transition-all duration-300 ease-in-out"
|
||||
>
|
||||
查看更多
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default CoursesSection;
|
|
@ -0,0 +1,222 @@
|
|||
import React, { useRef } from 'react';
|
||||
import { Typography, Tag, Carousel } from 'antd';
|
||||
import { StarFilled, UserOutlined, ReadOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
interface Teacher {
|
||||
name: string;
|
||||
title: string;
|
||||
avatar: string;
|
||||
courses: number;
|
||||
students: number;
|
||||
rating: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const featuredTeachers: Teacher[] = [
|
||||
{
|
||||
name: '张教授',
|
||||
title: '资深前端开发专家',
|
||||
avatar: '/images/teacher1.jpg',
|
||||
courses: 12,
|
||||
students: 25000,
|
||||
rating: 4.9,
|
||||
description: '前 BAT 高级工程师,10年+开发经验'
|
||||
},
|
||||
{
|
||||
name: '李教授',
|
||||
title: '算法与数据结构专家',
|
||||
avatar: '/images/teacher2.jpg',
|
||||
courses: 8,
|
||||
students: 18000,
|
||||
rating: 4.8,
|
||||
description: '计算机博士,专注算法教育8年'
|
||||
},
|
||||
{
|
||||
name: '王博士',
|
||||
title: '人工智能研究员',
|
||||
avatar: '/images/teacher3.jpg',
|
||||
courses: 15,
|
||||
students: 30000,
|
||||
rating: 4.95,
|
||||
description: '人工智能领域专家,曾主导多个大型AI项目'
|
||||
},
|
||||
{
|
||||
name: '陈教授',
|
||||
title: '云计算架构师',
|
||||
avatar: '/images/teacher4.jpg',
|
||||
courses: 10,
|
||||
students: 22000,
|
||||
rating: 4.85,
|
||||
description: '知名云服务提供商技术总监,丰富的实战经验'
|
||||
},
|
||||
{
|
||||
name: '郑老师',
|
||||
title: '移动开发专家',
|
||||
avatar: '/images/teacher5.jpg',
|
||||
courses: 14,
|
||||
students: 28000,
|
||||
rating: 4.88,
|
||||
description: '资深移动端开发者,著名互联网公司技术专家'
|
||||
}
|
||||
];
|
||||
|
||||
const generateGradientColors = (name: string) => {
|
||||
// 优化的哈希函数
|
||||
const hash = name.split('').reduce((acc, char, index) => {
|
||||
return char.charCodeAt(0) + ((acc << 5) - acc) + index;
|
||||
}, 0);
|
||||
|
||||
// 定义蓝色色相范围(210-240)
|
||||
const blueHueStart = 210;
|
||||
const blueHueRange = 30;
|
||||
|
||||
// 基础蓝色色相 - 将哈希值映射到蓝色范围内
|
||||
const baseHue = blueHueStart + Math.abs(hash % blueHueRange);
|
||||
|
||||
// 生成第二个蓝色色相,保持在蓝色范围内
|
||||
let secondHue = baseHue + 15; // 在基础色相的基础上略微偏移
|
||||
if (secondHue > blueHueStart + blueHueRange) {
|
||||
secondHue -= blueHueRange;
|
||||
}
|
||||
|
||||
// 基于输入字符串的特征调整饱和度和亮度
|
||||
const nameLength = name.length;
|
||||
const saturation = Math.max(65, Math.min(85, 75 + (nameLength % 10))); // 65-85%范围
|
||||
const lightness = Math.max(45, Math.min(65, 55 + (hash % 10))); // 45-65%范围
|
||||
|
||||
// 为第二个颜色稍微调整饱和度和亮度,创造层次感
|
||||
const saturation2 = Math.max(60, saturation - 5);
|
||||
const lightness2 = Math.min(70, lightness + 5);
|
||||
|
||||
return {
|
||||
from: `hsl(${Math.round(baseHue)}, ${Math.round(saturation)}%, ${Math.round(lightness)}%)`,
|
||||
to: `hsl(${Math.round(secondHue)}, ${Math.round(saturation2)}%, ${Math.round(lightness2)}%)`
|
||||
};
|
||||
};
|
||||
|
||||
const FeaturedTeachersSection: React.FC = () => {
|
||||
const carouselRef = useRef<any>(null);
|
||||
|
||||
const settings = {
|
||||
dots: true,
|
||||
infinite: true,
|
||||
speed: 500,
|
||||
slidesToShow: 4,
|
||||
slidesToScroll: 1,
|
||||
autoplay: true,
|
||||
autoplaySpeed: 5000,
|
||||
responsive: [
|
||||
{
|
||||
breakpoint: 1024,
|
||||
settings: {
|
||||
slidesToShow: 2,
|
||||
slidesToScroll: 1
|
||||
}
|
||||
},
|
||||
{
|
||||
breakpoint: 640,
|
||||
settings: {
|
||||
slidesToShow: 1,
|
||||
slidesToScroll: 1
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const TeacherCard = ({ teacher }: { teacher: Teacher }) => {
|
||||
const gradientColors = generateGradientColors(teacher.name);
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="bg-white rounded-2xl shadow-[0_4px_20px_-2px_rgba(0,0,0,0.1)] overflow-hidden transform transition-all duration-300 hover:shadow-[0_8px_30px_-4px_rgba(0,0,0,0.15)] hover:-translate-y-1">
|
||||
<div className="relative h-48" style={{
|
||||
background: `linear-gradient(to right, ${gradientColors.from}, ${gradientColors.to})`
|
||||
}}>
|
||||
<img
|
||||
src={teacher.avatar}
|
||||
alt={teacher.name}
|
||||
className="absolute left-1/2 bottom-0 transform -translate-x-1/2 translate-y-1/2 w-24 h-24 rounded-full border-4 border-white object-cover shadow-lg"
|
||||
/>
|
||||
</div>
|
||||
<div className="pt-16 p-6 min-h-[280px] flex flex-col">
|
||||
<div className="text-center mb-4">
|
||||
<Title level={4} className="mb-1">
|
||||
{teacher.name}
|
||||
</Title>
|
||||
<Tag color="blue" className="text-sm">
|
||||
{teacher.title}
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
<Text type="secondary" className="block text-center mb-6 line-clamp-2 min-h-[3rem]">
|
||||
{teacher.description}
|
||||
</Text>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 border-t pt-4">
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center gap-1 text-blue-600">
|
||||
<ReadOutlined className="text-lg" />
|
||||
<span className="font-bold">{teacher.courses}</span>
|
||||
</div>
|
||||
<Text type="secondary" className="text-sm">课程</Text>
|
||||
</div>
|
||||
<div className="text-center border-x">
|
||||
<div className="flex items-center justify-center gap-1 text-blue-600">
|
||||
<UserOutlined className="text-lg" />
|
||||
<span className="font-bold">{(teacher.students / 1000).toFixed(1)}k</span>
|
||||
</div>
|
||||
<Text type="secondary" className="text-sm">学员</Text>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center gap-1 text-blue-600">
|
||||
<StarFilled className="text-lg" />
|
||||
<span className="font-bold">{teacher.rating}</span>
|
||||
</div>
|
||||
<Text type="secondary" className="text-sm">评分</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="py-20 px-4 bg-gradient-to-b from-gray-50/50 to-transparent">
|
||||
<div className="max-w-screen-2xl mx-auto">
|
||||
<div className="relative z-10 text-center mb-16">
|
||||
<Title level={2} className="font-bold text-4xl mb-4">
|
||||
优秀讲师
|
||||
</Title>
|
||||
<Text type="secondary" className="text-lg">
|
||||
业界专家实战分享,传授独家经验
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className="relative group">
|
||||
<Carousel ref={carouselRef} {...settings}>
|
||||
{featuredTeachers.map((teacher, index) => (
|
||||
<TeacherCard key={index} teacher={teacher} />
|
||||
))}
|
||||
</Carousel>
|
||||
|
||||
<button
|
||||
onClick={() => carouselRef.current?.prev()}
|
||||
className="absolute left-0 top-1/2 -translate-y-1/2 -ml-4 w-10 h-10 bg-white rounded-full shadow-lg flex items-center justify-center hover:bg-gray-50 transition-colors opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<LeftOutlined />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => carouselRef.current?.next()}
|
||||
className="absolute right-0 top-1/2 -translate-y-1/2 -mr-4 w-10 h-10 bg-white rounded-full shadow-lg flex items-center justify-center hover:bg-gray-50 transition-colors opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<RightOutlined />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeaturedTeachersSection;
|
|
@ -0,0 +1,157 @@
|
|||
import React, { useRef, useCallback } from 'react';
|
||||
import { Button, Carousel, Typography } from 'antd';
|
||||
import {
|
||||
TeamOutlined,
|
||||
BookOutlined,
|
||||
StarOutlined,
|
||||
ClockCircleOutlined,
|
||||
LeftOutlined,
|
||||
RightOutlined
|
||||
} from '@ant-design/icons';
|
||||
import type { CarouselRef } from 'antd/es/carousel';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
interface CarouselItem {
|
||||
title: string;
|
||||
desc: string;
|
||||
image: string;
|
||||
action: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface PlatformStat {
|
||||
icon: React.ReactNode;
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const carouselItems: CarouselItem[] = [
|
||||
{
|
||||
title: '探索编程世界',
|
||||
desc: '从零开始学习编程,开启你的技术之旅',
|
||||
image: '/images/banner1.jpg',
|
||||
action: '立即开始',
|
||||
color: 'from-blue-600/90'
|
||||
},
|
||||
{
|
||||
title: '人工智能课程',
|
||||
desc: '掌握AI技术,引领未来发展',
|
||||
image: '/images/banner2.jpg',
|
||||
action: '了解更多',
|
||||
color: 'from-purple-600/90'
|
||||
}
|
||||
];
|
||||
|
||||
const platformStats: PlatformStat[] = [
|
||||
{ icon: <TeamOutlined />, value: '50,000+', label: '注册学员' },
|
||||
{ icon: <BookOutlined />, value: '1,000+', label: '精品课程' },
|
||||
{ icon: <StarOutlined />, value: '98%', label: '好评度' },
|
||||
{ icon: <ClockCircleOutlined />, value: '100万+', label: '学习时长' }
|
||||
];
|
||||
|
||||
const HeroSection = () => {
|
||||
const carouselRef = useRef<CarouselRef>(null);
|
||||
|
||||
const handlePrev = useCallback(() => {
|
||||
carouselRef.current?.prev();
|
||||
}, []);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
carouselRef.current?.next();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section className="relative ">
|
||||
<div className="group">
|
||||
<Carousel
|
||||
ref={carouselRef}
|
||||
autoplay
|
||||
effect="fade"
|
||||
className="h-[600px] mb-24"
|
||||
dots={{
|
||||
className: 'carousel-dots !bottom-32 !z-20',
|
||||
}}
|
||||
>
|
||||
{carouselItems.map((item, index) => (
|
||||
<div key={index} className="relative h-[600px]">
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center transform transition-[transform,filter] duration-[2000ms] group-hover:scale-105 group-hover:brightness-110 will-change-[transform,filter]"
|
||||
style={{
|
||||
backgroundImage: `url(${item.image})`,
|
||||
backfaceVisibility: 'hidden'
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={`absolute inset-0 bg-gradient-to-r ${item.color} to-transparent opacity-90 mix-blend-overlay transition-opacity duration-500`}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent" />
|
||||
|
||||
{/* Content Container */}
|
||||
<div className="relative h-full max-w-7xl mx-auto px-6 lg:px-8">
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 max-w-2xl">
|
||||
<Title
|
||||
className="text-white mb-8 text-5xl md:text-6xl xl:text-7xl !leading-tight font-bold tracking-tight"
|
||||
style={{
|
||||
transform: 'translateZ(0)'
|
||||
}}
|
||||
>
|
||||
{item.title}
|
||||
</Title>
|
||||
<Text className="text-white/95 text-lg md:text-xl block mb-12 font-light leading-relaxed">
|
||||
{item.desc}
|
||||
</Text>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
className="h-14 px-12 text-lg font-semibold bg-gradient-to-r from-primary to-primary-600 border-0 shadow-lg hover:shadow-xl hover:from-primary-600 hover:to-primary-700 hover:scale-105 transform transition-all duration-300 ease-out"
|
||||
>
|
||||
{item.action}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Carousel>
|
||||
|
||||
{/* Navigation Buttons */}
|
||||
<button
|
||||
onClick={handlePrev}
|
||||
className="absolute left-4 md:left-8 top-1/2 -translate-y-1/2 z-10 opacity-0 group-hover:opacity-100 transition-all duration-300 bg-black/20 hover:bg-black/30 w-12 h-12 flex items-center justify-center rounded-full transform hover:scale-110 hover:shadow-lg"
|
||||
aria-label="Previous slide"
|
||||
>
|
||||
<LeftOutlined className="text-white text-xl" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNext}
|
||||
className="absolute right-4 md:right-8 top-1/2 -translate-y-1/2 z-10 opacity-0 group-hover:opacity-100 transition-all duration-300 bg-black/20 hover:bg-black/30 w-12 h-12 flex items-center justify-center rounded-full transform hover:scale-110 hover:shadow-lg"
|
||||
aria-label="Next slide"
|
||||
>
|
||||
<RightOutlined className="text-white text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats Container */}
|
||||
<div className="absolute -bottom-24 left-1/2 -translate-x-1/2 w-full max-w-6xl px-4">
|
||||
<div className="rounded-2xl grid grid-cols-2 md:grid-cols-4 gap-4 md:gap-8 p-6 md:p-8 bg-white border shadow-xl hover:shadow-2xl transition-shadow duration-500 will-change-[transform,box-shadow]">
|
||||
{platformStats.map((stat, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="text-center transform hover:-translate-y-1 hover:scale-105 transition-transform duration-300 ease-out"
|
||||
>
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 mb-4 rounded-full bg-primary-50 text-primary-600 text-3xl transition-colors duration-300 group-hover:text-primary-700">
|
||||
{stat.icon}
|
||||
</div>
|
||||
<div className="text-2xl font-bold bg-gradient-to-r from-gray-800 to-gray-600 bg-clip-text text-transparent mb-1.5">
|
||||
{stat.value}
|
||||
</div>
|
||||
<div className="text-gray-600 font-medium">{stat.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroSection;
|
|
@ -1,25 +1,125 @@
|
|||
import GraphEditor from "@web/src/components/common/editor/graph/GraphEditor";
|
||||
// import FileUploader from "@web/src/components/common/uploader/FileUploader";
|
||||
import HeroSection from './components/HeroSection';
|
||||
import CategorySection from './components/CategorySection';
|
||||
import CoursesSection from './components/CoursesSection';
|
||||
import FeaturedTeachersSection from './components/FeaturedTeachersSection';
|
||||
|
||||
import React, { useState, useCallback } from "react";
|
||||
import * as tus from "tus-js-client";
|
||||
interface TusUploadProps {
|
||||
onSuccess?: (response: any) => void;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
const HomePage: React.FC<TusUploadProps> = ({ onSuccess, onError }) => {
|
||||
return (
|
||||
<div>
|
||||
{/* <FileUploader></FileUploader> */}
|
||||
<div className="w-full" style={{ height: 800 }}>
|
||||
<GraphEditor></GraphEditor>
|
||||
</div>
|
||||
{/* <div className=' h-screen'>
|
||||
<MindMap></MindMap>
|
||||
</div> */}
|
||||
{/* <MindMapEditor></MindMapEditor> */}
|
||||
</div>
|
||||
);
|
||||
const HomePage = () => {
|
||||
const mockCourses = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Python 零基础入门',
|
||||
instructor: '张教授',
|
||||
students: 12000,
|
||||
rating: 4.8,
|
||||
level: '入门',
|
||||
duration: '36小时',
|
||||
category: '编程语言',
|
||||
progress: 0,
|
||||
thumbnail: '/images/course1.jpg',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '数据结构与算法',
|
||||
instructor: '李教授',
|
||||
students: 8500,
|
||||
rating: 4.9,
|
||||
level: '进阶',
|
||||
duration: '48小时',
|
||||
category: '计算机基础',
|
||||
progress: 35,
|
||||
thumbnail: '/images/course2.jpg',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '前端开发实战',
|
||||
instructor: '王教授',
|
||||
students: 10000,
|
||||
rating: 4.7,
|
||||
level: '中级',
|
||||
duration: '42小时',
|
||||
category: '前端开发',
|
||||
progress: 68,
|
||||
thumbnail: '/images/course3.jpg',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'Java企业级开发',
|
||||
instructor: '刘教授',
|
||||
students: 9500,
|
||||
rating: 4.6,
|
||||
level: '高级',
|
||||
duration: '56小时',
|
||||
category: '编程语言',
|
||||
progress: 0,
|
||||
thumbnail: '/images/course4.jpg',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: '人工智能基础',
|
||||
instructor: '陈教授',
|
||||
students: 11000,
|
||||
rating: 4.9,
|
||||
level: '中级',
|
||||
duration: '45小时',
|
||||
category: '人工智能',
|
||||
progress: 20,
|
||||
thumbnail: '/images/course5.jpg',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: '大数据分析',
|
||||
instructor: '赵教授',
|
||||
students: 8000,
|
||||
rating: 4.8,
|
||||
level: '进阶',
|
||||
duration: '50小时',
|
||||
category: '数据科学',
|
||||
progress: 45,
|
||||
thumbnail: '/images/course6.jpg',
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
title: '云计算实践',
|
||||
instructor: '孙教授',
|
||||
students: 7500,
|
||||
rating: 4.7,
|
||||
level: '高级',
|
||||
duration: '48小时',
|
||||
category: '云计算',
|
||||
progress: 15,
|
||||
thumbnail: '/images/course7.jpg',
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
title: '移动应用开发',
|
||||
instructor: '周教授',
|
||||
students: 9000,
|
||||
rating: 4.8,
|
||||
level: '中级',
|
||||
duration: '40小时',
|
||||
category: '移动开发',
|
||||
progress: 0,
|
||||
thumbnail: '/images/course8.jpg',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<HeroSection />
|
||||
<CoursesSection
|
||||
title="推荐课程"
|
||||
description="最受欢迎的精品课程,助你快速成长"
|
||||
courses={mockCourses}
|
||||
/>
|
||||
<CoursesSection
|
||||
title="热门课程"
|
||||
description="最受欢迎的精品课程,助你快速成长"
|
||||
courses={mockCourses}
|
||||
/>
|
||||
<CategorySection />
|
||||
<FeaturedTeachersSection />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomePage;
|
||||
export default HomePage;
|
|
@ -0,0 +1,68 @@
|
|||
import { CloudOutlined, FileSearchOutlined, HomeOutlined, MailOutlined, PhoneOutlined } from '@ant-design/icons';
|
||||
import { Layout, Typography } from 'antd';
|
||||
export function MainFooter() {
|
||||
return (
|
||||
<footer className="bg-gradient-to-b from-slate-800 to-slate-900 text-secondary-200">
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* 开发组织信息 */}
|
||||
<div className="text-center md:text-left space-y-2">
|
||||
<h3 className="text-white font-semibold text-sm flex items-center justify-center md:justify-start">
|
||||
创新高地 软件小组
|
||||
</h3>
|
||||
<p className="text-gray-400 text-xs italic">
|
||||
提供技术支持
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 联系方式 */}
|
||||
<div className="text-center space-y-2">
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<PhoneOutlined className="text-gray-400" />
|
||||
<span className="text-gray-300 text-xs">628118</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<MailOutlined className="text-gray-400" />
|
||||
<span className="text-gray-300 text-xs">gcsjs6@tx3l.nb.kj</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 系统链接 */}
|
||||
<div className="text-center md:text-right space-y-2">
|
||||
<div className="flex items-center justify-center md:justify-end space-x-4">
|
||||
<a
|
||||
href="https://27.57.72.21"
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
title="访问门户网站"
|
||||
>
|
||||
<HomeOutlined className="text-lg" />
|
||||
</a>
|
||||
<a
|
||||
href="https://27.57.72.14"
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
title="访问烽火青云"
|
||||
>
|
||||
<CloudOutlined className="text-lg" />
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="http://27.57.72.38"
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
title="访问烽火律询"
|
||||
>
|
||||
<FileSearchOutlined className="text-lg" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 版权信息 */}
|
||||
<div className="border-t border-gray-700/50 mt-4 pt-4 text-center">
|
||||
<p className="text-gray-400 text-xs">
|
||||
© {new Date().getFullYear()} 南天烽火. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,65 @@
|
|||
import { useState } from 'react';
|
||||
import { Input, Layout, Avatar, Button, Dropdown } from 'antd';
|
||||
import { SearchOutlined, UserOutlined } from '@ant-design/icons';
|
||||
import { useAuth } from '@web/src/providers/auth-provider';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { UserMenu } from './UserMenu';
|
||||
import { NavigationMenu } from './NavigationMenu';
|
||||
|
||||
const { Header } = Layout;
|
||||
|
||||
export function MainHeader() {
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Header className="select-none flex items-center justify-center bg-white shadow-md border-b border-gray-100 fixed w-full z-30">
|
||||
<div className="w-full max-w-screen-2xl px-4 md:px-6 mx-auto flex items-center justify-between h-full">
|
||||
<div className="flex items-center space-x-8">
|
||||
<div
|
||||
onClick={() => navigate('/')}
|
||||
className="text-2xl font-bold bg-gradient-to-r from-primary-600 via-primary-500 to-primary-400 bg-clip-text text-transparent hover:scale-105 transition-transform cursor-pointer"
|
||||
>
|
||||
烽火慕课
|
||||
</div>
|
||||
<NavigationMenu />
|
||||
</div>
|
||||
<div className="flex items-center space-x-6">
|
||||
<div className="group relative">
|
||||
<Input
|
||||
size="large"
|
||||
prefix={<SearchOutlined className="text-gray-400 group-hover:text-blue-500 transition-colors" />}
|
||||
placeholder="搜索课程"
|
||||
className="w-72 rounded-full"
|
||||
value={searchValue}
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{isAuthenticated ? (
|
||||
<Dropdown
|
||||
overlay={<UserMenu />}
|
||||
trigger={['click']}
|
||||
placement="bottomRight"
|
||||
>
|
||||
<Avatar
|
||||
size="large"
|
||||
className="cursor-pointer hover:scale-105 transition-all bg-gradient-to-r from-blue-500 to-blue-600 text-white font-semibold"
|
||||
>
|
||||
{(user?.showname || user?.username || '')[0]?.toUpperCase()}
|
||||
</Avatar>
|
||||
</Dropdown>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => navigate('/login')}
|
||||
className="flex items-center space-x-1 bg-gradient-to-r from-blue-500 to-blue-600 text-white hover:from-blue-600 hover:to-blue-700 border-none shadow-md hover:shadow-lg transition-all"
|
||||
icon={<UserOutlined />}
|
||||
>
|
||||
登录
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Header>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
import { Layout } from 'antd';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { MainHeader } from './MainHeader';
|
||||
import { MainFooter } from './MainFooter';
|
||||
|
||||
const { Content } = Layout;
|
||||
|
||||
export function MainLayout() {
|
||||
return (
|
||||
<Layout className="min-h-screen">
|
||||
<MainHeader />
|
||||
<Content className="mt-16 bg-gray-50">
|
||||
<Outlet />
|
||||
</Content>
|
||||
<MainFooter />
|
||||
</Layout>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
import { Menu } from 'antd';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
|
||||
const menuItems = [
|
||||
{ key: 'home', path: '/', label: '首页' },
|
||||
{ key: 'courses', path: '/courses', label: '全部课程' },
|
||||
{ key: 'paths', path: '/paths', label: '学习路径' }
|
||||
];
|
||||
|
||||
export const NavigationMenu = () => {
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
const selectedKey = menuItems.find(item => item.path === pathname)?.key || '';
|
||||
return (
|
||||
<Menu
|
||||
mode="horizontal"
|
||||
className="border-none font-medium"
|
||||
selectedKeys={[selectedKey]}
|
||||
onClick={({ key }) => {
|
||||
const selectedItem = menuItems.find(item => item.key === key);
|
||||
if (selectedItem) navigate(selectedItem.path);
|
||||
}}
|
||||
>
|
||||
{menuItems.map(({ key, label }) => (
|
||||
<Menu.Item key={key} className="text-gray-600 hover:text-blue-600">
|
||||
{label}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,49 @@
|
|||
import { Avatar, Menu, Dropdown } from 'antd';
|
||||
import { LogoutOutlined, SettingOutlined } from '@ant-design/icons';
|
||||
import { useAuth } from '@web/src/providers/auth-provider';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export const UserMenu = () => {
|
||||
const { isAuthenticated, logout, user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Menu className="w-48">
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<Menu.Item key="profile" className="px-4 py-2">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Avatar className="bg-gradient-to-r from-blue-500 to-blue-600 text-white font-semibold">
|
||||
{(user?.showname || user?.username || '')[0]?.toUpperCase()}
|
||||
</Avatar>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{user?.showname || user?.username}</span>
|
||||
<span className="text-xs text-gray-500">{user?.department?.name || user?.officerId}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item key="settings" icon={<SettingOutlined />} className="px-4">
|
||||
个人设置
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
key="logout"
|
||||
icon={<LogoutOutlined />}
|
||||
onClick={async () => await logout()}
|
||||
className="px-4 text-red-500 hover:text-red-600 hover:bg-red-50"
|
||||
>
|
||||
退出登录
|
||||
</Menu.Item>
|
||||
</>
|
||||
) : (
|
||||
<Menu.Item
|
||||
key="login"
|
||||
onClick={() => navigate("/login")}
|
||||
className="px-4 text-blue-500 hover:text-blue-600 hover:bg-blue-50"
|
||||
>
|
||||
登录/注册
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,3 @@
|
|||
export default function PathsPage() {
|
||||
return <>paths</>
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
export default function MyCoursePage() {
|
||||
return (
|
||||
<div>
|
||||
My Course Page
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export default function ProfilesPage() {
|
||||
return <>Profiles</>
|
||||
}
|
|
@ -1,47 +0,0 @@
|
|||
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;
|
|
@ -1,17 +1,58 @@
|
|||
import { BaseEdge, Edge, EdgeLabelRenderer, EdgeProps, getBezierPath, getSmoothStepPath, getStraightPath, useReactFlow } from '@xyflow/react';
|
||||
import { BaseEdge, Node, Edge, EdgeLabelRenderer, EdgeProps, getBezierPath, getSmoothStepPath, getStraightPath, Position, useReactFlow, useInternalNode, InternalNode } 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,
|
||||
});
|
||||
function getEdgeParams(sourceNode: InternalNode, targetNode: InternalNode) {
|
||||
console.log(sourceNode)
|
||||
const sourceCenter = {
|
||||
x: sourceNode.position.x + sourceNode.width / 2,
|
||||
y: sourceNode.position.y + sourceNode.height / 2,
|
||||
};
|
||||
const targetCenter = {
|
||||
x: targetNode.position.x + targetNode.width / 2,
|
||||
y: targetNode.position.y + targetNode.height / 2,
|
||||
};
|
||||
|
||||
const dx = targetCenter.x - sourceCenter.x;
|
||||
|
||||
// 简化连接逻辑:只基于x轴方向判断
|
||||
let sourcePos: Position;
|
||||
let targetPos: Position;
|
||||
|
||||
// 如果目标在源节点右边,源节点用右侧连接点,目标节点用左侧连接点
|
||||
if (dx > 0) {
|
||||
sourcePos = Position.Right;
|
||||
targetPos = Position.Left;
|
||||
} else {
|
||||
// 如果目标在源节点左边,源节点用左侧连接点,目标节点用右侧连接点
|
||||
sourcePos = Position.Left;
|
||||
targetPos = Position.Right;
|
||||
}
|
||||
|
||||
// 使用节点中心的y坐标
|
||||
return {
|
||||
sourcePos,
|
||||
targetPos,
|
||||
sx: sourceCenter.x + sourceNode.measured.width / 2,
|
||||
sy: sourceCenter.y,
|
||||
tx: targetCenter.x - targetNode.measured.width / 2,
|
||||
ty: targetCenter.y,
|
||||
};
|
||||
}
|
||||
export const GraphEdge = ({ id, source, target, sourceX, sourceY, targetX, targetY, data, ...props }: EdgeProps<GraphEdge>) => {
|
||||
const sourceNode = useInternalNode(source);
|
||||
const targetNode = useInternalNode(target);
|
||||
const { sx, sy, tx, ty, targetPos, sourcePos } = getEdgeParams(sourceNode, targetNode)
|
||||
const [edgePath, labelX, labelY] = getBezierPath({
|
||||
sourceX: sx,
|
||||
sourceY: sy,
|
||||
targetX: tx,
|
||||
targetY: ty,
|
||||
sourcePosition: sourcePos,
|
||||
targetPosition: targetPos,
|
||||
curvature: 0.3,
|
||||
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<BaseEdge
|
||||
|
@ -21,9 +62,7 @@ export const GraphEdge = ({ id, sourceX, sourceY, targetX, targetY, data, ...pro
|
|||
stroke: '#b1b1b7',
|
||||
transition: 'stroke 0.3s, stroke-width 0.3s',
|
||||
}}
|
||||
className="hover:stroke-blue-500 hover:stroke-[3px]"
|
||||
/>
|
||||
{/* 添加边的标签渲染器 */}
|
||||
<EdgeLabelRenderer>
|
||||
{data?.text && (
|
||||
<div
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
import { LayoutOptions, LayoutStrategy, NodeWithLayout } from "./types";
|
||||
import { Edge, Node } from "@xyflow/react";
|
||||
// 抽象布局类,包含共用的工具方法
|
||||
export abstract class BaseLayout implements LayoutStrategy {
|
||||
protected buildNodeMap(nodes: Node[]): Map<string, NodeWithLayout> {
|
||||
const nodeMap = new Map<string, NodeWithLayout>();
|
||||
nodes.forEach(node => {
|
||||
nodeMap.set(node.id, { ...node, children: [], width: 150, height: 40 });
|
||||
});
|
||||
return nodeMap;
|
||||
}
|
||||
|
||||
protected buildTreeStructure(nodeMap: Map<string, NodeWithLayout>, edges: Edge[]): NodeWithLayout | undefined {
|
||||
edges.forEach(edge => {
|
||||
const source = nodeMap.get(edge.source);
|
||||
const target = nodeMap.get(edge.target);
|
||||
if (source && target) {
|
||||
source.children?.push(target);
|
||||
target.parent = source;
|
||||
}
|
||||
});
|
||||
return Array.from(nodeMap.values()).find(node => !node.parent);
|
||||
}
|
||||
|
||||
abstract layout(options: LayoutOptions): { nodes: Node[], edges: Edge[] };
|
||||
}
|
|
@ -0,0 +1,87 @@
|
|||
import { Edge,Node } from "@xyflow/react";
|
||||
import { BaseLayout } from "./BaseLayout";
|
||||
import { LayoutOptions, NodeWithLayout } from "./types";
|
||||
|
||||
// 思维导图布局实现
|
||||
export class MindMapLayout extends BaseLayout {
|
||||
layout(options: LayoutOptions): { nodes: Node[], edges: Edge[] } {
|
||||
const {
|
||||
nodes,
|
||||
edges,
|
||||
levelSeparation = 200,
|
||||
nodeSeparation = 60
|
||||
} = options;
|
||||
|
||||
const nodeMap = this.buildNodeMap(nodes);
|
||||
const rootNode = this.buildTreeStructure(nodeMap, edges);
|
||||
if (!rootNode) return { nodes, edges };
|
||||
|
||||
this.assignSides(rootNode);
|
||||
this.calculateSubtreeHeight(rootNode, nodeSeparation);
|
||||
this.calculateLayout(rootNode, 0, 0, levelSeparation, nodeSeparation);
|
||||
|
||||
const layoutedNodes = Array.from(nodeMap.values()).map(node => ({
|
||||
...node,
|
||||
position: node.position,
|
||||
}));
|
||||
|
||||
return { nodes: layoutedNodes, edges };
|
||||
}
|
||||
|
||||
private assignSides(node: NodeWithLayout, isRight: boolean = true): void {
|
||||
if (!node.children?.length) return;
|
||||
|
||||
const len = node.children.length;
|
||||
const midIndex = Math.floor(len / 2);
|
||||
|
||||
if (!node.parent) {
|
||||
for (let i = 0; i < len; i++) {
|
||||
const child = node.children[i];
|
||||
this.assignSides(child, i < midIndex);
|
||||
child.isRight = i < midIndex;
|
||||
}
|
||||
} else {
|
||||
node.children.forEach(child => {
|
||||
this.assignSides(child, isRight);
|
||||
child.isRight = isRight;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private calculateSubtreeHeight(node: NodeWithLayout, nodeSeparation: number): number {
|
||||
if (!node.children?.length) {
|
||||
node.subtreeHeight = node.height || 40;
|
||||
return node.subtreeHeight;
|
||||
}
|
||||
|
||||
const childrenHeight = node.children.reduce((sum, child) => {
|
||||
return sum + this.calculateSubtreeHeight(child, nodeSeparation);
|
||||
}, 0);
|
||||
|
||||
const totalGaps = (node.children.length - 1) * nodeSeparation;
|
||||
node.subtreeHeight = Math.max(node.height || 40, childrenHeight + totalGaps);
|
||||
return node.subtreeHeight;
|
||||
}
|
||||
|
||||
private calculateLayout(
|
||||
node: NodeWithLayout,
|
||||
x: number,
|
||||
y: number,
|
||||
levelSeparation: number,
|
||||
nodeSeparation: number
|
||||
): void {
|
||||
node.position = { x, y };
|
||||
if (!node.children?.length) return;
|
||||
|
||||
let currentY = y - (node.subtreeHeight || 0) / 2;
|
||||
|
||||
node.children.forEach(child => {
|
||||
const direction = child.isRight ? 1 : -1;
|
||||
const childX = x + (levelSeparation * direction);
|
||||
const childY = currentY + (child.subtreeHeight || 0) / 2;
|
||||
|
||||
this.calculateLayout(child, childX, childY, levelSeparation, nodeSeparation);
|
||||
currentY += (child.subtreeHeight || 0) + nodeSeparation;
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,127 @@
|
|||
import { Edge, Node } from "@xyflow/react";
|
||||
import { BaseLayout } from "./BaseLayout";
|
||||
import { LayoutOptions, NodeWithLayout } from "./types";
|
||||
|
||||
/**
|
||||
* SingleMapLayout 类继承自 BaseLayout,用于实现单图布局。
|
||||
* 该类主要负责将节点和边按照一定的规则进行布局,使得节点在视觉上呈现出层次分明、结构清晰的效果。
|
||||
*/
|
||||
export class SingleMapLayout extends BaseLayout {
|
||||
/**
|
||||
* 布局方法,根据提供的选项对节点和边进行布局。
|
||||
* @param options 布局选项,包含节点、边、层级间距和节点间距等信息。
|
||||
* @returns 返回布局后的节点和边。
|
||||
*/
|
||||
layout(options: LayoutOptions): { nodes: Node[], edges: Edge[] } {
|
||||
const { nodes, edges, levelSeparation = 100, nodeSeparation = 30 } = options;
|
||||
const nodeMap = this.buildNodeMap(nodes);
|
||||
const root = this.buildTreeStructure(nodeMap, edges);
|
||||
|
||||
if (!root) {
|
||||
return { nodes: [], edges: [] };
|
||||
}
|
||||
|
||||
// 计算子树的尺寸
|
||||
this.calculateSubtreeDimensions(root);
|
||||
|
||||
// 第一遍:分配垂直位置
|
||||
this.assignInitialVerticalPositions(root, 0);
|
||||
|
||||
// 第二遍:使用平衡布局定位节点
|
||||
this.positionNodes(root, 0, 0, levelSeparation, nodeSeparation, 'right');
|
||||
|
||||
return {
|
||||
nodes: Array.from(nodeMap.values()),
|
||||
edges
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算子树的尺寸,包括高度和宽度。
|
||||
* @param node 当前节点。
|
||||
*/
|
||||
private calculateSubtreeDimensions(node: NodeWithLayout): void {
|
||||
|
||||
node.subtreeHeight = node.height || 40;
|
||||
node.subtreeWidth = node.width || 150;
|
||||
|
||||
if (node.children && node.children.length > 0) {
|
||||
// 首先计算所有子节点的尺寸
|
||||
node.children.forEach(child => this.calculateSubtreeDimensions(child));
|
||||
|
||||
// 计算子节点所需的总高度,包括间距
|
||||
const totalChildrenHeight = this.calculateTotalChildrenHeight(node.children, 30);
|
||||
|
||||
// 更新节点的子树尺寸
|
||||
node.subtreeHeight = Math.max(node.subtreeHeight, totalChildrenHeight);
|
||||
node.subtreeWidth += Math.max(...node.children.map(child => child.subtreeWidth || 0));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算子节点的总高度。
|
||||
* @param children 子节点数组。
|
||||
* @param spacing 子节点之间的间距。
|
||||
* @returns 返回子节点的总高度。
|
||||
*/
|
||||
private calculateTotalChildrenHeight(children: NodeWithLayout[], spacing: number): number {
|
||||
if (!children.length) return 0;
|
||||
const totalHeight = children.reduce((sum, child) => sum + (child.subtreeHeight || 0), 0);
|
||||
return totalHeight + (spacing * (children.length - 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* 分配初始垂直位置。
|
||||
* @param node 当前节点。
|
||||
* @param level 当前层级。
|
||||
*/
|
||||
private assignInitialVerticalPositions(node: NodeWithLayout, level: number): void {
|
||||
if (!node.children?.length) return;
|
||||
const totalHeight = this.calculateTotalChildrenHeight(node.children, 30);
|
||||
let currentY = -(totalHeight / 2);
|
||||
node.children.forEach(child => {
|
||||
const childHeight = child.subtreeHeight || 0;
|
||||
child.verticalLevel = level + 1;
|
||||
child.relativeY = currentY + (childHeight / 2);
|
||||
this.assignInitialVerticalPositions(child, level + 1);
|
||||
currentY += childHeight + 30; // 30 是垂直间距
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 定位节点。
|
||||
* @param node 当前节点。
|
||||
* @param x 当前节点的水平位置。
|
||||
* @param y 当前节点的垂直位置。
|
||||
* @param levelSeparation 层级间距。
|
||||
* @param nodeSeparation 节点间距。
|
||||
* @param direction 布局方向,'left' 或 'right'。
|
||||
*/
|
||||
private positionNodes(
|
||||
node: NodeWithLayout,
|
||||
x: number,
|
||||
y: number,
|
||||
levelSeparation: number,
|
||||
nodeSeparation: number,
|
||||
direction: 'left' | 'right'
|
||||
): void {
|
||||
node.position = { x, y };
|
||||
if (!node.children?.length) return;
|
||||
// 计算子节点的水平位置
|
||||
const nextX = direction === 'right'
|
||||
? x + (node.width || 0) + levelSeparation
|
||||
: x - (node.width || 0) - levelSeparation;
|
||||
// 定位每个子节点
|
||||
node.children.forEach(child => {
|
||||
const childY = y + (child.relativeY || 0);
|
||||
this.positionNodes(
|
||||
child,
|
||||
nextX,
|
||||
childY,
|
||||
levelSeparation,
|
||||
nodeSeparation,
|
||||
direction
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,68 @@
|
|||
import { BaseLayout } from "./BaseLayout";
|
||||
import { LayoutOptions, LayoutStrategy, NodeWithLayout } from "./types";
|
||||
import { Edge, Node } from "@xyflow/react";
|
||||
export class TreeLayout extends BaseLayout {
|
||||
layout(options: LayoutOptions): { nodes: Node[], edges: Edge[] } {
|
||||
const {
|
||||
nodes,
|
||||
edges,
|
||||
levelSeparation = 100, // 层级间垂直距离
|
||||
nodeSeparation = 50 // 节点间水平距离
|
||||
} = options;
|
||||
|
||||
const nodeMap = this.buildNodeMap(nodes);
|
||||
const rootNode = this.buildTreeStructure(nodeMap, edges);
|
||||
if (!rootNode) return { nodes, edges };
|
||||
// 计算每个节点的子树宽度
|
||||
this.calculateSubtreeWidth(rootNode, nodeSeparation);
|
||||
// 计算布局位置
|
||||
this.calculateTreeLayout(rootNode, 0, 0, levelSeparation);
|
||||
const layoutedNodes = Array.from(nodeMap.values()).map(node => ({
|
||||
...node,
|
||||
position: node.position,
|
||||
}));
|
||||
return { nodes: layoutedNodes, edges };
|
||||
}
|
||||
|
||||
private calculateSubtreeWidth(node: NodeWithLayout, nodeSeparation: number): number {
|
||||
if (!node.children?.length) {
|
||||
node.subtreeWidth = node.width || 150;
|
||||
return node.subtreeWidth;
|
||||
}
|
||||
|
||||
const childrenWidth = node.children.reduce((sum, child) => {
|
||||
return sum + this.calculateSubtreeWidth(child, nodeSeparation);
|
||||
}, 0);
|
||||
|
||||
const totalGaps = (node.children.length - 1) * nodeSeparation;
|
||||
node.subtreeWidth = Math.max(node.width || 150, childrenWidth + totalGaps);
|
||||
return node.subtreeWidth;
|
||||
}
|
||||
|
||||
private calculateTreeLayout(
|
||||
node: NodeWithLayout,
|
||||
x: number,
|
||||
y: number,
|
||||
levelSeparation: number
|
||||
): void {
|
||||
node.position = { x, y };
|
||||
|
||||
if (!node.children?.length) return;
|
||||
|
||||
const totalChildrenWidth = node.children.reduce((sum, child) =>
|
||||
sum + (child.subtreeWidth || 0), 0);
|
||||
const totalGaps = (node.children.length - 1) * (node.width || 150);
|
||||
|
||||
// 计算最左侧子节点的起始x坐标
|
||||
let startX = x - (totalChildrenWidth + totalGaps) / 2;
|
||||
|
||||
node.children.forEach(child => {
|
||||
const childX = startX + (child.subtreeWidth || 0) / 2;
|
||||
const childY = y + levelSeparation;
|
||||
|
||||
this.calculateTreeLayout(child, childX, childY, levelSeparation);
|
||||
|
||||
startX += (child.subtreeWidth || 0) + (node.width || 150);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,124 +1,34 @@
|
|||
import { Node, Edge } from "@xyflow/react";
|
||||
import { LayoutOptions, LayoutStrategy } from "./types";
|
||||
import { TreeLayout } from "./TreeLayout";
|
||||
import { MindMapLayout } from "./MindMapLayout";
|
||||
import { SingleMapLayout } from "./SingleMapLayout";
|
||||
|
||||
interface LayoutOptions {
|
||||
nodes: Node[];
|
||||
edges: Edge[];
|
||||
levelSeparation?: number;
|
||||
nodeSeparation?: number;
|
||||
// 布局工厂类
|
||||
class LayoutFactory {
|
||||
static createLayout(type: 'mindmap' | 'tree' | 'force' | 'single'): LayoutStrategy {
|
||||
switch (type) {
|
||||
case 'mindmap':
|
||||
return new MindMapLayout();
|
||||
case 'tree':
|
||||
return new TreeLayout();
|
||||
case 'single':
|
||||
return new SingleMapLayout()
|
||||
case 'force':
|
||||
// return new ForceLayout(); // 待实现
|
||||
default:
|
||||
return new MindMapLayout();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface NodeWithLayout extends Node {
|
||||
width?: number;
|
||||
height?: number;
|
||||
children?: NodeWithLayout[];
|
||||
parent?: NodeWithLayout;
|
||||
subtreeHeight?: number;
|
||||
isRight?: boolean;
|
||||
// 导出布局函数
|
||||
export function getLayout(type: 'mindmap' | 'tree' | 'force' | 'single', options: LayoutOptions) {
|
||||
const layoutStrategy = LayoutFactory.createLayout(type);
|
||||
return layoutStrategy.layout(options);
|
||||
}
|
||||
|
||||
// 为了保持向后兼容,保留原有的导出
|
||||
export function getMindMapLayout(options: LayoutOptions) {
|
||||
const {
|
||||
nodes,
|
||||
edges,
|
||||
levelSeparation = 200,
|
||||
nodeSeparation = 60
|
||||
} = options;
|
||||
|
||||
// 构建树形结构
|
||||
const nodeMap = new Map<string, NodeWithLayout>();
|
||||
nodes.forEach(node => {
|
||||
nodeMap.set(node.id, { ...node, children: [], width: 150, height: 40 });
|
||||
});
|
||||
|
||||
let rootNode: NodeWithLayout | undefined;
|
||||
edges.forEach(edge => {
|
||||
const source = nodeMap.get(edge.source);
|
||||
const target = nodeMap.get(edge.target);
|
||||
if (source && target) {
|
||||
source.children?.push(target);
|
||||
target.parent = source;
|
||||
}
|
||||
});
|
||||
|
||||
// 找到根节点
|
||||
rootNode = Array.from(nodeMap.values()).find(node => !node.parent);
|
||||
if (!rootNode) return { nodes, edges };
|
||||
|
||||
// 分配节点到左右两侧
|
||||
function assignSides(node: NodeWithLayout, isRight: boolean = true) {
|
||||
if (!node.children?.length) return;
|
||||
|
||||
const len = node.children.length;
|
||||
const midIndex = Math.floor(len / 2);
|
||||
|
||||
// 如果是根节点,将子节点分为左右两部分
|
||||
if (!node.parent) {
|
||||
for (let i = 0; i < len; i++) {
|
||||
const child = node.children[i];
|
||||
assignSides(child, i < midIndex);
|
||||
child.isRight = i < midIndex;
|
||||
}
|
||||
}
|
||||
// 如果不是根节点,所有子节点继承父节点的方向
|
||||
else {
|
||||
node.children.forEach(child => {
|
||||
assignSides(child, isRight);
|
||||
child.isRight = isRight;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 计算子树高度
|
||||
function calculateSubtreeHeight(node: NodeWithLayout): number {
|
||||
if (!node.children?.length) {
|
||||
node.subtreeHeight = node.height || 40;
|
||||
return node.subtreeHeight;
|
||||
}
|
||||
|
||||
const childrenHeight = node.children.reduce((sum, child) => {
|
||||
return sum + calculateSubtreeHeight(child);
|
||||
}, 0);
|
||||
|
||||
const totalGaps = (node.children.length - 1) * nodeSeparation;
|
||||
node.subtreeHeight = Math.max(node.height || 40, childrenHeight + totalGaps);
|
||||
return node.subtreeHeight;
|
||||
}
|
||||
|
||||
// 布局计算
|
||||
function calculateLayout(node: NodeWithLayout, x: number, y: number) {
|
||||
node.position = { x, y };
|
||||
if (!node.children?.length) return;
|
||||
|
||||
let currentY = y - (node.subtreeHeight || 0) / 2;
|
||||
|
||||
node.children.forEach(child => {
|
||||
const direction = child.isRight ? 1 : -1;
|
||||
const childX = x + (levelSeparation * direction);
|
||||
const childY = currentY + (child.subtreeHeight || 0) / 2;
|
||||
|
||||
calculateLayout(child, childX, childY);
|
||||
currentY += (child.subtreeHeight || 0) + nodeSeparation;
|
||||
});
|
||||
}
|
||||
|
||||
// 执行布局流程
|
||||
if (rootNode) {
|
||||
// 1. 分配节点到左右两侧
|
||||
assignSides(rootNode);
|
||||
// 2. 计算子树高度
|
||||
calculateSubtreeHeight(rootNode);
|
||||
// 3. 执行布局计算
|
||||
calculateLayout(rootNode, 0, 0);
|
||||
}
|
||||
|
||||
// 转换回原始格式
|
||||
const layoutedNodes = Array.from(nodeMap.values()).map(node => ({
|
||||
...node,
|
||||
position: node.position,
|
||||
}));
|
||||
|
||||
return {
|
||||
nodes: layoutedNodes,
|
||||
edges,
|
||||
};
|
||||
return getLayout("single", options);
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
import { Node, Edge } from "@xyflow/react";
|
||||
// 基础接口和类型定义
|
||||
export interface LayoutOptions {
|
||||
nodes: Node[];
|
||||
edges: Edge[];
|
||||
levelSeparation?: number;
|
||||
nodeSeparation?: number;
|
||||
}
|
||||
|
||||
export interface NodeWithLayout extends Node {
|
||||
children?: NodeWithLayout[];
|
||||
parent?: NodeWithLayout;
|
||||
subtreeHeight?: number;
|
||||
subtreeWidth?: number;
|
||||
isRight?: boolean;
|
||||
relativeY?: number
|
||||
verticalLevel?: number
|
||||
}
|
||||
|
||||
// 布局策略接口
|
||||
export interface LayoutStrategy {
|
||||
layout(options: LayoutOptions): { nodes: Node[], edges: Edge[] };
|
||||
}
|
|
@ -1,8 +1,10 @@
|
|||
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Handle, Position, NodeProps, Node } from '@xyflow/react';
|
||||
import { Handle, Position, NodeProps, Node, useUpdateNodeInternals } from '@xyflow/react';
|
||||
import useGraphStore from '../store';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
import { GraphState } from '../types';
|
||||
import { cn } from '@web/src/utils/classname';
|
||||
import { LEVEL_STYLES, NODE_BASE_STYLES, TEXTAREA_BASE_STYLES } from './style';
|
||||
|
||||
export type GraphNode = Node<{
|
||||
label: string;
|
||||
|
@ -10,157 +12,166 @@ export type GraphNode = Node<{
|
|||
level?: number;
|
||||
}, 'graph-node'>;
|
||||
|
||||
const getLevelStyles = (level: number = 0) => {
|
||||
|
||||
|
||||
interface TextMeasurerProps {
|
||||
element: HTMLTextAreaElement;
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
padding?: number;
|
||||
}
|
||||
|
||||
const measureTextWidth = ({
|
||||
element,
|
||||
minWidth = 60,
|
||||
maxWidth = 400,
|
||||
padding = 16,
|
||||
}: TextMeasurerProps): number => {
|
||||
const span = document.createElement('span');
|
||||
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]
|
||||
visibility: 'hidden',
|
||||
position: 'absolute',
|
||||
whiteSpace: 'pre',
|
||||
fontSize: window.getComputedStyle(element).fontSize,
|
||||
} as const;
|
||||
|
||||
Object.assign(span.style, styles);
|
||||
span.textContent = element.value || element.placeholder;
|
||||
document.body.appendChild(span);
|
||||
|
||||
const contentWidth = Math.min(Math.max(span.offsetWidth + padding, minWidth), maxWidth);
|
||||
document.body.removeChild(span);
|
||||
|
||||
return contentWidth;
|
||||
};
|
||||
|
||||
|
||||
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>) => {
|
||||
export const GraphNode = memo(({ id, selected, width, height, 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) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const updateNodeInternals = useUpdateNodeInternals();
|
||||
// const [nodeWidth, setNodeWidth] = useState(width)
|
||||
// const [nodeHeight, setNodeHeight] = useState(height)
|
||||
const updateTextareaSize = useCallback((element: HTMLTextAreaElement) => {
|
||||
const contentWidth = measureTextWidth({ element });
|
||||
element.style.whiteSpace = contentWidth >= 400 ? 'pre-wrap' : 'pre';
|
||||
element.style.width = `${contentWidth}px`;
|
||||
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]);
|
||||
updateTextareaSize(evt.target);
|
||||
}, [updateNode, id, updateTextareaSize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
updateTextareaSize(textareaRef.current);
|
||||
}
|
||||
}, [isEditing, inputValue, updateTextareaSize]);
|
||||
|
||||
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);
|
||||
const isAlphanumeric = /^[a-zA-Z0-9]$/.test(evt.key);
|
||||
const isSpaceKey = evt.key === ' ';
|
||||
|
||||
if (!isEditing && (isAlphanumeric || isSpaceKey)) {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
|
||||
const newValue = isAlphanumeric ? evt.key : data.label;
|
||||
setIsEditing(true);
|
||||
setInputValue(newValue);
|
||||
updateNode(id, { label: newValue });
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEditing && evt.key === 'Enter' && !evt.shiftKey && !isComposing) {
|
||||
evt.preventDefault();
|
||||
setIsEditing(false);
|
||||
}
|
||||
}, [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);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
setIsEditing(false);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (isEditing && textareaRef.current) {
|
||||
updateTextareaHeight(textareaRef.current);
|
||||
// 聚焦并将光标移到末尾
|
||||
textareaRef.current.focus();
|
||||
const length = textareaRef.current.value.length;
|
||||
textareaRef.current.setSelectionRange(length, length);
|
||||
}
|
||||
}, [isEditing, updateTextareaHeight]);
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const { width, height } = entry.contentRect;
|
||||
updateNodeInternals(id);
|
||||
}
|
||||
});
|
||||
|
||||
if (containerRef.current) {
|
||||
resizeObserver.observe(containerRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
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' : ''}
|
||||
`}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
className={cn(
|
||||
NODE_BASE_STYLES,
|
||||
LEVEL_STYLES[data.level ?? 2].container,
|
||||
selected && 'ring-2 ring-blue-400',
|
||||
isEditing && 'ring-2 ring-blue-500'
|
||||
)}
|
||||
data-testid="graph-node"
|
||||
>
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
defaultValue={data.label}
|
||||
onChange={(evt) => handleChange(evt as any)}
|
||||
onBlur={handleBlur}
|
||||
value={inputValue}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
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' : ''}
|
||||
`}
|
||||
className={cn(
|
||||
TEXTAREA_BASE_STYLES,
|
||||
LEVEL_STYLES[data.level ?? 2].fontSize,
|
||||
isEditing ? 'nodrag' : '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}`}
|
||||
aria-label="节点内容"
|
||||
/>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
position={Position.Left}
|
||||
isConnectable={isConnectable}
|
||||
id="source"
|
||||
className={`${handleStyles} -mr-[6px] ${levelStyles.handle}`}
|
||||
style={{ left: 0 }}
|
||||
className="w-3 h-3 bg-blue-400 border-2 border-white rounded-full"
|
||||
/>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Right}
|
||||
isConnectable={isConnectable}
|
||||
id="target"
|
||||
style={{ right: 0 }}
|
||||
className="w-3 h-3 bg-blue-400 border-2 border-white rounded-full"
|
||||
/>
|
||||
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
@ -0,0 +1,49 @@
|
|||
export const LEVEL_STYLES = {
|
||||
0: {
|
||||
container: `
|
||||
bg-gradient-to-br from-blue-500 to-blue-600
|
||||
text-white px-8 py-4
|
||||
`,
|
||||
fontSize: 'text-xl font-semibold'
|
||||
},
|
||||
1: {
|
||||
container: `
|
||||
bg-white
|
||||
border-2 border-blue-400
|
||||
text-gray-700 px-4 py-2
|
||||
hover:border-blue-500
|
||||
`,
|
||||
fontSize: 'text-lg'
|
||||
},
|
||||
2: {
|
||||
container: `
|
||||
bg-gray-50
|
||||
border border-gray-200
|
||||
text-gray-600 px-2 py-1
|
||||
hover:border-blue-300
|
||||
hover:bg-gray-100
|
||||
`,
|
||||
fontSize: 'text-base'
|
||||
}
|
||||
} as const;
|
||||
|
||||
export const NODE_BASE_STYLES = `
|
||||
flex items-center justify-center
|
||||
rounded-xl
|
||||
min-w-[60px]
|
||||
w-fit
|
||||
relative
|
||||
`;
|
||||
|
||||
export const TEXTAREA_BASE_STYLES = `
|
||||
bg-transparent
|
||||
text-center
|
||||
break-words
|
||||
whitespace-pre-wrap
|
||||
resize-none
|
||||
overflow-hidden
|
||||
outline-none
|
||||
min-w-0
|
||||
w-auto
|
||||
flex-shrink
|
||||
`;
|
|
@ -114,9 +114,7 @@ const useGraphStore = createWithEqualityFn<GraphState>((set, get) => {
|
|||
}
|
||||
}))
|
||||
},
|
||||
|
||||
onEdgesChange: (changes: EdgeChange[]) => {
|
||||
|
||||
set(state => ({
|
||||
present: {
|
||||
nodes: state.present.nodes,
|
||||
|
@ -124,10 +122,8 @@ const useGraphStore = createWithEqualityFn<GraphState>((set, get) => {
|
|||
}
|
||||
}))
|
||||
},
|
||||
|
||||
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
|
||||
|
@ -139,19 +135,7 @@ const useGraphStore = createWithEqualityFn<GraphState>((set, get) => {
|
|||
}
|
||||
});
|
||||
},
|
||||
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
|
||||
);
|
||||
},
|
||||
|
||||
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
@ -1,40 +0,0 @@
|
|||
import { useState, useEffect, useRef, ReactNode } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { TopNavBar } from '@web/src/components/layout/main/top-nav-bar';
|
||||
import { navItems, notificationItems } from '@web/src/components/layout/main/nav-data';
|
||||
import { Sidebar } from '@web/src/components/layout/main/side-bar';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
|
||||
export function MainLayout() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [notifications, setNotifications] = useState(3);
|
||||
const [recentSearches] = useState([
|
||||
'React Fundamentals',
|
||||
'TypeScript Advanced',
|
||||
'Tailwind CSS Projects',
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<TopNavBar
|
||||
sidebarOpen={sidebarOpen}
|
||||
setSidebarOpen={setSidebarOpen}
|
||||
notifications={notifications}
|
||||
notificationItems={notificationItems}
|
||||
recentSearches={recentSearches}
|
||||
/>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{sidebarOpen && <Sidebar navItems={navItems} />}
|
||||
</AnimatePresence>
|
||||
|
||||
<main
|
||||
className={`pt-16 min-h-screen transition-all duration-300 ${sidebarOpen ? 'ml-64' : 'ml-0'
|
||||
}`}
|
||||
>
|
||||
<Outlet></Outlet>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,43 +0,0 @@
|
|||
import { NavItem } from '@nice/client';
|
||||
import {
|
||||
HomeIcon,
|
||||
BookOpenIcon,
|
||||
UserGroupIcon,
|
||||
Cog6ToothIcon,
|
||||
BellIcon,
|
||||
HeartIcon,
|
||||
AcademicCapIcon,
|
||||
UsersIcon,
|
||||
PresentationChartBarIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
export const navItems: NavItem[] = [
|
||||
{ icon: <HomeIcon className="w-6 h-6" />, label: '探索知识', path: '/' },
|
||||
{ icon: <AcademicCapIcon className="w-6 h-6" />, label: '我的学习', path: '/courses/student' },
|
||||
{ icon: <PresentationChartBarIcon className="w-6 h-6" />, label: '我的授课', path: '/courses/instructor' },
|
||||
{ icon: <UsersIcon className="w-6 h-6" />, label: '学习社区', path: '/community' },
|
||||
{ icon: <Cog6ToothIcon className="w-6 h-6" />, label: '应用设置', path: '/settings' },
|
||||
];
|
||||
export const notificationItems = [
|
||||
{
|
||||
icon: <BellIcon className="w-6 h-6 text-blue-500" />,
|
||||
title: "New Course Available",
|
||||
description: "Advanced TypeScript Programming is now available",
|
||||
time: "2 hours ago",
|
||||
isUnread: true,
|
||||
},
|
||||
{
|
||||
icon: <HeartIcon className="w-6 h-6 text-red-500" />,
|
||||
title: "Course Recommendation",
|
||||
description: "Based on your interests: React Native Development",
|
||||
time: "1 day ago",
|
||||
isUnread: true,
|
||||
},
|
||||
{
|
||||
icon: <AcademicCapIcon className="w-6 h-6 text-green-500" />,
|
||||
title: "Certificate Ready",
|
||||
description: "Your React Fundamentals certificate is ready to download",
|
||||
time: "2 days ago",
|
||||
isUnread: true,
|
||||
},
|
||||
];
|
|
@ -1,40 +0,0 @@
|
|||
import { useState, useRef, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { NotificationsPanel } from './notifications-panel';
|
||||
import { BellIcon } from '@heroicons/react/24/outline';
|
||||
import { useClickOutside } from '@web/src/hooks/useClickOutside';
|
||||
|
||||
interface NotificationsDropdownProps {
|
||||
notifications: number;
|
||||
notificationItems: Array<any>;
|
||||
}
|
||||
|
||||
export function NotificationsDropdown({ notifications, notificationItems }: NotificationsDropdownProps) {
|
||||
const [showNotifications, setShowNotifications] = useState(false);
|
||||
const notificationRef = useRef<HTMLDivElement>(null);
|
||||
useClickOutside(notificationRef, () => setShowNotifications(false));
|
||||
return (
|
||||
<div ref={notificationRef} className="relative">
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
onClick={() => setShowNotifications(!showNotifications)}
|
||||
>
|
||||
<BellIcon className='w-6 h-6' ></BellIcon>
|
||||
|
||||
{notifications > 0 && (
|
||||
<span className="absolute top-0 right-0 w-5 h-5 bg-red-500 text-white rounded-full text-xs flex items-center justify-center">
|
||||
{notifications}
|
||||
</span>
|
||||
)}
|
||||
</motion.button>
|
||||
|
||||
<AnimatePresence>
|
||||
{showNotifications && (
|
||||
<NotificationsPanel notificationItems={notificationItems} />
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,64 +0,0 @@
|
|||
import { ClockIcon } from '@heroicons/react/24/outline';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
interface NotificationsPanelProps {
|
||||
notificationItems: Array<{
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
time: string;
|
||||
isUnread: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function NotificationsPanel({ notificationItems }: NotificationsPanelProps) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="absolute right-0 mt-2 w-80 bg-white rounded-xl shadow-lg border border-gray-200 overflow-hidden"
|
||||
>
|
||||
<div className="p-4 border-b border-gray-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Notifications</h3>
|
||||
<span className="text-sm text-blue-600 hover:text-blue-700 cursor-pointer">
|
||||
Mark all as read
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[400px] overflow-y-auto overflow-x-hidden">
|
||||
{notificationItems.map((item, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
whileHover={{ x: 4 }}
|
||||
className={`p-4 border-b border-gray-100 cursor-pointer hover:bg-gray-50 transition-colors ${item.isUnread ? 'bg-blue-50/50' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center">
|
||||
{item.icon}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-medium text-gray-900">{item.title}</h4>
|
||||
<p className="text-sm text-gray-600 mt-1">{item.description}</p>
|
||||
<div className="flex items-center gap-1 mt-2 text-xs text-gray-500">
|
||||
<ClockIcon className='h-4 w-4'></ClockIcon>
|
||||
<span>{item.time}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-gray-100 bg-gray-50">
|
||||
<button className="w-full text-sm text-center text-blue-600 hover:text-blue-700">
|
||||
View all notifications
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
|
@ -1,55 +0,0 @@
|
|||
import { useState, useRef, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { SearchDropdown } from './search-dropdown';
|
||||
import { MagnifyingGlassIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import { useClickOutside } from '@web/src/hooks/useClickOutside';
|
||||
|
||||
interface SearchBarProps {
|
||||
recentSearches: string[];
|
||||
}
|
||||
|
||||
export function SearchBar({ recentSearches }: SearchBarProps) {
|
||||
const [searchFocused, setSearchFocused] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const searchRef = useRef<HTMLDivElement>(null);
|
||||
useClickOutside(searchRef, () => setSearchFocused(false))
|
||||
return (
|
||||
<div ref={searchRef} className="relative max-w-xl w-full px-4">
|
||||
<div className={`
|
||||
relative flex items-center w-full h-10 rounded-full
|
||||
transition-all duration-300 ease-in-out
|
||||
${searchFocused
|
||||
? 'bg-white shadow-md ring-2 ring-blue-500'
|
||||
: 'bg-gray-100 hover:bg-gray-200'
|
||||
}
|
||||
`}>
|
||||
<MagnifyingGlassIcon className="h-5 w-5 ml-3 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search for courses, topics, or instructors..."
|
||||
className="w-full h-full bg-transparent px-3 outline-none text-sm"
|
||||
onFocus={() => setSearchFocused(true)}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<motion.button
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
className="p-1.5 mr-2 rounded-full hover:bg-gray-200"
|
||||
onClick={() => setSearchQuery('')}
|
||||
>
|
||||
<XMarkIcon className="h-4 w-4 text-gray-500" />
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
<SearchDropdown
|
||||
searchFocused={searchFocused}
|
||||
searchQuery={searchQuery}
|
||||
recentSearches={recentSearches}
|
||||
setSearchQuery={setSearchQuery}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,58 +0,0 @@
|
|||
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
interface SearchDropdownProps {
|
||||
searchFocused: boolean;
|
||||
searchQuery: string;
|
||||
recentSearches: string[];
|
||||
setSearchQuery: (query: string) => void;
|
||||
}
|
||||
|
||||
export function SearchDropdown({
|
||||
searchFocused,
|
||||
searchQuery,
|
||||
recentSearches,
|
||||
setSearchQuery
|
||||
}: SearchDropdownProps) {
|
||||
if (!searchFocused) return null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="absolute top-12 left-4 right-4 bg-white rounded-xl shadow-lg border border-gray-200 overflow-hidden"
|
||||
>
|
||||
<div className="p-3">
|
||||
<h3 className="text-xs font-medium text-gray-500 mb-2">Recent Searches</h3>
|
||||
<div className="space-y-1">
|
||||
{recentSearches.map((search, index) => (
|
||||
<motion.button
|
||||
key={index}
|
||||
whileHover={{ x: 4 }}
|
||||
className="flex items-center gap-2 w-full p-2 rounded-lg hover:bg-gray-100 text-left"
|
||||
onClick={() => setSearchQuery(search)}
|
||||
>
|
||||
<MagnifyingGlassIcon className="h-4 w-4 text-gray-400" />
|
||||
<span className="text-sm text-gray-700">{search}</span>
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{searchQuery && (
|
||||
<div className="border-t border-gray-100 p-3">
|
||||
<motion.button
|
||||
whileHover={{ x: 4 }}
|
||||
className="flex items-center gap-2 w-full p-2 rounded-lg hover:bg-gray-100"
|
||||
>
|
||||
<MagnifyingGlassIcon className="h-4 w-4 text-blue-500" />
|
||||
<span className="text-sm text-blue-500">
|
||||
Search for "{searchQuery}"
|
||||
</span>
|
||||
</motion.button>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
|
@ -1,44 +0,0 @@
|
|||
import { motion } from 'framer-motion';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { NavItem } from '@nice/client';
|
||||
|
||||
interface SidebarProps {
|
||||
navItems: Array<NavItem>;
|
||||
}
|
||||
|
||||
export function Sidebar({ navItems }: SidebarProps) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
return (
|
||||
<motion.aside
|
||||
initial={{ x: -300, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
exit={{ x: -300, opacity: 0 }}
|
||||
transition={{ type: "spring", bounce: 0.1, duration: 0.5 }}
|
||||
className="fixed left-0 top-16 bottom-0 w-64 bg-white border-r border-gray-200 z-40"
|
||||
>
|
||||
<div className="p-4 space-y-2">
|
||||
{navItems.map((item, index) => {
|
||||
const isActive = location.pathname === item.path;
|
||||
return (
|
||||
<motion.button
|
||||
key={index}
|
||||
whileHover={{ x: 5 }}
|
||||
onClick={() => {
|
||||
navigate(item.path)
|
||||
}}
|
||||
className={`flex items-center gap-3 w-full p-3 rounded-lg transition-colors
|
||||
${isActive
|
||||
? 'bg-blue-50 text-blue-600'
|
||||
: 'text-gray-700 hover:bg-blue-50 hover:text-blue-600'
|
||||
}`}
|
||||
>
|
||||
{item.icon}
|
||||
<span className="font-medium">{item.label}</span>
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.aside>
|
||||
);
|
||||
}
|
|
@ -1,48 +0,0 @@
|
|||
|
||||
import { UserMenu } from '../element/usermenu/usermenu';
|
||||
import { NotificationsDropdown } from './notifications-dropdown';
|
||||
import { SearchBar } from './search-bar';
|
||||
import { UserMenuDropdown } from './usermenu-dropdown';
|
||||
import { Bars3Icon } from '@heroicons/react/24/outline';
|
||||
|
||||
interface TopNavBarProps {
|
||||
sidebarOpen: boolean;
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
notifications: number;
|
||||
notificationItems: Array<any>;
|
||||
recentSearches: string[];
|
||||
}
|
||||
export function TopNavBar({
|
||||
sidebarOpen,
|
||||
setSidebarOpen,
|
||||
notifications,
|
||||
notificationItems,
|
||||
recentSearches
|
||||
}: TopNavBarProps) {
|
||||
return (
|
||||
<nav className="fixed top-0 left-0 right-0 h-16 bg-white shadow-sm z-50">
|
||||
<div className="flex items-center justify-between h-full px-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 transition-colors">
|
||||
<Bars3Icon className='w-5 h-5' />
|
||||
</button>
|
||||
<h1 className="text-xl font-semibold text-slate-800 tracking-wide">
|
||||
fhmooc
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<SearchBar recentSearches={recentSearches} />
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<NotificationsDropdown
|
||||
notifications={notifications}
|
||||
notificationItems={notificationItems}
|
||||
/>
|
||||
<UserMenu />
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
|
@ -1,68 +0,0 @@
|
|||
import { useState, useRef, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { ArrowLeftStartOnRectangleIcon, Cog6ToothIcon, QuestionMarkCircleIcon, UserCircleIcon } from '@heroicons/react/24/outline';
|
||||
import { useAuth } from '@web/src/providers/auth-provider';
|
||||
import { Avatar } from '../../presentation/user/Avatar';
|
||||
import { useClickOutside } from '@web/src/hooks/useClickOutside';
|
||||
|
||||
export function UserMenuDropdown() {
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const { user, logout } = useAuth()
|
||||
useClickOutside(menuRef, () => setShowMenu(false));
|
||||
const menuItems = [
|
||||
{ icon: <UserCircleIcon className='w-5 h-5'></UserCircleIcon>, label: '个人信息', action: () => { } },
|
||||
{ icon: <Cog6ToothIcon className='w-5 h-5'></Cog6ToothIcon>, label: '设置', action: () => { } },
|
||||
{ icon: <QuestionMarkCircleIcon className='w-5 h-5'></QuestionMarkCircleIcon>, label: '帮助', action: () => { } },
|
||||
{ icon: <ArrowLeftStartOnRectangleIcon className='w-5 h-5' />, label: '注销', action: () => { logout() } },
|
||||
];
|
||||
|
||||
return (
|
||||
<div ref={menuRef} className="relative">
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={() => setShowMenu(!showMenu)}
|
||||
className="w-10 h-10" // 移除了边框相关的类
|
||||
>
|
||||
<Avatar
|
||||
src={user?.avatar}
|
||||
name={user?.showname || user?.username}
|
||||
size={40}
|
||||
className="ring-2 ring-gray-200 hover:ring-blue-500 transition-colors" // 使用 ring 替代 border
|
||||
/>
|
||||
</motion.button>
|
||||
|
||||
<AnimatePresence>
|
||||
{showMenu && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="absolute right-0 mt-2 w-48 bg-white rounded-xl shadow-lg border border-gray-200 overflow-hidden"
|
||||
>
|
||||
<div className="p-3 border-b border-gray-100">
|
||||
<h4 className="text-sm font-medium text-gray-900">{user?.showname}</h4>
|
||||
<p className="text-xs text-gray-500">{user?.username}</p>
|
||||
</div>
|
||||
|
||||
<div className="p-2">
|
||||
{menuItems.map((item, index) => (
|
||||
<motion.button
|
||||
key={index}
|
||||
whileHover={{ x: 4 }}
|
||||
onClick={item.action}
|
||||
className="flex items-center gap-2 w-full p-2 rounded-lg hover:bg-gray-100 text-gray-700 text-sm"
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,29 +1,9 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
import { MindMap, Node } from '@nice/mindmap';
|
||||
|
||||
const initialData: Node = {
|
||||
data: {
|
||||
text: "根节点"
|
||||
},
|
||||
children: []
|
||||
};
|
||||
|
||||
export default function MindMapEditor(): JSX.Element {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const mindMap = new MindMap({
|
||||
el: containerRef.current,
|
||||
data: initialData
|
||||
});
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
mindMap.destroy()
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <div ref={containerRef} className="mind-map-container h-screen" />;
|
||||
}
|
|
@ -46,13 +46,6 @@ export function useTusUpload() {
|
|||
onError: (error: Error) => void,
|
||||
fileKey: string // 添加文件唯一标识
|
||||
) => {
|
||||
// if (!file || !file.name || !file.type) {
|
||||
// const error = new Error("不可上传该类型文件");
|
||||
// setUploadError(error.message);
|
||||
// onError(error);
|
||||
// return;
|
||||
// }
|
||||
|
||||
setIsUploading(true);
|
||||
setUploadProgress((prev) => ({ ...prev, [fileKey]: 0 }));
|
||||
setUploadError(null);
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
}
|
||||
|
||||
.ag-custom-dragging-class {
|
||||
@apply border-b-2 border-primaryHover;
|
||||
@apply border-b-2 border-blue-200;
|
||||
}
|
||||
|
||||
.ant-popover-inner {
|
||||
|
@ -71,7 +71,7 @@
|
|||
|
||||
border-radius: 10px;
|
||||
border: 2px solid #f0f0f0;
|
||||
@apply hover:bg-primaryHover transition-all bg-gray-400 ease-in-out rounded-full;
|
||||
@apply hover:bg-blue-200 transition-all bg-gray-400 ease-in-out rounded-full;
|
||||
}
|
||||
|
||||
/* 鼠标悬停在滚动块上 */
|
||||
|
@ -123,4 +123,4 @@
|
|||
.custom-table .ant-table-tbody>tr:last-child>td {
|
||||
border-bottom: none;
|
||||
/* 去除最后一行的底部边框 */
|
||||
}
|
||||
}
|
|
@ -13,7 +13,6 @@ import RoleAdminPage from "../app/admin/role/page";
|
|||
import WithAuth from "../components/utils/with-auth";
|
||||
import LoginPage from "../app/login";
|
||||
import BaseSettingPage from "../app/admin/base-setting/page";
|
||||
import { MainLayout } from "../components/layout/main/MainLayout";
|
||||
import StudentCoursesPage from "../app/main/courses/student/page";
|
||||
import InstructorCoursesPage from "../app/main/courses/instructor/page";
|
||||
import HomePage from "../app/main/home/page";
|
||||
|
@ -23,6 +22,8 @@ import CourseContentForm from "../components/models/course/editor/form/CourseCon
|
|||
import { CourseGoalForm } from "../components/models/course/editor/form/CourseGoalForm";
|
||||
import CourseSettingForm from "../components/models/course/editor/form/CourseSettingForm";
|
||||
import CourseEditorLayout from "../components/models/course/editor/layout/CourseEditorLayout";
|
||||
import { MainLayout } from "../app/main/layout/MainLayout";
|
||||
import CoursesPage from "../app/main/courses/page";
|
||||
|
||||
interface CustomIndexRouteObject extends IndexRouteObject {
|
||||
name?: string;
|
||||
|
@ -61,6 +62,16 @@ export const routes: CustomRouteObject[] = [
|
|||
index: true,
|
||||
element: <HomePage />,
|
||||
},
|
||||
{
|
||||
path: "courses",
|
||||
element: <CoursesPage></CoursesPage>
|
||||
},
|
||||
{
|
||||
path: "my-courses"
|
||||
},
|
||||
{
|
||||
path: "profiles"
|
||||
},
|
||||
{
|
||||
path: "courses",
|
||||
children: [
|
||||
|
|
|
@ -1,53 +0,0 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: "var(--color-primary)",
|
||||
primaryActive: "var(--color-primary-active)",
|
||||
primaryHover: "var(--color-primary-hover)",
|
||||
error: "var(--color-error)",
|
||||
warning: "var(--color-warning)",
|
||||
info: "var(--color-info)",
|
||||
success: "var(--color-success)",
|
||||
link: "var(--color-link)",
|
||||
highlight: "var(--color-highlight)",
|
||||
},
|
||||
backgroundColor: {
|
||||
layout: "var(--color-bg-layout)",
|
||||
mask: "var(--color-bg-mask)",
|
||||
container: "var(--color-bg-container)",
|
||||
textHover: "var(--color-bg-text-hover)",
|
||||
primary: "var(--color-bg-primary)",
|
||||
error: "var(--color-error-bg)",
|
||||
warning: "var(--color-warning-bg)",
|
||||
info: "var(--color-info-bg)",
|
||||
success: "var(--color-success-bg)",
|
||||
},
|
||||
textColor: {
|
||||
default: "var(--color-text)",
|
||||
quaternary: "var(--color-text-quaternary)",
|
||||
placeholder: "var(--color-text-placeholder)",
|
||||
description: "var(--color-text-description)",
|
||||
secondary: "var(--color-text-secondary)",
|
||||
tertiary: "var(--color-text-tertiary)",
|
||||
primary: "var(--color-text-primary)",
|
||||
heading: "var(--color-text-heading)",
|
||||
label: "var(--color-text-label)",
|
||||
lightSolid: "var(--color-text-lightsolid)"
|
||||
},
|
||||
borderColor: {
|
||||
default: "var(--color-border)",
|
||||
},
|
||||
boxShadow: {
|
||||
elegant: '0 3px 6px -2px rgba(46, 117, 182, 0.10), 0 2px 4px -1px rgba(46, 117, 182, 0.05)'
|
||||
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
import { NiceTailwindConfig } from "@nice/config"
|
||||
export default NiceTailwindConfig
|
|
@ -3,10 +3,9 @@ export * from "./enum"
|
|||
export * from "./types"
|
||||
export * from "./utils"
|
||||
export * from "./constants"
|
||||
export * from "./select"
|
||||
export * from "./collaboration"
|
||||
export * from "./db"
|
||||
// export * from "./generated"
|
||||
export * from "@prisma/client"
|
||||
export * from "./upload"
|
||||
export * from "./tool"
|
||||
export * from "./tool"
|
||||
export * from "./models"
|
|
@ -0,0 +1,15 @@
|
|||
import { Department } from "@prisma/client";
|
||||
import { TreeDataNode} from "../types";
|
||||
import { StaffDto } from "./staff";
|
||||
import { TermDto } from "./term";
|
||||
|
||||
export interface DeptSimpleTreeNode extends TreeDataNode {
|
||||
hasStaff?: boolean;
|
||||
}
|
||||
export type DepartmentDto = Department & {
|
||||
parent: DepartmentDto;
|
||||
children: DepartmentDto[];
|
||||
hasChildren: boolean;
|
||||
staffs: StaffDto[];
|
||||
terms: TermDto[];
|
||||
};
|
|
@ -0,0 +1,7 @@
|
|||
export * from "./department"
|
||||
export * from "./message"
|
||||
export * from "./staff"
|
||||
export * from "./term"
|
||||
export * from "./post"
|
||||
export * from "./rbac"
|
||||
export * from "./select"
|
|
@ -0,0 +1,7 @@
|
|||
import { Message, Staff } from "@prisma/client";
|
||||
|
||||
export type MessageDto = Message & {
|
||||
readed: boolean;
|
||||
receivers: Staff[];
|
||||
sender: Staff;
|
||||
};
|
|
@ -0,0 +1,66 @@
|
|||
import { Post, Department, Staff, Enrollment } from "@prisma/client";
|
||||
import { StaffDto } from "./staff";
|
||||
|
||||
export type PostComment = {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
content: string;
|
||||
authorId: string;
|
||||
domainId: string;
|
||||
referenceId: string;
|
||||
resources: string[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
parentId: string;
|
||||
author: {
|
||||
id: string;
|
||||
showname: string;
|
||||
username: string;
|
||||
avatar: string;
|
||||
};
|
||||
};
|
||||
export type PostDto = Post & {
|
||||
readed: boolean;
|
||||
readedCount: number;
|
||||
author: StaffDto;
|
||||
limitedComments: PostComment[];
|
||||
commentsCount: number;
|
||||
perms?: {
|
||||
delete: boolean;
|
||||
// edit: boolean;
|
||||
};
|
||||
watchableDepts: Department[];
|
||||
watchableStaffs: Staff[];
|
||||
};
|
||||
|
||||
export type LectureMeta = {
|
||||
videoUrl?: string;
|
||||
videoThumbnail?: string;
|
||||
};
|
||||
|
||||
export type Lecture = Post & {
|
||||
meta?: LectureMeta;
|
||||
};
|
||||
|
||||
export type SectionMeta = {
|
||||
objectives?: string[];
|
||||
};
|
||||
export type Section = Post & {
|
||||
meta?: SectionMeta;
|
||||
};
|
||||
export type SectionDto = Section & {
|
||||
lectures: Lecture[];
|
||||
};
|
||||
export type CourseMeta = {
|
||||
thumbnail?: string;
|
||||
requirements?: string[];
|
||||
objectives?: string[];
|
||||
};
|
||||
export type Course = Post & {
|
||||
meta?: CourseMeta;
|
||||
};
|
||||
export type CourseDto = Course & {
|
||||
enrollments?: Enrollment[];
|
||||
sections?: SectionDto[];
|
||||
};
|
|
@ -0,0 +1,22 @@
|
|||
import { RoleMap } from "@prisma/client";
|
||||
import { StaffDto } from "./staff";
|
||||
|
||||
export interface ResPerm {
|
||||
instruction?: boolean;
|
||||
createProgress?: boolean;
|
||||
requestCancel?: boolean;
|
||||
acceptCancel?: boolean;
|
||||
|
||||
conclude?: boolean;
|
||||
createRisk?: boolean;
|
||||
editIndicator?: boolean;
|
||||
editMethod?: boolean;
|
||||
editOrg?: boolean;
|
||||
|
||||
edit?: boolean;
|
||||
delete?: boolean;
|
||||
read?: boolean;
|
||||
}
|
||||
export type RoleMapDto = RoleMap & {
|
||||
staff: StaffDto;
|
||||
};
|
|
@ -0,0 +1,5 @@
|
|||
import { Lecture, Section } from "@prisma/client";
|
||||
|
||||
export type SectionDto = Section & {
|
||||
lectures: Lecture[];
|
||||
};
|
|
@ -0,0 +1,38 @@
|
|||
import { Staff, Department } from "@prisma/client";
|
||||
import { RolePerms } from "../enum";
|
||||
|
||||
export type StaffRowModel = {
|
||||
avatar: string;
|
||||
dept_name: string;
|
||||
officer_id: string;
|
||||
phone_number: string;
|
||||
showname: string;
|
||||
username: string;
|
||||
};
|
||||
export type UserProfile = Staff & {
|
||||
permissions: RolePerms[];
|
||||
deptIds: string[];
|
||||
parentDeptIds: string[];
|
||||
domain: Department;
|
||||
department: Department;
|
||||
};
|
||||
|
||||
export type StaffDto = Staff & {
|
||||
domain?: Department;
|
||||
department?: Department;
|
||||
};
|
||||
export interface AuthDto {
|
||||
token: string;
|
||||
staff: StaffDto;
|
||||
refreshToken: string;
|
||||
perms: string[];
|
||||
}
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
username: string;
|
||||
}
|
||||
export interface TokenPayload {
|
||||
id: string;
|
||||
phoneNumber: string;
|
||||
name: string;
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
import { Term } from "@prisma/client";
|
||||
import { ResPerm } from "./rbac";
|
||||
|
||||
export type TermDto = Term & {
|
||||
permissions: ResPerm;
|
||||
children: TermDto[];
|
||||
hasChildren: boolean;
|
||||
};
|
|
@ -13,49 +13,17 @@ import type {
|
|||
import { SocketMsgType, RolePerms } from "./enum";
|
||||
import { RowRequestSchema } from "./schema";
|
||||
import { z, string } from "zod";
|
||||
import { StaffDto } from "./models/staff";
|
||||
// import { MessageWithRelations, PostWithRelations, TroubleWithRelations } from "./generated";
|
||||
export interface SocketMessage<T = any> {
|
||||
type: SocketMsgType;
|
||||
payload?: T;
|
||||
}
|
||||
|
||||
export interface DataNode {
|
||||
title: any;
|
||||
key: string;
|
||||
hasChildren?: boolean;
|
||||
children?: DataNode[];
|
||||
value: string;
|
||||
data?: any;
|
||||
isLeaf?: boolean;
|
||||
}
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
username: string;
|
||||
}
|
||||
export type AppLocalSettings = {
|
||||
urgent?: number;
|
||||
important?: number;
|
||||
exploreTime?: Date;
|
||||
};
|
||||
export type StaffDto = Staff & {
|
||||
domain?: Department;
|
||||
department?: Department;
|
||||
};
|
||||
export interface AuthDto {
|
||||
token: string;
|
||||
staff: StaffDto;
|
||||
refreshToken: string;
|
||||
perms: string[];
|
||||
}
|
||||
export type UserProfile = Staff & {
|
||||
permissions: RolePerms[];
|
||||
deptIds: string[];
|
||||
parentDeptIds: string[];
|
||||
domain: Department;
|
||||
department: Department;
|
||||
};
|
||||
|
||||
export interface DataNode {
|
||||
title: any;
|
||||
key: string;
|
||||
|
@ -70,91 +38,6 @@ export interface TreeDataNode extends DataNode {
|
|||
isLeaf?: boolean;
|
||||
pId?: string;
|
||||
}
|
||||
export interface DeptSimpleTreeNode extends TreeDataNode {
|
||||
hasStaff?: boolean;
|
||||
}
|
||||
export type StaffRowModel = {
|
||||
avatar: string;
|
||||
dept_name: string;
|
||||
officer_id: string;
|
||||
phone_number: string;
|
||||
showname: string;
|
||||
username: string;
|
||||
};
|
||||
export interface TokenPayload {
|
||||
id: string;
|
||||
phoneNumber: string;
|
||||
name: string;
|
||||
}
|
||||
export interface ResPerm {
|
||||
instruction?: boolean;
|
||||
createProgress?: boolean;
|
||||
requestCancel?: boolean;
|
||||
acceptCancel?: boolean;
|
||||
|
||||
conclude?: boolean;
|
||||
createRisk?: boolean;
|
||||
editIndicator?: boolean;
|
||||
editMethod?: boolean;
|
||||
editOrg?: boolean;
|
||||
|
||||
edit?: boolean;
|
||||
delete?: boolean;
|
||||
read?: boolean;
|
||||
}
|
||||
|
||||
export type MessageDto = Message & {
|
||||
readed: boolean;
|
||||
receivers: Staff[];
|
||||
sender: Staff;
|
||||
};
|
||||
export type PostComment = {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
content: string;
|
||||
authorId: string;
|
||||
domainId: string;
|
||||
referenceId: string;
|
||||
resources: string[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
parentId: string;
|
||||
author: {
|
||||
id: string;
|
||||
showname: string;
|
||||
username: string;
|
||||
avatar: string;
|
||||
};
|
||||
};
|
||||
export type PostDto = Post & {
|
||||
readed: boolean;
|
||||
readedCount: number;
|
||||
author: StaffDto;
|
||||
limitedComments: PostComment[];
|
||||
commentsCount: number;
|
||||
perms?: {
|
||||
delete: boolean;
|
||||
// edit: boolean;
|
||||
};
|
||||
watchableDepts: Department[];
|
||||
watchableStaffs: Staff[];
|
||||
};
|
||||
export type RoleMapDto = RoleMap & {
|
||||
staff: StaffDto;
|
||||
};
|
||||
export type TermDto = Term & {
|
||||
permissions: ResPerm;
|
||||
children: TermDto[];
|
||||
hasChildren: boolean;
|
||||
};
|
||||
export type DepartmentDto = Department & {
|
||||
parent: DepartmentDto;
|
||||
children: DepartmentDto[];
|
||||
hasChildren: boolean;
|
||||
staffs: StaffDto[];
|
||||
terms: TermDto[];
|
||||
};
|
||||
|
||||
export interface BaseSetting {
|
||||
appConfig?: {
|
||||
|
@ -167,35 +50,3 @@ export type RowModelResult = {
|
|||
rowCount: number;
|
||||
};
|
||||
export type RowModelRequest = z.infer<typeof RowRequestSchema>;
|
||||
|
||||
export type LectureMeta = {
|
||||
videoUrl?: string;
|
||||
videoThumbnail?: string;
|
||||
};
|
||||
|
||||
export type Lecture = Post & {
|
||||
meta?: LectureMeta;
|
||||
};
|
||||
|
||||
|
||||
export type SectionMeta = {
|
||||
objectives?: string[];
|
||||
};
|
||||
export type Section = Post & {
|
||||
meta?: SectionMeta;
|
||||
};
|
||||
export type SectionDto = Section & {
|
||||
lectures: Lecture[];
|
||||
};
|
||||
export type CourseMeta = {
|
||||
thumbnail?: string;
|
||||
requirements?: string[];
|
||||
objectives?: string[];
|
||||
};
|
||||
export type Course = Post & {
|
||||
meta?: CourseMeta;
|
||||
};
|
||||
export type CourseDto = Course & {
|
||||
enrollments?: Enrollment[];
|
||||
sections?: SectionDto[];
|
||||
};
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Staff } from "@prisma/client";
|
||||
import { TermDto, TreeDataNode } from "./types";
|
||||
import { TreeDataNode } from "./types";
|
||||
export function findNodeByKey(
|
||||
nodes: TreeDataNode[],
|
||||
targetKey: string
|
||||
|
@ -79,32 +79,28 @@ interface MappingConfig {
|
|||
hasChildrenField?: string; // Optional, in case the structure has nested items
|
||||
childrenField?: string;
|
||||
}
|
||||
export function stringToColor(str: string): string {
|
||||
let hash = 0;
|
||||
|
||||
/**
|
||||
* 将字符串转换为优雅的颜色值
|
||||
* @param str - 输入字符串
|
||||
* @param saturation - 饱和度(0-100),默认为75
|
||||
* @param lightness - 亮度(0-100),默认为65
|
||||
* @returns 返回HSL格式的颜色字符串
|
||||
*/
|
||||
export function stringToColor(str: string, saturation: number = 75, lightness: number = 65): string {
|
||||
let hash = 0;
|
||||
|
||||
// 使用字符串生成哈希值
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
|
||||
let color = '#';
|
||||
for (let i = 0; i < 3; i++) {
|
||||
let value = (hash >> (i * 8)) & 0xFF;
|
||||
|
||||
// Adjusting the value to avoid dark, gray or too light colors
|
||||
if (value < 100) {
|
||||
value += 100; // Avoids too dark colors
|
||||
}
|
||||
if (value > 200) {
|
||||
value -= 55; // Avoids too light colors
|
||||
}
|
||||
|
||||
// Ensure the color is not gray by adjusting R, G, B individually
|
||||
value = Math.floor((value + 255) / 2);
|
||||
|
||||
color += ('00' + value.toString(16)).slice(-2);
|
||||
}
|
||||
|
||||
return color;
|
||||
|
||||
// 将哈希值转换为0-360的色相值
|
||||
const hue = Math.abs(hash % 360);
|
||||
|
||||
// 使用HSL颜色空间生成颜色
|
||||
// 固定饱和度和亮度以确保颜色的优雅性
|
||||
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
|
||||
}
|
||||
|
||||
|
||||
|
@ -318,7 +314,7 @@ export const mapPropertiesToObjects = <T, K extends keyof T>(array: T[], key: K)
|
|||
* @returns {Array<Record<string, T>>} 映射后的对象数组
|
||||
*/
|
||||
export const mapArrayToObjectArray = <T>(
|
||||
array: T[],
|
||||
array: T[],
|
||||
key: string = 'id'
|
||||
): Array<Record<string, T>> => {
|
||||
return array.map(item => ({ [key]: item }));
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"name": "@nice/config",
|
||||
"version": "1.0.0",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"clean": "rimraf dist",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nice/utils": "workspace:^",
|
||||
"color": "^4.2.3",
|
||||
"nanoid": "^5.0.9"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/color": "^4.2.0",
|
||||
"@types/dagre": "^0.7.52",
|
||||
"@types/node": "^20.3.1",
|
||||
"@types/react": "18.2.38",
|
||||
"@types/react-dom": "18.2.15",
|
||||
"concurrently": "^8.0.0",
|
||||
"rimraf": "^6.0.1",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsup": "^8.3.5",
|
||||
"typescript": "^5.5.4"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
import Color from "color";
|
||||
import { ColorScale } from "./types";
|
||||
|
||||
export function generateColorScale(baseColor: string): ColorScale {
|
||||
const color = Color(baseColor);
|
||||
const steps = [-0.4, -0.32, -0.24, -0.16, -0.08, 0, 0.08, 0.16, 0.24, 0.32, 0.4];
|
||||
const keys = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950];
|
||||
|
||||
const scale = Object.fromEntries(
|
||||
keys.map((key, index) => [
|
||||
key,
|
||||
color.lighten(-steps[index]).rgb().string()
|
||||
])
|
||||
) as ColorScale;
|
||||
|
||||
return {
|
||||
...scale,
|
||||
DEFAULT: scale[500]
|
||||
};
|
||||
}
|
||||
export function withAlpha(color: string, alpha: number): string {
|
||||
|
||||
return Color(color).alpha(alpha).toString();
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
import { generateTheme} from "./generator";
|
||||
import { ThemeSeed } from "./types";
|
||||
|
||||
// 添加默认的主题配置
|
||||
export const USAFSeed: ThemeSeed = {
|
||||
colors: {
|
||||
primary: '#003087', // 深蓝色
|
||||
secondary: '#71767C', // 灰色
|
||||
neutral: '#4A4A4A', // 中性灰色
|
||||
success: '#287233', // 绿色
|
||||
warning: '#FF9F1C', // 警告橙色
|
||||
error: '#AF1E2D', // 红色
|
||||
info: '#00538E', // 信息蓝色
|
||||
|
||||
},
|
||||
|
||||
isDark: false
|
||||
};
|
||||
export const defaultTheme = generateTheme(USAFSeed)
|
|
@ -0,0 +1,50 @@
|
|||
import React, { createContext, useContext, useMemo, useState } from 'react';
|
||||
import type { ThemeSeed, ThemeToken } from './types';
|
||||
import { USAFSeed } from './constants';
|
||||
import { createTailwindTheme, injectThemeVariables } from './styles';
|
||||
import { generateTheme } from './generator';
|
||||
interface ThemeContextValue {
|
||||
token: ThemeToken
|
||||
setTheme: (options: ThemeSeed) => void;
|
||||
toggleDarkMode: () => void;
|
||||
}
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
seed = USAFSeed,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
seed?: ThemeSeed;
|
||||
}) {
|
||||
const [themeSeed, setThemeSeed] = useState<ThemeSeed>(seed);
|
||||
const token = useMemo<ThemeToken>(() => {
|
||||
|
||||
const result = generateTheme(themeSeed)
|
||||
console.log(createTailwindTheme(result))
|
||||
|
||||
injectThemeVariables(result)
|
||||
return result.token;
|
||||
}, [themeSeed]);
|
||||
|
||||
const contextValue = useMemo<ThemeContextValue>(
|
||||
() => ({
|
||||
token,
|
||||
setTheme: setThemeSeed,
|
||||
toggleDarkMode: () =>
|
||||
setThemeSeed((prev) => ({ ...prev, isDark: !prev.isDark })),
|
||||
}),
|
||||
[token]
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={contextValue}>{children}</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext);
|
||||
if (!context) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
|
@ -0,0 +1,99 @@
|
|||
import { Theme, ThemeColors, ThemeSemantics, ThemeSeed, ThemeToken } from './types';
|
||||
import { withAlpha, generateColorScale } from './colors';
|
||||
import { darkMode } from './utils';
|
||||
export function generateThemeColors(seed: ThemeSeed['colors']): ThemeColors {
|
||||
const defaultColors = {
|
||||
success: '#22c55e',
|
||||
warning: '#f59e0b',
|
||||
error: '#ef4444',
|
||||
info: '#3b82f6'
|
||||
};
|
||||
const colors = { ...defaultColors, ...seed };
|
||||
return Object.fromEntries(
|
||||
Object.entries(colors).map(([key, value]) => [
|
||||
key,
|
||||
generateColorScale(value)
|
||||
])
|
||||
) as ThemeColors;
|
||||
}
|
||||
|
||||
export function generateSemantics(colors: ThemeColors, isDark: boolean): ThemeSemantics {
|
||||
const neutral = colors.neutral;
|
||||
const primary = colors.primary;
|
||||
|
||||
const statusColors = {
|
||||
success: colors.success,
|
||||
warning: colors.warning,
|
||||
error: colors.error,
|
||||
info: colors.info
|
||||
};
|
||||
return {
|
||||
colors,
|
||||
textColor: {
|
||||
DEFAULT: darkMode(isDark, neutral[100], neutral[900]),
|
||||
primary: darkMode(isDark, neutral[100], neutral[900]),
|
||||
secondary: darkMode(isDark, neutral[300], neutral[700]),
|
||||
tertiary: darkMode(isDark, neutral[400], neutral[600]),
|
||||
disabled: darkMode(isDark, neutral[500], neutral[400]),
|
||||
inverse: darkMode(isDark, neutral[900], neutral[100]),
|
||||
success: statusColors.success[darkMode(isDark, 400, 600)],
|
||||
warning: statusColors.warning[darkMode(isDark, 400, 600)],
|
||||
error: statusColors.error[darkMode(isDark, 400, 600)],
|
||||
info: statusColors.info[darkMode(isDark, 400, 600)],
|
||||
link: primary[darkMode(isDark, 400, 600)],
|
||||
linkHover: primary[darkMode(isDark, 300, 700)],
|
||||
placeholder: darkMode(isDark, neutral[500], neutral[400]),
|
||||
highlight: primary[darkMode(isDark, 300, 700)]
|
||||
},
|
||||
|
||||
backgroundColor: {
|
||||
DEFAULT: darkMode(isDark, neutral[900], neutral[50]),
|
||||
paper: darkMode(isDark, neutral[800], neutral[100]),
|
||||
subtle: darkMode(isDark, neutral[700], neutral[200]),
|
||||
inverse: darkMode(isDark, neutral[50], neutral[900]),
|
||||
success: withAlpha(statusColors.success[darkMode(isDark, 900, 50)], 0.12),
|
||||
warning: withAlpha(statusColors.warning[darkMode(isDark, 900, 50)], 0.12),
|
||||
error: withAlpha(statusColors.error[darkMode(isDark, 900, 50)], 0.12),
|
||||
info: withAlpha(statusColors.info[darkMode(isDark, 900, 50)], 0.12),
|
||||
primaryHover: withAlpha(primary[darkMode(isDark, 800, 100)], 0.08),
|
||||
primaryActive: withAlpha(primary[darkMode(isDark, 700, 200)], 0.12),
|
||||
primaryDisabled: withAlpha(neutral[darkMode(isDark, 700, 200)], 0.12),
|
||||
secondaryHover: withAlpha(neutral[darkMode(isDark, 800, 100)], 0.08),
|
||||
secondaryActive: withAlpha(neutral[darkMode(isDark, 700, 200)], 0.12),
|
||||
secondaryDisabled: withAlpha(neutral[darkMode(isDark, 700, 200)], 0.12),
|
||||
selected: withAlpha(primary[darkMode(isDark, 900, 50)], 0.16),
|
||||
hover: withAlpha(neutral[darkMode(isDark, 800, 100)], 0.08),
|
||||
focused: withAlpha(primary[darkMode(isDark, 900, 50)], 0.12),
|
||||
disabled: withAlpha(neutral[darkMode(isDark, 700, 200)], 0.12),
|
||||
overlay: withAlpha(neutral[900], 0.5)
|
||||
},
|
||||
|
||||
border: {
|
||||
DEFAULT: darkMode(isDark, neutral[600], neutral[300]),
|
||||
subtle: darkMode(isDark, neutral[700], neutral[200]),
|
||||
strong: darkMode(isDark, neutral[500], neutral[400]),
|
||||
focus: primary[500],
|
||||
inverse: darkMode(isDark, neutral[300], neutral[600]),
|
||||
success: statusColors.success[darkMode(isDark, 500, 500)],
|
||||
warning: statusColors.warning[darkMode(isDark, 500, 500)],
|
||||
error: statusColors.error[darkMode(isDark, 500, 500)],
|
||||
info: statusColors.info[darkMode(isDark, 500, 500)],
|
||||
disabled: darkMode(isDark, neutral[600], neutral[300])
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export function generateTheme(seed: ThemeSeed): Theme {
|
||||
const isDark = seed.isDark ?? false;
|
||||
const colors = generateThemeColors(seed.colors);
|
||||
const semantics = generateSemantics(colors, isDark);
|
||||
|
||||
return {
|
||||
token: {
|
||||
...colors,
|
||||
...semantics
|
||||
},
|
||||
isDark
|
||||
};
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
export * from "./context"
|
||||
export * from "./types"
|
||||
export * from "./utils"
|
||||
export * from "./styles"
|
||||
export * from "./constants"
|
||||
export * from "./tailwind"
|
|
@ -0,0 +1,89 @@
|
|||
import { Theme, ThemeToken } from './types'
|
||||
import { flattenObject, toKebabCase } from './utils'
|
||||
import type { Config } from 'tailwindcss'
|
||||
|
||||
const PREFIX = '--nice'
|
||||
|
||||
/**
|
||||
* 生成CSS变量键名
|
||||
*/
|
||||
function createCssVariableName(path: string[]): string {
|
||||
return `${PREFIX}-${path.map(p => toKebabCase(p.toLowerCase())).join('-')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 将主题对象转换为CSS变量对象
|
||||
*/
|
||||
export function themeToCssVariables(theme: Theme): Record<string, string> {
|
||||
const flattenedToken = flattenObject(theme.token)
|
||||
console.log(flattenedToken)
|
||||
const cssVars: Record<string, string> = {}
|
||||
|
||||
for (const [path, value] of Object.entries(flattenedToken)) {
|
||||
const cssVarName = createCssVariableName(path.split('.'))
|
||||
|
||||
cssVars[cssVarName] = value
|
||||
}
|
||||
|
||||
return cssVars
|
||||
}
|
||||
|
||||
export function injectThemeVariables(theme: Theme) {
|
||||
const cssVars = themeToCssVariables(theme)
|
||||
const root = document.documentElement
|
||||
|
||||
Object.entries(cssVars).forEach(([key, value]) => {
|
||||
console.log(key, value)
|
||||
root.style.setProperty(key, value)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 将扁平化的主题对象转换为Tailwind主题配置
|
||||
*/
|
||||
function transformToTailwindConfig(flattenedToken: Record<string, any>): Record<string, any> {
|
||||
const result: Record<string, any> = {}
|
||||
|
||||
// 处理对象路径,将其转换为嵌套结构
|
||||
for (const [path, _] of Object.entries(flattenedToken)) {
|
||||
const parts = path.split('.')
|
||||
let current = result
|
||||
|
||||
// 遍历路径的每一部分,构建嵌套结构
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i]
|
||||
const isLast = i === parts.length - 1
|
||||
|
||||
// 如果是最后一个部分,设置CSS变量引用
|
||||
if (isLast) {
|
||||
current[part] = `var(${createCssVariableName(parts)})`
|
||||
} else {
|
||||
// 如果不是最后一个部分,确保存在嵌套对象
|
||||
current[part] = current[part] || {}
|
||||
current = current[part]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建引用CSS变量的Tailwind主题配置
|
||||
*/
|
||||
export function createTailwindTheme(theme: Theme): Partial<Config["theme"]> {
|
||||
const flattenedToken = flattenObject(theme.token)
|
||||
const themeConfig = transformToTailwindConfig(flattenedToken)
|
||||
|
||||
// 将主题配置映射到Tailwind的结构
|
||||
const result = {
|
||||
extend: {
|
||||
colors: themeConfig.colors,
|
||||
textColor: themeConfig.textColor,
|
||||
backgroundColor: themeConfig.backgroundColor,
|
||||
borderColor: themeConfig.border,
|
||||
}
|
||||
}
|
||||
console.log(result)
|
||||
return result
|
||||
}
|
|
@ -0,0 +1,132 @@
|
|||
import type { Config } from "tailwindcss";
|
||||
|
||||
export const NiceTailwindConfig: Config = {
|
||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// 主色调 - 优雅蓝(清新雅致)
|
||||
primary: {
|
||||
50: "#f0f7ff",
|
||||
100: "#e0f0ff",
|
||||
150: "#cce7ff",
|
||||
200: "#b3ddff",
|
||||
250: "#99d3ff",
|
||||
300: "#66bfff",
|
||||
350: "#47b3ff",
|
||||
400: "#1ca6ff",
|
||||
500: "#0088E8", // 主色调
|
||||
600: "#0070cc",
|
||||
700: "#0058a3",
|
||||
800: "#004080",
|
||||
900: "#002847",
|
||||
DEFAULT: "#0088E8",
|
||||
},
|
||||
// 辅助色 - 温和灰(内容展示)
|
||||
secondary: {
|
||||
50: "#fafafa",
|
||||
100: "#f5f5f5",
|
||||
200: "#eeeeee",
|
||||
300: "#e0e0e0",
|
||||
350: "#d4d4d4",
|
||||
400: "#bdbdbd",
|
||||
500: "#9e9e9e",
|
||||
600: "#757575",
|
||||
700: "#616161",
|
||||
800: "#424242",
|
||||
900: "#212121",
|
||||
DEFAULT: "#757575",
|
||||
},
|
||||
// 强调色 - 活力橙(重要操作、交互)
|
||||
accent: {
|
||||
50: "#fff4e5",
|
||||
100: "#ffe8cc",
|
||||
200: "#ffd699",
|
||||
300: "#ffc466",
|
||||
400: "#ffb333",
|
||||
500: "#ffa200",
|
||||
600: "#cc8200",
|
||||
700: "#996100",
|
||||
800: "#664100",
|
||||
900: "#332000",
|
||||
DEFAULT: "#ffa200",
|
||||
},
|
||||
// 功能色(协调搭配)
|
||||
success: "#4caf50",
|
||||
warning: "#ff9800",
|
||||
danger: "#f44336",
|
||||
info: "#03a9f4",
|
||||
},
|
||||
fontFamily: {
|
||||
sans: [
|
||||
"PingFang SC",
|
||||
"Microsoft YaHei",
|
||||
"Helvetica Neue",
|
||||
"system-ui",
|
||||
"sans-serif",
|
||||
],
|
||||
heading: [
|
||||
"PingFang SC",
|
||||
"Microsoft YaHei",
|
||||
"Helvetica Neue",
|
||||
"sans-serif",
|
||||
],
|
||||
mono: ["Source Code Pro", "monospace"],
|
||||
},
|
||||
spacing: {
|
||||
"72": "18rem",
|
||||
"84": "21rem",
|
||||
"96": "24rem",
|
||||
},
|
||||
borderRadius: {
|
||||
xl: "1rem",
|
||||
"2xl": "2rem",
|
||||
"3xl": "3rem",
|
||||
},
|
||||
boxShadow: {
|
||||
outline: "0 0 0 3px rgba(0, 136, 232, 0.4)",
|
||||
solid: "2px 2px 0 0 rgba(0, 0, 0, 0.1)",
|
||||
glow: "0 0 8px rgba(0, 136, 232, 0.4)",
|
||||
inset: "inset 0 2px 4px 0 rgba(0, 0, 0, 0.05)",
|
||||
"elevation-1": "0 1px 2px rgba(0, 0, 0, 0.05)",
|
||||
"elevation-2": "0 2px 4px rgba(0, 0, 0, 0.1)",
|
||||
"elevation-3": "0 4px 8px rgba(0, 0, 0, 0.1)",
|
||||
"elevation-4": "0 8px 16px rgba(0, 0, 0, 0.1)",
|
||||
"elevation-5": "0 16px 32px rgba(0, 0, 0, 0.1)",
|
||||
panel: "0 2px 4px rgba(0, 0, 0, 0.05)",
|
||||
button: "0 2px 4px rgba(0, 0, 0, 0.1)",
|
||||
card: "0 2px 8px rgba(0, 0, 0, 0.08)",
|
||||
modal: "0 4px 16px rgba(0, 0, 0, 0.1)",
|
||||
"video-thumbnail": "0 2px 4px rgba(0, 0, 0, 0.1)",
|
||||
"hover-card": "0 4px 8px rgba(0, 0, 0, 0.08)",
|
||||
"player-controls": "0 -2px 8px rgba(0, 0, 0, 0.1)",
|
||||
"floating-player": "0 4px 12px rgba(0, 0, 0, 0.1)",
|
||||
},
|
||||
animation: {
|
||||
"pulse-slow": "pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite",
|
||||
"spin-slow": "spin 3s linear infinite",
|
||||
"fade-in": "fadeIn 0.3s ease-in-out",
|
||||
"slide-up": "slideUp 0.4s ease-out",
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
"0%": { opacity: "0" },
|
||||
"100%": { opacity: "1" },
|
||||
},
|
||||
slideUp: {
|
||||
"0%": { transform: "translateY(10px)", opacity: "0" },
|
||||
"100%": { transform: "translateY(0)", opacity: "1" },
|
||||
},
|
||||
},
|
||||
transitionDuration: {
|
||||
"2000": "2000ms",
|
||||
"3000": "3000ms",
|
||||
},
|
||||
screens: {
|
||||
"3xl": "1920px",
|
||||
"4xl": "2560px",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
|
@ -0,0 +1,112 @@
|
|||
/**
|
||||
* 定义颜色比例尺,从最浅(50)到最深(950)的颜色梯度
|
||||
* 用于构建一致的颜色系统,每个数字代表颜色的深浅程度
|
||||
*/
|
||||
export type ColorScale = {
|
||||
50: string; // 最浅色调
|
||||
100: string; // 非常浅色调
|
||||
200: string; // 浅色调
|
||||
300: string; // 中浅色调
|
||||
400: string; // 中等偏浅色调
|
||||
500: string; // 基准色调
|
||||
600: string; // 中等偏深色调
|
||||
700: string; // 深色调
|
||||
800: string; // 很深色调
|
||||
900: string; // 非常深色调
|
||||
950: string; // 最深色调
|
||||
DEFAULT: string; // Tailwind 默认值,通常对应 500
|
||||
}
|
||||
/**
|
||||
* 主题的基础颜色配置
|
||||
* 定义系统中使用的所有基础色板
|
||||
*/
|
||||
export type ThemeColors = {
|
||||
primary: ColorScale; // 主要品牌色
|
||||
secondary: ColorScale; // 次要品牌色
|
||||
neutral: ColorScale; // 中性色,通常用于文本和背景
|
||||
success: ColorScale; // 成功状态颜色
|
||||
warning: ColorScale; // 警告状态颜色
|
||||
error: ColorScale; // 错误状态颜色
|
||||
info: ColorScale; // 信息状态颜色
|
||||
}
|
||||
/**
|
||||
* 主题的语义化颜色配置
|
||||
* 定义具体UI元素的颜色应用场景
|
||||
*/
|
||||
export type ThemeSemantics = {
|
||||
colors: ThemeColors,
|
||||
/** 文本颜色相关配置 */
|
||||
textColor: {
|
||||
DEFAULT: string; // 默认文本颜色
|
||||
primary: string; // 主要文本
|
||||
secondary: string; // 次要文本
|
||||
tertiary: string; // 第三级文本
|
||||
disabled: string; // 禁用状态
|
||||
inverse: string; // 反色文本
|
||||
success: string; // 成功状态
|
||||
warning: string; // 警告状态
|
||||
error: string; // 错误状态
|
||||
info: string; // 信息提示
|
||||
link: string; // 链接文本
|
||||
linkHover: string; // 链接悬浮
|
||||
placeholder: string; // 占位符文本
|
||||
highlight: string; // 高亮文本
|
||||
};
|
||||
|
||||
/** 背景颜色相关配置 */
|
||||
backgroundColor: {
|
||||
DEFAULT: string; // 默认背景色
|
||||
paper: string; // 卡片/纸张背景
|
||||
subtle: string; // 轻微背景
|
||||
inverse: string; // 反色背景
|
||||
success: string; // 成功状态背景
|
||||
warning: string; // 警告状态背景
|
||||
error: string; // 错误状态背景
|
||||
info: string; // 信息提示背景
|
||||
primaryHover: string; // 主要按钮悬浮
|
||||
primaryActive: string; // 主要按钮激活
|
||||
primaryDisabled: string; // 主要按钮禁用
|
||||
secondaryHover: string; // 次要按钮悬浮
|
||||
secondaryActive: string; // 次要按钮激活
|
||||
secondaryDisabled: string; // 次要按钮禁用
|
||||
selected: string; // 选中状态
|
||||
hover: string; // 通用悬浮态
|
||||
focused: string; // 聚焦状态
|
||||
disabled: string; // 禁用状态
|
||||
overlay: string; // 遮罩层
|
||||
};
|
||||
|
||||
/** 边框颜色配置 */
|
||||
border: {
|
||||
DEFAULT: string; // 默认边框
|
||||
subtle: string; // 轻微边框
|
||||
strong: string; // 强调边框
|
||||
focus: string; // 聚焦边框
|
||||
inverse: string; // 反色边框
|
||||
success: string; // 成功状态边框
|
||||
warning: string; // 警告状态边框
|
||||
error: string; // 错误状态边框
|
||||
info: string; // 信息提示边框
|
||||
disabled: string; // 禁用状态边框
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export type ThemeToken = ThemeSemantics
|
||||
export interface Theme {
|
||||
token: ThemeToken;
|
||||
isDark: boolean;
|
||||
}
|
||||
export interface ThemeSeed {
|
||||
colors: {
|
||||
primary: string;
|
||||
secondary: string;
|
||||
neutral: string;
|
||||
success?: string;
|
||||
warning?: string;
|
||||
error?: string;
|
||||
info?: string;
|
||||
}
|
||||
|
||||
isDark?: boolean;
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
// Helper function to generate conditional values based on dark mode
|
||||
export function darkMode<T>(isDark: boolean, darkValue: T, lightValue: T): T {
|
||||
return isDark ? darkValue : lightValue;
|
||||
}
|
||||
/**
|
||||
* 将驼峰命名转换为kebab-case
|
||||
*/
|
||||
export function toKebabCase(str: string): string {
|
||||
return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
|
||||
}
|
||||
/**
|
||||
* 将嵌套对象扁平化,使用点号连接键名
|
||||
*/
|
||||
export function flattenObject(obj: Record<string, any>, prefix = ''): Record<string, string> {
|
||||
return Object.keys(obj).reduce((acc: Record<string, string>, k: string) => {
|
||||
const pre = prefix.length ? prefix + '.' : ''
|
||||
|
||||
if (typeof obj[k] === 'object' && obj[k] !== null && !Array.isArray(obj[k])) {
|
||||
Object.assign(acc, flattenObject(obj[k], pre + k))
|
||||
} else {
|
||||
acc[pre + k] = obj[k].toString()
|
||||
}
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
}
|
|
@ -12,12 +12,12 @@
|
|||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"removeComments": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"strictNullChecks": false,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"esModuleInterop": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
|
@ -7,6 +7,7 @@ export default defineConfig({
|
|||
clean: true,
|
||||
sourcemap: true,
|
||||
minify: true,
|
||||
external: ['react', 'react-dom'],
|
||||
bundle: true,
|
||||
target: "esnext"
|
||||
})
|
|
@ -0,0 +1 @@
|
|||
Subproject commit b911b4ba7629da9d6c622abe241fd25299baf1a5
|
|
@ -1,3 +0,0 @@
|
|||
# 一个web思维导图的简单实现
|
||||
|
||||
详细文档见:[https://github.com/wanglin2/mind-map](https://github.com/wanglin2/mind-map)
|
|
@ -1,21 +0,0 @@
|
|||
const { exec } = require('child_process')
|
||||
const fs = require('fs')
|
||||
|
||||
const base = './src/plugins/'
|
||||
const list = fs.readdirSync(base)
|
||||
const files = []
|
||||
list.forEach(item => {
|
||||
const stat = fs.statSync(base + item)
|
||||
if (stat.isFile()) {
|
||||
files.push(item)
|
||||
}
|
||||
})
|
||||
const str = files
|
||||
.map(item => {
|
||||
return base + item
|
||||
})
|
||||
.join(' ')
|
||||
|
||||
exec(
|
||||
`tsc ${str} --declaration --allowJs --emitDeclarationOnly --outDir types/src/ --target es2017 --skipLibCheck `
|
||||
)
|
|
@ -1,152 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import ws from 'ws'
|
||||
import http from 'http'
|
||||
import * as map from 'lib0/map'
|
||||
|
||||
const wsReadyStateConnecting = 0
|
||||
const wsReadyStateOpen = 1
|
||||
const wsReadyStateClosing = 2 // eslint-disable-line
|
||||
const wsReadyStateClosed = 3 // eslint-disable-line
|
||||
|
||||
const pingTimeout = 30000
|
||||
|
||||
const port = process.env.PORT || 4444
|
||||
// @ts-ignore
|
||||
const wss = new ws.Server({ noServer: true })
|
||||
|
||||
const server = http.createServer((request, response) => {
|
||||
response.writeHead(200, { 'Content-Type': 'text/plain' })
|
||||
response.end('okay')
|
||||
})
|
||||
|
||||
/**
|
||||
* Map froms topic-name to set of subscribed clients.
|
||||
* @type {Map<string, Set<any>>}
|
||||
*/
|
||||
const topics = new Map()
|
||||
|
||||
/**
|
||||
* @param {any} conn
|
||||
* @param {object} message
|
||||
*/
|
||||
const send = (conn, message) => {
|
||||
if (
|
||||
conn.readyState !== wsReadyStateConnecting &&
|
||||
conn.readyState !== wsReadyStateOpen
|
||||
) {
|
||||
conn.close()
|
||||
}
|
||||
try {
|
||||
conn.send(JSON.stringify(message))
|
||||
} catch (e) {
|
||||
conn.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup a new client
|
||||
* @param {any} conn
|
||||
*/
|
||||
const onconnection = conn => {
|
||||
/**
|
||||
* @type {Set<string>}
|
||||
*/
|
||||
const subscribedTopics = new Set()
|
||||
let closed = false
|
||||
// Check if connection is still alive
|
||||
let pongReceived = true
|
||||
const pingInterval = setInterval(() => {
|
||||
if (!pongReceived) {
|
||||
conn.close()
|
||||
clearInterval(pingInterval)
|
||||
} else {
|
||||
pongReceived = false
|
||||
try {
|
||||
conn.ping()
|
||||
} catch (e) {
|
||||
conn.close()
|
||||
}
|
||||
}
|
||||
}, pingTimeout)
|
||||
conn.on('pong', () => {
|
||||
pongReceived = true
|
||||
})
|
||||
conn.on('close', () => {
|
||||
subscribedTopics.forEach(topicName => {
|
||||
const subs = topics.get(topicName) || new Set()
|
||||
subs.delete(conn)
|
||||
if (subs.size === 0) {
|
||||
topics.delete(topicName)
|
||||
}
|
||||
})
|
||||
subscribedTopics.clear()
|
||||
closed = true
|
||||
})
|
||||
conn.on(
|
||||
'message',
|
||||
/** @param {object} message */ message => {
|
||||
if (typeof message === 'string') {
|
||||
message = JSON.parse(message)
|
||||
}
|
||||
if (message && message.type && !closed) {
|
||||
switch (message.type) {
|
||||
case 'subscribe':
|
||||
/** @type {Array<string>} */ ;(message.topics || []).forEach(
|
||||
topicName => {
|
||||
if (typeof topicName === 'string') {
|
||||
// add conn to topic
|
||||
const topic = map.setIfUndefined(
|
||||
topics,
|
||||
topicName,
|
||||
() => new Set()
|
||||
)
|
||||
topic.add(conn)
|
||||
// add topic to conn
|
||||
subscribedTopics.add(topicName)
|
||||
}
|
||||
}
|
||||
)
|
||||
break
|
||||
case 'unsubscribe':
|
||||
/** @type {Array<string>} */ ;(message.topics || []).forEach(
|
||||
topicName => {
|
||||
const subs = topics.get(topicName)
|
||||
if (subs) {
|
||||
subs.delete(conn)
|
||||
}
|
||||
}
|
||||
)
|
||||
break
|
||||
case 'publish':
|
||||
if (message.topic) {
|
||||
const receivers = topics.get(message.topic)
|
||||
if (receivers) {
|
||||
message.clients = receivers.size
|
||||
receivers.forEach(receiver => send(receiver, message))
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'ping':
|
||||
send(conn, { type: 'pong' })
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
wss.on('connection', onconnection)
|
||||
|
||||
server.on('upgrade', (request, socket, head) => {
|
||||
// You may check auth of request here..
|
||||
/**
|
||||
* @param {any} ws
|
||||
*/
|
||||
const handleAuth = ws => {
|
||||
wss.emit('connection', ws, request)
|
||||
}
|
||||
wss.handleUpgrade(request, socket, head, handleAuth)
|
||||
})
|
||||
|
||||
server.listen(port)
|
||||
|
||||
console.log('Signaling server running on localhost:', port)
|
|
@ -1,941 +0,0 @@
|
|||
const createFullData = () => {
|
||||
return {
|
||||
"image": "/enJFNMHnedQTYTESGfDkctCp2.jpeg",
|
||||
"imageTitle": "图片名称",
|
||||
"imageSize": {
|
||||
"width": 1000,
|
||||
"height": 563
|
||||
},
|
||||
"icon": ['priority_1'],
|
||||
"tag": ["标签1", "标签2"],
|
||||
"hyperlink": "http://lxqnsys.com/",
|
||||
"hyperlinkTitle": "理想青年实验室",
|
||||
"note": "理想青年实验室\n一个有意思的角落",
|
||||
// 自定义位置
|
||||
// "customLeft": 1318,
|
||||
// "customTop": 374.5
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @Author: 王林
|
||||
* @Date: 2021-04-15 22:23:24
|
||||
* @Desc: 节点较多示例数据
|
||||
*/
|
||||
const data1 = {
|
||||
"root": {
|
||||
"data": {
|
||||
"text": "根节点"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"data": {
|
||||
"text": "二级节点1",
|
||||
"expand": true,
|
||||
},
|
||||
"children": [{
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
...createFullData()
|
||||
},
|
||||
"children": [{
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
"children": [{
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
...createFullData()
|
||||
},
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
"children": [{
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
"children": [{
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
"children": [{
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
"children": [{
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}]
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}]
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}]
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}]
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}]
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}]
|
||||
}]
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"text": "二级节点2",
|
||||
"expand": true,
|
||||
},
|
||||
"children": [{
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
"children": [{
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}]
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}]
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"text": "二级节点3",
|
||||
"expand": true,
|
||||
},
|
||||
"children": [{
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
"children": [{
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
"children": [{
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}]
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}]
|
||||
}]
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"text": "二级节点4",
|
||||
"expand": true,
|
||||
},
|
||||
"children": [{
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
"children": [{
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
"children": [{
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
"children": [{
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}]
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}]
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
"children": [{
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
"children": [{
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
"children": [{
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}]
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
"children": [{
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
"children": [{
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}]
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}]
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}]
|
||||
}]
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
"children": [{
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
"children": [{
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
"children": [{
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}]
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}]
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
"children": [{
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}]
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
"children": [{
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
},
|
||||
}]
|
||||
}]
|
||||
}]
|
||||
}]
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* javascript comment
|
||||
* @Author: 王林25
|
||||
* @Date: 2021-07-12 13:49:43
|
||||
* @Desc: 真实场景数据
|
||||
*/
|
||||
const data2 = {
|
||||
"root": {
|
||||
"data": {
|
||||
"text": "一周安排"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"data": {
|
||||
"text": "生活"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"data": {
|
||||
"text": "锻炼"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"data": {
|
||||
"text": "晨跑"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"data": {
|
||||
"text": "7:00-8:00"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"text": "夜跑"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"data": {
|
||||
"text": "20:00-21:00"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"text": "饮食"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"data": {
|
||||
"text": "早餐"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"data": {
|
||||
"text": "8:30"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"text": "午餐"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"data": {
|
||||
"text": "11:30"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"text": "晚餐"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"data": {
|
||||
"text": "19:00"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"text": "休息"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"data": {
|
||||
"text": "午休"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"data": {
|
||||
"text": "12:30-13:00"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"text": "晚休"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"data": {
|
||||
"text": "23:00-6:30"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"text": "工作日\n周一至周五"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"data": {
|
||||
"text": "日常工作"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"data": {
|
||||
"text": "9:00-18:00"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"text": "工作总结"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"data": {
|
||||
"text": "21:00-22:00"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"text": "学习"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"data": {
|
||||
"text": "工作日"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"data": {
|
||||
"text": "早间新闻"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"data": {
|
||||
"text": "8:00-8:30"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"text": "阅读"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"data": {
|
||||
"text": "21:00-23:00"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"text": "休息日"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"data": {
|
||||
"text": "财务管理"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"data": {
|
||||
"text": "9:00-10:30"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"text": "职场技能"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"data": {
|
||||
"text": "14:00-15:30"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"text": "其他书籍"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"data": {
|
||||
"text": "16:00-18:00"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"text": "休闲娱乐"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"data": {
|
||||
"text": "看电影"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"data": {
|
||||
"text": "1~2部"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"text": "逛街"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"data": {
|
||||
"text": "1~2次"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* javascript comment
|
||||
* @Author: 王林25
|
||||
* @Date: 2021-07-12 14:29:10
|
||||
* @Desc: 极简数据
|
||||
*/
|
||||
const data3 = {
|
||||
"root": {
|
||||
"data": {
|
||||
"text": "根节点"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"data": {
|
||||
"text": "二级节点"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"data": {
|
||||
"text": "分支主题"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"text": "分支主题"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const data4 = {
|
||||
"root": {
|
||||
"data": {
|
||||
"text": "根节点"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"data": {
|
||||
"text": "二级节点1"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"data": {
|
||||
"text": "子节点1-1"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"text": "子节点1-2"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"data": {
|
||||
"text": "子节点1-2-1"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"text": "子节点1-2-2"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"text": "子节点1-2-3"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"text": "二级节点2"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"data": {
|
||||
"text": "子节点2-1"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"data": {
|
||||
"text": "子节点2-1-1"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"data": {
|
||||
"text": "子节点2-1-1-1"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"text": "子节点2-2"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// 带概要
|
||||
const data5 = {
|
||||
"root": {
|
||||
"data": {
|
||||
"text": "根节点"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"data": {
|
||||
"text": "二级节点",
|
||||
"generalization": {
|
||||
"text": "概要",
|
||||
}
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"data": {
|
||||
"text": "分支主题"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"text": "分支主题"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// 富文本数据v0.4.0+,需要使用RichText插件才支持富文本编辑
|
||||
const richTextData = {
|
||||
"root": {
|
||||
"data": {
|
||||
"text": "<a href='http://lxqnsys.com/' target='_blank'>理想去年实验室</a>",
|
||||
"richText": true
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
}
|
||||
|
||||
const rootData = {
|
||||
"root": {
|
||||
"data": {
|
||||
"text": "根节点"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
// ...data1,
|
||||
// ...data2,
|
||||
// ...data3,
|
||||
// ...data4,
|
||||
...data5,
|
||||
// ...rootData,
|
||||
"theme": {
|
||||
"template": "classic4",
|
||||
"config": {
|
||||
// 自定义配置...
|
||||
}
|
||||
},
|
||||
"layout": "logicalStructure",
|
||||
// "layout": "mindMap",
|
||||
// "layout": "catalogOrganization"
|
||||
// "layout": "organizationStructure",
|
||||
"config": {}
|
||||
}
|
|
@ -1,73 +0,0 @@
|
|||
{
|
||||
"layout": "logicalStructure",
|
||||
"root": {
|
||||
"data": {
|
||||
"text": "根节点",
|
||||
"expand": true,
|
||||
"isActive": false
|
||||
},
|
||||
"children": [{
|
||||
"data": {
|
||||
"text": "二级节点",
|
||||
"generalization": {
|
||||
"text": "概要",
|
||||
"expand": true,
|
||||
"isActive": false
|
||||
},
|
||||
"expand": true,
|
||||
"isActive": false
|
||||
},
|
||||
"children": [{
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
"expand": true,
|
||||
"isActive": false
|
||||
},
|
||||
"children": []
|
||||
}, {
|
||||
"data": {
|
||||
"text": "分支主题",
|
||||
"expand": true,
|
||||
"isActive": false
|
||||
},
|
||||
"children": []
|
||||
}, {
|
||||
"data": {
|
||||
"text": "<a href='http://lxqnsys.com/' target='_blank'>理想去年实验室</a>",
|
||||
"richText": true
|
||||
},
|
||||
"children": []
|
||||
}]
|
||||
}]
|
||||
},
|
||||
"theme": {
|
||||
"template": "classic4",
|
||||
"config": {}
|
||||
},
|
||||
"view": {
|
||||
"transform": {
|
||||
"scaleX": 1,
|
||||
"scaleY": 1,
|
||||
"shear": 0,
|
||||
"rotate": 0,
|
||||
"translateX": 0,
|
||||
"translateY": 0,
|
||||
"originX": 0,
|
||||
"originY": 0,
|
||||
"a": 1,
|
||||
"b": 0,
|
||||
"c": 0,
|
||||
"d": 1,
|
||||
"e": 0,
|
||||
"f": 0
|
||||
},
|
||||
"state": {
|
||||
"scale": 1,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"sx": 0,
|
||||
"sy": 0
|
||||
}
|
||||
},
|
||||
"config": {}
|
||||
}
|
|
@ -1,53 +0,0 @@
|
|||
{
|
||||
"name": "@nice/mindmap",
|
||||
"version": "0.13.0",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"clean": "rimraf dist",
|
||||
"lint": "eslint src/",
|
||||
"format": "prettier --write .",
|
||||
"types": "npx -p typescript tsc index.js --declaration --allowJs --emitDeclarationOnly --outDir types --target es2017 --skipLibCheck & node ./bin/createPluginsTypeFiles.js",
|
||||
"wsServe": "node ./bin/wsServer.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@svgdotjs/svg.js": "3.2.0",
|
||||
"deepmerge": "^1.5.2",
|
||||
"eventemitter3": "^4.0.7",
|
||||
"jszip": "^3.10.1",
|
||||
"katex": "^0.16.8",
|
||||
"mdast-util-from-markdown": "^1.3.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"quill": "^2.0.3",
|
||||
"tern": "^0.24.3",
|
||||
"uuid": "^9.0.0",
|
||||
"ws": "^7.5.9",
|
||||
"xml-js": "^1.6.11",
|
||||
"y-webrtc": "^10.2.5",
|
||||
"yjs": "^13.6.8"
|
||||
},
|
||||
"keywords": [
|
||||
"javascript",
|
||||
"svg",
|
||||
"mind-map",
|
||||
"mindMap",
|
||||
"MindMap"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@types/dagre": "^0.7.52",
|
||||
"@types/katex": "^0.16.7",
|
||||
"@types/mdast": "^4.0.4",
|
||||
"@types/node": "^20.3.1",
|
||||
"@types/react": "18.2.38",
|
||||
"@types/react-dom": "18.2.15",
|
||||
"concurrently": "^8.0.0",
|
||||
"rimraf": "^6.0.1",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsup": "^8.3.5",
|
||||
"typescript": "^5.5.4"
|
||||
}
|
||||
}
|
|
@ -1,46 +0,0 @@
|
|||
// 遍历所有js文件
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
|
||||
const entryPath = path.resolve(__dirname, '../src')
|
||||
|
||||
const transform = dir => {
|
||||
let dirs = fs.readdirSync(dir)
|
||||
dirs.forEach(item => {
|
||||
let file = path.join(dir, item)
|
||||
if (fs.statSync(file).isDirectory()) {
|
||||
transform(file)
|
||||
} else if (/\.js$/.test(file)) {
|
||||
transformFile(file)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const transformFile = file => {
|
||||
console.log(file)
|
||||
let content = fs.readFileSync(file, 'utf-8')
|
||||
countCodeLines(content)
|
||||
// transformComments(file, content)
|
||||
}
|
||||
|
||||
// 统计代码行数
|
||||
let totalLines = 0
|
||||
const countCodeLines = content => {
|
||||
totalLines += content.split(/\n/).length
|
||||
}
|
||||
|
||||
// 转换注释类型
|
||||
const transformComments = (file, content) => {
|
||||
console.log('当前转换文件:', file)
|
||||
content = content.replace(/\/\*\*[^/]+\*\//g, str => {
|
||||
let res = /@Desc:([^\n]+)\n/g.exec(str)
|
||||
if (res.length > 0) {
|
||||
return '// ' + res[1]
|
||||
}
|
||||
})
|
||||
fs.writeFileSync(file, content)
|
||||
}
|
||||
|
||||
transform(entryPath)
|
||||
transformFile(path.join(__dirname, '../index.js'))
|
||||
console.log(totalLines)
|
|
@ -1,238 +0,0 @@
|
|||
// 常量
|
||||
export const CONSTANTS = {
|
||||
CHANGE_THEME: 'changeTheme',
|
||||
CHANGE_LAYOUT: 'changeLayout',
|
||||
SET_DATA: 'setData',
|
||||
MODE: {
|
||||
READONLY: 'readonly',
|
||||
EDIT: 'edit'
|
||||
},
|
||||
LAYOUT: {
|
||||
LOGICAL_STRUCTURE: 'logicalStructure',
|
||||
LOGICAL_STRUCTURE_LEFT: 'logicalStructureLeft',
|
||||
MIND_MAP: 'mindMap',
|
||||
ORGANIZATION_STRUCTURE: 'organizationStructure',
|
||||
CATALOG_ORGANIZATION: 'catalogOrganization',
|
||||
TIMELINE: 'timeline',
|
||||
TIMELINE2: 'timeline2',
|
||||
FISHBONE: 'fishbone',
|
||||
VERTICAL_TIMELINE: 'verticalTimeline'
|
||||
},
|
||||
DIR: {
|
||||
UP: 'up',
|
||||
LEFT: 'left',
|
||||
DOWN: 'down',
|
||||
RIGHT: 'right'
|
||||
},
|
||||
KEY_DIR: {
|
||||
LEFT: 'Left',
|
||||
UP: 'Up',
|
||||
RIGHT: 'Right',
|
||||
DOWN: 'Down'
|
||||
},
|
||||
SHAPE: {
|
||||
RECTANGLE: 'rectangle',
|
||||
DIAMOND: 'diamond',
|
||||
PARALLELOGRAM: 'parallelogram',
|
||||
ROUNDED_RECTANGLE: 'roundedRectangle',
|
||||
OCTAGONAL_RECTANGLE: 'octagonalRectangle',
|
||||
OUTER_TRIANGULAR_RECTANGLE: 'outerTriangularRectangle',
|
||||
INNER_TRIANGULAR_RECTANGLE: 'innerTriangularRectangle',
|
||||
ELLIPSE: 'ellipse',
|
||||
CIRCLE: 'circle'
|
||||
},
|
||||
MOUSE_WHEEL_ACTION: {
|
||||
ZOOM: 'zoom',
|
||||
MOVE: 'move'
|
||||
},
|
||||
INIT_ROOT_NODE_POSITION: {
|
||||
LEFT: 'left',
|
||||
TOP: 'top',
|
||||
RIGHT: 'right',
|
||||
BOTTOM: 'bottom',
|
||||
CENTER: 'center'
|
||||
},
|
||||
LAYOUT_GROW_DIR: {
|
||||
LEFT: 'left',
|
||||
TOP: 'top',
|
||||
RIGHT: 'right',
|
||||
BOTTOM: 'bottom'
|
||||
},
|
||||
PASTE_TYPE: {
|
||||
CLIP_BOARD: 'clipBoard',
|
||||
CANVAS: 'canvas'
|
||||
},
|
||||
SCROLL_BAR_DIR: {
|
||||
VERTICAL: 'vertical',
|
||||
HORIZONTAL: 'horizontal'
|
||||
},
|
||||
CREATE_NEW_NODE_BEHAVIOR: {
|
||||
DEFAULT: 'default',
|
||||
NOT_ACTIVE: 'notActive',
|
||||
ACTIVE_ONLY: 'activeOnly'
|
||||
},
|
||||
TAG_POSITION: {
|
||||
RIGHT: 'right',
|
||||
BOTTOM: 'bottom'
|
||||
},
|
||||
EDIT_NODE_CLASS: {
|
||||
SMM_NODE_EDIT_WRAP: 'smm-node-edit-wrap',
|
||||
RICH_TEXT_EDIT_WRAP: 'ql-editor',
|
||||
ASSOCIATIVE_LINE_TEXT_EDIT_WRAP: 'associative-line-text-edit-warp'
|
||||
}
|
||||
}
|
||||
|
||||
export const initRootNodePositionMap = {
|
||||
[CONSTANTS.INIT_ROOT_NODE_POSITION.LEFT]: 0,
|
||||
[CONSTANTS.INIT_ROOT_NODE_POSITION.TOP]: 0,
|
||||
[CONSTANTS.INIT_ROOT_NODE_POSITION.RIGHT]: 1,
|
||||
[CONSTANTS.INIT_ROOT_NODE_POSITION.BOTTOM]: 1,
|
||||
[CONSTANTS.INIT_ROOT_NODE_POSITION.CENTER]: 0.5
|
||||
}
|
||||
|
||||
// 布局结构列表
|
||||
export const layoutList = [
|
||||
{
|
||||
name: '逻辑结构图',
|
||||
value: CONSTANTS.LAYOUT.LOGICAL_STRUCTURE
|
||||
},
|
||||
{
|
||||
name: '向左逻辑结构图',
|
||||
value: CONSTANTS.LAYOUT.LOGICAL_STRUCTURE_LEFT
|
||||
},
|
||||
{
|
||||
name: '思维导图',
|
||||
value: CONSTANTS.LAYOUT.MIND_MAP
|
||||
},
|
||||
{
|
||||
name: '组织结构图',
|
||||
value: CONSTANTS.LAYOUT.ORGANIZATION_STRUCTURE
|
||||
},
|
||||
{
|
||||
name: '目录组织图',
|
||||
value: CONSTANTS.LAYOUT.CATALOG_ORGANIZATION
|
||||
},
|
||||
{
|
||||
name: '时间轴',
|
||||
value: CONSTANTS.LAYOUT.TIMELINE
|
||||
},
|
||||
{
|
||||
name: '时间轴2',
|
||||
value: CONSTANTS.LAYOUT.TIMELINE2
|
||||
},
|
||||
{
|
||||
name: '竖向时间轴',
|
||||
value: CONSTANTS.LAYOUT.VERTICAL_TIMELINE
|
||||
},
|
||||
{
|
||||
name: '鱼骨图',
|
||||
value: CONSTANTS.LAYOUT.FISHBONE
|
||||
}
|
||||
]
|
||||
export const layoutValueList = [
|
||||
CONSTANTS.LAYOUT.LOGICAL_STRUCTURE,
|
||||
CONSTANTS.LAYOUT.LOGICAL_STRUCTURE_LEFT,
|
||||
CONSTANTS.LAYOUT.MIND_MAP,
|
||||
CONSTANTS.LAYOUT.CATALOG_ORGANIZATION,
|
||||
CONSTANTS.LAYOUT.ORGANIZATION_STRUCTURE,
|
||||
CONSTANTS.LAYOUT.TIMELINE,
|
||||
CONSTANTS.LAYOUT.TIMELINE2,
|
||||
CONSTANTS.LAYOUT.VERTICAL_TIMELINE,
|
||||
CONSTANTS.LAYOUT.FISHBONE
|
||||
]
|
||||
|
||||
// 节点数据中非样式的字段
|
||||
export const nodeDataNoStylePropList = [
|
||||
'text',
|
||||
'image',
|
||||
'imageTitle',
|
||||
'imageSize',
|
||||
'icon',
|
||||
'tag',
|
||||
'hyperlink',
|
||||
'hyperlinkTitle',
|
||||
'note',
|
||||
'expand',
|
||||
'isActive',
|
||||
'generalization',
|
||||
'richText',
|
||||
'resetRichText',// 重新创建富文本内容,去掉原有样式
|
||||
'uid',
|
||||
'activeStyle',
|
||||
'associativeLineTargets',
|
||||
'associativeLineTargetControlOffsets',
|
||||
'associativeLinePoint',
|
||||
'associativeLineText',
|
||||
'attachmentUrl',
|
||||
'attachmentName',
|
||||
'notation',
|
||||
'outerFrame',
|
||||
'number',
|
||||
'range',
|
||||
'customLeft',
|
||||
'customTop',
|
||||
'customTextWidth',
|
||||
'checkbox',
|
||||
'dir',
|
||||
'needUpdate'// 重新创建节点内容
|
||||
]
|
||||
|
||||
// 错误类型
|
||||
export const ERROR_TYPES = {
|
||||
READ_CLIPBOARD_ERROR: 'read_clipboard_error',
|
||||
PARSE_PASTE_DATA_ERROR: 'parse_paste_data_error',
|
||||
CUSTOM_HANDLE_CLIPBOARD_TEXT_ERROR: 'custom_handle_clipboard_text_error',
|
||||
LOAD_CLIPBOARD_IMAGE_ERROR: 'load_clipboard_image_error',
|
||||
BEFORE_TEXT_EDIT_ERROR: 'before_text_edit_error',
|
||||
EXPORT_ERROR: 'export_error',
|
||||
EXPORT_LOAD_IMAGE_ERROR: 'export_load_image_error',
|
||||
DATA_CHANGE_DETAIL_EVENT_ERROR: 'data_change_detail_event_error'
|
||||
}
|
||||
|
||||
// css
|
||||
export const cssContent = `
|
||||
/* 鼠标hover和激活时渲染的矩形 */
|
||||
.smm-hover-node{
|
||||
display: none;
|
||||
opacity: 0.6;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.smm-node:not(.smm-node-dragging):hover .smm-hover-node{
|
||||
display: block;
|
||||
}
|
||||
|
||||
.smm-node.active .smm-hover-node, .smm-node-highlight .smm-hover-node{
|
||||
display: block;
|
||||
opacity: 1;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.smm-text-node-wrap, .smm-expand-btn-text {
|
||||
user-select: none;
|
||||
}
|
||||
`
|
||||
|
||||
// html自闭合标签列表
|
||||
export const selfCloseTagList = [
|
||||
'img',
|
||||
'br',
|
||||
'hr',
|
||||
'input',
|
||||
'link',
|
||||
'meta',
|
||||
'area'
|
||||
]
|
||||
|
||||
// 非富文本模式下的节点文本行高
|
||||
export const noneRichTextNodeLineHeight = 1.2
|
||||
|
||||
// 富文本支持的样式列表
|
||||
export const richTextSupportStyleList = [
|
||||
'fontFamily',
|
||||
'fontSize',
|
||||
'fontWeight',
|
||||
'fontStyle',
|
||||
'textDecoration',
|
||||
'color'
|
||||
]
|
|
@ -1,486 +0,0 @@
|
|||
import { CONSTANTS } from './constant'
|
||||
|
||||
// 默认选项配置
|
||||
export const defaultOpt = {
|
||||
// 【基本】
|
||||
// 容器元素,必传,必须为DOM元素
|
||||
el: null,
|
||||
// 思维导图回显数据
|
||||
data: null,
|
||||
// 要恢复的视图数据,一般通过mindMap.view.getTransformData()方法获取
|
||||
viewData: null,
|
||||
// 是否只读
|
||||
readonly: false,
|
||||
// 布局
|
||||
layout: CONSTANTS.LAYOUT.LOGICAL_STRUCTURE,
|
||||
// 如果结构为鱼骨图,那么可以通过该选项控制倾斜角度
|
||||
fishboneDeg: 45,
|
||||
// 主题
|
||||
theme: 'default', // 内置主题:default(默认主题)
|
||||
// 主题配置,会和所选择的主题进行合并
|
||||
themeConfig: {},
|
||||
// 放大缩小的增量比例
|
||||
scaleRatio: 0.2,
|
||||
// 平移的步长比例,只在鼠标滚轮和触控板触发的平移中应用
|
||||
translateRatio: 1,
|
||||
// 最小缩小值,百分数,最小为0,该选项只会影响view.narrow方法(影响的行为为Ctrl+-快捷键、鼠标滚轮及触控板),不会影响其他方法,比如view.setScale,所以需要你自行限制大小
|
||||
minZoomRatio: 20,
|
||||
// 最大放大值,百分数,传-1代表不限制,否则传0以上数字,,该选项只会影响view.enlarge方法
|
||||
maxZoomRatio: 400,
|
||||
// 自定义判断wheel事件是否来自电脑的触控板
|
||||
// 默认是通过判断e.deltaY的值是否小于10,显然这种方法是不准确的,当鼠标滚动的很慢,或者触摸移动的很快时判断就失效了,如果你有更好的方法,欢迎提交issue
|
||||
// 如果你希望自己来判断,那么传递一个函数,接收一个参数e(事件对象),需要返回true或false,代表是否是来自触控板
|
||||
customCheckIsTouchPad: null,
|
||||
// 鼠标缩放是否以鼠标当前位置为中心点,否则以画布中心点
|
||||
mouseScaleCenterUseMousePosition: true,
|
||||
// 最多显示几个标签
|
||||
maxTag: 5,
|
||||
// 标签显示的位置,相对于节点文本,bottom(下方)、right(右侧)
|
||||
tagPosition: CONSTANTS.TAG_POSITION.RIGHT,
|
||||
// 展开收缩按钮尺寸
|
||||
expandBtnSize: 20,
|
||||
// 节点里图片和文字的间距
|
||||
imgTextMargin: 5,
|
||||
// 节点里各种文字信息的间距,如图标和文字的间距
|
||||
textContentMargin: 2,
|
||||
// 自定义节点备注内容显示
|
||||
customNoteContentShow: null,
|
||||
/*
|
||||
{
|
||||
show(){},
|
||||
hide(){}
|
||||
}
|
||||
*/
|
||||
// 达到该宽度文本自动换行
|
||||
textAutoWrapWidth: 500,
|
||||
// 自定义鼠标滚轮事件处理
|
||||
// 可以传一个函数,回调参数为事件对象
|
||||
customHandleMousewheel: null,
|
||||
// 鼠标滚动的行为,如果customHandleMousewheel传了自定义函数,这个属性不生效
|
||||
mousewheelAction: CONSTANTS.MOUSE_WHEEL_ACTION.MOVE, // zoom(放大缩小)、move(上下移动)
|
||||
// 当mousewheelAction设为move时,可以通过该属性控制鼠标滚动一下视图移动的步长,单位px
|
||||
mousewheelMoveStep: 100,
|
||||
// 当mousewheelAction设为zoom时,或者按住Ctrl键时,默认向前滚动是缩小,向后滚动是放大,如果该属性设为true,那么会反过来
|
||||
mousewheelZoomActionReverse: true,
|
||||
// 默认插入的二级节点的文字
|
||||
defaultInsertSecondLevelNodeText: '二级节点',
|
||||
// 默认插入的二级以下节点的文字
|
||||
defaultInsertBelowSecondLevelNodeText: '分支主题',
|
||||
// 展开收起按钮的颜色
|
||||
expandBtnStyle: {
|
||||
color: '#808080',
|
||||
fill: '#fff',
|
||||
fontSize: 13,
|
||||
strokeColor: '#333333'
|
||||
},
|
||||
// 自定义展开收起按钮的图标
|
||||
expandBtnIcon: {
|
||||
open: '', // svg字符串
|
||||
close: ''
|
||||
},
|
||||
// 处理收起节点数量
|
||||
expandBtnNumHandler: null,
|
||||
// 是否显示带数量的收起按钮
|
||||
isShowExpandNum: true,
|
||||
// 是否只有当鼠标在画布内才响应快捷键事件
|
||||
enableShortcutOnlyWhenMouseInSvg: true,
|
||||
// 自定义判断是否响应快捷键事件,优先级比enableShortcutOnlyWhenMouseInSvg选项高
|
||||
// 可以传递一个函数,接收事件对象e为参数,需要返回true或false,返回true代表允许响应快捷键事件,反之不允许,库默认当事件目标为body,或为文本编辑框元素(普通文本编辑框、富文本编辑框、关联线文本编辑框)时响应快捷键,其他不响应
|
||||
customCheckEnableShortcut: null,
|
||||
// 初始根节点的位置
|
||||
initRootNodePosition: null,
|
||||
// 节点文本编辑框的z-index
|
||||
nodeTextEditZIndex: 3000,
|
||||
// 节点备注浮层的z-index
|
||||
nodeNoteTooltipZIndex: 3000,
|
||||
// 是否在点击了画布外的区域时结束节点文本的编辑状态
|
||||
isEndNodeTextEditOnClickOuter: true,
|
||||
// 最大历史记录数
|
||||
maxHistoryCount: 500,
|
||||
// 是否一直显示节点的展开收起按钮,默认为鼠标移上去和激活时才显示
|
||||
alwaysShowExpandBtn: false,
|
||||
// 不显示展开收起按钮,优先级比alwaysShowExpandBtn配置高
|
||||
notShowExpandBtn: false,
|
||||
// 扩展节点可插入的图标
|
||||
iconList: [
|
||||
// {
|
||||
// name: '',// 分组名称
|
||||
// type: '',// 分组的值
|
||||
// list: [// 分组下的图标列表
|
||||
// {
|
||||
// name: '',// 图标名称
|
||||
// icon:''// 图标,可以传svg或图片
|
||||
// }
|
||||
// ]
|
||||
// }
|
||||
],
|
||||
// 节点最大缓存数量
|
||||
maxNodeCacheCount: 1000,
|
||||
// 思维导图适应画布大小时的内边距
|
||||
fitPadding: 50,
|
||||
// 是否开启按住ctrl键多选节点功能
|
||||
enableCtrlKeyNodeSelection: true,
|
||||
// 设置为左键多选节点,右键拖动画布
|
||||
useLeftKeySelectionRightKeyDrag: false,
|
||||
// 节点即将进入编辑前的回调方法,如果该方法返回true以外的值,那么将取消编辑,函数可以返回一个值,或一个Promise,回调参数为节点实例
|
||||
beforeTextEdit: null,
|
||||
// 是否开启自定义节点内容
|
||||
isUseCustomNodeContent: false,
|
||||
// 自定义返回节点内容的方法
|
||||
customCreateNodeContent: null,
|
||||
// 指定内部一些元素(节点文本编辑元素、节点备注显示元素、关联线文本编辑元素、节点图片调整按钮元素)添加到的位置,默认添加到document.body下
|
||||
customInnerElsAppendTo: null,
|
||||
// 是否在存在一个激活节点时,当按下中文、英文、数字按键时自动进入文本编辑模式
|
||||
// 开启该特性后,需要给你的输入框绑定keydown事件,并禁止冒泡
|
||||
enableAutoEnterTextEditWhenKeydown: false,
|
||||
// 当enableAutoEnterTextEditWhenKeydown选项开启时生效,当通过按键进入文本编辑时是否自动清空原有文本
|
||||
autoEmptyTextWhenKeydownEnterEdit: false,
|
||||
// 自定义对剪贴板文本的处理。当按ctrl+v粘贴时会读取用户剪贴板中的文本和图片,默认只会判断文本是否是普通文本和simple-mind-map格式的节点数据,如果你想处理其他思维导图的数据,比如processon、zhixi等,那么可以传递一个函数,接受当前剪贴板中的文本为参数,返回处理后的数据,可以返回两种类型:
|
||||
/*
|
||||
1.返回一个纯文本,那么会直接以该文本创建一个子节点
|
||||
|
||||
2.返回一个节点对象,格式如下:
|
||||
{
|
||||
// 代表是simple-mind-map格式的数据
|
||||
simpleMindMap: true,
|
||||
// 节点数据,同simple-mind-map节点数据格式
|
||||
data: {
|
||||
data: {
|
||||
text: ''
|
||||
},
|
||||
children: []
|
||||
}
|
||||
}
|
||||
*/
|
||||
// 如果你的处理逻辑存在异步逻辑,也可以返回一个promise
|
||||
customHandleClipboardText: null,
|
||||
// 禁止鼠标滚轮缩放,你仍旧可以使用api进行缩放
|
||||
disableMouseWheelZoom: false,
|
||||
// 错误处理函数
|
||||
errorHandler: (code, error) => {
|
||||
console.error(code, error)
|
||||
},
|
||||
// 是否在鼠标双击时回到根节点,也就是让根节点居中显示
|
||||
enableDblclickBackToRootNode: false,
|
||||
// 节点鼠标hover和激活时显示的矩形边框的颜色
|
||||
hoverRectColor: 'rgb(94, 200, 248)',
|
||||
// 节点鼠标hover和激活时显示的矩形边框距节点内容的距离
|
||||
hoverRectPadding: 2,
|
||||
// 双击节点进入节点文本编辑时是否默认选中文本,默认只在创建新节点时会选中
|
||||
selectTextOnEnterEditText: false,
|
||||
// 删除节点后激活相邻节点
|
||||
deleteNodeActive: true,
|
||||
// 是否首次加载fit view
|
||||
fit: false,
|
||||
// 自定义标签的颜色
|
||||
// {pass: 'green, unpass: 'red'}
|
||||
tagsColorMap: {},
|
||||
// 节点协作样式配置
|
||||
cooperateStyle: {
|
||||
avatarSize: 22, // 头像大小
|
||||
fontSize: 12 // 如果是文字头像,那么文字的大小
|
||||
},
|
||||
// 协同编辑时,同一个节点不能同时被多人选中
|
||||
onlyOneEnableActiveNodeOnCooperate: false,
|
||||
// 插入概要的默认文本
|
||||
defaultGeneralizationText: '概要',
|
||||
// 粘贴文本的方式创建新节点时,控制是否按换行自动分割节点,即如果存在换行,那么会根据换行创建多个节点,否则只会创建一个节点
|
||||
// 可以传递一个函数,返回promise,resolve代表根据换行分割,reject代表忽略换行
|
||||
handleIsSplitByWrapOnPasteCreateNewNode: null,
|
||||
// 多少时间内只允许添加一次历史记录,避免添加没有必要的中间状态,单位:ms
|
||||
addHistoryTime: 100,
|
||||
// 是否禁止拖动画布
|
||||
isDisableDrag: false,
|
||||
// 创建新节点时的行为
|
||||
/*
|
||||
DEFAULT :默认会激活新创建的节点,并且进入编辑模式。如果同时创建了多个新节点,那么只会激活而不会进入编辑模式
|
||||
NOT_ACTIVE : 不激活新创建的节点
|
||||
ACTIVE_ONLY : 只激活新创建的节点,不进入编辑模式
|
||||
*/
|
||||
createNewNodeBehavior: CONSTANTS.CREATE_NEW_NODE_BEHAVIOR.DEFAULT,
|
||||
// 当节点图片加载失败时显示的默认图片
|
||||
defaultNodeImage: '',
|
||||
// 是否将思维导图限制在画布内
|
||||
// 比如向右拖动时,思维导图图形的最左侧到达画布中心时将无法继续向右拖动,其他同理
|
||||
isLimitMindMapInCanvas: false,
|
||||
// 在节点上粘贴剪贴板中的图片的处理方法,默认是转换为data:url数据插入到节点中,你可以通过该方法来将图片数据上传到服务器,实现保存图片的url
|
||||
// 可以传递一个异步方法,接收Blob类型的图片数据,需要返回如下结构:
|
||||
/*
|
||||
{
|
||||
url, // 图片url
|
||||
size: {
|
||||
width, // 图片的宽度
|
||||
height //图片的高度
|
||||
}
|
||||
}
|
||||
*/
|
||||
handleNodePasteImg: null,
|
||||
// 自定义创建节点形状的方法,可以传一个函数,均接收一个参数
|
||||
// 矩形、圆角矩形、椭圆、圆等形状会调用该方法
|
||||
// 接收svg path字符串,返回svg节点
|
||||
customCreateNodePath: null,
|
||||
// 菱形、平行四边形、八角矩形、外三角矩形、内三角矩形等形状会调用该方法
|
||||
// 接收points数组点位,返回svg节点
|
||||
customCreateNodePolygon: null,
|
||||
// 自定义转换节点连线路径的方法
|
||||
// 接收svg path字符串,返回转换后的svg path字符串
|
||||
customTransformNodeLinePath: null,
|
||||
// 快捷键操作即将执行前的生命周期函数,返回true可以阻止操作执行
|
||||
// 函数接收两个参数:key(快捷键)、activeNodeList(当前激活的节点列表)
|
||||
beforeShortcutRun: null,
|
||||
// 移动节点到画布中心、回到根节点等操作时是否将缩放层级复位为100%
|
||||
// 该选项实际影响的是render.moveNodeToCenter方法,moveNodeToCenter方法本身也存在第二个参数resetScale来设置是否复位,如果resetScale参数没有传递,那么使用resetScaleOnMoveNodeToCenter配置,否则使用resetScale配置
|
||||
resetScaleOnMoveNodeToCenter: false,
|
||||
// 添加附加的节点前置内容,前置内容指和文本同一行的区域中的前置内容,不包括节点图片部分。如果存在编号、任务勾选框内容,这里添加的前置内容会在这两者之后
|
||||
createNodePrefixContent: null,
|
||||
// 添加附加的节点后置内容,后置内容指和文本同一行的区域中的后置内容,不包括节点图片部分
|
||||
createNodePostfixContent: null,
|
||||
// 禁止粘贴用户剪贴板中的数据,禁止将复制的数据写入用户的剪贴板中
|
||||
disabledClipboard: false,
|
||||
// 自定义超链接的跳转
|
||||
// 如果不传,默认会以新窗口的方式打开超链接,可以传递一个函数,函数接收两个参数:link(超链接的url)、node(所属节点实例),只要传递了函数,就会阻止默认的跳转
|
||||
customHyperlinkJump: null,
|
||||
// 是否开启性能模式,默认情况下所有节点都会直接渲染,无论是否处于画布可视区域,这样当节点数量比较多时(1000+)会比较卡,如果你的数据量比较大,那么可以通过该配置开启性能模式,即只渲染画布可视区域内的节点,超出的节点不渲染,这样会大幅提高渲染速度,当然同时也会带来一些其他问题,比如:1.当拖动或是缩放画布时会实时计算并渲染未节点的节点,所以会带来一定卡顿;2.导出图片、svg、pdf时需要先渲染全部节点,所以会比较慢;3.其他目前未发现的问题
|
||||
openPerformance: false,
|
||||
// 性能优化模式配置
|
||||
performanceConfig: {
|
||||
time: 250, // 当视图改变后多久刷新一次节点,单位:ms,
|
||||
padding: 100, // 超出画布四周指定范围内依旧渲染节点
|
||||
removeNodeWhenOutCanvas: true // 节点移除画布可视区域后从画布删除
|
||||
},
|
||||
// 如果节点文本为空,那么为了避免空白节点高度塌陷,会用该字段指定的文本测量一个高度
|
||||
emptyTextMeasureHeightText: 'abc123我和你',
|
||||
// 是否在进行节点文本编辑时实时更新节点大小和节点位置,开启后当节点数量比较多时可能会造成卡顿
|
||||
openRealtimeRenderOnNodeTextEdit: false,
|
||||
// 默认会给容器元素el绑定mousedown事件,可通过该选项设置是否阻止其默认事件
|
||||
// 如果设置为true,会带来一定问题,比如你聚焦在思维导图外的其他输入框,点击画布就不会触发其失焦
|
||||
mousedownEventPreventDefault: false,
|
||||
// 在激活上粘贴用户剪贴板中的数据时,如果同时存在文本和图片,那么只粘贴文本,忽略图片
|
||||
onlyPasteTextWhenHasImgAndText: true,
|
||||
// 是否允许拖拽调整节点的宽度,实际上压缩的是节点里面文本内容的宽度,当节点文本内容宽度压缩到最小时无法继续压缩。如果节点存在图片,那么最小值以图片宽度和文本内容最小宽度的最大值为准(目前该特性仅在两种情况下可用:1.开启了富文本模式,即注册了RichText插件;2.自定义节点内容)
|
||||
enableDragModifyNodeWidth: true,
|
||||
// 当允许拖拽调整节点的宽度时,可以通过该选项设置节点文本内容允许压缩的最小宽度
|
||||
minNodeTextModifyWidth: 20,
|
||||
// 同minNodeTextModifyWidth,最大值,传-1代表不限制
|
||||
maxNodeTextModifyWidth: -1,
|
||||
// 自定义处理节点的连线方法,可以传递一个函数,函数接收三个参数:node(节点实例)、line(节点的某条连线,@svgjs库的path对象), { width, color, dasharray },dasharray(该条连线的虚线样式,为none代表实线),你可以修改line对象来达到修改节点连线样式的效果,比如增加流动效果
|
||||
customHandleLine: null,
|
||||
// 实例化完后是否立刻进行一次历史数据入栈操作
|
||||
// 即调用mindMap.command.addHistory方法
|
||||
addHistoryOnInit: true,
|
||||
// 自定义节点备注图标
|
||||
noteIcon: {
|
||||
icon: '', // svg字符串,如果不是确定要使用svg自带的样式,否则请去除其中的fill等样式属性
|
||||
style: {
|
||||
// size: 20,// 图标大小,不手动设置则会使用主题的iconSize配置
|
||||
// color: '',// 图标颜色,不手动设置则会使用节点文本的颜色
|
||||
}
|
||||
},
|
||||
// 自定义节点超链接图标
|
||||
hyperlinkIcon: {
|
||||
icon: '', // svg字符串,如果不是确定要使用svg自带的样式,否则请去除其中的fill等样式属性
|
||||
style: {
|
||||
// size: 20,// 图标大小,不手动设置则会使用主题的iconSize配置
|
||||
// color: '',// 图标颜色,不手动设置则会使用节点文本的颜色
|
||||
}
|
||||
},
|
||||
// 自定义节点附件图标
|
||||
attachmentIcon: {
|
||||
icon: '', // svg字符串,如果不是确定要使用svg自带的样式,否则请去除其中的fill等样式属性
|
||||
style: {
|
||||
// size: 20,// 图标大小,不手动设置则会使用主题的iconSize配置
|
||||
// color: '',// 图标颜色,不手动设置则会使用节点文本的颜色
|
||||
}
|
||||
},
|
||||
|
||||
// 【Select插件】
|
||||
// 多选节点时鼠标移动到边缘时的画布移动偏移量
|
||||
selectTranslateStep: 3,
|
||||
// 多选节点时鼠标移动距边缘多少距离时开始偏移
|
||||
selectTranslateLimit: 20,
|
||||
|
||||
// 【Drag插件】
|
||||
// 是否开启节点自由拖拽
|
||||
enableFreeDrag: false,
|
||||
// 拖拽节点时鼠标移动到画布边缘是否开启画布自动移动
|
||||
autoMoveWhenMouseInEdgeOnDrag: true,
|
||||
// 拖拽多个节点时随鼠标移动的示意矩形的样式配置
|
||||
dragMultiNodeRectConfig: {
|
||||
width: 40,
|
||||
height: 20,
|
||||
fill: 'rgb(94, 200, 248)' // 填充颜色
|
||||
},
|
||||
// 节点拖拽时新位置的示意矩形的填充颜色
|
||||
dragPlaceholderRectFill: 'rgb(94, 200, 248)',
|
||||
// 节点拖拽时新位置的示意连线的样式配置
|
||||
dragPlaceholderLineConfig: {
|
||||
color: 'rgb(94, 200, 248)',
|
||||
width: 2
|
||||
},
|
||||
// 节点拖拽时的透明度配置
|
||||
dragOpacityConfig: {
|
||||
cloneNodeOpacity: 0.5, // 跟随鼠标移动的克隆节点或矩形的透明度
|
||||
beingDragNodeOpacity: 0.3 // 被拖拽节点的透明度
|
||||
},
|
||||
// 拖拽单个节点时会克隆被拖拽节点,如果想修改该克隆节点,那么可以通过该选项提供一个处理函数,函数接收克隆节点对象
|
||||
// 需要注意的是节点对象指的是@svgdotjs/svg.js库的元素对象,所以你需要阅读该库的文档来操作该对象
|
||||
handleDragCloneNode: null,
|
||||
// 即将拖拽完成前调用该函数,函数接收一个对象作为参数:{overlapNodeUid,prevNodeUid,nextNodeUid},代表拖拽信息,如果要阻止本次拖拽,那么可以返回true,此时node_dragend事件不会再触发。函数可以是异步函数,返回Promise实例
|
||||
beforeDragEnd: null,
|
||||
// 即将开始调整节点前调用该函数,函数接收当前即将被拖拽的节点实例列表作为参数,如果要阻止本次拖拽,那么可以返回true
|
||||
beforeDragStart: null,
|
||||
|
||||
// 【Watermark插件】
|
||||
// 水印配置
|
||||
watermarkConfig: {
|
||||
onlyExport: false, // 是否仅在导出时添加水印
|
||||
text: '',
|
||||
lineSpacing: 100,
|
||||
textSpacing: 100,
|
||||
angle: 30,
|
||||
textStyle: {
|
||||
color: '#999',
|
||||
opacity: 0.5,
|
||||
fontSize: 14
|
||||
},
|
||||
belowNode: false
|
||||
},
|
||||
|
||||
// 【Export插件】
|
||||
// 导出png、svg、pdf时的图形内边距,注意是单侧内边距
|
||||
exportPaddingX: 10,
|
||||
exportPaddingY: 10,
|
||||
// 设置导出图片和svg时,针对富文本节点内容,也就是嵌入到svg中的html节点的默认样式覆盖
|
||||
// 如果不覆盖,会发生偏移问题
|
||||
resetCss: `
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
`,
|
||||
// 导出图片时canvas的缩放倍数,该配置会和window.devicePixelRatio值取最大值
|
||||
minExportImgCanvasScale: 2,
|
||||
// 导出png、svg、pdf时在头部和尾部添加自定义内容
|
||||
// 可传递一个函数,这个函数可以返回null代表不添加内容,也可以返回如下数据:
|
||||
/*
|
||||
{
|
||||
el,// 要追加的自定义DOM节点,样式可内联
|
||||
cssText,// 可选,如果样式不想内联,可以传递该值,一个css字符串
|
||||
height: 50// 返回的DOM节点的高度,必须传递
|
||||
}
|
||||
*/
|
||||
addContentToHeader: null,
|
||||
addContentToFooter: null,
|
||||
// 导出png、svg、pdf时会获取画布上的svg数据进行克隆,然后通过该克隆的元素进行导出,如果你想对该克隆元素做一些处理,比如新增、替换、修改其中的一些元素,那么可以通过该参数传递一个处理函数,接收svg元素对象,处理后,需要返回原svg元素对象。
|
||||
// 需要注意的是svg对象指的是@svgdotjs/svg.js库的元素对象,所以你需要阅读该库的文档来操作该对象
|
||||
handleBeingExportSvg: null,
|
||||
// 导出图片或pdf都是通过canvas将svg绘制出来,再导出,所以如果思维导图特别大,宽高可能会超出canvas支持的上限,所以会进行缩放,这个上限可以通过该参数设置,代表canvas宽和高的最大宽度
|
||||
maxCanvasSize: 16384,
|
||||
|
||||
// 【AssociativeLine插件】
|
||||
// 关联线默认文字
|
||||
defaultAssociativeLineText: '关联',
|
||||
// 关联线是否始终显示在节点上层
|
||||
// false:即创建关联线和激活关联线时处于最顶层,其他情况下处于节点下方
|
||||
associativeLineIsAlwaysAboveNode: true,
|
||||
// 默认情况下,新创建的关联线两个端点的位置是根据两个节点中心点的相对位置来计算的,如果你想固定位置,可以通过这个属性来配置
|
||||
// from和to都不传,则都自动计算,如果只传一个,另一个则会自动计算
|
||||
associativeLineInitPointsPosition: {
|
||||
// from和to可选值:left、top、bottom、right
|
||||
from: '', // 关联线起始节点上端点的位置
|
||||
to: '' // 关联线目标节点上端点的位置
|
||||
},
|
||||
// 是否允许调整关联线两个端点的位置
|
||||
enableAdjustAssociativeLinePoints: true,
|
||||
// 关联线连接即将完成时执行,如果要阻止本次连接可以返回true,函数接收一个参数:node(目标节点实例)
|
||||
beforeAssociativeLineConnection: null,
|
||||
|
||||
// 【TouchEvent插件】
|
||||
// 禁止双指缩放,你仍旧可以使用api进行缩放
|
||||
// 需要注册TouchEvent插件后生效
|
||||
disableTouchZoom: false,
|
||||
// 允许最大和最小的缩放值,百分数
|
||||
// 传-1代表不限制
|
||||
minTouchZoomScale: 20,
|
||||
maxTouchZoomScale: -1,
|
||||
|
||||
// 【Scrollbar插件】
|
||||
// 当注册了滚动条插件(Scrollbar)时,是否将思维导图限制在画布内,isLimitMindMapInCanvas不再起作用
|
||||
isLimitMindMapInCanvasWhenHasScrollbar: true,
|
||||
|
||||
// 【Search插件】
|
||||
// 是否仅搜索当前渲染的节点,被收起的节点不会被搜索到
|
||||
isOnlySearchCurrentRenderNodes: false,
|
||||
|
||||
// 【Cooperate插件】
|
||||
// 协同编辑时,节点操作即将更新到其他客户端前的生命周期函数
|
||||
// 函数接收一个对象作为参数:
|
||||
/*
|
||||
{
|
||||
type: createOrUpdate(创建节点或更新节点)、delete(删除节点)
|
||||
data: 1.当type=createOrUpdate时,代表被创建或被更新的节点数据,即将同步到其他客户端,所以你可以修改该数据;2.当type=delete时,代表被删除的节点数据
|
||||
}
|
||||
*/
|
||||
beforeCooperateUpdate: null,
|
||||
|
||||
// 【RainbowLines插件】
|
||||
// 彩虹线条配置,需要先注册RainbowLines插件
|
||||
rainbowLinesConfig: {
|
||||
open: false, // 是否开启彩虹线条
|
||||
colorsList: [] // 自定义彩虹线条的颜色列表,如果不设置,会使用默认颜色列表
|
||||
/*
|
||||
[
|
||||
'rgb(255, 213, 73)',
|
||||
'rgb(255, 136, 126)',
|
||||
'rgb(107, 225, 141)',
|
||||
'rgb(151, 171, 255)',
|
||||
'rgb(129, 220, 242)',
|
||||
'rgb(255, 163, 125)',
|
||||
'rgb(152, 132, 234)'
|
||||
]
|
||||
*/
|
||||
},
|
||||
|
||||
// 【Demonstrate插件】
|
||||
// 演示插件配置
|
||||
demonstrateConfig: null,
|
||||
|
||||
// 【Formula插件】
|
||||
// 是否开启在富文本编辑框中直接编辑数学公式
|
||||
enableEditFormulaInRichTextEdit: true,
|
||||
// katex库的字体文件的请求路径。仅当katex的output配置为html时才会请求字体文件。可以通过mindMap.formula.getKatexConfig()方法来获取当前的配置
|
||||
// 字体文件可以从node_modules中找到:katex/dist/fonts/。可以上传到你的服务器或cdn
|
||||
// 最终的字体请求路径为`${katexFontPath}fonts/KaTeX_AMS-Regular.woff2`,可以自行拼接进行测试是否可以访问
|
||||
katexFontPath: 'https://unpkg.com/katex@0.16.11/dist/',
|
||||
// 自定义katex库的输出模式。默认当Chrome内核100以下会使用html方式,否则使用mathml方式,如果你有自己的规则,那么可以传递一个函数,函数返回值为:mathml或html
|
||||
getKatexOutputType: null,
|
||||
|
||||
// 【RichText插件】
|
||||
// 转换富文本内容,当进入富文本编辑时,可以通过该参数传递一个函数,函数接收文本内容,需要返回你处理后的文本内容
|
||||
transformRichTextOnEnterEdit: null,
|
||||
// 可以传递一个函数,即将结束富文本编辑前会执行该函数,函数接收richText实例,所以你可以在此时机更新quill文档数据
|
||||
beforeHideRichTextEdit: null,
|
||||
|
||||
// 【OuterFrame】插件
|
||||
outerFramePaddingX: 10,
|
||||
outerFramePaddingY: 10,
|
||||
|
||||
// 【Painter】插件
|
||||
// 是否只格式刷节点手动设置的样式,不考虑节点通过主题的应用的样式
|
||||
onlyPainterNodeCustomStyles: false,
|
||||
|
||||
// 【NodeImgAdjust】插件
|
||||
// 拦截节点图片的删除,点击节点图片上的删除按钮删除图片前会调用该函数,如果函数返回true则取消删除
|
||||
beforeDeleteNodeImg: null,
|
||||
// 删除和调整两个按钮的大小
|
||||
imgResizeBtnSize: 25,
|
||||
// 最小允许缩放的尺寸,请传入>=0的数字
|
||||
minImgResizeWidth: 50,
|
||||
minImgResizeHeight: 50,
|
||||
// 最大允许缩放的尺寸依据主题的配置,即主题的imgMaxWidth和imgMaxHeight配置,如果设置为false,那么使用maxImgResizeWidth和maxImgResizeHeight选项
|
||||
maxImgResizeWidthInheritTheme: false,
|
||||
// 最大允许缩放的尺寸,maxImgResizeWidthInheritTheme选项设置为false时生效,不限制最大值可传递Infinity
|
||||
maxImgResizeWidth: Infinity,
|
||||
maxImgResizeHeight: Infinity
|
||||
}
|
|
@ -1,326 +0,0 @@
|
|||
/**
|
||||
* @file Command.ts
|
||||
* @description 思维导图命令管理模块,负责处理命令执行、历史记录和数据变更
|
||||
* @module mindmap/core/command
|
||||
*/
|
||||
|
||||
import {
|
||||
copyRenderTree,
|
||||
simpleDeepClone,
|
||||
throttle,
|
||||
isSameObject,
|
||||
transformTreeDataToObject
|
||||
} from '../../utils'
|
||||
import { ERROR_TYPES } from '../../constants/constant'
|
||||
import pkg from '../../../package.json'
|
||||
|
||||
/**
|
||||
* 命令管理类
|
||||
* @class Command
|
||||
* @description 实现命令模式,管理思维导图的所有操作命令
|
||||
* 主要功能:
|
||||
* 1. 命令注册与执行
|
||||
* 2. 操作历史管理(撤销/重做)
|
||||
* 3. 数据变更追踪
|
||||
*/
|
||||
class Command {
|
||||
private opt: Record<string, any>
|
||||
private mindMap: any
|
||||
private commands: Record<string, Function[]>
|
||||
public history: any[]
|
||||
private activeHistoryIndex: number
|
||||
public originAddHistory: Function
|
||||
private isPause: boolean
|
||||
|
||||
/**
|
||||
* @param opt 配置选项
|
||||
*/
|
||||
constructor(opt: Record<string, any> = {}) {
|
||||
this.opt = opt
|
||||
this.mindMap = opt.mindMap
|
||||
this.commands = {}
|
||||
this.history = []
|
||||
this.activeHistoryIndex = 0
|
||||
// 注册快捷键
|
||||
this.registerShortcutKeys()
|
||||
this.originAddHistory = this.addHistory.bind(this)
|
||||
// 节流处理历史记录添加
|
||||
this.addHistory = throttle(
|
||||
this.addHistory,
|
||||
this.mindMap.opt.addHistoryTime,
|
||||
this
|
||||
)
|
||||
// 是否暂停收集历史数据
|
||||
this.isPause = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂停收集历史数据
|
||||
*/
|
||||
pause(): void {
|
||||
this.isPause = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复收集历史数据
|
||||
*/
|
||||
recovery(): void {
|
||||
this.isPause = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空历史数据
|
||||
*/
|
||||
clearHistory(): void {
|
||||
this.history = []
|
||||
this.activeHistoryIndex = 0
|
||||
this.mindMap.emit('back_forward', 0, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册快捷键
|
||||
* @private
|
||||
*/
|
||||
private registerShortcutKeys(): void {
|
||||
this.mindMap.keyCommand.addShortcut('Control+z', () => {
|
||||
this.mindMap.execCommand('BACK')
|
||||
})
|
||||
this.mindMap.keyCommand.addShortcut('Control+y', () => {
|
||||
this.mindMap.execCommand('FORWARD')
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行命令
|
||||
* @param name 命令名称
|
||||
* @param args 命令参数
|
||||
*/
|
||||
exec(name: string, ...args: any[]): void {
|
||||
if (this.commands[name]) {
|
||||
this.commands[name].forEach(fn => {
|
||||
fn(...args)
|
||||
})
|
||||
this.mindMap.emit('afterExecCommand', name, ...args)
|
||||
if (
|
||||
['BACK', 'FORWARD', 'SET_NODE_ACTIVE', 'CLEAR_ACTIVE_NODE'].includes(
|
||||
name
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
this.addHistory()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加命令
|
||||
* @param name 命令名称
|
||||
* @param fn 命令执行函数
|
||||
*/
|
||||
add(name: string, fn: Function): void {
|
||||
if (this.commands[name]) {
|
||||
this.commands[name].push(fn)
|
||||
} else {
|
||||
this.commands[name] = [fn]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除命令
|
||||
* @param name 命令名称
|
||||
* @param fn 要移除的具体函数,不传则移除该命令的所有函数
|
||||
*/
|
||||
remove(name: string, fn?: Function): void {
|
||||
if (!this.commands[name]) {
|
||||
return
|
||||
}
|
||||
if (!fn) {
|
||||
delete this.commands[name]
|
||||
} else {
|
||||
const index = this.commands[name].findIndex(item => item === fn)
|
||||
if (index !== -1) {
|
||||
this.commands[name].splice(index, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加历史记录
|
||||
* @private
|
||||
*/
|
||||
public addHistory(): void {
|
||||
if (this.mindMap.opt.readonly || this.isPause) {
|
||||
return
|
||||
}
|
||||
const lastData = this.history.length > 0 ? this.history[this.activeHistoryIndex] : null
|
||||
const data = this.getCopyData()
|
||||
// 避免重复添加相同数据
|
||||
if (lastData === data) return
|
||||
if (lastData && JSON.stringify(lastData) === JSON.stringify(data)) {
|
||||
return
|
||||
}
|
||||
this.emitDataUpdatesEvent(lastData, data)
|
||||
// 删除当前历史指针后面的数据
|
||||
this.history = this.history.slice(0, this.activeHistoryIndex + 1)
|
||||
this.history.push(simpleDeepClone(data))
|
||||
// 限制历史记录数量
|
||||
if (this.history.length > this.mindMap.opt.maxHistoryCount) {
|
||||
this.history.shift()
|
||||
}
|
||||
this.activeHistoryIndex = this.history.length - 1
|
||||
this.mindMap.emit('data_change', data)
|
||||
this.mindMap.emit(
|
||||
'back_forward',
|
||||
this.activeHistoryIndex,
|
||||
this.history.length
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销操作
|
||||
* @param step 撤销步数
|
||||
* @returns 撤销后的数据
|
||||
*/
|
||||
back(step: number = 1): any {
|
||||
if (this.mindMap.opt.readonly) {
|
||||
return
|
||||
}
|
||||
if (this.activeHistoryIndex - step >= 0) {
|
||||
const lastData = this.history[this.activeHistoryIndex]
|
||||
this.activeHistoryIndex -= step
|
||||
this.mindMap.emit(
|
||||
'back_forward',
|
||||
this.activeHistoryIndex,
|
||||
this.history.length
|
||||
)
|
||||
const data = simpleDeepClone(this.history[this.activeHistoryIndex])
|
||||
this.emitDataUpdatesEvent(lastData, data)
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重做操作
|
||||
* @param step 前进步数
|
||||
* @returns 重做后的数据
|
||||
*/
|
||||
forward(step: number = 1): any {
|
||||
if (this.mindMap.opt.readonly) {
|
||||
return
|
||||
}
|
||||
const len = this.history.length
|
||||
if (this.activeHistoryIndex + step <= len - 1) {
|
||||
const lastData = this.history[this.activeHistoryIndex]
|
||||
this.activeHistoryIndex += step
|
||||
this.mindMap.emit(
|
||||
'back_forward',
|
||||
this.activeHistoryIndex,
|
||||
this.history.length
|
||||
)
|
||||
const data = simpleDeepClone(this.history[this.activeHistoryIndex])
|
||||
this.emitDataUpdatesEvent(lastData, data)
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取渲染树的深拷贝数据
|
||||
* @private
|
||||
* @returns 渲染树副本
|
||||
*/
|
||||
public getCopyData(): any {
|
||||
if (!this.mindMap.renderer.renderTree) return null
|
||||
const res = copyRenderTree({}, this.mindMap.renderer.renderTree, true)
|
||||
res.smmVersion = pkg.version
|
||||
return res
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除节点数据中的uid
|
||||
* @param data 节点数据
|
||||
* @returns 处理后的数据
|
||||
*/
|
||||
removeDataUid(data: any): any {
|
||||
data = simpleDeepClone(data)
|
||||
const walk = (root: any): void => {
|
||||
delete root.data.uid
|
||||
if (root.children && root.children.length > 0) {
|
||||
root.children.forEach((item: any) => {
|
||||
walk(item)
|
||||
})
|
||||
}
|
||||
}
|
||||
walk(data)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送数据更新详情事件
|
||||
* @private
|
||||
* @param lastData 更新前的数据
|
||||
* @param data 更新后的数据
|
||||
*/
|
||||
private emitDataUpdatesEvent(lastData: any, data: any): void {
|
||||
try {
|
||||
const eventName = 'data_change_detail'
|
||||
const count = this.mindMap.event.listenerCount(eventName)
|
||||
if (count > 0 && lastData && data) {
|
||||
const lastDataObj = simpleDeepClone(transformTreeDataToObject(lastData))
|
||||
const dataObj = simpleDeepClone(transformTreeDataToObject(data))
|
||||
const res: Array<{
|
||||
action: 'create' | 'update' | 'delete'
|
||||
data: any
|
||||
oldData?: any
|
||||
}> = []
|
||||
|
||||
const walkReplace = (root: any, obj: any): any => {
|
||||
if (root.children && root.children.length > 0) {
|
||||
root.children.forEach((childUid: any, index: number) => {
|
||||
root.children[index] =
|
||||
typeof childUid === 'string'
|
||||
? obj[childUid]
|
||||
: obj[childUid.data.uid]
|
||||
walkReplace(root.children[index], obj)
|
||||
})
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
// 处理新增和修改的节点
|
||||
Object.keys(dataObj).forEach(uid => {
|
||||
if (!lastDataObj[uid]) {
|
||||
res.push({
|
||||
action: 'create',
|
||||
data: walkReplace(dataObj[uid], dataObj)
|
||||
})
|
||||
} else if (!isSameObject(lastDataObj[uid], dataObj[uid])) {
|
||||
res.push({
|
||||
action: 'update',
|
||||
oldData: walkReplace(lastDataObj[uid], lastDataObj),
|
||||
data: walkReplace(dataObj[uid], dataObj)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 处理删除的节点
|
||||
Object.keys(lastDataObj).forEach(uid => {
|
||||
if (!dataObj[uid]) {
|
||||
res.push({
|
||||
action: 'delete',
|
||||
data: walkReplace(lastDataObj[uid], lastDataObj)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
this.mindMap.emit(eventName, res)
|
||||
}
|
||||
} catch (error) {
|
||||
this.mindMap.opt.errorHandler(
|
||||
ERROR_TYPES.DATA_CHANGE_DETAIL_EVENT_ERROR,
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Command
|
|
@ -1,334 +0,0 @@
|
|||
/**
|
||||
* @file KeyCommand.ts
|
||||
* @description 思维导图快捷键命令管理模块
|
||||
* @usage 用于处理思维导图的键盘快捷键事件,支持组合键、多命令绑定等功能
|
||||
*/
|
||||
|
||||
import { keyMap } from './keyMap'
|
||||
import { CONSTANTS } from '../../constants/constant'
|
||||
|
||||
interface KeyCommandOptions {
|
||||
mindMap: any // 思维导图实例
|
||||
enableShortcutOnlyWhenMouseInSvg?: boolean
|
||||
beforeShortcutRun?: (key: string, activeNodes: any[]) => boolean
|
||||
customCheckEnableShortcut?: (e: KeyboardEvent) => boolean
|
||||
}
|
||||
|
||||
type ShortcutFunction = () => void
|
||||
type ShortcutMap = Record<string, ShortcutFunction[]>
|
||||
|
||||
/**
|
||||
* @class KeyCommand
|
||||
* @description 快捷键命令管理类
|
||||
* @pattern 观察者模式 - 监听键盘事件并分发到对应的命令处理函数
|
||||
* @example
|
||||
* const keyCommand = new KeyCommand({ mindMap })
|
||||
* keyCommand.addShortcut('Control+c', () => console.log('复制'))
|
||||
*/
|
||||
export default class KeyCommand {
|
||||
private opt: KeyCommandOptions
|
||||
private mindMap: any
|
||||
private shortcutMap: ShortcutMap
|
||||
private shortcutMapCache: ShortcutMap
|
||||
private isPause: boolean
|
||||
private isInSvg: boolean
|
||||
|
||||
/**
|
||||
* @constructor
|
||||
* @param opt 配置选项
|
||||
*/
|
||||
constructor(opt: KeyCommandOptions) {
|
||||
this.opt = opt
|
||||
this.mindMap = opt.mindMap
|
||||
this.shortcutMap = {}
|
||||
this.shortcutMapCache = {}
|
||||
this.isPause = false
|
||||
this.isInSvg = false
|
||||
this.bindEvent()
|
||||
}
|
||||
|
||||
/**
|
||||
* @method extendKeyMap
|
||||
* @description 扩展按键映射表
|
||||
* @param key 按键名称
|
||||
* @param code 按键码
|
||||
*/
|
||||
public extendKeyMap(key: string, code: number): void {
|
||||
keyMap[key] = code
|
||||
}
|
||||
|
||||
/**
|
||||
* @method removeKeyMap
|
||||
* @description 从按键映射中删除指定按键
|
||||
* @param key 要删除的按键名称
|
||||
*/
|
||||
public removeKeyMap(key: string): void {
|
||||
if (typeof keyMap[key] !== 'undefined') {
|
||||
delete keyMap[key]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @method pause
|
||||
* @description 暂停快捷键响应
|
||||
*/
|
||||
public pause(): void {
|
||||
this.isPause = true
|
||||
}
|
||||
|
||||
/**
|
||||
* @method recovery
|
||||
* @description 恢复快捷键响应
|
||||
*/
|
||||
public recovery(): void {
|
||||
this.isPause = false
|
||||
}
|
||||
|
||||
/**
|
||||
* @method save
|
||||
* @description 保存当前快捷键配置到缓存
|
||||
*/
|
||||
public save(): void {
|
||||
if (Object.keys(this.shortcutMapCache).length > 0) {
|
||||
return
|
||||
}
|
||||
this.shortcutMapCache = { ...this.shortcutMap }
|
||||
this.shortcutMap = {}
|
||||
}
|
||||
|
||||
/**
|
||||
* @method restore
|
||||
* @description 从缓存恢复快捷键配置
|
||||
*/
|
||||
public restore(): void {
|
||||
if (Object.keys(this.shortcutMapCache).length <= 0) {
|
||||
return
|
||||
}
|
||||
this.shortcutMap = { ...this.shortcutMapCache }
|
||||
this.shortcutMapCache = {}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @method bindEvent
|
||||
* @description 绑定事件监听
|
||||
*/
|
||||
private bindEvent(): void {
|
||||
this.onKeydown = this.onKeydown.bind(this)
|
||||
|
||||
this.mindMap.on('svg_mouseenter', () => {
|
||||
this.isInSvg = true
|
||||
})
|
||||
|
||||
this.mindMap.on('svg_mouseleave', () => {
|
||||
if (this.mindMap.renderer.textEdit.isShowTextEdit()) return
|
||||
if (
|
||||
this.mindMap.associativeLine &&
|
||||
this.mindMap.associativeLine.showTextEdit
|
||||
) {
|
||||
return
|
||||
}
|
||||
this.isInSvg = false
|
||||
})
|
||||
|
||||
window.addEventListener('keydown', this.onKeydown)
|
||||
|
||||
this.mindMap.on('beforeDestroy', () => {
|
||||
this.unBindEvent()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @method unBindEvent
|
||||
* @description 解绑事件监听
|
||||
*/
|
||||
private unBindEvent(): void {
|
||||
window.removeEventListener('keydown', this.onKeydown)
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @method defaultEnableCheck
|
||||
* @description 默认的快捷键启用检查
|
||||
* @param e 键盘事件对象
|
||||
*/
|
||||
private defaultEnableCheck(e: KeyboardEvent): boolean {
|
||||
const target = e.target as HTMLElement
|
||||
return (
|
||||
target === document.body ||
|
||||
target.classList.contains(CONSTANTS.EDIT_NODE_CLASS.SMM_NODE_EDIT_WRAP) ||
|
||||
target.classList.contains(CONSTANTS.EDIT_NODE_CLASS.RICH_TEXT_EDIT_WRAP) ||
|
||||
target.classList.contains(CONSTANTS.EDIT_NODE_CLASS.ASSOCIATIVE_LINE_TEXT_EDIT_WRAP)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @method onKeydown
|
||||
* @description 按键事件处理
|
||||
* @param e 键盘事件对象
|
||||
*/
|
||||
private onKeydown(e: KeyboardEvent): void {
|
||||
const {
|
||||
enableShortcutOnlyWhenMouseInSvg,
|
||||
beforeShortcutRun,
|
||||
customCheckEnableShortcut
|
||||
} = this.mindMap.opt
|
||||
|
||||
const checkFn = typeof customCheckEnableShortcut === 'function'
|
||||
? customCheckEnableShortcut
|
||||
: this.defaultEnableCheck
|
||||
|
||||
if (!checkFn(e)) return
|
||||
|
||||
if (this.isPause || (enableShortcutOnlyWhenMouseInSvg && !this.isInSvg)) {
|
||||
return
|
||||
}
|
||||
|
||||
Object.keys(this.shortcutMap).forEach(key => {
|
||||
if (this.checkKey(e, key)) {
|
||||
if (!this.checkKey(e, 'Control+v')) {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
if (typeof beforeShortcutRun === 'function') {
|
||||
const isStop = beforeShortcutRun(key, [
|
||||
...this.mindMap.renderer.activeNodeList
|
||||
])
|
||||
if (isStop) return
|
||||
}
|
||||
|
||||
this.shortcutMap[key].forEach(fn => {
|
||||
fn()
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @method checkKey
|
||||
* @description 检查按键是否匹配
|
||||
* @param e 键盘事件对象
|
||||
* @param key 快捷键字符串
|
||||
*/
|
||||
private checkKey(e: KeyboardEvent, key: string): boolean {
|
||||
const originCodes = this.getOriginEventCodeArr(e)
|
||||
let keyCodes = this.getKeyCodeArr(key)
|
||||
|
||||
if (originCodes.length !== keyCodes.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
for (let i = 0; i < originCodes.length; i++) {
|
||||
const index = keyCodes.findIndex(item => item === originCodes[i])
|
||||
if (index === -1) {
|
||||
return false
|
||||
} else {
|
||||
keyCodes.splice(index, 1)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @method getOriginEventCodeArr
|
||||
* @description 获取事件对象中的按键码数组
|
||||
* @param e 键盘事件对象
|
||||
*/
|
||||
private getOriginEventCodeArr(e: KeyboardEvent): number[] {
|
||||
const arr: number[] = []
|
||||
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
arr.push(keyMap['Control'])
|
||||
}
|
||||
if (e.altKey) {
|
||||
arr.push(keyMap['Alt'])
|
||||
}
|
||||
if (e.shiftKey) {
|
||||
arr.push(keyMap['Shift'])
|
||||
}
|
||||
if (!arr.includes(e.keyCode)) {
|
||||
arr.push(e.keyCode)
|
||||
}
|
||||
|
||||
return arr
|
||||
}
|
||||
|
||||
/**
|
||||
* @method hasCombinationKey
|
||||
* @description 检查是否按下了组合键
|
||||
* @param e 键盘事件对象
|
||||
*/
|
||||
public hasCombinationKey(e: KeyboardEvent): boolean {
|
||||
return e.ctrlKey || e.metaKey || e.altKey || e.shiftKey
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @method getKeyCodeArr
|
||||
* @description 获取快捷键字符串对应的按键码数组
|
||||
* @param key 快捷键字符串
|
||||
*/
|
||||
private getKeyCodeArr(key: string): number[] {
|
||||
const keyArr = key.split(/\s*\+\s*/)
|
||||
return keyArr.map(item => keyMap[item])
|
||||
}
|
||||
|
||||
/**
|
||||
* @method addShortcut
|
||||
* @description 添加快捷键命令
|
||||
* @param key 快捷键字符串,支持 | 分隔的多个快捷键
|
||||
* @param fn 快捷键处理函数
|
||||
* @example
|
||||
* addShortcut('Enter', () => {})
|
||||
* addShortcut('Tab | Insert', () => {})
|
||||
* addShortcut('Shift + a', () => {})
|
||||
*/
|
||||
public addShortcut(key: string, fn: ShortcutFunction): void {
|
||||
key.split(/\s*\|\s*/).forEach(item => {
|
||||
if (this.shortcutMap[item]) {
|
||||
this.shortcutMap[item].push(fn)
|
||||
} else {
|
||||
this.shortcutMap[item] = [fn]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @method removeShortcut
|
||||
* @description 移除快捷键命令
|
||||
* @param key 快捷键字符串
|
||||
* @param fn 可选,指定要移除的处理函数,不指定则移除该快捷键的所有处理函数
|
||||
*/
|
||||
public removeShortcut(key: string, fn?: ShortcutFunction): void {
|
||||
key.split(/\s*\|\s*/).forEach(item => {
|
||||
if (this.shortcutMap[item]) {
|
||||
if (fn) {
|
||||
const index = this.shortcutMap[item].findIndex(f => f === fn)
|
||||
if (index !== -1) {
|
||||
this.shortcutMap[item].splice(index, 1)
|
||||
}
|
||||
} else {
|
||||
delete this.shortcutMap[item]
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @method getShortcutFn
|
||||
* @description 获取指定快捷键的所有处理函数
|
||||
* @param key 快捷键字符串
|
||||
*/
|
||||
public getShortcutFn(key: string): ShortcutFunction[] {
|
||||
let res: ShortcutFunction[] = []
|
||||
key.split(/\s*\|\s*/).forEach(item => {
|
||||
res = this.shortcutMap[item] || []
|
||||
})
|
||||
return res
|
||||
}
|
||||
}
|
|
@ -1,102 +0,0 @@
|
|||
/**
|
||||
* 键盘映射模块
|
||||
* 提供键盘按键码与按键名称的映射关系,以及按键判断功能
|
||||
*
|
||||
* 使用场景:
|
||||
* 1. 键盘事件处理
|
||||
* 2. 快捷键功能实现
|
||||
* 3. 按键状态判断
|
||||
*/
|
||||
|
||||
// 键盘映射对象类型定义
|
||||
interface KeyMap {
|
||||
[key: string]: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 键盘按键映射表
|
||||
* 包含常用功能键、数字键、字母键的键码映射
|
||||
*/
|
||||
const map: KeyMap = {
|
||||
// 功能键映射
|
||||
Backspace: 8,
|
||||
Tab: 9,
|
||||
Enter: 13,
|
||||
|
||||
// 控制键映射
|
||||
Shift: 16,
|
||||
Control: 17,
|
||||
Alt: 18,
|
||||
CapsLock: 20,
|
||||
|
||||
// ESC键
|
||||
Esc: 27,
|
||||
|
||||
// 空格键
|
||||
Spacebar: 32,
|
||||
|
||||
// 导航键映射
|
||||
PageUp: 33,
|
||||
PageDown: 34,
|
||||
End: 35,
|
||||
Home: 36,
|
||||
|
||||
Insert: 45,
|
||||
|
||||
// 方向键映射
|
||||
Left: 37,
|
||||
Up: 38,
|
||||
Right: 39,
|
||||
Down: 40,
|
||||
|
||||
Del: 46,
|
||||
|
||||
// 数字锁定键
|
||||
NumLock: 144,
|
||||
|
||||
// 命令键和功能键映射
|
||||
Cmd: 91,
|
||||
CmdFF: 224,
|
||||
F1: 112,
|
||||
F2: 113,
|
||||
F3: 114,
|
||||
F4: 115,
|
||||
F5: 116,
|
||||
F6: 117,
|
||||
F7: 118,
|
||||
F8: 119,
|
||||
F9: 120,
|
||||
F10: 121,
|
||||
F11: 122,
|
||||
F12: 123,
|
||||
|
||||
// 特殊字符键映射
|
||||
'`': 192,
|
||||
'=': 187,
|
||||
'-': 189,
|
||||
'/': 191,
|
||||
'.': 190
|
||||
}
|
||||
|
||||
// 添加数字键映射(0-9)
|
||||
for (let i = 0; i <= 9; i++) {
|
||||
map[i] = i + 48
|
||||
}
|
||||
|
||||
// 添加字母键映射(a-z)
|
||||
'abcdefghijklmnopqrstuvwxyz'.split('').forEach((n: string, index: number) => {
|
||||
map[n] = index + 65
|
||||
})
|
||||
|
||||
export const keyMap = map
|
||||
|
||||
/**
|
||||
* 判断键盘事件是否匹配指定按键
|
||||
* @param e - 键盘事件对象或键码
|
||||
* @param key - 需要匹配的按键名称
|
||||
* @returns 是否匹配
|
||||
*/
|
||||
export const isKey = (e: KeyboardEvent | number, key: string): boolean => {
|
||||
const code = typeof e === 'object' ? e.keyCode : e
|
||||
return map[key] === code
|
||||
}
|
|
@ -1,284 +0,0 @@
|
|||
/**
|
||||
* @file Event.ts
|
||||
* @description 思维导图事件管理模块
|
||||
* @module mindmap/core/event
|
||||
*
|
||||
* 使用场景:
|
||||
* 1. 处理思维导图画布的各类交互事件
|
||||
* 2. 提供事件总线功能,实现组件间通信
|
||||
*/
|
||||
|
||||
import EventEmitter from 'eventemitter3'
|
||||
import { CONSTANTS } from '../../constants/constant'
|
||||
|
||||
/**
|
||||
* 事件管理类
|
||||
* @class Event
|
||||
* @extends EventEmitter
|
||||
*
|
||||
* @description
|
||||
* 继承自EventEmitter,实现了思维导图所需的各类事件处理
|
||||
* 采用观察者模式,提供事件的订阅/发布功能
|
||||
*
|
||||
* @example
|
||||
* const event = new Event({ mindMap })
|
||||
* event.on('mousedown', (e) => {
|
||||
* // 处理鼠标按下事件
|
||||
* })
|
||||
*/
|
||||
class Event extends EventEmitter {
|
||||
private opt: Record<string, any>
|
||||
private mindMap: any
|
||||
private isLeftMousedown: boolean
|
||||
private isRightMousedown: boolean
|
||||
private isMiddleMousedown: boolean
|
||||
private mousedownPos: {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
private mousemovePos: {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
private mousemoveOffset: {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造函数
|
||||
* @param opt 配置项
|
||||
*/
|
||||
constructor(opt: Record<string, any> = {}) {
|
||||
super()
|
||||
this.opt = opt
|
||||
this.mindMap = opt.mindMap
|
||||
this.isLeftMousedown = false
|
||||
this.isRightMousedown = false
|
||||
this.isMiddleMousedown = false
|
||||
this.mousedownPos = {
|
||||
x: 0,
|
||||
y: 0
|
||||
}
|
||||
this.mousemovePos = {
|
||||
x: 0,
|
||||
y: 0
|
||||
}
|
||||
this.mousemoveOffset = {
|
||||
x: 0,
|
||||
y: 0
|
||||
}
|
||||
this.bindFn()
|
||||
this.bind()
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定事件处理函数的this指向
|
||||
* @private
|
||||
*/
|
||||
private bindFn(): void {
|
||||
this.onBodyMousedown = this.onBodyMousedown.bind(this)
|
||||
this.onBodyClick = this.onBodyClick.bind(this)
|
||||
this.onDrawClick = this.onDrawClick.bind(this)
|
||||
this.onMousedown = this.onMousedown.bind(this)
|
||||
this.onMousemove = this.onMousemove.bind(this)
|
||||
this.onMouseup = this.onMouseup.bind(this)
|
||||
this.onNodeMouseup = this.onNodeMouseup.bind(this)
|
||||
this.onMousewheel = this.onMousewheel.bind(this)
|
||||
this.onContextmenu = this.onContextmenu.bind(this)
|
||||
this.onSvgMousedown = this.onSvgMousedown.bind(this)
|
||||
this.onKeyup = this.onKeyup.bind(this)
|
||||
this.onMouseenter = this.onMouseenter.bind(this)
|
||||
this.onMouseleave = this.onMouseleave.bind(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定DOM事件
|
||||
* @public
|
||||
*/
|
||||
public bind(): void {
|
||||
document.body.addEventListener('mousedown', this.onBodyMousedown)
|
||||
document.body.addEventListener('click', this.onBodyClick)
|
||||
this.mindMap.svg.on('click', this.onDrawClick)
|
||||
this.mindMap.el.addEventListener('mousedown', this.onMousedown)
|
||||
this.mindMap.svg.on('mousedown', this.onSvgMousedown)
|
||||
window.addEventListener('mousemove', this.onMousemove)
|
||||
window.addEventListener('mouseup', this.onMouseup)
|
||||
this.on('node_mouseup', this.onNodeMouseup)
|
||||
this.mindMap.el.addEventListener('wheel', this.onMousewheel)
|
||||
this.mindMap.svg.on('contextmenu', this.onContextmenu)
|
||||
this.mindMap.svg.on('mouseenter', this.onMouseenter)
|
||||
this.mindMap.svg.on('mouseleave', this.onMouseleave)
|
||||
window.addEventListener('keyup', this.onKeyup)
|
||||
}
|
||||
|
||||
/**
|
||||
* 解绑DOM事件
|
||||
* @public
|
||||
*/
|
||||
public unbind(): void {
|
||||
document.body.removeEventListener('mousedown', this.onBodyMousedown)
|
||||
document.body.removeEventListener('click', this.onBodyClick)
|
||||
this.mindMap.svg.off('click', this.onDrawClick)
|
||||
this.mindMap.el.removeEventListener('mousedown', this.onMousedown)
|
||||
window.removeEventListener('mousemove', this.onMousemove)
|
||||
window.removeEventListener('mouseup', this.onMouseup)
|
||||
this.off('node_mouseup', this.onNodeMouseup)
|
||||
this.mindMap.el.removeEventListener('wheel', this.onMousewheel)
|
||||
this.mindMap.svg.off('contextmenu', this.onContextmenu)
|
||||
this.mindMap.svg.off('mouseenter', this.onMouseenter)
|
||||
this.mindMap.svg.off('mouseleave', this.onMouseleave)
|
||||
window.removeEventListener('keyup', this.onKeyup)
|
||||
}
|
||||
|
||||
/**
|
||||
* 画布点击事件处理
|
||||
* @param e 事件对象
|
||||
*/
|
||||
private onDrawClick(e: MouseEvent): void {
|
||||
this.emit('draw_click', e)
|
||||
}
|
||||
|
||||
/**
|
||||
* body鼠标按下事件处理
|
||||
* @param e 事件对象
|
||||
*/
|
||||
private onBodyMousedown(e: MouseEvent): void {
|
||||
this.emit('body_mousedown', e)
|
||||
}
|
||||
|
||||
/**
|
||||
* body点击事件处理
|
||||
* @param e 事件对象
|
||||
*/
|
||||
private onBodyClick(e: MouseEvent): void {
|
||||
this.emit('body_click', e)
|
||||
}
|
||||
|
||||
/**
|
||||
* SVG画布鼠标按下事件处理
|
||||
* @param e 事件对象
|
||||
*/
|
||||
private onSvgMousedown(e: MouseEvent): void {
|
||||
this.emit('svg_mousedown', e)
|
||||
}
|
||||
|
||||
/**
|
||||
* 鼠标按下事件处理
|
||||
* @param e 事件对象
|
||||
*/
|
||||
private onMousedown(e: MouseEvent): void {
|
||||
// 判断按下的鼠标按键
|
||||
if (e.which === 1) {
|
||||
this.isLeftMousedown = true
|
||||
} else if (e.which === 3) {
|
||||
this.isRightMousedown = true
|
||||
} else if (e.which === 2) {
|
||||
this.isMiddleMousedown = true
|
||||
}
|
||||
this.mousedownPos.x = e.clientX
|
||||
this.mousedownPos.y = e.clientY
|
||||
this.emit('mousedown', e, this)
|
||||
}
|
||||
|
||||
/**
|
||||
* 鼠标移动事件处理
|
||||
* @param e 事件对象
|
||||
*/
|
||||
private onMousemove(e: MouseEvent): void {
|
||||
const { useLeftKeySelectionRightKeyDrag } = this.mindMap.opt
|
||||
this.mousemovePos.x = e.clientX
|
||||
this.mousemovePos.y = e.clientY
|
||||
this.mousemoveOffset.x = e.clientX - this.mousedownPos.x
|
||||
this.mousemoveOffset.y = e.clientY - this.mousedownPos.y
|
||||
this.emit('mousemove', e, this)
|
||||
if (
|
||||
this.isMiddleMousedown ||
|
||||
(useLeftKeySelectionRightKeyDrag
|
||||
? this.isRightMousedown
|
||||
: this.isLeftMousedown)
|
||||
) {
|
||||
e.preventDefault()
|
||||
this.emit('drag', e, this)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 鼠标松开事件处理
|
||||
* @param e 事件对象
|
||||
*/
|
||||
private onMouseup(e: MouseEvent): void {
|
||||
this.onNodeMouseup()
|
||||
this.emit('mouseup', e, this)
|
||||
}
|
||||
|
||||
/**
|
||||
* 节点鼠标松开事件处理
|
||||
*/
|
||||
private onNodeMouseup(): void {
|
||||
this.isLeftMousedown = false
|
||||
this.isRightMousedown = false
|
||||
this.isMiddleMousedown = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 鼠标滚轮/触控板事件处理
|
||||
* @param e 事件对象
|
||||
*/
|
||||
private onMousewheel(e: WheelEvent): void {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
const dirs: string[] = []
|
||||
if (e.deltaY < 0) dirs.push(CONSTANTS.DIR.UP)
|
||||
if (e.deltaY > 0) dirs.push(CONSTANTS.DIR.DOWN)
|
||||
if (e.deltaX < 0) dirs.push(CONSTANTS.DIR.LEFT)
|
||||
if (e.deltaX > 0) dirs.push(CONSTANTS.DIR.RIGHT)
|
||||
|
||||
// 判断是否是触控板
|
||||
let isTouchPad = false
|
||||
const { customCheckIsTouchPad } = this.mindMap.opt
|
||||
if (typeof customCheckIsTouchPad === 'function') {
|
||||
isTouchPad = customCheckIsTouchPad(e)
|
||||
} else {
|
||||
isTouchPad = Math.abs(e.deltaY) <= 10
|
||||
}
|
||||
this.emit('mousewheel', e, dirs, this, isTouchPad)
|
||||
}
|
||||
|
||||
/**
|
||||
* 右键菜单事件处理
|
||||
* @param e 事件对象
|
||||
*/
|
||||
private onContextmenu(e: MouseEvent): void {
|
||||
e.preventDefault()
|
||||
// Mac上按住ctrl键点击鼠标左键不触发右键菜单
|
||||
if (e.ctrlKey) return
|
||||
this.emit('contextmenu', e)
|
||||
}
|
||||
|
||||
/**
|
||||
* 键盘按键松开事件处理
|
||||
* @param e 事件对象
|
||||
*/
|
||||
private onKeyup(e: KeyboardEvent): void {
|
||||
this.emit('keyup', e)
|
||||
}
|
||||
|
||||
/**
|
||||
* 鼠标进入SVG画布事件处理
|
||||
* @param e 事件对象
|
||||
*/
|
||||
private onMouseenter(e: MouseEvent): void {
|
||||
this.emit('svg_mouseenter', e)
|
||||
}
|
||||
|
||||
/**
|
||||
* 鼠标离开SVG画布事件处理
|
||||
* @param e 事件对象
|
||||
*/
|
||||
private onMouseleave(e: MouseEvent): void {
|
||||
this.emit('svg_mouseleave', e)
|
||||
}
|
||||
}
|
||||
|
||||
export default Event
|
File diff suppressed because it is too large
Load Diff
|
@ -1,502 +0,0 @@
|
|||
import {
|
||||
getStrWithBrFromHtml,
|
||||
checkNodeOuter,
|
||||
focusInput,
|
||||
selectAllInput,
|
||||
htmlEscape,
|
||||
handleInputPasteText,
|
||||
checkSmmFormatData,
|
||||
getTextFromHtml,
|
||||
isWhite,
|
||||
getVisibleColorFromTheme
|
||||
} from '../../utils'
|
||||
import {
|
||||
ERROR_TYPES,
|
||||
CONSTANTS,
|
||||
noneRichTextNodeLineHeight
|
||||
} from '../../constants/constant'
|
||||
|
||||
// 节点文字编辑类
|
||||
export default class TextEdit {
|
||||
// 构造函数
|
||||
constructor(renderer) {
|
||||
this.renderer = renderer
|
||||
this.mindMap = renderer.mindMap
|
||||
// 当前编辑的节点
|
||||
this.currentNode = null
|
||||
// 文本编辑框
|
||||
this.textEditNode = null
|
||||
// 文本编辑框是否显示
|
||||
this.showTextEdit = false
|
||||
// 如果编辑过程中缩放画布了,那么缓存当前编辑的内容
|
||||
this.cacheEditingText = ''
|
||||
this.hasBodyMousedown = false
|
||||
this.textNodePaddingX = 5
|
||||
this.textNodePaddingY = 3
|
||||
this.isNeedUpdateTextEditNode = false
|
||||
this.bindEvent()
|
||||
}
|
||||
|
||||
// 事件
|
||||
bindEvent() {
|
||||
this.show = this.show.bind(this)
|
||||
this.onScale = this.onScale.bind(this)
|
||||
this.onKeydown = this.onKeydown.bind(this)
|
||||
// 节点双击事件
|
||||
this.mindMap.on('node_dblclick', (node, e, isInserting) => {
|
||||
this.show({ node, e, isInserting })
|
||||
})
|
||||
// 点击事件
|
||||
this.mindMap.on('draw_click', () => {
|
||||
// 隐藏文本编辑框
|
||||
this.hideEditTextBox()
|
||||
})
|
||||
this.mindMap.on('body_mousedown', () => {
|
||||
this.hasBodyMousedown = true
|
||||
})
|
||||
this.mindMap.on('body_click', () => {
|
||||
if (!this.hasBodyMousedown) return
|
||||
this.hasBodyMousedown = false
|
||||
// 隐藏文本编辑框
|
||||
if (this.mindMap.opt.isEndNodeTextEditOnClickOuter) {
|
||||
this.hideEditTextBox()
|
||||
}
|
||||
})
|
||||
this.mindMap.on('svg_mousedown', () => {
|
||||
// 隐藏文本编辑框
|
||||
this.hideEditTextBox()
|
||||
})
|
||||
// 展开收缩按钮点击事件
|
||||
this.mindMap.on('expand_btn_click', () => {
|
||||
this.hideEditTextBox()
|
||||
})
|
||||
// 节点激活前事件
|
||||
this.mindMap.on('before_node_active', () => {
|
||||
this.hideEditTextBox()
|
||||
})
|
||||
// 鼠标滚动事件
|
||||
this.mindMap.on('mousewheel', () => {
|
||||
if (
|
||||
this.mindMap.opt.mousewheelAction === CONSTANTS.MOUSE_WHEEL_ACTION.MOVE
|
||||
) {
|
||||
this.hideEditTextBox()
|
||||
}
|
||||
})
|
||||
// 注册编辑快捷键
|
||||
this.mindMap.keyCommand.addShortcut('F2', () => {
|
||||
if (this.renderer.activeNodeList.length <= 0) {
|
||||
return
|
||||
}
|
||||
this.show({
|
||||
node: this.renderer.activeNodeList[0]
|
||||
})
|
||||
})
|
||||
this.mindMap.on('scale', this.onScale)
|
||||
// 监听按键事件,判断是否自动进入文本编辑模式
|
||||
if (this.mindMap.opt.enableAutoEnterTextEditWhenKeydown) {
|
||||
window.addEventListener('keydown', this.onKeydown)
|
||||
}
|
||||
this.mindMap.on('beforeDestroy', () => {
|
||||
this.unBindEvent()
|
||||
})
|
||||
this.mindMap.on('after_update_config', (opt, lastOpt) => {
|
||||
if (
|
||||
opt.openRealtimeRenderOnNodeTextEdit !==
|
||||
lastOpt.openRealtimeRenderOnNodeTextEdit
|
||||
) {
|
||||
if (this.mindMap.richText) {
|
||||
this.mindMap.richText.onOpenRealtimeRenderOnNodeTextEditConfigUpdate(
|
||||
opt.openRealtimeRenderOnNodeTextEdit
|
||||
)
|
||||
} else {
|
||||
this.onOpenRealtimeRenderOnNodeTextEditConfigUpdate(
|
||||
opt.openRealtimeRenderOnNodeTextEdit
|
||||
)
|
||||
}
|
||||
}
|
||||
if (
|
||||
opt.enableAutoEnterTextEditWhenKeydown !==
|
||||
lastOpt.enableAutoEnterTextEditWhenKeydown
|
||||
) {
|
||||
window[
|
||||
opt.enableAutoEnterTextEditWhenKeydown
|
||||
? 'addEventListener'
|
||||
: 'removeEventListener'
|
||||
]('keydown', this.onKeydown)
|
||||
}
|
||||
})
|
||||
// 正在编辑文本时,给节点添加了图标等其他内容时需要更新编辑框的位置
|
||||
this.mindMap.on('afterExecCommand', () => {
|
||||
if (!this.isShowTextEdit()) return
|
||||
this.isNeedUpdateTextEditNode = true
|
||||
})
|
||||
this.mindMap.on('node_tree_render_end', () => {
|
||||
if (!this.isShowTextEdit()) return
|
||||
if (this.isNeedUpdateTextEditNode) {
|
||||
this.isNeedUpdateTextEditNode = false
|
||||
this.updateTextEditNode()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 解绑事件
|
||||
unBindEvent() {
|
||||
window.removeEventListener('keydown', this.onKeydown)
|
||||
}
|
||||
|
||||
// 按键事件
|
||||
onKeydown(e) {
|
||||
if (e.target !== document.body) return
|
||||
const activeNodeList = this.mindMap.renderer.activeNodeList
|
||||
if (activeNodeList.length <= 0 || activeNodeList.length > 1) return
|
||||
const node = activeNodeList[0]
|
||||
// 当正在输入中文或英文或数字时,如果没有按下组合键,那么自动进入文本编辑模式
|
||||
if (node && this.checkIsAutoEnterTextEditKey(e)) {
|
||||
// 忽略第一个键值,避免中文输入法时进入编辑会导致第一个键值变成字母的问题
|
||||
// 带来的问题是按的第一下纯粹是进入文本编辑,但没有变成输入
|
||||
e.preventDefault()
|
||||
this.show({
|
||||
node,
|
||||
e,
|
||||
isInserting: false,
|
||||
isFromKeyDown: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 判断是否是自动进入文本编模式的按钮
|
||||
checkIsAutoEnterTextEditKey(e) {
|
||||
const keyCode = e.keyCode
|
||||
return (
|
||||
(keyCode === 229 ||
|
||||
(keyCode >= 65 && keyCode <= 90) ||
|
||||
(keyCode >= 48 && keyCode <= 57)) &&
|
||||
!this.mindMap.keyCommand.hasCombinationKey(e)
|
||||
)
|
||||
}
|
||||
|
||||
// 注册临时快捷键
|
||||
registerTmpShortcut() {
|
||||
this.mindMap.keyCommand.addShortcut('Enter', () => {
|
||||
this.hideEditTextBox()
|
||||
})
|
||||
this.mindMap.keyCommand.addShortcut('Tab', () => {
|
||||
this.hideEditTextBox()
|
||||
})
|
||||
}
|
||||
|
||||
// 获取当前文本编辑框是否处于显示状态,也就是是否处在文本编辑状态
|
||||
isShowTextEdit() {
|
||||
if (this.mindMap.richText) {
|
||||
return this.mindMap.richText.showTextEdit
|
||||
}
|
||||
return this.showTextEdit
|
||||
}
|
||||
|
||||
// 显示文本编辑框
|
||||
// isInserting:是否是刚创建的节点
|
||||
// isFromKeyDown:是否是在按键事件进入的编辑
|
||||
async show({
|
||||
node,
|
||||
isInserting = false,
|
||||
isFromKeyDown = false,
|
||||
isFromScale = false
|
||||
}) {
|
||||
// 使用了自定义节点内容那么不响应编辑事件
|
||||
if (node.isUseCustomNodeContent()) {
|
||||
return
|
||||
}
|
||||
// 如果有正在编辑中的节点,那么先结束它
|
||||
const currentEditNode = this.getCurrentEditNode()
|
||||
if (currentEditNode) {
|
||||
this.hideEditTextBox()
|
||||
}
|
||||
const { beforeTextEdit, openRealtimeRenderOnNodeTextEdit } =
|
||||
this.mindMap.opt
|
||||
if (typeof beforeTextEdit === 'function') {
|
||||
let isShow = false
|
||||
try {
|
||||
isShow = await beforeTextEdit(node, isInserting)
|
||||
} catch (error) {
|
||||
isShow = false
|
||||
this.mindMap.opt.errorHandler(ERROR_TYPES.BEFORE_TEXT_EDIT_ERROR, error)
|
||||
}
|
||||
if (!isShow) return
|
||||
}
|
||||
const { offsetLeft, offsetTop } = checkNodeOuter(this.mindMap, node)
|
||||
this.mindMap.view.translateXY(offsetLeft, offsetTop)
|
||||
const g = node._textData.node
|
||||
// 需要先显示,不然宽高获取到的可能是0
|
||||
if (openRealtimeRenderOnNodeTextEdit) {
|
||||
g.show()
|
||||
}
|
||||
const rect = g.node.getBoundingClientRect()
|
||||
// 如果开启了大小实时更新,那么直接隐藏节点原文本
|
||||
if (openRealtimeRenderOnNodeTextEdit) {
|
||||
g.hide()
|
||||
}
|
||||
const params = {
|
||||
node,
|
||||
rect,
|
||||
isInserting,
|
||||
isFromKeyDown,
|
||||
isFromScale
|
||||
}
|
||||
if (this.mindMap.richText) {
|
||||
this.mindMap.richText.showEditText(params)
|
||||
return
|
||||
}
|
||||
this.currentNode = node
|
||||
this.showEditTextBox(params)
|
||||
}
|
||||
|
||||
// 当openRealtimeRenderOnNodeTextEdit配置更新后需要更新编辑框样式
|
||||
onOpenRealtimeRenderOnNodeTextEditConfigUpdate(
|
||||
openRealtimeRenderOnNodeTextEdit
|
||||
) {
|
||||
if (!this.textEditNode) return
|
||||
this.textEditNode.style.background = openRealtimeRenderOnNodeTextEdit
|
||||
? 'transparent'
|
||||
: this.currentNode
|
||||
? this.getBackground(this.currentNode)
|
||||
: ''
|
||||
this.textEditNode.style.boxShadow = openRealtimeRenderOnNodeTextEdit
|
||||
? 'none'
|
||||
: '0 0 20px rgba(0,0,0,.5)'
|
||||
}
|
||||
|
||||
// 处理画布缩放
|
||||
onScale() {
|
||||
const node = this.getCurrentEditNode()
|
||||
if (!node) return
|
||||
if (this.mindMap.richText) {
|
||||
this.mindMap.richText.cacheEditingText =
|
||||
this.mindMap.richText.getEditText()
|
||||
this.mindMap.richText.showTextEdit = false
|
||||
} else {
|
||||
this.cacheEditingText = this.getEditText()
|
||||
this.showTextEdit = false
|
||||
}
|
||||
this.show({
|
||||
node,
|
||||
isFromScale: true
|
||||
})
|
||||
}
|
||||
|
||||
// 显示文本编辑框
|
||||
showEditTextBox({ node, rect, isInserting, isFromKeyDown, isFromScale }) {
|
||||
if (this.showTextEdit) return
|
||||
const {
|
||||
nodeTextEditZIndex,
|
||||
textAutoWrapWidth,
|
||||
selectTextOnEnterEditText,
|
||||
openRealtimeRenderOnNodeTextEdit,
|
||||
autoEmptyTextWhenKeydownEnterEdit
|
||||
} = this.mindMap.opt
|
||||
if (!isFromScale) {
|
||||
this.mindMap.emit('before_show_text_edit')
|
||||
}
|
||||
this.registerTmpShortcut()
|
||||
if (!this.textEditNode) {
|
||||
this.textEditNode = document.createElement('div')
|
||||
this.textEditNode.classList.add(
|
||||
CONSTANTS.EDIT_NODE_CLASS.SMM_NODE_EDIT_WRAP
|
||||
)
|
||||
this.textEditNode.style.cssText = `
|
||||
position: fixed;
|
||||
box-sizing: border-box;
|
||||
${
|
||||
openRealtimeRenderOnNodeTextEdit
|
||||
? ''
|
||||
: `box-shadow: 0 0 20px rgba(0,0,0,.5);`
|
||||
}
|
||||
padding: ${this.textNodePaddingY}px ${this.textNodePaddingX}px;
|
||||
margin-left: -${this.textNodePaddingX}px;
|
||||
margin-top: -${this.textNodePaddingY}px;
|
||||
outline: none;
|
||||
word-break: break-all;
|
||||
line-break: anywhere;
|
||||
`
|
||||
this.textEditNode.setAttribute('contenteditable', true)
|
||||
this.textEditNode.addEventListener('keyup', e => {
|
||||
e.stopPropagation()
|
||||
})
|
||||
this.textEditNode.addEventListener('click', e => {
|
||||
e.stopPropagation()
|
||||
})
|
||||
this.textEditNode.addEventListener('mousedown', e => {
|
||||
e.stopPropagation()
|
||||
})
|
||||
this.textEditNode.addEventListener('keydown', e => {
|
||||
if (this.checkIsAutoEnterTextEditKey(e)) {
|
||||
e.stopPropagation()
|
||||
}
|
||||
})
|
||||
this.textEditNode.addEventListener('paste', e => {
|
||||
const text = e.clipboardData.getData('text')
|
||||
const { isSmm, data } = checkSmmFormatData(text)
|
||||
if (isSmm && data[0] && data[0].data) {
|
||||
// 只取第一个节点的纯文本
|
||||
handleInputPasteText(e, getTextFromHtml(data[0].data.text))
|
||||
} else {
|
||||
handleInputPasteText(e)
|
||||
}
|
||||
this.emitTextChangeEvent()
|
||||
})
|
||||
this.textEditNode.addEventListener('input', () => {
|
||||
this.emitTextChangeEvent()
|
||||
})
|
||||
const targetNode =
|
||||
this.mindMap.opt.customInnerElsAppendTo || document.body
|
||||
targetNode.appendChild(this.textEditNode)
|
||||
}
|
||||
const scale = this.mindMap.view.scale
|
||||
const fontSize = node.style.merge('fontSize')
|
||||
const textLines = (this.cacheEditingText || node.getData('text'))
|
||||
.split(/\n/gim)
|
||||
.map(item => {
|
||||
return htmlEscape(item)
|
||||
})
|
||||
const isMultiLine = node._textData.node.attr('data-ismultiLine') === 'true'
|
||||
node.style.domText(this.textEditNode, scale)
|
||||
if (!openRealtimeRenderOnNodeTextEdit) {
|
||||
this.textEditNode.style.background = this.getBackground(node)
|
||||
}
|
||||
this.textEditNode.style.zIndex = nodeTextEditZIndex
|
||||
if (isFromKeyDown && autoEmptyTextWhenKeydownEnterEdit) {
|
||||
this.textEditNode.innerHTML = ''
|
||||
} else {
|
||||
this.textEditNode.innerHTML = textLines.join('<br>')
|
||||
}
|
||||
this.textEditNode.style.minWidth =
|
||||
rect.width + this.textNodePaddingX * 2 + 'px'
|
||||
this.textEditNode.style.minHeight = rect.height + 'px'
|
||||
this.textEditNode.style.left = Math.floor(rect.left) + 'px'
|
||||
this.textEditNode.style.top = Math.floor(rect.top) + 'px'
|
||||
this.textEditNode.style.display = 'block'
|
||||
this.textEditNode.style.maxWidth = textAutoWrapWidth * scale + 'px'
|
||||
if (isMultiLine) {
|
||||
this.textEditNode.style.lineHeight = noneRichTextNodeLineHeight
|
||||
this.textEditNode.style.transform = `translateY(${
|
||||
(((noneRichTextNodeLineHeight - 1) * fontSize) / 2) * scale
|
||||
}px)`
|
||||
} else {
|
||||
this.textEditNode.style.lineHeight = 'normal'
|
||||
}
|
||||
this.showTextEdit = true
|
||||
// 选中文本
|
||||
// if (!this.cacheEditingText) {
|
||||
// selectAllInput(this.textEditNode)
|
||||
// }
|
||||
if (isInserting || (selectTextOnEnterEditText && !isFromKeyDown)) {
|
||||
selectAllInput(this.textEditNode)
|
||||
} else {
|
||||
focusInput(this.textEditNode)
|
||||
}
|
||||
this.cacheEditingText = ''
|
||||
}
|
||||
|
||||
// 派发节点文本编辑事件
|
||||
emitTextChangeEvent() {
|
||||
this.mindMap.emit('node_text_edit_change', {
|
||||
node: this.currentNode,
|
||||
text: this.getEditText(),
|
||||
richText: false
|
||||
})
|
||||
}
|
||||
|
||||
// 更新文本编辑框的大小和位置
|
||||
updateTextEditNode() {
|
||||
if (this.mindMap.richText) {
|
||||
this.mindMap.richText.updateTextEditNode()
|
||||
return
|
||||
}
|
||||
if (!this.showTextEdit || !this.currentNode) {
|
||||
return
|
||||
}
|
||||
const rect = this.currentNode._textData.node.node.getBoundingClientRect()
|
||||
this.textEditNode.style.minWidth =
|
||||
rect.width + this.textNodePaddingX * 2 + 'px'
|
||||
this.textEditNode.style.minHeight =
|
||||
rect.height + this.textNodePaddingY * 2 + 'px'
|
||||
this.textEditNode.style.left = Math.floor(rect.left) + 'px'
|
||||
this.textEditNode.style.top = Math.floor(rect.top) + 'px'
|
||||
}
|
||||
|
||||
// 获取编辑区域的背景填充
|
||||
getBackground(node) {
|
||||
const gradientStyle = node.style.merge('gradientStyle')
|
||||
// 当前使用的是渐变色背景
|
||||
if (gradientStyle) {
|
||||
const startColor = node.style.merge('startColor')
|
||||
const endColor = node.style.merge('endColor')
|
||||
return `linear-gradient(to right, ${startColor}, ${endColor})`
|
||||
} else {
|
||||
// 单色背景
|
||||
const bgColor = node.style.merge('fillColor')
|
||||
const color = node.style.merge('color')
|
||||
// 默认使用节点的填充色,否则如果节点颜色是白色的话编辑时看不见
|
||||
return bgColor === 'transparent'
|
||||
? isWhite(color)
|
||||
? getVisibleColorFromTheme(this.mindMap.themeConfig)
|
||||
: '#fff'
|
||||
: bgColor
|
||||
}
|
||||
}
|
||||
|
||||
// 删除文本编辑元素
|
||||
removeTextEditEl() {
|
||||
if (this.mindMap.richText) {
|
||||
this.mindMap.richText.removeTextEditEl()
|
||||
return
|
||||
}
|
||||
if (!this.textEditNode) return
|
||||
const targetNode = this.mindMap.opt.customInnerElsAppendTo || document.body
|
||||
targetNode.removeChild(this.textEditNode)
|
||||
}
|
||||
|
||||
// 获取当前正在编辑的内容
|
||||
getEditText() {
|
||||
return getStrWithBrFromHtml(this.textEditNode.innerHTML)
|
||||
}
|
||||
|
||||
// 隐藏文本编辑框
|
||||
hideEditTextBox() {
|
||||
if (this.mindMap.richText) {
|
||||
return this.mindMap.richText.hideEditText()
|
||||
}
|
||||
if (!this.showTextEdit) {
|
||||
return
|
||||
}
|
||||
const currentNode = this.currentNode
|
||||
const text = this.getEditText()
|
||||
this.currentNode = null
|
||||
this.textEditNode.style.display = 'none'
|
||||
this.textEditNode.innerHTML = ''
|
||||
this.textEditNode.style.fontFamily = 'inherit'
|
||||
this.textEditNode.style.fontSize = 'inherit'
|
||||
this.textEditNode.style.fontWeight = 'normal'
|
||||
this.textEditNode.style.transform = 'translateY(0)'
|
||||
this.showTextEdit = false
|
||||
this.mindMap.execCommand('SET_NODE_TEXT', currentNode, text)
|
||||
// if (currentNode.isGeneralization) {
|
||||
// // 概要节点
|
||||
// currentNode.generalizationBelongNode.updateGeneralization()
|
||||
// }
|
||||
this.mindMap.render()
|
||||
this.mindMap.emit(
|
||||
'hide_text_edit',
|
||||
this.textEditNode,
|
||||
this.renderer.activeNodeList,
|
||||
currentNode
|
||||
)
|
||||
}
|
||||
|
||||
// 获取当前正在编辑中的节点实例
|
||||
getCurrentEditNode() {
|
||||
if (this.mindMap.richText) {
|
||||
return this.mindMap.richText.node
|
||||
}
|
||||
return this.currentNode
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
|
@ -1,294 +0,0 @@
|
|||
import { Polygon, Path, SVG } from '@svgdotjs/svg.js'
|
||||
import { CONSTANTS } from '../../../constants/constant'
|
||||
|
||||
interface ShapePadding {
|
||||
paddingX: number
|
||||
paddingY: number
|
||||
}
|
||||
|
||||
interface NodeSize {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
// 节点形状类
|
||||
export default class Shape {
|
||||
private node: any // TODO: Add proper type
|
||||
private mindMap: any // TODO: Add proper type
|
||||
|
||||
constructor(node: any) {
|
||||
this.node = node
|
||||
this.mindMap = node.mindMap
|
||||
}
|
||||
|
||||
// 形状需要的padding
|
||||
getShapePadding(width: number, height: number, paddingX: number, paddingY: number): ShapePadding {
|
||||
const shape = this.node.getShape()
|
||||
const defaultPaddingX = 15
|
||||
const defaultPaddingY = 5
|
||||
const actWidth = width + paddingX * 2
|
||||
const actHeight = height + paddingY * 2
|
||||
const actOffset = Math.abs(actWidth - actHeight)
|
||||
|
||||
switch (shape) {
|
||||
case CONSTANTS.SHAPE.ROUNDED_RECTANGLE:
|
||||
return {
|
||||
paddingX: height > width ? (height - width) / 2 : 0,
|
||||
paddingY: 0
|
||||
}
|
||||
case CONSTANTS.SHAPE.DIAMOND:
|
||||
return {
|
||||
paddingX: width / 2,
|
||||
paddingY: height / 2
|
||||
}
|
||||
case CONSTANTS.SHAPE.PARALLELOGRAM:
|
||||
return {
|
||||
paddingX: paddingX <= 0 ? defaultPaddingX : 0,
|
||||
paddingY: 0
|
||||
}
|
||||
case CONSTANTS.SHAPE.OUTER_TRIANGULAR_RECTANGLE:
|
||||
return {
|
||||
paddingX: paddingX <= 0 ? defaultPaddingX : 0,
|
||||
paddingY: 0
|
||||
}
|
||||
case CONSTANTS.SHAPE.INNER_TRIANGULAR_RECTANGLE:
|
||||
return {
|
||||
paddingX: paddingX <= 0 ? defaultPaddingX : 0,
|
||||
paddingY: 0
|
||||
}
|
||||
case CONSTANTS.SHAPE.ELLIPSE:
|
||||
return {
|
||||
paddingX: paddingX <= 0 ? defaultPaddingX : 0,
|
||||
paddingY: paddingY <= 0 ? defaultPaddingY : 0
|
||||
}
|
||||
case CONSTANTS.SHAPE.CIRCLE:
|
||||
return {
|
||||
paddingX: actHeight > actWidth ? actOffset / 2 : 0,
|
||||
paddingY: actHeight < actWidth ? actOffset / 2 : 0
|
||||
}
|
||||
default:
|
||||
return {
|
||||
paddingX: 0,
|
||||
paddingY: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建形状节点
|
||||
createShape(): Path | Polygon | null {
|
||||
const shape = this.node.getShape()
|
||||
let node: Path | Polygon | null = null
|
||||
|
||||
// 矩形
|
||||
if (shape === CONSTANTS.SHAPE.RECTANGLE) {
|
||||
node = this.createRect()
|
||||
} else if (shape === CONSTANTS.SHAPE.DIAMOND) {
|
||||
// 菱形
|
||||
node = this.createDiamond()
|
||||
} else if (shape === CONSTANTS.SHAPE.PARALLELOGRAM) {
|
||||
// 平行四边形
|
||||
node = this.createParallelogram()
|
||||
} else if (shape === CONSTANTS.SHAPE.ROUNDED_RECTANGLE) {
|
||||
// 圆角矩形
|
||||
node = this.createRoundedRectangle()
|
||||
} else if (shape === CONSTANTS.SHAPE.OCTAGONAL_RECTANGLE) {
|
||||
// 八角矩形
|
||||
node = this.createOctagonalRectangle()
|
||||
} else if (shape === CONSTANTS.SHAPE.OUTER_TRIANGULAR_RECTANGLE) {
|
||||
// 外三角矩形
|
||||
node = this.createOuterTriangularRectangle()
|
||||
} else if (shape === CONSTANTS.SHAPE.INNER_TRIANGULAR_RECTANGLE) {
|
||||
// 内三角矩形
|
||||
node = this.createInnerTriangularRectangle()
|
||||
} else if (shape === CONSTANTS.SHAPE.ELLIPSE) {
|
||||
// 椭圆
|
||||
node = this.createEllipse()
|
||||
} else if (shape === CONSTANTS.SHAPE.CIRCLE) {
|
||||
// 圆
|
||||
node = this.createCircle()
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
// 获取节点减去节点边框宽度、hover节点边框宽度后的尺寸
|
||||
private getNodeSize(): NodeSize {
|
||||
const borderWidth = this.node.getBorderWidth()
|
||||
let { width, height } = this.node
|
||||
width -= borderWidth
|
||||
height -= borderWidth
|
||||
return {
|
||||
width,
|
||||
height
|
||||
}
|
||||
}
|
||||
|
||||
// 创建路径节点
|
||||
private createPath(pathStr: string) {
|
||||
const { customCreateNodePath } = this.mindMap.opt
|
||||
if (customCreateNodePath) {
|
||||
return SVG(customCreateNodePath(pathStr))
|
||||
}
|
||||
return new Path().plot(pathStr)
|
||||
}
|
||||
|
||||
// 创建多边形节点
|
||||
private createPolygon(points: number[][]) {
|
||||
const { customCreateNodePolygon } = this.mindMap.opt
|
||||
if (customCreateNodePolygon) {
|
||||
return SVG(customCreateNodePolygon(points))
|
||||
}
|
||||
return new Polygon().plot(points.flat())
|
||||
}
|
||||
|
||||
// 创建矩形
|
||||
private createRect(): Path {
|
||||
const { width, height } = this.getNodeSize()
|
||||
const borderRadius = this.node.style.merge('borderRadius')
|
||||
const pathStr = `
|
||||
M${borderRadius},0
|
||||
L${width - borderRadius},0
|
||||
C${width - borderRadius},0 ${width},${0} ${width},${borderRadius}
|
||||
L${width},${height - borderRadius}
|
||||
C${width},${height - borderRadius} ${width},${height} ${width - borderRadius
|
||||
},${height}
|
||||
L${borderRadius},${height}
|
||||
C${borderRadius},${height} ${0},${height} ${0},${height - borderRadius}
|
||||
L${0},${borderRadius}
|
||||
C${0},${borderRadius} ${0},${0} ${borderRadius},${0}
|
||||
Z
|
||||
`
|
||||
return this.createPath(pathStr) as Path
|
||||
}
|
||||
|
||||
// 创建菱形
|
||||
private createDiamond(): Polygon {
|
||||
const { width, height } = this.getNodeSize()
|
||||
const halfWidth = width / 2
|
||||
const halfHeight = height / 2
|
||||
const points = [
|
||||
[halfWidth, 0],
|
||||
[width, halfHeight],
|
||||
[halfWidth, height],
|
||||
[0, halfHeight]
|
||||
]
|
||||
return this.createPolygon(points) as Polygon
|
||||
}
|
||||
|
||||
// 创建平行四边形
|
||||
private createParallelogram(): Polygon {
|
||||
let { paddingX } = this.node.getPaddingVale()
|
||||
paddingX = paddingX || this.node.shapePadding.paddingX
|
||||
const { width, height } = this.getNodeSize()
|
||||
const points = [
|
||||
[paddingX, 0],
|
||||
[width, 0],
|
||||
[width - paddingX, height],
|
||||
[0, height]
|
||||
]
|
||||
return this.createPolygon(points) as Polygon
|
||||
}
|
||||
|
||||
// 创建圆角矩形
|
||||
private createRoundedRectangle(): Path {
|
||||
const { width, height } = this.getNodeSize()
|
||||
const halfHeight = height / 2
|
||||
const pathStr = `
|
||||
M${halfHeight},0
|
||||
L${width - halfHeight},0
|
||||
A${height / 2},${height / 2} 0 0,1 ${width - halfHeight},${height}
|
||||
L${halfHeight},${height}
|
||||
A${height / 2},${height / 2} 0 0,1 ${halfHeight},${0}
|
||||
`
|
||||
return this.createPath(pathStr) as Path
|
||||
}
|
||||
|
||||
// 创建八角矩形
|
||||
private createOctagonalRectangle(): Polygon {
|
||||
const w = 5
|
||||
const { width, height } = this.getNodeSize()
|
||||
const points = [
|
||||
[0, w],
|
||||
[w, 0],
|
||||
[width - w, 0],
|
||||
[width, w],
|
||||
[width, height - w],
|
||||
[width - w, height],
|
||||
[w, height],
|
||||
[0, height - w]
|
||||
]
|
||||
return this.createPolygon(points) as Polygon
|
||||
}
|
||||
|
||||
// 创建外三角矩形
|
||||
private createOuterTriangularRectangle(): Polygon {
|
||||
let { paddingX } = this.node.getPaddingVale()
|
||||
paddingX = paddingX || this.node.shapePadding.paddingX
|
||||
const { width, height } = this.getNodeSize()
|
||||
const points = [
|
||||
[paddingX, 0],
|
||||
[width - paddingX, 0],
|
||||
[width, height / 2],
|
||||
[width - paddingX, height],
|
||||
[paddingX, height],
|
||||
[0, height / 2]
|
||||
]
|
||||
return this.createPolygon(points) as Polygon
|
||||
}
|
||||
|
||||
// 创建内三角矩形
|
||||
private createInnerTriangularRectangle(): Polygon {
|
||||
let { paddingX } = this.node.getPaddingVale()
|
||||
paddingX = paddingX || this.node.shapePadding.paddingX
|
||||
const { width, height } = this.getNodeSize()
|
||||
const points = [
|
||||
[0, 0],
|
||||
[width, 0],
|
||||
[width - paddingX / 2, height / 2],
|
||||
[width, height],
|
||||
[0, height],
|
||||
[paddingX / 2, height / 2]
|
||||
]
|
||||
return this.createPolygon(points) as Polygon
|
||||
}
|
||||
|
||||
// 创建椭圆
|
||||
private createEllipse(): Path {
|
||||
const { width, height } = this.getNodeSize()
|
||||
const halfWidth = width / 2
|
||||
const halfHeight = height / 2
|
||||
const pathStr = `
|
||||
M${halfWidth},0
|
||||
A${halfWidth},${halfHeight} 0 0,1 ${halfWidth},${height}
|
||||
M${halfWidth},${height}
|
||||
A${halfWidth},${halfHeight} 0 0,1 ${halfWidth},${0}
|
||||
`
|
||||
return this.createPath(pathStr) as Path
|
||||
}
|
||||
|
||||
// 创建圆
|
||||
private createCircle(): Path {
|
||||
const { width, height } = this.getNodeSize()
|
||||
const halfWidth = width / 2
|
||||
const halfHeight = height / 2
|
||||
const pathStr = `
|
||||
M${halfWidth},0
|
||||
A${halfWidth},${halfHeight} 0 0,1 ${halfWidth},${height}
|
||||
M${halfWidth},${height}
|
||||
A${halfWidth},${halfHeight} 0 0,1 ${halfWidth},${0}
|
||||
`
|
||||
return this.createPath(pathStr) as Path
|
||||
}
|
||||
}
|
||||
|
||||
// 形状列表
|
||||
export const shapeList: string[] = [
|
||||
CONSTANTS.SHAPE.RECTANGLE,
|
||||
CONSTANTS.SHAPE.DIAMOND,
|
||||
CONSTANTS.SHAPE.PARALLELOGRAM,
|
||||
CONSTANTS.SHAPE.ROUNDED_RECTANGLE,
|
||||
CONSTANTS.SHAPE.OCTAGONAL_RECTANGLE,
|
||||
CONSTANTS.SHAPE.OUTER_TRIANGULAR_RECTANGLE,
|
||||
CONSTANTS.SHAPE.INNER_TRIANGULAR_RECTANGLE,
|
||||
CONSTANTS.SHAPE.ELLIPSE,
|
||||
CONSTANTS.SHAPE.CIRCLE
|
||||
]
|
|
@ -1,376 +0,0 @@
|
|||
import { checkIsNodeStyleDataKey } from '../../../utils/index'
|
||||
import { ThemeConfig } from "../../../theme"
|
||||
const backgroundStyleProps = [
|
||||
'backgroundColor',
|
||||
'backgroundImage',
|
||||
'backgroundRepeat',
|
||||
'backgroundPosition',
|
||||
'backgroundSize'
|
||||
]
|
||||
|
||||
// 样式类
|
||||
class Style {
|
||||
private static cacheStyle: Record<string, string> | null = null;
|
||||
private ctx: any;
|
||||
private _markerPath: any;
|
||||
private _marker: any;
|
||||
private _gradient: any;
|
||||
|
||||
static setBackgroundStyle(el: HTMLElement, themeConfig: ThemeConfig): void {
|
||||
if (!el) return;
|
||||
|
||||
// Cache container element's original style
|
||||
if (!Style.cacheStyle) {
|
||||
Style.cacheStyle = {};
|
||||
const style = window.getComputedStyle(el);
|
||||
backgroundStyleProps.forEach(prop => {
|
||||
Style.cacheStyle![prop] = style[prop as any];
|
||||
});
|
||||
}
|
||||
|
||||
const {
|
||||
backgroundColor,
|
||||
backgroundImage,
|
||||
backgroundRepeat,
|
||||
backgroundPosition,
|
||||
backgroundSize
|
||||
} = themeConfig;
|
||||
|
||||
el.style.backgroundColor = backgroundColor;
|
||||
if (backgroundImage && backgroundImage !== 'none') {
|
||||
el.style.backgroundImage = `url(${backgroundImage})`;
|
||||
el.style.backgroundRepeat = backgroundRepeat || '';
|
||||
el.style.backgroundPosition = backgroundPosition || '';
|
||||
el.style.backgroundSize = backgroundSize || '';
|
||||
} else {
|
||||
el.style.backgroundImage = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
static removeBackgroundStyle(el: HTMLElement): void {
|
||||
if (!Style.cacheStyle) return;
|
||||
backgroundStyleProps.forEach(prop => {
|
||||
el.style[prop as any] = Style.cacheStyle![prop];
|
||||
});
|
||||
Style.cacheStyle = null;
|
||||
}
|
||||
|
||||
constructor(ctx: any) {
|
||||
this.ctx = ctx;
|
||||
this._markerPath = null;
|
||||
this._marker = null;
|
||||
this._gradient = null;
|
||||
}
|
||||
|
||||
merge(prop: string, root?: boolean): any {
|
||||
const themeConfig = this.ctx.mindMap.themeConfig;
|
||||
let defaultConfig: any = null;
|
||||
const useRoot = root;
|
||||
|
||||
if (useRoot) {
|
||||
defaultConfig = themeConfig;
|
||||
} else if (this.ctx.isGeneralization) {
|
||||
defaultConfig = themeConfig.generalization;
|
||||
} else if (this.ctx.layerIndex === 0) {
|
||||
defaultConfig = themeConfig.root;
|
||||
} else if (this.ctx.layerIndex === 1) {
|
||||
defaultConfig = themeConfig.second;
|
||||
} else {
|
||||
defaultConfig = themeConfig.node;
|
||||
}
|
||||
|
||||
let value: any = '';
|
||||
if (this.getSelfStyle(prop) !== undefined) {
|
||||
value = this.getSelfStyle(prop);
|
||||
} else if (defaultConfig[prop] !== undefined) {
|
||||
value = defaultConfig[prop];
|
||||
} else {
|
||||
value = themeConfig[prop];
|
||||
}
|
||||
|
||||
if (!useRoot) {
|
||||
this.addToEffectiveStyles({
|
||||
[prop]: value
|
||||
});
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
getStyle(prop: string, root?: boolean): any {
|
||||
return this.merge(prop, root);
|
||||
}
|
||||
|
||||
getSelfStyle(prop: string): any {
|
||||
return this.ctx.getData(prop);
|
||||
}
|
||||
|
||||
addToEffectiveStyles(styles: Record<string, any>): void {
|
||||
this.ctx.effectiveStyles = {
|
||||
...this.ctx.effectiveStyles,
|
||||
...styles
|
||||
};
|
||||
}
|
||||
|
||||
rect(node: any): void {
|
||||
this.shape(node);
|
||||
node.radius(this.merge('borderRadius'));
|
||||
}
|
||||
|
||||
shape(node: any): void {
|
||||
const styles = {
|
||||
gradientStyle: this.merge('gradientStyle'),
|
||||
startColor: this.merge('startColor'),
|
||||
endColor: this.merge('endColor'),
|
||||
startDir: this.merge('startDir'),
|
||||
endDir: this.merge('endDir'),
|
||||
fillColor: this.merge('fillColor'),
|
||||
borderColor: this.merge('borderColor'),
|
||||
borderWidth: this.merge('borderWidth'),
|
||||
borderDasharray: this.merge('borderDasharray')
|
||||
};
|
||||
|
||||
if (styles.gradientStyle) {
|
||||
if (!this._gradient) {
|
||||
this._gradient = this.ctx.nodeDraw.gradient('linear');
|
||||
}
|
||||
this._gradient.update((add: any) => {
|
||||
add.stop(0, styles.startColor);
|
||||
add.stop(1, styles.endColor);
|
||||
});
|
||||
this._gradient.from(...styles.startDir).to(...styles.endDir);
|
||||
node.fill(this._gradient);
|
||||
} else {
|
||||
node.fill({
|
||||
color: styles.fillColor
|
||||
});
|
||||
}
|
||||
|
||||
node.stroke({
|
||||
color: styles.borderColor,
|
||||
width: styles.borderWidth,
|
||||
dasharray: styles.borderDasharray
|
||||
});
|
||||
}
|
||||
|
||||
text(node: any): void {
|
||||
const styles = {
|
||||
color: this.merge('color'),
|
||||
fontFamily: this.merge('fontFamily'),
|
||||
fontSize: this.merge('fontSize'),
|
||||
fontWeight: this.merge('fontWeight'),
|
||||
fontStyle: this.merge('fontStyle'),
|
||||
textDecoration: this.merge('textDecoration')
|
||||
};
|
||||
|
||||
node
|
||||
.fill({
|
||||
color: styles.color
|
||||
})
|
||||
.css({
|
||||
'font-family': styles.fontFamily,
|
||||
'font-size': `${styles.fontSize}px`,
|
||||
'font-weight': styles.fontWeight,
|
||||
'font-style': styles.fontStyle,
|
||||
'text-decoration': styles.textDecoration
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// 生成内联样式
|
||||
createStyleText(customStyle = {}) {
|
||||
const styles = {
|
||||
color: this.merge('color'),
|
||||
fontFamily: this.merge('fontFamily'),
|
||||
fontSize: this.merge('fontSize'),
|
||||
fontWeight: this.merge('fontWeight'),
|
||||
fontStyle: this.merge('fontStyle'),
|
||||
textDecoration: this.merge('textDecoration'),
|
||||
...customStyle
|
||||
}
|
||||
return `
|
||||
color: ${styles.color};
|
||||
font-family: ${styles.fontFamily};
|
||||
font-size: ${styles.fontSize + 'px'};
|
||||
font-weight: ${styles.fontWeight};
|
||||
font-style: ${styles.fontStyle};
|
||||
text-decoration: ${styles.textDecoration}
|
||||
`
|
||||
}
|
||||
|
||||
// 获取文本样式
|
||||
getTextFontStyle() {
|
||||
const styles = {
|
||||
color: this.merge('color'),
|
||||
fontFamily: this.merge('fontFamily'),
|
||||
fontSize: this.merge('fontSize'),
|
||||
fontWeight: this.merge('fontWeight'),
|
||||
fontStyle: this.merge('fontStyle'),
|
||||
textDecoration: this.merge('textDecoration')
|
||||
}
|
||||
return {
|
||||
italic: styles.fontStyle === 'italic',
|
||||
bold: styles.fontWeight,
|
||||
fontSize: styles.fontSize,
|
||||
fontFamily: styles.fontFamily
|
||||
}
|
||||
}
|
||||
|
||||
// html文字节点
|
||||
domText(node: HTMLElement, fontSizeScale: number = 1): void {
|
||||
const styles = {
|
||||
color: this.merge('color'),
|
||||
fontFamily: this.merge('fontFamily'),
|
||||
fontSize: this.merge('fontSize'),
|
||||
fontWeight: this.merge('fontWeight'),
|
||||
fontStyle: this.merge('fontStyle'),
|
||||
textDecoration: this.merge('textDecoration')
|
||||
}
|
||||
node.style.color = styles.color
|
||||
node.style.textDecoration = styles.textDecoration
|
||||
node.style.fontFamily = styles.fontFamily
|
||||
node.style.fontSize = `${styles.fontSize * fontSizeScale}px`
|
||||
node.style.fontWeight = styles.fontWeight || 'normal'
|
||||
node.style.fontStyle = styles.fontStyle
|
||||
}
|
||||
|
||||
tagText(node: any, style: { fontSize: number }): void {
|
||||
node
|
||||
.fill({
|
||||
color: '#fff'
|
||||
})
|
||||
.css({
|
||||
'font-size': `${style.fontSize}px`
|
||||
})
|
||||
}
|
||||
|
||||
tagRect(node: any, style: { fill: string; radius?: number }): void {
|
||||
node.fill({
|
||||
color: style.fill
|
||||
})
|
||||
if (style.radius) {
|
||||
node.radius(style.radius)
|
||||
}
|
||||
}
|
||||
|
||||
iconNode(node: any, color?: string): void {
|
||||
node.attr({
|
||||
fill: color || this.merge('color')
|
||||
})
|
||||
}
|
||||
|
||||
line(
|
||||
line: any,
|
||||
{ width, color, dasharray }: { width?: number; color?: string; dasharray?: string } = {},
|
||||
enableMarker?: boolean,
|
||||
childNode?: any
|
||||
): void {
|
||||
const { customHandleLine } = this.ctx.mindMap.opt
|
||||
if (typeof customHandleLine === 'function') {
|
||||
customHandleLine(this.ctx, line, { width, color, dasharray })
|
||||
}
|
||||
line.stroke({ color, dasharray, width }).fill({ color: 'none' })
|
||||
|
||||
if (enableMarker) {
|
||||
const showMarker = this.merge('showLineMarker', true)
|
||||
const childNodeStyle = childNode.style
|
||||
|
||||
if (showMarker) {
|
||||
childNodeStyle._marker = childNodeStyle._marker || childNodeStyle.createMarker()
|
||||
childNodeStyle._markerPath.stroke({ color }).fill({ color })
|
||||
line.attr('marker-start', '')
|
||||
line.attr('marker-end', '')
|
||||
const dir = childNodeStyle.merge('lineMarkerDir')
|
||||
line.marker(dir, childNodeStyle._marker)
|
||||
} else if (childNodeStyle._marker) {
|
||||
line.attr('marker-start', '')
|
||||
line.attr('marker-end', '')
|
||||
childNodeStyle._marker.remove()
|
||||
childNodeStyle._marker = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createMarker(): any {
|
||||
return this.ctx.lineDraw.marker(20, 20, (add: any) => {
|
||||
add.ref(8, 5)
|
||||
add.size(20, 20)
|
||||
add.attr('markerUnits', 'userSpaceOnUse')
|
||||
add.attr('orient', 'auto-start-reverse')
|
||||
this._markerPath = add.path('M0,0 L2,5 L0,10 L10,5 Z')
|
||||
})
|
||||
}
|
||||
|
||||
generalizationLine(node: any): void {
|
||||
node
|
||||
.stroke({
|
||||
width: this.merge('generalizationLineWidth', true),
|
||||
color: this.merge('generalizationLineColor', true)
|
||||
})
|
||||
.fill({ color: 'none' })
|
||||
}
|
||||
|
||||
iconBtn(node: any, node2: any, fillNode: any): void {
|
||||
const { color, fill, fontSize, fontColor } = this.ctx.mindMap.opt
|
||||
.expandBtnStyle || {
|
||||
color: '#808080',
|
||||
fill: '#fff',
|
||||
fontSize: 12,
|
||||
strokeColor: '#333333',
|
||||
fontColor: '#333333'
|
||||
}
|
||||
node.fill({ color: color })
|
||||
node2.fill({ color: color })
|
||||
fillNode.fill({ color: fill })
|
||||
if (this.ctx.mindMap.opt.isShowExpandNum) {
|
||||
node.attr({ 'font-size': `${fontSize}px`, 'font-color': fontColor })
|
||||
}
|
||||
}
|
||||
|
||||
hasCustomStyle(): boolean {
|
||||
let res = false
|
||||
Object.keys(this.ctx.getData()).forEach(item => {
|
||||
if (checkIsNodeStyleDataKey(item)) {
|
||||
res = true
|
||||
}
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
getCustomStyle(): Record<string, any> {
|
||||
const customStyle: Record<string, any> = {}
|
||||
Object.keys(this.ctx.getData()).forEach(item => {
|
||||
if (checkIsNodeStyleDataKey(item)) {
|
||||
customStyle[item] = this.ctx.getData(item)
|
||||
}
|
||||
})
|
||||
return customStyle
|
||||
}
|
||||
|
||||
hoverNode(node: any): void {
|
||||
const hoverRectColor =
|
||||
this.merge('hoverRectColor') || this.ctx.mindMap.opt.hoverRectColor
|
||||
const hoverRectRadius = this.merge('hoverRectRadius')
|
||||
node.radius(hoverRectRadius).fill('none').stroke({
|
||||
color: hoverRectColor
|
||||
})
|
||||
}
|
||||
|
||||
onRemove(): void {
|
||||
if (this._marker) {
|
||||
this._marker.remove()
|
||||
this._marker = null
|
||||
}
|
||||
if (this._markerPath) {
|
||||
this._markerPath.remove()
|
||||
this._markerPath = null
|
||||
}
|
||||
if (this._gradient) {
|
||||
this._gradient.remove()
|
||||
this._gradient = null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
export default Style
|
|
@ -1,68 +0,0 @@
|
|||
// 设置数据
|
||||
function setData(data = {}) {
|
||||
this.mindMap.execCommand('SET_NODE_DATA', this, data)
|
||||
}
|
||||
|
||||
// 设置文本
|
||||
function setText(text, richText, resetRichText) {
|
||||
this.mindMap.execCommand('SET_NODE_TEXT', this, text, richText, resetRichText)
|
||||
}
|
||||
|
||||
// 设置图片
|
||||
function setImage(imgData) {
|
||||
this.mindMap.execCommand('SET_NODE_IMAGE', this, imgData)
|
||||
}
|
||||
|
||||
// 设置图标
|
||||
function setIcon(icons) {
|
||||
this.mindMap.execCommand('SET_NODE_ICON', this, icons)
|
||||
}
|
||||
|
||||
// 设置超链接
|
||||
function setHyperlink(link, title) {
|
||||
this.mindMap.execCommand('SET_NODE_HYPERLINK', this, link, title)
|
||||
}
|
||||
|
||||
// 设置备注
|
||||
function setNote(note) {
|
||||
this.mindMap.execCommand('SET_NODE_NOTE', this, note)
|
||||
}
|
||||
|
||||
// 设置附件
|
||||
function setAttachment(url, name) {
|
||||
this.mindMap.execCommand('SET_NODE_ATTACHMENT', this, url, name)
|
||||
}
|
||||
|
||||
// 设置标签
|
||||
function setTag(tag) {
|
||||
this.mindMap.execCommand('SET_NODE_TAG', this, tag)
|
||||
}
|
||||
|
||||
// 设置形状
|
||||
function setShape(shape) {
|
||||
this.mindMap.execCommand('SET_NODE_SHAPE', this, shape)
|
||||
}
|
||||
|
||||
// 修改某个样式
|
||||
function setStyle(prop, value) {
|
||||
this.mindMap.execCommand('SET_NODE_STYLE', this, prop, value)
|
||||
}
|
||||
|
||||
// 修改多个样式
|
||||
function setStyles(style) {
|
||||
this.mindMap.execCommand('SET_NODE_STYLES', this, style)
|
||||
}
|
||||
|
||||
export default {
|
||||
setData,
|
||||
setText,
|
||||
setImage,
|
||||
setIcon,
|
||||
setHyperlink,
|
||||
setNote,
|
||||
setAttachment,
|
||||
setTag,
|
||||
setShape,
|
||||
setStyle,
|
||||
setStyles
|
||||
}
|
|
@ -1,120 +0,0 @@
|
|||
import { Circle, G, Text, Image } from '@svgdotjs/svg.js'
|
||||
import { generateColorByContent } from '../../../utils/index'
|
||||
|
||||
// 协同相关功能
|
||||
|
||||
// 创建容器
|
||||
function createUserListNode() {
|
||||
// 如果没有注册协作插件,那么需要创建
|
||||
if (!this.mindMap.cooperate) return
|
||||
this._userListGroup = new G()
|
||||
this.group.add(this._userListGroup)
|
||||
}
|
||||
|
||||
// 创建文本头像
|
||||
function createTextAvatar(item) {
|
||||
const { avatarSize, fontSize } = this.mindMap.opt.cooperateStyle
|
||||
const g = new G()
|
||||
const str = item.isMore ? item.name : String(item.name)[0]
|
||||
// 圆
|
||||
const circle = new Circle().size(avatarSize, avatarSize)
|
||||
circle.fill({
|
||||
color: item.color || generateColorByContent(str)
|
||||
})
|
||||
// 文本
|
||||
const text = new Text()
|
||||
.text(str)
|
||||
.fill({
|
||||
color: '#fff'
|
||||
})
|
||||
.css({
|
||||
'font-size': fontSize + 'px'
|
||||
})
|
||||
.dx(-fontSize / 2)
|
||||
.dy((avatarSize - fontSize) / 2)
|
||||
g.add(circle).add(text)
|
||||
return g
|
||||
}
|
||||
|
||||
// 创建图片头像
|
||||
function createImageAvatar(item) {
|
||||
const { avatarSize } = this.mindMap.opt.cooperateStyle
|
||||
return new Image().load(item.avatar).size(avatarSize, avatarSize)
|
||||
}
|
||||
|
||||
// 更新渲染
|
||||
function updateUserListNode() {
|
||||
if (!this._userListGroup) return
|
||||
const { avatarSize } = this.mindMap.opt.cooperateStyle
|
||||
this._userListGroup.clear()
|
||||
// 根据当前节点长度计算最多能显示几个
|
||||
const length = this.userList.length
|
||||
const maxShowCount = Math.floor(this.width / avatarSize)
|
||||
const list = []
|
||||
if (length > maxShowCount) {
|
||||
// 如果当前用户数量比最多能显示的多,最后需要显示一个提示信息
|
||||
list.push(...this.userList.slice(0, maxShowCount - 1), {
|
||||
isMore: true,
|
||||
name: '+' + (length - maxShowCount + 1)
|
||||
})
|
||||
} else {
|
||||
list.push(...this.userList)
|
||||
}
|
||||
list.forEach((item, index) => {
|
||||
let node = null
|
||||
if (item.avatar) {
|
||||
node = this.createImageAvatar(item)
|
||||
} else {
|
||||
node = this.createTextAvatar(item)
|
||||
}
|
||||
node.on('click', (e) => {
|
||||
this.mindMap.emit('node_cooperate_avatar_click', item, this, node, e)
|
||||
})
|
||||
node.on('mouseenter', (e) => {
|
||||
this.mindMap.emit('node_cooperate_avatar_mouseenter', item, this, node, e)
|
||||
})
|
||||
node.on('mouseleave', (e) => {
|
||||
this.mindMap.emit('node_cooperate_avatar_mouseleave', item, this, node, e)
|
||||
})
|
||||
node.x(index * avatarSize).cy(-avatarSize / 2)
|
||||
this._userListGroup.add(node)
|
||||
})
|
||||
}
|
||||
|
||||
// 添加用户
|
||||
function addUser(userInfo) {
|
||||
if (
|
||||
this.userList.find(item => {
|
||||
return item.id == userInfo.id
|
||||
})
|
||||
)
|
||||
return
|
||||
this.userList.push(userInfo)
|
||||
this.updateUserListNode()
|
||||
}
|
||||
|
||||
// 移除用户
|
||||
function removeUser(userInfo) {
|
||||
const index = this.userList.findIndex(item => {
|
||||
return item.id == userInfo.id
|
||||
})
|
||||
if (index === -1) return
|
||||
this.userList.splice(index, 1)
|
||||
this.updateUserListNode()
|
||||
}
|
||||
|
||||
// 清空用户
|
||||
function emptyUser() {
|
||||
this.userList = []
|
||||
this.updateUserListNode()
|
||||
}
|
||||
|
||||
export default {
|
||||
createUserListNode,
|
||||
updateUserListNode,
|
||||
createTextAvatar,
|
||||
createImageAvatar,
|
||||
addUser,
|
||||
removeUser,
|
||||
emptyUser
|
||||
}
|
|
@ -1,573 +0,0 @@
|
|||
import {
|
||||
resizeImgSize,
|
||||
removeRichTextStyes,
|
||||
checkIsRichText,
|
||||
isUndef,
|
||||
createForeignObjectNode,
|
||||
addXmlns,
|
||||
generateColorByContent,
|
||||
camelCaseToHyphen,
|
||||
getNodeRichTextStyles
|
||||
} from '../../../utils'
|
||||
import { Image as SVGImage, SVG, A, G, Rect, Text } from '@svgdotjs/svg.js'
|
||||
import iconsSvg from '../../../svg/icons'
|
||||
import { noneRichTextNodeLineHeight } from '../../../constants/constant'
|
||||
|
||||
// 测量svg文本宽高
|
||||
const measureText = (text, style) => {
|
||||
const g = new G()
|
||||
const node = new Text().text(text)
|
||||
style.text(node)
|
||||
g.add(node)
|
||||
return g.bbox()
|
||||
}
|
||||
|
||||
// 标签默认的样式
|
||||
const defaultTagStyle = {
|
||||
radius: 3, // 标签矩形的圆角大小
|
||||
fontSize: 12, // 字号,建议文字高度不要大于height
|
||||
fill: '', // 标签矩形的背景颜色
|
||||
height: 20, // 标签矩形的高度
|
||||
paddingX: 8 // 水平内边距,如果设置了width,将忽略该配置
|
||||
//width: 30 // 标签矩形的宽度,如果不设置,默认以文字的宽度+paddingX*2为宽度
|
||||
}
|
||||
|
||||
// 创建图片节点
|
||||
function createImgNode() {
|
||||
const img = this.getData('image')
|
||||
if (!img) {
|
||||
return
|
||||
}
|
||||
const imgSize = this.getImgShowSize()
|
||||
const node = new SVGImage().load(img).size(...imgSize)
|
||||
// 如果指定了加载失败显示的图片,那么加载一下图片检测是否失败
|
||||
const { defaultNodeImage } = this.mindMap.opt
|
||||
if (defaultNodeImage) {
|
||||
const imgEl = new Image()
|
||||
imgEl.onerror = () => {
|
||||
node.load(defaultNodeImage)
|
||||
}
|
||||
imgEl.src = img
|
||||
}
|
||||
if (this.getData('imageTitle')) {
|
||||
node.attr('title', this.getData('imageTitle'))
|
||||
}
|
||||
node.on('dblclick', e => {
|
||||
this.mindMap.emit('node_img_dblclick', this, e)
|
||||
})
|
||||
node.on('mouseenter', e => {
|
||||
this.mindMap.emit('node_img_mouseenter', this, node, e)
|
||||
})
|
||||
node.on('mouseleave', e => {
|
||||
this.mindMap.emit('node_img_mouseleave', this, node, e)
|
||||
})
|
||||
node.on('mousemove', e => {
|
||||
this.mindMap.emit('node_img_mousemove', this, node, e)
|
||||
})
|
||||
return {
|
||||
node,
|
||||
width: imgSize[0],
|
||||
height: imgSize[1]
|
||||
}
|
||||
}
|
||||
|
||||
// 获取图片显示宽高
|
||||
function getImgShowSize() {
|
||||
const { custom, width, height } = this.getData('imageSize')
|
||||
// 如果是自定义了图片的宽高,那么不受最大宽高限制
|
||||
if (custom) return [width, height]
|
||||
return resizeImgSize(
|
||||
width,
|
||||
height,
|
||||
this.mindMap.themeConfig.imgMaxWidth,
|
||||
this.mindMap.themeConfig.imgMaxHeight
|
||||
)
|
||||
}
|
||||
|
||||
// 创建icon节点
|
||||
function createIconNode() {
|
||||
let _data = this.getData()
|
||||
if (!_data.icon || _data.icon.length <= 0) {
|
||||
return []
|
||||
}
|
||||
let iconSize = this.mindMap.themeConfig.iconSize
|
||||
return _data.icon.map(item => {
|
||||
let src = iconsSvg.getNodeIconListIcon(
|
||||
item,
|
||||
this.mindMap.opt.iconList || []
|
||||
)
|
||||
let node = null
|
||||
// svg图标
|
||||
if (/^<svg/.test(src)) {
|
||||
node = SVG(src)
|
||||
} else {
|
||||
// 图片图标
|
||||
node = new SVGImage().load(src)
|
||||
}
|
||||
node.size(iconSize, iconSize)
|
||||
node.on('click', e => {
|
||||
this.mindMap.emit('node_icon_click', this, item, e, node)
|
||||
})
|
||||
node.on('mouseenter', e => {
|
||||
this.mindMap.emit('node_icon_mouseenter', this, item, e, node)
|
||||
})
|
||||
node.on('mouseleave', e => {
|
||||
this.mindMap.emit('node_icon_mouseleave', this, item, e, node)
|
||||
})
|
||||
return {
|
||||
node,
|
||||
width: iconSize,
|
||||
height: iconSize
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 创建富文本节点
|
||||
function createRichTextNode(specifyText) {
|
||||
const hasCustomWidth = this.hasCustomWidth()
|
||||
let text =
|
||||
typeof specifyText === 'string' ? specifyText : this.getData('text')
|
||||
let { textAutoWrapWidth, emptyTextMeasureHeightText } = this.mindMap.opt
|
||||
textAutoWrapWidth = hasCustomWidth ? this.customTextWidth : textAutoWrapWidth
|
||||
const g = new G()
|
||||
// 创建富文本结构,或复位富文本样式
|
||||
let recoverText = false
|
||||
if (this.getData('resetRichText')) {
|
||||
delete this.nodeData.data.resetRichText
|
||||
recoverText = true
|
||||
}
|
||||
if (recoverText && !isUndef(text)) {
|
||||
if (checkIsRichText(text)) {
|
||||
// 如果是富文本那么移除内联样式
|
||||
text = removeRichTextStyes(text)
|
||||
} else {
|
||||
// 非富文本则改为富文本结构
|
||||
text = `<p>${text}</p>`
|
||||
}
|
||||
this.setData({
|
||||
text
|
||||
})
|
||||
}
|
||||
// 节点的富文本样式数据
|
||||
const nodeTextStyleList = []
|
||||
const nodeRichTextStyles = getNodeRichTextStyles(this)
|
||||
Object.keys(nodeRichTextStyles).forEach(prop => {
|
||||
nodeTextStyleList.push([prop, nodeRichTextStyles[prop]])
|
||||
})
|
||||
// 测量文本大小
|
||||
if (!this.mindMap.commonCaches.measureRichtextNodeTextSizeEl) {
|
||||
this.mindMap.commonCaches.measureRichtextNodeTextSizeEl =
|
||||
document.createElement('div')
|
||||
this.mindMap.commonCaches.measureRichtextNodeTextSizeEl.style.position =
|
||||
'fixed'
|
||||
this.mindMap.commonCaches.measureRichtextNodeTextSizeEl.style.left =
|
||||
'-999999px'
|
||||
this.mindMap.el.appendChild(
|
||||
this.mindMap.commonCaches.measureRichtextNodeTextSizeEl
|
||||
)
|
||||
}
|
||||
const div = this.mindMap.commonCaches.measureRichtextNodeTextSizeEl
|
||||
// 应用节点的文本样式
|
||||
nodeTextStyleList.forEach(([prop, value]) => {
|
||||
div.style[prop] = value
|
||||
})
|
||||
div.style.lineHeight = 1.2
|
||||
const html = `<div>${text}</div>`
|
||||
div.innerHTML = html
|
||||
const el = div.children[0]
|
||||
el.classList.add('smm-richtext-node-wrap')
|
||||
addXmlns(el)
|
||||
el.style.maxWidth = textAutoWrapWidth + 'px'
|
||||
if (hasCustomWidth) {
|
||||
el.style.width = this.customTextWidth + 'px'
|
||||
} else {
|
||||
el.style.width = ''
|
||||
}
|
||||
let { width, height } = el.getBoundingClientRect()
|
||||
// 如果文本为空,那么需要计算一个默认高度
|
||||
if (height <= 0) {
|
||||
div.innerHTML = `<p>${emptyTextMeasureHeightText}</p>`
|
||||
let elTmp = div.children[0]
|
||||
elTmp.classList.add('smm-richtext-node-wrap')
|
||||
height = elTmp.getBoundingClientRect().height
|
||||
div.innerHTML = html
|
||||
}
|
||||
width = Math.min(Math.ceil(width) + 1, textAutoWrapWidth) // 修复getBoundingClientRect方法对实际宽度是小数的元素获取到的值是整数,导致宽度不够文本发生换行的问题
|
||||
height = Math.ceil(height)
|
||||
g.attr('data-width', width)
|
||||
g.attr('data-height', height)
|
||||
const foreignObject = createForeignObjectNode({
|
||||
el: div.children[0],
|
||||
width,
|
||||
height
|
||||
})
|
||||
// 应用节点文本样式
|
||||
// 进入文本编辑时,这个样式也会同样添加到文本编辑框的元素上
|
||||
const foreignObjectStyle = {
|
||||
'line-height': 1.2
|
||||
}
|
||||
nodeTextStyleList.forEach(([prop, value]) => {
|
||||
foreignObjectStyle[camelCaseToHyphen(prop)] = value
|
||||
})
|
||||
foreignObject.css(foreignObjectStyle)
|
||||
g.add(foreignObject)
|
||||
return {
|
||||
node: g,
|
||||
nodeContent: foreignObject,
|
||||
width,
|
||||
height
|
||||
}
|
||||
}
|
||||
|
||||
// 创建文本节点
|
||||
function createTextNode(specifyText) {
|
||||
if (this.getData('needUpdate')) {
|
||||
delete this.nodeData.data.needUpdate
|
||||
}
|
||||
// 如果是富文本内容,那么转给富文本函数
|
||||
if (this.getData('richText')) {
|
||||
return this.createRichTextNode(specifyText)
|
||||
}
|
||||
const text =
|
||||
typeof specifyText === 'string' ? specifyText : this.getData('text')
|
||||
if (this.getData('resetRichText')) {
|
||||
delete this.nodeData.data.resetRichText
|
||||
}
|
||||
let g = new G()
|
||||
let fontSize = this.getStyle('fontSize', false)
|
||||
// 文本超长自动换行
|
||||
let textArr = []
|
||||
if (!isUndef(text)) {
|
||||
textArr = String(text).split(/\n/gim)
|
||||
}
|
||||
const { textAutoWrapWidth: maxWidth, emptyTextMeasureHeightText } =
|
||||
this.mindMap.opt
|
||||
let isMultiLine = textArr.length > 1
|
||||
textArr.forEach((item, index) => {
|
||||
let arr = item.split('')
|
||||
let lines = []
|
||||
let line = []
|
||||
while (arr.length) {
|
||||
let str = arr.shift()
|
||||
let text = [...line, str].join('')
|
||||
if (measureText(text, this.style).width <= maxWidth) {
|
||||
line.push(str)
|
||||
} else {
|
||||
lines.push(line.join(''))
|
||||
line = [str]
|
||||
}
|
||||
}
|
||||
if (line.length > 0) {
|
||||
lines.push(line.join(''))
|
||||
}
|
||||
if (lines.length > 1) {
|
||||
isMultiLine = true
|
||||
}
|
||||
textArr[index] = lines.join('\n')
|
||||
})
|
||||
textArr = textArr.join('\n').replace(/\n$/g, '').split(/\n/gim)
|
||||
textArr.forEach((item, index) => {
|
||||
// 避免尾部的空行不占宽度
|
||||
// 同时解决该问题:https://github.com/wanglin2/mind-map/issues/1037
|
||||
if (item === '') {
|
||||
item = ''
|
||||
}
|
||||
const node = new Text().text(item)
|
||||
node.addClass('smm-text-node-wrap')
|
||||
this.style.text(node)
|
||||
node.y(
|
||||
fontSize * noneRichTextNodeLineHeight * index +
|
||||
((noneRichTextNodeLineHeight - 1) * fontSize) / 2
|
||||
)
|
||||
g.add(node)
|
||||
})
|
||||
let { width, height } = g.bbox()
|
||||
// 如果文本为空,那么需要计算一个默认高度
|
||||
if (height <= 0) {
|
||||
const tmpNode = new Text().text(emptyTextMeasureHeightText)
|
||||
this.style.text(tmpNode)
|
||||
const tmpBbox = tmpNode.bbox()
|
||||
height = tmpBbox.height
|
||||
}
|
||||
width = Math.min(Math.ceil(width), maxWidth)
|
||||
height = Math.ceil(height)
|
||||
g.attr('data-width', width)
|
||||
g.attr('data-height', height)
|
||||
g.attr('data-ismultiLine', isMultiLine || textArr.length > 1)
|
||||
return {
|
||||
node: g,
|
||||
width,
|
||||
height
|
||||
}
|
||||
}
|
||||
|
||||
// 创建超链接节点
|
||||
function createHyperlinkNode() {
|
||||
const { hyperlink, hyperlinkTitle } = this.getData()
|
||||
if (!hyperlink) {
|
||||
return
|
||||
}
|
||||
const { customHyperlinkJump, hyperlinkIcon } = this.mindMap.opt
|
||||
const { icon, style } = hyperlinkIcon
|
||||
const iconSize = this.getNodeIconSize('hyperlinkIcon')
|
||||
const node = new SVG().size(iconSize, iconSize)
|
||||
// 超链接节点
|
||||
const a = new A().to(hyperlink).target('_blank')
|
||||
a.node.addEventListener('click', e => {
|
||||
if (typeof customHyperlinkJump === 'function') {
|
||||
e.preventDefault()
|
||||
customHyperlinkJump(hyperlink, this)
|
||||
}
|
||||
})
|
||||
if (hyperlinkTitle) {
|
||||
node.add(SVG(`<title>${hyperlinkTitle}</title>`))
|
||||
}
|
||||
// 添加一个透明的层,作为鼠标区域
|
||||
a.rect(iconSize, iconSize).fill({ color: 'transparent' })
|
||||
// 超链接图标
|
||||
const iconNode = SVG(icon || iconsSvg.hyperlink).size(iconSize, iconSize)
|
||||
this.style.iconNode(iconNode, style.color)
|
||||
a.add(iconNode)
|
||||
node.add(a)
|
||||
return {
|
||||
node,
|
||||
width: iconSize,
|
||||
height: iconSize
|
||||
}
|
||||
}
|
||||
|
||||
// 创建标签节点
|
||||
function createTagNode() {
|
||||
const tagData = this.getData('tag')
|
||||
if (!tagData || tagData.length <= 0) {
|
||||
return []
|
||||
}
|
||||
let { maxTag, tagsColorMap } = this.mindMap.opt
|
||||
tagsColorMap = tagsColorMap || {}
|
||||
const nodes = []
|
||||
tagData.slice(0, maxTag).forEach((item, index) => {
|
||||
let str = ''
|
||||
let style = {
|
||||
...defaultTagStyle
|
||||
}
|
||||
// 旧版只支持字符串类型
|
||||
if (typeof item === 'string') {
|
||||
str = item
|
||||
} else {
|
||||
// v0.10.3+版本支持对象类型
|
||||
str = item.text
|
||||
style = { ...defaultTagStyle, ...item.style }
|
||||
}
|
||||
// 是否手动设置了标签宽度
|
||||
const hasCustomWidth = typeof style.width !== 'undefined'
|
||||
// 创建容器节点
|
||||
const tag = new G()
|
||||
tag.on('click', () => {
|
||||
this.mindMap.emit('node_tag_click', this, item, index, tag)
|
||||
})
|
||||
// 标签文本
|
||||
const text = new Text().text(str)
|
||||
this.style.tagText(text, style)
|
||||
// 获取文本宽高
|
||||
const { width: textWidth, height: textHeight } = text.bbox()
|
||||
// 矩形宽度
|
||||
const rectWidth = hasCustomWidth
|
||||
? style.width
|
||||
: textWidth + style.paddingX * 2
|
||||
// 取文本和矩形最大宽高作为标签宽高
|
||||
const maxWidth = hasCustomWidth ? Math.max(rectWidth, textWidth) : rectWidth
|
||||
const maxHeight = Math.max(style.height, textHeight)
|
||||
// 文本居中
|
||||
if (hasCustomWidth) {
|
||||
text.x((maxWidth - textWidth) / 2)
|
||||
} else {
|
||||
text.x(hasCustomWidth ? 0 : style.paddingX)
|
||||
}
|
||||
text.cy(-maxHeight / 2)
|
||||
// 标签矩形
|
||||
const rect = new Rect().size(rectWidth, style.height).cy(-maxHeight / 2)
|
||||
if (hasCustomWidth) {
|
||||
rect.x((maxWidth - rectWidth) / 2)
|
||||
}
|
||||
this.style.tagRect(rect, {
|
||||
...style,
|
||||
fill:
|
||||
style.fill || // 优先节点自身配置
|
||||
tagsColorMap[text.node.textContent] || // 否则尝试从实例化选项tagsColorMap映射中获取颜色
|
||||
generateColorByContent(text.node.textContent) // 否则按照标签内容生成
|
||||
})
|
||||
tag.add(rect).add(text)
|
||||
nodes.push({
|
||||
node: tag,
|
||||
width: maxWidth,
|
||||
height: maxHeight
|
||||
})
|
||||
})
|
||||
return nodes
|
||||
}
|
||||
|
||||
// 创建备注节点
|
||||
function createNoteNode() {
|
||||
if (!this.getData('note')) {
|
||||
return null
|
||||
}
|
||||
const { icon, style } = this.mindMap.opt.noteIcon
|
||||
const iconSize = this.getNodeIconSize('noteIcon')
|
||||
const node = new SVG()
|
||||
.attr('cursor', 'pointer')
|
||||
.addClass('smm-node-note')
|
||||
.size(iconSize, iconSize)
|
||||
// 透明的层,用来作为鼠标区域
|
||||
node.add(new Rect().size(iconSize, iconSize).fill({ color: 'transparent' }))
|
||||
// 备注图标
|
||||
const iconNode = SVG(icon || iconsSvg.note).size(iconSize, iconSize)
|
||||
this.style.iconNode(iconNode, style.color)
|
||||
node.add(iconNode)
|
||||
// 备注tooltip
|
||||
if (!this.mindMap.opt.customNoteContentShow) {
|
||||
if (!this.noteEl) {
|
||||
this.noteEl = document.createElement('div')
|
||||
this.noteEl.style.cssText = `
|
||||
position: fixed;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 2px 5px rgb(0 0 0 / 10%);
|
||||
display: none;
|
||||
background-color: #fff;
|
||||
z-index: ${this.mindMap.opt.nodeNoteTooltipZIndex}
|
||||
`
|
||||
const targetNode =
|
||||
this.mindMap.opt.customInnerElsAppendTo || document.body
|
||||
targetNode.appendChild(this.noteEl)
|
||||
}
|
||||
this.noteEl.innerText = this.getData('note')
|
||||
}
|
||||
node.on('mouseover', () => {
|
||||
const { left, top } = this.getNoteContentPosition()
|
||||
if (!this.mindMap.opt.customNoteContentShow) {
|
||||
this.noteEl.style.left = left + 'px'
|
||||
this.noteEl.style.top = top + 'px'
|
||||
this.noteEl.style.display = 'block'
|
||||
} else {
|
||||
this.mindMap.opt.customNoteContentShow.show(
|
||||
this.getData('note'),
|
||||
left,
|
||||
top,
|
||||
this
|
||||
)
|
||||
}
|
||||
})
|
||||
node.on('mouseout', () => {
|
||||
if (!this.mindMap.opt.customNoteContentShow) {
|
||||
this.noteEl.style.display = 'none'
|
||||
} else {
|
||||
this.mindMap.opt.customNoteContentShow.hide()
|
||||
}
|
||||
})
|
||||
node.on('click', e => {
|
||||
this.mindMap.emit('node_note_click', this, e, node)
|
||||
})
|
||||
node.on('dblclick', e => {
|
||||
this.mindMap.emit('node_note_dblclick', this, e, node)
|
||||
})
|
||||
return {
|
||||
node,
|
||||
width: iconSize,
|
||||
height: iconSize
|
||||
}
|
||||
}
|
||||
|
||||
// 创建附件节点
|
||||
function createAttachmentNode() {
|
||||
const { attachmentUrl, attachmentName } = this.getData()
|
||||
if (!attachmentUrl) {
|
||||
return
|
||||
}
|
||||
const iconSize = this.getNodeIconSize('attachmentIcon')
|
||||
const { icon, style } = this.mindMap.opt.attachmentIcon
|
||||
const node = new SVG().attr('cursor', 'pointer').size(iconSize, iconSize)
|
||||
if (attachmentName) {
|
||||
node.add(SVG(`<title>${attachmentName}</title>`))
|
||||
}
|
||||
// 透明的层,用来作为鼠标区域
|
||||
node.add(new Rect().size(iconSize, iconSize).fill({ color: 'transparent' }))
|
||||
// 备注图标
|
||||
const iconNode = SVG(icon || iconsSvg.attachment).size(iconSize, iconSize)
|
||||
this.style.iconNode(iconNode, style.color)
|
||||
node.add(iconNode)
|
||||
node.on('click', e => {
|
||||
this.mindMap.emit('node_attachmentClick', this, e, node)
|
||||
})
|
||||
node.on('contextmenu', e => {
|
||||
this.mindMap.emit('node_attachmentContextmenu', this, e, node)
|
||||
})
|
||||
return {
|
||||
node,
|
||||
width: iconSize,
|
||||
height: iconSize
|
||||
}
|
||||
}
|
||||
|
||||
// 获取节点图标大小
|
||||
function getNodeIconSize(prop) {
|
||||
const { style } = this.mindMap.opt[prop]
|
||||
return isUndef(style.size) ? this.mindMap.themeConfig.iconSize : style.size
|
||||
}
|
||||
|
||||
// 获取节点备注显示位置
|
||||
function getNoteContentPosition() {
|
||||
const iconSize = this.getNodeIconSize('noteIcon')
|
||||
const { scaleY } = this.mindMap.view.getTransformData().transform
|
||||
const iconSizeAddScale = iconSize * scaleY
|
||||
let { left, top } = this._noteData.node.node.getBoundingClientRect()
|
||||
top += iconSizeAddScale
|
||||
return {
|
||||
left,
|
||||
top
|
||||
}
|
||||
}
|
||||
|
||||
// 测量自定义节点内容元素的宽高
|
||||
function measureCustomNodeContentSize(content) {
|
||||
if (!this.mindMap.commonCaches.measureCustomNodeContentSizeEl) {
|
||||
this.mindMap.commonCaches.measureCustomNodeContentSizeEl =
|
||||
document.createElement('div')
|
||||
this.mindMap.commonCaches.measureCustomNodeContentSizeEl.style.cssText = `
|
||||
position: fixed;
|
||||
left: -99999px;
|
||||
top: -99999px;
|
||||
`
|
||||
this.mindMap.el.appendChild(
|
||||
this.mindMap.commonCaches.measureCustomNodeContentSizeEl
|
||||
)
|
||||
}
|
||||
this.mindMap.commonCaches.measureCustomNodeContentSizeEl.innerHTML = ''
|
||||
this.mindMap.commonCaches.measureCustomNodeContentSizeEl.appendChild(content)
|
||||
let rect =
|
||||
this.mindMap.commonCaches.measureCustomNodeContentSizeEl.getBoundingClientRect()
|
||||
return {
|
||||
width: rect.width,
|
||||
height: rect.height
|
||||
}
|
||||
}
|
||||
|
||||
// 是否使用的是自定义节点内容
|
||||
function isUseCustomNodeContent() {
|
||||
return !!this._customNodeContent
|
||||
}
|
||||
|
||||
export default {
|
||||
createImgNode,
|
||||
getImgShowSize,
|
||||
createIconNode,
|
||||
createRichTextNode,
|
||||
createTextNode,
|
||||
createHyperlinkNode,
|
||||
createTagNode,
|
||||
createNoteNode,
|
||||
createAttachmentNode,
|
||||
getNoteContentPosition,
|
||||
getNodeIconSize,
|
||||
measureCustomNodeContentSize,
|
||||
isUseCustomNodeContent
|
||||
}
|
|
@ -1,187 +0,0 @@
|
|||
import btnsSvg from '../../../svg/btns'
|
||||
import { SVG, Circle, G, Text } from '@svgdotjs/svg.js'
|
||||
import { isUndef } from '../../../utils'
|
||||
|
||||
// 创建展开收起按钮的内容节点
|
||||
function createExpandNodeContent() {
|
||||
if (this._openExpandNode) {
|
||||
return
|
||||
}
|
||||
let { close, open } = this.mindMap.opt.expandBtnIcon || {}
|
||||
// 根据配置判断是否显示数量按钮
|
||||
if (this.mindMap.opt.isShowExpandNum) {
|
||||
// 展开的节点
|
||||
this._openExpandNode = new Text()
|
||||
this._openExpandNode.addClass('smm-expand-btn-text')
|
||||
// 文本垂直居中
|
||||
this._openExpandNode.attr({
|
||||
'text-anchor': 'middle',
|
||||
'dominant-baseline': 'middle',
|
||||
x: this.expandBtnSize / 2,
|
||||
y: 2
|
||||
})
|
||||
} else {
|
||||
this._openExpandNode = SVG(open || btnsSvg.open).size(
|
||||
this.expandBtnSize,
|
||||
this.expandBtnSize
|
||||
)
|
||||
this._openExpandNode.x(0).y(-this.expandBtnSize / 2)
|
||||
}
|
||||
// 收起的节点
|
||||
this._closeExpandNode = SVG(close || btnsSvg.close).size(
|
||||
this.expandBtnSize,
|
||||
this.expandBtnSize
|
||||
)
|
||||
this._closeExpandNode.x(0).y(-this.expandBtnSize / 2)
|
||||
// 填充节点
|
||||
this._fillExpandNode = new Circle().size(this.expandBtnSize)
|
||||
this._fillExpandNode.x(0).y(-this.expandBtnSize / 2)
|
||||
|
||||
// 设置样式
|
||||
this.style.iconBtn(
|
||||
this._openExpandNode,
|
||||
this._closeExpandNode,
|
||||
this._fillExpandNode
|
||||
)
|
||||
}
|
||||
function sumNode(data = []) {
|
||||
return data.reduce(
|
||||
(total, cur) => total + this.sumNode(cur.children || []),
|
||||
data.length
|
||||
)
|
||||
}
|
||||
// 创建或更新展开收缩按钮内容
|
||||
function updateExpandBtnNode() {
|
||||
let { expand } = this.getData()
|
||||
// 如果本次和上次的展开状态一样则返回
|
||||
if (expand === this._lastExpandBtnType) return
|
||||
if (this._expandBtn) {
|
||||
this._expandBtn.clear()
|
||||
}
|
||||
this.createExpandNodeContent()
|
||||
let node
|
||||
if (expand === false) {
|
||||
node = this._openExpandNode
|
||||
this._lastExpandBtnType = false
|
||||
} else {
|
||||
node = this._closeExpandNode
|
||||
this._lastExpandBtnType = true
|
||||
}
|
||||
|
||||
if (this._expandBtn) {
|
||||
// 如果是收起按钮加上边框
|
||||
let { isShowExpandNum, expandBtnStyle, expandBtnNumHandler } =
|
||||
this.mindMap.opt
|
||||
if (isShowExpandNum) {
|
||||
if (!expand) {
|
||||
// 数字按钮添加边框
|
||||
this._fillExpandNode.stroke({
|
||||
color: expandBtnStyle.strokeColor
|
||||
})
|
||||
// 计算子节点数量
|
||||
let count = this.sumNode(this.nodeData.children)
|
||||
if (typeof expandBtnNumHandler === 'function') {
|
||||
const res = expandBtnNumHandler(count, this)
|
||||
if (!isUndef(res)) {
|
||||
count = res
|
||||
}
|
||||
}
|
||||
node.text(String(count))
|
||||
} else {
|
||||
this._fillExpandNode.stroke('none')
|
||||
}
|
||||
}
|
||||
this._expandBtn.add(this._fillExpandNode).add(node)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新展开收缩按钮位置
|
||||
function updateExpandBtnPos() {
|
||||
if (!this._expandBtn) {
|
||||
return
|
||||
}
|
||||
this.renderer.layout.renderExpandBtn(this, this._expandBtn)
|
||||
}
|
||||
|
||||
// 创建展开收缩按钮
|
||||
function renderExpandBtn() {
|
||||
if (
|
||||
!this.nodeData.children ||
|
||||
this.nodeData.children.length <= 0 ||
|
||||
this.isRoot
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (this._expandBtn) {
|
||||
this.group.add(this._expandBtn)
|
||||
} else {
|
||||
this._expandBtn = new G()
|
||||
this._expandBtn.on('mouseover', e => {
|
||||
e.stopPropagation()
|
||||
this._expandBtn.css({
|
||||
cursor: 'pointer'
|
||||
})
|
||||
})
|
||||
this._expandBtn.on('mouseout', e => {
|
||||
e.stopPropagation()
|
||||
this._expandBtn.css({
|
||||
cursor: 'auto'
|
||||
})
|
||||
})
|
||||
this._expandBtn.on('click', e => {
|
||||
e.stopPropagation()
|
||||
// 展开收缩
|
||||
this.mindMap.execCommand('SET_NODE_EXPAND', this, !this.getData('expand'))
|
||||
this.mindMap.emit('expand_btn_click', this)
|
||||
})
|
||||
this._expandBtn.on('dblclick', e => {
|
||||
e.stopPropagation()
|
||||
})
|
||||
this._expandBtn.addClass('smm-expand-btn')
|
||||
this.group.add(this._expandBtn)
|
||||
}
|
||||
this._showExpandBtn = true
|
||||
this.updateExpandBtnNode()
|
||||
this.updateExpandBtnPos()
|
||||
}
|
||||
|
||||
// 移除展开收缩按钮
|
||||
function removeExpandBtn() {
|
||||
if (this._expandBtn && this._showExpandBtn) {
|
||||
this._expandBtn.remove()
|
||||
this._showExpandBtn = false
|
||||
}
|
||||
}
|
||||
|
||||
// 显示展开收起按钮
|
||||
function showExpandBtn() {
|
||||
const { alwaysShowExpandBtn, notShowExpandBtn } = this.mindMap.opt
|
||||
if (alwaysShowExpandBtn || notShowExpandBtn) return
|
||||
setTimeout(() => {
|
||||
this.renderExpandBtn()
|
||||
}, 0)
|
||||
}
|
||||
|
||||
// 隐藏展开收起按钮
|
||||
function hideExpandBtn() {
|
||||
const { alwaysShowExpandBtn, notShowExpandBtn } = this.mindMap.opt
|
||||
if (alwaysShowExpandBtn || this._isMouseenter || notShowExpandBtn) return
|
||||
// 非激活状态且展开状态鼠标移出才隐藏按钮
|
||||
let { isActive, expand } = this.getData()
|
||||
if (!isActive && expand) {
|
||||
setTimeout(() => {
|
||||
this.removeExpandBtn()
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
createExpandNodeContent,
|
||||
updateExpandBtnNode,
|
||||
updateExpandBtnPos,
|
||||
renderExpandBtn,
|
||||
removeExpandBtn,
|
||||
showExpandBtn,
|
||||
hideExpandBtn,
|
||||
sumNode
|
||||
}
|
|
@ -1,67 +0,0 @@
|
|||
import { Rect } from '@svgdotjs/svg.js'
|
||||
|
||||
// 渲染展开收起按钮的隐藏占位元素
|
||||
function renderExpandBtnPlaceholderRect() {
|
||||
// 根节点或没有子节点不需要渲染
|
||||
if (
|
||||
!this.nodeData.children ||
|
||||
this.nodeData.children.length <= 0 ||
|
||||
this.isRoot
|
||||
) {
|
||||
return
|
||||
}
|
||||
// 默认显示展开按钮的情况下或不显示展开收起按钮的情况下不需要渲染
|
||||
const { alwaysShowExpandBtn, notShowExpandBtn } = this.mindMap.opt
|
||||
if (!alwaysShowExpandBtn && !notShowExpandBtn) {
|
||||
let { width, height } = this
|
||||
if (!this._unVisibleRectRegionNode) {
|
||||
this._unVisibleRectRegionNode = new Rect()
|
||||
this._unVisibleRectRegionNode.fill({
|
||||
color: 'transparent'
|
||||
})
|
||||
}
|
||||
this.group.add(this._unVisibleRectRegionNode)
|
||||
this.renderer.layout.renderExpandBtnRect(
|
||||
this._unVisibleRectRegionNode,
|
||||
this.expandBtnSize,
|
||||
width,
|
||||
height,
|
||||
this
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 删除展开收起按钮的隐藏占位元素
|
||||
function clearExpandBtnPlaceholderRect() {
|
||||
if (!this._unVisibleRectRegionNode) {
|
||||
return
|
||||
}
|
||||
this._unVisibleRectRegionNode.remove()
|
||||
this._unVisibleRectRegionNode = null
|
||||
}
|
||||
|
||||
// 更新展开收起按钮的隐藏占位元素
|
||||
function updateExpandBtnPlaceholderRect() {
|
||||
// 布局改变需要重新渲染
|
||||
if (this.needRerenderExpandBtnPlaceholderRect) {
|
||||
this.needRerenderExpandBtnPlaceholderRect = false
|
||||
this.renderExpandBtnPlaceholderRect()
|
||||
}
|
||||
// 没有子节点到有子节点需要渲染
|
||||
if (this.nodeData.children && this.nodeData.children.length > 0) {
|
||||
if (!this._unVisibleRectRegionNode) {
|
||||
this.renderExpandBtnPlaceholderRect()
|
||||
}
|
||||
} else {
|
||||
// 有子节点到没子节点,需要删除
|
||||
if (this._unVisibleRectRegionNode) {
|
||||
this.clearExpandBtnPlaceholderRect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
renderExpandBtnPlaceholderRect,
|
||||
clearExpandBtnPlaceholderRect,
|
||||
updateExpandBtnPlaceholderRect
|
||||
}
|
|
@ -1,235 +0,0 @@
|
|||
import MindMapNode from './MindMapNode'
|
||||
import { createUid } from '../../../utils/index'
|
||||
|
||||
// 获取节点概要数据
|
||||
function formatGetGeneralization() {
|
||||
const data = this.getData('generalization')
|
||||
return Array.isArray(data) ? data : data ? [data] : []
|
||||
}
|
||||
|
||||
// 检查是否存在概要
|
||||
function checkHasGeneralization() {
|
||||
return this.formatGetGeneralization().length > 0
|
||||
}
|
||||
|
||||
// 检查是否存在自身的概要,非子节点区间
|
||||
function checkHasSelfGeneralization() {
|
||||
const list = this.formatGetGeneralization()
|
||||
return !!list.find(item => {
|
||||
return !item.range || item.range.length <= 0
|
||||
})
|
||||
}
|
||||
|
||||
// 获取概要节点所在的概要列表里的索引
|
||||
function getGeneralizationNodeIndex(node) {
|
||||
return this._generalizationList.findIndex(item => {
|
||||
return item.generalizationNode.uid === node.uid
|
||||
})
|
||||
}
|
||||
|
||||
// 创建概要节点
|
||||
function createGeneralizationNode() {
|
||||
if (this.isGeneralization || !this.checkHasGeneralization()) {
|
||||
return
|
||||
}
|
||||
let maxWidth = 0
|
||||
let maxHeight = 0
|
||||
const list = this.formatGetGeneralization()
|
||||
list.forEach((item, index) => {
|
||||
let cur = this._generalizationList[index]
|
||||
if (!cur) {
|
||||
cur = this._generalizationList[index] = {}
|
||||
}
|
||||
// 所属节点
|
||||
cur.node = this
|
||||
// 区间范围
|
||||
cur.range = item.range
|
||||
// 线和节点
|
||||
if (!cur.generalizationLine) {
|
||||
cur.generalizationLine = this.lineDraw.path()
|
||||
}
|
||||
if (!cur.generalizationNode) {
|
||||
cur.generalizationNode = new MindMapNode({
|
||||
data: {
|
||||
inserting: item.inserting,
|
||||
data: item
|
||||
},
|
||||
uid: createUid(),
|
||||
renderer: this.renderer,
|
||||
mindMap: this.mindMap,
|
||||
isGeneralization: true
|
||||
})
|
||||
}
|
||||
delete item.inserting
|
||||
// 关联所属节点
|
||||
cur.generalizationNode.generalizationBelongNode = this
|
||||
// 大小
|
||||
if (cur.generalizationNode.width > maxWidth)
|
||||
maxWidth = cur.generalizationNode.width
|
||||
if (cur.generalizationNode.height > maxHeight)
|
||||
maxHeight = cur.generalizationNode.height
|
||||
// 如果该概要为激活状态,那么加入激活节点列表
|
||||
if (item.isActive) {
|
||||
this.renderer.addNodeToActiveList(cur.generalizationNode)
|
||||
}
|
||||
})
|
||||
this._generalizationNodeWidth = maxWidth
|
||||
this._generalizationNodeHeight = maxHeight
|
||||
}
|
||||
|
||||
// 更新概要节点
|
||||
function updateGeneralization() {
|
||||
if (this.isGeneralization) return
|
||||
this.removeGeneralization()
|
||||
this.createGeneralizationNode()
|
||||
}
|
||||
|
||||
// 渲染概要节点
|
||||
function renderGeneralization(forceRender) {
|
||||
if (this.isGeneralization) return
|
||||
this.updateGeneralizationData()
|
||||
const list = this.formatGetGeneralization()
|
||||
if (list.length <= 0 || this.getData('expand') === false) {
|
||||
this.removeGeneralization()
|
||||
return
|
||||
}
|
||||
if (list.length !== this._generalizationList.length) {
|
||||
this.removeGeneralization()
|
||||
}
|
||||
this.createGeneralizationNode()
|
||||
this.renderer.layout.renderGeneralization(this._generalizationList)
|
||||
this._generalizationList.forEach(item => {
|
||||
this.style.generalizationLine(item.generalizationLine)
|
||||
item.generalizationNode.render(() => {}, forceRender)
|
||||
})
|
||||
}
|
||||
|
||||
// 更新节点概要数据
|
||||
function updateGeneralizationData() {
|
||||
const childrenLength = this.nodeData.children.length
|
||||
const list = this.formatGetGeneralization()
|
||||
const newList = []
|
||||
list.forEach(item => {
|
||||
if (!item.range) {
|
||||
newList.push(item)
|
||||
return
|
||||
}
|
||||
if (
|
||||
item.range.length > 0 &&
|
||||
item.range[0] <= childrenLength - 1 &&
|
||||
item.range[1] <= childrenLength - 1
|
||||
) {
|
||||
newList.push(item)
|
||||
}
|
||||
})
|
||||
if (newList.length !== list.length) {
|
||||
this.setData({
|
||||
generalization: newList
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 删除概要节点
|
||||
function removeGeneralization() {
|
||||
if (this.isGeneralization) return
|
||||
this._generalizationList.forEach(item => {
|
||||
item.generalizationNode.style.onRemove()
|
||||
if (item.generalizationLine) {
|
||||
item.generalizationLine.remove()
|
||||
item.generalizationLine = null
|
||||
}
|
||||
if (item.generalizationNode) {
|
||||
// 删除概要节点时要同步从激活节点里删除
|
||||
this.renderer.removeNodeFromActiveList(item.generalizationNode)
|
||||
item.generalizationNode.remove()
|
||||
item.generalizationNode = null
|
||||
}
|
||||
})
|
||||
this._generalizationList = []
|
||||
// hack修复当激活一个节点时创建概要,然后立即激活创建的概要节点后会重复创建概要节点并且无法删除的问题
|
||||
if (this.generalizationBelongNode) {
|
||||
this.nodeDraw
|
||||
.find('.generalization_' + this.generalizationBelongNode.uid)
|
||||
.remove()
|
||||
}
|
||||
}
|
||||
|
||||
// 隐藏概要节点
|
||||
function hideGeneralization() {
|
||||
if (this.isGeneralization) return
|
||||
this._generalizationList.forEach(item => {
|
||||
if (item.generalizationLine) item.generalizationLine.hide()
|
||||
if (item.generalizationNode) item.generalizationNode.hide()
|
||||
})
|
||||
}
|
||||
|
||||
// 显示概要节点
|
||||
function showGeneralization() {
|
||||
if (this.isGeneralization) return
|
||||
this._generalizationList.forEach(item => {
|
||||
if (item.generalizationLine) item.generalizationLine.show()
|
||||
if (item.generalizationNode) item.generalizationNode.show()
|
||||
})
|
||||
}
|
||||
|
||||
// 设置概要节点的透明度
|
||||
function setGeneralizationOpacity(val) {
|
||||
this._generalizationList.forEach(item => {
|
||||
item.generalizationLine.opacity(val)
|
||||
item.generalizationNode.group.opacity(val)
|
||||
})
|
||||
}
|
||||
|
||||
// 处理概要节点鼠标移入事件
|
||||
function handleGeneralizationMouseenter() {
|
||||
const belongNode = this.generalizationBelongNode
|
||||
const list = belongNode.formatGetGeneralization()
|
||||
const index = belongNode.getGeneralizationNodeIndex(this)
|
||||
const generalizationData = list[index]
|
||||
// 如果主题中设置了hoverRectColor颜色,那么使用该颜色
|
||||
// 否则使用hoverRectColor实例化选项的颜色
|
||||
// 兜底使用highlightNode方法的默认颜色
|
||||
const hoverRectColor = this.getStyle('hoverRectColor')
|
||||
const color = hoverRectColor || this.mindMap.opt.hoverRectColor
|
||||
const style = color
|
||||
? {
|
||||
stroke: color
|
||||
}
|
||||
: null
|
||||
// 区间概要,框子节点
|
||||
if (
|
||||
Array.isArray(generalizationData.range) &&
|
||||
generalizationData.range.length > 0
|
||||
) {
|
||||
this.mindMap.renderer.highlightNode(
|
||||
belongNode,
|
||||
generalizationData.range,
|
||||
style
|
||||
)
|
||||
} else {
|
||||
// 否则框自己
|
||||
this.mindMap.renderer.highlightNode(belongNode, null, style)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理概要节点鼠标移出事件
|
||||
function handleGeneralizationMouseleave() {
|
||||
this.mindMap.renderer.closeHighlightNode()
|
||||
}
|
||||
|
||||
export default {
|
||||
formatGetGeneralization,
|
||||
checkHasGeneralization,
|
||||
checkHasSelfGeneralization,
|
||||
getGeneralizationNodeIndex,
|
||||
createGeneralizationNode,
|
||||
updateGeneralization,
|
||||
updateGeneralizationData,
|
||||
renderGeneralization,
|
||||
removeGeneralization,
|
||||
hideGeneralization,
|
||||
showGeneralization,
|
||||
setGeneralizationOpacity,
|
||||
handleGeneralizationMouseenter,
|
||||
handleGeneralizationMouseleave
|
||||
}
|
|
@ -1,153 +0,0 @@
|
|||
import { Rect } from '@svgdotjs/svg.js'
|
||||
|
||||
// 初始化拖拽
|
||||
function initDragHandle() {
|
||||
if (!this.checkEnableDragModifyNodeWidth()) {
|
||||
return
|
||||
}
|
||||
// 拖拽手柄元素
|
||||
this._dragHandleNodes = null
|
||||
// 手柄元素的宽度
|
||||
this.dragHandleWidth = 4
|
||||
// 鼠标按下时的x坐标
|
||||
this.dragHandleMousedownX = 0
|
||||
// 鼠标是否处于按下状态
|
||||
this.isDragHandleMousedown = false
|
||||
// 当前拖拽的手柄序号
|
||||
this.dragHandleIndex = 0
|
||||
// 鼠标按下时记录当前的customTextWidth值
|
||||
this.dragHandleMousedownCustomTextWidth = 0
|
||||
// 鼠标按下时记录当前的手型样式
|
||||
this.dragHandleMousedownBodyCursor = ''
|
||||
// 鼠标按下时记录当前节点的left值
|
||||
this.dragHandleMousedownLeft = 0
|
||||
|
||||
this.onDragMousemoveHandle = this.onDragMousemoveHandle.bind(this)
|
||||
window.addEventListener('mousemove', this.onDragMousemoveHandle)
|
||||
this.onDragMouseupHandle = this.onDragMouseupHandle.bind(this)
|
||||
window.addEventListener('mouseup', this.onDragMouseupHandle)
|
||||
this.mindMap.on('node_mouseup', this.onDragMouseupHandle)
|
||||
}
|
||||
|
||||
// 鼠标移动事件
|
||||
function onDragMousemoveHandle(e) {
|
||||
if (!this.isDragHandleMousedown) return
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
let {
|
||||
minNodeTextModifyWidth,
|
||||
maxNodeTextModifyWidth,
|
||||
isUseCustomNodeContent,
|
||||
customCreateNodeContent
|
||||
} = this.mindMap.opt
|
||||
const useCustomContent =
|
||||
isUseCustomNodeContent && customCreateNodeContent && this._customNodeContent
|
||||
document.body.style.cursor = 'ew-resize'
|
||||
this.group.css({
|
||||
cursor: 'ew-resize'
|
||||
})
|
||||
const { scaleX } = this.mindMap.draw.transform()
|
||||
const ox = e.clientX - this.dragHandleMousedownX
|
||||
let newWidth =
|
||||
this.dragHandleMousedownCustomTextWidth +
|
||||
(this.dragHandleIndex === 0 ? -ox : ox) / scaleX
|
||||
newWidth = Math.max(newWidth, minNodeTextModifyWidth)
|
||||
if (maxNodeTextModifyWidth !== -1) {
|
||||
newWidth = Math.min(newWidth, maxNodeTextModifyWidth)
|
||||
}
|
||||
// 如果存在图片,那么最小值需要考虑图片宽度
|
||||
if (!useCustomContent && this.getData('image')) {
|
||||
const imgSize = this.getImgShowSize()
|
||||
if (
|
||||
this._rectInfo.textContentWidth - this.customTextWidth + newWidth <=
|
||||
imgSize[0]
|
||||
) {
|
||||
newWidth =
|
||||
imgSize[0] + this.customTextWidth - this._rectInfo.textContentWidth
|
||||
}
|
||||
}
|
||||
this.customTextWidth = newWidth
|
||||
if (this.dragHandleIndex === 0) {
|
||||
this.left = this.dragHandleMousedownLeft + ox / scaleX
|
||||
}
|
||||
// 自定义内容不重新渲染,交给开发者
|
||||
this.reRender(useCustomContent ? [] : ['text'], {
|
||||
ignoreUpdateCustomTextWidth: true
|
||||
})
|
||||
}
|
||||
|
||||
// 鼠标松开事件
|
||||
function onDragMouseupHandle() {
|
||||
if (!this.isDragHandleMousedown) return
|
||||
document.body.style.cursor = this.dragHandleMousedownBodyCursor
|
||||
this.group.css({
|
||||
cursor: 'default'
|
||||
})
|
||||
this.isDragHandleMousedown = false
|
||||
this.dragHandleMousedownX = 0
|
||||
this.dragHandleIndex = 0
|
||||
this.dragHandleMousedownCustomTextWidth = 0
|
||||
this.setData({
|
||||
customTextWidth: this.customTextWidth
|
||||
})
|
||||
this.mindMap.render()
|
||||
this.mindMap.emit('dragModifyNodeWidthEnd', this)
|
||||
}
|
||||
|
||||
// 插件拖拽手柄元素
|
||||
function createDragHandleNode() {
|
||||
const list = [new Rect(), new Rect()]
|
||||
list.forEach((node, index) => {
|
||||
node
|
||||
.size(this.dragHandleWidth, this.height)
|
||||
.fill({
|
||||
color: 'transparent'
|
||||
})
|
||||
.css({
|
||||
cursor: 'ew-resize'
|
||||
})
|
||||
node.on('mousedown', e => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
this.dragHandleMousedownX = e.clientX
|
||||
this.dragHandleIndex = index
|
||||
this.dragHandleMousedownCustomTextWidth =
|
||||
this.customTextWidth === undefined
|
||||
? this._textData
|
||||
? this._textData.width
|
||||
: this.width
|
||||
: this.customTextWidth
|
||||
this.dragHandleMousedownBodyCursor = document.body.style.cursor
|
||||
this.dragHandleMousedownLeft = this.left
|
||||
this.isDragHandleMousedown = true
|
||||
})
|
||||
})
|
||||
return list
|
||||
}
|
||||
|
||||
// 更新拖拽按钮的显隐和位置尺寸
|
||||
function updateDragHandle() {
|
||||
if (!this.checkEnableDragModifyNodeWidth()) return
|
||||
if (!this._dragHandleNodes) {
|
||||
this._dragHandleNodes = this.createDragHandleNode()
|
||||
}
|
||||
if (this.getData('isActive')) {
|
||||
this._dragHandleNodes.forEach(node => {
|
||||
node.height(this.height)
|
||||
this.group.add(node)
|
||||
})
|
||||
this._dragHandleNodes[1].x(this.width - this.dragHandleWidth)
|
||||
} else {
|
||||
this._dragHandleNodes.forEach(node => {
|
||||
node.remove()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
initDragHandle,
|
||||
onDragMousemoveHandle,
|
||||
onDragMouseupHandle,
|
||||
createDragHandleNode,
|
||||
updateDragHandle
|
||||
}
|
|
@ -1,506 +0,0 @@
|
|||
import { CONSTANTS } from '../../constants/constant'
|
||||
|
||||
// 视图操作类
|
||||
interface ViewOptions {
|
||||
mindMap: any; // Replace 'any' with actual MindMap type
|
||||
viewData?: any; // Replace 'any' with actual ViewData type
|
||||
}
|
||||
|
||||
interface TransformData {
|
||||
transform: any; // Replace 'any' with actual Transform type
|
||||
state: {
|
||||
scale: number;
|
||||
x: number;
|
||||
y: number;
|
||||
sx: number;
|
||||
sy: number;
|
||||
}
|
||||
}
|
||||
|
||||
class View {
|
||||
private opt: ViewOptions;
|
||||
private mindMap: any; // Replace 'any' with actual MindMap type
|
||||
private scale: number;
|
||||
private sx: number;
|
||||
private sy: number;
|
||||
private x: number;
|
||||
private y: number;
|
||||
private firstDrag: boolean;
|
||||
|
||||
constructor(opt: ViewOptions = {} as ViewOptions) {
|
||||
this.opt = opt;
|
||||
this.mindMap = this.opt.mindMap;
|
||||
this.scale = 1;
|
||||
this.sx = 0;
|
||||
this.sy = 0;
|
||||
this.x = 0;
|
||||
this.y = 0;
|
||||
this.firstDrag = true;
|
||||
this.setTransformData(this.mindMap.opt.viewData);
|
||||
this.bind();
|
||||
}
|
||||
|
||||
private bind(): void {
|
||||
// Keyboard shortcuts
|
||||
this.mindMap.keyCommand.addShortcut('Control+=', (): void => {
|
||||
this.enlarge();
|
||||
});
|
||||
this.mindMap.keyCommand.addShortcut('Control+-', (): void => {
|
||||
this.narrow();
|
||||
});
|
||||
this.mindMap.keyCommand.addShortcut('Control+i', (): void => {
|
||||
this.fit();
|
||||
});
|
||||
|
||||
// View dragging
|
||||
this.mindMap.event.on('mousedown', (e: MouseEvent): void => {
|
||||
const { isDisableDrag, mousedownEventPreventDefault } = this.mindMap.opt;
|
||||
if (isDisableDrag) return;
|
||||
if (mousedownEventPreventDefault) {
|
||||
e.preventDefault();
|
||||
}
|
||||
this.sx = this.x;
|
||||
this.sy = this.y;
|
||||
});
|
||||
|
||||
this.mindMap.event.on('drag', (e: MouseEvent, event: { mousemoveOffset: { x: number; y: number } }): void => {
|
||||
if (e.ctrlKey || e.metaKey || this.mindMap.opt.isDisableDrag) {
|
||||
return;
|
||||
}
|
||||
if (this.firstDrag) {
|
||||
this.firstDrag = false;
|
||||
if (this.mindMap.renderer.activeNodeList.length > 0) {
|
||||
this.mindMap.execCommand('CLEAR_ACTIVE_NODE');
|
||||
}
|
||||
}
|
||||
this.x = this.sx + event.mousemoveOffset.x;
|
||||
this.y = this.sy + event.mousemoveOffset.y;
|
||||
this.transform();
|
||||
});
|
||||
|
||||
this.mindMap.event.on('mouseup', (): void => {
|
||||
this.firstDrag = true;
|
||||
});
|
||||
|
||||
// View zooming
|
||||
this.mindMap.event.on('mousewheel', (e: WheelEvent, dirs: string[], event: Event, isTouchPad: boolean): void => {
|
||||
const {
|
||||
customHandleMousewheel,
|
||||
mousewheelAction,
|
||||
mouseScaleCenterUseMousePosition,
|
||||
mousewheelMoveStep,
|
||||
mousewheelZoomActionReverse,
|
||||
disableMouseWheelZoom,
|
||||
translateRatio
|
||||
} = this.mindMap.opt;
|
||||
|
||||
if (customHandleMousewheel && typeof customHandleMousewheel === 'function') {
|
||||
return customHandleMousewheel(e);
|
||||
}
|
||||
|
||||
if (mousewheelAction === CONSTANTS.MOUSE_WHEEL_ACTION.ZOOM || e.ctrlKey || e.metaKey) {
|
||||
if (disableMouseWheelZoom) return;
|
||||
|
||||
const { x: clientX, y: clientY } = this.mindMap.toPos(e.clientX, e.clientY);
|
||||
const cx = mouseScaleCenterUseMousePosition ? clientX : undefined;
|
||||
const cy = mouseScaleCenterUseMousePosition ? clientY : undefined;
|
||||
|
||||
if (isTouchPad && (dirs.includes(CONSTANTS.DIR.LEFT) || dirs.includes(CONSTANTS.DIR.RIGHT))) {
|
||||
dirs = dirs.filter(dir => ![CONSTANTS.DIR.LEFT, CONSTANTS.DIR.RIGHT].includes(dir));
|
||||
}
|
||||
|
||||
switch (true) {
|
||||
case dirs.includes(CONSTANTS.DIR.UP || CONSTANTS.DIR.LEFT):
|
||||
mousewheelZoomActionReverse
|
||||
? this.enlarge(cx, cy, isTouchPad)
|
||||
: this.narrow(cx, cy, isTouchPad);
|
||||
break;
|
||||
case dirs.includes(CONSTANTS.DIR.DOWN || CONSTANTS.DIR.RIGHT):
|
||||
mousewheelZoomActionReverse
|
||||
? this.narrow(cx, cy, isTouchPad)
|
||||
: this.enlarge(cx, cy, isTouchPad);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
let stepX = 0;
|
||||
let stepY = 0;
|
||||
|
||||
if (isTouchPad) {
|
||||
stepX = Math.abs((e as any).wheelDeltaX);
|
||||
stepY = Math.abs((e as any).wheelDeltaY);
|
||||
} else {
|
||||
stepX = stepY = mousewheelMoveStep;
|
||||
}
|
||||
|
||||
let mx = 0;
|
||||
let my = 0;
|
||||
|
||||
if (dirs.includes(CONSTANTS.DIR.DOWN)) my = -stepY;
|
||||
if (dirs.includes(CONSTANTS.DIR.UP)) my = stepY;
|
||||
if (dirs.includes(CONSTANTS.DIR.LEFT)) mx = stepX;
|
||||
if (dirs.includes(CONSTANTS.DIR.RIGHT)) mx = -stepX;
|
||||
|
||||
this.translateXY(mx * translateRatio, my * translateRatio);
|
||||
}
|
||||
});
|
||||
|
||||
this.mindMap.on('resize', (): void => {
|
||||
if (!this.checkNeedMindMapInCanvas()) return;
|
||||
this.transform();
|
||||
});
|
||||
}
|
||||
|
||||
public getTransformData(): TransformData {
|
||||
return {
|
||||
transform: this.mindMap.draw.transform(),
|
||||
state: {
|
||||
scale: this.scale,
|
||||
x: this.x,
|
||||
y: this.y,
|
||||
sx: this.sx,
|
||||
sy: this.sy
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 动态设置变换状态数据
|
||||
*/
|
||||
setTransformData(viewData: {
|
||||
state: Record<string, any>;
|
||||
transform: Record<string, any>;
|
||||
}): void {
|
||||
if (viewData) {
|
||||
Object.keys(viewData.state).forEach((prop: string) => {
|
||||
(this as any)[prop] = viewData.state[prop];
|
||||
});
|
||||
this.mindMap.draw.transform({
|
||||
...viewData.transform
|
||||
});
|
||||
this.mindMap.emit('view_data_change', this.getTransformData());
|
||||
this.emitEvent('scale');
|
||||
this.emitEvent('translate');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 平移x,y方向
|
||||
*/
|
||||
translateXY(x: number, y: number): void {
|
||||
if (x === 0 && y === 0) return;
|
||||
this.x += x;
|
||||
this.y += y;
|
||||
this.transform();
|
||||
this.emitEvent('translate');
|
||||
}
|
||||
|
||||
/**
|
||||
* 平移x方向
|
||||
*/
|
||||
translateX(step: number): void {
|
||||
if (step === 0) return;
|
||||
this.x += step;
|
||||
this.transform();
|
||||
this.emitEvent('translate');
|
||||
}
|
||||
|
||||
/**
|
||||
* 平移x方向到
|
||||
*/
|
||||
translateXTo(x: number): void {
|
||||
this.x = x;
|
||||
this.transform();
|
||||
this.emitEvent('translate');
|
||||
}
|
||||
|
||||
/**
|
||||
* 平移y方向
|
||||
*/
|
||||
translateY(step: number): void {
|
||||
if (step === 0) return;
|
||||
this.y += step;
|
||||
this.transform();
|
||||
this.emitEvent('translate');
|
||||
}
|
||||
|
||||
/**
|
||||
* 平移y方向到
|
||||
*/
|
||||
translateYTo(y: number): void {
|
||||
this.y = y;
|
||||
this.transform();
|
||||
this.emitEvent('translate');
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用变换
|
||||
*/
|
||||
transform(): void {
|
||||
try {
|
||||
this.limitMindMapInCanvas();
|
||||
} catch (error) { }
|
||||
this.mindMap.draw.transform({
|
||||
origin: [0, 0],
|
||||
scale: this.scale,
|
||||
translate: [this.x, this.y]
|
||||
});
|
||||
this.mindMap.emit('view_data_change', this.getTransformData());
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复
|
||||
*/
|
||||
reset(): void {
|
||||
const scaleChange: boolean = this.scale !== 1;
|
||||
const translateChange: boolean = this.x !== 0 || this.y !== 0;
|
||||
this.scale = 1;
|
||||
this.x = 0;
|
||||
this.y = 0;
|
||||
this.transform();
|
||||
if (scaleChange) {
|
||||
this.emitEvent('scale');
|
||||
}
|
||||
if (translateChange) {
|
||||
this.emitEvent('translate');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow/zoom out the view
|
||||
* @param cx - Center x coordinate
|
||||
* @param cy - Center y coordinate
|
||||
* @param isTouchPad - Whether triggered by touchpad
|
||||
*/
|
||||
narrow(cx?: number, cy?: number, isTouchPad?: boolean): void {
|
||||
let { scaleRatio, minZoomRatio } = this.mindMap.opt;
|
||||
scaleRatio = scaleRatio / (isTouchPad ? 5 : 1);
|
||||
const scale = Math.max(this.scale - scaleRatio, minZoomRatio / 100);
|
||||
this.scaleInCenter(scale, cx, cy);
|
||||
this.transform();
|
||||
this.emitEvent('scale');
|
||||
}
|
||||
|
||||
/**
|
||||
* Enlarge/zoom in the view
|
||||
* @param cx - Center x coordinate
|
||||
* @param cy - Center y coordinate
|
||||
* @param isTouchPad - Whether triggered by touchpad
|
||||
*/
|
||||
enlarge(cx?: number, cy?: number, isTouchPad?: boolean): void {
|
||||
let { scaleRatio, maxZoomRatio } = this.mindMap.opt;
|
||||
scaleRatio = scaleRatio / (isTouchPad ? 5 : 1);
|
||||
let scale = 0;
|
||||
if (maxZoomRatio === -1) {
|
||||
scale = this.scale + scaleRatio;
|
||||
} else {
|
||||
scale = Math.min(this.scale + scaleRatio, maxZoomRatio / 100);
|
||||
}
|
||||
this.scaleInCenter(scale, cx, cy);
|
||||
this.transform();
|
||||
this.emitEvent('scale');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scale view based on specified center point
|
||||
* @param scale - Scale ratio
|
||||
* @param cx - Center x coordinate
|
||||
* @param cy - Center y coordinate
|
||||
*/
|
||||
scaleInCenter(scale: number, cx?: number, cy?: number): void {
|
||||
if (cx === undefined || cy === undefined) {
|
||||
cx = this.mindMap.width / 2;
|
||||
cy = this.mindMap.height / 2;
|
||||
}
|
||||
const prevScale = this.scale;
|
||||
const ratio = 1 - scale / prevScale;
|
||||
const dx = (cx - this.x) * ratio;
|
||||
const dy = (cy - this.y) * ratio;
|
||||
this.x += dx;
|
||||
this.y += dy;
|
||||
this.scale = scale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set scale directly
|
||||
* @param scale - Scale ratio
|
||||
* @param cx - Center x coordinate
|
||||
* @param cy - Center y coordinate
|
||||
*/
|
||||
setScale(scale: number, cx?: number, cy?: number): void {
|
||||
if (cx !== undefined && cy !== undefined) {
|
||||
this.scaleInCenter(scale, cx, cy);
|
||||
} else {
|
||||
this.scale = scale;
|
||||
}
|
||||
this.transform();
|
||||
this.emitEvent('scale');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fit view to canvas size
|
||||
* @param getRbox - Function to get bounding box
|
||||
* @param enlarge - Whether to allow enlarging
|
||||
* @param fitPadding - Padding for fit calculation
|
||||
*/
|
||||
fit(getRbox: () => any = () => { }, enlarge: boolean = false, fitPadding?: number): void {
|
||||
fitPadding =
|
||||
fitPadding === undefined ? this.mindMap.opt.fitPadding : fitPadding;
|
||||
const draw = this.mindMap.draw;
|
||||
const origTransform = draw.transform();
|
||||
const rect = getRbox() || draw.rbox();
|
||||
const drawWidth = rect.width / origTransform.scaleX;
|
||||
const drawHeight = rect.height / origTransform.scaleY;
|
||||
const drawRatio = drawWidth / drawHeight;
|
||||
let { width: elWidth, height: elHeight } = this.mindMap.elRect;
|
||||
elWidth = elWidth - fitPadding * 2;
|
||||
elHeight = elHeight - fitPadding * 2;
|
||||
const elRatio = elWidth / elHeight;
|
||||
let newScale = 0;
|
||||
let flag: string | number = '';
|
||||
if (drawWidth <= elWidth && drawHeight <= elHeight && !enlarge) {
|
||||
newScale = 1;
|
||||
flag = 1;
|
||||
} else {
|
||||
let newWidth = 0;
|
||||
let newHeight = 0;
|
||||
if (drawRatio > elRatio) {
|
||||
newWidth = elWidth;
|
||||
newHeight = elWidth / drawRatio;
|
||||
flag = 2;
|
||||
} else {
|
||||
newHeight = elHeight;
|
||||
newWidth = elHeight * drawRatio;
|
||||
flag = 3;
|
||||
}
|
||||
newScale = newWidth / drawWidth;
|
||||
}
|
||||
this.setScale(newScale);
|
||||
const newRect = getRbox() || draw.rbox();
|
||||
newRect.x -= this.mindMap.elRect.left;
|
||||
newRect.y -= this.mindMap.elRect.top;
|
||||
let newX = 0;
|
||||
let newY = 0;
|
||||
if (flag === 1) {
|
||||
newX = -newRect.x + fitPadding + (elWidth - newRect.width) / 2;
|
||||
newY = -newRect.y + fitPadding + (elHeight - newRect.height) / 2;
|
||||
} else if (flag === 2) {
|
||||
newX = -newRect.x + fitPadding;
|
||||
newY = -newRect.y + fitPadding + (elHeight - newRect.height) / 2;
|
||||
} else if (flag === 3) {
|
||||
newX = -newRect.x + fitPadding + (elWidth - newRect.width) / 2;
|
||||
newY = -newRect.y + fitPadding;
|
||||
}
|
||||
this.translateXY(newX, newY);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否需要将思维导图限制在画布内
|
||||
*/
|
||||
private checkNeedMindMapInCanvas(): boolean {
|
||||
// 如果当前在演示模式,那么不需要限制
|
||||
if (this.mindMap.demonstrate?.isInDemonstrate) {
|
||||
return false
|
||||
}
|
||||
const { isLimitMindMapInCanvasWhenHasScrollbar, isLimitMindMapInCanvas } =
|
||||
this.mindMap.opt
|
||||
// 如果注册了滚动条插件,那么使用isLimitMindMapInCanvasWhenHasScrollbar配置
|
||||
if (this.mindMap.scrollbar) {
|
||||
return isLimitMindMapInCanvasWhenHasScrollbar
|
||||
} else {
|
||||
// 否则使用isLimitMindMapInCanvas配置
|
||||
return isLimitMindMapInCanvas
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将思维导图限制在画布内
|
||||
*/
|
||||
private limitMindMapInCanvas(): void {
|
||||
if (!this.checkNeedMindMapInCanvas()) return
|
||||
|
||||
let { scale, left, top, right, bottom } = this.getPositionLimit()
|
||||
|
||||
// 画布宽高改变了,但是思维导图元素变换的中心点依旧是原有位置,所以需要加上中心点变化量
|
||||
const centerXChange: number =
|
||||
((this.mindMap.width - this.mindMap.initWidth) / 2) * scale
|
||||
const centerYChange: number =
|
||||
((this.mindMap.height - this.mindMap.initHeight) / 2) * scale
|
||||
|
||||
// 如果缩放值改变了
|
||||
const scaleRatio: number = this.scale / scale
|
||||
left *= scaleRatio
|
||||
right *= scaleRatio
|
||||
top *= scaleRatio
|
||||
bottom *= scaleRatio
|
||||
|
||||
// 加上画布中心点距离
|
||||
const centerX: number = this.mindMap.width / 2
|
||||
const centerY: number = this.mindMap.height / 2
|
||||
const scaleOffset: number = this.scale - 1
|
||||
left -= scaleOffset * centerX - centerXChange
|
||||
right -= scaleOffset * centerX - centerXChange
|
||||
top -= scaleOffset * centerY - centerYChange
|
||||
bottom -= scaleOffset * centerY - centerYChange
|
||||
|
||||
// 判断是否超出边界
|
||||
if (this.x > left) {
|
||||
this.x = left
|
||||
}
|
||||
if (this.x < right) {
|
||||
this.x = right
|
||||
}
|
||||
if (this.y > top) {
|
||||
this.y = top
|
||||
}
|
||||
if (this.y < bottom) {
|
||||
this.y = bottom
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算图形四个方向的位置边界值
|
||||
*/
|
||||
private getPositionLimit(): {
|
||||
scale: number;
|
||||
left: number;
|
||||
right: number;
|
||||
top: number;
|
||||
bottom: number;
|
||||
} {
|
||||
const { scaleX, scaleY } = this.mindMap.draw.transform()
|
||||
const drawRect = this.mindMap.draw.rbox()
|
||||
const rootRect = this.mindMap.renderer.root.group.rbox()
|
||||
const rootCenterOffset = this.mindMap.renderer.layout.getRootCenterOffset(
|
||||
rootRect.width,
|
||||
rootRect.height
|
||||
)
|
||||
const left: number = rootRect.x - drawRect.x - rootCenterOffset.x * scaleX
|
||||
const right: number = rootRect.x - drawRect.x2 - rootCenterOffset.x * scaleX
|
||||
const top: number = rootRect.y - drawRect.y - rootCenterOffset.y * scaleY
|
||||
const bottom: number = rootRect.y - drawRect.y2 - rootCenterOffset.y * scaleY
|
||||
return {
|
||||
scale: scaleX,
|
||||
left,
|
||||
right,
|
||||
top,
|
||||
bottom
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 派发事件
|
||||
*/
|
||||
private emitEvent(type: 'scale' | 'translate'): void {
|
||||
switch (type) {
|
||||
case 'scale':
|
||||
this.mindMap.emit('scale', this.scale)
|
||||
break
|
||||
case 'translate':
|
||||
this.mindMap.emit('translate', this.x, this.y)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default View
|
|
@ -1,56 +0,0 @@
|
|||
import MindMap from './index'
|
||||
import MiniMap from './plugins/MiniMap.js'
|
||||
import Watermark from './plugins/Watermark.js'
|
||||
import KeyboardNavigation from './plugins/KeyboardNavigation.js'
|
||||
import ExportXMind from './plugins/ExportXMind.js'
|
||||
import ExportPDF from './plugins/ExportPDF.js'
|
||||
import Export from './plugins/Export.js'
|
||||
import Drag from './plugins/Drag.js'
|
||||
import Select from './plugins/Select.js'
|
||||
import AssociativeLine from './plugins/AssociativeLine.js'
|
||||
import RichText from './plugins/RichText.js'
|
||||
import NodeImgAdjust from './plugins/NodeImgAdjust.js'
|
||||
import TouchEvent from './plugins/TouchEvent.js'
|
||||
import Search from './plugins/Search.js'
|
||||
import Painter from './plugins/Painter.js'
|
||||
import Scrollbar from './plugins/Scrollbar.js'
|
||||
import Formula from './plugins/Formula.js'
|
||||
import RainbowLines from './plugins/RainbowLines.js'
|
||||
import Demonstrate from './plugins/Demonstrate.js'
|
||||
import OuterFrame from './plugins/OuterFrame.js'
|
||||
import MindMapLayoutPro from './plugins/MindMapLayoutPro.js'
|
||||
import xmind from './parse/xmind.js'
|
||||
import markdown from './parse/markdown.js'
|
||||
import icons from './svg/icons.js'
|
||||
import * as constants from './constants/constant.js'
|
||||
import * as defaultTheme from './theme/default.js'
|
||||
|
||||
MindMap.xmind = xmind
|
||||
MindMap.markdown = markdown
|
||||
MindMap.iconList = icons.nodeIconList
|
||||
MindMap.constants = constants
|
||||
MindMap.defaultTheme = defaultTheme
|
||||
MindMap.version = '0.13.0'
|
||||
|
||||
MindMap.usePlugin(MiniMap)
|
||||
.usePlugin(Watermark)
|
||||
.usePlugin(Drag)
|
||||
.usePlugin(KeyboardNavigation)
|
||||
.usePlugin(ExportXMind)
|
||||
.usePlugin(ExportPDF)
|
||||
.usePlugin(Export)
|
||||
.usePlugin(Select)
|
||||
.usePlugin(AssociativeLine)
|
||||
.usePlugin(RichText)
|
||||
.usePlugin(TouchEvent)
|
||||
.usePlugin(NodeImgAdjust)
|
||||
.usePlugin(Search)
|
||||
.usePlugin(Painter)
|
||||
.usePlugin(Scrollbar)
|
||||
.usePlugin(Formula)
|
||||
.usePlugin(RainbowLines)
|
||||
.usePlugin(Demonstrate)
|
||||
.usePlugin(OuterFrame)
|
||||
.usePlugin(MindMapLayoutPro)
|
||||
|
||||
export default MindMap
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue