This commit is contained in:
ditiqi 2025-02-06 16:47:09 +08:00
commit 0048468c8d
184 changed files with 5047 additions and 29719 deletions

View File

@ -0,0 +1,19 @@
temperature: 0.5
maxTokens: 8192
---
<system>
请扮演一名经验丰富的高级软件开发工程师,根据用户提供的指令创建、改进或扩展代码功能。
输入要求:
1. 用户将提供目标文件名或需要实现的功能描述。
2. 输入中可能包括文件路径、代码风格要求,以及与功能相关的具体业务逻辑或技术细节。
任务描述:
1. 根据提供的文件名或功能需求,编写符合规范的代码文件或代码片段。
2. 如果已有文件,检查并基于现有实现完善功能或修复问题。
3. 遵循约定的开发框架、语言标准和最佳实践。
4. 注重代码可维护性,添加适当的注释,确保逻辑清晰。
输出要求:
1. 仅返回生成的代码或文件内容。
2. 全程使用中文注释
3. 尽量避免硬编码和不必要的复杂性,以提高代码的可重用性和效率。
4. 如功能涉及外部接口或工具调用,请确保通过注释给出清晰的说明和依赖。
</system>

View File

@ -4,11 +4,9 @@ maxTokens: 8192
<system>
角色定位:
- 高级软件开发工程师
- 代码文档化与知识传播专家
注释目标:
1. 顶部注释
- 模块/文件整体功能描述
- 使用场景
2. 类注释
- 核心功能概述
- 设计模式解析
@ -22,12 +20,9 @@ maxTokens: 8192
- 逐行解释代码意图
- 关键语句原理阐述
- 高级语言特性解读
- 潜在的设计考量
注释风格要求:
- 全程使用中文
- 专业、清晰、通俗易懂
- 面向初学者的知识传递
- 保持技术严谨性
输出约束:
- 仅返回添加注释后的代码
- 注释与代码完美融合

View File

@ -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"
}
}
}

View File

@ -24,7 +24,7 @@ function App() {
theme={{
algorithm: theme.defaultAlgorithm,
token: {
colorPrimary: "#2e75b6",
colorPrimary: "#0088E8",
},
components: {},
}}>

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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)}小时`
}));

View File

@ -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>
);
}

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -0,0 +1,3 @@
export default function PathsPage() {
return <>paths</>
}

View File

@ -0,0 +1,7 @@
export default function MyCoursePage() {
return (
<div>
My Course Page
</div>
)
}

View File

@ -0,0 +1,3 @@
export default function ProfilesPage() {
return <>Profiles</>
}

View File

@ -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;

View File

@ -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

View File

@ -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[] };
}

View File

@ -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;
});
}
}

View File

@ -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
);
});
}
}

View File

@ -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);
});
}
}

View File

@ -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);
}

View File

@ -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[] };
}

View File

@ -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>
);
});

View File

@ -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
`;

View File

@ -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
);
},
};
});

View File

@ -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>
);
}

View File

@ -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,
},
];

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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" />;
}

View File

@ -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);

View File

@ -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;
/* 去除最后一行的底部边框 */
}
}

View File

@ -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: [

View File

@ -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: [],
}

2
apps/web/tailwind.config.ts Executable file
View File

@ -0,0 +1,2 @@
import { NiceTailwindConfig } from "@nice/config"
export default NiceTailwindConfig

View File

@ -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"

View File

@ -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[];
};

View File

@ -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"

View File

@ -0,0 +1,7 @@
import { Message, Staff } from "@prisma/client";
export type MessageDto = Message & {
readed: boolean;
receivers: Staff[];
sender: Staff;
};

View File

@ -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[];
};

View File

@ -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;
};

View File

@ -0,0 +1,5 @@
import { Lecture, Section } from "@prisma/client";
export type SectionDto = Section & {
lectures: Lecture[];
};

View File

@ -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;
}

View File

@ -0,0 +1,8 @@
import { Term } from "@prisma/client";
import { ResPerm } from "./rbac";
export type TermDto = Term & {
permissions: ResPerm;
children: TermDto[];
hasChildren: boolean;
};

View File

@ -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[];
};

View File

@ -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-10075
* @param lightness - 0-10065
* @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 }));

View File

@ -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"
}
}

View File

@ -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();
}

View File

@ -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)

View File

@ -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;
}

View File

@ -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
};
}

View File

@ -0,0 +1,6 @@
export * from "./context"
export * from "./types"
export * from "./utils"
export * from "./styles"
export * from "./constants"
export * from "./tailwind"

View File

@ -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
}

View File

@ -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: [],
};

View File

@ -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;
}

View File

@ -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
}, {})
}

View File

@ -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,

View File

@ -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

View File

@ -1,3 +0,0 @@
# 一个web思维导图的简单实现
详细文档见:[https://github.com/wanglin2/mind-map](https://github.com/wanglin2/mind-map)

View File

@ -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 `
)

View File

@ -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)

View File

@ -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": {}
}

View File

@ -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": {}
}

View File

@ -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"
}
}

View File

@ -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)

View File

@ -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'
]

View File

@ -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: '概要',
// 粘贴文本的方式创建新节点时,控制是否按换行自动分割节点,即如果存在换行,那么会根据换行创建多个节点,否则只会创建一个节点
// 可以传递一个函数返回promiseresolve代表根据换行分割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: createOrUpdatedelete
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
}

View File

@ -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

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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

View File

@ -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

View File

@ -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
]

View File

@ -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

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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

View File

@ -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