01102136
This commit is contained in:
parent
745826636e
commit
78496357f6
|
@ -19,7 +19,7 @@ export class InitService {
|
||||||
private readonly minioService: MinioService,
|
private readonly minioService: MinioService,
|
||||||
private readonly authService: AuthService,
|
private readonly authService: AuthService,
|
||||||
private readonly genDevService: GenDevService,
|
private readonly genDevService: GenDevService,
|
||||||
) {}
|
) { }
|
||||||
private async createRoles() {
|
private async createRoles() {
|
||||||
this.logger.log('Checking existing system roles');
|
this.logger.log('Checking existing system roles');
|
||||||
for (const role of InitRoles) {
|
for (const role of InitRoles) {
|
||||||
|
@ -135,22 +135,32 @@ export class InitService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async init() {
|
async init() {
|
||||||
this.logger.log('Initializing system roles');
|
try {
|
||||||
await this.createRoles();
|
this.logger.log('Starting system initialization');
|
||||||
this.logger.log('Initializing root account');
|
|
||||||
await this.createRoot();
|
await this.createRoles();
|
||||||
this.logger.log('Initializing taxonomies');
|
await this.createRoot();
|
||||||
await this.createOrUpdateTaxonomy();
|
await this.createOrUpdateTaxonomy();
|
||||||
this.logger.log('Initialize minio');
|
await this.initAppConfigs();
|
||||||
await this.createBucket();
|
|
||||||
this.logger.log('Initializing appConfigs');
|
|
||||||
await this.initAppConfigs();
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
try {
|
try {
|
||||||
await this.genDevService.genDataEvent();
|
this.logger.log('Initialize minio');
|
||||||
} catch (err: any) {
|
await this.createBucket();
|
||||||
this.logger.error(err.message);
|
} catch (error) {
|
||||||
|
this.logger.error('Minio initialization failed:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
try {
|
||||||
|
await this.genDevService.genDataEvent();
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Development data generation failed:', error);
|
||||||
|
// Not throwing here as this is development-only
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log('System initialization completed successfully');
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('System initialization failed:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,14 +16,8 @@ export class TasksService implements OnModuleInit {
|
||||||
|
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
this.logger.log('Main node launch');
|
this.logger.log('Main node launch');
|
||||||
try {
|
await this.initService.init();
|
||||||
await this.initService.init();
|
this.logger.log('Initialization successful');
|
||||||
this.logger.log('Initialization successful');
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error('Database not deployed or initialization error', err);
|
|
||||||
// Optionally rethrow the error if you want to halt further execution
|
|
||||||
// throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const cronExpression = process.env.DEADLINE_CRON;
|
const cronExpression = process.env.DEADLINE_CRON;
|
||||||
|
|
|
@ -29,6 +29,7 @@
|
||||||
"@hookform/resolvers": "^3.9.1",
|
"@hookform/resolvers": "^3.9.1",
|
||||||
"@nice/client": "workspace:^",
|
"@nice/client": "workspace:^",
|
||||||
"@nice/common": "workspace:^",
|
"@nice/common": "workspace:^",
|
||||||
|
"@nice/ui": "workspace:^",
|
||||||
"@nice/iconer": "workspace:^",
|
"@nice/iconer": "workspace:^",
|
||||||
"@tanstack/query-async-storage-persister": "^5.51.9",
|
"@tanstack/query-async-storage-persister": "^5.51.9",
|
||||||
"@tanstack/react-query": "^5.51.21",
|
"@tanstack/react-query": "^5.51.21",
|
||||||
|
@ -49,7 +50,6 @@
|
||||||
"hls.js": "^1.5.18",
|
"hls.js": "^1.5.18",
|
||||||
"idb-keyval": "^6.2.1",
|
"idb-keyval": "^6.2.1",
|
||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"plyr-react": "^5.3.0",
|
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-beautiful-dnd": "^13.1.1",
|
"react-beautiful-dnd": "^13.1.1",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
|
@ -65,8 +65,8 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.9.0",
|
"@eslint/js": "^9.9.0",
|
||||||
"@types/react": "^18.2.0",
|
"@types/react": "18.2.38",
|
||||||
"@types/react-dom": "^18.2.0",
|
"@types/react-dom": "18.2.15",
|
||||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"eslint": "^9.9.0",
|
"eslint": "^9.9.0",
|
||||||
|
|
|
@ -1,11 +1,10 @@
|
||||||
|
import { MindMap } from '@nice/ui';
|
||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
import * as tus from 'tus-js-client';
|
import * as tus from 'tus-js-client';
|
||||||
|
|
||||||
interface TusUploadProps {
|
interface TusUploadProps {
|
||||||
onSuccess?: (response: any) => void;
|
onSuccess?: (response: any) => void;
|
||||||
onError?: (error: Error) => void;
|
onError?: (error: Error) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TusUploader: React.FC<TusUploadProps> = ({
|
const TusUploader: React.FC<TusUploadProps> = ({
|
||||||
onSuccess,
|
onSuccess,
|
||||||
onError
|
onError
|
||||||
|
@ -13,23 +12,18 @@ const TusUploader: React.FC<TusUploadProps> = ({
|
||||||
const [progress, setProgress] = useState<number>(0);
|
const [progress, setProgress] = useState<number>(0);
|
||||||
const [isUploading, setIsUploading] = useState<boolean>(false);
|
const [isUploading, setIsUploading] = useState<boolean>(false);
|
||||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleFileUpload = useCallback((file: File) => {
|
const handleFileUpload = useCallback((file: File) => {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
setProgress(0);
|
setProgress(0);
|
||||||
setUploadError(null);
|
setUploadError(null);
|
||||||
|
|
||||||
// Extract file extension
|
// Extract file extension
|
||||||
const extension = file.name.split('.').pop() || '';
|
const extension = file.name.split('.').pop() || '';
|
||||||
|
|
||||||
const upload = new tus.Upload(file, {
|
const upload = new tus.Upload(file, {
|
||||||
endpoint: "http://localhost:3000/upload",
|
endpoint: "http://localhost:3000/upload",
|
||||||
retryDelays: [0, 1000, 3000, 5000],
|
retryDelays: [0, 1000, 3000, 5000],
|
||||||
metadata: {
|
metadata: {
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
// New metadata fields
|
|
||||||
size: file.size.toString(),
|
size: file.size.toString(),
|
||||||
mimeType: file.type,
|
mimeType: file.type,
|
||||||
extension: extension,
|
extension: extension,
|
||||||
|
@ -56,6 +50,9 @@ const TusUploader: React.FC<TusUploadProps> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
<div className=' h-screen'>
|
||||||
|
<MindMap></MindMap>
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
|
|
|
@ -25,8 +25,8 @@ export default function CourseDetailLayout() {
|
||||||
<CourseVideoPage
|
<CourseVideoPage
|
||||||
course={course}
|
course={course}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
videoSrc="https://flipfit-cdn.akamaized.net/flip_hls/664ce52bd6fcda001911a88c-8f1c4d/video_h1.m3u8"
|
videoSrc="http://localhost/uploads/2025/01/08/SvWL48Gjg0/stream/index.m3u8"
|
||||||
videoPoster="https://picsum.photos/800/450"
|
videoPoster="http://localhost/uploads/2025/01/08/SvWL48Gjg0/cover.webp"
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
|
|
@ -21,7 +21,7 @@ export function CourseBasicForm() {
|
||||||
placeholder="请输入课程描述"
|
placeholder="请输入课程描述"
|
||||||
/>
|
/>
|
||||||
<FormSelect name='level' label='难度等级' options={convertToOptions(CourseLevelLabel)}></FormSelect>
|
<FormSelect name='level' label='难度等级' options={convertToOptions(CourseLevelLabel)}></FormSelect>
|
||||||
{/* <FormArrayField inputProps={{ maxLength: 10 }} name='requirements' label='课程要求'></FormArrayField> */}
|
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,16 +1,13 @@
|
||||||
import { useFormContext } from 'react-hook-form';
|
import { useFormContext } from 'react-hook-form';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { CheckIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
import { CheckIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||||
import FormError from './FormError';
|
import FormError from './FormError';
|
||||||
|
|
||||||
export interface FormInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement>, 'type'> {
|
export interface FormInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement>, 'type'> {
|
||||||
name: string;
|
name: string;
|
||||||
label: string;
|
label: string;
|
||||||
type?: 'text' | 'textarea' | 'password' | 'email' | 'number' | 'tel' | 'url' | 'search' | 'date' | 'time' | 'datetime-local';
|
type?: 'text' | 'textarea' | 'password' | 'email' | 'number' | 'tel' | 'url' | 'search' | 'date' | 'time' | 'datetime-local';
|
||||||
rows?: number;
|
rows?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FormInput({
|
export function FormInput({
|
||||||
name,
|
name,
|
||||||
label,
|
label,
|
||||||
|
@ -34,7 +31,6 @@ export function FormInput({
|
||||||
const value = watch(name);
|
const value = watch(name);
|
||||||
const error = errors[name]?.message as string;
|
const error = errors[name]?.message as string;
|
||||||
const isValid = value && !error;
|
const isValid = value && !error;
|
||||||
|
|
||||||
const inputClasses = `
|
const inputClasses = `
|
||||||
w-full rounded-md border bg-white p-2 pr-8 outline-none shadow-sm
|
w-full rounded-md border bg-white p-2 pr-8 outline-none shadow-sm
|
||||||
transition-all duration-300 ease-out placeholder:text-gray-400
|
transition-all duration-300 ease-out placeholder:text-gray-400
|
||||||
|
@ -42,9 +38,7 @@ export function FormInput({
|
||||||
${isFocused ? 'ring-2 ring-opacity-50' : ''}
|
${isFocused ? 'ring-2 ring-opacity-50' : ''}
|
||||||
${className || ''}
|
${className || ''}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const InputElement = type === 'textarea' ? 'textarea' : 'input';
|
const InputElement = type === 'textarea' ? 'textarea' : 'input';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
|
@ -55,7 +49,6 @@ export function FormInput({
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<InputElement
|
<InputElement
|
||||||
{...register(name)}
|
{...register(name)}
|
||||||
|
@ -66,8 +59,8 @@ export function FormInput({
|
||||||
onFocus={() => setIsFocused(true)}
|
onFocus={() => setIsFocused(true)}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
className={inputClasses}
|
className={inputClasses}
|
||||||
|
aria-label={label}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center space-x-1">
|
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center space-x-1">
|
||||||
{value && isFocused && (
|
{value && isFocused && (
|
||||||
<button
|
<button
|
||||||
|
@ -75,6 +68,8 @@ export function FormInput({
|
||||||
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
|
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={() => setValue(name, '')}
|
onClick={() => setValue(name, '')}
|
||||||
|
aria-label={`清除${label}`}
|
||||||
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
<XMarkIcon className="w-4 h-4" />
|
<XMarkIcon className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|
|
@ -1,8 +1,6 @@
|
||||||
import React, { useContext, useEffect, useRef, useState } from "react";
|
import React, { useContext, useEffect, useRef, useState } from "react";
|
||||||
import Hls from "hls.js";
|
import Hls from "hls.js";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
|
||||||
import "plyr/dist/plyr.css";
|
|
||||||
import { VideoPlayerContext } from "./VideoPlayer";
|
import { VideoPlayerContext } from "./VideoPlayer";
|
||||||
export const LoadingOverlay = () => {
|
export const LoadingOverlay = () => {
|
||||||
const { loadingProgress } = useContext(VideoPlayerContext);
|
const { loadingProgress } = useContext(VideoPlayerContext);
|
||||||
|
|
|
@ -10,7 +10,6 @@ import {
|
||||||
ArrowsPointingOutIcon,
|
ArrowsPointingOutIcon,
|
||||||
ArrowsPointingInIcon,
|
ArrowsPointingInIcon,
|
||||||
} from "@heroicons/react/24/solid";
|
} from "@heroicons/react/24/solid";
|
||||||
import "plyr/dist/plyr.css";
|
|
||||||
import { VideoPlayerContext } from "./VideoPlayer";
|
import { VideoPlayerContext } from "./VideoPlayer";
|
||||||
import { formatTime } from "./utlis";
|
import { formatTime } from "./utlis";
|
||||||
import { PlaybackSpeed } from "./type";
|
import { PlaybackSpeed } from "./type";
|
||||||
|
|
|
@ -1,8 +1,6 @@
|
||||||
// VideoPlayer.tsx
|
// VideoPlayer.tsx
|
||||||
import React, { useContext, useEffect, useRef, useState } from "react";
|
import React, { useContext, useEffect, useRef, useState } from "react";
|
||||||
import Hls from "hls.js";
|
import Hls from "hls.js";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
|
||||||
import "plyr/dist/plyr.css";
|
|
||||||
import { VideoPlayerContext } from "./VideoPlayer";
|
import { VideoPlayerContext } from "./VideoPlayer";
|
||||||
|
|
||||||
interface VideoScreenProps {
|
interface VideoScreenProps {
|
||||||
|
|
|
@ -107,37 +107,28 @@
|
||||||
.custom-table .ant-table-tbody > tr:last-child > td {
|
.custom-table .ant-table-tbody > tr:last-child > td {
|
||||||
border-bottom: none; /* 去除最后一行的底部边框 */
|
border-bottom: none; /* 去除最后一行的底部边框 */
|
||||||
}
|
}
|
||||||
/* 自定义 Plyr 样式 */
|
|
||||||
.plyr--full-ui input[type="range"] {
|
|
||||||
color: #ff0000; /* YouTube 红色 */
|
/* .react-flow__node-mindmap {
|
||||||
|
@apply p-2 rounded hover:ring-2 shadow-lg
|
||||||
|
} */
|
||||||
|
.mindmap-node{
|
||||||
|
@apply p-2 rounded shadow-lg
|
||||||
|
}
|
||||||
|
.mindmap-node.selected{
|
||||||
|
@apply ring-2 ring-blue-500
|
||||||
|
}
|
||||||
|
.react-flow__node-mindmap input{
|
||||||
|
@apply outline-none bg-transparent
|
||||||
|
}
|
||||||
|
.react-flow__handle.target {
|
||||||
|
top: 50%;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plyr__control--overlaid {
|
.react-flow__handle.source {
|
||||||
background: rgba(255, 0, 0, 0.8);
|
@apply top-0 left-0 h-full w-full border-none rounded transform-none opacity-0
|
||||||
}
|
}
|
||||||
|
|
||||||
.plyr--video .plyr__control:hover {
|
|
||||||
background: #ff0000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plyr--full-ui input[type="range"]::-webkit-slider-thumb {
|
|
||||||
background: #ff0000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plyr--full-ui input[type="range"]::-moz-range-thumb {
|
|
||||||
background: #ff0000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plyr--full-ui input[type="range"]::-ms-thumb {
|
|
||||||
background: #ff0000;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 缓冲条样式 */
|
|
||||||
.plyr__progress__buffer {
|
|
||||||
color: rgba(255, 255, 255, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 控制栏背景 */
|
|
||||||
.plyr--video .plyr__controls {
|
|
||||||
background: linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.7));
|
|
||||||
}
|
|
||||||
|
|
|
@ -14,8 +14,8 @@
|
||||||
},
|
},
|
||||||
"dependencies": {},
|
"dependencies": {},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "18.2.38",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "18.2.15",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.15.0",
|
"@typescript-eslint/eslint-plugin": "^7.15.0",
|
||||||
"@typescript-eslint/parser": "^7.15.0",
|
"@typescript-eslint/parser": "^7.15.0",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
@ -27,7 +27,7 @@
|
||||||
"vite-plugin-svgr": "^4.2.0",
|
"vite-plugin-svgr": "^4.2.0",
|
||||||
"concurrently": "^8.0.1",
|
"concurrently": "^8.0.1",
|
||||||
"chokidar-cli": "^3.0.0",
|
"chokidar-cli": "^3.0.0",
|
||||||
"react": "^18.3.1",
|
"react": "18.2.0",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "18.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -0,0 +1,38 @@
|
||||||
|
{
|
||||||
|
"name": "@nice/ui",
|
||||||
|
"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": {
|
||||||
|
"@dagrejs/dagre": "^1.1.4",
|
||||||
|
"@nice/utils": "workspace:^",
|
||||||
|
"@xyflow/react": "^12.3.6",
|
||||||
|
"dagre": "^0.8.5",
|
||||||
|
"nanoid": "^5.0.9",
|
||||||
|
"react-hotkeys-hook": "^4.6.1",
|
||||||
|
"zustand": "^5.0.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "18.2.0",
|
||||||
|
"react-dom": "18.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/dagre": "^0.7.52",
|
||||||
|
"@types/node": "^20.3.1",
|
||||||
|
"@types/react": "18.2.38",
|
||||||
|
"@types/react-dom": "18.2.15",
|
||||||
|
"concurrently": "^8.0.0",
|
||||||
|
"rimraf": "^6.0.1",
|
||||||
|
"ts-node": "^10.9.1",
|
||||||
|
"tsup": "^8.3.5",
|
||||||
|
"typescript": "^5.5.4"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,90 @@
|
||||||
|
import { useCallback, useState, useRef, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
ReactFlow,
|
||||||
|
Controls,
|
||||||
|
Background,
|
||||||
|
useReactFlow,
|
||||||
|
Panel,
|
||||||
|
ReactFlowProvider,
|
||||||
|
NodeOrigin,
|
||||||
|
ConnectionLineType,
|
||||||
|
useStoreApi,
|
||||||
|
InternalNode,
|
||||||
|
} from '@xyflow/react';
|
||||||
|
import MindMapNode from './MindMapNode';
|
||||||
|
import useMindMapStore, { RFState } from './store';
|
||||||
|
import { shallow, useShallow } from 'zustand/shallow';
|
||||||
|
import MindMapEdge from './MindMapEdge';
|
||||||
|
import '@xyflow/react/dist/style.css';
|
||||||
|
import { useFlowKeyboardControls } from './hooks/useFlowKeyboardControl';
|
||||||
|
|
||||||
|
const selector = (state: RFState) => ({
|
||||||
|
nodes: state.nodes,
|
||||||
|
edges: state.edges,
|
||||||
|
onNodesChange: state.onNodesChange,
|
||||||
|
onEdgesChange: state.onEdgesChange,
|
||||||
|
addChildNode: state.addChildNode,
|
||||||
|
addSiblingNode: state.addSiblingNode,
|
||||||
|
selectedNodeId: state.selectedNodeId,
|
||||||
|
setSelectedNodeIdId: state.setSelectedNodeId,
|
||||||
|
undo: state.undo,
|
||||||
|
redo: state.redo,
|
||||||
|
canUndo: state.canUndo,
|
||||||
|
canRedo: state.canRedo
|
||||||
|
|
||||||
|
});
|
||||||
|
const nodeOrigin: NodeOrigin = [0.5, 0.5];
|
||||||
|
// 节点类型定义
|
||||||
|
const nodeTypes = {
|
||||||
|
mindmap: MindMapNode,
|
||||||
|
};
|
||||||
|
|
||||||
|
const edgeTypes = {
|
||||||
|
mindmap: MindMapEdge,
|
||||||
|
};
|
||||||
|
const connectionLineStyle = {
|
||||||
|
stroke: '#999',
|
||||||
|
strokeWidth: 2,
|
||||||
|
radius: 20 // Add corner radius for orthogonal lines
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultEdgeOptions = {
|
||||||
|
style: connectionLineStyle,
|
||||||
|
type: 'mindmap',
|
||||||
|
animated: false
|
||||||
|
};
|
||||||
|
export function Flow() {
|
||||||
|
const { nodes, edges, onNodesChange, undo, redo, setSelectedNodeIdId, onEdgesChange, addChildNode, addSiblingNode, selectedNodeId } = useMindMapStore(
|
||||||
|
useShallow(selector)
|
||||||
|
);
|
||||||
|
useFlowKeyboardControls()
|
||||||
|
return (
|
||||||
|
<ReactFlow
|
||||||
|
nodes={nodes}
|
||||||
|
edges={edges}
|
||||||
|
onNodesChange={onNodesChange}
|
||||||
|
onEdgesChange={onEdgesChange}
|
||||||
|
nodeTypes={nodeTypes}
|
||||||
|
edgeTypes={edgeTypes}
|
||||||
|
nodeOrigin={nodeOrigin}
|
||||||
|
connectionLineStyle={connectionLineStyle}
|
||||||
|
defaultEdgeOptions={defaultEdgeOptions}
|
||||||
|
connectionLineType={ConnectionLineType.SmoothStep}
|
||||||
|
fitView
|
||||||
|
panOnDrag={[2]}
|
||||||
|
minZoom={0.2}
|
||||||
|
maxZoom={4}
|
||||||
|
nodesConnectable={false}
|
||||||
|
|
||||||
|
>
|
||||||
|
<Background />
|
||||||
|
<Controls />
|
||||||
|
<Panel position="top-left">React Flow Mind Map</Panel>
|
||||||
|
</ReactFlow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export function MindMap() {
|
||||||
|
return <ReactFlowProvider>
|
||||||
|
<Flow></Flow>
|
||||||
|
</ReactFlowProvider>
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { BaseEdge, EdgeProps, getBezierPath, getStraightPath } from '@xyflow/react';
|
||||||
|
|
||||||
|
function MindMapEdge(props: EdgeProps) {
|
||||||
|
const { sourceX, sourceY, targetX, targetY } = props;
|
||||||
|
|
||||||
|
const [edgePath] = getBezierPath({
|
||||||
|
sourceX,
|
||||||
|
sourceY,
|
||||||
|
targetX,
|
||||||
|
targetY,
|
||||||
|
});
|
||||||
|
|
||||||
|
return <BaseEdge path={edgePath} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MindMapEdge;
|
|
@ -0,0 +1,83 @@
|
||||||
|
import { Handle, NodeProps, Position, useEdges } from '@xyflow/react';
|
||||||
|
import { MindMapNodeType } from './types';
|
||||||
|
import useMindMapStore, { RFState } from './store';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useShallow } from 'zustand/shallow';
|
||||||
|
import { useClickOutside } from '../../hooks/useClickOutside';
|
||||||
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
|
const selector = (state: RFState) => ({
|
||||||
|
selectedNodeId: state.selectedNodeId,
|
||||||
|
editingNodeId: state.editingNodeId,
|
||||||
|
updateNodeLabel: state.updateNodeLabel,
|
||||||
|
setSelectedNodeId: state.setSelectedNodeId,
|
||||||
|
setEditingNodeId: state.setEditingNodeId
|
||||||
|
|
||||||
|
});
|
||||||
|
function MindMapNode({ id, data }: NodeProps<MindMapNodeType>) {
|
||||||
|
const nodeRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [inputValue, setInputValue] = useState(data.label);
|
||||||
|
|
||||||
|
const {
|
||||||
|
updateNodeLabel,
|
||||||
|
selectedNodeId,
|
||||||
|
setSelectedNodeId,
|
||||||
|
setEditingNodeId,
|
||||||
|
editingNodeId
|
||||||
|
} = useMindMapStore(useShallow(selector));
|
||||||
|
useEffect(() => {
|
||||||
|
if (editingNodeId === id) {
|
||||||
|
setEditingNodeId(id);
|
||||||
|
setInputValue(data.label);
|
||||||
|
setTimeout(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
inputRef.current?.select();
|
||||||
|
}, 0);
|
||||||
|
} else {
|
||||||
|
inputRef.current?.blur()
|
||||||
|
}
|
||||||
|
}, [editingNodeId])
|
||||||
|
const handleDoubleClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setEditingNodeId(id)
|
||||||
|
};
|
||||||
|
|
||||||
|
useHotkeys("space", (e) => {
|
||||||
|
if (selectedNodeId === id)
|
||||||
|
setEditingNodeId(id)
|
||||||
|
}, { preventDefault: true });
|
||||||
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
|
setSelectedNodeId(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
useClickOutside(nodeRef, () => {
|
||||||
|
console.log(selectedNodeId, id)
|
||||||
|
if (selectedNodeId === id)
|
||||||
|
setSelectedNodeId(null)
|
||||||
|
if (editingNodeId === id) {
|
||||||
|
setEditingNodeId(null)
|
||||||
|
updateNodeLabel(id, inputValue)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={nodeRef}
|
||||||
|
onDoubleClick={handleDoubleClick}
|
||||||
|
onClick={handleClick}
|
||||||
|
className={`mindmap-node ${id === selectedNodeId ? 'selected' : ''}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
readOnly={id !== editingNodeId}
|
||||||
|
/>
|
||||||
|
<Handle type="target" position={Position.Top} />
|
||||||
|
<Handle type="source" position={Position.Top} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MindMapNode;
|
|
@ -0,0 +1,11 @@
|
||||||
|
import type { Edge, EdgeTypes } from "@xyflow/react";
|
||||||
|
|
||||||
|
export const initialEdges = [
|
||||||
|
{ id: "a->c", source: "a", target: "c", animated: true },
|
||||||
|
{ id: "b->d", source: "b", target: "d" },
|
||||||
|
{ id: "c->d", source: "c", target: "d", animated: true },
|
||||||
|
] satisfies Edge[];
|
||||||
|
|
||||||
|
export const edgeTypes = {
|
||||||
|
// Add your custom edge types here!
|
||||||
|
} satisfies EdgeTypes;
|
|
@ -0,0 +1,145 @@
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
|
import { useStoreApi } from '@xyflow/react';
|
||||||
|
import useMindMapStore, { RFState } from '../store';
|
||||||
|
import { useShallow } from 'zustand/shallow';
|
||||||
|
|
||||||
|
const controlsSelector = (state: RFState) => ({
|
||||||
|
selectedNodeId: state.selectedNodeId,
|
||||||
|
setSelectedNodeId: state.setSelectedNodeId,
|
||||||
|
addChildNode: state.addChildNode,
|
||||||
|
addSiblingNode: state.addSiblingNode,
|
||||||
|
undo: state.undo,
|
||||||
|
redo: state.redo,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function useFlowKeyboardControls() {
|
||||||
|
const {
|
||||||
|
selectedNodeId,
|
||||||
|
setSelectedNodeId,
|
||||||
|
addChildNode,
|
||||||
|
addSiblingNode,
|
||||||
|
undo,
|
||||||
|
redo,
|
||||||
|
} = useMindMapStore(useShallow(controlsSelector));
|
||||||
|
|
||||||
|
const store = useStoreApi();
|
||||||
|
|
||||||
|
const getNextNodeInDirection = useCallback((direction: 'left' | 'right' | 'up' | 'down') => {
|
||||||
|
const { nodeLookup, edges } = store.getState();
|
||||||
|
if (!selectedNodeId) return null;
|
||||||
|
|
||||||
|
const currentNode = nodeLookup.get(selectedNodeId);
|
||||||
|
if (!currentNode) return null;
|
||||||
|
|
||||||
|
// 构建节点关系图
|
||||||
|
const nodeRelations = new Map<string, { parent: string | null; children: string[]; siblings: string[] }>();
|
||||||
|
|
||||||
|
edges.forEach(edge => {
|
||||||
|
const source = edge.source;
|
||||||
|
const target = edge.target;
|
||||||
|
|
||||||
|
if (!nodeRelations.has(source)) {
|
||||||
|
nodeRelations.set(source, { parent: null, children: [], siblings: [] });
|
||||||
|
}
|
||||||
|
if (!nodeRelations.has(target)) {
|
||||||
|
nodeRelations.set(target, { parent: null, children: [], siblings: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
nodeRelations.get(target)!.parent = source;
|
||||||
|
nodeRelations.get(source)!.children.push(target);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 找出同级节点
|
||||||
|
const currentRelation = nodeRelations.get(selectedNodeId);
|
||||||
|
if (currentRelation?.parent) {
|
||||||
|
const parentRelation = nodeRelations.get(currentRelation.parent);
|
||||||
|
if (parentRelation) {
|
||||||
|
currentRelation.siblings = parentRelation.children.filter(id => id !== selectedNodeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据方向决定下一个节点
|
||||||
|
switch (direction) {
|
||||||
|
case 'left': {
|
||||||
|
// 如果当前节点是子节点,优先选择父节点
|
||||||
|
if (currentRelation?.parent) {
|
||||||
|
const parentNode = nodeLookup.get(currentRelation.parent);
|
||||||
|
if (parentNode && parentNode.position.x < currentNode.position.x) {
|
||||||
|
return currentRelation.parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'right': {
|
||||||
|
// 如果有子节点,选择第一个子节点
|
||||||
|
const children = currentRelation?.children || [];
|
||||||
|
if (children.length > 0) {
|
||||||
|
return children[0];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'up': {
|
||||||
|
// 在同级节点中找位置靠上的节点
|
||||||
|
const siblings = currentRelation?.siblings || [];
|
||||||
|
const upperSiblings = siblings
|
||||||
|
.map(id => nodeLookup.get(id))
|
||||||
|
.filter(node => node && node.position.y < currentNode.position.y)
|
||||||
|
.sort((a, b) => b!.position.y - a!.position.y);
|
||||||
|
|
||||||
|
if (upperSiblings.length > 0) {
|
||||||
|
return upperSiblings[0]!.id;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'down': {
|
||||||
|
// 在同级节点中找位置靠下的节点
|
||||||
|
const siblings = currentRelation?.siblings || [];
|
||||||
|
const lowerSiblings = siblings
|
||||||
|
.map(id => nodeLookup.get(id))
|
||||||
|
.filter(node => node && node.position.y > currentNode.position.y)
|
||||||
|
.sort((a, b) => a!.position.y - b!.position.y);
|
||||||
|
|
||||||
|
if (lowerSiblings.length > 0) {
|
||||||
|
return lowerSiblings[0]!.id;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}, [selectedNodeId, store]);
|
||||||
|
|
||||||
|
// Tab 键添加子节点
|
||||||
|
useHotkeys('tab', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (selectedNodeId) addChildNode(selectedNodeId);
|
||||||
|
}, { enableOnFormTags: true, preventDefault: true });
|
||||||
|
|
||||||
|
// Enter 键添加同级节点
|
||||||
|
useHotkeys('enter', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (selectedNodeId) addSiblingNode(selectedNodeId);
|
||||||
|
}, { enableOnFormTags: true, preventDefault: true });
|
||||||
|
|
||||||
|
// 撤销重做
|
||||||
|
// useHotkeys('ctrl+z, cmd+z', (e) => {
|
||||||
|
|
||||||
|
// undo();
|
||||||
|
// }, { enableOnFormTags: false });
|
||||||
|
|
||||||
|
// useHotkeys('ctrl+y, cmd+y', (e) => {
|
||||||
|
|
||||||
|
// redo();
|
||||||
|
// }, { enableOnFormTags: false });
|
||||||
|
|
||||||
|
// 方向键导航
|
||||||
|
const directions = ['left', 'right', 'up', 'down'] as const;
|
||||||
|
directions.forEach(direction => {
|
||||||
|
useHotkeys(direction, (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const nextNodeId = getNextNodeInDirection(direction);
|
||||||
|
if (nextNodeId) setSelectedNodeId(nextNodeId);
|
||||||
|
}, { enableOnFormTags: true });
|
||||||
|
});
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "./MindMap"
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { Edge, Node } from '@xyflow/react';
|
||||||
|
import dagre from 'dagre';
|
||||||
|
|
||||||
|
export const getLayoutedElements = (nodes: Node[], edges: Edge[], direction = 'LR') => {
|
||||||
|
const dagreGraph = new dagre.graphlib.Graph();
|
||||||
|
dagreGraph.setDefaultEdgeLabel(() => ({}));
|
||||||
|
|
||||||
|
const nodeWidth = 200;
|
||||||
|
const nodeHeight = 50;
|
||||||
|
|
||||||
|
dagreGraph.setGraph({
|
||||||
|
rankdir: direction,
|
||||||
|
nodesep: 80,
|
||||||
|
ranksep: 100
|
||||||
|
});
|
||||||
|
|
||||||
|
// 添加节点
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 添加边
|
||||||
|
edges.forEach((edge) => {
|
||||||
|
dagreGraph.setEdge(edge.source, edge.target);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 计算布局
|
||||||
|
dagre.layout(dagreGraph);
|
||||||
|
|
||||||
|
// 获取新的节点位置
|
||||||
|
const layoutedNodes = nodes.map((node) => {
|
||||||
|
const nodeWithPosition = dagreGraph.node(node.id);
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
position: {
|
||||||
|
x: nodeWithPosition.x - nodeWidth / 2,
|
||||||
|
y: nodeWithPosition.y - nodeHeight / 2,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { nodes: layoutedNodes, edges };
|
||||||
|
};
|
|
@ -0,0 +1,234 @@
|
||||||
|
import {
|
||||||
|
Edge,
|
||||||
|
EdgeChange,
|
||||||
|
Node,
|
||||||
|
NodeChange,
|
||||||
|
OnNodesChange,
|
||||||
|
OnEdgesChange,
|
||||||
|
applyNodeChanges,
|
||||||
|
applyEdgeChanges,
|
||||||
|
XYPosition,
|
||||||
|
} from '@xyflow/react';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { HistoryData, HistoryState, NodeLayout, NodeRelationType } from './types';
|
||||||
|
import { getLayoutedElements } from './layout';
|
||||||
|
|
||||||
|
|
||||||
|
const createHistoryState = (initialPresent: HistoryData): HistoryState<HistoryData> => ({
|
||||||
|
past: [],
|
||||||
|
present: initialPresent,
|
||||||
|
future: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const initialNodes: Node[] = [{
|
||||||
|
id: 'root',
|
||||||
|
type: 'mindmap',
|
||||||
|
data: { label: 'React Flow Mind Map' },
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
}];
|
||||||
|
|
||||||
|
export type RFState = {
|
||||||
|
nodes: Node[];
|
||||||
|
edges: Edge[];
|
||||||
|
onNodesChange: OnNodesChange;
|
||||||
|
onEdgesChange: OnEdgesChange;
|
||||||
|
history: HistoryState<HistoryData>;
|
||||||
|
addChildNode: (nodeId: string, position?: XYPosition) => void;
|
||||||
|
updateNodeLabel: (nodeId: string, label: string) => void
|
||||||
|
addSiblingNode: (nodeId: string, position?: XYPosition) => void
|
||||||
|
selectedNodeId: string | null;
|
||||||
|
setSelectedNodeId: (nodeId: string | null) => void;
|
||||||
|
editingNodeId: string | null;
|
||||||
|
setEditingNodeId: (nodeId: string | null) => void;
|
||||||
|
isEditing: boolean;
|
||||||
|
undo: () => void;
|
||||||
|
redo: () => void;
|
||||||
|
canUndo: boolean;
|
||||||
|
canRedo: boolean;
|
||||||
|
};
|
||||||
|
const useMindMapStore = create<RFState>((set, get) => {
|
||||||
|
const updateHistory = (newState: Partial<HistoryData>) => {
|
||||||
|
const currentState = get().history.present;
|
||||||
|
return {
|
||||||
|
past: [...get().history.past, currentState],
|
||||||
|
present: { ...currentState, ...newState },
|
||||||
|
future: [],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const createNewNode = (label: string = 'New Node'): Node => ({
|
||||||
|
id: nanoid(),
|
||||||
|
type: 'mindmap',
|
||||||
|
data: { label },
|
||||||
|
position: { x: 0, y: 0 }
|
||||||
|
});
|
||||||
|
const addNode = (
|
||||||
|
parentId: string,
|
||||||
|
relationType: NodeRelationType
|
||||||
|
) => {
|
||||||
|
const { nodes, edges, editingNodeId } = get();
|
||||||
|
const parentNode = nodes.find(node => node.id === parentId);
|
||||||
|
if (!parentNode) return;
|
||||||
|
const newNode = createNewNode();
|
||||||
|
const newEdge = {
|
||||||
|
id: nanoid(),
|
||||||
|
source: relationType === 'child' ? parentId : edges.find(e => e.target === parentId)?.source ?? parentId,
|
||||||
|
target: newNode.id,
|
||||||
|
type: 'smoothstep',
|
||||||
|
};
|
||||||
|
const newNodes = [...nodes, newNode];
|
||||||
|
const newEdges = [...edges, newEdge];
|
||||||
|
const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements(newNodes, newEdges);
|
||||||
|
set({
|
||||||
|
nodes: layoutedNodes,
|
||||||
|
edges: layoutedEdges,
|
||||||
|
selectedNodeId: newNode.id,
|
||||||
|
history: updateHistory({
|
||||||
|
nodes: layoutedNodes,
|
||||||
|
edges: layoutedEdges,
|
||||||
|
selectedNodeId: newNode.id,
|
||||||
|
editingNodeId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
nodes: initialNodes,
|
||||||
|
edges: [],
|
||||||
|
isEditing: false,
|
||||||
|
history: createHistoryState({ nodes: initialNodes, edges: [], selectedNodeId: null, editingNodeId: null }),
|
||||||
|
editingNodeId: null,
|
||||||
|
setEditingNodeId: (nodeId: string | null) => {
|
||||||
|
const { nodes, edges, selectedNodeId } = get();
|
||||||
|
set({
|
||||||
|
editingNodeId: nodeId,
|
||||||
|
isEditing: Boolean(nodeId),
|
||||||
|
history: {
|
||||||
|
past: [...get().history.past, get().history.present],
|
||||||
|
present: {
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
selectedNodeId,
|
||||||
|
editingNodeId: nodeId
|
||||||
|
},
|
||||||
|
future: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
selectedNodeId: null,
|
||||||
|
setSelectedNodeId: (nodeId: string | null) => {
|
||||||
|
const { nodes, edges, editingNodeId } = get();
|
||||||
|
set({
|
||||||
|
selectedNodeId: nodeId,
|
||||||
|
history: {
|
||||||
|
past: [...get().history.past, get().history.present],
|
||||||
|
present: { nodes, edges, selectedNodeId: nodeId, editingNodeId },
|
||||||
|
future: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
updateNodeLabel: (nodeId: string, label: string) => {
|
||||||
|
const { nodes, edges, selectedNodeId, editingNodeId } = get();
|
||||||
|
const newNodes = nodes.map((node) => {
|
||||||
|
if (node.id === nodeId) {
|
||||||
|
return { ...node, data: { ...node.data, label } };
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
});
|
||||||
|
set({
|
||||||
|
nodes: newNodes,
|
||||||
|
edges,
|
||||||
|
selectedNodeId,
|
||||||
|
history: {
|
||||||
|
past: [...get().history.past, get().history.present],
|
||||||
|
present: { nodes: newNodes, edges, selectedNodeId, editingNodeId },
|
||||||
|
future: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onNodesChange: (changes: NodeChange[]) => {
|
||||||
|
console.log('on node change', changes)
|
||||||
|
const { nodes, edges, selectedNodeId } = get();
|
||||||
|
const newNodes = applyNodeChanges(changes, nodes);
|
||||||
|
set({ nodes: newNodes });
|
||||||
|
},
|
||||||
|
onEdgesChange: (changes: EdgeChange[]) => {
|
||||||
|
const { nodes, edges, selectedNodeId } = get();
|
||||||
|
const newEdges = applyEdgeChanges(changes, edges);
|
||||||
|
|
||||||
|
set({ edges: newEdges });
|
||||||
|
},
|
||||||
|
addChildNode: (nodeId: string) =>
|
||||||
|
addNode(nodeId, 'child'),
|
||||||
|
addSiblingNode: (nodeId: string) =>
|
||||||
|
addNode(nodeId, 'sibling'),
|
||||||
|
undo: () => {
|
||||||
|
const { history } = get();
|
||||||
|
console.log('[Undo] Starting undo operation');
|
||||||
|
|
||||||
|
if (history.past.length === 0) {
|
||||||
|
console.log('[Undo] No past states available, undo skipped');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const previous = history.past[history.past.length - 1];
|
||||||
|
const newPast = history.past.slice(0, -1);
|
||||||
|
const newPresent = { ...history.present };
|
||||||
|
console.log('[Undo] Previous state:', previous);
|
||||||
|
console.log('[Undo] New past length:', newPast.length);
|
||||||
|
|
||||||
|
set({
|
||||||
|
nodes: previous.nodes,
|
||||||
|
edges: previous.edges,
|
||||||
|
selectedNodeId: previous.selectedNodeId,
|
||||||
|
editingNodeId: previous.editingNodeId,
|
||||||
|
history: {
|
||||||
|
past: newPast,
|
||||||
|
present: previous,
|
||||||
|
future: [newPresent, ...history.future],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[Undo] Operation completed');
|
||||||
|
},
|
||||||
|
|
||||||
|
redo: () => {
|
||||||
|
const { history } = get();
|
||||||
|
console.log('[Redo] Starting redo operation');
|
||||||
|
|
||||||
|
if (history.future.length === 0) {
|
||||||
|
console.log('[Redo] No future states available, redo skipped');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = history.future[0];
|
||||||
|
const newFuture = history.future.slice(1);
|
||||||
|
|
||||||
|
console.log('[Redo] Next state:', next);
|
||||||
|
console.log('[Redo] New future length:', newFuture.length);
|
||||||
|
|
||||||
|
set({
|
||||||
|
nodes: next.nodes,
|
||||||
|
edges: next.edges,
|
||||||
|
selectedNodeId: next.selectedNodeId,
|
||||||
|
editingNodeId: next.editingNodeId,
|
||||||
|
history: {
|
||||||
|
past: [...history.past, history.present],
|
||||||
|
present: next,
|
||||||
|
future: newFuture,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[Redo] Operation completed');
|
||||||
|
},
|
||||||
|
get canUndo() {
|
||||||
|
return get().history.past.length > 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
get canRedo() {
|
||||||
|
return get().history.future.length > 0;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
export default useMindMapStore;
|
|
@ -0,0 +1,32 @@
|
||||||
|
import type { Node, NodeTypes, BuiltInNode, Edge } from "@xyflow/react";
|
||||||
|
|
||||||
|
export type MindMapNodeType = Node<
|
||||||
|
{
|
||||||
|
label: string
|
||||||
|
level: number
|
||||||
|
isExpanded?: boolean
|
||||||
|
metadata?: Record<string, number>
|
||||||
|
}, "mindmap">
|
||||||
|
|
||||||
|
export type MindMapEdgeType = Edge<{
|
||||||
|
label: string
|
||||||
|
}, "mindmap">
|
||||||
|
|
||||||
|
export type HistoryState<T> = {
|
||||||
|
past: T[];
|
||||||
|
present: T;
|
||||||
|
future: T[];
|
||||||
|
};
|
||||||
|
export type HistoryData = {
|
||||||
|
nodes: Node[];
|
||||||
|
edges: Edge[];
|
||||||
|
selectedNodeId: string | null;
|
||||||
|
editingNodeId: string | null; // Add this
|
||||||
|
};
|
||||||
|
export type NodeRelationType = 'child' | 'sibling';
|
||||||
|
export type NodeLayout = {
|
||||||
|
horizontalSpacing: number;
|
||||||
|
verticalSpacing: number;
|
||||||
|
nodeWidth: number;
|
||||||
|
nodeHeight: number;
|
||||||
|
};
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { useEffect, RefObject } from 'react';
|
||||||
|
|
||||||
|
export function useClickOutside<T extends HTMLElement>(ref: RefObject<T>, handler: () => void) {
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
console.log(event.target)
|
||||||
|
if (ref.current && !ref.current.contains(event.target as Node)) {
|
||||||
|
handler();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, [ref, handler]);
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "./components/mindmap"
|
|
@ -0,0 +1,43 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2022",
|
||||||
|
"module": "esnext",
|
||||||
|
"allowJs": true,
|
||||||
|
"lib": [
|
||||||
|
"DOM",
|
||||||
|
"es2022",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"removeComments": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noImplicitReturns": false,
|
||||||
|
"noFallthroughCasesInSwitch": false,
|
||||||
|
"noUncheckedIndexedAccess": false,
|
||||||
|
"noImplicitOverride": false,
|
||||||
|
"noPropertyAccessFromIndexSignature": false,
|
||||||
|
|
||||||
|
"outDir": "dist",
|
||||||
|
"incremental": true,
|
||||||
|
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"dist",
|
||||||
|
"**/*.test.ts",
|
||||||
|
"**/*.spec.ts",
|
||||||
|
"**/__tests__"
|
||||||
|
]
|
||||||
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { defineConfig } from 'tsup'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
entry: ['src/index.ts'],
|
||||||
|
format: ['esm', 'cjs'],
|
||||||
|
dts: true,
|
||||||
|
clean: true,
|
||||||
|
sourcemap: true,
|
||||||
|
minify: true,
|
||||||
|
external: ['react', 'react-dom'],
|
||||||
|
bundle: true,
|
||||||
|
target: "esnext"
|
||||||
|
})
|
|
@ -0,0 +1,24 @@
|
||||||
|
{
|
||||||
|
"name": "@nice/utils",
|
||||||
|
"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": {},
|
||||||
|
"peerDependencies": {},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.3.1",
|
||||||
|
"ts-node": "^10.9.1",
|
||||||
|
"typescript": "^5.5.4",
|
||||||
|
"concurrently": "^8.0.0",
|
||||||
|
"tsup": "^8.3.5",
|
||||||
|
"rimraf": "^6.0.1"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
/**
|
||||||
|
* 生成唯一ID
|
||||||
|
* @param prefix - 可选的ID前缀
|
||||||
|
* @returns 唯一ID字符串
|
||||||
|
*/
|
||||||
|
export function generateUniqueId(prefix?: string): string {
|
||||||
|
// 获取当前时间戳
|
||||||
|
const timestamp = Date.now();
|
||||||
|
|
||||||
|
// 生成随机数部分
|
||||||
|
const randomPart = Math.random().toString(36).substring(2, 8);
|
||||||
|
|
||||||
|
// 获取环境特定的额外随机性
|
||||||
|
const environmentPart = typeof window !== 'undefined'
|
||||||
|
? window.crypto.getRandomValues(new Uint32Array(1))[0].toString(36)
|
||||||
|
: require('crypto').randomBytes(4).toString('hex');
|
||||||
|
|
||||||
|
// 组合所有部分
|
||||||
|
const uniquePart = `${timestamp}${randomPart}${environmentPart}`;
|
||||||
|
|
||||||
|
// 如果提供了前缀,则添加前缀
|
||||||
|
return prefix ? `${prefix}_${uniquePart}` : uniquePart;
|
||||||
|
}
|
|
@ -0,0 +1,40 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2022",
|
||||||
|
"module": "esnext",
|
||||||
|
"lib": [
|
||||||
|
"DOM",
|
||||||
|
"es2022"
|
||||||
|
],
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"removeComments": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noImplicitReturns": false,
|
||||||
|
"noFallthroughCasesInSwitch": false,
|
||||||
|
"noUncheckedIndexedAccess": false,
|
||||||
|
"noImplicitOverride": false,
|
||||||
|
"noPropertyAccessFromIndexSignature": false,
|
||||||
|
"emitDeclarationOnly": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"incremental": true,
|
||||||
|
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"dist",
|
||||||
|
"**/*.test.ts",
|
||||||
|
"**/*.spec.ts",
|
||||||
|
"**/__tests__"
|
||||||
|
]
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { defineConfig } from 'tsup';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
entry: ['src/index.ts'],
|
||||||
|
format: ['cjs', 'esm'],
|
||||||
|
splitting: false,
|
||||||
|
sourcemap: true,
|
||||||
|
clean: false,
|
||||||
|
dts: true
|
||||||
|
});
|
4687
pnpm-lock.yaml
4687
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue