diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..a2c76e4 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "marscode.chatLanguage": "cn" +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index e36b24a..0a51235 100755 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,21 @@ # 基础镜像 -FROM node:20-alpine as base +FROM node:18.17-alpine as base # 更改 apk 镜像源为阿里云 -RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories +# RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories + +# 使用阿里云镜像源 + 完整仓库声明 +RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories && \ + echo "https://mirrors.aliyun.com/alpine/v3.18/community" >> /etc/apk/repositories + +# 安装最新稳定版 PostgreSQL 客户端(15.11) +RUN apk update --no-cache && \ + apk add --no-cache \ + postgresql15-client \ + libpq \ + readline + + RUN apk add --no-cache ffmpeg + # 设置 npm 镜像源 RUN yarn config set registry https://registry.npmmirror.com @@ -17,24 +31,30 @@ COPY pnpm-workspace.yaml ./ # 首先复制 package.json, package-lock.json 和 pnpm-lock.yaml 文件 COPY package*.json pnpm-lock.yaml* ./ -COPY tsconfig.json . +COPY tsconfig.base.json . # 利用 Docker 缓存机制,如果依赖没有改变则不会重新执行 pnpm install #100-500 5-40 FROM base As server-build WORKDIR /app COPY packages/common /app/packages/common +COPY packages/tus /app/packages/tus COPY apps/server /app/apps/server -RUN pnpm install --filter server +RUN pnpm config set registry https://registry.npmmirror.com/ RUN pnpm install --filter common +RUN pnpm install --filter tus +RUN pnpm install --filter server RUN pnpm --filter common generate && pnpm --filter common build:cjs +RUN pnpm --filter tus build RUN pnpm --filter server build FROM base As server-prod-dep WORKDIR /app COPY packages/common /app/packages/common +COPY packages/tus /app/packages/tus COPY apps/server /app/apps/server RUN pnpm install --filter common --prod +RUN pnpm install --filter tus --prod RUN pnpm install --filter server --prod @@ -43,6 +63,7 @@ FROM server-prod-dep as server WORKDIR /app ENV NODE_ENV production COPY --from=server-build /app/packages/common/dist ./packages/common/dist +COPY --from=server-build /app/packages/tus/dist ./packages/tus/dist COPY --from=server-build /app/apps/server/dist ./apps/server/dist COPY apps/server/entrypoint.sh ./apps/server/entrypoint.sh @@ -85,7 +106,10 @@ EXPOSE 80 CMD ["/usr/bin/entrypoint.sh"] +# 使用 Nginx 的 Alpine 版本作为基础镜像 FROM nginx:stable-alpine as nginx + +# 替换 Alpine 的软件源为阿里云镜像 RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories # 设置工作目录 @@ -94,9 +118,11 @@ WORKDIR /usr/share/nginx/html # 设置环境变量 ENV NODE_ENV production -# 安装 envsubst 以支持环境变量替换 -RUN apk add --no-cache gettext +# 安装 envsubst 和 inotify-tools +RUN apk add --no-cache gettext inotify-tools + +# 创建 /data/uploads 目录 +RUN mkdir -p /data/uploads # 暴露 80 端口 -EXPOSE 80 - +EXPOSE 80 \ No newline at end of file diff --git a/apps/server/package.json b/apps/server/package.json index 566bfb2..6e80ee0 100755 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -110,4 +110,4 @@ "coverageDirectory": "../coverage", "testEnvironment": "node" } -} \ No newline at end of file +} diff --git a/apps/server/src/models/enrollment/enroll.schema.ts b/apps/server/src/models/enrollment/enroll.schema.ts deleted file mode 100755 index 652f2c8..0000000 --- a/apps/server/src/models/enrollment/enroll.schema.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { z } from 'zod'; - -export const EnrollSchema = z.object({ - studentId: z.string(), - postId: z.string(), -}); - -export const UnenrollSchema = z.object({ - studentId: z.string(), - postId: z.string(), -}); diff --git a/apps/server/src/models/enrollment/enrollment.module.ts b/apps/server/src/models/enrollment/enrollment.module.ts deleted file mode 100755 index dd9c554..0000000 --- a/apps/server/src/models/enrollment/enrollment.module.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Module } from '@nestjs/common'; -import { EnrollmentRouter } from './enrollment.router'; -import { EnrollmentService } from './enrollment.service'; - -@Module({ - exports: [EnrollmentRouter, EnrollmentService], - providers: [EnrollmentRouter, EnrollmentService], -}) -export class EnrollmentModule {} diff --git a/apps/server/src/models/enrollment/enrollment.router.ts b/apps/server/src/models/enrollment/enrollment.router.ts deleted file mode 100755 index b14e2f2..0000000 --- a/apps/server/src/models/enrollment/enrollment.router.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { TrpcService } from '@server/trpc/trpc.service'; -import { Prisma, UpdateOrderSchema } from '@nice/common'; -import { EnrollmentService } from './enrollment.service'; -import { z, ZodType } from 'zod'; -import { EnrollSchema, UnenrollSchema } from './enroll.schema'; -const EnrollmentCreateArgsSchema: ZodType = z.any() -const EnrollmentCreateManyInputSchema: ZodType = z.any() -const EnrollmentDeleteManyArgsSchema: ZodType = z.any() -const EnrollmentFindManyArgsSchema: ZodType = z.any() -const EnrollmentFindFirstArgsSchema: ZodType = z.any() -const EnrollmentWhereInputSchema: ZodType = z.any() -const EnrollmentSelectSchema: ZodType = z.any() - -@Injectable() -export class EnrollmentRouter { - constructor( - private readonly trpc: TrpcService, - private readonly enrollmentService: EnrollmentService, - ) { } - router = this.trpc.router({ - findFirst: this.trpc.procedure - .input(EnrollmentFindFirstArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword - .query(async ({ input }) => { - return await this.enrollmentService.findFirst(input); - }), - findMany: this.trpc.procedure - .input(EnrollmentFindManyArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword - .query(async ({ input }) => { - return await this.enrollmentService.findMany(input); - }), - findManyWithCursor: this.trpc.protectProcedure - .input(z.object({ - cursor: z.any().nullish(), - take: z.number().nullish(), - where: EnrollmentWhereInputSchema.nullish(), - select: EnrollmentSelectSchema.nullish() - })) - .query(async ({ ctx, input }) => { - const { staff } = ctx; - return await this.enrollmentService.findManyWithCursor(input); - }), - enroll: this.trpc.protectProcedure - .input(EnrollSchema) - .mutation(async ({ input }) => { - return await this.enrollmentService.enroll(input); - }), - unenroll: this.trpc.protectProcedure - .input(UnenrollSchema) - .mutation(async ({ input }) => { - return await this.enrollmentService.unenroll(input); - }), - }); -} diff --git a/apps/server/src/models/enrollment/enrollment.service.ts b/apps/server/src/models/enrollment/enrollment.service.ts deleted file mode 100755 index 1b6ca8f..0000000 --- a/apps/server/src/models/enrollment/enrollment.service.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { - ConflictException, - Injectable, - NotFoundException, -} from '@nestjs/common'; -import { BaseService } from '../base/base.service'; -import { - UserProfile, - db, - ObjectType, - Prisma, - EnrollmentStatus, -} from '@nice/common'; -import { z } from 'zod'; -import { EnrollSchema, UnenrollSchema } from './enroll.schema'; -import EventBus, { CrudOperation } from '@server/utils/event-bus'; - -@Injectable() -export class EnrollmentService extends BaseService { - constructor() { - super(db, ObjectType.COURSE); - } - async enroll(params: z.infer) { - const { studentId, postId } = params; - const result = await db.$transaction(async (tx) => { - // 检查是否已经报名 - const existing = await tx.enrollment.findUnique({ - where: { - studentId_postId: { - studentId: studentId, - postId: postId, - }, - }, - }); - if (existing) { - throw new ConflictException('Already enrolled in this post'); - } - // 创建报名记录 - const enrollment = await tx.enrollment.create({ - data: { - studentId: studentId, - postId: postId, - status: EnrollmentStatus.ACTIVE, - }, - }); - - return enrollment; - }); - - EventBus.emit('dataChanged', { - type: ObjectType.ENROLLMENT, - operation: CrudOperation.CREATED, - data: result, - }); - return result; - } - async unenroll(params: z.infer) { - const { studentId, postId } = params; - const result = await db.enrollment.update({ - where: { - studentId_postId: { - studentId, - postId, - }, - }, - data: { - status: EnrollmentStatus.CANCELLED, - }, - }); - - EventBus.emit('dataChanged', { - type: ObjectType.ENROLLMENT, - operation: CrudOperation.UPDATED, - data: result, - }); - return result; - } -} diff --git a/apps/server/src/models/post/post.service.ts b/apps/server/src/models/post/post.service.ts index cbcf7bc..0f3e8cd 100755 --- a/apps/server/src/models/post/post.service.ts +++ b/apps/server/src/models/post/post.service.ts @@ -15,11 +15,10 @@ import { import { MessageService } from '../message/message.service'; import { BaseService } from '../base/base.service'; import { DepartmentService } from '../department/department.service'; -import { setCourseInfo, setPostRelation } from './utils'; +import { setPostInfo, setPostRelation } from './utils'; import EventBus, { CrudOperation } from '@server/utils/event-bus'; import { BaseTreeService } from '../base/base.tree.service'; import { z } from 'zod'; -import { DefaultArgs } from '@prisma/client/runtime/library'; import dayjs from 'dayjs'; import { OrderByArgs } from '../base/base.type'; @@ -155,7 +154,7 @@ export class PostService extends BaseTreeService { if (result) { await setPostRelation({ data: result, staff }); await this.setPerms(result, staff); - await setCourseInfo({ data: result }); + await setPostInfo({ data: result }); } // console.log(result); return result; @@ -183,7 +182,7 @@ export class PostService extends BaseTreeService { pageSize?: number; where?: Prisma.PostWhereInput; orderBy?: OrderByArgs<(typeof db.post)['findMany']>; - select?: Prisma.PostSelect; + select?: Prisma.PostSelect; }): Promise<{ items: { id: string; diff --git a/apps/server/src/models/post/utils.ts b/apps/server/src/models/post/utils.ts index 57b0fd0..4dea80c 100755 --- a/apps/server/src/models/post/utils.ts +++ b/apps/server/src/models/post/utils.ts @@ -126,7 +126,7 @@ export async function updateCourseEnrollmentStats(courseId: string) { }); } -export async function setCourseInfo({ data }: { data: Post }) { +export async function setPostInfo({ data }: { data: Post }) { // await db.term if (data?.type === PostType.COURSE) { const ancestries = await db.postAncestry.findMany({ @@ -169,20 +169,36 @@ export async function setCourseInfo({ data }: { data: Post }) { ) as any as Lecture[]; }); - const students = await db.staff.findMany({ + Object.assign(data, { sections, lectureCount }); + } + if (data?.type === PostType.LECTURE || data?.type === PostType.SECTION) { + const ancestry = await db.postAncestry.findFirst({ where: { - learningPosts: { - some: { - id: data.id, - }, + descendantId: data?.id, + ancestor: { + type: PostType.COURSE, }, }, select: { - id: true, + ancestor: { select: { id: true } }, }, }); - - const studentIds = (students || []).map((student) => student?.id); - Object.assign(data, { sections, lectureCount, studentIds }); + const courseId = ancestry.ancestor.id; + Object.assign(data, { courseId }); } + const students = await db.staff.findMany({ + where: { + learningPosts: { + some: { + id: data.id, + }, + }, + }, + select: { + id: true, + }, + }); + + const studentIds = (students || []).map((student) => student?.id); + Object.assign(data, { studentIds }); } diff --git a/apps/server/src/models/resource/pipe/resource.pipeline.ts b/apps/server/src/models/resource/pipe/resource.pipeline.ts index bd3c7fd..b5d1965 100755 --- a/apps/server/src/models/resource/pipe/resource.pipeline.ts +++ b/apps/server/src/models/resource/pipe/resource.pipeline.ts @@ -1,4 +1,4 @@ -import { PrismaClient, Resource } from '@prisma/client'; +import { Resource } from '@nice/common'; import { ProcessResult, ResourceProcessor } from '../types'; import { db, ResourceStatus } from '@nice/common'; import { Logger } from '@nestjs/common'; diff --git a/apps/server/src/models/resource/processor/VideoProcessor.ts b/apps/server/src/models/resource/processor/VideoProcessor.ts index 083bb5f..ef0ecb2 100755 --- a/apps/server/src/models/resource/processor/VideoProcessor.ts +++ b/apps/server/src/models/resource/processor/VideoProcessor.ts @@ -12,6 +12,7 @@ export class VideoProcessor extends BaseProcessor { super(); } async process(resource: Resource): Promise { + console.log('process'); const { url } = resource; const filepath = getUploadFilePath(url); this.logger.log( @@ -114,6 +115,7 @@ export class VideoProcessor extends BaseProcessor { filepath: string, outputDir: string, ): Promise { + console.log('outputDir', outputDir); const m3u8Path = path.join(outputDir, 'index.m3u8'); this.logger.log( `Generating M3U8 stream for video: ${filepath}, Output Dir: ${outputDir}`, diff --git a/apps/server/src/models/resource/resource.router.ts b/apps/server/src/models/resource/resource.router.ts index 6d4290f..12afc82 100755 --- a/apps/server/src/models/resource/resource.router.ts +++ b/apps/server/src/models/resource/resource.router.ts @@ -3,68 +3,76 @@ import { TrpcService } from '@server/trpc/trpc.service'; import { Prisma, UpdateOrderSchema } from '@nice/common'; import { ResourceService } from './resource.service'; import { z, ZodType } from 'zod'; -const ResourceCreateArgsSchema: ZodType = z.any() -const ResourceCreateManyInputSchema: ZodType = z.any() -const ResourceDeleteManyArgsSchema: ZodType = z.any() -const ResourceFindManyArgsSchema: ZodType = z.any() -const ResourceFindFirstArgsSchema: ZodType = z.any() -const ResourceWhereInputSchema: ZodType = z.any() -const ResourceSelectSchema: ZodType = z.any() + +const ResourceCreateArgsSchema: ZodType = z.any(); +const ResourceCreateManyInputSchema: ZodType = + z.any(); +const ResourceDeleteManyArgsSchema: ZodType = + z.any(); +const ResourceFindManyArgsSchema: ZodType = + z.any(); +const ResourceFindFirstArgsSchema: ZodType = + z.any(); +const ResourceWhereInputSchema: ZodType = z.any(); +const ResourceSelectSchema: ZodType = z.any(); @Injectable() export class ResourceRouter { - constructor( - private readonly trpc: TrpcService, - private readonly resourceService: ResourceService, - ) { } - router = this.trpc.router({ - create: this.trpc.protectProcedure - .input(ResourceCreateArgsSchema) - .mutation(async ({ ctx, input }) => { - const { staff } = ctx; - return await this.resourceService.create(input, { staff }); - }), - createMany: this.trpc.protectProcedure.input(z.array(ResourceCreateManyInputSchema)) - .mutation(async ({ ctx, input }) => { - const { staff } = ctx; + constructor( + private readonly trpc: TrpcService, + private readonly resourceService: ResourceService, + ) {} + router = this.trpc.router({ + create: this.trpc.protectProcedure + .input(ResourceCreateArgsSchema) + .mutation(async ({ ctx, input }) => { + const { staff } = ctx; + return await this.resourceService.create(input, { staff }); + }), + createMany: this.trpc.protectProcedure + .input(z.array(ResourceCreateManyInputSchema)) + .mutation(async ({ ctx, input }) => { + const { staff } = ctx; - return await this.resourceService.createMany({ data: input }, staff); - }), - deleteMany: this.trpc.procedure - .input(ResourceDeleteManyArgsSchema) - .mutation(async ({ input }) => { - return await this.resourceService.deleteMany(input); - }), - findFirst: this.trpc.procedure - .input(ResourceFindFirstArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword - .query(async ({ input }) => { - return await this.resourceService.findFirst(input); - }), - softDeleteByIds: this.trpc.protectProcedure - .input(z.object({ ids: z.array(z.string()) })) // expect input according to the schema - .mutation(async ({ input }) => { - return this.resourceService.softDeleteByIds(input.ids); - }), - updateOrder: this.trpc.protectProcedure - .input(UpdateOrderSchema) - .mutation(async ({ input }) => { - return this.resourceService.updateOrder(input); - }), - findMany: this.trpc.procedure - .input(ResourceFindManyArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword - .query(async ({ input }) => { - return await this.resourceService.findMany(input); - }), - findManyWithCursor: this.trpc.protectProcedure - .input(z.object({ - cursor: z.any().nullish(), - take: z.number().nullish(), - where: ResourceWhereInputSchema.nullish(), - select: ResourceSelectSchema.nullish() - })) - .query(async ({ ctx, input }) => { - const { staff } = ctx; - return await this.resourceService.findManyWithCursor(input); - }), - }); + return await this.resourceService.createMany({ data: input }, staff); + }), + deleteMany: this.trpc.procedure + .input(ResourceDeleteManyArgsSchema) + .mutation(async ({ input }) => { + return await this.resourceService.deleteMany(input); + }), + findFirst: this.trpc.procedure + .input(ResourceFindFirstArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword + .query(async ({ input }) => { + return await this.resourceService.findFirst(input); + }), + softDeleteByIds: this.trpc.protectProcedure + .input(z.object({ ids: z.array(z.string()) })) // expect input according to the schema + .mutation(async ({ input }) => { + return this.resourceService.softDeleteByIds(input.ids); + }), + updateOrder: this.trpc.protectProcedure + .input(UpdateOrderSchema) + .mutation(async ({ input }) => { + return this.resourceService.updateOrder(input); + }), + findMany: this.trpc.procedure + .input(ResourceFindManyArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword + .query(async ({ input }) => { + return await this.resourceService.findMany(input); + }), + findManyWithCursor: this.trpc.protectProcedure + .input( + z.object({ + cursor: z.any().nullish(), + take: z.number().nullish(), + where: ResourceWhereInputSchema.nullish(), + select: ResourceSelectSchema.nullish(), + }), + ) + .query(async ({ ctx, input }) => { + const { staff } = ctx; + return await this.resourceService.findManyWithCursor(input); + }), + }); } diff --git a/apps/server/src/models/visit/visit.service.ts b/apps/server/src/models/visit/visit.service.ts index 20607e3..ba6ed20 100755 --- a/apps/server/src/models/visit/visit.service.ts +++ b/apps/server/src/models/visit/visit.service.ts @@ -9,13 +9,13 @@ export class VisitService extends BaseService { } async create(args: Prisma.VisitCreateArgs, staff?: UserProfile) { const { postId, lectureId, messageId } = args.data; - const visitorId = args.data.visitorId || staff?.id; + const visitorId = args.data?.visitorId || staff?.id; let result; const existingVisit = await db.visit.findFirst({ where: { type: args.data.type, - visitorId, - OR: [{ postId }, { lectureId }, { messageId }], + // visitorId: visitorId ? visitorId : null, + OR: [{ postId }, { messageId }], }, }); if (!existingVisit) { diff --git a/apps/server/src/queue/models/post/utils.ts b/apps/server/src/queue/models/post/utils.ts index 57129a3..7e3c3f1 100755 --- a/apps/server/src/queue/models/post/utils.ts +++ b/apps/server/src/queue/models/post/utils.ts @@ -66,8 +66,6 @@ export async function updatePostViewCount(id: string, type: VisitType) { where: { id }, select: { id: true, meta: true, type: true }, }); - console.log(post?.type); - console.log('updatePostViewCount'); const metaFieldMap = { [VisitType.READED]: 'views', [VisitType.LIKE]: 'likes', @@ -105,7 +103,6 @@ export async function updatePostViewCount(id: string, type: VisitType) { type: type, }, }); - console.log(courseViews); await db.post.update({ where: { id: course.id }, data: { @@ -126,7 +123,6 @@ export async function updatePostViewCount(id: string, type: VisitType) { type: type, }, }); - console.log('totalViews', totalViews); await db.post.update({ where: { id }, data: { diff --git a/apps/server/src/tasks/init/gendev.service.ts b/apps/server/src/tasks/init/gendev.service.ts index e2c31c9..e5c3f9f 100755 --- a/apps/server/src/tasks/init/gendev.service.ts +++ b/apps/server/src/tasks/init/gendev.service.ts @@ -31,6 +31,7 @@ export class GenDevService { domainDepts: Record = {}; staffs: Staff[] = []; deptGeneratedCount = 0; + courseGeneratedCount = 1; constructor( private readonly appConfigService: AppConfigService, @@ -194,8 +195,9 @@ export class GenDevService { cate.id, randomLevelId, ); + this.courseGeneratedCount++; this.logger.log( - `Generated ${this.deptGeneratedCount}/${total} departments`, + `Generated ${this.courseGeneratedCount}/${total} course`, ); } } diff --git a/apps/server/src/upload/tus.service.ts b/apps/server/src/upload/tus.service.ts index 6de4e53..01c9072 100755 --- a/apps/server/src/upload/tus.service.ts +++ b/apps/server/src/upload/tus.service.ts @@ -128,6 +128,7 @@ export class TusService implements OnModuleInit { } async handleTus(req: Request, res: Response) { + // console.log(req) return this.tusServer.handle(req, res); } } diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json index 858dbf5..689b525 100755 --- a/apps/server/tsconfig.json +++ b/apps/server/tsconfig.json @@ -12,7 +12,6 @@ "outDir": "./dist", "strict": true, "esModuleInterop": true, - "incremental": true, - // "skipLibCheck": true, - }, -} \ No newline at end of file + "incremental": true + } +} diff --git a/apps/web/index.html b/apps/web/index.html index 6199b0c..2305b2c 100755 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -12,7 +12,7 @@ VITE_APP_VERSION: "$VITE_APP_VERSION", }; - fhmooc + %VITE_APP_APP_NAME% diff --git a/apps/web/package.json b/apps/web/package.json index f10e338..909aa90 100755 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -34,9 +34,8 @@ "@nice/common": "workspace:^", "@nice/config": "workspace:^", "@nice/iconer": "workspace:^", - "@nice/utils": "workspace:^", - "mind-elixir": "workspace:^", "@nice/ui": "workspace:^", + "@nice/utils": "workspace:^", "@tanstack/query-async-storage-persister": "^5.51.9", "@tanstack/react-query": "^5.51.21", "@tanstack/react-query-persist-client": "^5.51.9", @@ -59,6 +58,7 @@ "framer-motion": "^11.15.0", "hls.js": "^1.5.18", "idb-keyval": "^6.2.1", + "mind-elixir": "workspace:^", "mitt": "^3.0.1", "quill": "2.0.3", "react": "18.2.0", @@ -69,6 +69,7 @@ "react-router-dom": "^6.24.1", "superjson": "^2.2.1", "tailwind-merge": "^2.6.0", + "use-debounce": "^10.0.4", "uuid": "^10.0.0", "yjs": "^13.6.20", "zod": "^3.23.8" @@ -89,4 +90,4 @@ "typescript-eslint": "^8.0.1", "vite": "^5.4.1" } -} \ No newline at end of file +} diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 9e6304c..3ae08aa 100755 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -11,11 +11,12 @@ import dayjs from "dayjs"; import "dayjs/locale/zh-cn"; import { AuthProvider } from './providers/auth-provider'; import { Toaster } from 'react-hot-toast'; - +// import PostForm from './components/models/post/PostSelect/PostSelect'; dayjs.locale("zh-cn"); function App() { return ( <> + {/* */} - + + ); } diff --git a/apps/web/src/app/admin/base-setting/page.tsx b/apps/web/src/app/admin/base-setting/page.tsx index d4a4efa..b5dcc83 100755 --- a/apps/web/src/app/admin/base-setting/page.tsx +++ b/apps/web/src/app/admin/base-setting/page.tsx @@ -9,6 +9,7 @@ import FixedHeader from "@web/src/components/layout/fix-header"; import { useForm } from "antd/es/form/Form"; import { api } from "@nice/client"; import { MainLayoutContext } from "../layout"; +import CarouselUrlInput from "@web/src/components/common/input/CarouselUrlInput"; export default function BaseSettingPage() { const { update, baseSetting } = useAppConfig(); @@ -134,6 +135,13 @@ export default function BaseSettingPage() { +
+ + + +
{/*
{ const [showLogin, setShowLogin] = useState(true); const [registerLoading, setRegisterLoading] = useState(false); - const { - login, - isAuthenticated, - signup - } = useAuth() + const { login, isAuthenticated, signup } = useAuth(); const loginFormRef = useRef(null); const registerFormRef = useRef(null); const location = useLocation(); @@ -21,7 +17,7 @@ const LoginPage: React.FC = () => { const { username, password } = values; await login(username, password); } catch (err: any) { - message.error(err?.response?.data?.message || "帐号或密码错误!"); + message.error(err?.response?.data?.message || "账号或密码错误!"); console.error(err); } }; @@ -52,10 +48,12 @@ const LoginPage: React.FC = () => { return (
+ style={ + { + // backgroundImage: `url(${backgroundUrl})`, + // backgroundSize: "cover", + } + }>
@@ -103,11 +101,11 @@ const LoginPage: React.FC = () => { size="large"> @@ -158,17 +156,17 @@ const LoginPage: React.FC = () => { diff --git a/apps/web/src/app/main/home/components/CategorySection.tsx b/apps/web/src/app/main/home/components/CategorySection.tsx index 4c8141e..6630e1b 100755 --- a/apps/web/src/app/main/home/components/CategorySection.tsx +++ b/apps/web/src/app/main/home/components/CategorySection.tsx @@ -35,6 +35,7 @@ const CategorySection = () => { const handleMouseClick = useCallback((categoryId: string) => { setSelectedTerms({ + ...selectedTerms, [TaxonomySlug.CATEGORY]: [categoryId], }); navigate("/courses"); @@ -57,7 +58,7 @@ const CategorySection = () => { {isLoading ? ( ) : ( - courseCategoriesData?.map((category, index) => { + courseCategoriesData?.filter(c=>!c.deletedAt)?.map((category, index) => { const categoryColor = stringToColor(category.name); const isHovered = hoveredIndex === index; diff --git a/apps/web/src/app/main/home/components/CoursesSection.tsx b/apps/web/src/app/main/home/components/CoursesSection.tsx index cc68bd0..bd7e18d 100755 --- a/apps/web/src/app/main/home/components/CoursesSection.tsx +++ b/apps/web/src/app/main/home/components/CoursesSection.tsx @@ -23,7 +23,7 @@ function useGetTaxonomy({ type }): GetTaxonomyProps { const categories = useMemo(() => { const allCategories = isLoading ? [] - : data?.map((course) => course.name); + : data?.filter(c=>!c.deletedAt)?.map((course) => course.name); return [...Array.from(new Set(allCategories))]; }, [data]); return { categories, isLoading }; diff --git a/apps/web/src/app/main/home/components/HeroSection.tsx b/apps/web/src/app/main/home/components/HeroSection.tsx index 8f6b7a8..74643fb 100755 --- a/apps/web/src/app/main/home/components/HeroSection.tsx +++ b/apps/web/src/app/main/home/components/HeroSection.tsx @@ -16,6 +16,7 @@ import { } from "@ant-design/icons"; import type { CarouselRef } from "antd/es/carousel"; import { useAppConfig } from "@nice/client"; +import { useNavigate } from "react-router-dom"; const { Title, Text } = Typography; @@ -35,8 +36,9 @@ interface PlatformStat { const HeroSection = () => { const carouselRef = useRef(null); - const { statistics, slides } = useAppConfig(); + const { statistics, slides, slideLinks = [] } = useAppConfig(); const [countStatistics, setCountStatistics] = useState(4); + const navigator = useNavigate() const platformStats: PlatformStat[] = useMemo(() => { return [ { @@ -91,7 +93,11 @@ const HeroSection = () => { }}> {Array.isArray(slides) ? ( slides.map((item, index) => ( -
+
{ + if(slideLinks?.[index])window.open(slideLinks?.[index],"_blank") + }} + >

{tax?.name} + {/* {JSON.stringify(items)} */}

