diff --git a/apps/nestjs-backend/src/app.module.ts b/apps/nestjs-backend/src/app.module.ts index 83f3d0830e..92d76cafb0 100644 --- a/apps/nestjs-backend/src/app.module.ts +++ b/apps/nestjs-backend/src/app.module.ts @@ -7,6 +7,7 @@ import { AuthModule } from './features/auth/auth.module'; import { BaseModule } from './features/base/base.module'; import { ChatModule } from './features/chat/chat.module'; import { CollaboratorModule } from './features/collaborator/collaborator.module'; +import { CommentOpenApiModule } from './features/comment/comment-open-api.module'; import { DashboardModule } from './features/dashboard/dashboard.module'; import { ExportOpenApiModule } from './features/export/open-api/export-open-api.module'; import { FieldOpenApiModule } from './features/field/open-api/field-open-api.module'; @@ -59,6 +60,7 @@ export const appModules = { TrashModule, PluginModule, DashboardModule, + CommentOpenApiModule, ], providers: [InitBootstrapProvider], }; diff --git a/apps/nestjs-backend/src/features/attachments/plugins/adapter.ts b/apps/nestjs-backend/src/features/attachments/plugins/adapter.ts index 9f4f37a8f9..33aee5e77e 100644 --- a/apps/nestjs-backend/src/features/attachments/plugins/adapter.ts +++ b/apps/nestjs-backend/src/features/attachments/plugins/adapter.ts @@ -15,6 +15,8 @@ export default abstract class StorageAdapter { case UploadType.Form: case UploadType.Plugin: return storageConfig().publicBucket; + case UploadType.Comment: + return storageConfig().privateBucket; default: throw new BadRequestException('Invalid upload type'); } @@ -34,6 +36,8 @@ export default abstract class StorageAdapter { return 'import'; case UploadType.Plugin: return 'plugin'; + case UploadType.Comment: + return 'comment'; default: throw new BadRequestException('Invalid upload type'); } diff --git a/apps/nestjs-backend/src/features/comment/comment-open-api.controller.spec.ts b/apps/nestjs-backend/src/features/comment/comment-open-api.controller.spec.ts new file mode 100644 index 0000000000..2967c3c53f --- /dev/null +++ b/apps/nestjs-backend/src/features/comment/comment-open-api.controller.spec.ts @@ -0,0 +1,19 @@ +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; +import { CommentOpenApiController } from './comment-open-api.controller'; + +describe('CommentOpenApiController', () => { + let controller: CommentOpenApiController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [CommentOpenApiController], + }).compile(); + + controller = module.get(CommentOpenApiController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/apps/nestjs-backend/src/features/comment/comment-open-api.controller.ts b/apps/nestjs-backend/src/features/comment/comment-open-api.controller.ts new file mode 100644 index 0000000000..ee12c981eb --- /dev/null +++ b/apps/nestjs-backend/src/features/comment/comment-open-api.controller.ts @@ -0,0 +1,159 @@ +import { Controller, Get, Post, Body, Param, Patch, Delete, Query } from '@nestjs/common'; +import type { ICommentVo, IGetCommentListVo, ICommentSubscribeVo } from '@teable/openapi'; +import { + getRecordsRoSchema, + createCommentRoSchema, + ICreateCommentRo, + IUpdateCommentRo, + updateCommentRoSchema, + updateCommentReactionRoSchema, + IUpdateCommentReactionRo, + getCommentListQueryRoSchema, + IGetCommentListQueryRo, + IGetRecordsRo, +} from '@teable/openapi'; +import { ZodValidationPipe } from '../../zod.validation.pipe'; +import { AttachmentsStorageService } from '../attachments/attachments-storage.service'; +import { Permissions } from '../auth/decorators/permissions.decorator'; +import { TokenAccess } from '../auth/decorators/token.decorator'; +import { TqlPipe } from '../record/open-api/tql.pipe'; +import { CommentOpenApiService } from './comment-open-api.service'; + +@Controller('api/comment/:tableId') +@TokenAccess() +export class CommentOpenApiController { + constructor( + private readonly commentOpenApiService: CommentOpenApiService, + private readonly attachmentsStorageService: AttachmentsStorageService + ) {} + + @Get('/:recordId/count') + @Permissions('view|read') + async getRecordCommentCount( + @Param('tableId') tableId: string, + @Param('recordId') recordId: string + ) { + return this.commentOpenApiService.getRecordCommentCount(tableId, recordId); + } + + @Get('/count') + @Permissions('view|read') + async getTableCommentCount( + @Param('tableId') tableId: string, + @Query(new ZodValidationPipe(getRecordsRoSchema), TqlPipe) query: IGetRecordsRo + ) { + return this.commentOpenApiService.getTableCommentCount(tableId, query); + } + + @Get('/:recordId/attachment/:path') + // eslint-disable-next-line sonarjs/no-duplicate-string + @Permissions('record|read') + async getAttachmentPresignedUrl(@Param('path') path: string) { + const [bucket, token] = path.split('/'); + return this.attachmentsStorageService.getPreviewUrlByPath(bucket, path, token); + } + + // eslint-disable-next-line sonarjs/no-duplicate-string + @Get('/:recordId/subscribe') + @Permissions('record|read') + async getSubscribeDetail( + @Param('tableId') tableId: string, + @Param('recordId') recordId: string + ): Promise { + return this.commentOpenApiService.getSubscribeDetail(tableId, recordId); + } + + @Post('/:recordId/subscribe') + @Permissions('record|read') + async subscribeComment(@Param('tableId') tableId: string, @Param('recordId') recordId: string) { + return this.commentOpenApiService.subscribeComment(tableId, recordId); + } + + @Delete('/:recordId/subscribe') + @Permissions('record|read') + async unsubscribeComment(@Param('tableId') tableId: string, @Param('recordId') recordId: string) { + return this.commentOpenApiService.unsubscribeComment(tableId, recordId); + } + + @Get('/:recordId/list') + @Permissions('record|read') + async getCommentList( + @Param('tableId') tableId: string, + @Param('recordId') recordId: string, + @Query(new ZodValidationPipe(getCommentListQueryRoSchema)) + getCommentListQueryRo: IGetCommentListQueryRo + ): Promise { + return this.commentOpenApiService.getCommentList(tableId, recordId, getCommentListQueryRo); + } + + @Post('/:recordId/create') + // eslint-disable-next-line sonarjs/no-duplicate-string + @Permissions('record|comment') + async createComment( + @Param('tableId') tableId: string, + @Param('recordId') recordId: string, + @Body(new ZodValidationPipe(createCommentRoSchema)) createCommentRo: ICreateCommentRo + ) { + return this.commentOpenApiService.createComment(tableId, recordId, createCommentRo); + } + + // eslint-disable-next-line sonarjs/no-duplicate-string + @Get('/:recordId/:commentId') + @Permissions('record|read') + async getCommentDetail(@Param('commentId') commentId: string): Promise { + return this.commentOpenApiService.getCommentDetail(commentId); + } + + @Patch('/:recordId/:commentId') + @Permissions('record|comment') + async updateComment( + @Param('tableId') tableId: string, + @Param('recordId') recordId: string, + @Param('commentId') commentId: string, + @Body(new ZodValidationPipe(updateCommentRoSchema)) updateCommentRo: IUpdateCommentRo + ) { + return this.commentOpenApiService.updateComment(tableId, recordId, commentId, updateCommentRo); + } + + @Delete('/:recordId/:commentId') + @Permissions('record|read') + async deleteComment( + @Param('tableId') tableId: string, + @Param('recordId') recordId: string, + @Param('commentId') commentId: string + ) { + return this.commentOpenApiService.deleteComment(tableId, recordId, commentId); + } + + @Delete('/:recordId/:commentId/reaction') + @Permissions('record|comment') + async deleteCommentReaction( + @Param('tableId') tableId: string, + @Param('recordId') recordId: string, + @Param('commentId') commentId: string, + @Body(new ZodValidationPipe(updateCommentReactionRoSchema)) reactionRo: IUpdateCommentReactionRo + ) { + return this.commentOpenApiService.deleteCommentReaction( + tableId, + recordId, + commentId, + reactionRo + ); + } + + @Patch('/:recordId/:commentId/reaction') + @Permissions('record|comment') + async updateCommentReaction( + @Param('tableId') tableId: string, + @Param('recordId') recordId: string, + @Param('commentId') commentId: string, + @Body(new ZodValidationPipe(updateCommentReactionRoSchema)) reactionRo: IUpdateCommentReactionRo + ) { + return this.commentOpenApiService.createCommentReaction( + tableId, + recordId, + commentId, + reactionRo + ); + } +} diff --git a/apps/nestjs-backend/src/features/comment/comment-open-api.module.ts b/apps/nestjs-backend/src/features/comment/comment-open-api.module.ts new file mode 100644 index 0000000000..4cdd25d636 --- /dev/null +++ b/apps/nestjs-backend/src/features/comment/comment-open-api.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { ShareDbModule } from '../../share-db/share-db.module'; +import { AttachmentsStorageModule } from '../attachments/attachments-storage.module'; +import { NotificationModule } from '../notification/notification.module'; +import { RecordOpenApiModule } from '../record/open-api/record-open-api.module'; +import { RecordModule } from '../record/record.module'; +import { CommentOpenApiController } from './comment-open-api.controller'; +import { CommentOpenApiService } from './comment-open-api.service'; + +@Module({ + imports: [ + NotificationModule, + RecordOpenApiModule, + AttachmentsStorageModule, + RecordModule, + ShareDbModule, + ], + controllers: [CommentOpenApiController], + providers: [CommentOpenApiService], + exports: [CommentOpenApiService], +}) +export class CommentOpenApiModule {} diff --git a/apps/nestjs-backend/src/features/comment/comment-open-api.service.ts b/apps/nestjs-backend/src/features/comment/comment-open-api.service.ts new file mode 100644 index 0000000000..d2d9b85bf1 --- /dev/null +++ b/apps/nestjs-backend/src/features/comment/comment-open-api.service.ts @@ -0,0 +1,564 @@ +import { + Injectable, + Logger, + ForbiddenException, + BadGatewayException, + BadRequestException, +} from '@nestjs/common'; +import { generateCommentId, getCommentChannel, getTableCommentChannel } from '@teable/core'; +import { PrismaService } from '@teable/db-main-prisma'; +import type { + ICreateCommentRo, + ICommentVo, + IUpdateCommentRo, + IGetCommentListQueryRo, + ICommentContent, + IGetRecordsRo, + IParagraphCommentContent, +} from '@teable/openapi'; +import { CommentNodeType, CommentPatchType } from '@teable/openapi'; +import { uniq, omit } from 'lodash'; +import { ClsService } from 'nestjs-cls'; +import { ShareDbService } from '../../share-db/share-db.service'; +import type { IClsStore } from '../../types/cls'; +import { NotificationService } from '../notification/notification.service'; +import { RecordService } from '../record/record.service'; + +@Injectable() +export class CommentOpenApiService { + private logger = new Logger(CommentOpenApiService.name); + constructor( + private readonly notificationService: NotificationService, + private readonly recordService: RecordService, + private readonly prismaService: PrismaService, + private readonly cls: ClsService, + private readonly shareDbService: ShareDbService + ) {} + + async getCommentDetail(commentId: string) { + const rawComment = await this.prismaService.comment.findFirst({ + where: { + id: commentId, + deletedTime: null, + }, + select: { + id: true, + content: true, + createdBy: true, + createdTime: true, + lastModifiedTime: true, + deletedTime: true, + quoteId: true, + reaction: true, + }, + }); + + if (!rawComment) { + return null; + } + + return { + ...rawComment, + reaction: rawComment.reaction ? JSON.parse(rawComment?.reaction) : null, + content: rawComment?.content ? JSON.parse(rawComment?.content) : null, + } as ICommentVo; + } + + async getCommentList( + tableId: string, + recordId: string, + getCommentListQuery: IGetCommentListQueryRo + ) { + const { cursor, take = 20, direction = 'forward', includeCursor = true } = getCommentListQuery; + + if (take > 1000) { + throw new BadRequestException(`${take} exceed the max count comment list count 1000`); + } + + const takeWithDirection = direction === 'forward' ? -(take + 1) : take + 1; + + const rawComments = await this.prismaService.comment.findMany({ + where: { + recordId, + tableId, + deletedTime: null, + }, + orderBy: [{ createdTime: 'asc' }], + take: takeWithDirection, + skip: cursor ? (includeCursor ? 0 : 1) : 0, + cursor: cursor ? { id: cursor } : undefined, + select: { + id: true, + content: true, + createdBy: true, + createdTime: true, + lastModifiedTime: true, + quoteId: true, + reaction: true, + }, + }); + + const hasNextPage = rawComments.length > take; + + const nextCursor = hasNextPage + ? direction === 'forward' + ? rawComments.shift()?.id + : rawComments.pop()?.id + : null; + + const parsedComments = rawComments + .sort((a, b) => a.createdTime.getTime() - b.createdTime.getTime()) + .map( + (comment) => + ({ + ...comment, + content: comment.content ? JSON.parse(comment.content) : null, + reaction: comment.reaction ? JSON.parse(comment.reaction) : null, + }) as ICommentVo + ); + + return { + comments: parsedComments, + nextCursor, + }; + } + + async createComment(tableId: string, recordId: string, createCommentRo: ICreateCommentRo) { + const id = generateCommentId(); + const result = await this.prismaService.comment.create({ + data: { + id, + tableId, + recordId, + content: JSON.stringify(createCommentRo.content), + createdBy: this.cls.get('user.id'), + quoteId: createCommentRo.quoteId, + lastModifiedTime: null, + }, + }); + + await this.sendCommentNotify(tableId, recordId, id, { + content: result.content, + quoteId: result.quoteId, + }); + + this.sendCommentPatch(tableId, recordId, CommentPatchType.CreateComment, result); + this.sendTableCommentPatch(tableId, recordId, CommentPatchType.CreateComment); + + return { + ...result, + content: result.content ? JSON.parse(result.content) : null, + }; + } + + async updateComment( + tableId: string, + recordId: string, + commentId: string, + updateCommentRo: IUpdateCommentRo + ) { + const result = await this.prismaService.comment + .update({ + where: { + id: commentId, + createdBy: this.cls.get('user.id'), + }, + data: { + content: JSON.stringify(updateCommentRo.content), + lastModifiedTime: new Date().toISOString(), + }, + }) + .catch(() => { + throw new ForbiddenException('You have no permission to delete this comment'); + }); + + this.sendCommentPatch(tableId, recordId, CommentPatchType.UpdateComment, result); + await this.sendCommentNotify(tableId, recordId, commentId, { + quoteId: result.quoteId, + content: result.content, + }); + } + + async deleteComment(tableId: string, recordId: string, commentId: string) { + await this.prismaService.comment + .update({ + where: { + id: commentId, + createdBy: this.cls.get('user.id'), + }, + data: { + deletedTime: new Date().toISOString(), + }, + }) + .catch(() => { + throw new ForbiddenException('You have no permission to delete this comment'); + }); + + this.sendCommentPatch(tableId, recordId, CommentPatchType.CreateReaction, { id: commentId }); + this.sendTableCommentPatch(tableId, recordId, CommentPatchType.DeleteComment); + } + + async deleteCommentReaction( + tableId: string, + recordId: string, + commentId: string, + reactionRo: { reaction: string } + ) { + const commentRaw = await this.getCommentReactionById(commentId); + const { reaction } = reactionRo; + let data: ICommentVo['reaction'] = []; + + if (commentRaw && commentRaw.reaction) { + const emojis = JSON.parse(commentRaw.reaction) as NonNullable; + const index = emojis.findIndex((item) => item.reaction === reaction); + if (index > -1) { + const newUser = emojis[index].user.filter((item) => item !== this.cls.get('user.id')); + if (newUser.length === 0) { + emojis.splice(index, 1); + } else { + emojis.splice(index, 1, { + reaction, + user: newUser, + }); + } + data = [...emojis]; + } + } + + const result = await this.prismaService.comment + .update({ + where: { + id: commentId, + }, + data: { + reaction: data.length ? JSON.stringify(data) : null, + }, + }) + .catch((e) => { + throw new BadGatewayException(e); + }); + + this.sendCommentPatch(tableId, recordId, CommentPatchType.DeleteReaction, result); + } + + async createCommentReaction( + tableId: string, + recordId: string, + commentId: string, + reactionRo: { reaction: string } + ) { + const commentRaw = await this.getCommentReactionById(commentId); + const { reaction } = reactionRo; + let data: ICommentVo['reaction']; + + if (commentRaw && commentRaw.reaction) { + const emojis = JSON.parse(commentRaw.reaction) as NonNullable; + const index = emojis.findIndex((item) => item.reaction === reaction); + if (index > -1) { + emojis.splice(index, 1, { + reaction, + user: uniq([...emojis[index].user, this.cls.get('user.id')]), + }); + } else { + emojis.push({ + reaction, + user: [this.cls.get('user.id')], + }); + } + data = [...emojis]; + } else { + data = [ + { + reaction, + user: [this.cls.get('user.id')], + }, + ]; + } + + const result = await this.prismaService.comment + .update({ + where: { + id: commentId, + }, + data: { + reaction: JSON.stringify(data), + lastModifiedTime: commentRaw?.lastModifiedTime, + }, + }) + .catch((e) => { + throw new BadGatewayException(e); + }); + + await this.sendCommentPatch(tableId, recordId, CommentPatchType.CreateReaction, result); + await this.sendCommentNotify(tableId, recordId, commentId, { + quoteId: result.quoteId, + content: result.content, + }); + } + + async getSubscribeDetail(tableId: string, recordId: string) { + return await this.prismaService.commentSubscription + .findUniqueOrThrow({ + where: { + // eslint-disable-next-line + tableId_recordId: { + tableId, + recordId, + }, + }, + select: { + tableId: true, + recordId: true, + createdBy: true, + }, + }) + .catch(() => { + return null; + }); + } + + async subscribeComment(tableId: string, recordId: string) { + await this.prismaService.commentSubscription.create({ + data: { + tableId, + recordId, + createdBy: this.cls.get('user.id'), + }, + }); + } + + async unsubscribeComment(tableId: string, recordId: string) { + await this.prismaService.commentSubscription.delete({ + where: { + // eslint-disable-next-line + tableId_recordId: { + tableId, + recordId, + }, + }, + }); + } + + async getTableCommentCount(tableId: string, query: IGetRecordsRo) { + const recordVo = await this.recordService.getRecords(tableId, query); + const recordsId = recordVo.records.map((rec) => rec.id); + + const result = await this.prismaService.comment.groupBy({ + by: ['recordId'], + where: { + recordId: { + in: recordsId, + }, + deletedTime: null, + }, + _count: { + ['recordId']: true, + }, + }); + + return result.map(({ _count: { recordId: count }, recordId }) => ({ + recordId, + count, + })); + } + + async getRecordCommentCount(tableId: string, recordId: string) { + const result = await this.prismaService.comment.count({ + where: { + tableId, + recordId, + deletedTime: null, + }, + }); + + return { + count: result, + }; + } + + private async getCommentReactionById(commentId: string) { + return await this.prismaService.comment.findFirst({ + where: { + id: commentId, + }, + select: { + reaction: true, + lastModifiedTime: true, + }, + }); + } + + private async sendCommentNotify( + tableId: string, + recordId: string, + commentId: string, + notifyVo: { quoteId: string | null; content: string | null } + ) { + const { quoteId, content } = notifyVo; + const { id: fromUserId, name: fromUserName } = this.cls.get('user'); + const relativeUsers: string[] = []; + + if (quoteId) { + const { createdBy: quoteCommentCreator } = + (await this.prismaService.comment.findUnique({ + where: { + id: quoteId, + }, + select: { + createdBy: true, + }, + })) || {}; + quoteCommentCreator && relativeUsers.push(quoteCommentCreator); + } + + const mentionUsers = this.getMentionUserByContent(content); + + if (mentionUsers.length) { + relativeUsers.push(...mentionUsers); + } + + const { baseId, name: tableName } = + (await this.prismaService.tableMeta.findFirst({ + where: { + id: tableId, + }, + select: { + baseId: true, + name: true, + }, + })) || {}; + + const { id: fieldId } = + (await this.prismaService.field.findFirst({ + where: { + tableId, + isPrimary: true, + }, + select: { + id: true, + }, + })) || {}; + + if (!baseId || !fieldId) { + return; + } + + const { name: baseName } = + (await this.prismaService.base.findFirst({ + where: { + id: baseId, + }, + select: { + name: true, + }, + })) || {}; + + const recordName = await this.recordService.getCellValue(tableId, recordId, fieldId); + + const notifyUsers = await this.prismaService.commentSubscription.findMany({ + where: { + tableId, + recordId, + }, + select: { + createdBy: true, + }, + }); + + const subscribeUsersIds = Array.from( + new Set([...notifyUsers.map(({ createdBy }) => createdBy), ...relativeUsers]) + ).filter((userId) => userId !== fromUserId); + + const message = `${fromUserName} made a commented on ${recordName ? recordName : 'a record'} in ${tableName} ${baseName ? `in ${baseName}` : ''}`; + + subscribeUsersIds.forEach((userId) => { + this.notificationService.sendCommentNotify({ + baseId, + tableId, + recordId, + commentId, + toUserId: userId, + message, + fromUserId, + }); + }); + } + + private getMentionUserByContent(commentContentRaw: string | null) { + if (!commentContentRaw) { + return []; + } + + const commentContent = JSON.parse(commentContentRaw) as ICommentContent; + + return commentContent + .filter( + // so strange that infer automatically error + (comment): comment is IParagraphCommentContent => comment.type === CommentNodeType.Paragraph + ) + .flatMap((paragraphNode) => paragraphNode.children) + .filter((lineNode) => lineNode.type === CommentNodeType.Mention) + .map((mentionNode) => mentionNode.value) as string[]; + } + + private createCommentPresence(tableId: string, recordId: string) { + const channel = getCommentChannel(tableId, recordId); + const presence = this.shareDbService.connect().getPresence(channel); + return presence.create(channel); + } + + private sendCommentPatch( + tableId: string, + recordId: string, + type: CommentPatchType, + data: Record + ) { + const localPresence = this.createCommentPresence(tableId, recordId); + + let finalData = omit(data, ['tableId', 'recordId']); + + if ( + [ + CommentPatchType.CreateComment, + CommentPatchType.CreateReaction, + CommentPatchType.UpdateComment, + CommentPatchType.DeleteReaction, + ].includes(type) + ) { + const { content, reaction } = finalData; + finalData = { + ...finalData, + content: content ? JSON.parse(content as string) : content, + reaction: reaction ? JSON.parse(reaction as string) : reaction, + }; + } + + localPresence.submit( + { + type: type, + data: finalData, + }, + (error) => { + error && this.logger.error('Comment patch presence error: ', error); + } + ); + } + + private sendTableCommentPatch(tableId: string, recordId: string, type: CommentPatchType) { + const channel = getTableCommentChannel(tableId); + const presence = this.shareDbService.connect().getPresence(channel); + const localPresence = presence.create(channel); + + localPresence.submit( + { + type, + data: { + recordId, + }, + }, + (error) => { + error && this.logger.error('Comment patch presence error: ', error); + } + ); + } +} diff --git a/apps/nestjs-backend/src/features/notification/notification.service.ts b/apps/nestjs-backend/src/features/notification/notification.service.ts index b40ea940a9..0a99dcb3c0 100644 --- a/apps/nestjs-backend/src/features/notification/notification.service.ts +++ b/apps/nestjs-backend/src/features/notification/notification.service.ts @@ -9,6 +9,7 @@ import { notificationUrlSchema, userIconSchema, SYSTEM_USER_ID, + assertNever, } from '@teable/core'; import type { Prisma } from '@teable/db-main-prisma'; import { PrismaService } from '@teable/db-main-prisma'; @@ -123,6 +124,7 @@ export class NotificationService { async sendCommonNotify( params: { path: string; + fromUserId?: string; toUserId: string; message: string; emailConfig?: { @@ -134,7 +136,7 @@ export class NotificationService { }, type = NotificationTypeEnum.System ) { - const { toUserId, emailConfig, message, path } = params; + const { toUserId, emailConfig, message, path, fromUserId = SYSTEM_USER_ID } = params; const notifyId = generateNotificationId(); const toUser = await this.userService.getUserById(toUserId); if (!toUser) { @@ -143,27 +145,27 @@ export class NotificationService { const data: Prisma.NotificationCreateInput = { id: notifyId, - fromUserId: SYSTEM_USER_ID, + fromUserId: fromUserId, toUserId, type, urlPath: path, - createdBy: SYSTEM_USER_ID, + createdBy: fromUserId, message, }; const notifyData = await this.createNotify(data); const unreadCount = (await this.unreadCount(toUser.id)).unreadCount; + const rawUsers = await this.prismaService.user.findMany({ + select: { id: true, name: true, avatar: true }, + where: { id: fromUserId }, + }); + const fromUserSets = keyBy(rawUsers, 'id'); + const systemNotifyIcon = this.generateNotifyIcon( notifyData.type as NotificationTypeEnum, - SYSTEM_USER_ID, - { - [SYSTEM_USER_ID]: { - id: SYSTEM_USER_ID, - name: SYSTEM_USER_ID, - avatar: null, - }, - } + fromUserId, + fromUserSets ); const socketNotification = { @@ -221,6 +223,44 @@ export class NotificationService { }); } + async sendCommentNotify(params: { + baseId: string; + tableId: string; + recordId: string; + commentId: string; + toUserId: string; + message: string; + fromUserId: string; + }) { + const { toUserId, tableId, message, baseId, commentId, recordId, fromUserId } = params; + const toUser = await this.userService.getUserById(toUserId); + if (!toUser) { + return; + } + const type = NotificationTypeEnum.Comment; + const urlMeta = notificationUrlSchema.parse({ + baseId: baseId, + tableId: tableId, + recordId: recordId, + commentId: commentId, + }); + const notifyPath = this.generateNotifyPath(type, urlMeta); + + this.sendCommonNotify( + { + path: notifyPath, + fromUserId, + toUserId, + message, + emailConfig: { + title: 'Record comment notification', + message: message, + }, + }, + type + ); + } + async getNotifyList(userId: string, query: IGetNotifyListQuery): Promise { const { notifyStates, cursor } = query; const limit = 10; @@ -283,6 +323,7 @@ export class NotificationService { switch (notifyType) { case NotificationTypeEnum.System: return { iconUrl: `${origin}/images/favicon/favicon.svg` }; + case NotificationTypeEnum.Comment: case NotificationTypeEnum.CollaboratorCellTag: case NotificationTypeEnum.CollaboratorMultiRowTag: { const { id, name, avatar } = fromUserSets[fromUserId]; @@ -294,6 +335,8 @@ export class NotificationService { avatar && getFullStorageUrl(StorageAdapter.getBucket(UploadType.Avatar), avatar), }; } + default: + throw assertNever(notifyType); } } @@ -303,12 +346,19 @@ export class NotificationService { const { baseId, tableId } = urlMeta || {}; return `/base/${baseId}/${tableId}`; } + case NotificationTypeEnum.Comment: { + const { baseId, tableId, recordId, commentId } = urlMeta || {}; + + return `/base/${baseId}/${tableId}${`?recordId=${recordId}&commentId=${commentId}`}`; + } case NotificationTypeEnum.CollaboratorCellTag: case NotificationTypeEnum.CollaboratorMultiRowTag: { const { baseId, tableId, recordId } = urlMeta || {}; return `/base/${baseId}/${tableId}${recordId ? `?recordId=${recordId}` : ''}`; } + default: + throw assertNever(notifyType); } } diff --git a/apps/nestjs-backend/test/comment.e2e-spec.ts b/apps/nestjs-backend/test/comment.e2e-spec.ts new file mode 100644 index 0000000000..4ce4cf6718 --- /dev/null +++ b/apps/nestjs-backend/test/comment.e2e-spec.ts @@ -0,0 +1,189 @@ +import type { INestApplication } from '@nestjs/common'; +import type { ICommentVo } from '@teable/openapi'; +import { + createComment, + CommentNodeType, + getCommentList, + updateComment, + getCommentDetail, + createCommentReaction, + deleteCommentReaction, + createCommentSubscribe, + EmojiSymbol, + getCommentSubscribe, + deleteCommentSubscribe, +} from '@teable/openapi'; +import { createTable, deleteTable, initApp } from './utils/init-app'; + +describe('OpenAPI CommentController (e2e)', () => { + let app: INestApplication; + const baseId = globalThis.testConfig.baseId; + const userId = globalThis.testConfig.userId; + let tableId: string; + let recordId: string; + let comments: ICommentVo[] = []; + + beforeAll(async () => { + const appCtx = await initApp(); + app = appCtx.app; + }); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(async () => { + const { id, records } = await createTable(baseId, { name: 'table' }); + tableId = id; + recordId = records[0].id; + + const commentList = []; + for (let i = 0; i < 20; i++) { + const result = await createComment(tableId, recordId, { + content: [ + { + type: CommentNodeType.Paragraph, + children: [{ type: CommentNodeType.Text, value: `${i}` }], + }, + ], + quoteId: null, + }); + commentList.push(result.data); + } + comments = commentList; + }); + afterEach(async () => { + await deleteTable(baseId, tableId); + }); + + it('should achieve the whole comment curd flow', async () => { + // create comment + const createRes = await createComment(tableId, recordId, { + content: [ + { + type: CommentNodeType.Paragraph, + children: [{ type: CommentNodeType.Text, value: 'hello world' }], + }, + ], + quoteId: null, + }); + + const result = await getCommentDetail(tableId, recordId, createRes.data.id); + const { content, id: commentId } = result?.data as ICommentVo; + expect(content).toEqual([ + { + type: CommentNodeType.Paragraph, + children: [{ type: CommentNodeType.Text, value: 'hello world' }], + }, + ]); + + // update comment + await updateComment(tableId, recordId, commentId, { + content: [ + { + type: CommentNodeType.Paragraph, + children: [{ type: CommentNodeType.Text, value: 'Good night, Paris.' }], + }, + ], + }); + + const updatedResult = await getCommentDetail(tableId, recordId, createRes.data.id); + + expect(updatedResult?.data?.content).toEqual([ + { + type: CommentNodeType.Paragraph, + children: [{ type: CommentNodeType.Text, value: 'Good night, Paris.' }], + }, + ]); + + // create reaction + await createCommentReaction(tableId, recordId, createRes.data.id, { + reaction: EmojiSymbol.eyes, + }); + + const createdReactionResult = await getCommentDetail(tableId, recordId, createRes.data.id); + expect(createdReactionResult?.data?.reaction?.[0]?.reaction).toEqual(EmojiSymbol.eyes); + + // delete reaction + await deleteCommentReaction(tableId, recordId, createRes.data.id, { + reaction: EmojiSymbol.eyes, + }); + + const deletedReactionResult = await getCommentDetail(tableId, recordId, createRes.data.id); + expect(deletedReactionResult?.data?.reaction).toBeNull(); + }); + + describe('get comment list with cursor', async () => { + it('should get latest comments when cursor is null', async () => { + const latestRes = await getCommentList(tableId, recordId, { + cursor: null, + take: 5, + }); + + expect(latestRes.data.comments.length).toBe(5); + expect(latestRes.data.comments.map((com) => com.id)).toEqual( + comments.slice(-5).map((com) => com.id) + ); + expect(latestRes.data.nextCursor).toBe(comments.slice(-6).shift()?.id); + }); + + it('should return next 20 comments', async () => { + const nextCursorCommentRes = await getCommentList(tableId, recordId, { + cursor: comments[14].id, + take: 20, + }); + + expect(nextCursorCommentRes.data.comments.length).toBe(15); + expect(nextCursorCommentRes.data.comments.map((com) => com.id)).toEqual( + comments.slice(0, 15).map((com) => com.id) + ); + expect(nextCursorCommentRes.data.nextCursor).toBeNull(); + }); + it('should get comment by cursor with backward direction', async () => { + const backwardRes = await getCommentList(tableId, recordId, { + cursor: comments[0].id, + take: 10, + direction: 'backward', + }); + expect(backwardRes.data.comments.length).toBe(10); + expect(backwardRes.data.comments.map((com) => com.id)).toEqual( + comments.slice(0, 10).map((com) => com.id) + ); + expect(backwardRes.data.nextCursor).toBe(comments[10].id); + }); + + it('should return the comment by cursor exclude cursor', async () => { + const result = await getCommentList(tableId, recordId, { + cursor: comments[0].id, + take: 10, + direction: 'backward', + includeCursor: false, + }); + + expect(result.data.comments.length).toBe(10); + expect(result.data.comments.map((com) => com.id)).toEqual( + comments.slice(1, 11).map((com) => com.id) + ); + expect(result.data.nextCursor).toBe(comments[11].id); + }); + }); + + describe('comment subscribe relative', () => { + it('should subscribe the record comment', async () => { + await createCommentSubscribe(tableId, recordId); + const result = await getCommentSubscribe(tableId, recordId); + expect(result?.data?.createdBy).toBe(userId); + }); + + it('should return null when can not found the subscribe info', async () => { + await createCommentSubscribe(tableId, recordId); + const result = await getCommentSubscribe(tableId, recordId); + expect(result?.data?.createdBy).toBe(userId); + + await deleteCommentSubscribe(tableId, recordId); + const subscribeInfo = await getCommentSubscribe(tableId, recordId); + // actually the subscribe info is null but, there is no idea to return ''. + expect(subscribeInfo.data).toEqual(''); + }); + }); +}); diff --git a/apps/nextjs-app/src/features/app/blocks/view/grid/GridViewBase.tsx b/apps/nextjs-app/src/features/app/blocks/view/grid/GridViewBase.tsx index 26e18d9ee6..944db3b877 100644 --- a/apps/nextjs-app/src/features/app/blocks/view/grid/GridViewBase.tsx +++ b/apps/nextjs-app/src/features/app/blocks/view/grid/GridViewBase.tsx @@ -3,7 +3,7 @@ import type { GridView } from '@teable/sdk'; import { useGridColumns } from '@teable/sdk'; import { useView, useViewId } from '@teable/sdk/hooks'; import { Skeleton } from '@teable/ui-lib'; -import React, { Fragment, useState } from 'react'; +import React, { useState } from 'react'; import { useMount } from 'react-use'; import { GridViewBaseInner } from './GridViewBaseInner'; diff --git a/apps/nextjs-app/src/features/app/blocks/view/grid/GridViewBaseInner.tsx b/apps/nextjs-app/src/features/app/blocks/view/grid/GridViewBaseInner.tsx index a9dfb993a7..57b684d837 100644 --- a/apps/nextjs-app/src/features/app/blocks/view/grid/GridViewBaseInner.tsx +++ b/apps/nextjs-app/src/features/app/blocks/view/grid/GridViewBaseInner.tsx @@ -35,6 +35,7 @@ import { useGridColumnStatistics, useGridColumnOrder, useGridAsyncRecords, + useCommentCountMap, useGridIcons, useGridTooltipStore, hexToRGBA, @@ -131,12 +132,10 @@ export const GridViewBaseInner: React.FC = ( generateLocalId(tableId, activeViewId) ); - const { onVisibleRegionChanged, onReset, recordMap, groupPoints } = useGridAsyncRecords( - ssrRecords, - undefined, - viewQuery, - groupPointsServerData - ); + const { onVisibleRegionChanged, onReset, recordMap, groupPoints, recordsQuery } = + useGridAsyncRecords(ssrRecords, undefined, viewQuery, groupPointsServerData); + + const commentCountMap = useCommentCountMap(recordsQuery); const onRowOrdered = useGridRowOrder(recordMap); @@ -681,6 +680,7 @@ export const GridViewBaseInner: React.FC = ( freezeColumnCount={frozenColumnCount} columnStatistics={columnStatistics} columns={columns} + commentCountMap={commentCountMap} customIcons={customIcons} rowControls={rowControls} collapsedGroupIds={collapsedGroupIds} @@ -741,6 +741,7 @@ export const GridViewBaseInner: React.FC = ( draggable={DraggableType.None} selectable={SelectableType.Cell} columns={columns} + commentCountMap={commentCountMap} columnHeaderVisible={false} freezeColumnCount={frozenColumnCount} customIcons={customIcons} diff --git a/apps/nextjs-app/src/features/app/components/ExpandRecordContainer/ExpandRecordContainer.tsx b/apps/nextjs-app/src/features/app/components/ExpandRecordContainer/ExpandRecordContainer.tsx index 110e2d64c4..4fd7dfde1f 100644 --- a/apps/nextjs-app/src/features/app/components/ExpandRecordContainer/ExpandRecordContainer.tsx +++ b/apps/nextjs-app/src/features/app/components/ExpandRecordContainer/ExpandRecordContainer.tsx @@ -18,7 +18,12 @@ export const ExpandRecordContainer = forwardRef< if (!recordId) { return; } - const { recordId: _recordId, fromNotify: _fromNotify, ...resetQuery } = router.query; + const { + recordId: _recordId, + fromNotify: _fromNotify, + commentId: _commentId, + ...resetQuery + } = router.query; router.push( { pathname: router.pathname, diff --git a/apps/nextjs-app/src/features/app/components/ExpandRecordContainer/ExpandRecordContainerBase.tsx b/apps/nextjs-app/src/features/app/components/ExpandRecordContainer/ExpandRecordContainerBase.tsx index b4e58b5eb7..6de68455fa 100644 --- a/apps/nextjs-app/src/features/app/components/ExpandRecordContainer/ExpandRecordContainerBase.tsx +++ b/apps/nextjs-app/src/features/app/components/ExpandRecordContainer/ExpandRecordContainerBase.tsx @@ -16,6 +16,7 @@ export const ExpandRecordContainerBase = forwardRef< const { tableId, recordServerData, onClose, onUpdateRecordIdCallback } = props; const router = useRouter(); const recordId = router.query.recordId as string; + const commentId = router.query.commentId as string; const [recordIds, setRecordIds] = useState(); useImperativeHandle(forwardRef, () => ({ @@ -26,6 +27,7 @@ export const ExpandRecordContainerBase = forwardRef< { ); } + case NotificationTypeEnum.Comment: case NotificationTypeEnum.CollaboratorCellTag: case NotificationTypeEnum.CollaboratorMultiRowTag: { const { userAvatarUrl, userName } = notifyIcon as INotificationUserIcon; diff --git a/apps/nextjs-app/tsconfig.json b/apps/nextjs-app/tsconfig.json index d96ddec0b9..a0ce0af4d5 100644 --- a/apps/nextjs-app/tsconfig.json +++ b/apps/nextjs-app/tsconfig.json @@ -9,6 +9,7 @@ "module": "esnext", "jsx": "preserve", "incremental": true, + "moduleResolution": "Bundler", "paths": { "@/test-utils": ["../config/tests/test-utils"], "@/config/*": ["./config/*"], diff --git a/monorepo.code-workspace b/monorepo.code-workspace index e9598dfa67..5f769387c3 100644 --- a/monorepo.code-workspace +++ b/monorepo.code-workspace @@ -72,6 +72,17 @@ "./packages/eslint-config-bases", "./packages/ui-lib", ], - "cSpell.words": ["jschardet", "sharedb", "Teable"], + "cSpell.words": [ + "combobox", + "jschardet", + "overscan", + "sharedb", + "tada", + "Teable", + "thumbsdown", + "thumbsup", + "udecode", + "zustand", + ], }, } diff --git a/packages/common-i18n/src/locales/en/sdk.json b/packages/common-i18n/src/locales/en/sdk.json index c8b8764857..2021636188 100644 --- a/packages/common-i18n/src/locales/en/sdk.json +++ b/packages/common-i18n/src/locales/en/sdk.json @@ -681,5 +681,29 @@ "aggregation": { "title": "Aggregation" } + }, + "comment": { + "title": "Comment", + "placeholder": "Leave a comment...", + "emptyComment": "Start a conversation", + "deletedComment": "Deleted Comment", + "imageSizeLimit": "Image size could not be greater than {{size}}", + "tip": { + "editing": "Editing...", + "edited": "(Edited)", + "onNotify": "Only be notified comment relative to you if you turn off", + "offNotify": "You will be notified by all active if you turn on" + }, + "toolbar": { + "link": "Link", + "image": "Image" + }, + "floatToolbar": { + "editLink": "Edit Link", + "caption": "Caption", + "delete": "Delete", + "linkText": "Link Text", + "enterUrl": "Enter URL" + } } } diff --git a/packages/common-i18n/src/locales/fr/sdk.json b/packages/common-i18n/src/locales/fr/sdk.json index f1c3753a00..5d4f7b8ec2 100644 --- a/packages/common-i18n/src/locales/fr/sdk.json +++ b/packages/common-i18n/src/locales/fr/sdk.json @@ -678,5 +678,29 @@ "aggregation": { "title": "Agrégation" } + }, + "comment": { + "title": "Commentaire", + "placeholder": "Laissez un commentaire...", + "emptyComment": "Démarrer une conversation", + "deletedComment": "Commentaire supprimé", + "imageSizeLimit": "La taille de l'image ne peut pas dépasser {{size}}", + "tip": { + "editing": "Modification en cours...", + "edited": "(Modifié)", + "onNotify": "Vous ne serez notifié que des commentaires vous concernant si vous désactivez", + "offNotify": "Vous serez notifié de toutes les activités si vous activez" + }, + "toolbar": { + "link": "Lien", + "image": "Image" + }, + "floatToolbar": { + "editLink": "Modifier le lien", + "caption": "Légende", + "delete": "Supprimer", + "linkText": "Texte du lien", + "enterUrl": "Entrez l'URL" + } } } diff --git a/packages/common-i18n/src/locales/ja/sdk.json b/packages/common-i18n/src/locales/ja/sdk.json index 2335dd7832..c523b47bbd 100644 --- a/packages/common-i18n/src/locales/ja/sdk.json +++ b/packages/common-i18n/src/locales/ja/sdk.json @@ -665,5 +665,29 @@ "aggregation": { "title": "集約" } + }, + "comment": { + "title": "コメント", + "placeholder": "コメントを残す...", + "emptyComment": "会話を始める", + "deletedComment": "削除されたコメント", + "imageSizeLimit": "画像サイズは{{size}}を超えることはできません", + "tip": { + "editing": "編集中...", + "edited": "(編集済み)", + "onNotify": "オフにすると、あなたに関連するコメントのみ通知されます", + "offNotify": "オンにすると、すべてのアクティビティが通知されます" + }, + "toolbar": { + "link": "リンク", + "image": "画像" + }, + "floatToolbar": { + "editLink": "リンクを編集", + "caption": "キャプション", + "delete": "削除", + "linkText": "リンクテキスト", + "enterUrl": "URLを入力" + } } } diff --git a/packages/common-i18n/src/locales/zh/sdk.json b/packages/common-i18n/src/locales/zh/sdk.json index a0be2150b2..e52342a710 100644 --- a/packages/common-i18n/src/locales/zh/sdk.json +++ b/packages/common-i18n/src/locales/zh/sdk.json @@ -682,5 +682,29 @@ "aggregation": { "title": "聚合" } + }, + "comment": { + "title": "评论", + "placeholder": "添加评论...", + "emptyComment": "开始一段新评论...", + "deletedComment": "该评论已删除", + "imageSizeLimit": "文件大小不能超过{{size}}", + "tip": { + "editing": "正在编辑...", + "edited": "(已编辑)", + "onNotify": "关闭之后只会接受到与自己相关的评论通知", + "offNotify": "开启后,可以收到所有评论动态" + }, + "toolbar": { + "link": "链接", + "image": "图片" + }, + "floatToolbar": { + "editLink": "编辑", + "caption": "设置标题", + "delete": "删除", + "linkText": "链接标题", + "enterUrl": "输入链接" + } } } diff --git a/packages/core/src/models/channel.ts b/packages/core/src/models/channel.ts index 0453529afa..56ad62c1dc 100644 --- a/packages/core/src/models/channel.ts +++ b/packages/core/src/models/channel.ts @@ -21,3 +21,11 @@ export function getBasePermissionUpdateChannel(baseId: string) { export function getTableImportChannel(tableId: string) { return `__table_import_${tableId}`; } + +export function getCommentChannel(tableId: string, recordId: string) { + return `__record_comment_${tableId}_${recordId}`; +} + +export function getTableCommentChannel(tableId: string) { + return `__table_comment_${tableId}`; +} diff --git a/packages/core/src/models/notification/notification.enum.ts b/packages/core/src/models/notification/notification.enum.ts index f83fbf8ecc..9de87a537b 100644 --- a/packages/core/src/models/notification/notification.enum.ts +++ b/packages/core/src/models/notification/notification.enum.ts @@ -2,6 +2,7 @@ export enum NotificationTypeEnum { System = 'system', CollaboratorCellTag = 'collaboratorCellTag', CollaboratorMultiRowTag = 'collaboratorMultiRowTag', + Comment = 'comment', } export enum NotificationStatesEnum { diff --git a/packages/core/src/models/notification/notification.schema.ts b/packages/core/src/models/notification/notification.schema.ts index a33494c6a6..ebf2a99741 100644 --- a/packages/core/src/models/notification/notification.schema.ts +++ b/packages/core/src/models/notification/notification.schema.ts @@ -21,6 +21,7 @@ export const tableRecordUrlSchema = z.object({ baseId: z.string().startsWith(IdPrefix.Base), tableId: z.string().startsWith(IdPrefix.Table), recordId: z.string().startsWith(IdPrefix.Record).optional(), + commentId: z.string().startsWith(IdPrefix.Comment).optional(), }); export const notificationUrlSchema = tableRecordUrlSchema.optional(); diff --git a/packages/core/src/utils/id-generator.ts b/packages/core/src/utils/id-generator.ts index d59df49228..c32c27ff04 100644 --- a/packages/core/src/utils/id-generator.ts +++ b/packages/core/src/utils/id-generator.ts @@ -8,6 +8,7 @@ export enum IdPrefix { Field = 'fld', View = 'viw', Record = 'rec', + Comment = 'com', Attachment = 'act', Choice = 'cho', @@ -68,6 +69,10 @@ export function generateRecordId() { return IdPrefix.Record + getRandomString(16); } +export function generateCommentId() { + return IdPrefix.Comment + getRandomString(16); +} + export function generateChoiceId() { return IdPrefix.Choice + getRandomString(8); } diff --git a/packages/db-main-prisma/prisma/postgres/migrations/20240919032636_add_comment/migration.sql b/packages/db-main-prisma/prisma/postgres/migrations/20240919032636_add_comment/migration.sql new file mode 100644 index 0000000000..10fa1a2627 --- /dev/null +++ b/packages/db-main-prisma/prisma/postgres/migrations/20240919032636_add_comment/migration.sql @@ -0,0 +1,32 @@ +-- CreateTable +CREATE TABLE "comment" ( + "id" TEXT NOT NULL, + "table_id" TEXT NOT NULL, + "record_id" TEXT NOT NULL, + "quote_Id" TEXT, + "content" TEXT, + "reaction" TEXT, + "deleted_time" TIMESTAMP(3), + "created_time" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "created_by" TEXT NOT NULL, + "last_modified_time" TIMESTAMP(3), + + CONSTRAINT "comment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "comment_subscription" ( + "table_id" TEXT NOT NULL, + "record_id" TEXT NOT NULL, + "created_by" TEXT NOT NULL, + "created_time" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateIndex +CREATE INDEX "comment_table_id_record_id_idx" ON "comment"("table_id", "record_id"); + +-- CreateIndex +CREATE INDEX "comment_subscription_table_id_record_id_idx" ON "comment_subscription"("table_id", "record_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "comment_subscription_table_id_record_id_key" ON "comment_subscription"("table_id", "record_id"); diff --git a/packages/db-main-prisma/prisma/postgres/schema.prisma b/packages/db-main-prisma/prisma/postgres/schema.prisma index eb6b25b38a..cce2161b7b 100644 --- a/packages/db-main-prisma/prisma/postgres/schema.prisma +++ b/packages/db-main-prisma/prisma/postgres/schema.prisma @@ -463,3 +463,31 @@ model Dashboard { @@map("dashboard") } + +model Comment { + id String @id @default(cuid()) + tableId String @map("table_id") + recordId String @map("record_id") + quoteId String? @map("quote_Id") + content String? + reaction String? + + deletedTime DateTime? @map("deleted_time") + createdTime DateTime @default(now()) @map("created_time") + createdBy String @map("created_by") + lastModifiedTime DateTime? @updatedAt @map("last_modified_time") + + @@map("comment") + @@index([tableId, recordId]) +} + +model CommentSubscription { + tableId String @map("table_id") + recordId String @map("record_id") + createdBy String @map("created_by") + createdTime DateTime @default(now()) @map("created_time") + + @@unique([tableId, recordId]) + @@index([tableId, recordId]) + @@map("comment_subscription") +} \ No newline at end of file diff --git a/packages/db-main-prisma/prisma/sqlite/migrations/20240919032621_add_comment/migration.sql b/packages/db-main-prisma/prisma/sqlite/migrations/20240919032621_add_comment/migration.sql new file mode 100644 index 0000000000..ac833c7446 --- /dev/null +++ b/packages/db-main-prisma/prisma/sqlite/migrations/20240919032621_add_comment/migration.sql @@ -0,0 +1,30 @@ +-- CreateTable +CREATE TABLE "comment" ( + "id" TEXT NOT NULL PRIMARY KEY, + "table_id" TEXT NOT NULL, + "record_id" TEXT NOT NULL, + "quote_Id" TEXT, + "content" TEXT, + "reaction" TEXT, + "deleted_time" DATETIME, + "created_time" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "created_by" TEXT NOT NULL, + "last_modified_time" DATETIME +); + +-- CreateTable +CREATE TABLE "comment_subscription" ( + "table_id" TEXT NOT NULL, + "record_id" TEXT NOT NULL, + "created_by" TEXT NOT NULL, + "created_time" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateIndex +CREATE INDEX "comment_table_id_record_id_idx" ON "comment"("table_id", "record_id"); + +-- CreateIndex +CREATE INDEX "comment_subscription_table_id_record_id_idx" ON "comment_subscription"("table_id", "record_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "comment_subscription_table_id_record_id_key" ON "comment_subscription"("table_id", "record_id"); diff --git a/packages/db-main-prisma/prisma/sqlite/schema.prisma b/packages/db-main-prisma/prisma/sqlite/schema.prisma index 039367955d..4658adf827 100644 --- a/packages/db-main-prisma/prisma/sqlite/schema.prisma +++ b/packages/db-main-prisma/prisma/sqlite/schema.prisma @@ -463,3 +463,31 @@ model Dashboard { @@map("dashboard") } + +model Comment { + id String @id @default(cuid()) + tableId String @map("table_id") + recordId String @map("record_id") + quoteId String? @map("quote_Id") + content String? + reaction String? + + deletedTime DateTime? @map("deleted_time") + createdTime DateTime @default(now()) @map("created_time") + createdBy String @map("created_by") + lastModifiedTime DateTime? @updatedAt @map("last_modified_time") + + @@map("comment") + @@index([tableId, recordId]) +} + +model CommentSubscription { + tableId String @map("table_id") + recordId String @map("record_id") + createdBy String @map("created_by") + createdTime DateTime @default(now()) @map("created_time") + + @@unique([tableId, recordId]) + @@index([tableId, recordId]) + @@map("comment_subscription") +} \ No newline at end of file diff --git a/packages/db-main-prisma/prisma/template.prisma b/packages/db-main-prisma/prisma/template.prisma index 2d78a589e3..c1783bcd6d 100644 --- a/packages/db-main-prisma/prisma/template.prisma +++ b/packages/db-main-prisma/prisma/template.prisma @@ -463,3 +463,31 @@ model Dashboard { @@map("dashboard") } + +model Comment { + id String @id @default(cuid()) + tableId String @map("table_id") + recordId String @map("record_id") + quoteId String? @map("quote_Id") + content String? + reaction String? + + deletedTime DateTime? @map("deleted_time") + createdTime DateTime @default(now()) @map("created_time") + createdBy String @map("created_by") + lastModifiedTime DateTime? @updatedAt @map("last_modified_time") + + @@map("comment") + @@index([tableId, recordId]) +} + +model CommentSubscription { + tableId String @map("table_id") + recordId String @map("record_id") + createdBy String @map("created_by") + createdTime DateTime @default(now()) @map("created_time") + + @@unique([tableId, recordId]) + @@index([tableId, recordId]) + @@map("comment_subscription") +} \ No newline at end of file diff --git a/packages/icons/package.json b/packages/icons/package.json index bffb46354f..23730c21a8 100644 --- a/packages/icons/package.json +++ b/packages/icons/package.json @@ -12,7 +12,15 @@ "directory": "packages/icons" }, "main": "./dist/index.js", + "module": "./dist/index.js", "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, "files": [ "dist" ], diff --git a/packages/icons/src/components/Image.tsx b/packages/icons/src/components/Image.tsx new file mode 100644 index 0000000000..fe0c662ddc --- /dev/null +++ b/packages/icons/src/components/Image.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; +import type { SVGProps } from 'react'; +const Image = (props: SVGProps) => ( + + + + +); +export default Image; diff --git a/packages/icons/src/index.ts b/packages/icons/src/index.ts index e6f1213305..b002af3c82 100644 --- a/packages/icons/src/index.ts +++ b/packages/icons/src/index.ts @@ -75,6 +75,7 @@ export { default as HelpCircle } from './components/HelpCircle'; export { default as History } from './components/History'; export { default as Home } from './components/Home'; export { default as HttpRequest } from './components/HttpRequest'; +export { default as Image } from './components/Image'; export { default as Import } from './components/Import'; export { default as Inbox } from './components/Inbox'; export { default as Kanban } from './components/Kanban'; diff --git a/packages/openapi/src/attachment/signature.ts b/packages/openapi/src/attachment/signature.ts index 232c229107..6855acaa3b 100644 --- a/packages/openapi/src/attachment/signature.ts +++ b/packages/openapi/src/attachment/signature.ts @@ -10,6 +10,7 @@ export enum UploadType { OAuth = 4, Import = 5, Plugin = 6, + Comment = 7, } export const signatureRoSchema = z.object({ diff --git a/packages/openapi/src/comment/create.ts b/packages/openapi/src/comment/create.ts new file mode 100644 index 0000000000..6c213690fd --- /dev/null +++ b/packages/openapi/src/comment/create.ts @@ -0,0 +1,42 @@ +import type { RouteConfig } from '@asteasolutions/zod-to-openapi'; +import { axios } from '../axios'; +import { registerRoute, urlBuilder } from '../utils'; +import { z } from '../zod'; +import type { ICommentVo } from './get'; +import { createCommentRoSchema } from './types'; +import type { ICreateCommentRo } from './types'; + +export const CREATE_COMMENT = '/comment/{tableId}/{recordId}/create'; + +export const CreateCommentRoute: RouteConfig = registerRoute({ + method: 'post', + path: CREATE_COMMENT, + description: 'create record comment', + request: { + params: z.object({ + tableId: z.string(), + recordId: z.string(), + }), + body: { + content: { + 'application/json': { + schema: createCommentRoSchema, + }, + }, + }, + }, + responses: { + 200: { + description: 'Successfully create comment.', + }, + }, + tags: ['comment'], +}); + +export const createComment = async ( + tableId: string, + recordId: string, + createCommentRo: ICreateCommentRo +) => { + return axios.post(urlBuilder(CREATE_COMMENT, { tableId, recordId }), createCommentRo); +}; diff --git a/packages/openapi/src/comment/delete.ts b/packages/openapi/src/comment/delete.ts new file mode 100644 index 0000000000..6f2392bac8 --- /dev/null +++ b/packages/openapi/src/comment/delete.ts @@ -0,0 +1,29 @@ +import type { RouteConfig } from '@asteasolutions/zod-to-openapi'; +import { axios } from '../axios'; +import { registerRoute, urlBuilder } from '../utils'; +import { z } from '../zod'; + +export const DELETE_COMMENT = '/comment/{tableId}/{recordId}/{commentId}'; + +export const DeleteCommentRoute: RouteConfig = registerRoute({ + method: 'delete', + path: DELETE_COMMENT, + description: 'delete record comment', + request: { + params: z.object({ + tableId: z.string(), + recordId: z.string(), + commentId: z.string(), + }), + }, + responses: { + 200: { + description: 'Successfully delete comment.', + }, + }, + tags: ['comment'], +}); + +export const deleteComment = async (tableId: string, recordId: string, commentId: string) => { + return axios.delete(urlBuilder(DELETE_COMMENT, { tableId, recordId, commentId })); +}; diff --git a/packages/openapi/src/comment/get-attachment-url.ts b/packages/openapi/src/comment/get-attachment-url.ts new file mode 100644 index 0000000000..1a6ebb8207 --- /dev/null +++ b/packages/openapi/src/comment/get-attachment-url.ts @@ -0,0 +1,33 @@ +import type { RouteConfig } from '@asteasolutions/zod-to-openapi'; +import { axios } from '../axios'; +import { registerRoute, urlBuilder } from '../utils'; +import { z } from '../zod'; + +export const GET_COMMENT_ATTACHMENT_URL = '/comment/{tableId}/{recordId}/attachment/{path}'; + +export const GetCommentAttachmentUrlRoute: RouteConfig = registerRoute({ + method: 'get', + path: GET_COMMENT_ATTACHMENT_URL, + description: 'Get record comment attachment url', + request: { + params: z.object({ + tableId: z.string(), + recordId: z.string(), + }), + }, + responses: { + 200: { + description: "Returns the record's comment attachment url", + content: { + 'application/json': { + schema: z.string(), + }, + }, + }, + }, + tags: ['comment'], +}); + +export const getCommentAttachmentUrl = async (tableId: string, recordId: string, path: string) => { + return axios.get(urlBuilder(GET_COMMENT_ATTACHMENT_URL, { tableId, recordId, path })); +}; diff --git a/packages/openapi/src/comment/get-count.ts b/packages/openapi/src/comment/get-count.ts new file mode 100644 index 0000000000..7b792e3b86 --- /dev/null +++ b/packages/openapi/src/comment/get-count.ts @@ -0,0 +1,42 @@ +import type { RouteConfig } from '@asteasolutions/zod-to-openapi'; +import { axios } from '../axios'; +import { getRecordsRoSchema } from '../record'; +import { registerRoute, urlBuilder } from '../utils'; +import { z } from '../zod'; + +export const GET_RECORD_COMMENT_COUNT = '/comment/{tableId}/{recordId}/count'; + +export const recordCommentCountVoSchema = z.object({ + count: z.number(), +}); + +export type IRecordCommentCountVo = z.infer; + +export const GetRecordCommentCountRoute: RouteConfig = registerRoute({ + method: 'get', + path: GET_RECORD_COMMENT_COUNT, + description: 'Get record comment count', + request: { + params: z.object({ + tableId: z.string(), + }), + query: getRecordsRoSchema, + }, + responses: { + 200: { + description: 'Returns the comment count by query', + content: { + 'application/json': { + schema: recordCommentCountVoSchema, + }, + }, + }, + }, + tags: ['comment'], +}); + +export const getRecordCommentCount = async (tableId: string, recordId: string) => { + return axios.get( + urlBuilder(GET_RECORD_COMMENT_COUNT, { tableId, recordId }) + ); +}; diff --git a/packages/openapi/src/comment/get-counts-by-query.ts b/packages/openapi/src/comment/get-counts-by-query.ts new file mode 100644 index 0000000000..6c8378d878 --- /dev/null +++ b/packages/openapi/src/comment/get-counts-by-query.ts @@ -0,0 +1,46 @@ +import type { RouteConfig } from '@asteasolutions/zod-to-openapi'; +import { axios } from '../axios'; +import type { IGetRecordsRo } from '../record'; +import { getRecordsRoSchema } from '../record'; +import { registerRoute, urlBuilder } from '../utils'; +import { z } from '../zod'; + +export const GET_COMMENT_COUNT = '/comment/{tableId}/count'; + +export const commentCountVoSchema = z + .object({ + recordId: z.string(), + count: z.number(), + }) + .array(); + +export type ICommentCountVo = z.infer; + +export const GetCommentCountRoute: RouteConfig = registerRoute({ + method: 'get', + path: GET_COMMENT_COUNT, + description: 'Get record comment counts by query', + request: { + params: z.object({ + tableId: z.string(), + }), + query: getRecordsRoSchema, + }, + responses: { + 200: { + description: 'Returns the comment counts by query', + content: { + 'application/json': { + schema: commentCountVoSchema, + }, + }, + }, + }, + tags: ['comment'], +}); + +export const getCommentCount = async (tableId: string, query: IGetRecordsRo) => { + return axios.get(urlBuilder(GET_COMMENT_COUNT, { tableId }), { + params: query, + }); +}; diff --git a/packages/openapi/src/comment/get-list.ts b/packages/openapi/src/comment/get-list.ts new file mode 100644 index 0000000000..099f1b1aa4 --- /dev/null +++ b/packages/openapi/src/comment/get-list.ts @@ -0,0 +1,74 @@ +import type { RouteConfig } from '@asteasolutions/zod-to-openapi'; +import { axios } from '../axios'; +import { registerRoute, urlBuilder } from '../utils'; +import { z } from '../zod'; +import { commentSchema } from './get'; + +export const GET_COMMENT_LIST = '/comment/{tableId}/{recordId}/list'; + +export const getCommentListVoSchema = z.object({ + comments: commentSchema.array(), + nextCursor: z.string().optional().nullable(), +}); + +export type IGetCommentListVo = z.infer; + +export const getCommentListQueryRoSchema = z.object({ + take: z + .string() + .or(z.number()) + .transform(Number) + .pipe( + z + .number() + .min(1, 'You should at least take 1 record') + .max(1000, `Can't take more than ${1000} records, please reduce take count`) + ) + .default(20) + .optional() + .openapi({ + example: 20, + description: `The record count you want to take, maximum is ${1000}`, + }), + cursor: z.string().optional().nullable(), + includeCursor: z + .union([z.boolean(), z.enum(['true', 'false']).transform((value) => value === 'true')]) + .optional(), + direction: z.union([z.literal('forward'), z.literal('backward')]).optional(), +}); + +export type IGetCommentListQueryRo = z.infer; + +export const GetCommentListRoute: RouteConfig = registerRoute({ + method: 'get', + path: GET_COMMENT_LIST, + description: 'Get record comment list', + request: { + params: z.object({ + tableId: z.string(), + recordId: z.string(), + }), + query: getCommentListQueryRoSchema, + }, + responses: { + 200: { + description: "Returns the list of record's comment", + content: { + 'application/json': { + schema: getCommentListVoSchema, + }, + }, + }, + }, + tags: ['comment'], +}); + +export const getCommentList = async ( + tableId: string, + recordId: string, + getCommentListQueryRo: IGetCommentListQueryRo +) => { + return axios.get(urlBuilder(GET_COMMENT_LIST, { tableId, recordId }), { + params: getCommentListQueryRo, + }); +}; diff --git a/packages/openapi/src/comment/get.ts b/packages/openapi/src/comment/get.ts new file mode 100644 index 0000000000..2e34cf3d6e --- /dev/null +++ b/packages/openapi/src/comment/get.ts @@ -0,0 +1,50 @@ +import type { RouteConfig } from '@asteasolutions/zod-to-openapi'; +import { axios } from '../axios'; +import { registerRoute, urlBuilder } from '../utils'; +import { z } from '../zod'; +import { commentReactionSchema } from './reaction'; +import { commentContentSchema } from './types'; + +export const GET_COMMENT_DETAIL = '/comment/{tableId}/{recordId}/{commentId}'; + +export const commentSchema = z.object({ + id: z.string(), + content: commentContentSchema, + createdBy: z.string(), + reaction: commentReactionSchema.optional().nullable(), + createdTime: z.date(), + lastModifiedTime: z.date(), + quoteId: z.string().optional(), + deletedTime: z.date().optional(), +}); + +export type ICommentVo = z.infer; + +export const GetCommentDetailRoute: RouteConfig = registerRoute({ + method: 'get', + path: GET_COMMENT_DETAIL, + description: 'Get record comment detail', + request: { + params: z.object({ + tableId: z.string(), + recordId: z.string(), + }), + }, + responses: { + 200: { + description: "Returns the record's comment detail", + content: { + 'application/json': { + schema: commentSchema.array(), + }, + }, + }, + }, + tags: ['comment'], +}); + +export const getCommentDetail = async (tableId: string, recordId: string, commentId: string) => { + return axios.get( + urlBuilder(GET_COMMENT_DETAIL, { tableId, recordId, commentId }) + ); +}; diff --git a/packages/openapi/src/comment/index.ts b/packages/openapi/src/comment/index.ts new file mode 100644 index 0000000000..f8423b36e4 --- /dev/null +++ b/packages/openapi/src/comment/index.ts @@ -0,0 +1,11 @@ +export * from './get-list'; +export * from './types'; +export * from './create'; +export * from './update'; +export * from './get'; +export * from './delete'; +export * from './reaction'; +export * from './subscribe'; +export * from './get-attachment-url'; +export * from './get-counts-by-query'; +export * from './get-count'; diff --git a/packages/openapi/src/comment/reaction/constant.ts b/packages/openapi/src/comment/reaction/constant.ts new file mode 100644 index 0000000000..aa19bfbcc4 --- /dev/null +++ b/packages/openapi/src/comment/reaction/constant.ts @@ -0,0 +1,64 @@ +export enum EmojiSymbol { + thumbsup = `👍`, + thumbsdown = `👎`, + smile = `😄`, + heart = `❤️`, + eyes = `👀`, + rocket = `🚀`, + tada = `🎉`, +} + +export const SUPPORT_EMOJIS = [ + EmojiSymbol.thumbsup, + EmojiSymbol.thumbsdown, + EmojiSymbol.smile, + EmojiSymbol.heart, + EmojiSymbol.eyes, + EmojiSymbol.rocket, + EmojiSymbol.tada, +]; + +export const Emojis = [ + { + key: 'thumbsup', + value: EmojiSymbol.thumbsup, + unified: '1f44d', + unifiedWithoutSkinTone: '1f44d', + }, + { + key: 'thumbsdown', + value: EmojiSymbol.thumbsdown, + unified: '1f44e', + unifiedWithoutSkinTone: '1f44e', + }, + { + key: 'smile', + value: EmojiSymbol.smile, + unified: '1f604', + unifiedWithoutSkinTone: '1f604', + }, + { + key: 'heart', + value: EmojiSymbol.heart, + unified: '2764-fe0f', + unifiedWithoutSkinTone: '2764-fe0f', + }, + { + key: 'eyes', + value: EmojiSymbol.eyes, + unified: '1f440', + unifiedWithoutSkinTone: '1f440', + }, + { + key: 'rocket', + value: EmojiSymbol.rocket, + unified: '1f680', + unifiedWithoutSkinTone: '1f680', + }, + { + key: 'tada', + value: EmojiSymbol.tada, + unified: '1f389', + unifiedWithoutSkinTone: '1f389', + }, +]; diff --git a/packages/openapi/src/comment/reaction/create-reaction.ts b/packages/openapi/src/comment/reaction/create-reaction.ts new file mode 100644 index 0000000000..5213401b5d --- /dev/null +++ b/packages/openapi/src/comment/reaction/create-reaction.ts @@ -0,0 +1,66 @@ +import type { RouteConfig } from '@asteasolutions/zod-to-openapi'; +import { axios } from '../../axios'; +import { registerRoute, urlBuilder } from '../../utils'; +import { z } from '../../zod'; +import type { EmojiSymbol } from './constant'; +import { SUPPORT_EMOJIS } from './constant'; + +export const CREATE_COMMENT_REACTION = '/comment/{tableId}/{recordId}/{commentId}/reaction'; + +export const commentReactionSymbolSchema = z + .string() + .emoji() + .refine((value) => { + return SUPPORT_EMOJIS.includes(value as EmojiSymbol); + }); + +export const commentReactionSchema = z + .object({ + reaction: commentReactionSymbolSchema, + user: z.array(z.string()), + }) + .array(); + +export const updateCommentReactionRoSchema = z.object({ + reaction: commentReactionSymbolSchema, +}); + +export type IUpdateCommentReactionRo = z.infer; + +export const CreateCommentReactionRoute: RouteConfig = registerRoute({ + method: 'post', + path: CREATE_COMMENT_REACTION, + description: 'create record comment reaction', + request: { + params: z.object({ + tableId: z.string(), + recordId: z.string(), + commentId: z.string(), + }), + body: { + content: { + 'application/json': { + schema: updateCommentReactionRoSchema, + }, + }, + }, + }, + responses: { + 200: { + description: 'Successfully create comment reaction.', + }, + }, + tags: ['comment'], +}); + +export const createCommentReaction = async ( + tableId: string, + recordId: string, + commentId: string, + createCommentReactionRo: IUpdateCommentReactionRo +) => { + return axios.patch( + urlBuilder(CREATE_COMMENT_REACTION, { tableId, recordId, commentId }), + createCommentReactionRo + ); +}; diff --git a/packages/openapi/src/comment/reaction/delete-reaction.ts b/packages/openapi/src/comment/reaction/delete-reaction.ts new file mode 100644 index 0000000000..8d9a3786ad --- /dev/null +++ b/packages/openapi/src/comment/reaction/delete-reaction.ts @@ -0,0 +1,45 @@ +import type { RouteConfig } from '@asteasolutions/zod-to-openapi'; +import { axios } from '../../axios'; +import { registerRoute, urlBuilder } from '../../utils'; +import { z } from '../../zod'; +import type { IUpdateCommentReactionRo } from './create-reaction'; +import { updateCommentReactionRoSchema } from './create-reaction'; + +export const DELETE_COMMENT_REACTION = '/comment/{tableId}/{recordId}/{commentId}/reaction'; + +export const DeleteCommentReactionRoute: RouteConfig = registerRoute({ + method: 'delete', + path: DELETE_COMMENT_REACTION, + description: 'delete record comment reaction', + request: { + params: z.object({ + tableId: z.string(), + recordId: z.string(), + commentId: z.string(), + }), + body: { + content: { + 'application/json': { + schema: updateCommentReactionRoSchema, + }, + }, + }, + }, + responses: { + 200: { + description: 'Successfully delete comment reaction.', + }, + }, + tags: ['comment'], +}); + +export const deleteCommentReaction = async ( + tableId: string, + recordId: string, + commentId: string, + deleteCommentReactionRo: IUpdateCommentReactionRo +) => { + return axios.delete(urlBuilder(DELETE_COMMENT_REACTION, { tableId, recordId, commentId }), { + data: deleteCommentReactionRo, + }); +}; diff --git a/packages/openapi/src/comment/reaction/index.ts b/packages/openapi/src/comment/reaction/index.ts new file mode 100644 index 0000000000..2acabca6d8 --- /dev/null +++ b/packages/openapi/src/comment/reaction/index.ts @@ -0,0 +1,3 @@ +export * from './create-reaction'; +export * from './delete-reaction'; +export * from './constant'; diff --git a/packages/openapi/src/comment/subscribe/create-subscribe.ts b/packages/openapi/src/comment/subscribe/create-subscribe.ts new file mode 100644 index 0000000000..4a8f7c0b73 --- /dev/null +++ b/packages/openapi/src/comment/subscribe/create-subscribe.ts @@ -0,0 +1,28 @@ +import type { RouteConfig } from '@asteasolutions/zod-to-openapi'; +import { axios } from '../../axios'; +import { registerRoute, urlBuilder } from '../../utils'; +import { z } from '../../zod'; + +export const CREATE_COMMENT_SUBSCRIBE = '/comment/{tableId}/{recordId}/subscribe'; + +export const CreateCommentSubscribeRoute: RouteConfig = registerRoute({ + method: 'post', + path: CREATE_COMMENT_SUBSCRIBE, + description: "subscribe record comment's active", + request: { + params: z.object({ + tableId: z.string(), + recordId: z.string(), + }), + }, + responses: { + 200: { + description: 'Successfully subscribe record comment.', + }, + }, + tags: ['comment'], +}); + +export const createCommentSubscribe = async (tableId: string, recordId: string) => { + return axios.post(urlBuilder(CREATE_COMMENT_SUBSCRIBE, { tableId, recordId })); +}; diff --git a/packages/openapi/src/comment/subscribe/delete-subscribe.ts b/packages/openapi/src/comment/subscribe/delete-subscribe.ts new file mode 100644 index 0000000000..633c68ccf2 --- /dev/null +++ b/packages/openapi/src/comment/subscribe/delete-subscribe.ts @@ -0,0 +1,28 @@ +import type { RouteConfig } from '@asteasolutions/zod-to-openapi'; +import { axios } from '../../axios'; +import { registerRoute, urlBuilder } from '../../utils'; +import { z } from '../../zod'; + +export const DELETE_COMMENT_SUBSCRIBE = '/comment/{tableId}/{recordId}/subscribe'; + +export const DeleteCommentSubscribeRoute: RouteConfig = registerRoute({ + method: 'delete', + path: DELETE_COMMENT_SUBSCRIBE, + description: 'unsubscribe record comment', + request: { + params: z.object({ + tableId: z.string(), + recordId: z.string(), + }), + }, + responses: { + 200: { + description: 'Successfully subscribe record comment.', + }, + }, + tags: ['comment'], +}); + +export const deleteCommentSubscribe = async (tableId: string, recordId: string) => { + return axios.delete(urlBuilder(DELETE_COMMENT_SUBSCRIBE, { tableId, recordId })); +}; diff --git a/packages/openapi/src/comment/subscribe/get-subscribe.ts b/packages/openapi/src/comment/subscribe/get-subscribe.ts new file mode 100644 index 0000000000..faace81ed7 --- /dev/null +++ b/packages/openapi/src/comment/subscribe/get-subscribe.ts @@ -0,0 +1,43 @@ +import type { RouteConfig } from '@asteasolutions/zod-to-openapi'; +import { axios } from '../../axios'; +import { registerRoute, urlBuilder } from '../../utils'; +import { z } from '../../zod'; + +export const GET_COMMENT_SUBSCRIBE = '/comment/{tableId}/{recordId}/subscribe'; + +export const commentSubscribeVoSchema = z.object({ + tableId: z.string(), + recordId: z.string(), + createdBy: z.string(), +}); + +export type ICommentSubscribeVo = z.infer; + +export const GetCommentSubscribeRoute: RouteConfig = registerRoute({ + method: 'get', + path: GET_COMMENT_SUBSCRIBE, + description: 'get record comment subscribe detail', + request: { + params: z.object({ + tableId: z.string(), + recordId: z.string(), + }), + }, + responses: { + 200: { + description: 'Successfully get record comment subscribe detail.', + content: { + 'application/json': { + schema: commentSubscribeVoSchema.nullable(), + }, + }, + }, + }, + tags: ['comment'], +}); + +export const getCommentSubscribe = async (tableId: string, recordId: string) => { + return axios.get( + urlBuilder(GET_COMMENT_SUBSCRIBE, { tableId, recordId }) + ); +}; diff --git a/packages/openapi/src/comment/subscribe/index.ts b/packages/openapi/src/comment/subscribe/index.ts new file mode 100644 index 0000000000..4559bae15b --- /dev/null +++ b/packages/openapi/src/comment/subscribe/index.ts @@ -0,0 +1,3 @@ +export * from './create-subscribe'; +export * from './delete-subscribe'; +export * from './get-subscribe'; diff --git a/packages/openapi/src/comment/types.ts b/packages/openapi/src/comment/types.ts new file mode 100644 index 0000000000..567abc9834 --- /dev/null +++ b/packages/openapi/src/comment/types.ts @@ -0,0 +1,85 @@ +import { z } from '../zod'; + +export enum CommentNodeType { + // inline + Text = 'span', + Link = 'a', + + // block + Paragraph = 'p', + Img = 'img', + + // custom + Mention = 'mention', +} + +export enum CommentPatchType { + CreateComment = 'create_comment', + UpdateComment = 'update_comment', + DeleteComment = 'delete_comment', + + CreateReaction = 'create_reaction', + DeleteReaction = 'delete_reaction', +} + +export const baseCommentContentSchema = z.object({ + type: z.nativeEnum(CommentNodeType), + value: z.unknown().optional(), +}); + +export const textCommentContentSchema = baseCommentContentSchema.extend({ + type: z.literal(CommentNodeType.Text), + value: z.string(), +}); + +export const mentionCommentContentSchema = baseCommentContentSchema.extend({ + type: z.literal(CommentNodeType.Mention), + value: z.string(), +}); + +export const linkCommentContentSchema = baseCommentContentSchema.extend({ + type: z.literal(CommentNodeType.Link), + url: z.string(), + title: z.string(), +}); + +export const imageCommentContentSchema = baseCommentContentSchema.extend({ + type: z.literal(CommentNodeType.Img), + path: z.string(), + width: z.number().optional(), +}); + +export const paragraphCommentContentSchema = baseCommentContentSchema.extend({ + type: z.literal(CommentNodeType.Paragraph), + children: z.array( + z.union([textCommentContentSchema, mentionCommentContentSchema, linkCommentContentSchema]) + ), +}); + +export type IParagraphCommentContent = z.infer; + +export const commentContentSchema = z + .union([paragraphCommentContentSchema, imageCommentContentSchema]) + .array(); + +export type ICommentContent = z.infer; + +export const createCommentRoSchema = z.object({ + quoteId: z.string().optional().nullable(), + content: commentContentSchema, +}); + +export const updateCommentRoSchema = createCommentRoSchema.pick({ + content: true, +}); + +export type ICreateCommentRo = z.infer; + +export type IUpdateCommentRo = z.infer; + +export const commentPatchDataSchema = z.object({ + type: z.nativeEnum(CommentPatchType), + data: z.record(z.unknown()), +}); + +export type ICommentPatchData = z.infer; diff --git a/packages/openapi/src/comment/update.ts b/packages/openapi/src/comment/update.ts new file mode 100644 index 0000000000..abd252afb6 --- /dev/null +++ b/packages/openapi/src/comment/update.ts @@ -0,0 +1,46 @@ +import type { RouteConfig } from '@asteasolutions/zod-to-openapi'; +import { axios } from '../axios'; +import { registerRoute, urlBuilder } from '../utils'; +import { z } from '../zod'; +import { updateCommentRoSchema } from './types'; +import type { IUpdateCommentRo } from './types'; + +export const UPDATE_COMMENT = '/comment/{tableId}/{recordId}/{commentId}'; + +export const UpdateCommentRoute: RouteConfig = registerRoute({ + method: 'patch', + path: UPDATE_COMMENT, + description: 'update record comment', + request: { + params: z.object({ + tableId: z.string(), + recordId: z.string(), + commentId: z.string(), + }), + body: { + content: { + 'application/json': { + schema: updateCommentRoSchema, + }, + }, + }, + }, + responses: { + 200: { + description: 'Successfully update comment.', + }, + }, + tags: ['comment'], +}); + +export const updateComment = async ( + tableId: string, + recordId: string, + commentId: string, + updateCommentRo: IUpdateCommentRo +) => { + return axios.patch( + urlBuilder(UPDATE_COMMENT, { tableId, recordId, commentId }), + updateCommentRo + ); +}; diff --git a/packages/openapi/src/index.ts b/packages/openapi/src/index.ts index b1af189960..86d3eb5a64 100644 --- a/packages/openapi/src/index.ts +++ b/packages/openapi/src/index.ts @@ -30,3 +30,4 @@ export * from './trash'; export * from './undo-redo'; export * from './plugin'; export * from './dashboard'; +export * from './comment'; diff --git a/packages/openapi/src/user/update-name.ts b/packages/openapi/src/user/update-name.ts index 1cbbb6aae5..e8bdd75989 100644 --- a/packages/openapi/src/user/update-name.ts +++ b/packages/openapi/src/user/update-name.ts @@ -1,6 +1,6 @@ import type { RouteConfig } from '@asteasolutions/zod-to-openapi'; import { axios } from '../axios'; -import { registerRoute, urlBuilder } from '../utils'; +import { registerRoute } from '../utils'; import { z } from '../zod'; export const UPDATE_USER_NAME = '/user/name'; diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 02e0e886f0..016c4e3908 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -47,9 +47,11 @@ "test": "run-s test-unit", "test-unit": "vitest run --silent", "test-unit-cover": "pnpm test-unit --coverage", - "fix-all-files": "eslint . --ext .ts,.tsx,.js,.jsx,.cjs,.mjs --fix" + "fix-all-files": "eslint . --ext .ts,.tsx,.js,.jsx,.cjs,.mjs --fix", + "plate:add": "npx @udecode/plate-ui@latest add" }, "dependencies": { + "@ariakit/react": "0.4.10", "@belgattitude/http-exception": "1.5.0", "@codemirror/autocomplete": "6.15.0", "@codemirror/commands": "6.3.3", @@ -61,6 +63,9 @@ "@dnd-kit/utilities": "3.2.2", "@lezer/highlight": "1.2.0", "@radix-ui/react-icons": "1.3.0", + "@radix-ui/react-separator": "1.0.3", + "@radix-ui/react-toolbar": "1.1.0", + "@radix-ui/react-tooltip": "1.0.7", "@tanstack/react-query": "4.36.1", "@tanstack/react-table": "8.11.7", "@tanstack/react-virtual": "3.2.0", @@ -70,9 +75,25 @@ "@teable/next-themes": "0.3.3", "@teable/openapi": "workspace:*", "@teable/ui-lib": "workspace:*", + "@udecode/cn": "37.0.0", + "@udecode/plate-alignment": "37.0.0", + "@udecode/plate-combobox": "37.0.0", + "@udecode/plate-common": "37.0.0", + "@udecode/plate-core": "37.0.7", + "@udecode/plate-floating": "37.0.0", + "@udecode/plate-heading": "37.0.0", + "@udecode/plate-image": "16.0.1", + "@udecode/plate-link": "37.0.0", + "@udecode/plate-media": "37.0.0", + "@udecode/plate-mention": "37.0.0", + "@udecode/plate-resizable": "37.0.0", + "@udecode/plate-select": "37.0.0", + "@udecode/plate-slash-command": "37.0.0", + "@udecode/plate-trailing-block": "37.0.0", "antlr4ts": "0.5.0-alpha.4", "axios": "1.6.8", "class-transformer": "0.5.1", + "class-variance-authority": "0.7.0", "date-fns": "2.30.0", "date-fns-tz": "2.0.1", "dayjs": "1.11.10", @@ -92,6 +113,10 @@ "reconnecting-websocket": "4.4.0", "scroller": "0.0.3", "sharedb": "4.1.2", + "slate": "0.103.0", + "slate-history": "0.109.0", + "slate-hyperscript": "0.100.0", + "slate-react": "0.109.0", "ts-key-enum": "2.0.12", "ts-keycode-enum": "1.0.6", "ts-mixer": "6.0.4", diff --git a/packages/sdk/plate-components.json b/packages/sdk/plate-components.json new file mode 100644 index 0000000000..4b28a21970 --- /dev/null +++ b/packages/sdk/plate-components.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://platejs.org/schema.json", + "aliases": { + "components": "src/components/comment/comment-editor" + }, + "rsc": false, + "style": "default", + "tailwind": { + "baseColor": "slate", + "config": "tailwind.config.js", + "css": "src/styles/globals.css", + "cssVariables": true + } +} diff --git a/packages/sdk/src/components/comment/CommentHeader.tsx b/packages/sdk/src/components/comment/CommentHeader.tsx new file mode 100644 index 0000000000..0355d8b117 --- /dev/null +++ b/packages/sdk/src/components/comment/CommentHeader.tsx @@ -0,0 +1,80 @@ +import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query'; +import { Bell } from '@teable/icons'; +import { + getCommentSubscribe, + createCommentSubscribe, + deleteCommentSubscribe, +} from '@teable/openapi'; +import { Toggle, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@teable/ui-lib'; +import { ReactQueryKeys } from '../../config'; +import { useTranslation } from '../../context/app/i18n'; +import type { IBaseQueryParams } from './types'; + +interface ICommentHeaderProps extends IBaseQueryParams {} + +export const CommentHeader = (props: ICommentHeaderProps) => { + const { tableId, recordId } = props; + const { t } = useTranslation(); + const queryClient = useQueryClient(); + + const { data: subscribeStatus } = useQuery({ + queryKey: ReactQueryKeys.commentSubscribeStatus(tableId, recordId), + queryFn: () => + getCommentSubscribe(tableId!, recordId!).then((res) => { + return res.data; + }), + enabled: !!(tableId && recordId), + }); + + const { mutateAsync: createSubscribe } = useMutation({ + mutationFn: ({ tableId, recordId }: { tableId: string; recordId: string }) => + createCommentSubscribe(tableId, recordId), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ReactQueryKeys.commentSubscribeStatus(tableId, recordId), + }); + }, + }); + + const { mutateAsync: deleteSubscribeFn } = useMutation({ + mutationFn: ({ tableId, recordId }: { tableId: string; recordId: string }) => + deleteCommentSubscribe(tableId, recordId), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ReactQueryKeys.commentSubscribeStatus(tableId, recordId), + }); + }, + }); + + const subscribeHandler = () => { + if (!subscribeStatus) { + createSubscribe({ tableId: tableId!, recordId: recordId! }); + } else { + deleteSubscribeFn({ tableId: tableId!, recordId: recordId! }); + } + }; + + return ( + + +
+
{t('comment.title')}
+ + subscribeHandler()} + > + + + +
+ +

{subscribeStatus ? t('comment.tip.onNotify') : t('comment.tip.offNotify')}

+
+
+
+ ); +}; diff --git a/packages/sdk/src/components/comment/CommentPanel.tsx b/packages/sdk/src/components/comment/CommentPanel.tsx new file mode 100644 index 0000000000..9bf22f251f --- /dev/null +++ b/packages/sdk/src/components/comment/CommentPanel.tsx @@ -0,0 +1,43 @@ +import { useQuery } from '@tanstack/react-query'; +import { getBaseCollaboratorList } from '@teable/openapi'; +import { useEffect } from 'react'; +import { ReactQueryKeys } from '../../config'; +import { CommentEditor } from './comment-editor'; +import { CommentList } from './comment-list'; +import { CommentHeader } from './CommentHeader'; +import { CommentContext } from './context'; +import type { IBaseQueryParams } from './types'; +import { useCommentStore } from './useCommentStore'; + +interface ICommentPanelProps extends IBaseQueryParams { + baseId: string; + tableId: string; + recordId: string; + commentId?: string; +} + +export const CommentPanel = (props: ICommentPanelProps) => { + const { recordId, tableId, baseId, commentId } = props; + const { resetCommentStore } = useCommentStore(); + + const { data: collaborators = [] } = useQuery({ + queryKey: ReactQueryKeys.baseCollaboratorList(baseId), + queryFn: ({ queryKey }) => getBaseCollaboratorList(queryKey[1]).then((res) => res.data), + }); + + useEffect(() => { + return () => { + resetCommentStore?.(); + }; + }, [resetCommentStore]); + + return ( + +
+ + + +
+
+ ); +}; diff --git a/packages/sdk/src/components/comment/comment-editor/CommentEditor.tsx b/packages/sdk/src/components/comment/comment-editor/CommentEditor.tsx new file mode 100644 index 0000000000..060a17176b --- /dev/null +++ b/packages/sdk/src/components/comment/comment-editor/CommentEditor.tsx @@ -0,0 +1,306 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { generateAttachmentId } from '@teable/core'; +import type { ICreateCommentRo, IUpdateCommentRo } from '@teable/openapi'; +import { createComment, getCommentDetail, updateComment, UploadType } from '@teable/openapi'; +import { Button, toast } from '@teable/ui-lib'; +import { AlignPlugin } from '@udecode/plate-alignment'; +import { insertNodes } from '@udecode/plate-common'; +import type { TElement } from '@udecode/plate-common'; +import { + Plate, + usePlateEditor, + ParagraphPlugin, + blurEditor, + focusEditor, +} from '@udecode/plate-common/react'; +import { LinkPlugin } from '@udecode/plate-link/react'; +import type { TMentionElement } from '@udecode/plate-mention'; +import { MentionPlugin, MentionInputPlugin } from '@udecode/plate-mention/react'; +import { DeletePlugin, SelectOnBackspacePlugin } from '@udecode/plate-select'; +import { SlashPlugin } from '@udecode/plate-slash-command'; +import { TrailingBlockPlugin } from '@udecode/plate-trailing-block'; +import { noop } from 'lodash'; +import { useEffect, useRef, useState } from 'react'; +import { ReactQueryKeys } from '../../../config'; +import { useTranslation } from '../../../context/app/i18n'; +import { useTablePermission } from '../../../hooks'; +import { AttachmentManager } from '../../editor'; +import { useModalRefElement } from '../../expand-record/useModalRefElement'; +import { MentionUser } from '../comment-list/node'; +import { useCollaborators } from '../hooks'; +import { useCommentStore } from '../useCommentStore'; +import { CommentQuote } from './CommentQuote'; +import { Editor } from './Editor'; +import { + MentionInputElement, + LinkElement, + LinkFloatingToolbar, + LinkToolbarButton, + MentionElement, + Toolbar, + TooltipProvider, + withPlaceholders, + ImageElement, + ImageToolbarButton, + ParagraphElement, + ImagePreview, +} from './plate-ui'; +import type { TImageElement } from './plugin'; +import { ImagePlugin } from './plugin'; +import { EditorTransform } from './transform'; + +interface ICommentEditorProps { + tableId: string; + recordId: string; +} + +const defaultEditorValue = [ + { + type: 'p', + children: [{ text: '' }], + }, +] as TElement[]; + +export const CommentEditor = (props: ICommentEditorProps) => { + const { tableId, recordId } = props; + const editorRef = useRef({ + focus: noop, + blur: noop, + }); + const { t } = useTranslation(); + const { quoteId, setQuoteId, setEditorRef, editingCommentId, setEditingCommentId } = + useCommentStore(); + const mentionUserRender = (element: TMentionElement) => { + const value = element.value; + return ; + }; + const [value, setValue] = useState(defaultEditorValue); + const permission = useTablePermission(); + const queryClient = useQueryClient(); + const modalElementRef = useModalRefElement(); + const editor = usePlateEditor({ + id: recordId, + plugins: [ + MentionPlugin.configure({ + options: { + triggerPreviousCharPattern: /^$|^[\s"']$/, + }, + }), + LinkPlugin.extend({ + render: { afterEditable: () => }, + }), + DeletePlugin, + ImagePlugin.extend({ + options: { + customUploadImage: (file: File) => { + if (file.size > 5 * 1024 * 1024) { + toast({ + variant: 'destructive', + description: t('comment.imageSizeLimit', { size: `5MB` }), + }); + return; + } + const attachmentManager = new AttachmentManager(1); + attachmentManager.upload( + [ + { + id: generateAttachmentId(), + instance: file, + }, + ], + UploadType.Comment, + { + successCallback: (_, result) => { + const text = { text: '' }; + const image: TImageElement = { + children: [text], + type: editor.getType(ImagePlugin), + url: result.presignedUrl, + path: result.path, + }; + insertNodes(editor, image, { + nextBlock: true, + }); + }, + } + ); + }, + }, + render: { afterEditable: ImagePreview }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any), + SlashPlugin, + AlignPlugin.extend({ + inject: { + targetPlugins: [ImagePlugin.key], + }, + }), + TrailingBlockPlugin.configure({ + options: { type: ParagraphPlugin.key }, + }), + SelectOnBackspacePlugin.configure({ + options: { + query: { + allow: [ImagePlugin.key], + }, + }, + }), + ], + shouldNormalizeEditor: true, + override: { + components: { + ...withPlaceholders({ + [LinkPlugin.key]: LinkElement, + [MentionPlugin.key]: (props: React.ComponentProps) => ( + + ), + [MentionInputPlugin.key]: MentionInputElement, + [ImagePlugin.key]: ImageElement, + }), + [ParagraphPlugin.key]: ParagraphElement, + }, + }, + value: value, + }); + const { data: editingComment } = useQuery({ + queryKey: [editingCommentId], + queryFn: () => getCommentDetail(tableId!, recordId!, editingCommentId!).then((res) => res.data), + enabled: !!tableId && !!recordId && !!editingCommentId, + }); + useEffect(() => { + // todo replace Standard api to reset to value + if (editingCommentId && editingComment) { + editor?.api?.reset(); + editor.insertNodes(EditorTransform.commentValue2EditorValue(editingComment.content), { + at: [0], + }); + } + }, [editingCommentId, editor, editingComment, tableId, recordId]); + useEffect(() => { + editorRef.current = { + focus: () => focusEditor(editor), + blur: () => blurEditor(editor), + }; + setEditorRef(editorRef.current); + }, [editor, setEditorRef]); + const { mutateAsync: createCommentFn } = useMutation({ + mutationFn: ({ + tableId, + recordId, + createCommentRo, + }: { + tableId: string; + recordId: string; + createCommentRo: ICreateCommentRo; + }) => createComment(tableId, recordId, createCommentRo), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ReactQueryKeys.recordCommentCount(tableId, recordId), + }); + editor?.api?.reset(); + setQuoteId(undefined); + }, + }); + const { mutateAsync: updateCommentFn } = useMutation({ + mutationFn: ({ + tableId, + recordId, + commentId, + updateCommentRo, + }: { + tableId: string; + recordId: string; + commentId: string; + updateCommentRo: IUpdateCommentRo; + }) => updateComment(tableId, recordId, commentId, updateCommentRo), + onSuccess: () => { + editor?.api?.reset(); + setQuoteId(undefined); + setEditingCommentId(undefined); + }, + }); + const submit = () => { + if (!EditorTransform.editorValue2CommentValue(value).length) { + return; + } + if (editingCommentId) { + updateCommentFn({ + tableId, + recordId, + commentId: editingCommentId, + updateCommentRo: { + content: EditorTransform.editorValue2CommentValue(value), + }, + }); + } else { + createCommentFn({ + tableId, + recordId, + createCommentRo: { + quoteId: quoteId, + content: EditorTransform.editorValue2CommentValue(value), + }, + }); + } + }; + + return ( + +
+ { + setValue(value); + }} + > + {editingCommentId && ( +
+ {t('comment.tip.editing')} + + +
+ )} + { + setQuoteId(undefined); + }} + /> + + + + + { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + submit(); + } + if (event.key === 'Escape') { + blurEditor(editor); + event.stopPropagation(); + modalElementRef?.current?.focus(); + } + }} + /> +
+
+
+ ); +}; diff --git a/packages/sdk/src/components/comment/comment-editor/CommentQuote.tsx b/packages/sdk/src/components/comment/comment-editor/CommentQuote.tsx new file mode 100644 index 0000000000..ef51b81f6d --- /dev/null +++ b/packages/sdk/src/components/comment/comment-editor/CommentQuote.tsx @@ -0,0 +1,104 @@ +import { useQuery } from '@tanstack/react-query'; +import { assertNever } from '@teable/core'; +import { X } from '@teable/icons'; +import type { ICommentContent } from '@teable/openapi'; +import { getCommentDetail, CommentNodeType } from '@teable/openapi'; +import { Button, cn } from '@teable/ui-lib'; +import { useMemo } from 'react'; +import { ReactQueryKeys } from '../../../config'; +import { useTranslation } from '../../../context/app/i18n'; +import { useTableId } from '../../../hooks'; +import { MentionUser, BlockImageElement } from '../comment-list/node'; +import { useRecordId } from '../hooks'; + +interface ICommentQuoteProps { + quoteId?: string; + className?: string; + onClose?: () => void; +} + +export const CommentQuote = (props: ICommentQuoteProps) => { + const { className, quoteId, onClose } = props; + const tableId = useTableId(); + const recordId = useRecordId(); + const { t } = useTranslation(); + const { data: quoteData } = useQuery({ + queryKey: ReactQueryKeys.commentDetail(tableId!, recordId!, quoteId!), + queryFn: () => getCommentDetail(tableId!, recordId!, quoteId!).then((res) => res.data), + enabled: !!tableId && !!recordId && !!quoteId, + }); + + const findDisplayLine = (commentContent: ICommentContent) => { + for (let i = 0; i < commentContent.length; i++) { + const curLine = commentContent[i]; + if (curLine.type === CommentNodeType.Paragraph && curLine?.children?.length) { + return curLine.children; + } + + if (curLine.type === CommentNodeType.Img) { + return curLine; + } + } + + return null; + }; + + const quoteAbbreviationRender = useMemo(() => { + const displayLine = findDisplayLine(quoteData?.content || []); + + if (!quoteData || !displayLine) { + return null; + } + + // only display the first line of the quote + if (Array.isArray(displayLine)) { + return ( + + {displayLine.map((node, index) => { + switch (node.type) { + case CommentNodeType.Link: + return {node.title || node.url}; + case CommentNodeType.Text: + return {node.value}; + case CommentNodeType.Mention: + return ; + default: + assertNever(node); + } + })} + + ); + } + + if (displayLine.type === CommentNodeType.Img) { + return ; + } + + return null; + }, [quoteData]); + + return ( + quoteId && ( +
+
+ + : + {!quoteData ? ( + + {t('comment.deletedComment')} + + ) : ( + quoteAbbreviationRender + )} +
+ {onClose && ( + + )} +
+ ) + ); +}; diff --git a/packages/sdk/src/components/comment/comment-editor/Editor.tsx b/packages/sdk/src/components/comment/comment-editor/Editor.tsx new file mode 100644 index 0000000000..dd29db2369 --- /dev/null +++ b/packages/sdk/src/components/comment/comment-editor/Editor.tsx @@ -0,0 +1,75 @@ +import { cn } from '@teable/ui-lib'; +import type { PlateContentProps } from '@udecode/plate-common/react'; +import { PlateContent } from '@udecode/plate-common/react'; +import { cva } from 'class-variance-authority'; + +import type { VariantProps } from 'class-variance-authority'; +import React from 'react'; + +const editorVariants = cva( + cn( + 'relative overflow-x-auto whitespace-pre-wrap break-words', + 'min-h-[80px] w-full rounded-md bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none', + '[&_[data-slate-placeholder]]:text-muted-foreground [&_[data-slate-placeholder]]:!opacity-100', + '[&_[data-slate-placeholder]]:top-[auto_!important]', + '[&_strong]:font-bold' + ), + { + variants: { + variant: { + outline: 'border border-input', + ghost: '', + }, + focused: { + true: 'ring-2 ring-ring ring-offset-2', + }, + disabled: { + true: 'cursor-not-allowed opacity-50', + }, + focusRing: { + true: 'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', + false: '', + }, + size: { + sm: 'text-sm', + md: 'text-base', + }, + }, + defaultVariants: { + variant: 'outline', + focusRing: true, + size: 'sm', + }, + } +); + +export type EditorProps = PlateContentProps & VariantProps; + +const Editor = React.forwardRef( + ({ className, disabled, focused, focusRing, readOnly, size, variant, ...props }, ref) => { + return ( +
+ +
+ ); + } +); + +Editor.displayName = 'Editor'; + +export { Editor }; diff --git a/packages/sdk/src/components/comment/comment-editor/index.ts b/packages/sdk/src/components/comment/comment-editor/index.ts new file mode 100644 index 0000000000..46ad01ff2a --- /dev/null +++ b/packages/sdk/src/components/comment/comment-editor/index.ts @@ -0,0 +1 @@ +export * from './CommentEditor'; diff --git a/packages/sdk/src/components/comment/comment-editor/plate-ui/button.tsx b/packages/sdk/src/components/comment/comment-editor/plate-ui/button.tsx new file mode 100644 index 0000000000..66027a6976 --- /dev/null +++ b/packages/sdk/src/components/comment/comment-editor/plate-ui/button.tsx @@ -0,0 +1,39 @@ +import { cva } from 'class-variance-authority'; + +export const buttonVariants = cva( + 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 dark:ring-offset-slate-950 dark:focus-visible:ring-slate-300', + { + defaultVariants: { + size: 'default', + variant: 'default', + }, + variants: { + isMenu: { + true: 'h-auto w-full cursor-pointer justify-start', + }, + size: { + default: 'h-10 px-4 py-2', + icon: 'size-10', + lg: 'h-11 rounded-md px-8', + none: '', + sm: 'h-9 rounded-md px-3', + sms: 'size-9 rounded-md px-0', + xs: 'h-8 rounded-md px-3', + }, + variant: { + default: + 'bg-slate-900 text-slate-50 hover:bg-slate-900/90 dark:bg-slate-50 dark:text-slate-900 dark:hover:bg-slate-50/90', + destructive: + 'bg-red-500 text-slate-50 hover:bg-red-500/90 dark:bg-red-900 dark:text-slate-50 dark:hover:bg-red-900/90', + ghost: + 'hover:bg-slate-100 hover:text-slate-900 dark:hover:bg-slate-800 dark:hover:text-slate-50', + inlineLink: 'text-base text-slate-900 underline underline-offset-4 dark:text-slate-50', + link: 'text-slate-900 underline-offset-4 hover:underline dark:text-slate-50', + outline: + 'border border-slate-200 bg-white hover:bg-slate-100 hover:text-slate-900 dark:border-slate-800 dark:bg-slate-950 dark:hover:bg-slate-800 dark:hover:text-slate-50', + secondary: + 'bg-slate-100 text-slate-900 hover:bg-slate-100/80 dark:bg-slate-800 dark:text-slate-50 dark:hover:bg-slate-800/80', + }, + }, + } +); diff --git a/packages/sdk/src/components/comment/comment-editor/plate-ui/editor.tsx b/packages/sdk/src/components/comment/comment-editor/plate-ui/editor.tsx new file mode 100644 index 0000000000..f785181a90 --- /dev/null +++ b/packages/sdk/src/components/comment/comment-editor/plate-ui/editor.tsx @@ -0,0 +1,75 @@ +import { cn } from '@udecode/cn'; +import type { PlateContentProps } from '@udecode/plate-common/react'; +import { PlateContent } from '@udecode/plate-common/react'; +import type { VariantProps } from 'class-variance-authority'; + +import { cva } from 'class-variance-authority'; +import React from 'react'; + +const editorVariants = cva( + cn( + 'relative overflow-x-auto whitespace-pre-wrap break-words', + 'min-h-[80px] w-full rounded-md bg-background px-6 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none', + '[&_[data-slate-placeholder]]:text-muted-foreground [&_[data-slate-placeholder]]:!opacity-100', + '[&_[data-slate-placeholder]]:top-[auto_!important]', + '[&_strong]:font-bold' + ), + { + defaultVariants: { + focusRing: true, + size: 'sm', + variant: 'outline', + }, + variants: { + disabled: { + true: 'cursor-not-allowed opacity-50', + }, + focusRing: { + false: '', + true: 'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', + }, + focused: { + true: 'ring-2 ring-ring ring-offset-2', + }, + size: { + md: 'text-base', + sm: 'text-sm', + }, + variant: { + ghost: '', + outline: 'border border-input', + }, + }, + } +); + +export type EditorProps = PlateContentProps & VariantProps; + +const Editor = React.forwardRef( + ({ className, disabled, focusRing, focused, readOnly, size, variant, ...props }, ref) => { + return ( +
+ +
+ ); + } +); +Editor.displayName = 'Editor'; + +export { Editor }; diff --git a/packages/sdk/src/components/comment/comment-editor/plate-ui/fixed-toolbar.tsx b/packages/sdk/src/components/comment/comment-editor/plate-ui/fixed-toolbar.tsx new file mode 100644 index 0000000000..917f9e2eb3 --- /dev/null +++ b/packages/sdk/src/components/comment/comment-editor/plate-ui/fixed-toolbar.tsx @@ -0,0 +1,8 @@ +import { withCn } from '@udecode/cn'; + +import { Toolbar } from './toolbar'; + +export const FixedToolbar = withCn( + Toolbar, + 'supports-backdrop-blur:bg-background/60 sticky left-0 top-0 z-50 w-full justify-between overflow-x-auto rounded-t-lg border-b border-b-border bg-background/95 backdrop-blur' +); diff --git a/packages/sdk/src/components/comment/comment-editor/plate-ui/floating-toolbar.tsx b/packages/sdk/src/components/comment/comment-editor/plate-ui/floating-toolbar.tsx new file mode 100644 index 0000000000..47a4b99823 --- /dev/null +++ b/packages/sdk/src/components/comment/comment-editor/plate-ui/floating-toolbar.tsx @@ -0,0 +1,66 @@ +'use client'; + +import { cn, withRef } from '@udecode/cn'; +import { + PortalBody, + useComposedRef, + useEditorId, + useEventEditorSelectors, +} from '@udecode/plate-common/react'; +import { + type FloatingToolbarState, + flip, + offset, + useFloatingToolbar, + useFloatingToolbarState, +} from '@udecode/plate-floating'; + +import { Toolbar } from './toolbar'; + +export const FloatingToolbar = withRef< + typeof Toolbar, + { + state?: FloatingToolbarState; + } +>(({ children, state, ...props }, componentRef) => { + const editorId = useEditorId(); + const focusedEditorId = useEventEditorSelectors.focus(); + + const floatingToolbarState = useFloatingToolbarState({ + editorId, + focusedEditorId, + ...state, + floatingOptions: { + middleware: [ + offset(12), + flip({ + fallbackPlacements: ['top-start', 'top-end', 'bottom-start', 'bottom-end'], + padding: 12, + }), + ], + placement: 'top', + ...state?.floatingOptions, + }, + }); + + const { hidden, props: rootProps, ref: floatingRef } = useFloatingToolbar(floatingToolbarState); + + const ref = useComposedRef(componentRef, floatingRef); + + if (hidden) return null; + + return ( + + + {children} + + + ); +}); diff --git a/packages/sdk/src/components/comment/comment-editor/plate-ui/icons.tsx b/packages/sdk/src/components/comment/comment-editor/plate-ui/icons.tsx new file mode 100644 index 0000000000..5a2666e96b --- /dev/null +++ b/packages/sdk/src/components/comment/comment-editor/plate-ui/icons.tsx @@ -0,0 +1,503 @@ +import type { IconProps } from '@radix-ui/react-icons/dist/types'; + +import { cva } from 'class-variance-authority'; +import { + AlignCenter, + AlignJustify, + AlignLeft, + AlignRight, + ArrowLeft, + ArrowRight, + Baseline, + Bold, + Check, + ChevronDown, + ChevronLeft, + ChevronRight, + ChevronsUpDown, + ClipboardCheck, + Code2, + Combine, + Copy, + Download, + DownloadCloud, + ExternalLink, + Eye, + File, + FileCode, + Film, + GripVertical, + Heading1, + Heading2, + Heading3, + Heading4, + Heading5, + Heading6, + Highlighter, + Image, + Indent, + Italic, + Keyboard, + Laptop, + Link, + Link2, + Link2Off, + List, + ListOrdered, + Loader2, + type LucideIcon, + type LucideProps, + MessageSquare, + MessageSquarePlus, + Minus, + Moon, + MoreHorizontal, + MoreVertical, + Outdent, + PaintBucket, + Paperclip, + Pen, + PenLine, + PenTool, + Pilcrow, + Plus, + Quote, + RectangleHorizontal, + RectangleVertical, + RotateCcw, + Search, + Settings, + Settings2, + Smile, + Square, + Strikethrough, + Subscript, + SunMedium, + Superscript, + Table, + Text, + Trash, + Underline, + Ungroup, + Unlink, + WrapText, + X, +} from 'lucide-react'; +import React from 'react'; + +export type Icon = LucideIcon; + +const borderAll = (props: LucideProps) => ( + + + +); + +const borderBottom = (props: LucideProps) => ( + + + +); + +const borderLeft = (props: LucideProps) => ( + + + +); + +const borderNone = (props: LucideProps) => ( + + + +); + +const borderRight = (props: LucideProps) => ( + + + +); + +const borderTop = (props: LucideProps) => ( + + + +); + +const discord = (props: LucideProps) => ( + + + + + + + + + + +); + +const gitHub = (props: LucideProps) => ( + + + +); + +const npm = (props: LucideProps) => ( + + + +); + +const radix = (props: LucideProps) => ( + + + + + +); +const react = (props: LucideProps) => ( + + + +); + +const tailwind = (props: LucideProps) => ( + + + +); +const yarn = (props: LucideProps) => ( + + + +); + +export const DoubleColumnOutlined = (props: LucideProps) => ( + + + +); + +export const ThreeColumnOutlined = (props: LucideProps) => ( + + + +); + +export const RightSideDoubleColumnOutlined = (props: LucideProps) => ( + + + +); + +export const LeftSideDoubleColumnOutlined = (props: LucideProps) => ( + + + +); + +export const DoubleSideDoubleColumnOutlined = (props: LucideProps) => ( + + + +); + +const LayoutIcon = (props: LucideProps) => ( + + + + + +); + +export const Icons = { + LayoutIcon, + add: Plus, + alignCenter: AlignCenter, + alignJustify: AlignJustify, + alignLeft: AlignLeft, + alignRight: AlignRight, + arrowDown: ChevronDown, + arrowLeft: ArrowLeft, + arrowRight: ArrowRight, + attachment: Paperclip, + bg: PaintBucket, + blockquote: Quote, + bold: Bold, + borderAll, + borderBottom, + borderLeft, + borderNone, + borderRight, + borderTop, + check: Check, + chevronDown: ChevronDown, + chevronLeft: ChevronLeft, + chevronRight: ChevronRight, + chevronsUpDown: ChevronsUpDown, + clear: X, + close: X, + code: Code2, + codeblock: FileCode, + color: Baseline, + column: RectangleVertical, + combine: Combine, + comment: MessageSquare, + commentAdd: MessageSquarePlus, + conflict: Unlink, + copy: Copy, + copyDone: ClipboardCheck, + delete: Trash, + dependency: Link, + discord, + doubleColumn: DoubleColumnOutlined, + doubleSideDoubleColumn: DoubleSideDoubleColumnOutlined, + downLoad: Download, + downloadCloud: DownloadCloud, + dragHandle: GripVertical, + editing: Pen, + ellipsis: MoreVertical, + embed: Film, + emoji: Smile, + excalidraw: PenTool, + externalLink: ExternalLink, + gitHub, + h1: Heading1, + h2: Heading2, + h3: Heading3, + h4: Heading4, + h5: Heading5, + h6: Heading6, + highlight: Highlighter, + hr: Minus, + image: Image, + indent: Indent, + italic: Italic, + kbd: Keyboard, + laptop: Laptop, + leftSideDoubleColumn: LeftSideDoubleColumnOutlined, + lineHeight: WrapText, + link: Link2, + media: Image, + minus: Minus, + moon: Moon, + more: MoreHorizontal, + moreVertical: MoreVertical, + npm, + ol: ListOrdered, + outdent: Outdent, + page: File, + paragraph: Pilcrow, + plugin: Settings2, + pragma: Link, + radix, + react, + refresh: RotateCcw, + rightSideDoubleColumn: RightSideDoubleColumnOutlined, + row: RectangleHorizontal, + search: Search, + settings: Settings, + spinner: Loader2, + strikethrough: Strikethrough, + subscript: Subscript, + suggesting: PenLine, + sun: SunMedium, + superscript: Superscript, + table: Table, + tailwind, + text: Text, + threeColumn: ThreeColumnOutlined, + todo: Square, + trash: Trash, + twitter: (props: IconProps) => ( + + + + ), + ul: List, + underline: Underline, + ungroup: Ungroup, + unlink: Link2Off, + viewing: Eye, + yarn, +}; + +export const iconVariants = cva('', { + defaultVariants: {}, + variants: { + size: { + md: 'mr-2 size-6', + sm: 'mr-2 size-4', + }, + variant: { + menuItem: 'mr-2 size-5', + toolbar: 'size-5', + }, + }, +}); diff --git a/packages/sdk/src/components/comment/comment-editor/plate-ui/image-element.tsx b/packages/sdk/src/components/comment/comment-editor/plate-ui/image-element.tsx new file mode 100644 index 0000000000..3dd3284147 --- /dev/null +++ b/packages/sdk/src/components/comment/comment-editor/plate-ui/image-element.tsx @@ -0,0 +1,89 @@ +import { useQuery } from '@tanstack/react-query'; +import { getCommentAttachmentUrl } from '@teable/openapi'; +import { cn, withRef } from '@udecode/cn'; +import { PlateElement, withHOC } from '@udecode/plate-common/react'; +import { Image, useMediaState } from '@udecode/plate-media/react'; +import { ResizableProvider } from '@udecode/plate-resizable'; +import React, { useMemo } from 'react'; +import { ReactQueryKeys } from '../../../../config'; +import { useTableId } from '../../../../hooks'; +import { useRecordId } from '../../hooks'; +import { useCommentStore } from '../../useCommentStore'; +import type { TImageElement } from '../plugin/image-plugin/ImagePlugin'; + +import { Resizable, ResizeHandle, mediaResizeHandleVariants } from './resizable'; + +interface IImageElementProps extends TImageElement { + path: string; +} + +export const ImageElement = withHOC( + ResizableProvider, + withRef(({ children, className, nodeProps, ...props }, ref) => { + const { align = 'center', focused, readOnly, selected } = useMediaState(); + const tableId = useTableId(); + const recordId = useRecordId(); + const element = props.element as IImageElementProps; + const { path, url } = element; + + const { attachmentPresignedUrls, setAttachmentPresignedUrls } = useCommentStore(); + + const { data: imageData } = useQuery({ + queryKey: ReactQueryKeys.commentAttachment(tableId!, recordId!, path), + queryFn: () => getCommentAttachmentUrl(tableId!, recordId!, path).then(({ data }) => data), + enabled: !attachmentPresignedUrls[path as string] && !!tableId && !!recordId, + }); + + if (imageData && !attachmentPresignedUrls[path as string]) { + setAttachmentPresignedUrls(path, imageData); + } + + const mergedProps = useMemo( + () => ({ + ...props, + element: { + ...props.element, + url: url ?? imageData ?? attachmentPresignedUrls[path as string], + }, + }), + [imageData, path, props, attachmentPresignedUrls, url] + ); + + return ( + +
+ + + + + +
+ + {children} +
+ ); + }) +); diff --git a/packages/sdk/src/components/comment/comment-editor/plate-ui/image-preview.tsx b/packages/sdk/src/components/comment/comment-editor/plate-ui/image-preview.tsx new file mode 100644 index 0000000000..12294590ee --- /dev/null +++ b/packages/sdk/src/components/comment/comment-editor/plate-ui/image-preview.tsx @@ -0,0 +1,144 @@ +import { cn, createPrimitiveComponent } from '@udecode/cn'; +import { + PreviewImage, + useImagePreview, + useImagePreviewState, + useScaleInput, + useScaleInputState, +} from '@udecode/plate-media/react'; +import { cva } from 'class-variance-authority'; + +import { Icons } from './icons'; + +const toolButtonVariants = cva('rounded bg-[rgba(0,0,0,0.5)] px-1', { + defaultVariants: { + variant: 'default', + }, + variants: { + variant: { + default: 'text-white', + disabled: 'cursor-not-allowed text-gray-400', + }, + }, +}); + +const ScaleInput = createPrimitiveComponent('input')({ + propsHook: useScaleInput, + stateHook: useScaleInputState, +}); + +const SCROLL_SPEED = 4; + +export const ImagePreview = () => { + const state = useImagePreviewState({ scrollSpeed: SCROLL_SPEED }); + + const { + closeProps, + currentUrlIndex, + maskLayerProps, + nextDisabled, + nextProps, + prevDisabled, + prevProps, + scaleTextProps, + zommOutProps, + zoomInDisabled, + zoomInProps, + zoomOutDisabled, + } = useImagePreview(state); + + const { isOpen, scale } = state; + + return ( +
+
+
+
+
+ +
e.stopPropagation()} + className="absolute bottom-0 left-1/2 z-40 flex w-fit -translate-x-1/2 justify-center gap-4 p-2 text-center text-white" + onClick={(e) => e.stopPropagation()} + > +
+ + {(currentUrlIndex ?? 0) + 1} + +
+
+ +
+ {state.isEditingScale ? ( + <> + {' '} + % + + ) : ( + {scale * 100 + '%'} + )} +
+ +
+ {/* TODO: downLoad the image */} + {/* */} + +
+
+
+
+ ); +}; diff --git a/packages/sdk/src/components/comment/comment-editor/plate-ui/index.ts b/packages/sdk/src/components/comment/comment-editor/plate-ui/index.ts new file mode 100644 index 0000000000..221d9af776 --- /dev/null +++ b/packages/sdk/src/components/comment/comment-editor/plate-ui/index.ts @@ -0,0 +1,12 @@ +export * from './mention-element'; +export * from './image-element'; +export * from './image-preview'; +export * from './link-element'; +export * from './link-floating-toolbar'; +export * from './mention-input-element'; +export * from './placeholder'; +export * from './toolbar'; +export * from './tooltip'; +export * from './media-popover'; +export * from './toolbar-button'; +export * from './paragraph-element'; diff --git a/packages/sdk/src/components/comment/comment-editor/plate-ui/inline-combobox.tsx b/packages/sdk/src/components/comment/comment-editor/plate-ui/inline-combobox.tsx new file mode 100644 index 0000000000..1ad3bf6367 --- /dev/null +++ b/packages/sdk/src/components/comment/comment-editor/plate-ui/inline-combobox.tsx @@ -0,0 +1,332 @@ +import { + Combobox, + ComboboxItem, + type ComboboxItemProps, + ComboboxPopover, + ComboboxProvider, + Portal, + useComboboxContext, + useComboboxStore, +} from '@ariakit/react'; +import { cn } from '@udecode/cn'; +import { filterWords } from '@udecode/plate-combobox'; +import { + type UseComboboxInputResult, + useComboboxInput, + useHTMLInputCursorState, +} from '@udecode/plate-combobox/react'; +import { + type TElement, + createPointRef, + getPointBefore, + insertText, + moveSelection, +} from '@udecode/plate-common'; +import { findNodePath, useComposedRef, useEditorRef } from '@udecode/plate-common/react'; +import { cva } from 'class-variance-authority'; +import React, { + type HTMLAttributes, + type ReactNode, + type RefObject, + createContext, + forwardRef, + startTransition, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import type { PointRef } from 'slate'; +import { useModalRefElement } from '../../../expand-record/useModalRefElement'; + +type FilterFn = (item: { keywords?: string[]; value: string }, search: string) => boolean; + +interface InlineComboboxContextValue { + filter: FilterFn | false; + inputProps: UseComboboxInputResult['props']; + inputRef: RefObject; + removeInput: UseComboboxInputResult['removeInput']; + setHasEmpty: (hasEmpty: boolean) => void; + showTrigger: boolean; + trigger: string; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const InlineComboboxContext = createContext(null as any); + +export const defaultFilter: FilterFn = ({ keywords = [], value }, search) => + [value, ...keywords].some((keyword) => filterWords(keyword, search)); + +interface InlineComboboxProps { + children: ReactNode; + element: TElement; + trigger: string; + filter?: FilterFn | false; + hideWhenNoValue?: boolean; + setValue?: (value: string) => void; + showTrigger?: boolean; + value?: string; +} + +const InlineCombobox = ({ + children, + element, + filter = defaultFilter, + hideWhenNoValue = false, + setValue: setValueProp, + showTrigger = true, + trigger, + value: valueProp, +}: InlineComboboxProps) => { + const editor = useEditorRef(); + const inputRef = React.useRef(null); + const cursorState = useHTMLInputCursorState(inputRef); + + const [valueState, setValueState] = useState(''); + const hasValueProp = valueProp !== undefined; + const value = hasValueProp ? valueProp : valueState; + + const setValue = useCallback( + (newValue: string) => { + setValueProp?.(newValue); + + if (!hasValueProp) { + setValueState(newValue); + } + }, + [setValueProp, hasValueProp] + ); + + /** + * Track the point just before the input element so we know where to + * insertText if the combobox closes due to a selection change. + */ + const [insertPoint, setInsertPoint] = useState(null); + + useEffect(() => { + const path = findNodePath(editor, element); + + if (!path) return; + + const point = getPointBefore(editor, path); + + if (!point) return; + + const pointRef = createPointRef(editor, point); + setInsertPoint(pointRef); + + return () => { + pointRef.unref(); + }; + }, [editor, element]); + + const { props: inputProps, removeInput } = useComboboxInput({ + cancelInputOnBlur: false, + cursorState, + onCancelInput: (cause) => { + if (cause !== 'backspace') { + insertText(editor, trigger + value, { + at: insertPoint?.current ?? undefined, + }); + } + if (cause === 'arrowLeft' || cause === 'arrowRight') { + moveSelection(editor, { + distance: 1, + reverse: cause === 'arrowLeft', + }); + } + }, + ref: inputRef, + }); + + const [hasEmpty, setHasEmpty] = useState(false); + + const contextValue: InlineComboboxContextValue = useMemo( + () => ({ + filter, + inputProps, + inputRef, + removeInput, + setHasEmpty, + showTrigger, + trigger, + }), + [trigger, showTrigger, filter, inputRef, inputProps, removeInput, setHasEmpty] + ); + + const store = useComboboxStore({ + // open: , + setValue: (newValue) => startTransition(() => setValue(newValue)), + }); + + const items = store.useState('items'); + + /** + * If there is no active ID and the list of items changes, select the first + * item. + */ + useEffect(() => { + if (!store.getState().activeId) { + store.setActiveId(store.first()); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [items, store]); + + return ( + + 0 || hasEmpty) && (!hideWhenNoValue || value.length > 0)} + store={store} + > + + {children} + + + + ); +}; + +const InlineComboboxInput = forwardRef>( + ({ className, ...props }, propRef) => { + const { + inputProps, + inputRef: contextRef, + showTrigger, + trigger, + } = useContext(InlineComboboxContext); + + const store = useComboboxContext()!; + const value = store.useState('value'); + + const ref = useComposedRef(propRef, contextRef); + + /** + * To create an auto-resizing input, we render a visually hidden span + * containing the input value and position the input element on top of it. + * This works well for all cases except when input exceeds the width of the + * container. + */ + + return ( + <> + {showTrigger && trigger} + + + + + + + + ); + } +); + +InlineComboboxInput.displayName = 'InlineComboboxInput'; + +const InlineComboboxContent: typeof ComboboxPopover = ({ className, ...props }) => { + const ref = useModalRefElement(); + // Portal prevents CSS from leaking into popover + return ( + + + + ); +}; + +const comboboxItemVariants = cva( + 'relative flex h-9 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none', + { + defaultVariants: { + interactive: true, + }, + variants: { + interactive: { + false: '', + true: 'cursor-pointer transition-colors hover:bg-slate-100 hover:text-slate-900 data-[active-item=true]:bg-slate-100 data-[active-item=true]:text-slate-900 dark:hover:bg-slate-800 dark:hover:text-slate-50 dark:data-[active-item=true]:bg-slate-800 dark:data-[active-item=true]:text-slate-50', + }, + }, + } +); + +export type InlineComboboxItemProps = { + keywords?: string[]; +} & ComboboxItemProps & + Required>; + +const InlineComboboxItem = ({ + className, + keywords, + onClick, + ...props +}: InlineComboboxItemProps) => { + const { value } = props; + + const { filter, removeInput } = useContext(InlineComboboxContext); + + const store = useComboboxContext()!; + + // Optimization: Do not subscribe to value if filter is false + const search = filter && store.useState('value'); + + const visible = useMemo( + () => !filter || filter({ keywords, value }, search as string), + [filter, value, keywords, search] + ); + + if (!visible) return null; + + return ( + { + removeInput(true); + onClick?.(event); + }} + {...props} + /> + ); +}; + +const InlineComboboxEmpty = ({ children, className }: HTMLAttributes) => { + const { setHasEmpty } = useContext(InlineComboboxContext); + const store = useComboboxContext()!; + const items = store.useState('items'); + + useEffect(() => { + setHasEmpty(true); + + return () => { + setHasEmpty(false); + }; + }, [setHasEmpty]); + + if (items.length > 0) return null; + + return ( +
{children}
+ ); +}; + +export { + InlineCombobox, + InlineComboboxContent, + InlineComboboxEmpty, + InlineComboboxInput, + InlineComboboxItem, +}; diff --git a/packages/sdk/src/components/comment/comment-editor/plate-ui/input.tsx b/packages/sdk/src/components/comment/comment-editor/plate-ui/input.tsx new file mode 100644 index 0000000000..02f03a52e5 --- /dev/null +++ b/packages/sdk/src/components/comment/comment-editor/plate-ui/input.tsx @@ -0,0 +1,25 @@ +import { withVariants } from '@udecode/cn'; +import { cva } from 'class-variance-authority'; + +export const inputVariants = cva( + 'flex w-full rounded-md bg-transparent text-sm file:border-0 file:bg-white file:text-sm file:font-medium placeholder:text-slate-500 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:file:bg-slate-950 dark:placeholder:text-slate-400', + { + defaultVariants: { + h: 'md', + variant: 'default', + }, + variants: { + h: { + md: 'h-10 px-3 py-2', + sm: 'h-9 px-3 py-2', + }, + variant: { + default: + 'border border-slate-200 ring-offset-white focus-visible:ring-2 focus-visible:ring-slate-950 focus-visible:ring-offset-2 dark:border-slate-800 dark:ring-offset-slate-950 dark:focus-visible:ring-slate-300', + ghost: 'border-none focus-visible:ring-transparent', + }, + }, + } +); + +export const Input = withVariants('input', inputVariants, ['variant', 'h']); diff --git a/packages/sdk/src/components/comment/comment-editor/plate-ui/link-element.tsx b/packages/sdk/src/components/comment/comment-editor/plate-ui/link-element.tsx new file mode 100644 index 0000000000..5e256622a6 --- /dev/null +++ b/packages/sdk/src/components/comment/comment-editor/plate-ui/link-element.tsx @@ -0,0 +1,28 @@ +import { cn, withRef } from '@udecode/cn'; +import { PlateElement, useElement } from '@udecode/plate-common/react'; +import type { TLinkElement } from '@udecode/plate-link'; +import { useLink } from '@udecode/plate-link/react'; +import React from 'react'; + +export const LinkElement = withRef( + ({ children, className, ...props }, ref) => { + const element = useElement(); + const { props: linkProps } = useLink({ element }); + + return ( + + {children} + + ); + } +); diff --git a/packages/sdk/src/components/comment/comment-editor/plate-ui/link-floating-toolbar.tsx b/packages/sdk/src/components/comment/comment-editor/plate-ui/link-floating-toolbar.tsx new file mode 100644 index 0000000000..fa13b6b853 --- /dev/null +++ b/packages/sdk/src/components/comment/comment-editor/plate-ui/link-floating-toolbar.tsx @@ -0,0 +1,166 @@ +'use client'; + +import { Link, A } from '@teable/icons'; +import { cn } from '@udecode/cn'; +import { useFormInputProps, useEditorRef } from '@udecode/plate-common/react'; +import { type UseVirtualFloatingOptions, flip, offset } from '@udecode/plate-floating'; +import { + FloatingLinkUrlInput, + type LinkFloatingToolbarState, + LinkOpenButton, + useFloatingLinkEdit, + useFloatingLinkEditState, + useFloatingLinkInsert, + useFloatingLinkInsertState, +} from '@udecode/plate-link/react'; +import { cva } from 'class-variance-authority'; +import { useTranslation } from '../../../../context/app/i18n'; +import { buttonVariants } from './button'; +import { Icons } from './icons'; +import { inputVariants } from './input'; +import { Separator } from './separator'; + +export const popoverVariants = cva( + 'w-72 rounded-md border border-slate-200 bg-white p-4 text-slate-950 shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50 print:hidden' +); + +const floatingOptions: UseVirtualFloatingOptions = { + middleware: [ + offset(12), + flip({ + fallbackPlacements: ['bottom-end', 'top-start', 'top-end'], + padding: 12, + }), + ], + placement: 'bottom-start', +}; + +export interface LinkFloatingToolbarProps { + state?: LinkFloatingToolbarState; +} + +export function LinkFloatingToolbar({ state }: LinkFloatingToolbarProps) { + const insertState = useFloatingLinkInsertState({ + ...state, + floatingOptions: { + ...floatingOptions, + ...state?.floatingOptions, + }, + }); + const { + hidden, + props: insertProps, + ref: insertRef, + textInputProps, + } = useFloatingLinkInsert(insertState); + const { t } = useTranslation(); + const editorRef = useEditorRef(); + const editState = useFloatingLinkEditState({ + ...state, + floatingOptions: { + ...floatingOptions, + ...state?.floatingOptions, + }, + }); + const { + editButtonProps, + props: editProps, + ref: editRef, + unlinkButtonProps, + } = useFloatingLinkEdit(editState); + const inputProps = useFormInputProps({ + preventDefaultOnEnterKeydown: true, + }); + + if (hidden) return null; + + const input = ( +
+
+
+ +
+ + { + if (e.key === 'Escape') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (editorRef as any)?.api?.floatingLink?.hide(); + } + e.stopPropagation(); + }} + /> +
+ + +
+ ); + + const editContent = editState.isEditing ? ( + input + ) : ( +
+ + + + + + + + + + + +
+ ); + + return ( + <> +
+ {input} +
+ +
+ {editContent} +
+ + ); +} diff --git a/packages/sdk/src/components/comment/comment-editor/plate-ui/media-popover.tsx b/packages/sdk/src/components/comment/comment-editor/plate-ui/media-popover.tsx new file mode 100644 index 0000000000..afaad8bd2f --- /dev/null +++ b/packages/sdk/src/components/comment/comment-editor/plate-ui/media-popover.tsx @@ -0,0 +1,83 @@ +import { Button, Popover, PopoverAnchor, PopoverContent } from '@teable/ui-lib'; +import { type WithRequiredKey, isSelectionExpanded } from '@udecode/plate-common'; +import { useEditorSelector, useElement, useRemoveNodeButton } from '@udecode/plate-common/react'; +import { + FloatingMedia as FloatingMediaPrimitive, + floatingMediaActions, + useFloatingMediaSelectors, +} from '@udecode/plate-media/react'; +import React, { useEffect } from 'react'; +import { useReadOnly, useSelected } from 'slate-react'; + +// import { useTranslation } from '../../../../context/app/i18n'; +// import { buttonVariants } from './button'; +import { Icons } from './icons'; +import { inputVariants } from './input'; +// import { Separator } from './separator'; + +export interface MediaPopoverProps { + children: React.ReactNode; + plugin: WithRequiredKey; +} + +export function MediaPopover({ children, plugin }: MediaPopoverProps) { + const readOnly = useReadOnly(); + const selected = useSelected(); + // const { t } = useTranslation(); + + const selectionCollapsed = useEditorSelector((editor) => !isSelectionExpanded(editor), []); + const isOpen = !readOnly && selected && selectionCollapsed; + const isEditing = useFloatingMediaSelectors().isEditing(); + + useEffect(() => { + if (!isOpen && isEditing) { + floatingMediaActions.isEditing(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen]); + + const element = useElement(); + const { props: buttonProps } = useRemoveNodeButton({ element }); + + if (readOnly) return <>{children}; + + return ( + + {children} + e.preventDefault()}> + {isEditing ? ( +
+
+
+ +
+ + { + e.stopPropagation(); + }} + /> +
+
+ ) : ( +
+ {/* + {t('comment.floatToolbar.editLink')} + */} + + {/* */} + + +
+ )} +
+
+ ); +} diff --git a/packages/sdk/src/components/comment/comment-editor/plate-ui/mention-element.tsx b/packages/sdk/src/components/comment/comment-editor/plate-ui/mention-element.tsx new file mode 100644 index 0000000000..085133a33d --- /dev/null +++ b/packages/sdk/src/components/comment/comment-editor/plate-ui/mention-element.tsx @@ -0,0 +1,42 @@ +import { cn, withRef } from '@udecode/cn'; +import { getHandler } from '@udecode/plate-common'; +import { PlateElement, useElement } from '@udecode/plate-common/react'; +import type { TMentionElement } from '@udecode/plate-mention'; +import React from 'react'; +import { useFocused, useSelected } from 'slate-react'; + +export const MentionElement = withRef< + typeof PlateElement, + { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onClick?: (mentionNode: any) => void; + prefix?: string; + render?: (mentionable: TMentionElement) => React.ReactNode; + } +>(({ prefix, className, onClick, render, children, ...props }, ref) => { + const element = useElement(); + const selected = useSelected(); + const focused = useFocused(); + + return ( + + {prefix} + {render ? render(element) : element.value} + {children} + + ); +}); diff --git a/packages/sdk/src/components/comment/comment-editor/plate-ui/mention-input-element.tsx b/packages/sdk/src/components/comment/comment-editor/plate-ui/mention-input-element.tsx new file mode 100644 index 0000000000..3c49e22e17 --- /dev/null +++ b/packages/sdk/src/components/comment/comment-editor/plate-ui/mention-input-element.tsx @@ -0,0 +1,80 @@ +import { cn, withRef } from '@udecode/cn'; +import { PlateElement } from '@udecode/plate-common/react'; +import { getMentionOnSelectItem } from '@udecode/plate-mention'; +import React, { useState } from 'react'; +import { useTranslation } from '../../../../context/app/i18n'; +import { useSession } from '../../../../hooks'; +import { UserAvatar } from '../../../cell-value'; +import { useCollaborators } from '../../hooks'; +import { + InlineCombobox, + InlineComboboxContent, + InlineComboboxEmpty, + InlineComboboxInput, + InlineComboboxItem, +} from './inline-combobox'; + +const onSelectItem = getMentionOnSelectItem(); + +export const MentionInputElement = withRef(({ className, ...props }, ref) => { + const { children, editor, element } = props; + const { t } = useTranslation(); + const [search, setSearch] = useState(''); + const { user } = useSession(); + const collaborators = useCollaborators(); + const mentionUsers = collaborators.filter((item) => item.userId !== user.id); + + return ( + { + e.stopPropagation(); + }} + > + + + + + + + {t('common.search.empty')} + + {mentionUsers.map((item) => ( + + onSelectItem( + editor, + { + text: item.userId, + }, + search + ) + } + value={item.userName} + > + + {item.userName} + + ))} + + + + {children} + + ); +}); diff --git a/packages/sdk/src/components/comment/comment-editor/plate-ui/paragraph-element.tsx b/packages/sdk/src/components/comment/comment-editor/plate-ui/paragraph-element.tsx new file mode 100644 index 0000000000..837594be89 --- /dev/null +++ b/packages/sdk/src/components/comment/comment-editor/plate-ui/paragraph-element.tsx @@ -0,0 +1,4 @@ +import { withCn } from '@udecode/cn'; +import { PlateElement } from '@udecode/plate-common/react'; + +export const ParagraphElement = withCn(PlateElement, 'm-0 px-0 py-0 leading-6'); diff --git a/packages/sdk/src/components/comment/comment-editor/plate-ui/placeholder.tsx b/packages/sdk/src/components/comment/comment-editor/plate-ui/placeholder.tsx new file mode 100644 index 0000000000..d4a108f6aa --- /dev/null +++ b/packages/sdk/src/components/comment/comment-editor/plate-ui/placeholder.tsx @@ -0,0 +1,52 @@ +import { cn } from '@udecode/cn'; +import { ParagraphPlugin } from '@udecode/plate-common/react'; +import { + type PlaceholderProps, + createNodeHOC, + createNodesHOC, + usePlaceholderState, +} from '@udecode/plate-common/react'; +import { HEADING_KEYS } from '@udecode/plate-heading'; +import React from 'react'; + +export const Placeholder = (props: PlaceholderProps) => { + const { children, nodeProps, placeholder } = props; + + const { enabled } = usePlaceholderState(props); + + return React.Children.map(children, (child) => { + return React.cloneElement(child, { + className: child.props.className, + nodeProps: { + ...nodeProps, + className: cn( + enabled && + 'before:absolute before:cursor-text before:opacity-30 before:content-[attr(placeholder)]' + ), + placeholder, + }, + }); + }); +}; + +export const withPlaceholder = createNodeHOC(Placeholder); + +export const withPlaceholdersPrimitive = createNodesHOC(Placeholder); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const withPlaceholders = (components: any) => + withPlaceholdersPrimitive(components, [ + { + hideOnBlur: true, + key: ParagraphPlugin.key, + placeholder: 'Type a paragraph', + query: { + maxLevel: 1, + }, + }, + { + hideOnBlur: false, + key: HEADING_KEYS.h1, + placeholder: 'Untitled', + }, + ]); diff --git a/packages/sdk/src/components/comment/comment-editor/plate-ui/resizable.tsx b/packages/sdk/src/components/comment/comment-editor/plate-ui/resizable.tsx new file mode 100644 index 0000000000..6638650991 --- /dev/null +++ b/packages/sdk/src/components/comment/comment-editor/plate-ui/resizable.tsx @@ -0,0 +1,55 @@ +'use client'; + +import { cn, withRef, withVariants } from '@udecode/cn'; +import { + Resizable as ResizablePrimitive, + ResizeHandle as ResizeHandlePrimitive, +} from '@udecode/plate-resizable'; +import { cva } from 'class-variance-authority'; +import React from 'react'; + +export const mediaResizeHandleVariants = cva( + cn( + 'top-0 flex w-6 select-none flex-col justify-center', + 'after:flex after:h-16 after:w-[3px] after:rounded-[6px] after:bg-slate-950 after:opacity-0 after:content-[_] group-hover:after:opacity-100 dark:after:bg-slate-300' + ), + { + variants: { + direction: { + left: '-left-3 -ml-3 pl-3', + right: '-right-3 -mr-3 items-end pr-3', + }, + }, + } +); + +const resizeHandleVariants = cva(cn('absolute z-40'), { + variants: { + direction: { + bottom: 'w-full cursor-row-resize', + left: 'h-full cursor-col-resize', + right: 'h-full cursor-col-resize', + top: 'w-full cursor-row-resize', + }, + }, +}); + +const ResizeHandleVariants = withVariants(ResizeHandlePrimitive, resizeHandleVariants, [ + 'direction', +]); + +export const ResizeHandle = withRef((props, ref) => ( + +)); + +const resizableVariants = cva('', { + variants: { + align: { + center: 'mx-auto', + left: 'mr-auto', + right: 'ml-auto', + }, + }, +}); + +export const Resizable = withVariants(ResizablePrimitive, resizableVariants, ['align']); diff --git a/packages/sdk/src/components/comment/comment-editor/plate-ui/separator.tsx b/packages/sdk/src/components/comment/comment-editor/plate-ui/separator.tsx new file mode 100644 index 0000000000..86e01647c7 --- /dev/null +++ b/packages/sdk/src/components/comment/comment-editor/plate-ui/separator.tsx @@ -0,0 +1,25 @@ +'use client'; + +import * as SeparatorPrimitive from '@radix-ui/react-separator'; +import { withProps, withVariants } from '@udecode/cn'; +import { cva } from 'class-variance-authority'; + +const separatorVariants = cva('shrink-0 bg-slate-200 dark:bg-slate-800', { + defaultVariants: { + orientation: 'horizontal', + }, + variants: { + orientation: { + horizontal: 'h-px w-full', + vertical: 'h-full w-px', + }, + }, +}); + +export const Separator = withVariants( + withProps(SeparatorPrimitive.Root, { + decorative: true, + orientation: 'horizontal', + }), + separatorVariants +); diff --git a/packages/sdk/src/components/comment/comment-editor/plate-ui/toolbar-button/image-toolbar-button/image-toolbar-button.tsx b/packages/sdk/src/components/comment/comment-editor/plate-ui/toolbar-button/image-toolbar-button/image-toolbar-button.tsx new file mode 100644 index 0000000000..ecc6ecb946 --- /dev/null +++ b/packages/sdk/src/components/comment/comment-editor/plate-ui/toolbar-button/image-toolbar-button/image-toolbar-button.tsx @@ -0,0 +1,79 @@ +import { generateAttachmentId } from '@teable/core'; +import { Image } from '@teable/icons'; +import type { INotifyVo } from '@teable/openapi'; +import { UploadType } from '@teable/openapi'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, Button } from '@teable/ui-lib'; +import { insertNodes } from '@udecode/plate-common'; +import { focusEditor, useEditorRef } from '@udecode/plate-common/react'; +import type { TImageElement } from '@udecode/plate-media'; +import { ImagePlugin } from '@udecode/plate-media'; +import { useRef } from 'react'; +import { AttachmentManager } from '../../../../../../components'; +import { useTranslation } from '../../../../../../context/app/i18n'; + +export const ImageToolbarButton = () => { + const editor = useEditorRef(); + const imageUploadRef = useRef(null); + const { t } = useTranslation(); + const uploadFile = async (file: File) => { + return new Promise((resolve) => { + const attchmentManager = new AttachmentManager(1); + attchmentManager.upload( + [{ id: generateAttachmentId(), instance: file }], + UploadType.Comment, + { + successCallback: (_, result) => { + resolve(result); + }, + } + ); + }); + }; + const onFileChangeHandler = async (e: React.ChangeEvent) => { + const files = (e.target.files && Array.from(e.target.files)) || null; + if (files && files.length > 0) { + const result = await uploadFile(files[0]); + const image: TImageElement = { + children: [{ text: '' }], + type: editor.getType(ImagePlugin), + url: result.presignedUrl, + path: result.path, + }; + insertNodes(editor, image, { + nextBlock: true, + }); + focusEditor(editor); + } + }; + + return ( + + + + + + +

{t('comment.toolbar.image')}

+
+
+
+ ); +}; diff --git a/packages/sdk/src/components/comment/comment-editor/plate-ui/toolbar-button/index.ts b/packages/sdk/src/components/comment/comment-editor/plate-ui/toolbar-button/index.ts new file mode 100644 index 0000000000..c888422167 --- /dev/null +++ b/packages/sdk/src/components/comment/comment-editor/plate-ui/toolbar-button/index.ts @@ -0,0 +1,2 @@ +export * from './image-toolbar-button/image-toolbar-button'; +export * from './link-toolbar-button'; diff --git a/packages/sdk/src/components/comment/comment-editor/plate-ui/toolbar-button/link-toolbar-button.tsx b/packages/sdk/src/components/comment/comment-editor/plate-ui/toolbar-button/link-toolbar-button.tsx new file mode 100644 index 0000000000..dffe523a1c --- /dev/null +++ b/packages/sdk/src/components/comment/comment-editor/plate-ui/toolbar-button/link-toolbar-button.tsx @@ -0,0 +1,32 @@ +import { Link } from '@teable/icons'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, Button } from '@teable/ui-lib'; +import { focusEditor, useEditorRef } from '@udecode/plate-common/react'; +import { triggerFloatingLink } from '@udecode/plate-link/react'; +import { useTranslation } from '../../../../../context/app/i18n'; + +export const LinkToolbarButton = () => { + const { t } = useTranslation(); + const editor = useEditorRef(); + + return ( + + + + + + +

{t('comment.toolbar.link')}

+
+
+
+ ); +}; diff --git a/packages/sdk/src/components/comment/comment-editor/plate-ui/toolbar.tsx b/packages/sdk/src/components/comment/comment-editor/plate-ui/toolbar.tsx new file mode 100644 index 0000000000..253028048d --- /dev/null +++ b/packages/sdk/src/components/comment/comment-editor/plate-ui/toolbar.tsx @@ -0,0 +1,145 @@ +'use client'; + +import * as ToolbarPrimitive from '@radix-ui/react-toolbar'; +import { cn, withCn, withRef, withVariants } from '@udecode/cn'; +import { type VariantProps, cva } from 'class-variance-authority'; +import * as React from 'react'; + +import { Icons } from './icons'; +import { Separator } from './separator'; +import { withTooltip } from './tooltip'; + +export const Toolbar = withCn( + ToolbarPrimitive.Root, + 'relative flex select-none items-center gap-1 bg-white dark:bg-slate-950' +); + +export const ToolbarToggleGroup = withCn(ToolbarPrimitive.ToolbarToggleGroup, 'flex items-center'); + +export const ToolbarLink = withCn( + ToolbarPrimitive.Link, + 'font-medium underline underline-offset-4' +); + +export const ToolbarSeparator = withCn( + ToolbarPrimitive.Separator, + 'my-1 w-px shrink-0 bg-slate-200 dark:bg-slate-800' +); + +const toolbarButtonVariants = cva( + cn( + 'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 dark:ring-offset-slate-950 dark:focus-visible:ring-slate-300', + '[&_svg:not([data-icon])]:size-5' + ), + { + defaultVariants: { + size: 'sm', + variant: 'default', + }, + variants: { + size: { + default: 'h-10 px-3', + lg: 'h-11 px-5', + sm: 'h-9 px-2', + }, + variant: { + default: + 'bg-transparent hover:bg-slate-100 hover:text-slate-500 aria-checked:bg-slate-100 aria-checked:text-slate-900 dark:hover:bg-slate-800 dark:hover:text-slate-400 dark:aria-checked:bg-slate-800 dark:aria-checked:text-slate-50', + outline: + 'border border-slate-200 bg-transparent hover:bg-slate-100 hover:text-slate-900 dark:border-slate-800 dark:hover:bg-slate-800 dark:hover:text-slate-50', + }, + }, + } +); + +const ToolbarButton = withTooltip( + // eslint-disable-next-line react/display-name + React.forwardRef< + React.ElementRef, + { + isDropdown?: boolean; + pressed?: boolean; + } & Omit, 'asChild' | 'value'> & + VariantProps + >(({ children, className, isDropdown, pressed, size, variant, ...props }, ref) => { + return typeof pressed === 'boolean' ? ( + + + {isDropdown ? ( + <> +
{children}
+
+ +
+ + ) : ( + children + )} +
+
+ ) : ( + + {children} + + ); + }) +); +ToolbarButton.displayName = 'ToolbarButton'; + +export { ToolbarButton }; + +export const ToolbarToggleItem = withVariants(ToolbarPrimitive.ToggleItem, toolbarButtonVariants, [ + 'variant', + 'size', +]); + +export const ToolbarGroup = withRef< + 'div', + { + noSeparator?: boolean; + } +>(({ children, className, noSeparator }, ref) => { + const childArr = React.Children.map(children, (c) => c); + + if (!childArr || childArr.length === 0) return null; + + return ( +
+ {!noSeparator && ( +
+ +
+ )} + +
{children}
+
+ ); +}); + +export const FixedToolbar = withCn( + Toolbar, + 'supports-backdrop-blur:bg-background/60 sticky left-0 top-0 z-50 w-full justify-between overflow-x-auto rounded-t-lg border-b border-b-border bg-background/95 backdrop-blur' +); diff --git a/packages/sdk/src/components/comment/comment-editor/plate-ui/tooltip.tsx b/packages/sdk/src/components/comment/comment-editor/plate-ui/tooltip.tsx new file mode 100644 index 0000000000..8d9c1f88a6 --- /dev/null +++ b/packages/sdk/src/components/comment/comment-editor/plate-ui/tooltip.tsx @@ -0,0 +1,60 @@ +'use client'; + +import * as TooltipPrimitive from '@radix-ui/react-tooltip'; +import { withCn, withProps } from '@udecode/cn'; +import React from 'react'; + +export const TooltipProvider = TooltipPrimitive.Provider; + +export const Tooltip = TooltipPrimitive.Root; + +export const TooltipTrigger = TooltipPrimitive.Trigger; + +export const TooltipPortal = TooltipPrimitive.Portal; + +export const TooltipContent = withCn( + withProps(TooltipPrimitive.Content, { + sideOffset: 4, + }), + 'z-50 overflow-hidden rounded-md border border-slate-200 bg-white px-3 py-1.5 text-sm text-slate-950 shadow-md dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50' +); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function withTooltip | keyof HTMLElementTagNameMap>( + Component: T +) { + return React.forwardRef< + React.ElementRef, + { + tooltip?: React.ReactNode; + tooltipContentProps?: Omit< + React.ComponentPropsWithoutRef, + 'children' + >; + tooltipProps?: Omit, 'children'>; + } & React.ComponentPropsWithoutRef + >(function ExtendComponent({ tooltip, tooltipContentProps, tooltipProps, ...props }, ref) { + const [mounted, setMounted] = React.useState(false); + + React.useEffect(() => { + setMounted(true); + }, []); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const component = ; + + if (tooltip && mounted) { + return ( + + {component} + + + {tooltip} + + + ); + } + + return component; + }); +} diff --git a/packages/sdk/src/components/comment/comment-editor/plugin/image-plugin/ImagePlugin.ts b/packages/sdk/src/components/comment/comment-editor/plugin/image-plugin/ImagePlugin.ts new file mode 100644 index 0000000000..a2ee4d827a --- /dev/null +++ b/packages/sdk/src/components/comment/comment-editor/plugin/image-plugin/ImagePlugin.ts @@ -0,0 +1,52 @@ +import { type PluginConfig, createTSlatePlugin } from '@udecode/plate-common'; + +import type { MediaPluginOptions, TMediaElement } from './types'; + +import { withImage } from './withImage'; + +export interface TImageElement extends TMediaElement {} + +export type ImageConfig = PluginConfig< + 'img', + { + /** Disable url embed on insert data. */ + disableEmbedInsert?: boolean; + + /** Disable file upload on insert data. */ + disableUploadInsert?: boolean; + + customUploadImage?: (file: File) => void; + + /** + * An optional method that will upload the image to a server. The method + * receives the base64 dataUrl of the uploaded image, and should return the + * URL of the uploaded image. + */ + uploadImage?: ( + file: ArrayBuffer | string + ) => ArrayBuffer | Promise | string; + } & MediaPluginOptions +>; + +/** Enables support for images. */ +export const ImagePlugin = createTSlatePlugin({ + extendEditor: withImage, + key: 'img', + node: { isElement: true, isVoid: true }, +}).extend(({ plugin }) => ({ + parsers: { + html: { + deserializer: { + parse: ({ element }) => ({ + type: plugin.node.type, + url: element.getAttribute('src'), + }), + rules: [ + { + validNodeName: 'IMG', + }, + ], + }, + }, + }, +})); diff --git a/packages/sdk/src/components/comment/comment-editor/plugin/image-plugin/transforms/index.ts b/packages/sdk/src/components/comment/comment-editor/plugin/image-plugin/transforms/index.ts new file mode 100644 index 0000000000..c334d0ebc8 --- /dev/null +++ b/packages/sdk/src/components/comment/comment-editor/plugin/image-plugin/transforms/index.ts @@ -0,0 +1,5 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from './insertImage'; diff --git a/packages/sdk/src/components/comment/comment-editor/plugin/image-plugin/transforms/insertImage.ts b/packages/sdk/src/components/comment/comment-editor/plugin/image-plugin/transforms/insertImage.ts new file mode 100644 index 0000000000..4c529880f8 --- /dev/null +++ b/packages/sdk/src/components/comment/comment-editor/plugin/image-plugin/transforms/insertImage.ts @@ -0,0 +1,22 @@ +import { type InsertNodesOptions, type SlateEditor, insertNodes } from '@udecode/plate-common'; + +import { ImagePlugin, type TImageElement } from '../ImagePlugin'; + +export const insertImage = ( + editor: E, + url: ArrayBuffer | string, + options: InsertNodesOptions = {} +) => { + const text = { text: '' }; + const image: TImageElement = { + children: [text], + type: editor.getType(ImagePlugin), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + url: url as any, + }; + insertNodes(editor, image, { + nextBlock: true, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...(options as any), + }); +}; diff --git a/packages/sdk/src/components/comment/comment-editor/plugin/image-plugin/types.ts b/packages/sdk/src/components/comment/comment-editor/plugin/image-plugin/types.ts new file mode 100644 index 0000000000..ba66c6fd0b --- /dev/null +++ b/packages/sdk/src/components/comment/comment-editor/plugin/image-plugin/types.ts @@ -0,0 +1,16 @@ +import type { TElement } from '@udecode/plate-common'; + +export interface TMediaElement extends TElement { + url: string; + align?: 'center' | 'left' | 'right'; + id?: string; + isUpload?: boolean; + name?: string; +} + +export interface MediaPluginOptions { + isUrl?: (text: string) => boolean; + + /** Transforms the url. */ + transformUrl?: (url: string) => string; +} diff --git a/packages/sdk/src/components/comment/comment-editor/plugin/image-plugin/utils/index.ts b/packages/sdk/src/components/comment/comment-editor/plugin/image-plugin/utils/index.ts new file mode 100644 index 0000000000..c94ed2ff12 --- /dev/null +++ b/packages/sdk/src/components/comment/comment-editor/plugin/image-plugin/utils/index.ts @@ -0,0 +1,5 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from './isImageUrl'; diff --git a/packages/sdk/src/components/comment/comment-editor/plugin/image-plugin/utils/isImageUrl.ts b/packages/sdk/src/components/comment/comment-editor/plugin/image-plugin/utils/isImageUrl.ts new file mode 100644 index 0000000000..bddfd4de62 --- /dev/null +++ b/packages/sdk/src/components/comment/comment-editor/plugin/image-plugin/utils/isImageUrl.ts @@ -0,0 +1,132 @@ +import { isUrl } from '@udecode/plate-common'; + +const imageExtensions = new Set([ + 'ase', + 'art', + 'bmp', + 'blp', + 'cd5', + 'cit', + 'cpt', + 'cr2', + 'cut', + 'dds', + 'dib', + 'djvu', + 'egt', + 'exif', + 'gif', + 'gpl', + 'grf', + 'icns', + 'ico', + 'iff', + 'jng', + 'jpeg', + 'jpg', + 'jfif', + 'jp2', + 'jps', + 'lbm', + 'max', + 'miff', + 'mng', + 'msp', + 'nitf', + 'ota', + 'pbm', + 'pc1', + 'pc2', + 'pc3', + 'pcf', + 'pcx', + 'pdn', + 'pgm', + 'PI1', + 'PI2', + 'PI3', + 'pict', + 'pct', + 'pnm', + 'pns', + 'ppm', + 'psb', + 'psd', + 'pdd', + 'psp', + 'px', + 'pxm', + 'pxr', + 'qfx', + 'raw', + 'rle', + 'sct', + 'sgi', + 'rgb', + 'int', + 'bw', + 'tga', + 'tiff', + 'tif', + 'vtf', + 'xbm', + 'xcf', + 'xpm', + '3dv', + 'amf', + 'ai', + 'awg', + 'cgm', + 'cdr', + 'cmx', + 'dxf', + 'e2d', + 'egt', + 'eps', + 'fs', + 'gbr', + 'odg', + 'svg', + 'stl', + 'vrml', + 'x3d', + 'sxd', + 'v2d', + 'vnd', + 'wmf', + 'emf', + 'art', + 'xar', + 'png', + 'webp', + 'jxr', + 'hdp', + 'wdp', + 'cur', + 'ecw', + 'iff', + 'lbm', + 'liff', + 'nrrd', + 'pam', + 'pcx', + 'pgf', + 'sgi', + 'rgb', + 'rgba', + 'bw', + 'int', + 'inta', + 'sid', + 'ras', + 'sun', + 'tga', +]); + +export const isImageUrl = (url: string) => { + if (!isUrl(url)) return false; + + const ext = new URL(url).pathname.split('.').pop() as string; + + return imageExtensions.has(ext); +}; diff --git a/packages/sdk/src/components/comment/comment-editor/plugin/image-plugin/withImage.ts b/packages/sdk/src/components/comment/comment-editor/plugin/image-plugin/withImage.ts new file mode 100644 index 0000000000..7c11dbe9d8 --- /dev/null +++ b/packages/sdk/src/components/comment/comment-editor/plugin/image-plugin/withImage.ts @@ -0,0 +1,17 @@ +import type { ExtendEditor } from '@udecode/plate-common'; + +import type { ImageConfig } from './ImagePlugin'; + +import { withImageEmbed } from './withImageEmbed'; +import { withImageUpload } from './withImageUpload'; + +/** + * @see withImageUpload + * @see withImageEmbed + */ +export const withImage: ExtendEditor = ({ editor, ...ctx }) => { + editor = withImageUpload({ editor, ...ctx }); + editor = withImageEmbed({ editor, ...ctx }); + + return editor; +}; diff --git a/packages/sdk/src/components/comment/comment-editor/plugin/image-plugin/withImageEmbed.ts b/packages/sdk/src/components/comment/comment-editor/plugin/image-plugin/withImageEmbed.ts new file mode 100644 index 0000000000..a03a021ba2 --- /dev/null +++ b/packages/sdk/src/components/comment/comment-editor/plugin/image-plugin/withImageEmbed.ts @@ -0,0 +1,29 @@ +import type { ExtendEditor } from '@udecode/plate-common'; + +import type { ImageConfig } from './ImagePlugin'; + +import { insertImage } from './transforms/insertImage'; +import { isImageUrl } from './utils/isImageUrl'; + +/** If inserted text is image url, insert image instead. */ +export const withImageEmbed: ExtendEditor = ({ editor, getOptions }) => { + const { insertData } = editor; + + editor.insertData = (dataTransfer: DataTransfer) => { + if (getOptions().disableEmbedInsert) { + return insertData(dataTransfer); + } + + const text = dataTransfer.getData('text/plain'); + + if (isImageUrl(text)) { + insertImage(editor, text); + + return; + } + + insertData(dataTransfer); + }; + + return editor; +}; diff --git a/packages/sdk/src/components/comment/comment-editor/plugin/image-plugin/withImageUpload.ts b/packages/sdk/src/components/comment/comment-editor/plugin/image-plugin/withImageUpload.ts new file mode 100644 index 0000000000..4629585ee9 --- /dev/null +++ b/packages/sdk/src/components/comment/comment-editor/plugin/image-plugin/withImageUpload.ts @@ -0,0 +1,47 @@ +import { type ExtendEditor, getInjectedPlugins, pipeInsertDataQuery } from '@udecode/plate-common'; +import type { ImageConfig } from './ImagePlugin'; + +/** + * Allows for pasting images from clipboard. Not yet: dragging and dropping + * images, selecting them through a file system dialog. + */ +export const withImageUpload: ExtendEditor = (params) => { + const { editor, getOptions, plugin } = params; + + const { insertData } = editor; + + editor.insertData = (dataTransfer: DataTransfer) => { + if (getOptions().disableUploadInsert) { + return insertData(dataTransfer); + } + + const text = dataTransfer.getData('text/plain'); + const { files } = dataTransfer; + + if (!text && files && files.length > 0) { + const injectedPlugins = getInjectedPlugins(editor, plugin); + + if ( + !pipeInsertDataQuery(editor, injectedPlugins, { + data: text, + dataTransfer, + }) + ) { + return insertData(dataTransfer); + } + + for (const file of files) { + const [mime] = file.type.split('/'); + + if (mime === 'image') { + const customUploadImage = getOptions().customUploadImage; + customUploadImage?.(file); + } + } + } else { + insertData(dataTransfer); + } + }; + + return editor; +}; diff --git a/packages/sdk/src/components/comment/comment-editor/plugin/index.ts b/packages/sdk/src/components/comment/comment-editor/plugin/index.ts new file mode 100644 index 0000000000..f122fe632e --- /dev/null +++ b/packages/sdk/src/components/comment/comment-editor/plugin/index.ts @@ -0,0 +1 @@ +export * from './image-plugin/ImagePlugin'; diff --git a/packages/sdk/src/components/comment/comment-editor/transform.tsx b/packages/sdk/src/components/comment/comment-editor/transform.tsx new file mode 100644 index 0000000000..cc1f3b0152 --- /dev/null +++ b/packages/sdk/src/components/comment/comment-editor/transform.tsx @@ -0,0 +1,112 @@ +import { CommentNodeType } from '@teable/openapi'; +import type { ICommentContent } from '@teable/openapi'; +import type { TElement, TDescendant } from '@udecode/plate-common'; +import { size, has } from 'lodash'; + +export const hasOnlyProperty = (obj: Record, propertyName: string) => { + return size(obj) === 1 && has(obj, propertyName); +}; + +export const isTextCommentNode = (element: TDescendant) => { + return hasOnlyProperty(element, 'text') && !!element.text; +}; + +export class EditorTransform { + static editorValue2CommentValue = (value: TElement[]): ICommentContent => { + if ( + !value || + (value.length === 1 && value[0]?.children.length === 1 && !value[0].children[0].text) + ) { + return [] as ICommentContent; + } + + return value.map((element) => { + if (element.type === CommentNodeType.Img) { + return { + type: CommentNodeType.Img, + path: element.path, + width: element.width, + }; + } else { + return { + type: CommentNodeType.Paragraph, + children: element.children + .filter((chi) => { + return chi.text || chi.type; + }) + .map((child) => { + if (isTextCommentNode(child)) { + return { + value: child.text, + type: CommentNodeType.Text, + }; + } + if (child.type === CommentNodeType.Mention) { + return { + type: CommentNodeType.Mention, + value: child.value, + }; + } + + if (child.type === CommentNodeType.Img) { + return { + type: CommentNodeType.Img, + path: child.path, + width: child.width, + }; + } + + if (child.type === CommentNodeType.Link) { + return { + type: CommentNodeType.Link, + url: child.url, + title: (child as TElement)?.children?.[0].text || '', + }; + } + }), + }; + } + }) as ICommentContent; + }; + + static commentValue2EditorValue = (value: ICommentContent): TElement[] => { + return value.map((element) => { + const { type: lineType } = element; + if (lineType === CommentNodeType.Img) { + return { + type: CommentNodeType.Img, + path: element.path, + width: element.width, + children: [{ text: '' }], + } as TElement; + } else { + return { + type: 'p', + children: element.children.map((child) => { + switch (child.type) { + case CommentNodeType.Text: { + return { + text: child.value, + }; + } + case CommentNodeType.Mention: { + return { + value: child.value, + children: [{ text: '' }], + type: CommentNodeType.Mention, + }; + } + case CommentNodeType.Link: { + return { + type: CommentNodeType.Link, + url: child.url, + children: [{ text: child.title }], + }; + } + } + }), + } as TElement; + } + }); + }; +} diff --git a/packages/sdk/src/components/comment/comment-list/CommentContent.tsx b/packages/sdk/src/components/comment/comment-list/CommentContent.tsx new file mode 100644 index 0000000000..c6d50bf10a --- /dev/null +++ b/packages/sdk/src/components/comment/comment-list/CommentContent.tsx @@ -0,0 +1,58 @@ +import { CommentNodeType } from '@teable/openapi'; +import type { ICommentContent } from '@teable/openapi'; +import { cn } from '@teable/ui-lib'; +import { MentionUser, BlockImageElement, InlineLinkElement, BlockParagraphElement } from './node'; +import { useIsMe } from './useIsMe'; + +interface ICommentContentProps { + content: ICommentContent; +} + +export const CommentContent = (props: ICommentContentProps) => { + const { content } = props; + const isMe = useIsMe(); + const finalContent = content.map((item: ICommentContent[number], index) => { + if (item.type === CommentNodeType.Img) { + return ( + + ); + } else { + return ( + + {item.children.map((node, index) => { + switch (node.type) { + case CommentNodeType.Text: { + return {node.value}; + } + case CommentNodeType.Mention: { + return ( + + ); + } + case CommentNodeType.Link: { + return ; + } + } + })} + + ); + } + }); + return
{finalContent}
; +}; diff --git a/packages/sdk/src/components/comment/comment-list/CommentItem.tsx b/packages/sdk/src/components/comment/comment-list/CommentItem.tsx new file mode 100644 index 0000000000..f7c19a460d --- /dev/null +++ b/packages/sdk/src/components/comment/comment-list/CommentItem.tsx @@ -0,0 +1,238 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { Heart, MessageSquare, Edit, Trash2 } from '@teable/icons'; +import type { ListBaseCollaboratorVo, ICommentVo, IUpdateCommentReactionRo } from '@teable/openapi'; +import { deleteComment, createCommentReaction } from '@teable/openapi'; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, + Button, + cn, + HoverCardPortal, +} from '@teable/ui-lib'; +import { useState, useRef, useEffect } from 'react'; +import { ReactQueryKeys } from '../../../config'; +import { useTranslation } from '../../../context/app/i18n'; +import { useLanDayjs, useSession } from '../../../hooks'; +import { UserAvatar } from '../../cell-value'; +import { useModalRefElement } from '../../expand-record/useModalRefElement'; +import { CommentQuote } from '../comment-editor/CommentQuote'; +import type { IBaseQueryParams } from '../types'; +import { useCommentStore } from '../useCommentStore'; +import { CommentContent } from './CommentContent'; +import { CommentListContext } from './context'; +import { Reaction, ReactionPicker } from './reaction'; + +interface ICommentItemProps extends Omit, IBaseQueryParams { + createdBy: ListBaseCollaboratorVo[number]; + commentId?: string; +} + +export const CommentItem = (props: ICommentItemProps) => { + const { + createdBy, + createdTime, + content, + id, + recordId, + tableId, + quoteId, + lastModifiedTime, + reaction, + commentId, + } = props; + const dayjs = useLanDayjs(); + const { t } = useTranslation(); + const [emojiPickOpen, setEmojiPickOpen] = useState(false); + const relativeTime = dayjs(createdTime).fromNow(); + const { setQuoteId, setEditingCommentId, editorRef } = useCommentStore(); + const { user } = useSession(); + const isMe = !!(createdBy?.userId && user?.id === createdBy?.userId); + const queryClient = useQueryClient(); + const { mutateAsync: deleteCommentFn } = useMutation({ + mutationFn: ({ tableId, recordId, id }: { tableId: string; recordId: string; id: string }) => + deleteComment(tableId, recordId, id), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ReactQueryKeys.commentDetail(tableId, recordId, id), + }); + queryClient.invalidateQueries({ + queryKey: ReactQueryKeys.recordCommentCount(tableId, recordId), + }); + }, + }); + const modalRef = useModalRefElement(); + const itemRef = useRef(null); + const firstRef = useRef(true); + useEffect(() => { + if (commentId && itemRef && commentId === id && firstRef.current) { + setTimeout(() => { + itemRef?.current?.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + }); + firstRef.current = false; + }, 200); + } + }, [commentId, id]); + const { mutateAsync: createCommentEmojiFn } = useMutation({ + mutationFn: ({ + tableId, + recordId, + commentId, + reactionRo, + }: { + tableId: string; + recordId: string; + commentId: string; + reactionRo: IUpdateCommentReactionRo; + }) => createCommentReaction(tableId, recordId, commentId, reactionRo), + }); + + return ( + createdBy && ( + + +
+
+ +
+ +
+
+ + {createdBy.userName} + + + {relativeTime} + + {lastModifiedTime && ( + + {t('comment.tip.edited')} + + )} +
+
+ + + + +
+ +
+
+
+ + + { + setEmojiPickOpen(open); + }} + > + + + + + + + { + createCommentEmojiFn({ + tableId, + recordId, + commentId: id, + reactionRo: { reaction: emoji }, + }).then(() => { + setTimeout(() => { + itemRef?.current && + itemRef?.current?.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + }); + }, 200); + }); + }} + /> + + + + + + {isMe && ( + + )} + {isMe && ( + + )} + + +
+ ) + ); +}; diff --git a/packages/sdk/src/components/comment/comment-list/CommentList.tsx b/packages/sdk/src/components/comment/comment-list/CommentList.tsx new file mode 100644 index 0000000000..aff661e2b9 --- /dev/null +++ b/packages/sdk/src/components/comment/comment-list/CommentList.tsx @@ -0,0 +1,219 @@ +import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query'; +import { getCommentList, CommentPatchType } from '@teable/openapi'; +import type { ICommentVo, ListBaseCollaboratorVo, ICommentPatchData } from '@teable/openapi'; +import { Spin, Button } from '@teable/ui-lib'; +import { isEqual } from 'lodash'; +import { + forwardRef, + useImperativeHandle, + useRef, + useMemo, + useEffect, + useCallback, + useState, +} from 'react'; +import { ReactQueryKeys } from '../../../config'; +import { useTranslation } from '../../../context/app/i18n'; +import { useSession } from '../../../hooks'; +import { useCollaborators } from '../hooks'; +import type { IBaseQueryParams } from '../types'; +import { CommentItem } from './CommentItem'; +import { useCommentPatchListener } from './useCommentPatchListener'; + +export interface ICommentListProps extends IBaseQueryParams { + commentId?: string; +} + +export interface CommentListRefHandle { + scrollToBottom: () => void; +} + +export const CommentList = forwardRef((props, ref) => { + const { tableId, recordId, commentId } = props; + const { t } = useTranslation(); + const collaborators = useCollaborators(); + const listRef = useRef(null); + const [commentList, setCommentList] = useState([]); + const { user: self } = useSession(); + + const queryClient = useQueryClient(); + useEffect(() => { + return () => queryClient.removeQueries(ReactQueryKeys.commentList(tableId, recordId)); + }, [queryClient, recordId, tableId]); + + const scrollToBottom = useCallback(() => { + if (listRef.current) { + const scrollHeight = listRef.current.scrollHeight; + listRef.current.scrollTo({ + top: scrollHeight, + behavior: 'smooth', + }); + } + }, []); + + const scrollDownSlightly = useCallback(() => { + if (listRef.current) { + const scrollTop = listRef.current.scrollTop; + listRef.current.scrollTo({ + top: scrollTop + 48, + behavior: 'smooth', + }); + } + }, []); + + useImperativeHandle(ref, () => ({ + scrollToBottom: scrollToBottom, + })); + + const { data, fetchPreviousPage, isFetchingPreviousPage, hasPreviousPage } = useInfiniteQuery({ + queryKey: ReactQueryKeys.commentList(tableId, recordId), + refetchOnMount: 'always', + refetchOnWindowFocus: false, + queryFn: ({ pageParam }) => + getCommentList(tableId!, recordId!, { + cursor: pageParam?.cursor, + take: 20, + direction: pageParam?.direction || 'forward', + }).then((res) => res.data), + getPreviousPageParam: (firstPage) => + firstPage.nextCursor + ? { + cursor: firstPage.nextCursor, + direction: 'forward', + } + : undefined, + onSuccess: (data) => { + // first come move to bottom + if (data.pages.length === 1 && listRef.current) { + const scrollToBottom = () => { + if (listRef.current) { + const scrollHeight = listRef.current.scrollHeight; + listRef.current.scrollTop = scrollHeight; + } + }; + setTimeout(scrollToBottom, 100); + } + }, + enabled: !!tableId && !!recordId, + }); + + useEffect(() => { + let result = [...commentList]; + const pageList = data?.pages.flatMap((page) => page.comments); + if (Array.isArray(pageList)) { + const uniqueComments: Record = {}; + + [...pageList, ...result].forEach((comment) => { + uniqueComments[comment.id] = comment; + }); + + const mergedList = Object.values(uniqueComments) as ICommentVo[]; + + result = mergedList; + } + + result = result.sort( + (a, b) => new Date(a.createdTime).getTime() - new Date(b.createdTime).getTime() + ); + + if (!isEqual(result, commentList)) { + setCommentList(result); + } + }, [commentList, data?.pages]); + + const commentListener = useCallback( + (remoteData: unknown) => { + const { data, type } = remoteData as ICommentPatchData; + + switch (type) { + case CommentPatchType.CreateComment: { + setCommentList((prevList) => [ + ...prevList, + { + ...data, + } as ICommentVo, + ]); + if (data.createdBy === self.id) { + setTimeout(() => { + scrollToBottom(); + }, 100); + } else { + setTimeout(() => { + scrollDownSlightly(); + }, 100); + } + break; + } + case CommentPatchType.DeleteComment: { + setCommentList((prevList) => prevList.filter((comment) => comment.id !== data.id)); + break; + } + + case CommentPatchType.UpdateComment: + case CommentPatchType.CreateReaction: + case CommentPatchType.DeleteReaction: { + setCommentList((prevList) => { + const newList = [...prevList]; + const index = newList.findIndex((list) => list.id === data.id); + if (index > -1) { + newList[index] = { ...data } as ICommentVo; + } + return newList; + }); + break; + } + } + }, + [scrollDownSlightly, scrollToBottom, self.id] + ); + + useCommentPatchListener(tableId, recordId, commentListener); + + const commentListWithCollaborators = useMemo(() => { + return commentList.map((comment) => ({ + ...comment, + createdBy: collaborators?.find( + (collaborator) => collaborator.userId === comment.createdBy + ) as ListBaseCollaboratorVo[number], + })); + }, [commentList, collaborators]); + + return ( +
+ {isFetchingPreviousPage && ( +
+ +
+ )} + {isFetchingPreviousPage ? ( +
+ +
+ ) : ( + hasPreviousPage && ( + + ) + )} + + {commentListWithCollaborators?.length ? ( + commentListWithCollaborators.map((comment) => ( + + )) + ) : ( +
+ {t('comment.emptyComment')} +
+ )} +
+ ); +}); + +CommentList.displayName = 'CommentList'; diff --git a/packages/sdk/src/components/comment/comment-list/context.ts b/packages/sdk/src/components/comment/comment-list/context.ts new file mode 100644 index 0000000000..de517bdb1c --- /dev/null +++ b/packages/sdk/src/components/comment/comment-list/context.ts @@ -0,0 +1,8 @@ +import { createContext } from 'react'; +interface ICommentListProps { + isMe: boolean; +} + +export const CommentListContext = createContext({ + isMe: false, +}); diff --git a/packages/sdk/src/components/comment/comment-list/index.ts b/packages/sdk/src/components/comment/comment-list/index.ts new file mode 100644 index 0000000000..dd026e09d6 --- /dev/null +++ b/packages/sdk/src/components/comment/comment-list/index.ts @@ -0,0 +1 @@ +export * from './CommentList'; diff --git a/packages/sdk/src/components/comment/comment-list/node/block-element/Image.tsx b/packages/sdk/src/components/comment/comment-list/node/block-element/Image.tsx new file mode 100644 index 0000000000..d6e17e1d21 --- /dev/null +++ b/packages/sdk/src/components/comment/comment-list/node/block-element/Image.tsx @@ -0,0 +1,38 @@ +import { useQuery } from '@tanstack/react-query'; +import { getCommentAttachmentUrl } from '@teable/openapi'; +import { cn } from '@teable/ui-lib'; +import { ReactQueryKeys } from '../../../../../config'; +import { useTableId } from '../../../../../hooks'; +import { useRecordId } from '../../../hooks'; +import { useCommentStore } from '../../../useCommentStore'; +import type { IBaseNodeProps } from '../type'; + +interface IBlockImageElementProps extends IBaseNodeProps { + path: string; + width?: number; +} +export const BlockImageElement = (props: IBlockImageElementProps) => { + const { path, width, className } = props; + const tableId = useTableId(); + const recordId = useRecordId(); + const { attachmentPresignedUrls, setAttachmentPresignedUrls } = useCommentStore(); + const { data: imageData } = useQuery({ + queryKey: ReactQueryKeys.commentAttachment(tableId!, recordId!, path), + queryFn: () => + getCommentAttachmentUrl(tableId!, recordId!, path as string).then(({ data }) => data), + enabled: !!(!attachmentPresignedUrls[path] && tableId && recordId), + }); + if (imageData && !attachmentPresignedUrls[path as string]) { + setAttachmentPresignedUrls(path, imageData); + } + return ( +
+ img +
+ ); +}; diff --git a/packages/sdk/src/components/comment/comment-list/node/block-element/Paragraph.tsx b/packages/sdk/src/components/comment/comment-list/node/block-element/Paragraph.tsx new file mode 100644 index 0000000000..4762d23b07 --- /dev/null +++ b/packages/sdk/src/components/comment/comment-list/node/block-element/Paragraph.tsx @@ -0,0 +1,15 @@ +import { cn } from '@teable/ui-lib'; +import type { IBaseNodeProps } from '../type'; + +interface IBlockParagraphElementProps extends IBaseNodeProps { + children: React.ReactNode; +} + +export const BlockParagraphElement = (props: IBlockParagraphElementProps) => { + const { children, className } = props; + return ( +
+ {children} +
+ ); +}; diff --git a/packages/sdk/src/components/comment/comment-list/node/block-element/index.ts b/packages/sdk/src/components/comment/comment-list/node/block-element/index.ts new file mode 100644 index 0000000000..0b7682badd --- /dev/null +++ b/packages/sdk/src/components/comment/comment-list/node/block-element/index.ts @@ -0,0 +1,2 @@ +export * from './Image'; +export * from './Paragraph'; diff --git a/packages/sdk/src/components/comment/comment-list/node/index.ts b/packages/sdk/src/components/comment/comment-list/node/index.ts new file mode 100644 index 0000000000..36dd2c385b --- /dev/null +++ b/packages/sdk/src/components/comment/comment-list/node/index.ts @@ -0,0 +1,2 @@ +export * from './inline-element'; +export * from './block-element'; diff --git a/packages/sdk/src/components/comment/comment-list/node/inline-element/Link.tsx b/packages/sdk/src/components/comment/comment-list/node/inline-element/Link.tsx new file mode 100644 index 0000000000..f5620798ca --- /dev/null +++ b/packages/sdk/src/components/comment/comment-list/node/inline-element/Link.tsx @@ -0,0 +1,21 @@ +import { cn } from '@teable/ui-lib'; +import type { IBaseNodeProps } from '../type'; + +interface InlineLinkElementProps extends IBaseNodeProps { + href: string; + title?: string; +} + +export const InlineLinkElement = (props: InlineLinkElementProps) => { + const { href, title, className } = props; + return ( +
+ {title} + + ); +}; diff --git a/packages/sdk/src/components/comment/comment-list/node/inline-element/MentionUser.tsx b/packages/sdk/src/components/comment/comment-list/node/inline-element/MentionUser.tsx new file mode 100644 index 0000000000..db9fdaefdc --- /dev/null +++ b/packages/sdk/src/components/comment/comment-list/node/inline-element/MentionUser.tsx @@ -0,0 +1,34 @@ +import { User } from '@teable/icons'; +import { cn } from '@teable/ui-lib'; +import { UserAvatar } from '../../../../cell-value'; +import { useCollaborator } from '../../../hooks'; + +interface IMentionUserProps { + id: string; + className?: string; +} + +export const MentionUser = (props: IMentionUserProps) => { + const { id, className } = props; + const user = useCollaborator(id) || { + avatar: '', + userName: '', + }; + + return ( +
+ {user.avatar ? ( + <> + + + {user.userName} + + + ) : ( +
+ +
+ )} +
+ ); +}; diff --git a/packages/sdk/src/components/comment/comment-list/node/inline-element/index.ts b/packages/sdk/src/components/comment/comment-list/node/inline-element/index.ts new file mode 100644 index 0000000000..13e36e59f3 --- /dev/null +++ b/packages/sdk/src/components/comment/comment-list/node/inline-element/index.ts @@ -0,0 +1,2 @@ +export * from './Link'; +export * from './MentionUser'; diff --git a/packages/sdk/src/components/comment/comment-list/node/type.ts b/packages/sdk/src/components/comment/comment-list/node/type.ts new file mode 100644 index 0000000000..fd8c691436 --- /dev/null +++ b/packages/sdk/src/components/comment/comment-list/node/type.ts @@ -0,0 +1,3 @@ +export interface IBaseNodeProps { + className?: string; +} diff --git a/packages/sdk/src/components/comment/comment-list/reaction/Reaction.tsx b/packages/sdk/src/components/comment/comment-list/reaction/Reaction.tsx new file mode 100644 index 0000000000..3dd48fdf6f --- /dev/null +++ b/packages/sdk/src/components/comment/comment-list/reaction/Reaction.tsx @@ -0,0 +1,90 @@ +import { useMutation } from '@tanstack/react-query'; +import type { ICommentVo, IUpdateCommentReactionRo } from '@teable/openapi'; +import { deleteCommentReaction, createCommentReaction } from '@teable/openapi'; +import { Button, cn } from '@teable/ui-lib'; + +import { useSession, useTableId } from '../../../../hooks'; +import { useRecordId } from '../../hooks'; +interface ICommentReactionProps { + commentId: string; + value: ICommentVo['reaction']; +} + +export const Reaction = (props: ICommentReactionProps) => { + const { value, commentId } = props; + const tableId = useTableId(); + const recordId = useRecordId(); + const { user: sessionUser } = useSession(); + const { mutateAsync: createCommentReactionFn } = useMutation({ + mutationFn: ({ + tableId, + recordId, + commentId, + reactionRo, + }: { + tableId: string; + recordId: string; + commentId: string; + reactionRo: IUpdateCommentReactionRo; + }) => createCommentReaction(tableId, recordId, commentId, reactionRo), + }); + const { mutateAsync: deleteCommentEmojiFn } = useMutation({ + mutationFn: ({ + tableId, + recordId, + commentId, + reactionRo, + }: { + tableId: string; + recordId: string; + commentId: string; + reactionRo: IUpdateCommentReactionRo; + }) => deleteCommentReaction(tableId, recordId, commentId, reactionRo), + }); + + const reactionHandler = async (emoji: string) => { + const users = value?.find((item) => item.reaction === emoji)?.user || []; + if (!tableId || !recordId) { + return; + } + if (users.includes(sessionUser?.id)) { + await deleteCommentEmojiFn({ tableId, recordId, commentId, reactionRo: { reaction: emoji } }); + } else { + await createCommentReactionFn({ + tableId, + recordId, + commentId, + reactionRo: { reaction: emoji }, + }); + } + }; + + return ( + value && ( +
+ {value?.map(({ reaction, user }, index) => ( + + ))} +
+ ) + ); +}; diff --git a/packages/sdk/src/components/comment/comment-list/reaction/ReactionPicker.tsx b/packages/sdk/src/components/comment/comment-list/reaction/ReactionPicker.tsx new file mode 100644 index 0000000000..c6a46cde6d --- /dev/null +++ b/packages/sdk/src/components/comment/comment-list/reaction/ReactionPicker.tsx @@ -0,0 +1,27 @@ +import { Emojis } from '@teable/openapi'; +import { Button } from '@teable/ui-lib'; + +interface IEmojiPickerProps { + onReactionClick: (emoji: string) => void; +} + +export const ReactionPicker = (props: IEmojiPickerProps) => { + const { onReactionClick } = props; + + return ( +
+ {Emojis.map((emoji) => { + return ( + + ); + })} +
+ ); +}; diff --git a/packages/sdk/src/components/comment/comment-list/reaction/index.ts b/packages/sdk/src/components/comment/comment-list/reaction/index.ts new file mode 100644 index 0000000000..c063b6acb1 --- /dev/null +++ b/packages/sdk/src/components/comment/comment-list/reaction/index.ts @@ -0,0 +1,2 @@ +export * from './Reaction'; +export * from './ReactionPicker'; diff --git a/packages/sdk/src/components/comment/comment-list/useCommentPatchListener.ts b/packages/sdk/src/components/comment/comment-list/useCommentPatchListener.ts new file mode 100644 index 0000000000..de97fdd4cc --- /dev/null +++ b/packages/sdk/src/components/comment/comment-list/useCommentPatchListener.ts @@ -0,0 +1,39 @@ +import { getCommentChannel } from '@teable/core'; +import { isEmpty, get } from 'lodash'; +import { useEffect } from 'react'; +import { useConnection } from '../../../hooks'; + +export const useCommentPatchListener = ( + tableId: string, + recordId: string, + cb: (remoteData: unknown) => void +) => { + const { connection } = useConnection(); + const presenceKey = getCommentChannel(tableId, recordId); + const presence = connection.getPresence(presenceKey); + + useEffect(() => { + if (!presence || !tableId || !connection || !recordId) { + return; + } + + if (presence.subscribed) { + return; + } + + presence.subscribe(); + + const receiveHandler = () => { + const { remotePresences } = presence; + !isEmpty(remotePresences) && cb?.(get(remotePresences, presenceKey)); + }; + + presence.on('receive', receiveHandler); + + return () => { + presence?.removeListener('receive', receiveHandler); + presence?.listenerCount('receive') === 0 && presence?.unsubscribe(); + presence?.listenerCount('receive') === 0 && presence?.destroy(); + }; + }, [cb, connection, presence, presenceKey, recordId, tableId]); +}; diff --git a/packages/sdk/src/components/comment/comment-list/useIsMe.ts b/packages/sdk/src/components/comment/comment-list/useIsMe.ts new file mode 100644 index 0000000000..c428739ab2 --- /dev/null +++ b/packages/sdk/src/components/comment/comment-list/useIsMe.ts @@ -0,0 +1,7 @@ +import { useContext } from 'react'; +import { CommentListContext } from './context'; + +export const useIsMe = () => { + const { isMe = false } = useContext(CommentListContext); + return isMe; +}; diff --git a/packages/sdk/src/components/comment/context.ts b/packages/sdk/src/components/comment/context.ts new file mode 100644 index 0000000000..ef3afed192 --- /dev/null +++ b/packages/sdk/src/components/comment/context.ts @@ -0,0 +1,11 @@ +import type { ListBaseCollaboratorVo } from '@teable/openapi'; +import React from 'react'; + +export interface ICommentContext { + collaborators: ListBaseCollaboratorVo; + recordId?: string; +} + +export const CommentContext = React.createContext({ + collaborators: [], +}); diff --git a/packages/sdk/src/components/comment/hooks/index.ts b/packages/sdk/src/components/comment/hooks/index.ts new file mode 100644 index 0000000000..e07cd70927 --- /dev/null +++ b/packages/sdk/src/components/comment/hooks/index.ts @@ -0,0 +1,4 @@ +export * from './useCollaborators'; +export * from './useCollaborator'; +export * from './useRecordId'; +export * from './useRecordCommentCount'; diff --git a/packages/sdk/src/components/comment/hooks/useCollaborator.ts b/packages/sdk/src/components/comment/hooks/useCollaborator.ts new file mode 100644 index 0000000000..79b2652c2f --- /dev/null +++ b/packages/sdk/src/components/comment/hooks/useCollaborator.ts @@ -0,0 +1,6 @@ +import { useCollaborators } from './useCollaborators'; + +export const useCollaborator = (id: string) => { + const collaborators = useCollaborators(); + return collaborators.find((collaborator) => collaborator.userId === id); +}; diff --git a/packages/sdk/src/components/comment/hooks/useCollaborators.ts b/packages/sdk/src/components/comment/hooks/useCollaborators.ts new file mode 100644 index 0000000000..e9eadd24ac --- /dev/null +++ b/packages/sdk/src/components/comment/hooks/useCollaborators.ts @@ -0,0 +1,6 @@ +import { useContext } from 'react'; +import { CommentContext } from '../context'; + +export const useCollaborators = () => { + return useContext(CommentContext).collaborators || []; +}; diff --git a/packages/sdk/src/components/comment/hooks/useRecordCommentCount.ts b/packages/sdk/src/components/comment/hooks/useRecordCommentCount.ts new file mode 100644 index 0000000000..773286f69c --- /dev/null +++ b/packages/sdk/src/components/comment/hooks/useRecordCommentCount.ts @@ -0,0 +1,18 @@ +import { useQuery } from '@tanstack/react-query'; +import { getRecordCommentCount } from '@teable/openapi'; +import { ReactQueryKeys } from '../../../config'; + +export const useRecordCommentCount = (tableId: string, recordId: string, enabled?: boolean) => { + const { data } = useQuery({ + queryKey: ReactQueryKeys.recordCommentCount(tableId, recordId), + queryFn: () => { + if (tableId && recordId) { + return getRecordCommentCount(tableId, recordId).then(({ data }) => data); + } + return Promise.resolve({ count: 0 }); + }, + enabled: !!(tableId && recordId && enabled), + }); + + return data?.count || 0; +}; diff --git a/packages/sdk/src/components/comment/hooks/useRecordId.ts b/packages/sdk/src/components/comment/hooks/useRecordId.ts new file mode 100644 index 0000000000..4a411982df --- /dev/null +++ b/packages/sdk/src/components/comment/hooks/useRecordId.ts @@ -0,0 +1,6 @@ +import { useContext } from 'react'; +import { CommentContext } from '../context'; + +export const useRecordId = () => { + return useContext(CommentContext).recordId; +}; diff --git a/packages/sdk/src/components/comment/index.ts b/packages/sdk/src/components/comment/index.ts new file mode 100644 index 0000000000..274a3d3134 --- /dev/null +++ b/packages/sdk/src/components/comment/index.ts @@ -0,0 +1 @@ +export * from './CommentPanel'; diff --git a/packages/sdk/src/components/comment/types.ts b/packages/sdk/src/components/comment/types.ts new file mode 100644 index 0000000000..ebf66dcf76 --- /dev/null +++ b/packages/sdk/src/components/comment/types.ts @@ -0,0 +1,4 @@ +export interface IBaseQueryParams { + tableId: string; + recordId: string; +} diff --git a/packages/sdk/src/components/comment/useCommentStore.ts b/packages/sdk/src/components/comment/useCommentStore.ts new file mode 100644 index 0000000000..a48c69aa6b --- /dev/null +++ b/packages/sdk/src/components/comment/useCommentStore.ts @@ -0,0 +1,96 @@ +import type { ICommentVo } from '@teable/openapi'; +import { noop } from 'lodash'; +import { create } from 'zustand'; + +interface IEditorRef { + focus: () => void; + blur: () => void; +} + +interface ICommentState { + quoteId?: string; + editingCommentId?: string; + editorRef: IEditorRef; + attachmentPresignedUrls: Record; + commentList: ICommentVo[]; + listRef: React.RefObject | null; + + setQuoteId: (quoteId?: string) => void; + setEditingCommentId: (editingCommentId?: string) => void; + setEditorRef: (editorRef: IEditorRef) => void; + setAttachmentPresignedUrls: (path: string, url: string) => void; + setCommentList: (list: ICommentVo[]) => void; + + resetCommentStore: () => void; +} + +export const useCommentStore = create((set) => ({ + quoteId: undefined, + editingCommentId: undefined, + attachmentPresignedUrls: {}, + commentList: [] as ICommentVo[], + listRef: null, + setQuoteId: (quoteId?: string) => { + set((state) => { + return { + ...state, + editingCommentId: undefined, + quoteId, + }; + }); + }, + setEditingCommentId: (editingCommentId?: string) => { + set((state) => { + return { + ...state, + quoteId: undefined, + editingCommentId, + }; + }); + }, + editorRef: { + focus: noop, + blur: noop, + }, + setAttachmentPresignedUrls: (path: string, url: string) => { + set((state) => { + return { + ...state, + attachmentPresignedUrls: { + ...state.attachmentPresignedUrls, + [path]: url, + }, + }; + }); + }, + setCommentList: (list: ICommentVo[]) => { + set((state) => { + return { + ...state, + commentList: [...list], + }; + }); + }, + setEditorRef: (editorRef: IEditorRef) => { + set((state) => { + return { + ...state, + editorRef, + }; + }); + }, + resetCommentStore: () => { + set(() => { + return { + quoteId: undefined, + editingCommentId: undefined, + attachmentPresignedUrls: {}, + commentList: [] as ICommentVo[], + editorRef: { + focus: noop, + blur: noop, + }, + }; + }); + }, +})); diff --git a/packages/sdk/src/components/expand-record/ExpandRecord.tsx b/packages/sdk/src/components/expand-record/ExpandRecord.tsx index 92ea57ffb7..f57d9adad9 100644 --- a/packages/sdk/src/components/expand-record/ExpandRecord.tsx +++ b/packages/sdk/src/components/expand-record/ExpandRecord.tsx @@ -1,5 +1,5 @@ import type { IRecord } from '@teable/core'; -import { Skeleton } from '@teable/ui-lib'; +import { Skeleton, cn } from '@teable/ui-lib'; import { isEqual } from 'lodash'; import { useCallback, useMemo } from 'react'; import { @@ -9,8 +9,11 @@ import { useRecord, useViewId, useViews, + useTableId, + useBaseId, } from '../../hooks'; import type { GridView, IFieldInstance } from '../../model'; +import { CommentPanel } from '../comment'; import { ExpandRecordHeader } from './ExpandRecordHeader'; import { ExpandRecordWrap } from './ExpandRecordWrap'; import { RecordEditor } from './RecordEditor'; @@ -20,15 +23,18 @@ import { ExpandRecordModel } from './type'; interface IExpandRecordProps { recordId: string; recordIds?: string[]; + commentId?: string; visible?: boolean; model?: ExpandRecordModel; serverData?: IRecord; recordHistoryVisible?: boolean; + commentVisible?: boolean; onClose?: () => void; onPrev?: (recordId: string) => void; onNext?: (recordId: string) => void; onCopyUrl?: () => void; onRecordHistoryToggle?: () => void; + onCommentToggle?: () => void; onDelete?: () => Promise; } @@ -37,19 +43,24 @@ export const ExpandRecord = (props: IExpandRecordProps) => { model, visible, recordId, + commentId, recordIds, serverData, recordHistoryVisible, + commentVisible, onPrev, onNext, onClose, onCopyUrl, onRecordHistoryToggle, + onCommentToggle, onDelete, } = props; const views = useViews() as (GridView | undefined)[]; + const tableId = useTableId(); const defaultViewId = views?.[0]?.id; const viewId = useViewId() ?? defaultViewId; + const baseId = useBaseId(); const allFields = useFields({ withHidden: true, withDenied: true }); const showFields = useFields(); const record = useRecord(recordId, serverData); @@ -83,15 +94,18 @@ export const ExpandRecord = (props: IExpandRecordProps) => { return recordIds?.length ? recordIds.findIndex((id) => recordId === id) - 1 : -1; }, [recordId, recordIds]); - const onChange = (newValue: unknown, fieldId: string) => { - if (isEqual(record?.getCellValue(fieldId), newValue)) { - return; - } - if (Array.isArray(newValue) && newValue.length === 0) { - return record?.updateCell(fieldId, null); - } - record?.updateCell(fieldId, newValue); - }; + const onChange = useCallback( + (newValue: unknown, fieldId: string) => { + if (isEqual(record?.getCellValue(fieldId), newValue)) { + return; + } + if (Array.isArray(newValue) && newValue.length === 0) { + return record?.updateCell(fieldId, null); + } + record?.updateCell(fieldId, newValue); + }, + [record] + ); const onPrevInner = () => { if (!recordIds?.length || prevRecordIndex === -1) { @@ -115,38 +129,58 @@ export const ExpandRecord = (props: IExpandRecordProps) => { model={isTouchDevice ? ExpandRecordModel.Drawer : model ?? ExpandRecordModel.Modal} visible={visible} onClose={onClose} + className={cn({ 'max-w-5xl': commentVisible })} >
- + {tableId && recordId && ( + + )}
{recordHistoryVisible ? (
) : ( -
+
{fields.length > 0 ? ( - +
+ +
) : ( )} + + {commentVisible && baseId && tableId && recordId && ( +
+ +
+ )}
)}
diff --git a/packages/sdk/src/components/expand-record/ExpandRecordHeader.tsx b/packages/sdk/src/components/expand-record/ExpandRecordHeader.tsx index 2873df5955..addcb402e8 100644 --- a/packages/sdk/src/components/expand-record/ExpandRecordHeader.tsx +++ b/packages/sdk/src/components/expand-record/ExpandRecordHeader.tsx @@ -1,4 +1,13 @@ -import { ChevronDown, ChevronUp, History, Link, MoreHorizontal, Trash2, X } from '@teable/icons'; +import { + ChevronDown, + ChevronUp, + History, + Link, + MoreHorizontal, + Trash2, + X, + MessageSquare, +} from '@teable/icons'; import { Button, cn, @@ -10,12 +19,16 @@ import { } from '@teable/ui-lib'; import { useMeasure } from 'react-use'; import { useTranslation } from '../../context/app/i18n'; -import { usePermissionActionsStatic, useTablePermission } from '../../hooks'; +import { useTablePermission } from '../../hooks'; +import { useRecordCommentCount } from '../comment/hooks'; import { TooltipWrap } from './TooltipWrap'; interface IExpandRecordHeader { + tableId: string; + recordId: string; title?: string; recordHistoryVisible?: boolean; + commentVisible?: boolean; disabledPrev?: boolean; disabledNext?: boolean; onClose?: () => void; @@ -23,6 +36,7 @@ interface IExpandRecordHeader { onNext?: () => void; onCopyUrl?: () => void; onRecordHistoryToggle?: () => void; + onCommentToggle?: () => void; onDelete?: () => Promise; } @@ -33,8 +47,11 @@ const MIN_OPERATOR_WIDTH = 200; export const ExpandRecordHeader = (props: IExpandRecordHeader) => { const { + tableId, + recordId, title, recordHistoryVisible, + commentVisible, disabledPrev, disabledNext, onPrev, @@ -42,24 +59,26 @@ export const ExpandRecordHeader = (props: IExpandRecordHeader) => { onClose, onCopyUrl, onRecordHistoryToggle, + onCommentToggle, onDelete, } = props; - const { actionStaticMap } = usePermissionActionsStatic(); const permission = useTablePermission(); const editable = Boolean(permission['record|update']); + const canRead = Boolean(permission['record|read']); const canDelete = Boolean(permission['record|delete']); const [ref, { width }] = useMeasure(); const { t } = useTranslation(); const showTitle = width > MIN_TITLE_WIDTH; const showOperator = width > MIN_OPERATOR_WIDTH; + const recordCommentCount = useRecordCommentCount(tableId, recordId, canRead); return (
@@ -95,7 +114,7 @@ export const ExpandRecordHeader = (props: IExpandRecordHeader) => { )} {showOperator && ( -
+
+ + )} + {canDelete ? ( diff --git a/packages/sdk/src/components/expand-record/ExpandRecordWrap.tsx b/packages/sdk/src/components/expand-record/ExpandRecordWrap.tsx index bce1f3fa4b..476baa7e13 100644 --- a/packages/sdk/src/components/expand-record/ExpandRecordWrap.tsx +++ b/packages/sdk/src/components/expand-record/ExpandRecordWrap.tsx @@ -6,15 +6,17 @@ import { ExpandRecordModel } from './type'; export const ExpandRecordWrap: FC< PropsWithChildren<{ model?: ExpandRecordModel; + modal?: boolean; visible?: boolean; onClose?: () => void; + className?: string; }> > = (props) => { - const { children, model, visible, onClose } = props; + const { children, model, visible, onClose, modal, className } = props; if (model === ExpandRecordModel.Modal) return ( - + {children} ); diff --git a/packages/sdk/src/components/expand-record/ExpandRecorder.tsx b/packages/sdk/src/components/expand-record/ExpandRecorder.tsx index 60ca7598ac..f89c53eacf 100644 --- a/packages/sdk/src/components/expand-record/ExpandRecorder.tsx +++ b/packages/sdk/src/components/expand-record/ExpandRecorder.tsx @@ -1,7 +1,7 @@ import type { IRecord } from '@teable/core'; import { deleteRecord } from '@teable/openapi'; import { useToast } from '@teable/ui-lib'; -import type { FC, PropsWithChildren } from 'react'; +import { useEffect, type FC, type PropsWithChildren } from 'react'; import { useLocalStorage } from 'react-use'; import { LocalStorageKeys } from '../../config/local-storage-keys'; import { StandaloneViewProvider, ViewProvider } from '../../context'; @@ -29,6 +29,7 @@ interface IExpandRecorderProps { tableId: string; viewId?: string; recordId?: string; + commentId?: string; recordIds?: string[]; model?: ExpandRecordModel; serverData?: IRecord; @@ -37,18 +38,36 @@ interface IExpandRecorderProps { } export const ExpandRecorder = (props: IExpandRecorderProps) => { - const { model, tableId, recordId, recordIds, serverData, onClose, onUpdateRecordIdCallback } = - props; + const { + model, + tableId, + recordId, + recordIds, + serverData, + onClose, + onUpdateRecordIdCallback, + commentId, + } = props; const { toast } = useToast(); const { t } = useTranslation(); const permission = useTablePermission(); const editable = Boolean(permission['record|update']); + const canRead = Boolean(permission['record|read']); const canDelete = Boolean(permission['record|delete']); const [recordHistoryVisible, setRecordHistoryVisible] = useLocalStorage( LocalStorageKeys.RecordHistoryVisible, false ); + const [commentVisible, setCommentVisible] = useLocalStorage( + LocalStorageKeys.CommentVisible, + !!commentId || false + ); + + useEffect(() => { + commentId && setCommentVisible(true); + }, [commentId, setCommentVisible]); + if (!recordId) { return <>; } @@ -64,9 +83,15 @@ export const ExpandRecorder = (props: IExpandRecorderProps) => { }; const onRecordHistoryToggle = () => { + setCommentVisible(false); setRecordHistoryVisible(!recordHistoryVisible); }; + const onCommentToggle = () => { + setRecordHistoryVisible(false); + setCommentVisible(!commentVisible); + }; + return (
@@ -75,13 +100,16 @@ export const ExpandRecorder = (props: IExpandRecorderProps) => { model={model} recordId={recordId} recordIds={recordIds} + commentId={commentId} serverData={serverData?.id === recordId ? serverData : undefined} recordHistoryVisible={editable && recordHistoryVisible} + commentVisible={canRead && commentVisible} onClose={onClose} onPrev={updateCurrentRecordId} onNext={updateCurrentRecordId} onCopyUrl={onCopyUrl} onRecordHistoryToggle={onRecordHistoryToggle} + onCommentToggle={onCommentToggle} onDelete={async () => { if (canDelete) await deleteRecord(tableId, recordId); }} diff --git a/packages/sdk/src/components/expand-record/Modal.tsx b/packages/sdk/src/components/expand-record/Modal.tsx index 79f36a4486..6ea15714df 100644 --- a/packages/sdk/src/components/expand-record/Modal.tsx +++ b/packages/sdk/src/components/expand-record/Modal.tsx @@ -1,5 +1,7 @@ import { Dialog, DialogContent, cn } from '@teable/ui-lib'; import { type FC, type PropsWithChildren } from 'react'; +import { useRef } from 'react'; +import { ModalContext } from './ModalContext'; export const Modal: FC< PropsWithChildren<{ @@ -11,23 +13,31 @@ export const Modal: FC< }> > = (props) => { const { modal, className, children, container, visible, onClose } = props; + const ref = useRef(null); return ( - + e.stopPropagation()} onKeyDown={(e) => { if (e.key === 'Escape') { onClose?.(); } + if (e.key === 'Enter') { + return; + } + e.stopPropagation(); + }} + onInteractOutside={(e) => { e.stopPropagation(); + e.preventDefault(); }} + ref={ref} > - {children} + {children} ); diff --git a/packages/sdk/src/components/expand-record/ModalContext.ts b/packages/sdk/src/components/expand-record/ModalContext.ts new file mode 100644 index 0000000000..e31ca8012f --- /dev/null +++ b/packages/sdk/src/components/expand-record/ModalContext.ts @@ -0,0 +1,7 @@ +import { createContext } from 'react'; + +interface IModalContext { + ref: React.RefObject; +} + +export const ModalContext = createContext({ ref: { current: null } }); diff --git a/packages/sdk/src/components/expand-record/useModalRefElement.ts b/packages/sdk/src/components/expand-record/useModalRefElement.ts new file mode 100644 index 0000000000..0d82850d0a --- /dev/null +++ b/packages/sdk/src/components/expand-record/useModalRefElement.ts @@ -0,0 +1,7 @@ +import { useContext } from 'react'; +import { ModalContext } from './ModalContext'; + +export const useModalRefElement = () => { + const { ref } = useContext(ModalContext); + return ref; +}; diff --git a/packages/sdk/src/components/grid-enhancements/hooks/use-grid-async-records.ts b/packages/sdk/src/components/grid-enhancements/hooks/use-grid-async-records.ts index 46a973f9b9..0fcf7d9b60 100644 --- a/packages/sdk/src/components/grid-enhancements/hooks/use-grid-async-records.ts +++ b/packages/sdk/src/components/grid-enhancements/hooks/use-grid-async-records.ts @@ -15,6 +15,7 @@ type IRes = { recordMap: IRecordIndexMap; onReset: () => void; onForceUpdate: () => void; + recordsQuery: IGetRecordsRo; onVisibleRegionChanged: NonNullable; }; @@ -133,6 +134,7 @@ export const useGridAsyncRecords = ( groupPoints, recordMap: loadedRecordMap, onVisibleRegionChanged, + recordsQuery, onForceUpdate, onReset, }; diff --git a/packages/sdk/src/components/grid/Grid.tsx b/packages/sdk/src/components/grid/Grid.tsx index c56141c13a..bba38130d7 100644 --- a/packages/sdk/src/components/grid/Grid.tsx +++ b/packages/sdk/src/components/grid/Grid.tsx @@ -123,6 +123,7 @@ export interface IGridExternalProps { export interface IGridProps extends IGridExternalProps { columns: IGridColumn[]; + commentCountMap?: Record; freezeColumnCount?: number; rowCount: number; rowHeight?: number; @@ -160,6 +161,7 @@ const { const GridBase: ForwardRefRenderFunction = (props, forwardRef) => { const { columns, + commentCountMap, groupCollection, collapsedGroupIds, draggable = DraggableType.All, @@ -535,6 +537,7 @@ const GridBase: ForwardRefRenderFunction = (props, forward height={height} theme={theme} columns={columns} + commentCountMap={commentCountMap} mouseState={mouseState} scrollState={scrollState} rowControls={rowControls} @@ -569,6 +572,7 @@ const GridBase: ForwardRefRenderFunction = (props, forward height={height} theme={theme} columns={columns} + commentCountMap={commentCountMap} draggable={draggable} selectable={selectable} collaborators={collaborators} diff --git a/packages/sdk/src/components/grid/InteractionLayer.tsx b/packages/sdk/src/components/grid/InteractionLayer.tsx index 465c672a4a..85d5588b46 100644 --- a/packages/sdk/src/components/grid/InteractionLayer.tsx +++ b/packages/sdk/src/components/grid/InteractionLayer.tsx @@ -98,6 +98,7 @@ export const InteractionLayerBase: ForwardRefRenderFunction< width, height, columns, + commentCountMap, draggable, selectable, rowControls, @@ -699,6 +700,7 @@ export const InteractionLayerBase: ForwardRefRenderFunction< width={width} height={height} columns={columns} + commentCountMap={commentCountMap} columnStatistics={columnStatistics} coordInstance={coordInstance} rowControls={rowControls} diff --git a/packages/sdk/src/components/grid/RenderLayer.tsx b/packages/sdk/src/components/grid/RenderLayer.tsx index 392b36b6fa..17956fbf22 100644 --- a/packages/sdk/src/components/grid/RenderLayer.tsx +++ b/packages/sdk/src/components/grid/RenderLayer.tsx @@ -24,6 +24,7 @@ export interface IRenderLayerProps | 'width' | 'height' | 'columns' + | 'commentCountMap' | 'rowControls' | 'imageManager' | 'spriteManager' @@ -66,6 +67,7 @@ export const RenderLayer: FC> = (prop width, height, columns, + commentCountMap, isEditing, rowControls, visibleRegion, @@ -139,6 +141,7 @@ export const RenderLayer: FC> = (prop width, height, columns, + commentCountMap, isEditing, rowControls, visibleRegion, @@ -180,6 +183,7 @@ export const RenderLayer: FC> = (prop width, height, columns, + commentCountMap, isEditing, rowControls, visibleRegion, diff --git a/packages/sdk/src/components/grid/renderers/layout-renderer/interface.ts b/packages/sdk/src/components/grid/renderers/layout-renderer/interface.ts index 8d26a94199..9898becef3 100644 --- a/packages/sdk/src/components/grid/renderers/layout-renderer/interface.ts +++ b/packages/sdk/src/components/grid/renderers/layout-renderer/interface.ts @@ -26,6 +26,7 @@ export interface IRowHeaderDrawerProps extends IRectangle { isHover?: boolean; isChecked?: boolean; rowIndexVisible?: boolean; + commentCount?: number; } export interface IGroupRowHeaderDrawerProps extends IRectangle { diff --git a/packages/sdk/src/components/grid/renderers/layout-renderer/layoutRenderer.ts b/packages/sdk/src/components/grid/renderers/layout-renderer/layoutRenderer.ts index 837eea3a92..ce06b375b8 100644 --- a/packages/sdk/src/components/grid/renderers/layout-renderer/layoutRenderer.ts +++ b/packages/sdk/src/components/grid/renderers/layout-renderer/layoutRenderer.ts @@ -1,5 +1,6 @@ import { contractColorForTheme } from '@teable/core'; import { isEqual, groupBy, cloneDeep } from 'lodash'; +import type { IGridTheme } from '../../configs'; import { GRID_DEFAULT, ROW_RELATED_REGIONS } from '../../configs'; import type { IVisibleRegion } from '../../hooks'; import { getDropTargetIndex } from '../../hooks'; @@ -110,6 +111,7 @@ export const calcCells = (props: ILayoutDrawerProps, renderRegion: RenderRegion) hoverCellPosition, theme, columns, + commentCountMap, imageManager, spriteManager, groupCollection, @@ -171,6 +173,9 @@ export const calcCells = (props: ILayoutDrawerProps, renderRegion: RenderRegion) const rowHeight = coordInstance.getRowHeight(rowIndex); const y = coordInstance.getRowOffset(rowIndex) - scrollTop; + const cell = getCellContent([columnIndex, linearRow.realIndex]); + const recordId = cell.id?.split('-')[0]; + if (linearRowType === LinearRowType.Group) { const { depth, value, isCollapsed } = linearRow; if (isFirstColumn) { @@ -263,6 +268,7 @@ export const calcCells = (props: ILayoutDrawerProps, renderRegion: RenderRegion) rowControls, theme, spriteManager, + commentCount: recordId ? commentCountMap?.[recordId] : undefined, }); } @@ -813,6 +819,7 @@ export const drawRowHeader = (ctx: CanvasRenderingContext2D, props: IRowHeaderDr rowControls, spriteManager, rowIndexVisible, + commentCount, } = props; const { @@ -854,6 +861,19 @@ export const drawRowHeader = (ctx: CanvasRenderingContext2D, props: IRowHeaderDr }); const halfSize = iconSizeXS / 2; + ctx.font = `${10}px ${theme.fontFamily}`; + + if (commentCount) { + const controlSize = width / rowControls.length; + const offsetX = controlSize * (2 + 0.5); + drawCommentCount(ctx, { + x: x + offsetX - halfSize, + y: y + rowHeadIconPaddingTop, + count: commentCount, + theme, + }); + } + if (isChecked || isHover || !rowIndexVisible) { const controlSize = width / rowControls.length; for (let i = 0; i < rowControls.length; i++) { @@ -871,17 +891,20 @@ export const drawRowHeader = (ctx: CanvasRenderingContext2D, props: IRowHeaderDr }); } else { if (isChecked && !isHover && rowIndexVisible && type === RowControlType.Expand) continue; - spriteManager.drawSprite(ctx, { - sprite: icon || spriteIconMap[type], - x: x + offsetX - halfSize, - y: y + rowHeadIconPaddingTop, - size: iconSizeXS, - theme, - }); + if (!commentCount || type !== RowControlType.Expand) { + spriteManager.drawSprite(ctx, { + sprite: icon || spriteIconMap[type], + x: x + offsetX - halfSize, + y: y + rowHeadIconPaddingTop, + size: iconSizeXS, + theme, + }); + } } } return; } + drawSingleLineText(ctx, { x: x + width / 2, y: y + cellVerticalPaddingMD + 1, @@ -891,6 +914,38 @@ export const drawRowHeader = (ctx: CanvasRenderingContext2D, props: IRowHeaderDr }); }; +export const drawCommentCount = ( + ctx: CanvasRenderingContext2D, + props: { + x: number; + y: number; + count: number; + theme: IGridTheme; + } +) => { + drawRect(ctx, { + ...props, + x: props.x, + y: props.y, + width: 18, + height: 16, + stroke: 'rgb(251 146 60)', + radius: 3, + fill: 'rgb(251 146 60)', + }); + + drawSingleLineText(ctx, { + ...props, + x: props.x + 9, + y: props.y + 3.5, + text: props.count > 99 ? '99+' : props.count.toString(), + textAlign: 'center', + verticalAlign: 'middle', + fontSize: 10, + fill: '#fff', + }); +}; + export const drawColumnHeader = (ctx: CanvasRenderingContext2D, props: IFieldHeadDrawerProps) => { const { x, y, width, height, theme, fill, column, hasMenu, spriteManager } = props; const { name, icon, description, hasMenu: hasColumnMenu, isPrimary } = column; diff --git a/packages/sdk/src/config/local-storage-keys.ts b/packages/sdk/src/config/local-storage-keys.ts index 7156d4f34a..fd680a5381 100644 --- a/packages/sdk/src/config/local-storage-keys.ts +++ b/packages/sdk/src/config/local-storage-keys.ts @@ -1,6 +1,7 @@ export enum LocalStorageKeys { FieldSystem = 'ls_field_system', RecordHistoryVisible = 'ls_record_history_visible', + CommentVisible = 'ls_comment_visible', ExpandRecordPanelSize = 'ls_expand_record_panel_size', DashboardKey = 'ls_dashboard_key', ViewFromData = 'ls_view_form_data', diff --git a/packages/sdk/src/config/react-query-keys.ts b/packages/sdk/src/config/react-query-keys.ts index 4178b8163a..d6f76977bb 100644 --- a/packages/sdk/src/config/react-query-keys.ts +++ b/packages/sdk/src/config/react-query-keys.ts @@ -8,6 +8,7 @@ import type { IQueryBaseRo, ResourceType, ListSpaceCollaboratorRo, + IGetRecordsRo, } from '@teable/openapi'; export const ReactQueryKeys = { @@ -23,6 +24,23 @@ export const ReactQueryKeys = { tableList: (baseId: string) => ['table-list', baseId] as const, + recordCommentCount: (tableId: string, recordId: string) => + ['record-comment-count', tableId, recordId] as const, + + commentList: (tableId: string, recordId: string) => ['comment-list', tableId, recordId] as const, + + commentCount: (tableId: string, query?: IGetRecordsRo) => + ['comment-count', tableId, query] as const, + + commentDetail: (tableId: string, recordId: string, commentId: string) => + ['comment-detail', tableId, recordId, commentId] as const, + + commentAttachment: (tableId: string, recordId: string, path: string) => + ['comment-attachment', tableId, recordId, path] as const, + + commentSubscribeStatus: (tableId: string, recordId: string) => + ['comment-notify-status', tableId, recordId] as const, + subscriptionSummary: (spaceId: string) => ['subscription-summary', spaceId] as const, subscriptionSummaryList: () => ['subscription-summary'] as const, diff --git a/packages/sdk/src/hooks/index.ts b/packages/sdk/src/hooks/index.ts index 9c978751ac..7c657fad05 100644 --- a/packages/sdk/src/hooks/index.ts +++ b/packages/sdk/src/hooks/index.ts @@ -35,3 +35,4 @@ export * from './use-view-listener'; export * from './use-lan-dayjs'; export * from './use-base-id'; export * from './use-undo-redo'; +export * from './use-comment-count-map'; diff --git a/packages/sdk/src/hooks/use-comment-count-map.ts b/packages/sdk/src/hooks/use-comment-count-map.ts new file mode 100644 index 0000000000..a4f3e8cab0 --- /dev/null +++ b/packages/sdk/src/hooks/use-comment-count-map.ts @@ -0,0 +1,98 @@ +import { useQuery } from '@tanstack/react-query'; +import { IdPrefix, getTableCommentChannel } from '@teable/core'; +import type { IGetRecordsRo, ICommentCountVo } from '@teable/openapi'; +import { getCommentCount, CommentPatchType } from '@teable/openapi'; +import { get } from 'lodash'; +import { useMemo, useEffect, useState } from 'react'; +import { ReactQueryKeys } from '../config'; +import { useConnection } from './use-connection'; +import { useSearch } from './use-search'; +import { useTableId } from './use-table-id'; +import { useView } from './use-view'; +import { useViewId } from './use-view-id'; + +export const useCommentCountMap = (query?: IGetRecordsRo) => { + const tableId = useTableId(); + + const viewId = useViewId(); + + const view = useView(); + + const { searchQuery } = useSearch(); + + const { connection } = useConnection(); + + const queryParams = useMemo(() => { + return { + viewId, + search: searchQuery, + type: IdPrefix.Record, + ...query, + groupBy: query?.groupBy ? JSON.stringify(query?.groupBy) : query?.groupBy, + filter: view?.filter ? JSON.stringify(view?.filter) : view?.filter, + orderBy: view?.sort?.sortObjs ? JSON.stringify(view?.sort?.sortObjs) : view?.sort?.sortObjs, + } as IGetRecordsRo; + }, [query, searchQuery, viewId, view]); + + const { data } = useQuery({ + queryKey: ReactQueryKeys.commentCount(tableId!, queryParams), + queryFn: () => getCommentCount(tableId!, queryParams).then(({ data }) => data), + enabled: !!tableId, + }); + + const [commentCount, setCommentCount] = useState([]); + + useEffect(() => { + data && setCommentCount(data); + }, [data]); + + useEffect(() => { + if (!tableId) { + return; + } + + const presenceKey = getTableCommentChannel(tableId); + const presence = connection.getPresence(presenceKey); + + if (!presence || !connection) { + return; + } + + presence.subscribe(); + + const receiveHandler = () => { + const { remotePresences } = presence; + const remoteData = get(remotePresences, presenceKey); + if (remoteData) { + const remoteRecordId = remoteData.data.recordId; + setCommentCount((pre) => { + const index = pre.findIndex((com) => com.recordId === remoteRecordId); + if (index > -1) { + remoteData.type === CommentPatchType.CreateComment && pre[index].count++; + remoteData.type === CommentPatchType.DeleteComment && pre[index].count--; + pre?.[index].count === 0 && pre.splice(index, 1); + } else { + remoteData.type === CommentPatchType.CreateComment && + pre.push({ + recordId: remoteRecordId, + count: 1, + }); + } + return [...pre]; + }); + } + }; + + presence.on('receive', receiveHandler); + + return () => { + presence?.removeListener('receive', receiveHandler); + presence?.listenerCount('receive') === 0 && presence?.unsubscribe(); + presence?.listenerCount('receive') === 0 && presence?.destroy(); + }; + }, [connection, tableId]); + + return useMemo(() => { + return Object.fromEntries(commentCount.map((item) => [item.recordId, item.count])); + }, [commentCount]); +}; diff --git a/packages/sdk/tsconfig.json b/packages/sdk/tsconfig.json index b7e48a4eb9..f1d9aa1241 100644 --- a/packages/sdk/tsconfig.json +++ b/packages/sdk/tsconfig.json @@ -16,6 +16,7 @@ "composite": true, "sourceMap": true, "incremental": true, + "moduleResolution": "Bundler", "paths": { "@teable/icons": ["../icons/src"], "@teable/core": ["../core/src"], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0d806f918..ebf66cde5d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1309,6 +1309,9 @@ importers: packages/sdk: dependencies: + '@ariakit/react': + specifier: 0.4.10 + version: 0.4.10(react-dom@18.2.0)(react@18.2.0) '@belgattitude/http-exception': specifier: 1.5.0 version: 1.5.0 @@ -1342,6 +1345,15 @@ importers: '@radix-ui/react-icons': specifier: 1.3.0 version: 1.3.0(react@18.2.0) + '@radix-ui/react-separator': + specifier: 1.0.3 + version: 1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-toolbar': + specifier: 1.1.0 + version: 1.1.0(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-tooltip': + specifier: 1.0.7 + version: 1.0.7(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) '@tanstack/react-query': specifier: 4.36.1 version: 4.36.1(react-dom@18.2.0)(react@18.2.0) @@ -1369,6 +1381,51 @@ importers: '@teable/ui-lib': specifier: workspace:* version: link:../ui-lib + '@udecode/cn': + specifier: 37.0.0 + version: 37.0.0(@types/react@18.2.69)(class-variance-authority@0.7.0)(react-dom@18.2.0)(react@18.2.0)(tailwind-merge@2.2.2) + '@udecode/plate-alignment': + specifier: 37.0.0 + version: 37.0.0(@udecode/plate-common@37.0.0)(react-dom@18.2.0)(react@18.2.0)(slate-history@0.109.0)(slate-hyperscript@0.100.0)(slate-react@0.109.0)(slate@0.103.0) + '@udecode/plate-combobox': + specifier: 37.0.0 + version: 37.0.0(@udecode/plate-common@37.0.0)(react-dom@18.2.0)(react@18.2.0)(slate-history@0.109.0)(slate-hyperscript@0.100.0)(slate-react@0.109.0)(slate@0.103.0) + '@udecode/plate-common': + specifier: 37.0.0 + version: 37.0.0(@types/react@18.2.69)(immer@10.0.4)(react-dom@18.2.0)(react@18.2.0)(scheduler@0.23.0)(slate-history@0.109.0)(slate-hyperscript@0.100.0)(slate-react@0.109.0)(slate@0.103.0) + '@udecode/plate-core': + specifier: 37.0.7 + version: 37.0.7(@types/react@18.2.69)(immer@10.0.4)(react-dom@18.2.0)(react@18.2.0)(scheduler@0.23.0)(slate-history@0.109.0)(slate-hyperscript@0.100.0)(slate-react@0.109.0)(slate@0.103.0) + '@udecode/plate-floating': + specifier: 37.0.0 + version: 37.0.0(@udecode/plate-common@37.0.0)(react-dom@18.2.0)(react@18.2.0)(slate-history@0.109.0)(slate-hyperscript@0.100.0)(slate-react@0.109.0)(slate@0.103.0) + '@udecode/plate-heading': + specifier: 37.0.0 + version: 37.0.0(@udecode/plate-common@37.0.0)(react-dom@18.2.0)(react@18.2.0)(slate-history@0.109.0)(slate-hyperscript@0.100.0)(slate-react@0.109.0)(slate@0.103.0) + '@udecode/plate-image': + specifier: 16.0.1 + version: 16.0.1 + '@udecode/plate-link': + specifier: 37.0.0 + version: 37.0.0(@udecode/plate-common@37.0.0)(react-dom@18.2.0)(react@18.2.0)(slate-history@0.109.0)(slate-hyperscript@0.100.0)(slate-react@0.109.0)(slate@0.103.0) + '@udecode/plate-media': + specifier: 37.0.0 + version: 37.0.0(@udecode/plate-common@37.0.0)(react-dom@18.2.0)(react@18.2.0)(slate-history@0.109.0)(slate-hyperscript@0.100.0)(slate-react@0.109.0)(slate@0.103.0) + '@udecode/plate-mention': + specifier: 37.0.0 + version: 37.0.0(@udecode/plate-common@37.0.0)(react-dom@18.2.0)(react@18.2.0)(slate-history@0.109.0)(slate-hyperscript@0.100.0)(slate-react@0.109.0)(slate@0.103.0) + '@udecode/plate-resizable': + specifier: 37.0.0 + version: 37.0.0(@udecode/plate-common@37.0.0)(react-dom@18.2.0)(react@18.2.0)(slate-history@0.109.0)(slate-hyperscript@0.100.0)(slate-react@0.109.0)(slate@0.103.0) + '@udecode/plate-select': + specifier: 37.0.0 + version: 37.0.0(@udecode/plate-common@37.0.0)(react-dom@18.2.0)(react@18.2.0)(slate-history@0.109.0)(slate-hyperscript@0.100.0)(slate-react@0.109.0)(slate@0.103.0) + '@udecode/plate-slash-command': + specifier: 37.0.0 + version: 37.0.0(@udecode/plate-common@37.0.0)(react-dom@18.2.0)(react@18.2.0)(slate-history@0.109.0)(slate-hyperscript@0.100.0)(slate-react@0.109.0)(slate@0.103.0) + '@udecode/plate-trailing-block': + specifier: 37.0.0 + version: 37.0.0(@udecode/plate-common@37.0.0)(react-dom@18.2.0)(react@18.2.0)(slate-history@0.109.0)(slate-hyperscript@0.100.0)(slate-react@0.109.0)(slate@0.103.0) antlr4ts: specifier: 0.5.0-alpha.4 version: 0.5.0-alpha.4 @@ -1378,6 +1435,9 @@ importers: class-transformer: specifier: 0.5.1 version: 0.5.1 + class-variance-authority: + specifier: 0.7.0 + version: 0.7.0 date-fns: specifier: 2.30.0 version: 2.30.0 @@ -1435,6 +1495,18 @@ importers: sharedb: specifier: 4.1.2 version: 4.1.2 + slate: + specifier: 0.103.0 + version: 0.103.0 + slate-history: + specifier: 0.109.0 + version: 0.109.0(slate@0.103.0) + slate-hyperscript: + specifier: 0.100.0 + version: 0.100.0(slate@0.103.0) + slate-react: + specifier: 0.109.0 + version: 0.109.0(react-dom@18.2.0)(react@18.2.0)(slate@0.103.0) ts-key-enum: specifier: 2.0.12 version: 2.0.12 @@ -2179,6 +2251,34 @@ packages: tslib: 2.6.2 dev: false + /@ariakit/core@0.4.9: + resolution: {integrity: sha512-nV0B/OTK/0iB+P9RC7fudznYZ8eR6rR1F912Zc54e3+wSW5RrRvNOiRxyMrgENidd4R7cCMDw77XJLSBLKgEPQ==} + dev: false + + /@ariakit/react-core@0.4.10(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-r6DZmtHBmSoOj848+RpBwdZy/55YxPhMhfH14JIO2OLn1F6iSFkQwR7AAGpIrlYycWJFSF7KrQu50O+SSfFJdQ==} + peerDependencies: + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + dependencies: + '@ariakit/core': 0.4.9 + '@floating-ui/dom': 1.6.3 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + use-sync-external-store: 1.2.0(react@18.2.0) + dev: false + + /@ariakit/react@0.4.10(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-c1+6sNLj57aAXrBZMCVGG+OXeFrPAG0TV1jT7oPJcN/KLRs3aCuO3CCJVep/eKepFzzK01kNRGYX3wPT1TXPNw==} + peerDependencies: + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + dependencies: + '@ariakit/react-core': 0.4.10(react-dom@18.2.0)(react@18.2.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@asteasolutions/zod-to-openapi@6.4.0(zod@3.22.4): resolution: {integrity: sha512-8cxfF7AHHx2PqnN4Cd8/O8CBu/nVYJP9DpnfVLW3BFb66VJDnqI/CczZnkqMc3SNh6J9GiX7JbJ5T4BSP4HZ2Q==} peerDependencies: @@ -5789,6 +5889,17 @@ packages: '@floating-ui/utils': 0.2.1 dev: false + /@floating-ui/react-dom@1.3.0(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-htwHm67Ji5E/pROEAr7f8IKFShuiCKHwUC/UY4vC3I5jiSvGFAYnSYiZO5MlGmads+QqvUkR9ANHEguGrDv72g==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + dependencies: + '@floating-ui/dom': 1.6.3 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@floating-ui/react-dom@2.0.8(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-HOdqOt3R3OGeTKidaLvJKcgg75S6tibQ3Tif4eyd91QnIJWr0NLvoXFpJA/j8HqkFSL68GDca9AuyWEHlhyClw==} peerDependencies: @@ -5800,6 +5911,19 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false + /@floating-ui/react@0.22.3(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-RlF+7yU3/abTZcUez44IHoEH89yDHHonkYzZocynTWbl6J6MiMINMbyZSmSKdRKdadrC+MwQLdEexu++irvZhQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + dependencies: + '@floating-ui/react-dom': 1.3.0(react-dom@18.2.0)(react@18.2.0) + aria-hidden: 1.2.4 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + tabbable: 6.2.0 + dev: false + /@floating-ui/utils@0.2.1: resolution: {integrity: sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==} dev: false @@ -8211,6 +8335,26 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false + /@radix-ui/react-separator@1.1.0(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-3uBAs+egzvJBDZAzvb/n4NxxOYpnspmWxO2u5NbZ8Y6FM/NdrGSF9bop3Cf6F6C71z1rTSn8KV0Fo2ZVd79lGA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) + '@types/react': 18.2.69 + '@types/react-dom': 18.2.22 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@radix-ui/react-slot@1.0.2(@types/react@18.2.69)(react@18.2.0): resolution: {integrity: sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==} peerDependencies: @@ -8397,6 +8541,32 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false + /@radix-ui/react-toolbar@1.1.0(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-ZUKknxhMTL/4hPh+4DuaTot9aO7UD6Kupj4gqXCsBTayX1pD1L+0C2/2VZKXb4tIifQklZ3pf2hG9T+ns+FclQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-context': 1.1.0(@types/react@18.2.69)(react@18.2.0) + '@radix-ui/react-direction': 1.1.0(@types/react@18.2.69)(react@18.2.0) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-roving-focus': 1.1.0(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-separator': 1.1.0(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-toggle-group': 1.1.0(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) + '@types/react': 18.2.69 + '@types/react-dom': 18.2.22 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@radix-ui/react-tooltip@1.0.7(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-lPh5iKNFVQ/jav/j6ZrWq3blfDJ0OH9R6FlNUHPMqdLuQ9vwDgFsRxvl8b7Asuy5c8xmoojHUxKHQSOAvMHxyw==} peerDependencies: @@ -11884,6 +12054,493 @@ packages: '@typescript-eslint/types': 7.3.1 eslint-visitor-keys: 3.4.3 + /@udecode/cn@37.0.0(@types/react@18.2.69)(class-variance-authority@0.7.0)(react-dom@18.2.0)(react@18.2.0)(tailwind-merge@2.2.2): + resolution: {integrity: sha512-qZv22asJ17m+LmrkDRI5F4wAooY69lSwAFfKEbBZhpgWeSB31VZzhT1Xr+bxpeL7Xi7i46mtuK1RdiS1Jw5PgA==} + peerDependencies: + class-variance-authority: '>=0.7.0' + react: '>=16.8.0' + react-dom: '>=16.8.0' + tailwind-merge: '>=2.2.0' + dependencies: + '@udecode/react-utils': 37.0.0(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) + class-variance-authority: 0.7.0 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + tailwind-merge: 2.2.2 + transitivePeerDependencies: + - '@types/react' + dev: false + + /@udecode/plate-alignment@37.0.0(@udecode/plate-common@37.0.0)(react-dom@18.2.0)(react@18.2.0)(slate-history@0.109.0)(slate-hyperscript@0.100.0)(slate-react@0.109.0)(slate@0.103.0): + resolution: {integrity: sha512-ytmC6ZYhePn2c0ZgDABk/CRznhSKe3sAsQ68V1FcXRuCiF4NrWJxOlOwWXZm55FwJeGdgPgUwUNxA+7gIZYhOA==} + peerDependencies: + '@udecode/plate-common': '>=37.0.0' + react: '>=16.8.0' + react-dom: '>=16.8.0' + slate: '>=0.103.0' + slate-history: '>=0.93.0' + slate-hyperscript: '>=0.66.0' + slate-react: '>=0.108.0' + dependencies: + '@udecode/plate-common': 37.0.0(@types/react@18.2.69)(immer@10.0.4)(react-dom@18.2.0)(react@18.2.0)(scheduler@0.23.0)(slate-history@0.109.0)(slate-hyperscript@0.100.0)(slate-react@0.109.0)(slate@0.103.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + slate: 0.103.0 + slate-history: 0.109.0(slate@0.103.0) + slate-hyperscript: 0.100.0(slate@0.103.0) + slate-react: 0.109.0(react-dom@18.2.0)(react@18.2.0)(slate@0.103.0) + dev: false + + /@udecode/plate-combobox@37.0.0(@udecode/plate-common@37.0.0)(react-dom@18.2.0)(react@18.2.0)(slate-history@0.109.0)(slate-hyperscript@0.100.0)(slate-react@0.109.0)(slate@0.103.0): + resolution: {integrity: sha512-6dFQEks8eD6hl3p418B0PpE13uVuc/uzZa2ZVUO//uytXrS96LQjBtRhNi8MIkcFNhgdfClAD+DReZ+TEARa8A==} + peerDependencies: + '@udecode/plate-common': '>=37.0.0' + react: '>=16.8.0' + react-dom: '>=16.8.0' + slate: '>=0.103.0' + slate-history: '>=0.93.0' + slate-hyperscript: '>=0.66.0' + slate-react: '>=0.108.0' + dependencies: + '@udecode/plate-common': 37.0.0(@types/react@18.2.69)(immer@10.0.4)(react-dom@18.2.0)(react@18.2.0)(scheduler@0.23.0)(slate-history@0.109.0)(slate-hyperscript@0.100.0)(slate-react@0.109.0)(slate@0.103.0) + downshift: 6.1.12(react@18.2.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + slate: 0.103.0 + slate-history: 0.109.0(slate@0.103.0) + slate-hyperscript: 0.100.0(slate@0.103.0) + slate-react: 0.109.0(react-dom@18.2.0)(react@18.2.0)(slate@0.103.0) + dev: false + + /@udecode/plate-common@37.0.0(@types/react@18.2.69)(immer@10.0.4)(react-dom@18.2.0)(react@18.2.0)(scheduler@0.23.0)(slate-history@0.109.0)(slate-hyperscript@0.100.0)(slate-react@0.109.0)(slate@0.103.0): + resolution: {integrity: sha512-Fcc1TVIE54MOi4xU8w1w0YcfVY5dNIbJK5TPSc9MGFTZdF8SF4Sce3S7BmzJwagud5lyqs77KmEFceBA7hHCmQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + slate: '>=0.103.0' + slate-history: '>=0.93.0' + slate-hyperscript: '>=0.66.0' + slate-react: '>=0.108.0' + dependencies: + '@udecode/plate-core': 37.0.0(@types/react@18.2.69)(immer@10.0.4)(react-dom@18.2.0)(react@18.2.0)(scheduler@0.23.0)(slate-history@0.109.0)(slate-hyperscript@0.100.0)(slate-react@0.109.0)(slate@0.103.0) + '@udecode/plate-utils': 37.0.0(@types/react@18.2.69)(immer@10.0.4)(react-dom@18.2.0)(react@18.2.0)(scheduler@0.23.0)(slate-history@0.109.0)(slate-hyperscript@0.100.0)(slate-react@0.109.0)(slate@0.103.0) + '@udecode/react-hotkeys': 37.0.0(react-dom@18.2.0)(react@18.2.0) + '@udecode/react-utils': 37.0.0(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) + '@udecode/slate': 37.0.0(slate-history@0.109.0)(slate@0.103.0) + '@udecode/slate-react': 37.0.0(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0)(slate-history@0.109.0)(slate-react@0.109.0)(slate@0.103.0) + '@udecode/slate-utils': 37.0.0(slate-history@0.109.0)(slate@0.103.0) + '@udecode/utils': 37.0.0 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + slate: 0.103.0 + slate-history: 0.109.0(slate@0.103.0) + slate-hyperscript: 0.100.0(slate@0.103.0) + slate-react: 0.109.0(react-dom@18.2.0)(react@18.2.0)(slate@0.103.0) + transitivePeerDependencies: + - '@types/react' + - immer + - react-native + - scheduler + dev: false + + /@udecode/plate-core@37.0.0(@types/react@18.2.69)(immer@10.0.4)(react-dom@18.2.0)(react@18.2.0)(scheduler@0.23.0)(slate-history@0.109.0)(slate-hyperscript@0.100.0)(slate-react@0.109.0)(slate@0.103.0): + resolution: {integrity: sha512-UXbK7M0X/c82vxLC2bFWZwhJFEK3PsDYtBMO3TI2jBJeRmYs9YadZGJN6jOGa4EaSO2d1iMLkj7CmOgyvKkC8w==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + slate: '>=0.103.0' + slate-history: '>=0.93.0' + slate-hyperscript: '>=0.66.0' + slate-react: '>=0.108.0' + dependencies: + '@udecode/react-hotkeys': 37.0.0(react-dom@18.2.0)(react@18.2.0) + '@udecode/react-utils': 37.0.0(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) + '@udecode/slate': 37.0.0(slate-history@0.109.0)(slate@0.103.0) + '@udecode/slate-react': 37.0.0(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0)(slate-history@0.109.0)(slate-react@0.109.0)(slate@0.103.0) + '@udecode/slate-utils': 37.0.0(slate-history@0.109.0)(slate@0.103.0) + '@udecode/utils': 37.0.0 + clsx: 1.2.1 + is-hotkey: 0.2.0 + jotai: 2.9.3(@types/react@18.2.69)(react@18.2.0) + jotai-optics: 0.3.2(jotai@2.9.3)(optics-ts@2.4.1) + jotai-x: 1.2.4(@types/react@18.2.69)(jotai@2.9.3)(react@18.2.0) + lodash: 4.17.21 + nanoid: 3.3.7 + optics-ts: 2.4.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + slate: 0.103.0 + slate-history: 0.109.0(slate@0.103.0) + slate-hyperscript: 0.100.0(slate@0.103.0) + slate-react: 0.109.0(react-dom@18.2.0)(react@18.2.0)(slate@0.103.0) + use-deep-compare: 1.3.0(react@18.2.0) + zustand: 4.5.2(@types/react@18.2.69)(immer@10.0.4)(react@18.2.0) + zustand-x: 3.0.4(react-dom@18.2.0)(react@18.2.0)(scheduler@0.23.0)(zustand@4.5.2) + transitivePeerDependencies: + - '@types/react' + - immer + - react-native + - scheduler + dev: false + + /@udecode/plate-core@37.0.7(@types/react@18.2.69)(immer@10.0.4)(react-dom@18.2.0)(react@18.2.0)(scheduler@0.23.0)(slate-history@0.109.0)(slate-hyperscript@0.100.0)(slate-react@0.109.0)(slate@0.103.0): + resolution: {integrity: sha512-p8gIrDCX/7EG8eY/wM0WJ91eCIA98tugfuQc+rbVzCnU7Zq5YUJQKoOw4jFYAZqf1HQ34m6jYCPdRRv3cSxpbA==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + slate: '>=0.103.0' + slate-history: '>=0.93.0' + slate-hyperscript: '>=0.66.0' + slate-react: '>=0.108.0' + dependencies: + '@udecode/react-hotkeys': 37.0.0(react-dom@18.2.0)(react@18.2.0) + '@udecode/react-utils': 37.0.0(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) + '@udecode/slate': 37.0.0(slate-history@0.109.0)(slate@0.103.0) + '@udecode/slate-react': 37.0.0(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0)(slate-history@0.109.0)(slate-react@0.109.0)(slate@0.103.0) + '@udecode/slate-utils': 37.0.0(slate-history@0.109.0)(slate@0.103.0) + '@udecode/utils': 37.0.0 + clsx: 1.2.1 + is-hotkey: 0.2.0 + jotai: 2.9.3(@types/react@18.2.69)(react@18.2.0) + jotai-optics: 0.3.2(jotai@2.9.3)(optics-ts@2.4.1) + jotai-x: 1.2.4(@types/react@18.2.69)(jotai@2.9.3)(react@18.2.0) + lodash: 4.17.21 + nanoid: 3.3.7 + optics-ts: 2.4.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + slate: 0.103.0 + slate-history: 0.109.0(slate@0.103.0) + slate-hyperscript: 0.100.0(slate@0.103.0) + slate-react: 0.109.0(react-dom@18.2.0)(react@18.2.0)(slate@0.103.0) + use-deep-compare: 1.3.0(react@18.2.0) + zustand: 4.5.2(@types/react@18.2.69)(immer@10.0.4)(react@18.2.0) + zustand-x: 3.0.4(react-dom@18.2.0)(react@18.2.0)(scheduler@0.23.0)(zustand@4.5.2) + transitivePeerDependencies: + - '@types/react' + - immer + - react-native + - scheduler + dev: false + + /@udecode/plate-floating@37.0.0(@udecode/plate-common@37.0.0)(react-dom@18.2.0)(react@18.2.0)(slate-history@0.109.0)(slate-hyperscript@0.100.0)(slate-react@0.109.0)(slate@0.103.0): + resolution: {integrity: sha512-X2+MyRZXFmY9jV6AgE+D5JKTY3E5bE7PCNNqP0JKwVNhcRqtmqyHy8gSQfj2dISxxeO9nJIL+4K5+F51SO21/w==} + peerDependencies: + '@udecode/plate-common': '>=37.0.0' + react: '>=16.8.0' + react-dom: '>=16.8.0' + slate: '>=0.103.0' + slate-history: '>=0.93.0' + slate-hyperscript: '>=0.66.0' + slate-react: '>=0.108.0' + dependencies: + '@floating-ui/core': 1.6.0 + '@floating-ui/react': 0.22.3(react-dom@18.2.0)(react@18.2.0) + '@udecode/plate-common': 37.0.0(@types/react@18.2.69)(immer@10.0.4)(react-dom@18.2.0)(react@18.2.0)(scheduler@0.23.0)(slate-history@0.109.0)(slate-hyperscript@0.100.0)(slate-react@0.109.0)(slate@0.103.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + slate: 0.103.0 + slate-history: 0.109.0(slate@0.103.0) + slate-hyperscript: 0.100.0(slate@0.103.0) + slate-react: 0.109.0(react-dom@18.2.0)(react@18.2.0)(slate@0.103.0) + dev: false + + /@udecode/plate-heading@37.0.0(@udecode/plate-common@37.0.0)(react-dom@18.2.0)(react@18.2.0)(slate-history@0.109.0)(slate-hyperscript@0.100.0)(slate-react@0.109.0)(slate@0.103.0): + resolution: {integrity: sha512-ZJNM6ufdKZ7U+ZpE+lbRbpgl6q97WZOafLW4dyVJpGVS6DZUdixpBOIv0uvLA0CiaoV6grOayoJyjB06ru0w9Q==} + peerDependencies: + '@udecode/plate-common': '>=37.0.0' + react: '>=16.8.0' + react-dom: '>=16.8.0' + slate: '>=0.103.0' + slate-history: '>=0.93.0' + slate-hyperscript: '>=0.66.0' + slate-react: '>=0.108.0' + dependencies: + '@udecode/plate-common': 37.0.0(@types/react@18.2.69)(immer@10.0.4)(react-dom@18.2.0)(react@18.2.0)(scheduler@0.23.0)(slate-history@0.109.0)(slate-hyperscript@0.100.0)(slate-react@0.109.0)(slate@0.103.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + slate: 0.103.0 + slate-history: 0.109.0(slate@0.103.0) + slate-hyperscript: 0.100.0(slate@0.103.0) + slate-react: 0.109.0(react-dom@18.2.0)(react@18.2.0)(slate@0.103.0) + dev: false + + /@udecode/plate-image@16.0.1: + resolution: {integrity: sha512-gYCQ/LzskvLiwGH1ZkrTRc5zJj+ttDdmgddfhRWVJWsSPLJHUVp7F5MGNTrMj/TM6vQW7Vq9sICbpaks7ykzlA==} + deprecated: This package is no longer maintained. Please use @udecode/plate-media or check GitHub for the latest Plate packages and documentation. + dev: false + + /@udecode/plate-link@37.0.0(@udecode/plate-common@37.0.0)(react-dom@18.2.0)(react@18.2.0)(slate-history@0.109.0)(slate-hyperscript@0.100.0)(slate-react@0.109.0)(slate@0.103.0): + resolution: {integrity: sha512-YF3IURwFeOoGb9vAy6WAbcPFCqjR+8ITNbE32dzWnInPyYqrPnmAuyTL4E2/QRYgz9wwGySrC8xKQZthLYynTQ==} + peerDependencies: + '@udecode/plate-common': '>=37.0.0' + react: '>=16.8.0' + react-dom: '>=16.8.0' + slate: '>=0.103.0' + slate-history: '>=0.93.0' + slate-hyperscript: '>=0.66.0' + slate-react: '>=0.108.0' + dependencies: + '@udecode/plate-common': 37.0.0(@types/react@18.2.69)(immer@10.0.4)(react-dom@18.2.0)(react@18.2.0)(scheduler@0.23.0)(slate-history@0.109.0)(slate-hyperscript@0.100.0)(slate-react@0.109.0)(slate@0.103.0) + '@udecode/plate-floating': 37.0.0(@udecode/plate-common@37.0.0)(react-dom@18.2.0)(react@18.2.0)(slate-history@0.109.0)(slate-hyperscript@0.100.0)(slate-react@0.109.0)(slate@0.103.0) + '@udecode/plate-normalizers': 37.0.0(@udecode/plate-common@37.0.0)(react-dom@18.2.0)(react@18.2.0)(slate-history@0.109.0)(slate-hyperscript@0.100.0)(slate-react@0.109.0)(slate@0.103.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + slate: 0.103.0 + slate-history: 0.109.0(slate@0.103.0) + slate-hyperscript: 0.100.0(slate@0.103.0) + slate-react: 0.109.0(react-dom@18.2.0)(react@18.2.0)(slate@0.103.0) + dev: false + + /@udecode/plate-media@37.0.0(@udecode/plate-common@37.0.0)(react-dom@18.2.0)(react@18.2.0)(slate-history@0.109.0)(slate-hyperscript@0.100.0)(slate-react@0.109.0)(slate@0.103.0): + resolution: {integrity: sha512-nFA2yHzOqblcf1Bg7rBrcKRPMKXR8EuONbbDUN0XrnCksj+9YuzXSH38HeQKXHy850ojYOO7P/4Cda8Bs8PacA==} + peerDependencies: + '@udecode/plate-common': '>=37.0.0' + react: '>=16.8.0' + react-dom: '>=16.8.0' + slate: '>=0.103.0' + slate-history: '>=0.93.0' + slate-hyperscript: '>=0.66.0' + slate-react: '>=0.108.0' + dependencies: + '@udecode/plate-common': 37.0.0(@types/react@18.2.69)(immer@10.0.4)(react-dom@18.2.0)(react@18.2.0)(scheduler@0.23.0)(slate-history@0.109.0)(slate-hyperscript@0.100.0)(slate-react@0.109.0)(slate@0.103.0) + js-video-url-parser: 0.5.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + slate: 0.103.0 + slate-history: 0.109.0(slate@0.103.0) + slate-hyperscript: 0.100.0(slate@0.103.0) + slate-react: 0.109.0(react-dom@18.2.0)(react@18.2.0)(slate@0.103.0) + dev: false + + /@udecode/plate-mention@37.0.0(@udecode/plate-common@37.0.0)(react-dom@18.2.0)(react@18.2.0)(slate-history@0.109.0)(slate-hyperscript@0.100.0)(slate-react@0.109.0)(slate@0.103.0): + resolution: {integrity: sha512-2Gbl379rHgKF3anRGrgRDmIRb7uA+KrYmvjy/ajN8NPKBVgOJO9NGZ7IyqghGE3lZJsY3Mz3rtZwVIiM1IiKyg==} + peerDependencies: + '@udecode/plate-common': '>=37.0.0' + react: '>=16.8.0' + react-dom: '>=16.8.0' + slate: '>=0.103.0' + slate-history: '>=0.93.0' + slate-hyperscript: '>=0.66.0' + slate-react: '>=0.108.0' + dependencies: + '@udecode/plate-combobox': 37.0.0(@udecode/plate-common@37.0.0)(react-dom@18.2.0)(react@18.2.0)(slate-history@0.109.0)(slate-hyperscript@0.100.0)(slate-react@0.109.0)(slate@0.103.0) + '@udecode/plate-common': 37.0.0(@types/react@18.2.69)(immer@10.0.4)(react-dom@18.2.0)(react@18.2.0)(scheduler@0.23.0)(slate-history@0.109.0)(slate-hyperscript@0.100.0)(slate-react@0.109.0)(slate@0.103.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + slate: 0.103.0 + slate-history: 0.109.0(slate@0.103.0) + slate-hyperscript: 0.100.0(slate@0.103.0) + slate-react: 0.109.0(react-dom@18.2.0)(react@18.2.0)(slate@0.103.0) + dev: false + + /@udecode/plate-normalizers@37.0.0(@udecode/plate-common@37.0.0)(react-dom@18.2.0)(react@18.2.0)(slate-history@0.109.0)(slate-hyperscript@0.100.0)(slate-react@0.109.0)(slate@0.103.0): + resolution: {integrity: sha512-cQyAg66fsmAGep9fIeIsRI3VuMvgn4mM3ZxY91JhI1tqVPi03pGgBc0vanG3wkrkvuvUhJBug3K56YGCqFdSvQ==} + peerDependencies: + '@udecode/plate-common': '>=37.0.0' + react: '>=16.8.0' + react-dom: '>=16.8.0' + slate: '>=0.103.0' + slate-history: '>=0.93.0' + slate-hyperscript: '>=0.66.0' + slate-react: '>=0.108.0' + dependencies: + '@udecode/plate-common': 37.0.0(@types/react@18.2.69)(immer@10.0.4)(react-dom@18.2.0)(react@18.2.0)(scheduler@0.23.0)(slate-history@0.109.0)(slate-hyperscript@0.100.0)(slate-react@0.109.0)(slate@0.103.0) + lodash: 4.17.21 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + slate: 0.103.0 + slate-history: 0.109.0(slate@0.103.0) + slate-hyperscript: 0.100.0(slate@0.103.0) + slate-react: 0.109.0(react-dom@18.2.0)(react@18.2.0)(slate@0.103.0) + dev: false + + /@udecode/plate-resizable@37.0.0(@udecode/plate-common@37.0.0)(react-dom@18.2.0)(react@18.2.0)(slate-history@0.109.0)(slate-hyperscript@0.100.0)(slate-react@0.109.0)(slate@0.103.0): + resolution: {integrity: sha512-odBxqMz7bZ+47q4PwqF/H7LsPw79UHB1SqK/U/ZuvQwAvkT3Z/aZrmH9EDFoeqTFKbBCDDz2mAzYg1CFvT7Xeg==} + peerDependencies: + '@udecode/plate-common': '>=37.0.0' + react: '>=16.8.0' + react-dom: '>=16.8.0' + slate: '>=0.103.0' + slate-history: '>=0.93.0' + slate-hyperscript: '>=0.66.0' + slate-react: '>=0.108.0' + dependencies: + '@udecode/plate-common': 37.0.0(@types/react@18.2.69)(immer@10.0.4)(react-dom@18.2.0)(react@18.2.0)(scheduler@0.23.0)(slate-history@0.109.0)(slate-hyperscript@0.100.0)(slate-react@0.109.0)(slate@0.103.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + slate: 0.103.0 + slate-history: 0.109.0(slate@0.103.0) + slate-hyperscript: 0.100.0(slate@0.103.0) + slate-react: 0.109.0(react-dom@18.2.0)(react@18.2.0)(slate@0.103.0) + dev: false + + /@udecode/plate-select@37.0.0(@udecode/plate-common@37.0.0)(react-dom@18.2.0)(react@18.2.0)(slate-history@0.109.0)(slate-hyperscript@0.100.0)(slate-react@0.109.0)(slate@0.103.0): + resolution: {integrity: sha512-I7a+DrNFwL5KTyDxWYllCt52y2ySsB+9a31+U9VjdukCumsrFnrCcVh0MiCEBpy7d20xfg2HbZShgMoO/HH5GA==} + peerDependencies: + '@udecode/plate-common': '>=37.0.0' + react: '>=16.8.0' + react-dom: '>=16.8.0' + slate: '>=0.103.0' + slate-history: '>=0.93.0' + slate-hyperscript: '>=0.66.0' + slate-react: '>=0.108.0' + dependencies: + '@udecode/plate-common': 37.0.0(@types/react@18.2.69)(immer@10.0.4)(react-dom@18.2.0)(react@18.2.0)(scheduler@0.23.0)(slate-history@0.109.0)(slate-hyperscript@0.100.0)(slate-react@0.109.0)(slate@0.103.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + slate: 0.103.0 + slate-history: 0.109.0(slate@0.103.0) + slate-hyperscript: 0.100.0(slate@0.103.0) + slate-react: 0.109.0(react-dom@18.2.0)(react@18.2.0)(slate@0.103.0) + dev: false + + /@udecode/plate-slash-command@37.0.0(@udecode/plate-common@37.0.0)(react-dom@18.2.0)(react@18.2.0)(slate-history@0.109.0)(slate-hyperscript@0.100.0)(slate-react@0.109.0)(slate@0.103.0): + resolution: {integrity: sha512-XXBsa0fa4Pn5RSpLcCM539dBurtfcN4o2uG/UHWbQDX6vJDYs3YBmc1bNxUVPMF0Va1oD+246nssrVMQB4KgJw==} + peerDependencies: + '@udecode/plate-common': '>=37.0.0' + react: '>=16.8.0' + react-dom: '>=16.8.0' + slate: '>=0.103.0' + slate-history: '>=0.93.0' + slate-hyperscript: '>=0.66.0' + slate-react: '>=0.108.0' + dependencies: + '@udecode/plate-combobox': 37.0.0(@udecode/plate-common@37.0.0)(react-dom@18.2.0)(react@18.2.0)(slate-history@0.109.0)(slate-hyperscript@0.100.0)(slate-react@0.109.0)(slate@0.103.0) + '@udecode/plate-common': 37.0.0(@types/react@18.2.69)(immer@10.0.4)(react-dom@18.2.0)(react@18.2.0)(scheduler@0.23.0)(slate-history@0.109.0)(slate-hyperscript@0.100.0)(slate-react@0.109.0)(slate@0.103.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + slate: 0.103.0 + slate-history: 0.109.0(slate@0.103.0) + slate-hyperscript: 0.100.0(slate@0.103.0) + slate-react: 0.109.0(react-dom@18.2.0)(react@18.2.0)(slate@0.103.0) + dev: false + + /@udecode/plate-trailing-block@37.0.0(@udecode/plate-common@37.0.0)(react-dom@18.2.0)(react@18.2.0)(slate-history@0.109.0)(slate-hyperscript@0.100.0)(slate-react@0.109.0)(slate@0.103.0): + resolution: {integrity: sha512-jvmgjAmrsmH5i/zqRaeUXRGB6DzTzwiyrRXHd5hgH0NiTsvv1/W4+QkeK7NrnZM4zIcAwn/JBskSZq6KEV+rZA==} + peerDependencies: + '@udecode/plate-common': '>=37.0.0' + react: '>=16.8.0' + react-dom: '>=16.8.0' + slate: '>=0.103.0' + slate-history: '>=0.93.0' + slate-hyperscript: '>=0.66.0' + slate-react: '>=0.108.0' + dependencies: + '@udecode/plate-common': 37.0.0(@types/react@18.2.69)(immer@10.0.4)(react-dom@18.2.0)(react@18.2.0)(scheduler@0.23.0)(slate-history@0.109.0)(slate-hyperscript@0.100.0)(slate-react@0.109.0)(slate@0.103.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + slate: 0.103.0 + slate-history: 0.109.0(slate@0.103.0) + slate-hyperscript: 0.100.0(slate@0.103.0) + slate-react: 0.109.0(react-dom@18.2.0)(react@18.2.0)(slate@0.103.0) + dev: false + + /@udecode/plate-utils@37.0.0(@types/react@18.2.69)(immer@10.0.4)(react-dom@18.2.0)(react@18.2.0)(scheduler@0.23.0)(slate-history@0.109.0)(slate-hyperscript@0.100.0)(slate-react@0.109.0)(slate@0.103.0): + resolution: {integrity: sha512-DVpW5Asi/tJlyTFQ1/vHGQiQ5AKYbG7opWdQK0kftWnQol2UhsxW0lv8kp1cmErUYa6UXXvSb0EwbkMwvY/UFA==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + slate: '>=0.103.0' + slate-history: '>=0.93.0' + slate-hyperscript: '>=0.66.0' + slate-react: '>=0.108.0' + dependencies: + '@udecode/plate-core': 37.0.0(@types/react@18.2.69)(immer@10.0.4)(react-dom@18.2.0)(react@18.2.0)(scheduler@0.23.0)(slate-history@0.109.0)(slate-hyperscript@0.100.0)(slate-react@0.109.0)(slate@0.103.0) + '@udecode/react-utils': 37.0.0(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) + '@udecode/slate': 37.0.0(slate-history@0.109.0)(slate@0.103.0) + '@udecode/slate-react': 37.0.0(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0)(slate-history@0.109.0)(slate-react@0.109.0)(slate@0.103.0) + '@udecode/slate-utils': 37.0.0(slate-history@0.109.0)(slate@0.103.0) + '@udecode/utils': 37.0.0 + clsx: 1.2.1 + lodash: 4.17.21 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + slate: 0.103.0 + slate-history: 0.109.0(slate@0.103.0) + slate-hyperscript: 0.100.0(slate@0.103.0) + slate-react: 0.109.0(react-dom@18.2.0)(react@18.2.0)(slate@0.103.0) + transitivePeerDependencies: + - '@types/react' + - immer + - react-native + - scheduler + dev: false + + /@udecode/react-hotkeys@37.0.0(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-3ZV5LiaTnKyhXwN6U0NE2cofNsNN2IPMkNCDntbSIIRLYmI+o6LRkDwAucSNh/BIdNXfvxscsR04RYyIwjGbJw==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + dependencies: + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /@udecode/react-utils@37.0.0(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-llu7fX4yfmBttR6jdW0b23s7MnD0IEajkS8XuY2HoVmqjECQ8Bah/TPhjgiw5GFrBRA5HsMZezfgKabKuaDmBw==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + dependencies: + '@radix-ui/react-slot': 1.0.2(@types/react@18.2.69)(react@18.2.0) + '@udecode/utils': 37.0.0 + clsx: 1.2.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + transitivePeerDependencies: + - '@types/react' + dev: false + + /@udecode/slate-react@37.0.0(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0)(slate-history@0.109.0)(slate-react@0.109.0)(slate@0.103.0): + resolution: {integrity: sha512-/pa8rp9vQILC33T/Ik6hFiiS5HI+M+atsiFzPfs0TLKtpkOqHUJLqCnEU564xzAbnO2Y1PHrZQowFom/kE2rIg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + slate: '>=0.103.0' + slate-history: '>=0.93.0' + slate-react: '>=0.108.0' + dependencies: + '@udecode/react-utils': 37.0.0(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) + '@udecode/slate': 37.0.0(slate-history@0.109.0)(slate@0.103.0) + '@udecode/utils': 37.0.0 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + slate: 0.103.0 + slate-history: 0.109.0(slate@0.103.0) + slate-react: 0.109.0(react-dom@18.2.0)(react@18.2.0)(slate@0.103.0) + transitivePeerDependencies: + - '@types/react' + dev: false + + /@udecode/slate-utils@37.0.0(slate-history@0.109.0)(slate@0.103.0): + resolution: {integrity: sha512-ar/hogPcpklzPn9b90ACyFxj6kdA1r64u7ssWomP86n5rBJdC6vnbEhIIX+GCpFu++Sk1lxkerAIxUn1of1p2A==} + peerDependencies: + slate: '>=0.103.0' + slate-history: '>=0.93.0' + dependencies: + '@udecode/slate': 37.0.0(slate-history@0.109.0)(slate@0.103.0) + '@udecode/utils': 37.0.0 + lodash: 4.17.21 + slate: 0.103.0 + slate-history: 0.109.0(slate@0.103.0) + dev: false + + /@udecode/slate@37.0.0(slate-history@0.109.0)(slate@0.103.0): + resolution: {integrity: sha512-js5i347k1ssfQUD/M9LhxltLN3HcIPiDeuu/Iy3BwNKP0kigjaOFWfWkjGi3EXrJM89iTKrHf5qKiPFsnKW3mQ==} + peerDependencies: + slate: '>=0.103.0' + slate-history: '>=0.93.0' + dependencies: + '@udecode/utils': 37.0.0 + slate: 0.103.0 + slate-history: 0.109.0(slate@0.103.0) + dev: false + + /@udecode/utils@37.0.0: + resolution: {integrity: sha512-30ixi2pznIXyIqpFocX+X5Sj38js+wZ0RLY14eZv1C1zwWo5BxSuJfzpGQTvGcLPJnij019tEpmGH61QdDxtrQ==} + dev: false + /@ungap/structured-clone@1.2.0: resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} @@ -14015,6 +14672,14 @@ packages: resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} dev: true + /compute-scroll-into-view@1.0.20: + resolution: {integrity: sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==} + dev: false + + /compute-scroll-into-view@3.1.0: + resolution: {integrity: sha512-rj8l8pD4bJ1nx+dAkMhV1xB5RuZEyVysfxJqB1pRchh1KVvwOv9b7CGB8ZfjTImVv2oF+sYMUkMZq6Na5Ftmbg==} + dev: false + /concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -15077,6 +15742,11 @@ packages: dependencies: path-type: 4.0.0 + /direction@1.0.4: + resolution: {integrity: sha512-GYqKi1aH7PJXxdhTeZBFrg8vUBeKXi+cNprXsC1kpJcbcVnV9wBsrOu1cQEdG0WeQwlfHiy3XvnKfIrJ2R0NzQ==} + hasBin: true + dev: false + /display-notification@2.0.0: resolution: {integrity: sha512-TdmtlAcdqy1NU+j7zlkDdMnCL878zriLaBmoD9quOoq1ySSSGv03l0hXK5CvIFZlIfFI/hizqdQuW+Num7xuhw==} engines: {node: '>=4'} @@ -15240,6 +15910,19 @@ packages: engines: {node: '>=10'} dev: true + /downshift@6.1.12(react@18.2.0): + resolution: {integrity: sha512-7XB/iaSJVS4T8wGFT3WRXmSF1UlBHAA40DshZtkrIscIN+VC+Lh363skLxFTvJwtNgHxAMDGEHT4xsyQFWL+UA==} + peerDependencies: + react: '>=16.12.0' + dependencies: + '@babel/runtime': 7.24.1 + compute-scroll-into-view: 1.0.20 + prop-types: 15.8.1 + react: 18.2.0 + react-is: 17.0.2 + tslib: 2.6.2 + dev: false + /duplexer@0.1.1: resolution: {integrity: sha512-sxNZ+ljy+RA1maXoUReeqBBpBC6RLKmg5ewzV+x+mSETmWNoKdZN6vcQjpFROemza23hGFskJtFNoUWUaQ+R4Q==} dev: true @@ -18581,6 +19264,10 @@ packages: /is-hexadecimal@2.0.1: resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + /is-hotkey@0.2.0: + resolution: {integrity: sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw==} + dev: false + /is-interactive@1.0.0: resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} engines: {node: '>=8'} @@ -19000,6 +19687,49 @@ packages: '@sideway/pinpoint': 2.0.0 dev: false + /jotai-optics@0.3.2(jotai@2.9.3)(optics-ts@2.4.1): + resolution: {integrity: sha512-RH6SvqU5hmkVqnHmaqf9zBXvIAs4jLxkDHS4fr5ljuBKHs8+HQ02v+9hX7ahTppxx6dUb0GGUE80jQKJ0kFTLw==} + peerDependencies: + jotai: '>=1.11.0' + optics-ts: '*' + dependencies: + jotai: 2.9.3(@types/react@18.2.69)(react@18.2.0) + optics-ts: 2.4.1 + dev: false + + /jotai-x@1.2.4(@types/react@18.2.69)(jotai@2.9.3)(react@18.2.0): + resolution: {integrity: sha512-FyLrAR/ZDtmaWgif4cNRuJvMam/RSFv+B11/p4T427ws/T+8WhZzwmULwNogG6ZbZq+v1XpH6f9aN1lYqY5dLg==} + peerDependencies: + '@types/react': '>=17.0.0' + jotai: '>=2.0.0' + react: '>=17.0.0' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + dependencies: + '@types/react': 18.2.69 + jotai: 2.9.3(@types/react@18.2.69)(react@18.2.0) + react: 18.2.0 + dev: false + + /jotai@2.9.3(@types/react@18.2.69)(react@18.2.0): + resolution: {integrity: sha512-IqMWKoXuEzWSShjd9UhalNsRGbdju5G2FrqNLQJT+Ih6p41VNYe2sav5hnwQx4HJr25jq9wRqvGSWGviGG6Gjw==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=17.0.0' + react: '>=17.0.0' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + dependencies: + '@types/react': 18.2.69 + react: 18.2.0 + dev: false + /joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -19038,6 +19768,10 @@ packages: resolution: {integrity: sha512-UfJMcSJc+SEXEl9lH/VLHSZbThQyLpw1vLO1Lb+j4RWDvG3N2f7yj3PVQA3cmkTBNldJ9eFnM+xEXxHIXrYiJw==} dev: true + /js-video-url-parser@0.5.1: + resolution: {integrity: sha512-/vwqT67k0AyIGMHAvSOt+n4JfrZWF7cPKgKswDO35yr27GfW4HtjpQVlTx6JLF45QuPm8mkzFHkZgFVnFm4x/w==} + dev: false + /js-yaml@3.14.1: resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} hasBin: true @@ -19591,6 +20325,10 @@ packages: resolution: {integrity: sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==} dev: true + /lodash.mapvalues@4.6.0: + resolution: {integrity: sha512-JPFqXFeZQ7BfS00H58kClY7SPVeHertPE0lNuCyZ26/XlN8TvakYD7b9bGyNmXbT/D3BbtPAAmq90gPWqLkxlQ==} + dev: false + /lodash.memoize@4.1.2: resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} dev: true @@ -22110,6 +22848,10 @@ packages: hasBin: true dev: true + /optics-ts@2.4.1: + resolution: {integrity: sha512-HaYzMHvC80r7U/LqAd4hQyopDezC60PO2qF5GuIwALut2cl5rK1VWHsqTp0oqoJJWjiv6uXKqsO+Q2OO0C3MmQ==} + dev: false + /optionator@0.9.3: resolution: {integrity: sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==} engines: {node: '>= 0.8.0'} @@ -23857,6 +24599,10 @@ packages: forwarded: 0.2.0 ipaddr.js: 1.9.1 + /proxy-compare@2.6.0: + resolution: {integrity: sha512-8xuCeM3l8yqdmbPoYeLbrAXCBWu19XEYc5/F28f5qOaoAIMyfmBUkl5axiK+x9olUvRlcekvnm98AP9RDngOIw==} + dev: false + /proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} @@ -24307,7 +25053,6 @@ packages: /react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} - dev: true /react-is@18.1.0: resolution: {integrity: sha512-Fl7FuabXsJnV5Q1qIOQwx/sagGF18kogb4gpfcG4gjLBWO0WDiiz1ko/ExayuxE7InyQkBLkxRFG5oxY6Uu3Kg==} @@ -24531,6 +25276,26 @@ packages: - '@types/react' dev: false + /react-tracked@1.7.14(react-dom@18.2.0)(react@18.2.0)(scheduler@0.23.0): + resolution: {integrity: sha512-6UMlgQeRAGA+uyYzuQGm7kZB6ZQYFhc7sntgP7Oxwwd6M0Ud/POyb4K3QWT1eXvoifSa80nrAWnXWFGpOvbwkw==} + peerDependencies: + react: '>=16.8.0' + react-dom: '*' + react-native: '*' + scheduler: '>=0.19.0' + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + dependencies: + proxy-compare: 2.6.0 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + scheduler: 0.23.0 + use-context-selector: 1.4.4(react-dom@18.2.0)(react@18.2.0)(scheduler@0.23.0) + dev: false + /react-transition-group@4.4.5(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} peerDependencies: @@ -25403,6 +26168,12 @@ packages: engines: {node: '>=0.10.0'} dev: false + /scroll-into-view-if-needed@3.1.0: + resolution: {integrity: sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==} + dependencies: + compute-scroll-into-view: 3.1.0 + dev: false + /scroll@3.0.1: resolution: {integrity: sha512-pz7y517OVls1maEzlirKO5nPYle9AXsFzTMNJrRGmT951mzpIBy7sNHOg5o/0MQd/NqliCiWnAi0kZneMPFLcg==} dev: false @@ -25759,6 +26530,51 @@ packages: engines: {node: '>=14.16'} dev: true + /slate-history@0.109.0(slate@0.103.0): + resolution: {integrity: sha512-DHavPwrTTAEAV66eAocB3iQHEj65N6IVtbRK98ZuqGT0S44T3zXlhzY+5SZ7EPxRcoOYVt1dioRxXYM/+PmCiQ==} + peerDependencies: + slate: '>=0.65.3' + dependencies: + is-plain-object: 5.0.0 + slate: 0.103.0 + dev: false + + /slate-hyperscript@0.100.0(slate@0.103.0): + resolution: {integrity: sha512-fb2KdAYg6RkrQGlqaIi4wdqz3oa0S4zKNBJlbnJbNOwa23+9FLD6oPVx9zUGqCSIpy+HIpOeqXrg0Kzwh/Ii4A==} + peerDependencies: + slate: '>=0.65.3' + dependencies: + is-plain-object: 5.0.0 + slate: 0.103.0 + dev: false + + /slate-react@0.109.0(react-dom@18.2.0)(react@18.2.0)(slate@0.103.0): + resolution: {integrity: sha512-tzSJFqwzAvy4PmIPobuKp7PX2Q/R/jwG0DU7AJTnMLVQpGpzS0yacsDcFeGRaGAQpFZYlUteFkKiBm9MKgDEyg==} + peerDependencies: + react: '>=18.2.0' + react-dom: '>=18.2.0' + slate: '>=0.99.0' + dependencies: + '@juggle/resize-observer': 3.4.0 + direction: 1.0.4 + is-hotkey: 0.2.0 + is-plain-object: 5.0.0 + lodash: 4.17.21 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + scroll-into-view-if-needed: 3.1.0 + slate: 0.103.0 + tiny-invariant: 1.3.1 + dev: false + + /slate@0.103.0: + resolution: {integrity: sha512-eCUOVqUpADYMZ59O37QQvUdnFG+8rin0OGQAXNHvHbQeVJ67Bu0spQbcy621vtf8GQUXTEQBlk6OP9atwwob4w==} + dependencies: + immer: 10.0.4 + is-plain-object: 5.0.0 + tiny-warning: 1.0.3 + dev: false + /slice-ansi@5.0.0: resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==} engines: {node: '>=12'} @@ -26565,6 +27381,10 @@ packages: tslib: 2.6.2 dev: true + /tabbable@6.2.0: + resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==} + dev: false + /tailwind-merge@2.2.2: resolution: {integrity: sha512-tWANXsnmJzgw6mQ07nE3aCDkCK4QdT3ThPMCzawoYA2Pws7vSTCvz3Vrjg61jVUGfFZPJzxEP+NimbcW+EdaDw==} dependencies: @@ -26894,9 +27714,17 @@ packages: globrex: 0.1.2 dev: true + /tiny-invariant@1.3.1: + resolution: {integrity: sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==} + dev: false + /tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + /tiny-warning@1.0.3: + resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} + dev: false + /tinybench@2.6.0: resolution: {integrity: sha512-N8hW3PG/3aOoZAN5V/NSAEDz0ZixDSSt5b/a05iqtpgfLWMSVuCo7w0k2vVvEjdrIoeGqZzweX2WlyioNIHchA==} dev: true @@ -27661,6 +28489,33 @@ packages: react: 18.2.0 dev: false + /use-context-selector@1.4.4(react-dom@18.2.0)(react@18.2.0)(scheduler@0.23.0): + resolution: {integrity: sha512-pS790zwGxxe59GoBha3QYOwk8AFGp4DN6DOtH+eoqVmgBBRXVx4IlPDhJmmMiNQAgUaLlP+58aqRC3A4rdaSjg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '*' + react-native: '*' + scheduler: '>=0.19.0' + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + dependencies: + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + scheduler: 0.23.0 + dev: false + + /use-deep-compare@1.3.0(react@18.2.0): + resolution: {integrity: sha512-94iG+dEdEP/Sl3WWde+w9StIunlV8Dgj+vkt5wTwMoFQLaijiEZSXXy8KtcStpmEDtIptRJiNeD4ACTtVvnIKA==} + peerDependencies: + react: '>=16.8.0' + dependencies: + dequal: 2.0.3 + react: 18.2.0 + dev: false + /use-isomorphic-layout-effect@1.1.2(@types/react@18.2.69)(react@18.2.0): resolution: {integrity: sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==} peerDependencies: @@ -28750,6 +29605,22 @@ packages: tslib: 2.3.0 dev: false + /zustand-x@3.0.4(react-dom@18.2.0)(react@18.2.0)(scheduler@0.23.0)(zustand@4.5.2): + resolution: {integrity: sha512-dVD8WUEpR/0mMdLah9j8i+r6PMAq9Ii2u+BX/9Bn4MHRt8sSnRQ90YMUlTVonZYAHGb2UHZwPpE2gMb8GtYDDw==} + peerDependencies: + zustand: '>=4.3.9' + dependencies: + immer: 10.0.4 + lodash.mapvalues: 4.6.0 + react-tracked: 1.7.14(react-dom@18.2.0)(react@18.2.0)(scheduler@0.23.0) + zustand: 4.5.2(@types/react@18.2.69)(immer@10.0.4)(react@18.2.0) + transitivePeerDependencies: + - react + - react-dom + - react-native + - scheduler + dev: false + /zustand@4.5.2(@types/react@18.2.69)(immer@10.0.4)(react@18.2.0): resolution: {integrity: sha512-2cN1tPkDVkwCy5ickKrI7vijSjPksFRfqS6237NzT0vqSsztTNnQdHw9mmN7uBdk3gceVXU0a+21jFzFzAc9+g==} engines: {node: '>=12.7.0'}