handleTermChange( diff --git a/apps/web/src/app/main/layout/MainFooter.tsx b/apps/web/src/app/main/layout/MainFooter.tsx index b232752..eb4ef75 100755 --- a/apps/web/src/app/main/layout/MainFooter.tsx +++ b/apps/web/src/app/main/layout/MainFooter.tsx @@ -26,13 +26,13 @@ export function MainFooter() {
- 628118 + 628532
- gcsjs6@tx3l.nb.kj + ruanjian1@tx3l.nb.kj
diff --git a/apps/web/src/app/main/layout/MainHeader.tsx b/apps/web/src/app/main/layout/MainHeader.tsx index deb6b62..91b1771 100755 --- a/apps/web/src/app/main/layout/MainHeader.tsx +++ b/apps/web/src/app/main/layout/MainHeader.tsx @@ -1,16 +1,11 @@ - -import { Input, Layout, Avatar, Button, Dropdown } from "antd"; -import { - EditFilled, - PlusOutlined, - SearchOutlined, - UserOutlined, -} from "@ant-design/icons"; +import { Input, Button } from "antd"; +import { PlusOutlined, SearchOutlined, UserOutlined } from "@ant-design/icons"; import { useAuth } from "@web/src/providers/auth-provider"; -import { useNavigate, useParams, useSearchParams } from "react-router-dom"; +import { useNavigate, useParams } from "react-router-dom"; import { UserMenu } from "./UserMenu/UserMenu"; import { NavigationMenu } from "./NavigationMenu"; import { useMainContext } from "./MainProvider"; +import { env } from "@web/src/env"; export function MainHeader() { const { isAuthenticated, user } = useAuth(); const { id } = useParams(); @@ -25,12 +20,11 @@ export function MainHeader() {
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 whitespace-nowrap"> - 烽火慕课 + {env.APP_NAME}
- {/* 右侧区域 - 可以灵活收缩 */}
@@ -43,7 +37,9 @@ export function MainHeader() { className="w-full md:w-96 rounded-full" value={searchValue} onClick={(e) => { - if (!window.location.pathname.startsWith("/search")) { + if ( + !window.location.pathname.startsWith("/search") + ) { navigate(`/search`); window.scrollTo({ top: 0, @@ -53,7 +49,9 @@ export function MainHeader() { }} onChange={(e) => setSearchValue(e.target.value)} onPressEnter={(e) => { - if (!window.location.pathname.startsWith("/search")) { + if ( + !window.location.pathname.startsWith("/search") + ) { navigate(`/search`); window.scrollTo({ top: 0, @@ -69,14 +67,11 @@ export function MainHeader() { shape="round" icon={} onClick={() => { - const url = id - ? `/course/${id}/editor` - : "/course/editor"; + const url = "/course/editor"; navigate(url); }} - type="primary" - > - {id ? "编辑课程" : "创建课程"} + type="primary"> + {"创建课程"} )} @@ -87,7 +82,8 @@ export function MainHeader() { onClick={() => { window.location.href = "/path/editor"; }} - ghost type="primary" + ghost + type="primary" icon={}> 创建思维导图 @@ -100,7 +96,6 @@ export function MainHeader() { size="large" shape="round" onClick={() => navigate("/login")} - icon={}> 登录 @@ -110,4 +105,3 @@ export function MainHeader() {
); } - diff --git a/apps/web/src/app/main/layout/MainProvider.tsx b/apps/web/src/app/main/layout/MainProvider.tsx index d928e9d..1d0396c 100755 --- a/apps/web/src/app/main/layout/MainProvider.tsx +++ b/apps/web/src/app/main/layout/MainProvider.tsx @@ -6,6 +6,7 @@ import React, { useMemo, useState, } from "react"; +import { useDebounce } from "use-debounce"; interface SelectedTerms { [key: string]: string[]; // 每个 slug 对应一个 string 数组 } @@ -35,7 +36,8 @@ export function MainProvider({ children }: MainProviderProps) { PostType.COURSE | PostType.PATH | "both" >("both"); const [showSearchMode, setShowSearchMode] = useState(false); - const [searchValue, setSearchValue] = useState(""); + const [searchValue, setSearchValue] = useState(""); + const [debouncedValue] = useDebounce(searchValue, 500); const [selectedTerms, setSelectedTerms] = useState({}); // 初始化状态 const termFilters = useMemo(() => { return Object.entries(selectedTerms) @@ -60,10 +62,10 @@ export function MainProvider({ children }: MainProviderProps) { }, [termFilters]); const searchCondition: Prisma.PostWhereInput = useMemo(() => { const containTextCondition: Prisma.StringNullableFilter = { - contains: searchValue, + contains: debouncedValue, mode: "insensitive" as Prisma.QueryMode, // 使用类型断言 }; - return searchValue + return debouncedValue ? { OR: [ { title: containTextCondition }, @@ -79,7 +81,7 @@ export function MainProvider({ children }: MainProviderProps) { ], } : {}; - }, [searchValue]); + }, [debouncedValue]); return ( { } }, [isAuthenticated]); - const selectedKey = - menuItems.find((item) => item.path === pathname)?.key || ""; + const selectedKey = useMemo(() => { + const normalizePath = (path: string): string => path.replace(/\/$/, ""); + return pathname === '/' ? "home" : menuItems.find((item) => normalizePath(pathname) === item.path)?.key || ""; + }, [pathname]); + return ( { +const TermInfo = ({ terms = [] }: { terms?: TermDto[] }) => { return (
- {post?.terms && post?.terms?.length > 0 ? ( + {terms && terms?.length > 0 ? (
- {post?.terms?.map((term: any) => { + {terms?.map((term: any) => { return ( - -
+ const { id } = useParams(); + return ( + + ; + + ); } diff --git a/apps/web/src/app/main/path/page.tsx b/apps/web/src/app/main/path/page.tsx index 65178db..542689b 100755 --- a/apps/web/src/app/main/path/page.tsx +++ b/apps/web/src/app/main/path/page.tsx @@ -3,12 +3,15 @@ import BasePostLayout from "../layout/BasePost/BasePostLayout"; import { useMainContext } from "../layout/MainProvider"; import PathListContainer from "./components/PathListContainer"; import { PostType } from "@nice/common"; +import { PostDetailProvider } from "@web/src/components/models/course/detail/PostDetailContext"; +import { useParams } from "react-router-dom"; export default function PathPage() { const { setSearchMode } = useMainContext(); useEffect(() => { setSearchMode(PostType.PATH); }, [setSearchMode]); + const { id } = useParams(); return ( diff --git a/apps/web/src/components/common/editor/MindEditor.tsx b/apps/web/src/components/common/editor/MindEditor.tsx index 6a4e540..08803c7 100755 --- a/apps/web/src/components/common/editor/MindEditor.tsx +++ b/apps/web/src/components/common/editor/MindEditor.tsx @@ -12,7 +12,7 @@ import { } from "@nice/common"; import TermSelect from "../../models/term/term-select"; import DepartmentSelect from "../../models/department/department-select"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useContext, useEffect, useMemo, useRef, useState } from "react"; import toast from "react-hot-toast"; import { MindElixirInstance } from "mind-elixir"; import MindElixir from "mind-elixir"; @@ -21,22 +21,35 @@ import { useNavigate } from "react-router-dom"; import { useAuth } from "@web/src/providers/auth-provider"; import { MIND_OPTIONS } from "./constant"; import { SaveOutlined } from "@ant-design/icons"; +import JoinButton from "../../models/course/detail/CourseOperationBtns/JoinButton"; +import { CourseDetailContext } from "../../models/course/detail/PostDetailContext"; export default function MindEditor({ id }: { id?: string }) { const containerRef = useRef(null); + const { + post, + isLoading, + // userIsLearning, + // setUserIsLearning, + } = useContext(CourseDetailContext); const [instance, setInstance] = useState(null); const { isAuthenticated, user, hasSomePermissions } = useAuth(); - const { read } = useVisitor() - const { data: post, isLoading }: { data: PathDto; isLoading: boolean } = - api.post.findFirst.useQuery({ - where: { - id, - }, - select: postDetailSelect, - }, { enabled: Boolean(id) }); + const { read } = useVisitor(); + // const { data: post, isLoading }: { data: PathDto; isLoading: boolean } = + // api.post.findFirst.useQuery( + // { + // where: { + // id, + // }, + // select: postDetailSelect, + // }, + // { enabled: Boolean(id) } + // ); + const canEdit: boolean = useMemo(() => { const isAuth = isAuthenticated && user?.id === post?.author?.id; - return !!id || isAuth || hasSomePermissions(RolePerms.MANAGE_ANY_POST); + return !id || isAuth || hasSomePermissions(RolePerms.MANAGE_ANY_POST); }, [user]); + const navigate = useNavigate(); const { create, update } = usePost(); const { data: taxonomies } = api.taxonomy.getAll.useQuery({ @@ -64,8 +77,8 @@ export default function MindEditor({ id }: { id?: string }) { deptIds: deptIds, }; post.terms?.forEach((term) => { - formData[term.taxonomyId] = term.id // 假设 taxonomyName是您在 Form.Item 中使用的name - }) + formData[term.taxonomyId] = term.id; // 假设 taxonomyName是您在 Form.Item 中使用的name + }); form.setFieldsValue(formData); } }, [post, form, instance, id]); @@ -94,8 +107,8 @@ export default function MindEditor({ id }: { id?: string }) { if ((!id || post) && instance) { containerRef.current.hidden = false; instance.toCenter(); - if (post?.meta?.nodeData) { - instance.refresh(post?.meta); + if ((post as any as PathDto)?.meta?.nodeData) { + instance.refresh((post as any as PathDto)?.meta); } } }, [id, post, instance]); @@ -103,6 +116,7 @@ export default function MindEditor({ id }: { id?: string }) { const handleSave = async () => { if (!instance) return; const values = form.getFieldsValue(); + //以图片格式导出思维导图以作为思维导图封面 const imgBlob = await instance?.exportPng(); handleFileUpload( imgBlob, @@ -159,22 +173,20 @@ export default function MindEditor({ id }: { id?: string }) { } console.log(result); }, - (error) => { }, + (error) => {}, `mind-thumb-${new Date().toString()}` ); }; useEffect(() => { - containerRef.current.style.height = `${Math.floor(window.innerHeight - 271)}px`; }, []); - + useEffect(()=>{ + console.log(canEdit,user?.id,post?.author?.id) + }) return ( -
- {canEdit && taxonomies && ( -
+ {taxonomies && ( +
{taxonomies.map((tax, index) => ( @@ -202,15 +214,20 @@ export default function MindEditor({ id }: { id?: string }) { multiple /> + {id ? : <>} +
+
+ {canEdit && ( + + )}
- {canEdit && }
)} @@ -220,24 +237,20 @@ export default function MindEditor({ id }: { id?: string }) { onContextMenu={(e) => e.preventDefault()} /> {canEdit && instance && } - { - isLoading && ( -
- -
- ) - } - { - !post && id && !isLoading && ( -
- -
- ) - } -
+ {isLoading && ( +
+ +
+ )} + {!post && id && !isLoading && ( +
+ +
+ )} +
); -} +} \ No newline at end of file diff --git a/apps/web/src/components/common/editor/NodeMenu.tsx b/apps/web/src/components/common/editor/NodeMenu.tsx index f762967..e833a79 100755 --- a/apps/web/src/components/common/editor/NodeMenu.tsx +++ b/apps/web/src/components/common/editor/NodeMenu.tsx @@ -1,190 +1,273 @@ -import React, { useState, useEffect, useRef } from 'react'; -import { Input, Button, ColorPicker, Select } from 'antd'; +import React, { useState, useEffect, useRef, useMemo } from "react"; +import { Input, Button, ColorPicker, Select } from "antd"; import { - FontSizeOutlined, - BoldOutlined, - LinkOutlined, -} from '@ant-design/icons'; -import type { MindElixirInstance, NodeObj } from 'mind-elixir'; - -const xmindColorPresets = [ - // 经典16色 - '#FFFFFF', '#F5F5F5', // 白色系 - '#2196F3', '#1976D2', // 蓝色系 - '#4CAF50', '#388E3C', // 绿色系 - '#FF9800', '#F57C00', // 橙色系 - '#F44336', '#D32F2F', // 红色系 - '#9C27B0', '#7B1FA2', // 紫色系 - '#424242', '#757575', // 灰色系 - '#FFEB3B', '#FBC02D' // 黄色系 -]; + FontSizeOutlined, + BoldOutlined, + LinkOutlined, + GlobalOutlined, + SwapOutlined, +} from "@ant-design/icons"; +import type { MindElixirInstance, NodeObj } from "mind-elixir"; +import PostSelect from "../../models/post/PostSelect/PostSelect"; +import { Lecture, PostType } from "@nice/common"; +import { xmindColorPresets } from "./constant"; +import { api } from "@nice/client"; +import { env } from "@web/src/env"; interface NodeMenuProps { - mind: MindElixirInstance; + mind: MindElixirInstance; } +//管理节点样式状态 const NodeMenu: React.FC = ({ mind }) => { - const [isOpen, setIsOpen] = useState(false); - const [selectedFontColor, setSelectedFontColor] = useState(''); - const [selectedBgColor, setSelectedBgColor] = useState(''); - const [selectedSize, setSelectedSize] = useState(''); - const [isBold, setIsBold] = useState(false); - const [url, setUrl] = useState(''); - const containerRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); + const [selectedFontColor, setSelectedFontColor] = useState(""); + const [selectedBgColor, setSelectedBgColor] = useState(""); + const [selectedSize, setSelectedSize] = useState(""); + const [isBold, setIsBold] = useState(false); - useEffect(() => { - const handleSelectNode = (nodeObj: NodeObj) => { - setIsOpen(true); - const style = nodeObj.style || {}; - setSelectedFontColor(style.color || ''); - setSelectedBgColor(style.background || ''); - setSelectedSize(style.fontSize || '24'); - setIsBold(style.fontWeight === 'bold'); - setUrl(nodeObj.hyperLink || ''); - }; - const handleUnselectNode = () => { - setIsOpen(false); - }; - mind.bus.addListener('selectNode', handleSelectNode); - mind.bus.addListener('unselectNode', handleUnselectNode); - }, [mind]); + const [urlMode, setUrlMode] = useState<"URL" | "POSTURL">("POSTURL"); + const [url, setUrl] = useState(""); + const [postId, setPostId] = useState(""); + const containerRef = useRef(null); + const { data: lecture, isLoading }: { data: Lecture; isLoading: boolean } = + api.post.findFirst.useQuery( + { + where: { id: postId }, + }, + { enabled: !!postId } + ); + useEffect(() => { + { + if(lecture?.courseId && lecture?.id){ + if (urlMode === "POSTURL"){ + setUrl(`/course/${lecture?.courseId}/detail/${lecture?.id}`); + } + mind.reshapeNode(mind.currentNode, { + hyperLink: `/course/${lecture?.courseId}/detail/${lecture?.id}`, + }); + } + } + }, [postId, lecture, isLoading, urlMode]); - useEffect(() => { - if (containerRef.current && mind.container) { - mind.container.appendChild(containerRef.current); - } + //监听思维导图节点选择事件,更新节点菜单状态 + useEffect(() => { + const handleSelectNode = (nodeObj: NodeObj) => { + setIsOpen(true); + const style = nodeObj.style || {}; + setSelectedFontColor(style.color || ""); + setSelectedBgColor(style.background || ""); + setSelectedSize(style.fontSize || "24"); + setIsBold(style.fontWeight === "bold"); + setUrl(nodeObj.hyperLink || ""); + }; + const handleUnselectNode = () => { + setIsOpen(false); + }; + mind.bus.addListener("selectNode", handleSelectNode); + mind.bus.addListener("unselectNode", handleUnselectNode); + }, [mind]); - }, [mind.container]); + useEffect(() => { + const handleSelectNode = (nodeObj: NodeObj) => { + setIsOpen(true); + const style = nodeObj.style || {}; + setSelectedFontColor(style.color || ""); + setSelectedBgColor(style.background || ""); + setSelectedSize(style.fontSize || "24"); + setIsBold(style.fontWeight === "bold"); + setUrl(nodeObj.hyperLink || ""); + }; + const handleUnselectNode = () => { + setIsOpen(false); + }; + mind.bus.addListener("selectNode", handleSelectNode); + mind.bus.addListener("unselectNode", handleUnselectNode); + }, [mind]); - const handleColorChange = (type: "font" | "background", color: string) => { - if (type === 'font') { - setSelectedFontColor(color); - } else { - setSelectedBgColor(color); - } - const patch = { style: {} as any }; - if (type === 'font') { - patch.style.color = color; - } else { - patch.style.background = color; - } - mind.reshapeNode(mind.currentNode, patch); - }; + useEffect(() => { + if (containerRef.current && mind.container) { + mind.container.appendChild(containerRef.current); + } + }, [mind.container]); - const handleSizeChange = (size: string) => { - setSelectedSize(size); - mind.reshapeNode(mind.currentNode, { style: { fontSize: size } }); - }; + const handleColorChange = (type: "font" | "background", color: string) => { + if (type === "font") { + setSelectedFontColor(color); + } else { + setSelectedBgColor(color); + } + const patch = { style: {} as any }; + if (type === "font") { + patch.style.color = color; + } else { + patch.style.background = color; + } + mind.reshapeNode(mind.currentNode, patch); + }; - const handleBoldToggle = () => { - const fontWeight = isBold ? '' : 'bold'; - setIsBold(!isBold); - mind.reshapeNode(mind.currentNode, { style: { fontWeight } }); - }; + const handleSizeChange = (size: string) => { + setSelectedSize(size); + mind.reshapeNode(mind.currentNode, { style: { fontSize: size } }); + }; - const handleUrlChange = (e: React.ChangeEvent) => { - const value = e.target.value; - setUrl(value); - mind.reshapeNode(mind.currentNode, { hyperLink: value }); - }; + const handleBoldToggle = () => { + const fontWeight = isBold ? "" : "bold"; + setIsBold(!isBold); + mind.reshapeNode(mind.currentNode, { style: { fontWeight } }); + }; - return ( -
-
- {/* Font Size Selector */} -
-

文字样式

-
- } + className="w-1/2" + options={[ + { value: "12", label: "12" }, + { value: "14", label: "14" }, + { value: "16", label: "16" }, + { value: "18", label: "18" }, + { value: "20", label: "20" }, + { value: "24", label: "24" }, + { value: "28", label: "28" }, + { value: "32", label: "32" }, + ]} + /> + +
+
- {/* Color Picker */} -
-

颜色设置

+ {/* Color Picker */} +
+

+ 颜色设置 +

- {/* Font Color Picker */} -
-

文字颜色

-
- {xmindColorPresets.map((color) => ( -
{ + {/* Font Color Picker */} +
+

+ 文字颜色 +

+
+ {xmindColorPresets.map((color) => ( +
{ + handleColorChange("font", color); + }} + /> + ))} +
+
- handleColorChange('font', color); - }} - /> - ))} -
-
+ {/* Background Color Picker */} +
+

+ 背景颜色 +

+
+ {xmindColorPresets.map((color) => ( +
{ + handleColorChange("background", color); + }} + /> + ))} +
+
+
- {/* Background Color Picker */} -
-

背景颜色

-
- {xmindColorPresets.map((color) => ( -
{ - handleColorChange('background', color); - }} - /> - ))} -
-
-
+
+ {urlMode === "URL" ? "关联链接" : "关联课时"} +
-

关联链接

- {/* URL Input */} -
- } - /> - {url && !/^https?:\/\/\S+$/.test(url) && ( -

请输入有效的URL地址

- )} -
-
-
- ); +
+ {urlMode === "POSTURL" ? ( + { + if (typeof value === "string") { + setPostId(value); + } + }} + params={{ + where: { + type: PostType.LECTURE, + }, + }} + /> + ) : ( + } + /> + )} + + {urlMode === "URL" && + url && + !/^(https?:\/\/\S+|\/|\.\/|\.\.\/)?\S+$/.test(url) && ( +

+ 请输入有效的URL地址 +

+ )} +
+
+
+ ); }; export default NodeMenu; diff --git a/apps/web/src/components/common/editor/constant.ts b/apps/web/src/components/common/editor/constant.ts index 29e6890..eb0766e 100755 --- a/apps/web/src/components/common/editor/constant.ts +++ b/apps/web/src/components/common/editor/constant.ts @@ -32,3 +32,23 @@ export const MIND_OPTIONS = { }, }, }; + +export const xmindColorPresets = [ + // 经典16色 + "#FFFFFF", + "#F5F5F5", // 白色系 + "#2196F3", + "#1976D2", // 蓝色系 + "#4CAF50", + "#388E3C", // 绿色系 + "#FF9800", + "#F57C00", // 橙色系 + "#F44336", + "#D32F2F", // 红色系 + "#9C27B0", + "#7B1FA2", // 紫色系 + "#424242", + "#757575", // 灰色系 + "#FFEB3B", + "#FBC02D", // 黄色系 +]; diff --git a/apps/web/src/components/common/editor/graph/GraphEditor.tsx b/apps/web/src/components/common/editor/graph/GraphEditor.tsx deleted file mode 100755 index 633bab7..0000000 --- a/apps/web/src/components/common/editor/graph/GraphEditor.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { addEdge, ReactFlow, Background, Controls, Edge, Node, ReactFlowProvider, useEdgesState, useNodesState, MiniMap, Panel, BackgroundVariant, ControlButton, applyNodeChanges, applyEdgeChanges, SelectionMode, OnNodesChange, OnEdgesChange, useReactFlow, useOnSelectionChange, useNodesInitialized } from '@xyflow/react'; -import { Button } from '../../element/Button'; -import '@xyflow/react/dist/style.css'; -import { edgeTypes, GraphState, nodeTypes } from './types'; -import useGraphStore from './store'; -import { shallow } from 'zustand/shallow'; -import { useKeyboardCtrl } from './useKeyboardCtrl'; -import { getMindMapLayout } from './layout'; - - - - -const selector = (store: GraphState) => ({ - nodes: store.present.nodes, - edges: store.present.edges, - setNodes: store.setNodes, - setEdges: store.setEdges, - record: store.record, - onNodesChange: store.onNodesChange, - onEdgesChange: store.onEdgesChange, -}); - -const panOnDrag = [1, 2]; - -const Flow: React.FC = () => { - - const store = useGraphStore(selector, shallow); - useKeyboardCtrl() - const nodesInitialized = useNodesInitialized(); - const onLayout = useCallback(async () => { - const layouted = getMindMapLayout({ nodes: store.nodes, edges: store.edges }) - store.setNodes(layouted.nodes) - store.setEdges(layouted.edges) - }, [store.nodes, store.edges]); - useEffect(() => { - if (nodesInitialized && store.nodes.length) { - console.log('layout') - onLayout() - } - }, [nodesInitialized, store.nodes.length]); - - return ( - { - const recordTypes = new Set(['remove', 'select']); - const undoChanges = changes.filter(change => recordTypes.has(change.type)) - const otherChanges = changes.filter(change => !recordTypes.has(change.type)) - if (undoChanges.length) - store.record(() => { - store.onNodesChange(undoChanges); - }); - store.onNodesChange(otherChanges); - }} - onEdgesChange={(changes) => { - const recordTypes = new Set(['remove', 'select']); - changes.forEach((change) => { - if (recordTypes.has(change.type)) { - store.record(() => { - store.onEdgesChange([change]); - }); - } else { - store.onEdgesChange([change]); - } - }); - }} - selectionOnDrag - panOnDrag={panOnDrag} - nodeTypes={nodeTypes} - edgeTypes={edgeTypes} - selectionMode={SelectionMode.Partial} - fitView - minZoom={0.001} - maxZoom={1000} - > - -
- - 节点个数{store.nodes.length} - 边条数{store.edges.length} -
- -
- - - 测试 - - -
- ); -}; - -const GraphEditor: React.FC = () => { - return ( - - - - ); -}; - -export default GraphEditor; \ No newline at end of file diff --git a/apps/web/src/components/common/editor/graph/data.ts b/apps/web/src/components/common/editor/graph/data.ts deleted file mode 100755 index c358f7a..0000000 --- a/apps/web/src/components/common/editor/graph/data.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { MarkerType } from "@xyflow/react"; - -// 生成思维导图数据的函数 -function generateMindMapData(levels: number, nodesPerLevel: number) { - const nodes = []; - const edges = []; - - // 添加根节点 - nodes.push({ - id: 'root', - data: { label: '核心主题', level: 0 }, - type: 'graph-node', - position: { x: 0, y: 0 } - }); - - // 为每一层生成节点 - for (let level = 1; level <= levels; level++) { - const angleStep = (2 * Math.PI) / nodesPerLevel; - const radius = level * 200; // 每层的半径 - - for (let i = 0; i < nodesPerLevel; i++) { - const angle = i * angleStep; - const nodeId = `node-${level}-${i}`; - - // 计算节点位置 - const x = Math.cos(angle) * radius; - const y = Math.sin(angle) * radius; - - // 添加节点 - nodes.push({ - id: nodeId, - data: { label: `主题${level}-${i}`, level }, - type: 'graph-node', - position: { x, y } - }); - - // 添加边 - // 第一层连接到根节点,其他层连接到上一层的节点 - const sourceId = level === 1 ? 'root' : `node-${level - 1}-${Math.floor(i / 2)}`; - edges.push({ - id: `edge-${level}-${i}`, - source: sourceId, - target: nodeId, - type: 'graph-edge', - }); - } - } - - return { nodes, edges }; -} - -// 生成测试数据 - 可以调整参数来控制规模 -// 参数1: 层级数量 -// 参数2: 每层节点数量 -const { nodes: initialNodes, edges: initialEdges } = generateMindMapData(2, 3); - -export { initialNodes, initialEdges }; \ No newline at end of file diff --git a/apps/web/src/components/common/editor/graph/edges/GraphEdge.tsx b/apps/web/src/components/common/editor/graph/edges/GraphEdge.tsx deleted file mode 100755 index 15e2224..0000000 --- a/apps/web/src/components/common/editor/graph/edges/GraphEdge.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { BaseEdge, Node, Edge, EdgeLabelRenderer, EdgeProps, getBezierPath, getSmoothStepPath, getStraightPath, Position, useReactFlow, useInternalNode, InternalNode } from '@xyflow/react'; - -export type GraphEdge = Edge<{ text: string }, 'graph-edge'>; - -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) => { - 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 ( - <> - - - {data?.text && ( -
- {data.text} -
- )} -
- - ); -}; \ No newline at end of file diff --git a/apps/web/src/components/common/editor/graph/edges/algorithms/a-star.ts b/apps/web/src/components/common/editor/graph/edges/algorithms/a-star.ts deleted file mode 100755 index c167657..0000000 --- a/apps/web/src/components/common/editor/graph/edges/algorithms/a-star.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { areLinesReverseDirection, areLinesSameDirection } from "../edge"; -import { - ControlPoint, - NodeRect, - isEqualPoint, - isSegmentCrossingRect, -} from "../point"; - -interface GetAStarPathParams { - /** - * Collection of potential control points between `sourceOffset` and `targetOffset`, excluding the `source` and `target` points. - */ - points: ControlPoint[]; - source: ControlPoint; - target: ControlPoint; - /** - * Node size information for the `source` and `target`, used to optimize edge routing without intersecting nodes. - */ - sourceRect: NodeRect; - targetRect: NodeRect; -} - -/** - * Utilizes the [A\* search algorithm](https://en.wikipedia.org/wiki/A*_search_algorithm) combined with - * [Manhattan Distance](https://simple.wikipedia.org/wiki/Manhattan_distance) to find the optimal path for edges. - * - * @returns Control points including sourceOffset and targetOffset (not including source and target points). - */ -export const getAStarPath = ({ - points, - source, - target, - sourceRect, - targetRect, -}: GetAStarPathParams): ControlPoint[] => { - if (points.length < 3) { - return points; - } - const start = points[0]; - const end = points[points.length - 1]; - const openSet: ControlPoint[] = [start]; - const closedSet: Set = new Set(); - const cameFrom: Map = new Map(); - const gScore: Map = new Map().set(start, 0); - const fScore: Map = new Map().set( - start, - heuristicCostEstimate({ - from: start, - to: start, - start, - end, - source, - target, - }) - ); - - while (openSet.length) { - let current; - let currentIdx; - let lowestFScore = Infinity; - openSet.forEach((p, idx) => { - const score = fScore.get(p) ?? 0; - if (score < lowestFScore) { - lowestFScore = score; - current = p; - currentIdx = idx; - } - }); - - if (!current) { - break; - } - - if (current === end) { - return buildPath(cameFrom, current); - } - - openSet.splice(currentIdx!, 1); - closedSet.add(current); - - const curFScore = fScore.get(current) ?? 0; - const previous = cameFrom.get(current); - const neighbors = getNextNeighborPoints({ - points, - previous, - current, - sourceRect, - targetRect, - }); - for (const neighbor of neighbors) { - if (closedSet.has(neighbor)) { - continue; - } - const neighborGScore = gScore.get(neighbor) ?? 0; - const tentativeGScore = curFScore + estimateDistance(current, neighbor); - if (openSet.includes(neighbor) && tentativeGScore >= neighborGScore) { - continue; - } - openSet.push(neighbor); - cameFrom.set(neighbor, current); - gScore.set(neighbor, tentativeGScore); - fScore.set( - neighbor, - neighborGScore + - heuristicCostEstimate({ - from: current, - to: neighbor, - start, - end, - source, - target, - }) - ); - } - } - return [start, end]; -}; - -const buildPath = ( - cameFrom: Map, - current: ControlPoint -): ControlPoint[] => { - const path = [current]; - - let previous = cameFrom.get(current); - while (previous) { - path.push(previous); - previous = cameFrom.get(previous); - } - - return path.reverse(); -}; - -interface GetNextNeighborPointsParams { - points: ControlPoint[]; - previous?: ControlPoint; - current: ControlPoint; - sourceRect: NodeRect; - targetRect: NodeRect; -} - -/** - * Get the set of possible neighboring points for the current control point - * - * - The line is in a horizontal or vertical direction - * - The line does not intersect with the two end nodes - * - The line does not overlap with the previous line segment in reverse direction - */ -export const getNextNeighborPoints = ({ - points, - previous, - current, - sourceRect, - targetRect, -}: GetNextNeighborPointsParams): ControlPoint[] => { - return points.filter((p) => { - if (p === current) { - return false; - } - // The connection is in the horizontal or vertical direction - const rightDirection = p.x === current.x || p.y === current.y; - // Reverse direction with the previous line segment (overlap) - const reverseDirection = previous - ? areLinesReverseDirection(previous, current, current, p) - : false; - return ( - rightDirection && // The line is in a horizontal or vertical direction - !reverseDirection && // The line does not overlap with the previous line segment in reverse direction - !isSegmentCrossingRect(p, current, sourceRect) && // Does not intersect with sourceNode - !isSegmentCrossingRect(p, current, targetRect) // Does not intersect with targetNode - ); - }); -}; - -interface HeuristicCostParams { - from: ControlPoint; - to: ControlPoint; - start: ControlPoint; - end: ControlPoint; - source: ControlPoint; - target: ControlPoint; -} - -/** - * Connection point distance loss function - * - * - The smaller the sum of distances, the better - * - The closer the start and end line segments are in direction, the better - * - The better the inflection point is symmetric or centered in the line segment - */ -const heuristicCostEstimate = ({ - from, - to, - start, - end, - source, - target, -}: HeuristicCostParams): number => { - const base = estimateDistance(to, start) + estimateDistance(to, end); - const startCost = isEqualPoint(from, start) - ? areLinesSameDirection(from, to, source, start) - ? -base / 2 - : 0 - : 0; - const endCost = isEqualPoint(to, end) - ? areLinesSameDirection(from, to, end, target) - ? -base / 2 - : 0 - : 0; - return base + startCost + endCost; -}; - -/** - * Calculate the estimated distance between two points - * - * Manhattan distance: the sum of horizontal and vertical distances, faster calculation speed - */ -const estimateDistance = (p1: ControlPoint, p2: ControlPoint): number => - Math.abs(p1.x - p2.x) + Math.abs(p1.y - p2.y); diff --git a/apps/web/src/components/common/editor/graph/edges/algorithms/index.ts b/apps/web/src/components/common/editor/graph/edges/algorithms/index.ts deleted file mode 100755 index a9fd894..0000000 --- a/apps/web/src/components/common/editor/graph/edges/algorithms/index.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { areLinesSameDirection, isHorizontalFromPosition } from "../edge"; -import { - ControlPoint, - HandlePosition, - NodeRect, - getCenterPoints, - getExpandedRect, - getOffsetPoint, - getSidesFromPoints, - getVerticesFromRectVertex, - optimizeInputPoints, - reducePoints, -} from "../point"; -import { getAStarPath } from "./a-star"; -import { getSimplePath } from "./simple"; - -export interface GetControlPointsParams { - source: HandlePosition; - target: HandlePosition; - sourceRect: NodeRect; - targetRect: NodeRect; - /** - * Minimum spacing between edges and nodes - */ - offset: number; -} - -/** - * Calculate control points on the optimal path of an edge. - * - * Reference article: https://juejin.cn/post/6942727734518874142 - */ -export const getControlPoints = ({ - source: oldSource, - target: oldTarget, - sourceRect, - targetRect, - offset = 20, -}: GetControlPointsParams) => { - const source: ControlPoint = oldSource; - const target: ControlPoint = oldTarget; - let edgePoints: ControlPoint[] = []; - let optimized: ReturnType; - - // 1. Find the starting and ending points after applying the offset - const sourceOffset = getOffsetPoint(oldSource, offset); - const targetOffset = getOffsetPoint(oldTarget, offset); - const expandedSource = getExpandedRect(sourceRect, offset); - const expandedTarget = getExpandedRect(targetRect, offset); - - // 2. Determine if the two Rects are relatively close or should directly connected - const minOffset = 2 * offset + 10; - const isHorizontalLayout = isHorizontalFromPosition(oldSource.position); - const isSameDirection = areLinesSameDirection( - source, - sourceOffset, - targetOffset, - target - ); - const sides = getSidesFromPoints([ - source, - target, - sourceOffset, - targetOffset, - ]); - const isTooClose = isHorizontalLayout - ? sides.right - sides.left < minOffset - : sides.bottom - sides.top < minOffset; - const isDirectConnect = isHorizontalLayout - ? isSameDirection && source.x < target.x - : isSameDirection && source.y < target.y; - - if (isTooClose || isDirectConnect) { - // 3. If the two Rects are relatively close or directly connected, return a simple Path - edgePoints = getSimplePath({ - source, - target, - sourceOffset, - targetOffset, - isDirectConnect, - }); - optimized = optimizeInputPoints({ - source: oldSource, - target: oldTarget, - sourceOffset, - targetOffset, - edgePoints, - }); - edgePoints = optimized.edgePoints; - } else { - // 3. Find the vertices of the two expanded Rects - edgePoints = [ - ...getVerticesFromRectVertex(expandedSource, targetOffset), - ...getVerticesFromRectVertex(expandedTarget, sourceOffset), - ]; - // 4. Find possible midpoints and intersections - edgePoints = edgePoints.concat( - getCenterPoints({ - source: expandedSource, - target: expandedTarget, - sourceOffset, - targetOffset, - }) - ); - // 5. Merge nearby coordinate points and remove duplicate coordinate points - optimized = optimizeInputPoints({ - source: oldSource, - target: oldTarget, - sourceOffset, - targetOffset, - edgePoints, - }); - // 6. Find the optimal path - edgePoints = getAStarPath({ - points: optimized.edgePoints, - source: optimized.source, - target: optimized.target, - sourceRect: getExpandedRect(sourceRect, offset / 2), - targetRect: getExpandedRect(targetRect, offset / 2), - }); - } - - return { - points: reducePoints([optimized.source, ...edgePoints, optimized.target]), - inputPoints: optimized.edgePoints, - }; -}; diff --git a/apps/web/src/components/common/editor/graph/edges/algorithms/simple.ts b/apps/web/src/components/common/editor/graph/edges/algorithms/simple.ts deleted file mode 100755 index 4526ef4..0000000 --- a/apps/web/src/components/common/editor/graph/edges/algorithms/simple.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { uuid } from "@/utils/uuid"; - -import { LayoutDirection } from "../../node"; -import { ControlPoint, isInLine, isOnLine } from "../point"; - -interface GetSimplePathParams { - isDirectConnect?: boolean; - source: ControlPoint; - target: ControlPoint; - sourceOffset: ControlPoint; - targetOffset: ControlPoint; -} - -const getLineDirection = ( - start: ControlPoint, - end: ControlPoint -): LayoutDirection => (start.x === end.x ? "vertical" : "horizontal"); - -/** - * When two nodes are too close, use the simple path - * - * @returns Control points including sourceOffset and targetOffset (not including source and target points). - */ -export const getSimplePath = ({ - isDirectConnect, - source, - target, - sourceOffset, - targetOffset, -}: GetSimplePathParams): ControlPoint[] => { - const points: ControlPoint[] = []; - const sourceDirection = getLineDirection(source, sourceOffset); - const targetDirection = getLineDirection(target, targetOffset); - const isHorizontalLayout = sourceDirection === "horizontal"; - if (isDirectConnect) { - // Direct connection, return a simple Path - if (isHorizontalLayout) { - if (sourceOffset.x <= targetOffset.x) { - const centerX = (sourceOffset.x + targetOffset.x) / 2; - return [ - { id: uuid(), x: centerX, y: sourceOffset.y }, - { id: uuid(), x: centerX, y: targetOffset.y }, - ]; - } else { - const centerY = (sourceOffset.y + targetOffset.y) / 2; - return [ - sourceOffset, - { id: uuid(), x: sourceOffset.x, y: centerY }, - { id: uuid(), x: targetOffset.x, y: centerY }, - targetOffset, - ]; - } - } else { - if (sourceOffset.y <= targetOffset.y) { - const centerY = (sourceOffset.y + targetOffset.y) / 2; - return [ - { id: uuid(), x: sourceOffset.x, y: centerY }, - { id: uuid(), x: targetOffset.x, y: centerY }, - ]; - } else { - const centerX = (sourceOffset.x + targetOffset.x) / 2; - return [ - sourceOffset, - { id: uuid(), x: centerX, y: sourceOffset.y }, - { id: uuid(), x: centerX, y: targetOffset.y }, - targetOffset, - ]; - } - } - } - if (sourceDirection === targetDirection) { - // Same direction, add two points, two endpoints of parallel lines at half the vertical distance - if (source.y === sourceOffset.y) { - points.push({ - id: uuid(), - x: sourceOffset.x, - y: (sourceOffset.y + targetOffset.y) / 2, - }); - points.push({ - id: uuid(), - x: targetOffset.x, - y: (sourceOffset.y + targetOffset.y) / 2, - }); - } else { - points.push({ - id: uuid(), - x: (sourceOffset.x + targetOffset.x) / 2, - y: sourceOffset.y, - }); - points.push({ - id: uuid(), - x: (sourceOffset.x + targetOffset.x) / 2, - y: targetOffset.y, - }); - } - } else { - // Different directions, add one point, ensure it's not on the current line segment (to avoid overlap), and there are no turns - let point = { id: uuid(), x: sourceOffset.x, y: targetOffset.y }; - const inStart = isInLine(point, source, sourceOffset); - const inEnd = isInLine(point, target, targetOffset); - if (inStart || inEnd) { - point = { id: uuid(), x: targetOffset.x, y: sourceOffset.y }; - } else { - const onStart = isOnLine(point, source, sourceOffset); - const onEnd = isOnLine(point, target, targetOffset); - if (onStart && onEnd) { - point = { id: uuid(), x: targetOffset.x, y: sourceOffset.y }; - } - } - points.push(point); - } - return [sourceOffset, ...points, targetOffset]; -}; diff --git a/apps/web/src/components/common/editor/graph/layout/BaseLayout.ts b/apps/web/src/components/common/editor/graph/layout/BaseLayout.ts deleted file mode 100755 index 74d5378..0000000 --- a/apps/web/src/components/common/editor/graph/layout/BaseLayout.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { LayoutOptions, LayoutStrategy, NodeWithLayout } from "./types"; -import { Edge, Node } from "@xyflow/react"; -// 抽象布局类,包含共用的工具方法 -export abstract class BaseLayout implements LayoutStrategy { - protected buildNodeMap(nodes: Node[]): Map { - const nodeMap = new Map(); - nodes.forEach(node => { - nodeMap.set(node.id, { ...node, children: [], width: 150, height: 40 }); - }); - return nodeMap; - } - - protected buildTreeStructure(nodeMap: Map, 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[] }; -} diff --git a/apps/web/src/components/common/editor/graph/layout/MindMapLayout.ts b/apps/web/src/components/common/editor/graph/layout/MindMapLayout.ts deleted file mode 100755 index 42779ca..0000000 --- a/apps/web/src/components/common/editor/graph/layout/MindMapLayout.ts +++ /dev/null @@ -1,87 +0,0 @@ -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; - }); - } -} \ No newline at end of file diff --git a/apps/web/src/components/common/editor/graph/layout/SingleMapLayout.ts b/apps/web/src/components/common/editor/graph/layout/SingleMapLayout.ts deleted file mode 100755 index 2d7d68c..0000000 --- a/apps/web/src/components/common/editor/graph/layout/SingleMapLayout.ts +++ /dev/null @@ -1,127 +0,0 @@ -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 - ); - }); - } -} \ No newline at end of file diff --git a/apps/web/src/components/common/editor/graph/layout/TreeLayout.ts b/apps/web/src/components/common/editor/graph/layout/TreeLayout.ts deleted file mode 100755 index 31fe186..0000000 --- a/apps/web/src/components/common/editor/graph/layout/TreeLayout.ts +++ /dev/null @@ -1,68 +0,0 @@ -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); - }); - } -} \ No newline at end of file diff --git a/apps/web/src/components/common/editor/graph/layout/edge/algorithms/a-star.ts b/apps/web/src/components/common/editor/graph/layout/edge/algorithms/a-star.ts deleted file mode 100755 index 4b71dea..0000000 --- a/apps/web/src/components/common/editor/graph/layout/edge/algorithms/a-star.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { areLinesReverseDirection, areLinesSameDirection } from "../edge"; -import { - ControlPoint, - NodeRect, - isEqualPoint, - isSegmentCrossingRect, -} from "../point"; - -interface GetAStarPathParams { - /** - * Collection of potential control points between `sourceOffset` and `targetOffset`, excluding the `source` and `target` points. - */ - points: ControlPoint[]; - source: ControlPoint; - target: ControlPoint; - /** - * Node size information for the `source` and `target`, used to optimize edge routing without intersecting nodes. - */ - sourceRect: NodeRect; - targetRect: NodeRect; -} - -/** - * Utilizes the [A\* search algorithm](https://en.wikipedia.org/wiki/A*_search_algorithm) combined with - * [Manhattan Distance](https://simple.wikipedia.org/wiki/Manhattan_distance) to find the optimal path for edges. - * - * @returns Control points including sourceOffset and targetOffset (not including source and target points). - */ -export const getAStarPath = ({ - points, - source, - target, - sourceRect, - targetRect, -}: GetAStarPathParams): ControlPoint[] => { - if (points.length < 3) { - return points; - } - const start = points[0]; - const end = points[points.length - 1]; - const openSet: ControlPoint[] = [start]; - const closedSet: Set = new Set(); - const cameFrom: Map = new Map(); - const gScore: Map = new Map().set(start, 0); - const fScore: Map = new Map().set( - start, - heuristicCostEstimate({ - from: start, - to: start, - start, - end, - source, - target, - }) - ); - - while (openSet.length) { - let current; - let currentIdx; - let lowestFScore = Infinity; - openSet.forEach((p, idx) => { - const score = fScore.get(p) ?? 0; - if (score < lowestFScore) { - lowestFScore = score; - current = p; - currentIdx = idx; - } - }); - - if (!current) { - break; - } - - if (current === end) { - return buildPath(cameFrom, current); - } - - openSet.splice(currentIdx!, 1); - closedSet.add(current); - - const curFScore = fScore.get(current) ?? 0; - const previous = cameFrom.get(current); - const neighbors = getNextNeighborPoints({ - points, - previous, - current, - sourceRect, - targetRect, - }); - for (const neighbor of neighbors) { - if (closedSet.has(neighbor)) { - continue; - } - const neighborGScore = gScore.get(neighbor) ?? 0; - const tentativeGScore = curFScore + estimateDistance(current, neighbor); - if (openSet.includes(neighbor) && tentativeGScore >= neighborGScore) { - continue; - } - openSet.push(neighbor); - cameFrom.set(neighbor, current); - gScore.set(neighbor, tentativeGScore); - fScore.set( - neighbor, - neighborGScore + - heuristicCostEstimate({ - from: current, - to: neighbor, - start, - end, - source, - target, - }) - ); - } - } - return [start, end]; -}; - -const buildPath = ( - cameFrom: Map, - current: ControlPoint -): ControlPoint[] => { - const path = [current]; - - let previous = cameFrom.get(current); - while (previous) { - path.push(previous); - previous = cameFrom.get(previous); - } - - return path.reverse(); -}; - -interface GetNextNeighborPointsParams { - points: ControlPoint[]; - previous?: ControlPoint; - current: ControlPoint; - sourceRect: NodeRect; - targetRect: NodeRect; -} - -/** - * Get the set of possible neighboring points for the current control point - * - * - The line is in a horizontal or vertical direction - * - The line does not intersect with the two end nodes - * - The line does not overlap with the previous line segment in reverse direction - */ -export const getNextNeighborPoints = ({ - points, - previous, - current, - sourceRect, - targetRect, -}: GetNextNeighborPointsParams): ControlPoint[] => { - return points.filter((p) => { - if (p === current) { - return false; - } - // The connection is in the horizontal or vertical direction - const rightDirection = p.x === current.x || p.y === current.y; - // Reverse direction with the previous line segment (overlap) - const reverseDirection = previous - ? areLinesReverseDirection(previous, current, current, p) - : false; - return ( - rightDirection && // The line is in a horizontal or vertical direction - !reverseDirection && // The line does not overlap with the previous line segment in reverse direction - !isSegmentCrossingRect(p, current, sourceRect) && // Does not intersect with sourceNode - !isSegmentCrossingRect(p, current, targetRect) // Does not intersect with targetNode - ); - }); -}; - -/** - * 路径规划所需的启发式代价计算参数接口 - * 包含了计算路径代价所需的所有控制点信息: - * - from/to: 当前路径段的起点和终点 - * - start/end: 整条路径的起点和终点 - * - source/target: 连接的源节点和目标节点位置 - */ -interface HeuristicCostParams { - from: ControlPoint; // 当前路径段的起点 - to: ControlPoint; // 当前路径段的终点 - start: ControlPoint; // 整条路径的起始点 - end: ControlPoint; // 整条路径的终点 - source: ControlPoint; // 源节点的连接点 - target: ControlPoint; // 目标节点的连接点 -} - -/** - * 启发式路径代价估算函数 - * - * 该函数通过多个因素综合评估路径的优劣程度: - * 1. 基础代价: 当前点到起点和终点的曼哈顿距离之和 - * 2. 起点优化: 如果是起始段,判断方向一致性给予奖励 - * 3. 终点优化: 如果是结束段,判断方向一致性给予奖励 - * - * 优化目标: - * - 减少路径总长度 - * - 保持路径走向的连续性 - * - 使拐点在路径中更均匀分布 - * - * @param params 包含所有必要控制点的参数对象 - * @returns 计算得到的启发式代价值,值越小路径越优 - */ -const heuristicCostEstimate = ({ - from, - to, - start, - end, - source, - target, -}: HeuristicCostParams): number => { - // 计算基础代价 - 到起点和终点的距离之和 - const base = estimateDistance(to, start) + estimateDistance(to, end); - - // 起点方向优化 - 如果是起始段且方向一致,给予奖励 - const startCost = isEqualPoint(from, start) - ? areLinesSameDirection(from, to, source, start) - ? -base / 2 // 方向一致时减少代价 - : 0 - : 0; - - // 终点方向优化 - 如果是结束段且方向一致,给予奖励 - const endCost = isEqualPoint(to, end) - ? areLinesSameDirection(from, to, end, target) - ? -base / 2 // 方向一致时减少代价 - : 0 - : 0; - - return base + startCost + endCost; -}; - -/** - * 计算两点间的估计距离 - * - * 采用曼哈顿距离(Manhattan distance)计算: - * - 只计算水平和垂直方向的距离之和 - * - 避免使用欧几里得距离的开方运算 - * - 在网格化的路径规划中性能更优 - * - * @param p1 第一个控制点 - * @param p2 第二个控制点 - * @returns 两点间的曼哈顿距离 - */ -const estimateDistance = (p1: ControlPoint, p2: ControlPoint): number => - Math.abs(p1.x - p2.x) + Math.abs(p1.y - p2.y); \ No newline at end of file diff --git a/apps/web/src/components/common/editor/graph/layout/edge/algorithms/index.ts b/apps/web/src/components/common/editor/graph/layout/edge/algorithms/index.ts deleted file mode 100755 index 8530033..0000000 --- a/apps/web/src/components/common/editor/graph/layout/edge/algorithms/index.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { areLinesSameDirection, isHorizontalFromPosition } from "../edge"; -import { - ControlPoint, - HandlePosition, - NodeRect, - getCenterPoints, - getExpandedRect, - getOffsetPoint, - getSidesFromPoints, - getVerticesFromRectVertex, - optimizeInputPoints, - reducePoints, -} from "../point"; -import { getAStarPath } from "./a-star"; -import { getSimplePath } from "./simple"; - -/** - * 边缘控制点计算模块 - * 用于计算图形边缘连接线的控制点,以实现平滑的连接效果 - * 主要应用于流程图、思维导图等需要节点间连线的场景 - */ - -/** - * 控制点计算所需的输入参数接口 - */ -export interface GetControlPointsParams { - source: HandlePosition; // 起始连接点位置 - target: HandlePosition; // 目标连接点位置 - sourceRect: NodeRect; // 起始节点的矩形区域 - targetRect: NodeRect; // 目标节点的矩形区域 - /** - * 边缘与节点之间的最小间距 - * @default 20 - */ - offset: number; -} - -/** - * 计算两个节点之间连接线的控制点 - * @param params 控制点计算参数 - * @returns 返回优化后的路径点和输入点集合 - */ -export const getControlPoints = ({ - source: oldSource, - target: oldTarget, - sourceRect, - targetRect, - offset = 20, -}: GetControlPointsParams) => { - const source: ControlPoint = oldSource; - const target: ControlPoint = oldTarget; - let edgePoints: ControlPoint[] = []; - let optimized: ReturnType; - - // 1. 计算考虑偏移量后的起始和结束点 - const sourceOffset = getOffsetPoint(oldSource, offset); - const targetOffset = getOffsetPoint(oldTarget, offset); - const expandedSource = getExpandedRect(sourceRect, offset); - const expandedTarget = getExpandedRect(targetRect, offset); - - // 2. 判断两个矩形是否靠得较近或应该直接连接 - const minOffset = 2 * offset + 10; // 最小间距阈值 - const isHorizontalLayout = isHorizontalFromPosition(oldSource.position); // 是否为水平布局 - const isSameDirection = areLinesSameDirection( - source, - sourceOffset, - targetOffset, - target - ); // 判断是否同向 - const sides = getSidesFromPoints([ - source, - target, - sourceOffset, - targetOffset, - ]); // 获取边界信息 - - // 判断节点是否过近 - const isTooClose = isHorizontalLayout - ? sides.right - sides.left < minOffset - : sides.bottom - sides.top < minOffset; - // 判断是否可以直接连接 - const isDirectConnect = isHorizontalLayout - ? isSameDirection && source.x < target.x - : isSameDirection && source.y < target.y; - - if (isTooClose || isDirectConnect) { - // 3. 如果节点较近或可直接连接,返回简单路径 - edgePoints = getSimplePath({ - source, - target, - sourceOffset, - targetOffset, - isDirectConnect, - }); - // 优化输入点 - optimized = optimizeInputPoints({ - source: oldSource, - target: oldTarget, - sourceOffset, - targetOffset, - edgePoints, - }); - edgePoints = optimized.edgePoints; - } else { - // 3. 获取两个扩展矩形的顶点 - edgePoints = [ - ...getVerticesFromRectVertex(expandedSource, targetOffset), - ...getVerticesFromRectVertex(expandedTarget, sourceOffset), - ]; - // 4. 计算可能的中点和交点 - edgePoints = edgePoints.concat( - getCenterPoints({ - source: expandedSource, - target: expandedTarget, - sourceOffset, - targetOffset, - }) - ); - // 5. 合并临近坐标点并去除重复点 - optimized = optimizeInputPoints({ - source: oldSource, - target: oldTarget, - sourceOffset, - targetOffset, - edgePoints, - }); - // 6. 使用A*算法寻找最优路径 - edgePoints = getAStarPath({ - points: optimized.edgePoints, - source: optimized.source, - target: optimized.target, - sourceRect: getExpandedRect(sourceRect, offset / 2), - targetRect: getExpandedRect(targetRect, offset / 2), - }); - } - - // 返回简化后的路径点和输入点集合 - return { - points: reducePoints([optimized.source, ...edgePoints, optimized.target]), - inputPoints: optimized.edgePoints, - }; -}; \ No newline at end of file diff --git a/apps/web/src/components/common/editor/graph/layout/edge/algorithms/simple.ts b/apps/web/src/components/common/editor/graph/layout/edge/algorithms/simple.ts deleted file mode 100755 index 8545fd2..0000000 --- a/apps/web/src/components/common/editor/graph/layout/edge/algorithms/simple.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { uuid } from "../../../utils/uuid"; -import { LayoutDirection } from "../../node"; -import { ControlPoint, isInLine, isOnLine } from "../point"; - -interface GetSimplePathParams { - isDirectConnect?: boolean; - source: ControlPoint; - target: ControlPoint; - sourceOffset: ControlPoint; - targetOffset: ControlPoint; -} - -const getLineDirection = ( - start: ControlPoint, - end: ControlPoint -): LayoutDirection => (start.x === end.x ? "vertical" : "horizontal"); - -/** - * When two nodes are too close, use the simple path - * - * @returns Control points including sourceOffset and targetOffset (not including source and target points). - */ -export const getSimplePath = ({ - isDirectConnect, - source, - target, - sourceOffset, - targetOffset, -}: GetSimplePathParams): ControlPoint[] => { - const points: ControlPoint[] = []; - const sourceDirection = getLineDirection(source, sourceOffset); - const targetDirection = getLineDirection(target, targetOffset); - const isHorizontalLayout = sourceDirection === "horizontal"; - if (isDirectConnect) { - // Direct connection, return a simple Path - if (isHorizontalLayout) { - if (sourceOffset.x <= targetOffset.x) { - const centerX = (sourceOffset.x + targetOffset.x) / 2; - return [ - { id: uuid(), x: centerX, y: sourceOffset.y }, - { id: uuid(), x: centerX, y: targetOffset.y }, - ]; - } else { - const centerY = (sourceOffset.y + targetOffset.y) / 2; - return [ - sourceOffset, - { id: uuid(), x: sourceOffset.x, y: centerY }, - { id: uuid(), x: targetOffset.x, y: centerY }, - targetOffset, - ]; - } - } else { - if (sourceOffset.y <= targetOffset.y) { - const centerY = (sourceOffset.y + targetOffset.y) / 2; - return [ - { id: uuid(), x: sourceOffset.x, y: centerY }, - { id: uuid(), x: targetOffset.x, y: centerY }, - ]; - } else { - const centerX = (sourceOffset.x + targetOffset.x) / 2; - return [ - sourceOffset, - { id: uuid(), x: centerX, y: sourceOffset.y }, - { id: uuid(), x: centerX, y: targetOffset.y }, - targetOffset, - ]; - } - } - } - if (sourceDirection === targetDirection) { - // Same direction, add two points, two endpoints of parallel lines at half the vertical distance - if (source.y === sourceOffset.y) { - points.push({ - id: uuid(), - x: sourceOffset.x, - y: (sourceOffset.y + targetOffset.y) / 2, - }); - points.push({ - id: uuid(), - x: targetOffset.x, - y: (sourceOffset.y + targetOffset.y) / 2, - }); - } else { - points.push({ - id: uuid(), - x: (sourceOffset.x + targetOffset.x) / 2, - y: sourceOffset.y, - }); - points.push({ - id: uuid(), - x: (sourceOffset.x + targetOffset.x) / 2, - y: targetOffset.y, - }); - } - } else { - // Different directions, add one point, ensure it's not on the current line segment (to avoid overlap), and there are no turns - let point = { id: uuid(), x: sourceOffset.x, y: targetOffset.y }; - const inStart = isInLine(point, source, sourceOffset); - const inEnd = isInLine(point, target, targetOffset); - if (inStart || inEnd) { - point = { id: uuid(), x: targetOffset.x, y: sourceOffset.y }; - } else { - const onStart = isOnLine(point, source, sourceOffset); - const onEnd = isOnLine(point, target, targetOffset); - if (onStart && onEnd) { - point = { id: uuid(), x: targetOffset.x, y: sourceOffset.y }; - } - } - points.push(point); - } - return [sourceOffset, ...points, targetOffset]; -}; diff --git a/apps/web/src/components/common/editor/graph/layout/edge/edge.ts b/apps/web/src/components/common/editor/graph/layout/edge/edge.ts deleted file mode 100755 index 0f25f05..0000000 --- a/apps/web/src/components/common/editor/graph/layout/edge/edge.ts +++ /dev/null @@ -1,389 +0,0 @@ -import { Position, XYPosition } from "@xyflow/react"; -import { ControlPoint, HandlePosition } from "./point"; -import { uuid } from "../../utils/uuid"; - -export interface ILine { - start: ControlPoint; - end: ControlPoint; -} - -/** - * 判断给定位置是否为水平方向 - * @param position - 位置枚举值 - * @returns 如果是左侧或右侧位置则返回true,否则返回false - */ -export const isHorizontalFromPosition = (position: Position) => { - return [Position.Left, Position.Right].includes(position); -}; - -/** - * 判断连接是否为反向 - * 在图形布局中,通常希望连线从左到右或从上到下。当连线方向与此相反时,即为反向连接 - * @param props - 包含源点和目标点位置信息的对象 - * @param props.source - 源节点的位置信息 - * @param props.target - 目标节点的位置信息 - * @returns 如果是反向连接则返回true,否则返回false - */ -export const isConnectionBackward = (props: { - source: HandlePosition; - target: HandlePosition; -}) => { - const { source, target } = props; - // 判断是水平还是垂直方向的连接 - const isHorizontal = isHorizontalFromPosition(source.position); - let isBackward = false; - - // 水平方向时,如果源点x坐标大于目标点,则为反向 - if (isHorizontal) { - if (source.x > target.x) { - isBackward = true; - } - } - // 垂直方向时,如果源点y坐标大于目标点,则为反向 - else { - if (source.y > target.y) { - isBackward = true; - } - } - return isBackward; -}; - -/** - * 计算两点之间的欧几里得距离 - * 使用勾股定理(Math.hypot)计算两点间的直线距离 - * @param p1 - 第一个控制点 - * @param p2 - 第二个控制点 - * @returns 两点间的距离 - */ -export const distance = (p1: ControlPoint, p2: ControlPoint) => { - return Math.hypot(p2.x - p1.x, p2.y - p1.y); -}; - -/** - * 计算线段的中点坐标 - * 通过取两端点坐标的算术平均值来确定中点位置 - * @param p1 - 第一个控制点 - * @param p2 - 第二个控制点 - * @returns 包含中点坐标和唯一标识的控制点对象 - */ -export const getLineCenter = ( - p1: ControlPoint, - p2: ControlPoint -): ControlPoint => { - return { - id: uuid(), - x: (p1.x + p2.x) / 2, // x坐标取两端点x坐标的平均值 - y: (p1.y + p2.y) / 2, // y坐标取两端点y坐标的平均值 - }; -}; - -/** - * 判断点是否在线段上 - * - * @description - * 该函数用于检测给定的点是否位于由两个控制点构成的线段上。 - * 判断逻辑分为两种情况: - * 1. 垂直线段: 当起点和终点的x坐标相同时,判断目标点的x坐标是否等于线段x坐标,且y坐标在线段y坐标范围内 - * 2. 水平线段: 当起点和终点的y坐标相同时,判断目标点的y坐标是否等于线段y坐标,且x坐标在线段x坐标范围内 - * - * @param start - 线段起点坐标 - * @param end - 线段终点坐标 - * @param p - 待检测点的坐标 - * @returns {boolean} 如果点在线段上返回true,否则返回false - */ -export const isLineContainsPoint = ( - start: ControlPoint, - end: ControlPoint, - p: ControlPoint -) => { - return ( - // 判断垂直线段 - (start.x === end.x && // 起点终点x坐标相同 - p.x === start.x && // 目标点x坐标与线段相同 - p.y <= Math.max(start.y, end.y) && // 目标点y坐标不超过线段y坐标最大值 - p.y >= Math.min(start.y, end.y)) || // 目标点y坐标不小于线段y坐标最小值 - // 判断水平线段 - (start.y === end.y && // 起点终点y坐标相同 - p.y === start.y && // 目标点y坐标与线段相同 - p.x <= Math.max(start.x, end.x) && // 目标点x坐标不超过线段x坐标最大值 - p.x >= Math.min(start.x, end.x)) // 目标点x坐标不小于线段x坐标最小值 - ); -}; -/** -/** - * 生成带圆角转角的SVG路径 - * - * 该函数用于在图形编辑器中生成连接两点之间的边线路径。路径具有以下特点: - * 1. 两个控制点之间为直线段 - * 2. 在转折点处生成圆角过渡 - * 3. 支持垂直和水平方向的转角 - * - * @param points 控制点数组,包含边的起点、终点和中间的转折点 - * - 至少需要2个点(起点和终点) - * - 点的顺序应从输入端点开始到输出端点结束 - * @param radius 转角处的圆角半径 - * @returns 返回SVG路径字符串 - * @throws 当points数组长度小于2时抛出错误 - */ -export function getPathWithRoundCorners( - points: ControlPoint[], - radius: number -): string { - if (points.length < 2) { - throw new Error("At least 2 points are required."); - } - - /** - * 计算两条线段交点处的圆角路径 - * @param center 转折点坐标 - * @param p1 前一个点的坐标 - * @param p2 后一个点的坐标 - * @param radius 圆角半径 - * @returns SVG路径命令字符串 - */ - function getRoundCorner( - center: ControlPoint, - p1: ControlPoint, - p2: ControlPoint, - radius: number - ) { - const { x, y } = center; - - // 如果两条线段不垂直,则直接返回直线路径 - if (!areLinesPerpendicular(p1, center, center, p2)) { - return `L ${x} ${y}`; - } - - // 计算实际可用的圆角半径,取三个值中的最小值: - // 1. 与前一个点的距离的一半 - // 2. 与后一个点的距离的一半 - // 3. 传入的目标半径 - const d1 = distance(center, p1); - const d2 = distance(center, p2); - radius = Math.min(d1 / 2, d2 / 2, radius); - - // 判断第一条线段是否为水平线 - const isHorizontal = p1.y === y; - - // 根据点的相对位置确定圆角绘制方向 - const xDir = isHorizontal ? (p1.x < p2.x ? -1 : 1) : p1.x < p2.x ? 1 : -1; - const yDir = isHorizontal ? (p1.y < p2.y ? 1 : -1) : p1.y < p2.y ? -1 : 1; - - // 根据线段方向生成不同的圆角路径 - if (isHorizontal) { - return `L ${x + radius * xDir},${y}Q ${x},${y} ${x},${y + radius * yDir}`; - } - return `L ${x},${y + radius * yDir}Q ${x},${y} ${x + radius * xDir},${y}`; - } - - // 构建完整的SVG路径 - const path: string[] = []; - for (let i = 0; i < points.length; i++) { - if (i === 0) { - // 起点使用移动命令M - path.push(`M ${points[i].x} ${points[i].y}`); - } else if (i === points.length - 1) { - // 终点使用直线命令L - path.push(`L ${points[i].x} ${points[i].y}`); - } else { - // 中间点使用圆角转角 - path.push( - getRoundCorner(points[i], points[i - 1], points[i + 1], radius) - ); - } - } - - // 将所有路径命令连接成完整的路径字符串 - return path.join(" "); -} -/** - * 获取折线中最长的线段 - * @param points 控制点数组,每个点包含x和y坐标 - * @returns 返回最长线段的起点和终点坐标 - * - * 实现原理: - * 1. 初始化第一条线段为最长线段 - * 2. 遍历所有相邻点对,计算线段长度 - * 3. 如果找到更长的线段,则更新最长线段记录 - * 4. 返回最长线段的两个端点 - */ -export function getLongestLine( - points: ControlPoint[] -): [ControlPoint, ControlPoint] { - let longestLine: [ControlPoint, ControlPoint] = [points[0], points[1]]; - let longestDistance = distance(...longestLine); - for (let i = 1; i < points.length - 1; i++) { - const _distance = distance(points[i], points[i + 1]); - if (_distance > longestDistance) { - longestDistance = _distance; - longestLine = [points[i], points[i + 1]]; - } - } - return longestLine; -} - -/** - * 计算折线上标签的位置 - * @param points 控制点数组 - * @param minGap 最小间隔距离,默认为20 - * @returns 标签的坐标位置 - * - * 计算逻辑: - * 1. 如果折线点数为偶数: - * - 取中间两个点 - * - 如果这两点间距大于最小间隔,返回它们的中点 - * 2. 如果折线点数为奇数或中间段太短: - * - 找出最长的线段 - * - 返回最长线段的中点作为标签位置 - */ -export function getLabelPosition( - points: ControlPoint[], - minGap = 20 -): XYPosition { - if (points.length % 2 === 0) { - const middleP1 = points[points.length / 2 - 1]; - const middleP2 = points[points.length / 2]; - if (distance(middleP1, middleP2) > minGap) { - return getLineCenter(middleP1, middleP2); - } - } - const [start, end] = getLongestLine(points); - return { - x: (start.x + end.x) / 2, - y: (start.y + end.y) / 2, - }; -} - -/** - * 判断两条线段是否垂直 - * @param p1,p2 第一条线段的起点和终点 - * @param p3,p4 第二条线段的起点和终点 - * @returns 如果两线段垂直则返回true - * - * 判断依据: - * - 假设线段要么水平要么垂直 - * - 当一条线段水平(y相等)而另一条垂直(x相等)时,两线段垂直 - */ -export function areLinesPerpendicular( - p1: ControlPoint, - p2: ControlPoint, - p3: ControlPoint, - p4: ControlPoint -): boolean { - return (p1.x === p2.x && p3.y === p4.y) || (p1.y === p2.y && p3.x === p4.x); -} - -/** - * 判断两条线段是否平行 - * @param p1,p2 第一条线段的起点和终点 - * @param p3,p4 第二条线段的起点和终点 - * @returns 如果两线段平行则返回true - * - * 判断依据: - * - 假设线段要么水平要么垂直 - * - 当两条线段都是水平的(x相等)或都是垂直的(y相等)时,两线段平行 - */ -export function areLinesParallel( - p1: ControlPoint, - p2: ControlPoint, - p3: ControlPoint, - p4: ControlPoint -) { - return (p1.x === p2.x && p3.x === p4.x) || (p1.y === p2.y && p3.y === p4.y); -} - -/** - * 判断两条线段是否同向 - * @param p1 第一条线段的起点 - * @param p2 第一条线段的终点 - * @param p3 第二条线段的起点 - * @param p4 第二条线段的终点 - * @returns boolean 如果两线段同向返回true,否则返回false - * - * 判断逻辑: - * 1. 对于水平线段(y坐标相等),判断x方向的变化是否同向 - * 2. 对于垂直线段(x坐标相等),判断y方向的变化是否同向 - */ -export function areLinesSameDirection( - p1: ControlPoint, - p2: ControlPoint, - p3: ControlPoint, - p4: ControlPoint -) { - return ( - // 判断垂直线段是否同向 - (p1.x === p2.x && p3.x === p4.x && (p1.y - p2.y) * (p3.y - p4.y) > 0) || - // 判断水平线段是否同向 - (p1.y === p2.y && p3.y === p4.y && (p1.x - p2.x) * (p3.x - p4.x) > 0) - ); -} - -/** - * 判断两条线段是否反向 - * @param p1 第一条线段的起点 - * @param p2 第一条线段的终点 - * @param p3 第二条线段的起点 - * @param p4 第二条线段的终点 - * @returns boolean 如果两线段反向返回true,否则返回false - * - * 判断逻辑: - * 1. 对于水平线段(y坐标相等),判断x方向的变化是否反向 - * 2. 对于垂直线段(x坐标相等),判断y方向的变化是否反向 - */ -export function areLinesReverseDirection( - p1: ControlPoint, - p2: ControlPoint, - p3: ControlPoint, - p4: ControlPoint -) { - return ( - // 判断垂直线段是否反向 - (p1.x === p2.x && p3.x === p4.x && (p1.y - p2.y) * (p3.y - p4.y) < 0) || - // 判断水平线段是否反向 - (p1.y === p2.y && p3.y === p4.y && (p1.x - p2.x) * (p3.x - p4.x) < 0) - ); -} - -/** - * 计算两条线段之间的夹角 - * @param p1 第一条线段的起点 - * @param p2 第一条线段的终点 - * @param p3 第二条线段的起点 - * @param p4 第二条线段的终点 - * @returns number 两线段之间的夹角(单位:度) - * - * 计算步骤: - * 1. 计算两个向量 - * 2. 计算向量的点积 - * 3. 计算向量的模长 - * 4. 使用反余弦函数计算弧度 - * 5. 将弧度转换为角度 - */ -export function getAngleBetweenLines( - p1: ControlPoint, - p2: ControlPoint, - p3: ControlPoint, - p4: ControlPoint -) { - // 计算两条线段对应的向量 - const v1 = { x: p2.x - p1.x, y: p2.y - p1.y }; - const v2 = { x: p4.x - p3.x, y: p4.y - p3.y }; - - // 计算向量的点积 - const dotProduct = v1.x * v2.x + v1.y * v2.y; - - // 计算两个向量的模长 - const magnitude1 = Math.sqrt(v1.x ** 2 + v1.y ** 2); - const magnitude2 = Math.sqrt(v2.x ** 2 + v2.y ** 2); - - // 计算夹角的余弦值 - const cosine = dotProduct / (magnitude1 * magnitude2); - - // 使用反余弦函数计算弧度 - const angleInRadians = Math.acos(cosine); - - // 将弧度转换为角度并返回 - const angleInDegrees = (angleInRadians * 180) / Math.PI; - - return angleInDegrees; -} \ No newline at end of file diff --git a/apps/web/src/components/common/editor/graph/layout/edge/index.ts b/apps/web/src/components/common/editor/graph/layout/edge/index.ts deleted file mode 100755 index 8e3f699..0000000 --- a/apps/web/src/components/common/editor/graph/layout/edge/index.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { EdgeLayout } from "../../types"; -import { getControlPoints, GetControlPointsParams } from "./algorithms"; -import { getLabelPosition, getPathWithRoundCorners } from "./edge"; -import { InternalNode, Node } from "@xyflow/react" -interface GetBasePathParams extends GetControlPointsParams { - borderRadius: number; -} - -export function getBasePath({ - id, - offset, - borderRadius, - source, - target, - sourceX, - sourceY, - targetX, - targetY, - sourcePosition, - targetPosition, -}: any) { - const sourceNode: InternalNode = - kReactFlow.instance!.getNode(source)!; - const targetNode: InternalNode = - kReactFlow.instance!.getNode(target)!; - return getPathWithPoints({ - offset, - borderRadius, - source: { - id: "source-" + id, - x: sourceX, - y: sourceY, - position: sourcePosition, - }, - target: { - id: "target-" + id, - x: targetX, - y: targetY, - position: targetPosition, - }, - sourceRect: { - ...(sourceNode.internals.positionAbsolute || sourceNode.position), - width: sourceNode.width!, - height: sourceNode.height!, - }, - targetRect: { - ...(targetNode.internals.positionAbsolute || targetNode.position), - width: targetNode.width!, - height: targetNode.height!, - }, - }); -} - -export function getPathWithPoints({ - source, - target, - sourceRect, - targetRect, - offset = 20, - borderRadius = 16, -}: GetBasePathParams): EdgeLayout { - const { points, inputPoints } = getControlPoints({ - source, - target, - offset, - sourceRect, - targetRect, - }); - const labelPosition = getLabelPosition(points); - const path = getPathWithRoundCorners(points, borderRadius); - return { path, points, inputPoints, labelPosition }; -} diff --git a/apps/web/src/components/common/editor/graph/layout/edge/point.ts b/apps/web/src/components/common/editor/graph/layout/edge/point.ts deleted file mode 100755 index 7c0fdf1..0000000 --- a/apps/web/src/components/common/editor/graph/layout/edge/point.ts +++ /dev/null @@ -1,623 +0,0 @@ -import { Position } from "@xyflow/react"; -import { isHorizontalFromPosition } from "./edge"; -import { uuid } from "../../utils/uuid"; - -export interface ControlPoint { - id: string; - x: number; - y: number; -} - -export interface NodeRect { - x: number; // left - y: number; // top - width: number; - height: number; -} - -export interface RectSides { - top: number; - right: number; - bottom: number; - left: number; -} - -export interface HandlePosition extends ControlPoint { - position: Position; -} - -export interface GetVerticesParams { - source: NodeRect; - target: NodeRect; - sourceOffset: ControlPoint; - targetOffset: ControlPoint; -} - -/** - * 计算两个节点之间的控制点位置 - * - * 该函数用于在图形编辑器中确定边的控制点,以实现更自然的边布局。 - * 主要应用于: - * 1. 节点之间连线的路径规划 - * 2. 边的弯曲程度控制 - * 3. 避免边与节点重叠 - * - * 实现原理: - * 1. 基于源节点和目标节点构建外部边界矩形 - * 2. 基于偏移点构建内部边界矩形 - * 3. 在两个矩形的边上生成候选控制点 - * 4. 过滤掉无效的控制点 - * - * @param {GetVerticesParams} params - 计算所需的参数 - * @param {Rect} params.source - 源节点的矩形区域,包含x、y、width、height - * @param {Rect} params.target - 目标节点的矩形区域 - * @param {Point} params.sourceOffset - 源节点上的连接点坐标 - * @param {Point} params.targetOffset - 目标节点上的连接点坐标 - * @returns {ControlPoint[]} 有效的控制点数组,每个点包含唯一ID和坐标 - */ -export const getCenterPoints = ({ - source, - target, - sourceOffset, - targetOffset, -}: GetVerticesParams): ControlPoint[] => { - // 特殊情况处理:当源点和目标点在同一直线上时,无法构成有效的控制区域 - if (sourceOffset.x === targetOffset.x || sourceOffset.y === targetOffset.y) { - return []; - } - - // 步骤1: 获取外部边界 - // 收集两个节点的所有顶点,用于构建外部最大矩形 - const vertices = [...getRectVertices(source), ...getRectVertices(target)]; - const outerSides = getSidesFromPoints(vertices); - - // 步骤2: 获取内部边界 - // 根据偏移点(实际连接点)计算内部矩形的四条边 - const { left, right, top, bottom } = getSidesFromPoints([ - sourceOffset, - targetOffset, - ]); - - // 步骤3: 计算中心参考线 - const centerX = (left + right) / 2; // 水平中心线 - const centerY = (top + bottom) / 2; // 垂直中心线 - - // 步骤4: 生成候选控制点 - // 在内外两个矩形的边上各生成4个控制点,共8个候选点 - const points = [ - { id: uuid(), x: centerX, y: top }, // 内矩形-上 - { id: uuid(), x: right, y: centerY }, // 内矩形-右 - { id: uuid(), x: centerX, y: bottom }, // 内矩形-下 - { id: uuid(), x: left, y: centerY }, // 内矩形-左 - { id: uuid(), x: centerX, y: outerSides.top }, // 外矩形-上 - { id: uuid(), x: outerSides.right, y: centerY }, // 外矩形-右 - { id: uuid(), x: centerX, y: outerSides.bottom },// 外矩形-下 - { id: uuid(), x: outerSides.left, y: centerY }, // 外矩形-左 - ]; - - // 步骤5: 过滤无效控制点 - // 移除落在源节点或目标节点内部的控制点,避免边穿过节点 - return points.filter((p) => { - return !isPointInRect(p, source) && !isPointInRect(p, target); - }); -}; - -/** - * 扩展矩形区域 - * @param rect 原始矩形区域 - * @param offset 扩展偏移量 - * @returns 扩展后的新矩形区域 - * - * 该函数将一个矩形区域向四周扩展指定的偏移量。 - * 扩展规则: - * 1. x和y坐标各向外偏移offset距离 - * 2. 宽度和高度各增加2*offset - */ -export const getExpandedRect = (rect: NodeRect, offset: number): NodeRect => { - return { - x: rect.x - offset, - y: rect.y - offset, - width: rect.width + 2 * offset, - height: rect.height + 2 * offset, - }; -}; - -/** - * 检测两个矩形是否重叠 - * @param rect1 第一个矩形 - * @param rect2 第二个矩形 - * @returns 布尔值,true表示重叠,false表示不重叠 - * - * 使用AABB(轴对齐包围盒)碰撞检测算法: - * 1. 计算x轴投影是否重叠 - * 2. 计算y轴投影是否重叠 - * 两个轴向都重叠则矩形重叠 - */ -export const isRectOverLapping = (rect1: NodeRect, rect2: NodeRect) => { - return ( - Math.abs(rect1.x - rect2.x) < (rect1.width + rect2.width) / 2 && - Math.abs(rect1.y - rect2.y) < (rect1.height + rect2.height) / 2 - ); -}; - -/** - * 判断点是否在矩形内 - * @param p 待检测的控制点 - * @param box 矩形区域 - * @returns 布尔值,true表示点在矩形内,false表示点在矩形外 - * - * 点在矩形内的条件: - * 1. x坐标在矩形左右边界之间 - * 2. y坐标在矩形上下边界之间 - */ -export const isPointInRect = (p: ControlPoint, box: NodeRect) => { - const sides = getRectSides(box); - return ( - p.x >= sides.left && - p.x <= sides.right && - p.y >= sides.top && - p.y <= sides.bottom - ); -}; - -/** - * 矩形顶点计算模块 - * 用于处理图形编辑器中矩形节点的顶点、边界等几何计算 - * 主要应用于连线路径规划和节点定位 - */ - -/** - * 根据矩形和外部顶点计算包围矩形的顶点坐标 - * @param box 原始矩形的位置和尺寸信息 - * @param vertex 外部控制点 - * @returns 包围矩形的四个顶点坐标 - * 算法思路: - * 1. 合并外部顶点和原矩形的顶点 - * 2. 计算所有点的边界范围 - * 3. 根据边界生成新的矩形顶点 - */ -export const getVerticesFromRectVertex = ( - box: NodeRect, - vertex: ControlPoint -): ControlPoint[] => { - const points = [vertex, ...getRectVertices(box)]; - const { top, right, bottom, left } = getSidesFromPoints(points); - return [ - { id: uuid(), x: left, y: top }, // 左上角顶点 - { id: uuid(), x: right, y: top }, // 右上角顶点 - { id: uuid(), x: right, y: bottom }, // 右下角顶点 - { id: uuid(), x: left, y: bottom }, // 左下角顶点 - ]; -}; - -/** - * 计算一组点的边界范围 - * @param points 控制点数组 - * @returns 返回边界的上下左右极值 - * 实现方式: - * - 使用数组map和Math.min/max计算坐标的最值 - */ -export const getSidesFromPoints = (points: ControlPoint[]) => { - const left = Math.min(...points.map((p) => p.x)); // 最左侧x坐标 - const right = Math.max(...points.map((p) => p.x)); // 最右侧x坐标 - const top = Math.min(...points.map((p) => p.y)); // 最上方y坐标 - const bottom = Math.max(...points.map((p) => p.y)); // 最下方y坐标 - return { top, right, bottom, left }; -}; - -/** - * 获取矩形的四条边界位置 - * @param box 矩形的位置和尺寸信息 - * @returns 矩形的上下左右边界坐标 - * 计算方式: - * - 左边界 = x坐标 - * - 右边界 = x + width - * - 上边界 = y坐标 - * - 下边界 = y + height - */ -export const getRectSides = (box: NodeRect): RectSides => { - const { x: left, y: top, width, height } = box; - const right = left + width; - const bottom = top + height; - return { top, right, bottom, left }; -}; - -/** - * 根据边界信息生成矩形的四个顶点 - * @param sides 矩形的上下左右边界坐标 - * @returns 返回四个顶点的坐标信息 - * 顶点顺序: 左上 -> 右上 -> 右下 -> 左下 - */ -export const getRectVerticesFromSides = ({ - top, - right, - bottom, - left, -}: RectSides): ControlPoint[] => { - return [ - { id: uuid(), x: left, y: top }, // 左上角顶点 - { id: uuid(), x: right, y: top }, // 右上角顶点 - { id: uuid(), x: right, y: bottom }, // 右下角顶点 - { id: uuid(), x: left, y: bottom }, // 左下角顶点 - ]; -}; - -/** - * 获取矩形的四个顶点坐标 - * @param box 矩形的位置和尺寸信息 - * @returns 返回矩形四个顶点的坐标 - * 实现流程: - * 1. 先计算矩形的边界 - * 2. 根据边界生成顶点 - */ -export const getRectVertices = (box: NodeRect) => { - const sides = getRectSides(box); - return getRectVerticesFromSides(sides); -}; -/** - * 合并多个矩形区域,返回一个能包含所有输入矩形的最小矩形 - * @param boxes 需要合并的矩形数组,每个矩形包含 x,y 坐标和宽高信息 - * @returns 合并后的最小包围矩形 - * - * 实现原理: - * 1. 找出所有矩形中最左边的 x 坐标(left)和最右边的 x 坐标(right) - * 2. 找出所有矩形中最上边的 y 坐标(top)和最下边的 y 坐标(bottom) - * 3. 用这四个边界值构造出新的矩形 - */ -export const mergeRects = (...boxes: NodeRect[]): NodeRect => { - // 计算所有矩形的最左边界 - const left = Math.min( - ...boxes.reduce((pre, e) => [...pre, e.x, e.x + e.width], [] as number[]) - ); - // 计算所有矩形的最右边界 - const right = Math.max( - ...boxes.reduce((pre, e) => [...pre, e.x, e.x + e.width], [] as number[]) - ); - // 计算所有矩形的最上边界 - const top = Math.min( - ...boxes.reduce((pre, e) => [...pre, e.y, e.y + e.height], [] as number[]) - ); - // 计算所有矩形的最下边界 - const bottom = Math.max( - ...boxes.reduce((pre, e) => [...pre, e.y, e.y + e.height], [] as number[]) - ); - - // 返回能包含所有输入矩形的最小矩形 - return { - x: left, // 左上角 x 坐标 - y: top, // 左上角 y 坐标 - width: right - left, // 宽度 = 最右边界 - 最左边界 - height: bottom - top, // 高度 = 最下边界 - 最上边界 - }; -}; - -/** - * 根据给定的位置和偏移量计算控制点坐标 - * @param box - 起始位置信息,包含x、y坐标和位置类型(上下左右) - * @param offset - 偏移距离 - * @returns 返回计算后的控制点对象,包含唯一id和新的x、y坐标 - */ -export const getOffsetPoint = ( - box: HandlePosition, - offset: number -): ControlPoint => { - // 根据不同的位置类型计算偏移后的坐标 - switch (box.position) { - case Position.Top: // 顶部位置,y坐标向上偏移 - return { - id: uuid(), - x: box.x, - y: box.y - offset, - }; - case Position.Bottom: // 底部位置,y坐标向下偏移 - return { id: uuid(), x: box.x, y: box.y + offset }; - case Position.Left: // 左侧位置,x坐标向左偏移 - return { id: uuid(), x: box.x - offset, y: box.y }; - case Position.Right: // 右侧位置,x坐标向右偏移 - return { id: uuid(), x: box.x + offset, y: box.y }; - } -}; - -/** - * 判断一个点是否在线段上 - * @param p - 待判断的点 - * @param p1 - 线段起点 - * @param p2 - 线段终点 - * @returns 如果点在线段上返回true,否则返回false - * - * 判断逻辑: - * 1. 点必须在线段所在的直线上(x坐标相等或y坐标相等) - * 2. 点的坐标必须在线段两端点坐标范围内 - */ -export const isInLine = ( - p: ControlPoint, - p1: ControlPoint, - p2: ControlPoint -) => { - // 获取x坐标的范围区间[min, max] - const xPoints = p1.x < p2.x ? [p1.x, p2.x] : [p2.x, p1.x]; - // 获取y坐标的范围区间[min, max] - const yPoints = p1.y < p2.y ? [p1.y, p2.y] : [p2.y, p1.y]; - - return ( - // 垂直线段:三点x坐标相等,且待判断点的y坐标在范围内 - (p1.x === p.x && p.x === p2.x && p.y >= yPoints[0] && p.y <= yPoints[1]) || - // 水平线段:三点y坐标相等,且待判断点的x坐标在范围内 - (p1.y === p.y && p.y === p2.y && p.x >= xPoints[0] && p.x <= xPoints[1]) - ); -}; - -/** - * 判断一个点是否在直线上(不考虑线段端点限制) - * @param p - 待判断的点 - * @param p1 - 直线上的点1 - * @param p2 - 直线上的点2 - * @returns 如果点在直线上返回true,否则返回false - * - * 判断逻辑: - * 仅判断点是否与直线上的两点共线(x坐标相等或y坐标相等) - */ -export const isOnLine = ( - p: ControlPoint, - p1: ControlPoint, - p2: ControlPoint -) => { - return (p1.x === p.x && p.x === p2.x) || (p1.y === p.y && p.y === p2.y); -}; -export interface OptimizePointsParams { - edgePoints: ControlPoint[]; - source: HandlePosition; - target: HandlePosition; - sourceOffset: ControlPoint; - targetOffset: ControlPoint; -} - -/** - * 优化边的控制点 - * - * 主要功能: - * 1. 合并坐标相近的点 - * 2. 删除重复的坐标点 - * 3. 修正起点和终点的位置 - * - * @param p 包含边的起点、终点、偏移点和中间控制点等信息的参数对象 - * @returns 优化后的控制点信息,包含起点、终点、起点偏移、终点偏移和中间控制点 - */ -export const optimizeInputPoints = (p: OptimizePointsParams) => { - // 合并坐标相近的点,将所有点放入一个数组进行处理 - let edgePoints = mergeClosePoints([ - p.source, - p.sourceOffset, - ...p.edgePoints, - p.targetOffset, - p.target, - ]); - - // 从合并后的点中提取起点和终点 - const source = edgePoints.shift()!; - const target = edgePoints.pop()!; - const sourceOffset = edgePoints[0]; - const targetOffset = edgePoints[edgePoints.length - 1]; - - // 根据起点和终点的位置类型修正其坐标 - // 如果是水平方向,则保持x坐标不变;否则保持y坐标不变 - if (isHorizontalFromPosition(p.source.position)) { - source.x = p.source.x; - } else { - source.y = p.source.y; - } - if (isHorizontalFromPosition(p.target.position)) { - target.x = p.target.x; - } else { - target.y = p.target.y; - } - - // 移除重复的坐标点,并为每个点分配唯一ID - edgePoints = removeRepeatPoints(edgePoints).map((p, idx) => ({ - ...p, - id: `${idx + 1}`, - })); - - return { source, target, sourceOffset, targetOffset, edgePoints }; -}; - -/** - * 简化边的控制点 - * - * 主要功能: - * 1. 确保直线上只保留两个端点 - * 2. 移除位于直线内部的控制点 - * - * 实现原理: - * - 遍历所有中间点 - * - 判断每个点是否在其相邻两点形成的直线上 - * - 如果在直线上则移除该点 - * - * @param points 原始控制点数组 - * @returns 简化后的控制点数组 - */ -export function reducePoints(points: ControlPoint[]): ControlPoint[] { - const optimizedPoints = [points[0]]; - - // 遍历除首尾点外的所有点 - for (let i = 1; i < points.length - 1; i++) { - // 判断当前点是否在前后两点形成的直线上 - const inSegment = isInLine(points[i], points[i - 1], points[i + 1]); - // 如果不在直线上,则保留该点 - if (!inSegment) { - optimizedPoints.push(points[i]); - } - } - - optimizedPoints.push(points[points.length - 1]); - return optimizedPoints; -} -/** - * 坐标点处理工具函数集合 - * 主要用于图形边缘控制点的坐标处理,包括合并、去重等操作 - */ - -/** - * 合并临近坐标点,同时将坐标值取整 - * @param points 控制点数组 - * @param threshold 合并阈值,默认为4个像素 - * @returns 处理后的控制点数组 - * - * 实现原理: - * 1. 分别记录x和y轴上的所有坐标值 - * 2. 对每个新坐标,在阈值范围内查找已存在的相近值 - * 3. 如果找到相近值则使用已存在值,否则添加新值 - */ -export function mergeClosePoints( - points: ControlPoint[], - threshold = 4 -): ControlPoint[] { - // 存储已处理的离散坐标值 - const positions = { x: [] as number[], y: [] as number[] }; - - /** - * 查找或添加坐标值 - * @param axis 坐标轴('x'|'y') - * @param v 待处理的坐标值 - * @returns 最终使用的坐标值 - */ - const findPosition = (axis: "x" | "y", v: number) => { - // 向下取整,确保坐标为整数 - v = Math.floor(v); - const ps = positions[axis]; - // 在阈值范围内查找已存在的相近值 - let p = ps.find((e) => Math.abs(v - e) < threshold); - // 如果没找到相近值,则添加新值 - if (p == null) { - p = v; - positions[axis].push(v); - } - return p; - }; - - // 处理每个控制点的坐标 - const finalPoints = points.map((point) => { - return { - ...point, - x: findPosition("x", point.x), - y: findPosition("y", point.y), - }; - }); - - return finalPoints; -} - -/** - * 判断两个控制点是否重合 - * @param p1 控制点1 - * @param p2 控制点2 - * @returns 是否重合 - */ -export function isEqualPoint(p1: ControlPoint, p2: ControlPoint) { - return p1.x === p2.x && p1.y === p2.y; -} - -/** - * 移除重复的控制点,但保留起点和终点 - * @param points 控制点数组 - * @returns 去重后的控制点数组 - * - * 实现思路: - * 1. 使用Set存储已处理的坐标字符串(格式:"x-y") - * 2. 保留最后一个点(终点) - * 3. 遍历时跳过重复坐标,但保留第一次出现的点 - */ -export function removeRepeatPoints(points: ControlPoint[]): ControlPoint[] { - // 先添加终点坐标,确保终点被保留 - const lastP = points[points.length - 1]; - const uniquePoints = new Set([`${lastP.x}-${lastP.y}`]); - const finalPoints: ControlPoint[] = []; - - points.forEach((p, idx) => { - // 处理终点 - if (idx === points.length - 1) { - return finalPoints.push(p); - } - // 使用坐标字符串作为唯一标识 - const key = `${p.x}-${p.y}`; - if (!uniquePoints.has(key)) { - uniquePoints.add(key); - finalPoints.push(p); - } - }); - return finalPoints; -} -/** - * 判断两条线段是否相交 - * @param p0 第一条线段的起点 - * @param p1 第一条线段的终点 - * @param p2 第二条线段的起点 - * @param p3 第二条线段的终点 - * @returns 如果两线段相交返回true,否则返回false - * - * 实现原理: - * 1. 使用向量叉积判断两线段是否平行 - * 2. 使用参数方程求解交点参数s和t - * 3. 判断参数是否在[0,1]区间内来确定是否相交 - */ -const isSegmentsIntersected = ( - p0: ControlPoint, - p1: ControlPoint, - p2: ControlPoint, - p3: ControlPoint -): boolean => { - // 计算两条线段的方向向量 - const s1x = p1.x - p0.x; - const s1y = p1.y - p0.y; - const s2x = p3.x - p2.x; - const s2y = p3.y - p2.y; - - // 使用向量叉积判断两线段是否平行 - if (s1x * s2y - s1y * s2x === 0) { - // 平行线段必不相交 - return false; - } - - // 求解参数方程,获取交点参数s和t - const denominator = -s2x * s1y + s1x * s2y; - const s = (s1y * (p2.x - p0.x) - s1x * (p2.y - p0.y)) / denominator; - const t = (s2x * (p0.y - p2.y) - s2y * (p0.x - p2.x)) / denominator; - - // 当且仅当s和t都在[0,1]区间内时,两线段相交 - return s >= 0 && s <= 1 && t >= 0 && t <= 1; -}; - -/** - * 判断线段是否与矩形相交 - * @param p1 线段起点 - * @param p2 线段终点 - * @param box 矩形区域 - * @returns 如果线段与矩形有交点返回true,否则返回false - * - * 实现思路: - * 1. 首先处理特殊情况:矩形退化为点时必不相交 - * 2. 将矩形分解为四条边 - * 3. 判断线段是否与任意一条矩形边相交 - * 4. 只要与任意一边相交,则与矩形相交 - */ -export const isSegmentCrossingRect = ( - p1: ControlPoint, - p2: ControlPoint, - box: NodeRect -): boolean => { - // 处理特殊情况:矩形退化为点 - if (box.width === 0 && box.height === 0) { - return false; - } - - // 获取矩形的四个顶点 - const [topLeft, topRight, bottomRight, bottomLeft] = getRectVertices(box); - - // 判断线段是否与矩形的任意一条边相交 - return ( - isSegmentsIntersected(p1, p2, topLeft, topRight) || // 上边 - isSegmentsIntersected(p1, p2, topRight, bottomRight) || // 右边 - isSegmentsIntersected(p1, p2, bottomRight, bottomLeft) || // 下边 - isSegmentsIntersected(p1, p2, bottomLeft, topLeft) // 左边 - ); -}; \ No newline at end of file diff --git a/apps/web/src/components/common/editor/graph/layout/edge/style.ts b/apps/web/src/components/common/editor/graph/layout/edge/style.ts deleted file mode 100755 index 409ec72..0000000 --- a/apps/web/src/components/common/editor/graph/layout/edge/style.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { deepClone, lastOf } from "@/utils/base"; -import { Position, getBezierPath } from "reactflow"; - -import { getBasePath } from "."; -import { - kBaseMarkerColor, - kBaseMarkerColors, - kNoMarkerColor, - kYesMarkerColor, -} from "../../components/Edges/Marker"; -import { isEqual } from "../../utils/diff"; -import { EdgeLayout, ReactFlowEdgeWithData } from "../../data/types"; -import { kReactFlow } from "../../states/reactflow"; -import { getPathWithRoundCorners } from "./edge"; - -interface EdgeStyle { - color: string; - edgeType: "solid" | "dashed"; - pathType: "base" | "bezier"; -} - -/** - * Get the style of the connection line - * - * 1. When there are more than 3 edges connecting to both ends of the Node, use multiple colors to distinguish the edges. - * 2. When the connection line goes backward or connects to a hub Node, use dashed lines to distinguish the edges. - * 3. When the connection line goes from a hub to a Node, use bezier path. - */ -export const getEdgeStyles = (props: { - id: string; - isBackward: boolean; -}): EdgeStyle => { - const { id, isBackward } = props; - const idx = parseInt(lastOf(id.split("#")) ?? "0", 10); - if (isBackward) { - // Use dashed lines to distinguish the edges when the connection line goes backward or connects to a hub Node - return { color: kNoMarkerColor, edgeType: "dashed", pathType: "base" }; - } - const edge: ReactFlowEdgeWithData = kReactFlow.instance!.getEdge(id)!; - if (edge.data!.targetPort.edges > 2) { - // Use dashed bezier path when the connection line connects to a hub Node - return { - color: kYesMarkerColor, - edgeType: "dashed", - pathType: "bezier", - }; - } - if (edge.data!.sourcePort.edges > 2) { - // Use multiple colors to distinguish the edges when there are more than 3 edges connecting to both ends of the Node - return { - color: kBaseMarkerColors[idx % kBaseMarkerColors.length], - edgeType: "solid", - pathType: "base", - }; - } - return { color: kBaseMarkerColor, edgeType: "solid", pathType: "base" }; -}; - -interface ILayoutEdge { - id: string; - layout?: EdgeLayout; - offset: number; - borderRadius: number; - pathType: EdgeStyle["pathType"]; - source: string; - target: string; - sourceX: number; - sourceY: number; - targetX: number; - targetY: number; - sourcePosition: Position; - targetPosition: Position; -} - -export function layoutEdge({ - id, - layout, - offset, - borderRadius, - pathType, - source, - target, - sourceX, - sourceY, - targetX, - targetY, - sourcePosition, - targetPosition, -}: ILayoutEdge): EdgeLayout { - const relayoutDeps = [sourceX, sourceY, targetX, targetY]; - const needRelayout = !isEqual(relayoutDeps, layout?.deps?.relayoutDeps); - const reBuildPathDeps = layout?.points; - const needReBuildPath = !isEqual( - reBuildPathDeps, - layout?.deps?.reBuildPathDeps - ); - let newLayout = layout; - if (needRelayout) { - newLayout = _layoutEdge({ - id, - offset, - borderRadius, - pathType, - source, - target, - sourceX, - sourceY, - targetX, - targetY, - sourcePosition, - targetPosition, - }); - } else if (needReBuildPath) { - newLayout = _layoutEdge({ - layout, - id, - offset, - borderRadius, - pathType, - source, - target, - sourceX, - sourceY, - targetX, - targetY, - sourcePosition, - targetPosition, - }); - } - newLayout!.deps = deepClone({ relayoutDeps, reBuildPathDeps }); - return newLayout!; -} - -function _layoutEdge({ - id, - layout, - offset, - borderRadius, - pathType, - source, - target, - sourceX, - sourceY, - targetX, - targetY, - sourcePosition, - targetPosition, -}: ILayoutEdge): EdgeLayout { - const _pathType: EdgeStyle["pathType"] = pathType; - if (_pathType === "bezier") { - const [path, labelX, labelY] = getBezierPath({ - sourceX, - sourceY, - targetX, - targetY, - sourcePosition, - targetPosition, - }); - const points = [ - { - id: "source-" + id, - x: sourceX, - y: sourceY, - }, - { - id: "target-" + id, - x: targetX, - y: targetY, - }, - ]; - return { - path, - points, - inputPoints: points, - labelPosition: { - x: labelX, - y: labelY, - }, - }; - } - - if ((layout?.points?.length ?? 0) > 1) { - layout!.path = getPathWithRoundCorners(layout!.points, borderRadius); - return layout!; - } - - return getBasePath({ - id, - offset, - borderRadius, - source, - target, - sourceX, - sourceY, - targetX, - targetY, - sourcePosition, - targetPosition, - }); -} diff --git a/apps/web/src/components/common/editor/graph/layout/index.ts b/apps/web/src/components/common/editor/graph/layout/index.ts deleted file mode 100755 index 45022e5..0000000 --- a/apps/web/src/components/common/editor/graph/layout/index.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Node, Edge } from "@xyflow/react"; -import { LayoutOptions, LayoutStrategy } from "./types"; -import { TreeLayout } from "./TreeLayout"; -import { MindMapLayout } from "./MindMapLayout"; -import { SingleMapLayout } from "./SingleMapLayout"; - -// 布局工厂类 -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(); - } - } -} - -// 导出布局函数 -export function getLayout(type: 'mindmap' | 'tree' | 'force' | 'single', options: LayoutOptions) { - const layoutStrategy = LayoutFactory.createLayout(type); - return layoutStrategy.layout(options); -} - -// 为了保持向后兼容,保留原有的导出 -export function getMindMapLayout(options: LayoutOptions) { - return getLayout("single", options); -} \ No newline at end of file diff --git a/apps/web/src/components/common/editor/graph/layout/metadata.ts b/apps/web/src/components/common/editor/graph/layout/metadata.ts deleted file mode 100755 index 992f95a..0000000 --- a/apps/web/src/components/common/editor/graph/layout/metadata.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { MarkerType, Position, useInternalNode, Node, Edge } from "@xyflow/react"; -import { LayoutDirection, LayoutVisibility } from "./node"; - -/** - * 获取流程图中的根节点 - * @param nodes - 所有节点数组 - * @param edges - 所有边的数组 - * @returns 根节点数组(没有入边的节点) - */ -export const getRootNodes = (nodes: Node[], edges: Edge[]): Node[] => { - // 创建一个Set来存储所有有入边的节点ID - const nodesWithIncoming = new Set( - edges.map((edge) => edge.target) - ); - - // 过滤出没有入边的节点 - const rootNodes = nodes.filter( - (node) => !nodesWithIncoming.has(node.id) - ); - - return rootNodes; -}; -/** - * 获取节点尺寸信息的工具函数 - * @param node 需要获取尺寸的节点对象 - * @param defaultSize 默认尺寸配置,包含默认宽度(150px)和高度(36px) - * @returns 返回节点的尺寸信息对象,包含: - * - hasDimension: 是否已设置实际尺寸 - * - width: 节点实际宽度 - * - height: 节点实际高度 - * - widthWithDefault: 实际宽度或默认宽度 - * - heightWithDefault: 实际高度或默认高度 - */ -export const getNodeSize = ( - node: Node, - defaultSize = { width: 150, height: 36 } -) => { - // 获取节点的实际宽高 - const nodeWith = node?.width; - const nodeHeight = node?.height; - // 检查节点是否同时设置了宽度和高度 - const hasDimension = [nodeWith, nodeHeight].every((e) => e != null); - - // 返回包含完整尺寸信息的对象 - // 使用空值合并运算符(??)在实际尺寸未设置时使用默认值 - return { - hasDimension, - width: nodeWith, - height: nodeHeight, - widthWithDefault: nodeWith ?? defaultSize.width, - heightWithDefault: nodeHeight ?? defaultSize.height, - }; -}; - -export type IFixPosition = (pros: { - x: number; - y: number; - width: number; - height: number; -}) => { - x: number; - y: number; -}; -/** - * 节点布局计算函数 - * @description 根据给定的节点信息和布局参数,计算节点的最终布局属性 - * @param props 布局参数对象 - * @param props.node 需要布局的节点对象 - * @param props.position 节点的初始位置坐标 - * @param props.direction 布局方向,'horizontal'表示水平布局,'vertical'表示垂直布局 - * @param props.visibility 节点可见性,'visible'表示可见,其他值表示隐藏 - * @param props.fixPosition 可选的位置修正函数,用于调整最终位置 - * @returns 返回计算好布局属性的节点对象 - */ -export const getNodeLayouted = (props: { - node: Node; - position: { x: number; y: number }; - direction: LayoutDirection; - visibility: LayoutVisibility; - fixPosition?: IFixPosition; -}) => { - // 解构布局参数,设置位置修正函数的默认值 - const { - node, - position, - direction, - visibility, - fixPosition = (p) => ({ x: p.x, y: p.y }), - } = props; - - // 计算节点的显示状态和布局方向 - const hidden = visibility !== "visible"; - const isHorizontal = direction === "horizontal"; - - // 获取节点尺寸信息 - const { width, height, widthWithDefault, heightWithDefault } = - getNodeSize(node); - - // 根据布局方向设置节点的连接点位置 - node.targetPosition = isHorizontal ? Position.Left : Position.Top; - node.sourcePosition = isHorizontal ? Position.Right : Position.Bottom; - - // 返回带有完整布局属性的节点对象 - return { - ...node, - width, - height, - hidden, - position: fixPosition({ - ...position, - width: widthWithDefault, - height: heightWithDefault, - }), - style: { - ...node.style, - opacity: hidden ? 0 : 1, - }, - }; -}; - -/** - * 边布局计算函数 - * @description 根据给定的边信息和可见性参数,计算边的最终布局属性 - * @param props 布局参数对象 - * @param props.edge 需要布局的边对象 - * @param props.visibility 边的可见性,'visible'表示可见,其他值表示隐藏 - * @returns 返回计算好布局属性的边对象 - */ -export const getEdgeLayouted = (props: { - edge: Edge; - visibility: LayoutVisibility; -}) => { - const { edge, visibility } = props; - const hidden = visibility !== "visible"; - - // 返回带有完整布局属性的边对象 - return { - ...edge, - hidden, - markerEnd: { - type: MarkerType.ArrowClosed, // 设置箭头样式为闭合箭头 - }, - style: { - ...edge.style, - opacity: hidden ? 0 : 1, - }, - }; -}; \ No newline at end of file diff --git a/apps/web/src/components/common/editor/graph/layout/node/algorithms/d3-dag.ts b/apps/web/src/components/common/editor/graph/layout/node/algorithms/d3-dag.ts deleted file mode 100755 index 92ec51f..0000000 --- a/apps/web/src/components/common/editor/graph/layout/node/algorithms/d3-dag.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { graphStratify, sugiyama } from "d3-dag"; -import { getIncomers, type Node } from "@xyflow/react"; -import { getEdgeLayouted, getNodeLayouted, getNodeSize } from "../../metadata"; -import { LayoutAlgorithm, LayoutAlgorithmProps } from ".."; -type NodeWithPosition = Node & { x: number; y: number }; - -// Since d3-dag layout algorithm does not support multiple root nodes, -// we attach the sub-workflows to the global rootNode. -const rootNode: NodeWithPosition = { - id: "#root", - x: 0, - y: 0, - position: { x: 0, y: 0 }, - data: {} as any, -}; - -const algorithms = { - "d3-dag": "d3-dag", - "ds-dag(s)": "ds-dag(s)", -}; - -export type D3DAGLayoutAlgorithms = "d3-dag" | "ds-dag(s)"; - -export const layoutD3DAG = async ( - props: LayoutAlgorithmProps & { algorithm?: D3DAGLayoutAlgorithms } -) => { - const { - nodes, - edges, - direction, - visibility, - spacing, - algorithm = "d3-dag", - } = props; - const isHorizontal = direction === "horizontal"; - - const initialNodes = [] as NodeWithPosition[]; - let maxNodeWidth = 0; - let maxNodeHeight = 0; - for (const node of nodes) { - const { widthWithDefault, heightWithDefault } = getNodeSize(node); - initialNodes.push({ - ...node, - ...node.position, - width: widthWithDefault, - height: heightWithDefault, - }); - maxNodeWidth = Math.max(maxNodeWidth, widthWithDefault); - maxNodeHeight = Math.max(maxNodeHeight, heightWithDefault); - } - - // Since d3-dag does not support horizontal layout, - // we swap the width and height of nodes and interchange x and y mappings based on the layout direction. - const nodeSize: any = isHorizontal - ? [maxNodeHeight + spacing.y, maxNodeWidth + spacing.x] - : [maxNodeWidth + spacing.x, maxNodeHeight + spacing.y]; - - const getParentIds = (node: Node) => { - if (node.id === rootNode.id) { - return undefined; - } - // Node without input is the root node of sub-workflow, and we should connect it to the rootNode - const incomers = getIncomers(node, nodes, edges); - if (incomers.length < 1) { - return [rootNode.id]; - } - return algorithm === "d3-dag" - ? [incomers[0]?.id] - : incomers.map((e) => e.id); - }; - - const stratify = graphStratify(); - const dag = stratify( - [rootNode, ...initialNodes].map((node) => { - return { - id: node.id, - parentIds: getParentIds(node), - }; - }) - ); - - const layout = sugiyama().nodeSize(nodeSize); - layout(dag); - - const layoutNodes = new Map(); - for (const node of dag.nodes()) { - layoutNodes.set(node.data.id, node); - } - - return { - nodes: nodes.map((node) => { - const { x, y } = layoutNodes.get(node.id); - // Interchange x and y mappings based on the layout direction. - const position = isHorizontal ? { x: y, y: x } : { x, y }; - return getNodeLayouted({ - node, - position, - direction, - visibility, - fixPosition: ({ x, y, width, height }) => { - // This algorithm uses the center coordinate of the node as the reference point, - // which needs adjustment for ReactFlow's topLeft coordinate system. - return { - x: x - width / 2, - y: y - height / 2, - }; - }, - }); - }), - edges: edges.map((edge) => getEdgeLayouted({ edge, visibility })), - }; -}; - -export const kD3DAGAlgorithms: Record = Object.keys( - algorithms -).reduce((pre, algorithm) => { - pre[algorithm] = (props: any) => { - return layoutD3DAG({ ...props, algorithm }); - }; - return pre; -}, {} as any); diff --git a/apps/web/src/components/common/editor/graph/layout/node/algorithms/d3-hierarchy.ts b/apps/web/src/components/common/editor/graph/layout/node/algorithms/d3-hierarchy.ts deleted file mode 100755 index eae2813..0000000 --- a/apps/web/src/components/common/editor/graph/layout/node/algorithms/d3-hierarchy.ts +++ /dev/null @@ -1,89 +0,0 @@ -// Based on: https://github.com/flanksource/flanksource-ui/blob/75b35591d3bbc7d446fa326d0ca7536790f38d88/src/ui/Graphs/Layouts/algorithms/d3-hierarchy.ts - -import { stratify, tree, type HierarchyPointNode } from "d3-hierarchy"; -import {getIncomers, Node} from "@xyflow/react" -import { LayoutAlgorithm } from ".."; -import { getEdgeLayouted, getNodeLayouted, getNodeSize } from "../../metadata"; -type NodeWithPosition = Node & { x: number; y: number }; - -const layout = tree().separation(() => 1); - -// Since d3-hierarchy layout algorithm does not support multiple root nodes, -// we attach the sub-workflows to the global rootNode. -const rootNode: NodeWithPosition = { - id: "#root", - x: 0, - y: 0, - position: { x: 0, y: 0 }, - data: {} as any, -}; - -export const layoutD3Hierarchy: LayoutAlgorithm = async (props) => { - const { nodes, edges, direction, visibility, spacing } = props; - const isHorizontal = direction === "horizontal"; - - const initialNodes = [] as NodeWithPosition[]; - let maxNodeWidth = 0; - let maxNodeHeight = 0; - for (const node of nodes) { - const { widthWithDefault, heightWithDefault } = getNodeSize(node); - initialNodes.push({ - ...node, - ...node.position, - width: widthWithDefault, - height: heightWithDefault, - }); - maxNodeWidth = Math.max(maxNodeWidth, widthWithDefault); - maxNodeHeight = Math.max(maxNodeHeight, heightWithDefault); - } - - // Since d3-hierarchy does not support horizontal layout, - // we swap the width and height of nodes and interchange x and y mappings based on the layout direction. - const nodeSize: [number, number] = isHorizontal - ? [maxNodeHeight + spacing.y, maxNodeWidth + spacing.x] - : [maxNodeWidth + spacing.x, maxNodeHeight + spacing.y]; - - layout.nodeSize(nodeSize); - - const getParentId = (node: Node) => { - if (node.id === rootNode.id) { - return undefined; - } - // Node without input is the root node of sub-workflow, and we should connect it to the rootNode - const incomers = getIncomers(node, nodes, edges); - return incomers[0]?.id || rootNode.id; - }; - - const hierarchy = stratify() - .id((d) => d.id) - .parentId(getParentId)([rootNode, ...initialNodes]); - - const root = layout(hierarchy); - const layoutNodes = new Map>(); - for (const node of root) { - layoutNodes.set(node.id!, node); - } - - return { - nodes: nodes.map((node) => { - const { x, y } = layoutNodes.get(node.id)!; - // Interchange x and y mappings based on the layout direction. - const position = isHorizontal ? { x: y, y: x } : { x, y }; - return getNodeLayouted({ - node, - position, - direction, - visibility, - fixPosition: ({ x, y, width, height }) => { - // This algorithm uses the center coordinate of the node as the reference point, - // which needs adjustment for ReactFlow's topLeft coordinate system. - return { - x: x - width / 2, - y: y - height / 2, - }; - }, - }); - }), - edges: edges.map((edge) => getEdgeLayouted({ edge, visibility })), - }; -}; diff --git a/apps/web/src/components/common/editor/graph/layout/node/algorithms/dagre-tree.ts b/apps/web/src/components/common/editor/graph/layout/node/algorithms/dagre-tree.ts deleted file mode 100755 index 000b560..0000000 --- a/apps/web/src/components/common/editor/graph/layout/node/algorithms/dagre-tree.ts +++ /dev/null @@ -1,122 +0,0 @@ -import dagre from "@dagrejs/dagre"; -import { LayoutAlgorithm } from ".."; -import { getIncomers, Node } from "@xyflow/react"; -import { getEdgeLayouted, getNodeLayouted, getNodeSize } from "../../metadata"; -import { randomInt } from "../../../utils/base"; - -// 布局配置常量 -const LAYOUT_CONFIG = { - VIRTUAL_ROOT_ID: '#root', - VIRTUAL_NODE_SIZE: 1, - RANKER: 'tight-tree', -} as const; - -// 创建并配置 dagre 图实例 -const createDagreGraph = () => { - const graph = new dagre.graphlib.Graph(); - graph.setDefaultEdgeLabel(() => ({})); - return graph; -}; - -// 获取布局方向配置 -const getLayoutConfig = ( - direction: 'horizontal' | 'vertical', - spacing: { x: number, y: number }, - graph: dagre.graphlib.Graph -) => ({ - nodesep: direction === 'horizontal' ? spacing.y : spacing.x, - ranksep: direction === 'horizontal' ? spacing.x : spacing.y, - ranker: LAYOUT_CONFIG.RANKER, - rankdir: direction === 'horizontal' ? 'LR' : 'TB', - -}); - -// 查找根节点 -const findRootNodes = (nodes: Node[], edges: any[]): Node[] => - nodes.filter(node => getIncomers(node, nodes, edges).length < 1); - -// 计算节点边界 -const calculateBounds = (nodes: Node[], graph: dagre.graphlib.Graph) => { - const bounds = { - minX: Number.POSITIVE_INFINITY, - minY: Number.POSITIVE_INFINITY, - maxX: Number.NEGATIVE_INFINITY, - maxY: Number.NEGATIVE_INFINITY, - }; - - nodes.forEach(node => { - const pos = graph.node(node.id); - if (pos) { - bounds.minX = Math.min(bounds.minX, pos.x); - bounds.minY = Math.min(bounds.minY, pos.y); - bounds.maxX = Math.max(bounds.maxX, pos.x); - bounds.maxY = Math.max(bounds.maxY, pos.y); - } - }); - - return bounds; -}; - -export const layoutDagreTree: LayoutAlgorithm = async ({ - nodes, - edges, - direction, - visibility, - spacing -}) => { - const dagreGraph = createDagreGraph(); - - // 设置图的布局参数 - dagreGraph.setGraph(getLayoutConfig(direction, spacing, dagreGraph)); - - // 添加节点 - nodes.forEach((node) => { - const { widthWithDefault, heightWithDefault } = getNodeSize(node); - dagreGraph.setNode(node.id, { - width: widthWithDefault, - height: heightWithDefault, - order: randomInt(0, 10) - }); - }); - - // 添加边 - edges.forEach(edge => dagreGraph.setEdge(edge.source, edge.target)); - - // 处理多个子工作流的情况 - const rootNodes = findRootNodes(nodes, edges); - if (rootNodes.length > 1) { - dagreGraph.setNode(LAYOUT_CONFIG.VIRTUAL_ROOT_ID, { - width: LAYOUT_CONFIG.VIRTUAL_NODE_SIZE, - height: LAYOUT_CONFIG.VIRTUAL_NODE_SIZE, - rank: -1 // 确保虚拟根节点排在最前面 - }); - rootNodes.forEach(node => - dagreGraph.setEdge(LAYOUT_CONFIG.VIRTUAL_ROOT_ID, node.id) - ); - } - - // 执行布局 - dagre.layout(dagreGraph); - - // 移除虚拟根节点 - if (rootNodes.length > 1) { - dagreGraph.removeNode(LAYOUT_CONFIG.VIRTUAL_ROOT_ID); - } - - // 计算边界并返回布局结果 - const bounds = calculateBounds(nodes, dagreGraph); - - return { - nodes: nodes.map(node => getNodeLayouted({ - node, - position: dagreGraph.node(node.id), - direction, - visibility, - fixPosition: ({ x, y, width, height }) => ({ - x: x - width / 2 - bounds.minX, - y: y - height / 2 - bounds.minY, - }), - })), - edges: edges.map(edge => getEdgeLayouted({ edge, visibility })), - }; -}; \ No newline at end of file diff --git a/apps/web/src/components/common/editor/graph/layout/node/algorithms/elk.ts b/apps/web/src/components/common/editor/graph/layout/node/algorithms/elk.ts deleted file mode 100755 index 0238b71..0000000 --- a/apps/web/src/components/common/editor/graph/layout/node/algorithms/elk.ts +++ /dev/null @@ -1,128 +0,0 @@ -import ELK, { ElkNode } from "elkjs/lib/elk.bundled.js"; -import { getIncomers,Node } from "@xyflow/react"; -import { LayoutAlgorithm, LayoutAlgorithmProps } from ".."; -import { getEdgeLayouted, getNodeLayouted, getNodeSize } from "../../metadata"; - -const algorithms = { - "elk-layered": "layered", - "elk-mr-tree": "mrtree", -}; - -const elk = new ELK({ algorithms: Object.values(algorithms) }); - -export type ELKLayoutAlgorithms = "elk-layered" | "elk-mr-tree"; - -export const layoutELK = async ( - props: LayoutAlgorithmProps & { algorithm?: ELKLayoutAlgorithms } -) => { - const { - nodes, - edges, - direction, - visibility, - spacing, - algorithm = "elk-mr-tree", - } = props; - const isHorizontal = direction === "horizontal"; - - const subWorkflowRootNodes: Node[] = []; - const layoutNodes = nodes.map((node) => { - const incomers = getIncomers(node, nodes, edges); - if (incomers.length < 1) { - // Node without input is the root node of sub-workflow - subWorkflowRootNodes.push(node); - } - const { widthWithDefault, heightWithDefault } = getNodeSize(node); - const sourcePorts = node.data.sourceHandles.map((id) => ({ - id, - properties: { - side: isHorizontal ? "EAST" : "SOUTH", - }, - })); - const targetPorts = node.data.targetHandles.map((id) => ({ - id, - properties: { - side: isHorizontal ? "WEST" : "NORTH", - }, - })); - return { - id: node.id, - width: widthWithDefault, - height: heightWithDefault, - ports: [...targetPorts, ...sourcePorts], - properties: { - "org.eclipse.elk.portConstraints": "FIXED_ORDER", - }, - }; - }); - - const layoutEdges = edges.map((edge) => { - return { - id: edge.id, - sources: [edge.sourceHandle || edge.source], - targets: [edge.targetHandle || edge.target], - }; - }); - - // Connect sub-workflows' root nodes to the rootNode - const rootNode: any = { id: "#root", width: 1, height: 1 }; - layoutNodes.push(rootNode); - for (const subWorkflowRootNode of subWorkflowRootNodes) { - layoutEdges.push({ - id: `${rootNode.id}-${subWorkflowRootNode.id}`, - sources: [rootNode.id], - targets: [subWorkflowRootNode.id], - }); - } - - const layouted = await elk - .layout({ - id: "@root", - children: layoutNodes, - edges: layoutEdges, - layoutOptions: { - // - https://www.eclipse.org/elk/reference/algorithms.html - "elk.algorithm": algorithms[algorithm], - "elk.direction": isHorizontal ? "RIGHT" : "DOWN", - // - https://www.eclipse.org/elk/reference/options.html - "elk.spacing.nodeNode": isHorizontal - ? spacing.y.toString() - : spacing.x.toString(), - "elk.layered.spacing.nodeNodeBetweenLayers": isHorizontal - ? spacing.x.toString() - : spacing.y.toString(), - }, - }) - .catch((e) => { - console.log("❌ ELK layout failed", e); - }) as ElkNode - - if (!layouted?.children) { - return; - } - - const layoutedNodePositions = layouted.children.reduce((pre, v) => { - pre[v.id] = { - x: v.x ?? 0, - y: v.y ?? 0, - }; - return pre; - }, {} as Record); - - return { - nodes: nodes.map((node) => { - const position = layoutedNodePositions[node.id]; - return getNodeLayouted({ node, position, direction, visibility }); - }), - edges: edges.map((edge) => getEdgeLayouted({ edge, visibility })), - }; -}; - -export const kElkAlgorithms: Record = Object.keys( - algorithms -).reduce((pre, algorithm) => { - pre[algorithm] = (props: any) => { - return layoutELK({ ...props, algorithm }); - }; - return pre; -}, {} as any); diff --git a/apps/web/src/components/common/editor/graph/layout/node/algorithms/origin.ts b/apps/web/src/components/common/editor/graph/layout/node/algorithms/origin.ts deleted file mode 100755 index 25099ba..0000000 --- a/apps/web/src/components/common/editor/graph/layout/node/algorithms/origin.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { LayoutAlgorithm } from ".."; -import { getEdgeLayouted, getNodeLayouted } from "../../metadata"; - -/** - * Positions all nodes at the origin (0,0) in the layout. - */ -export const layoutOrigin: LayoutAlgorithm = async (props) => { - const { nodes, edges, direction, visibility } = props; - return { - nodes: nodes.map((node) => { - return getNodeLayouted({ - node, - direction, - visibility, - position: { x: 0, y: 0 }, - }); - }), - edges: edges.map((edge) => getEdgeLayouted({ edge, visibility })), - }; -}; diff --git a/apps/web/src/components/common/editor/graph/layout/node/index.ts b/apps/web/src/components/common/editor/graph/layout/node/index.ts deleted file mode 100755 index 477b766..0000000 --- a/apps/web/src/components/common/editor/graph/layout/node/index.ts +++ /dev/null @@ -1,149 +0,0 @@ -/** - * 图形布局模块 - * - * 该模块提供了一系列用于处理 ReactFlow 图形布局的工具和算法。 - * 支持多种布局算法,包括原始布局、树形布局、层次布局等。 - * 主要用于自动计算和调整图形中节点和边的位置。 - */ - -import { ReactFlowGraph } from "../../types"; -import { removeEmpty } from "../../utils/base"; -import { D3DAGLayoutAlgorithms, kD3DAGAlgorithms } from "./algorithms/d3-dag"; -import { layoutD3Hierarchy } from "./algorithms/d3-hierarchy"; -import { layoutDagreTree } from "./algorithms/dagre-tree"; -import { ELKLayoutAlgorithms, kElkAlgorithms } from "./algorithms/elk"; -import { layoutOrigin } from "./algorithms/origin"; - -/** - * 布局方向类型 - * vertical: 垂直布局 - * horizontal: 水平布局 - */ -export type LayoutDirection = "vertical" | "horizontal"; - -/** - * 布局可见性类型 - * visible: 可见 - * hidden: 隐藏 - */ -export type LayoutVisibility = "visible" | "hidden"; - -/** - * 布局间距配置接口 - * x: 水平间距 - * y: 垂直间距 - */ -export interface LayoutSpacing { - x: number; - y: number; -} - -/** - * ReactFlow 布局配置接口 - * 定义了布局所需的各项参数 - */ -export type ReactFlowLayoutConfig = { - algorithm: LayoutAlgorithms; // 使用的布局算法 - direction: LayoutDirection; // 布局方向 - spacing: LayoutSpacing; // 节点间距 - /** - * 布局可见性配置 - * 在首次布局时如果节点大小不可用,可能需要隐藏布局 - */ - visibility: LayoutVisibility; - /** - * 是否反转源节点手柄顺序 - */ - reverseSourceHandles: boolean; - autoCenterRoot: boolean -}; - -/** - * 布局算法所需的属性类型 - * 继承自 ReactFlowGraph 并包含布局配置(除算法外) - */ -export type LayoutAlgorithmProps = ReactFlowGraph & - Omit; - -/** - * 布局算法函数类型定义 - * 接收布局属性作为参数,返回布局后的图形数据 - */ -export type LayoutAlgorithm = ( - props: LayoutAlgorithmProps -) => Promise; - -/** - * 可用的布局算法映射表 - * 包含所有支持的布局算法实现 - */ -export const layoutAlgorithms: Record = { - origin: layoutOrigin, - "dagre-tree": layoutDagreTree, - "d3-hierarchy": layoutD3Hierarchy, - ...kElkAlgorithms, - ...kD3DAGAlgorithms, -}; - -/** - * 默认布局配置 - */ -export const defaultLayoutConfig: ReactFlowLayoutConfig = { - algorithm: "dagre-tree", // 默认使用 elk-mr-tree 算法 - direction: "horizontal", // 默认垂直布局 - visibility: "visible", // 默认可见 - spacing: { x: 120, y: 120 }, // 默认间距 - reverseSourceHandles: false, // 默认不反转源节点手柄 - autoCenterRoot: false -}; - -/** - * 支持的布局算法类型联合 - */ -export type LayoutAlgorithms = - | "origin" - | "dagre-tree" - | "d3-hierarchy" - | ELKLayoutAlgorithms - | D3DAGLayoutAlgorithms; - -/** - * ReactFlow 布局类型 - * 包含图形数据和可选的布局配置 - */ -export type ReactFlowLayout = ReactFlowGraph & Partial; - -/** - * 执行 ReactFlow 图形布局的主函数 - * - * @param options - 布局选项,包含图形数据和布局配置 - * @returns 返回布局后的图形数据 - * - * 函数流程: - * 1. 合并默认配置和用户配置 - * 2. 获取对应的布局算法 - * 3. 执行布局计算 - * 4. 如果布局失败,回退到原始布局 - */ -export const layoutReactFlow = async ( - options: ReactFlowLayout -): Promise => { - // 合并配置,移除空值 - const config = { ...defaultLayoutConfig, ...removeEmpty(options) }; - const { nodes = [], edges = [] } = config; - - // 获取并执行布局算法 - const layout = layoutAlgorithms[config.algorithm]; - let result = await layout({ ...config, nodes, edges }); - - // 布局失败时回退处理 - if (!result) { - result = await layoutReactFlow({ - ...config, - nodes, - edges, - algorithm: "origin", - }); - } - return result!; -}; \ No newline at end of file diff --git a/apps/web/src/components/common/editor/graph/layout/types.ts b/apps/web/src/components/common/editor/graph/layout/types.ts deleted file mode 100755 index b46b4df..0000000 --- a/apps/web/src/components/common/editor/graph/layout/types.ts +++ /dev/null @@ -1,23 +0,0 @@ -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[] }; -} \ No newline at end of file diff --git a/apps/web/src/components/common/editor/graph/nodes/GraphNode.tsx b/apps/web/src/components/common/editor/graph/nodes/GraphNode.tsx deleted file mode 100755 index d4aec7b..0000000 --- a/apps/web/src/components/common/editor/graph/nodes/GraphNode.tsx +++ /dev/null @@ -1,179 +0,0 @@ -import { memo, useCallback, useEffect, useRef, useState } from '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; - color?: string; - level?: number; -}, 'graph-node'>; - - - -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 = { - 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 selector = (store: GraphState) => ({ - updateNode: store.updateNode, -}); - -export const GraphNode = memo(({ id, selected, width, height, data, isConnectable }: NodeProps) => { - const { updateNode } = useGraphStore(selector, shallow); - const [isEditing, setIsEditing] = useState(false); - const [inputValue, setInputValue] = useState(data.label); - const [isComposing, setIsComposing] = useState(false); - const containerRef = useRef(null); - const textareaRef = useRef(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) => { - const newValue = evt.target.value; - setInputValue(newValue); - updateNode(id, { label: newValue }); - updateTextareaSize(evt.target); - }, [updateNode, id, updateTextareaSize]); - - useEffect(() => { - if (textareaRef.current) { - updateTextareaSize(textareaRef.current); - } - }, [isEditing, inputValue, updateTextareaSize]); - - const handleKeyDown = useCallback((evt: React.KeyboardEvent) => { - 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); - }, []); - useEffect(() => { - 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 ( -
-