From a0ec2f30ca06bf98b7244f1280ff01f1883100f8 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Tue, 30 Jan 2024 00:14:21 +0100 Subject: [PATCH] feat: search --- README.md | 2 +- .../features/search/queries/search-query.ts | 11 + .../src/features/search/search-spotlight.tsx | 86 +++++--- .../search/services/search-service.ts | 7 + .../src/features/search/types/search.types.ts | 12 ++ apps/server/package.json | 1 + .../src/collaboration/collaboration.util.ts | 44 ++++ .../extensions/persistence.extension.ts | 47 ++-- apps/server/src/core/core.module.ts | 2 + .../src/core/page/entities/page.entity.ts | 19 +- apps/server/src/core/page/page.module.ts | 7 +- .../src/core/page/services/page.service.ts | 2 + .../core/search/dto/search-response.dto.ts | 9 + apps/server/src/core/search/dto/search.dto.ts | 18 ++ .../src/core/search/search.controller.spec.ts | 18 ++ .../src/core/search/search.controller.ts | 44 ++++ apps/server/src/core/search/search.module.ts | 13 ++ .../src/core/search/search.service.spec.ts | 18 ++ apps/server/src/core/search/search.service.ts | 72 +++++++ package.json | 3 +- .../editor-ext/src/lib/comment/comment.ts | 201 ++++++++++-------- pnpm-lock.yaml | 34 +++ 22 files changed, 509 insertions(+), 161 deletions(-) create mode 100644 apps/client/src/features/search/queries/search-query.ts create mode 100644 apps/client/src/features/search/services/search-service.ts create mode 100644 apps/client/src/features/search/types/search.types.ts create mode 100644 apps/server/src/collaboration/collaboration.util.ts create mode 100644 apps/server/src/core/search/dto/search-response.dto.ts create mode 100644 apps/server/src/core/search/dto/search.dto.ts create mode 100644 apps/server/src/core/search/search.controller.spec.ts create mode 100644 apps/server/src/core/search/search.controller.ts create mode 100644 apps/server/src/core/search/search.module.ts create mode 100644 apps/server/src/core/search/search.service.spec.ts create mode 100644 apps/server/src/core/search/search.service.ts diff --git a/README.md b/README.md index cdd00c8f..758dadd9 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ The server will be available on `http://localhost:3000` $ pnpm nx run server:migration:create init # Generates 'init' migration file from existing entities to update the database schema -$ pnpm nx run server::generate init +$ pnpm nx run server:migration:generate init # Runs all pending migrations to update the database schema $ pnpm nx run server:migration:run diff --git a/apps/client/src/features/search/queries/search-query.ts b/apps/client/src/features/search/queries/search-query.ts new file mode 100644 index 00000000..2b9bafa8 --- /dev/null +++ b/apps/client/src/features/search/queries/search-query.ts @@ -0,0 +1,11 @@ +import { useQuery, UseQueryResult } from '@tanstack/react-query'; +import { searchPage } from '@/features/search/services/search-service'; +import { IPageSearch } from '@/features/search/types/search.types'; + +export function usePageSearchQuery(query: string): UseQueryResult { + return useQuery({ + queryKey: ['page-history', query], + queryFn: () => searchPage(query), + enabled: !!query, + }); +} diff --git a/apps/client/src/features/search/search-spotlight.tsx b/apps/client/src/features/search/search-spotlight.tsx index 7cf2f421..261e8aea 100644 --- a/apps/client/src/features/search/search-spotlight.tsx +++ b/apps/client/src/features/search/search-spotlight.tsx @@ -1,43 +1,59 @@ -import { rem } from '@mantine/core'; -import { Spotlight, SpotlightActionData } from '@mantine/spotlight'; -import { IconHome, IconDashboard, IconSettings, IconSearch } from '@tabler/icons-react'; +import { Group, Center, Text } from '@mantine/core'; +import { Spotlight } from '@mantine/spotlight'; +import { IconFileDescription, IconHome, IconSearch, IconSettings } from '@tabler/icons-react'; +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useDebouncedValue } from '@mantine/hooks'; +import { usePageSearchQuery } from '@/features/search/queries/search-query'; -const actions: SpotlightActionData[] = [ - { - id: 'home', - label: 'Home', - description: 'Get to home page', - onClick: () => console.log('Home'), - leftSection: , - }, - { - id: 'dashboard', - label: 'Dashboard', - description: 'Get full information about current system status', - onClick: () => console.log('Dashboard'), - leftSection: , - }, - { - id: 'settings', - label: 'Settings', - description: 'Account settings and workspace management', - onClick: () => console.log('Settings'), - leftSection: , - }, -]; export function SearchSpotlight() { + const navigate = useNavigate(); + const [query, setQuery] = useState(''); + const [debouncedSearchQuery] = useDebouncedValue(query, 300); + const { data: searchResults, isLoading, error } = usePageSearchQuery(debouncedSearchQuery) + + const items = (searchResults && searchResults.length > 0 ? searchResults : []) + .map((item) => ( + navigate(`/p/${item.id}`)}> + +
+ {item?.icon ? ( + { item.icon } + ) : ( + + )} +
+ +
+ {item.title} + + {item?.highlight && ( + + )} +
+ +
+
+ )); + return ( <> - , - placeholder: 'Search...', - }} - /> + + + } /> + + {items.length > 0 ? items : No results found...} + + + ); } diff --git a/apps/client/src/features/search/services/search-service.ts b/apps/client/src/features/search/services/search-service.ts new file mode 100644 index 00000000..2e75ec53 --- /dev/null +++ b/apps/client/src/features/search/services/search-service.ts @@ -0,0 +1,7 @@ +import api from '@/lib/api-client'; +import { IPageSearch } from '@/features/search/types/search.types'; + +export async function searchPage(query: string): Promise { + const req = await api.post('/search', { query }); + return req.data as any; +} diff --git a/apps/client/src/features/search/types/search.types.ts b/apps/client/src/features/search/types/search.types.ts new file mode 100644 index 00000000..fc4f32e5 --- /dev/null +++ b/apps/client/src/features/search/types/search.types.ts @@ -0,0 +1,12 @@ + +export interface IPageSearch { + id: string; + title: string; + icon: string; + parentPageId: string; + creatorId: string; + createdAt: Date; + updatedAt: Date; + rank: string; + highlight: string; +} diff --git a/apps/server/package.json b/apps/server/package.json index 8772dac0..d77bd1b7 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -48,6 +48,7 @@ "fs-extra": "^11.1.1", "mime-types": "^2.1.35", "pg": "^8.11.3", + "pg-tsquery": "^8.4.1", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", "sanitize-filename-ts": "^1.0.2", diff --git a/apps/server/src/collaboration/collaboration.util.ts b/apps/server/src/collaboration/collaboration.util.ts new file mode 100644 index 00000000..948fc9f4 --- /dev/null +++ b/apps/server/src/collaboration/collaboration.util.ts @@ -0,0 +1,44 @@ +import { StarterKit } from '@tiptap/starter-kit'; +import { TextAlign } from '@tiptap/extension-text-align'; +import { TaskList } from '@tiptap/extension-task-list'; +import { TaskItem } from '@tiptap/extension-task-item'; +import { Underline } from '@tiptap/extension-underline'; +import { Link } from '@tiptap/extension-link'; +import { Superscript } from '@tiptap/extension-superscript'; +import SubScript from '@tiptap/extension-subscript'; +import { Highlight } from '@tiptap/extension-highlight'; +import { Typography } from '@tiptap/extension-typography'; +import { TextStyle } from '@tiptap/extension-text-style'; +import { Color } from '@tiptap/extension-color'; +import { TrailingNode, Comment } from '@docmost/editor-ext'; +import { generateHTML, generateJSON } from '@tiptap/html'; +import { generateText, JSONContent } from '@tiptap/core'; + +export const tiptapExtensions = [ + StarterKit, + Comment, + TextAlign, + TaskList, + TaskItem, + Underline, + Link, + Superscript, + SubScript, + Highlight, + Typography, + TrailingNode, + TextStyle, + Color, +]; + +export function jsonToHtml(tiptapJson: JSONContent) { + return generateHTML(tiptapJson, tiptapExtensions); +} + +export function htmlToJson(html: string) { + return generateJSON(html, tiptapExtensions); +} + +export function jsonToText(tiptapJson: JSONContent) { + return generateText(tiptapJson, tiptapExtensions); +} diff --git a/apps/server/src/collaboration/extensions/persistence.extension.ts b/apps/server/src/collaboration/extensions/persistence.extension.ts index 1168d154..11e7ff86 100644 --- a/apps/server/src/collaboration/extensions/persistence.extension.ts +++ b/apps/server/src/collaboration/extensions/persistence.extension.ts @@ -7,19 +7,8 @@ import * as Y from 'yjs'; import { PageService } from '../../core/page/services/page.service'; import { Injectable } from '@nestjs/common'; import { TiptapTransformer } from '@hocuspocus/transformer'; -import { StarterKit } from '@tiptap/starter-kit'; -import { TextAlign } from '@tiptap/extension-text-align'; -import { TaskList } from '@tiptap/extension-task-list'; -import { TaskItem } from '@tiptap/extension-task-item'; -import { Underline } from '@tiptap/extension-underline'; -import { Link } from '@tiptap/extension-link'; -import { Superscript } from '@tiptap/extension-superscript'; -import SubScript from '@tiptap/extension-subscript'; -import { Highlight } from '@tiptap/extension-highlight'; -import { Typography } from '@tiptap/extension-typography'; -import { TextStyle } from '@tiptap/extension-text-style'; -import { Color } from '@tiptap/extension-color'; -import { TrailingNode, Comment } from '@docmost/editor-ext'; +import { jsonToHtml, jsonToText, tiptapExtensions } from '../collaboration.util'; +import { generateText } from '@tiptap/core' @Injectable() export class PersistenceExtension implements Extension { @@ -55,22 +44,12 @@ export class PersistenceExtension implements Extension { if (page.content) { console.log('converting json to ydoc'); - const ydoc = TiptapTransformer.toYdoc(page.content, 'default', [ - StarterKit, - Comment, - TextAlign, - TaskList, - TaskItem, - Underline, - Link, - Superscript, - SubScript, - Highlight, - Typography, - TrailingNode, - TextStyle, - Color, - ]); + const ydoc = TiptapTransformer.toYdoc( + page.content, + 'default', + tiptapExtensions, + ); + Y.encodeStateAsUpdate(ydoc); return ydoc; } @@ -87,8 +66,16 @@ export class PersistenceExtension implements Extension { const tiptapJson = TiptapTransformer.fromYdoc(document, 'default'); const ydocState = Buffer.from(Y.encodeStateAsUpdate(document)); + const textContent = jsonToText(tiptapJson); + console.log(jsonToText(tiptapJson)); + try { - await this.pageService.updateState(pageId, tiptapJson, ydocState); + await this.pageService.updateState( + pageId, + tiptapJson, + textContent, + ydocState, + ); } catch (err) { console.error(`Failed to update page ${documentName}`); } diff --git a/apps/server/src/core/core.module.ts b/apps/server/src/core/core.module.ts index fbc56834..c4d56511 100644 --- a/apps/server/src/core/core.module.ts +++ b/apps/server/src/core/core.module.ts @@ -7,6 +7,7 @@ import { StorageModule } from './storage/storage.module'; import { AttachmentModule } from './attachment/attachment.module'; import { EnvironmentModule } from '../environment/environment.module'; import { CommentModule } from './comment/comment.module'; +import { SearchModule } from './search/search.module'; @Module({ imports: [ @@ -19,6 +20,7 @@ import { CommentModule } from './comment/comment.module'; }), AttachmentModule, CommentModule, + SearchModule, ], }) export class CoreModule {} diff --git a/apps/server/src/core/page/entities/page.entity.ts b/apps/server/src/core/page/entities/page.entity.ts index e5d99710..e1820177 100644 --- a/apps/server/src/core/page/entities/page.entity.ts +++ b/apps/server/src/core/page/entities/page.entity.ts @@ -22,21 +22,34 @@ export class Page { @Column({ length: 500, nullable: true }) title: string; + @Column({ nullable: true }) + icon: string; + @Column({ type: 'jsonb', nullable: true }) content: string; @Column({ type: 'text', nullable: true }) html: string; + @Column({ type: 'text', nullable: true }) + textContent: string; + + @Column({ + type: 'tsvector', + generatedType: 'STORED', + asExpression: + "setweight(to_tsvector('english', coalesce(pages.title, '')), 'A') || setweight(to_tsvector('english', coalesce(pages.\"textContent\", '')), 'B')", + select: false, + nullable: true, + }) + tsv: string; + @Column({ type: 'bytea', nullable: true }) ydoc: any; @Column({ nullable: true }) slug: string; - @Column({ nullable: true }) - icon: string; - @Column({ nullable: true }) coverPhoto: string; diff --git a/apps/server/src/core/page/page.module.ts b/apps/server/src/core/page/page.module.ts index 02246c72..9229c804 100644 --- a/apps/server/src/core/page/page.module.ts +++ b/apps/server/src/core/page/page.module.ts @@ -26,6 +26,11 @@ import { PageHistoryRepository } from './repositories/page-history.repository'; PageRepository, PageHistoryRepository, ], - exports: [PageService, PageOrderingService, PageHistoryService], + exports: [ + PageService, + PageOrderingService, + PageHistoryService, + PageRepository, + ], }) export class PageModule {} diff --git a/apps/server/src/core/page/services/page.service.ts b/apps/server/src/core/page/services/page.service.ts index c982cad0..53e4bd48 100644 --- a/apps/server/src/core/page/services/page.service.ts +++ b/apps/server/src/core/page/services/page.service.ts @@ -108,11 +108,13 @@ export class PageService { async updateState( pageId: string, content: any, + textContent: string, ydoc: any, userId?: string, // TODO: fix this ): Promise { await this.pageRepository.update(pageId, { content: content, + textContent: textContent, ydoc: ydoc, ...(userId && { lastUpdatedById: userId }), }); diff --git a/apps/server/src/core/search/dto/search-response.dto.ts b/apps/server/src/core/search/dto/search-response.dto.ts new file mode 100644 index 00000000..ef65ae4b --- /dev/null +++ b/apps/server/src/core/search/dto/search-response.dto.ts @@ -0,0 +1,9 @@ +export class SearchResponseDto { + id: string; + title: string; + icon: string; + parentPageId: string; + creatorId: string; + rank: string; + highlight: string; +} diff --git a/apps/server/src/core/search/dto/search.dto.ts b/apps/server/src/core/search/dto/search.dto.ts new file mode 100644 index 00000000..f5b34664 --- /dev/null +++ b/apps/server/src/core/search/dto/search.dto.ts @@ -0,0 +1,18 @@ +import { IsNumber, IsOptional, IsString } from 'class-validator'; + +export class SearchDTO { + @IsString() + query: string; + + @IsOptional() + @IsString() + creatorId?: string; + + @IsOptional() + @IsNumber() + limit?: number; + + @IsOptional() + @IsNumber() + offset?: number; +} diff --git a/apps/server/src/core/search/search.controller.spec.ts b/apps/server/src/core/search/search.controller.spec.ts new file mode 100644 index 00000000..6d6bad58 --- /dev/null +++ b/apps/server/src/core/search/search.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { SearchController } from './search.controller'; + +describe('SearchController', () => { + let controller: SearchController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [SearchController], + }).compile(); + + controller = module.get(SearchController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/apps/server/src/core/search/search.controller.ts b/apps/server/src/core/search/search.controller.ts new file mode 100644 index 00000000..75030794 --- /dev/null +++ b/apps/server/src/core/search/search.controller.ts @@ -0,0 +1,44 @@ +import { + Body, + Controller, + HttpCode, + HttpStatus, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { JwtUser } from '../../decorators/jwt-user.decorator'; +import { WorkspaceService } from '../workspace/services/workspace.service'; +import { JwtGuard } from '../auth/guards/JwtGuard'; +import { SearchService } from './search.service'; +import { SearchDTO } from './dto/search.dto'; + +@UseGuards(JwtGuard) +@Controller('search') +export class SearchController { + constructor( + private readonly searchService: SearchService, + private readonly workspaceService: WorkspaceService, + ) {} + + @HttpCode(HttpStatus.OK) + @Post() + async pageSearch( + @Query('type') type: string, + @Body() searchDto: SearchDTO, + @JwtUser() jwtUser, + ) { + const workspaceId = ( + await this.workspaceService.getUserCurrentWorkspace(jwtUser.id) + ).id; + + if (!type || type === 'page') { + return this.searchService.searchPage( + searchDto.query, + searchDto, + workspaceId, + ); + } + return; + } +} diff --git a/apps/server/src/core/search/search.module.ts b/apps/server/src/core/search/search.module.ts new file mode 100644 index 00000000..ed68962c --- /dev/null +++ b/apps/server/src/core/search/search.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { SearchController } from './search.controller'; +import { SearchService } from './search.service'; +import { AuthModule } from '../auth/auth.module'; +import { WorkspaceModule } from '../workspace/workspace.module'; +import { PageModule } from '../page/page.module'; + +@Module({ + imports: [AuthModule, WorkspaceModule, PageModule], + controllers: [SearchController], + providers: [SearchService], +}) +export class SearchModule {} diff --git a/apps/server/src/core/search/search.service.spec.ts b/apps/server/src/core/search/search.service.spec.ts new file mode 100644 index 00000000..63fc48c0 --- /dev/null +++ b/apps/server/src/core/search/search.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { SearchService } from './search.service'; + +describe('SearchService', () => { + let service: SearchService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [SearchService], + }).compile(); + + service = module.get(SearchService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/apps/server/src/core/search/search.service.ts b/apps/server/src/core/search/search.service.ts new file mode 100644 index 00000000..81aeb4ff --- /dev/null +++ b/apps/server/src/core/search/search.service.ts @@ -0,0 +1,72 @@ +import { Injectable } from '@nestjs/common'; +import { PageRepository } from '../page/repositories/page.repository'; +import { SearchDTO } from './dto/search.dto'; +import { SearchResponseDto } from './dto/search-response.dto'; +// eslint-disable-next-line @typescript-eslint/no-var-requires +const tsquery = require('pg-tsquery')(); + +@Injectable() +export class SearchService { + constructor(private pageRepository: PageRepository) {} + + async searchPage( + query: string, + searchParams: SearchDTO, + workspaceId: string, + ): Promise { + if (query.length < 1) { + return; + } + const searchQuery = tsquery(query.trim() + '*'); + + const selectColumns = [ + 'page.id as id', + 'page.title as title', + 'page.icon as icon', + 'page.parentPageId as "parentPageId"', + 'page.creatorId as "creatorId"', + 'page.createdAt as "createdAt"', + 'page.updatedAt as "updatedAt"', + ]; + + const searchQueryBuilder = await this.pageRepository + .createQueryBuilder('page') + .select(selectColumns); + + searchQueryBuilder.andWhere('page.workspaceId = :workspaceId', { + workspaceId, + }); + + searchQueryBuilder + .addSelect('ts_rank(page.tsv, to_tsquery(:searchQuery))', 'rank') + .addSelect( + `ts_headline('english', page.textContent, to_tsquery(:searchQuery), 'MinWords=9, MaxWords=10, MaxFragments=10')`, + 'highlight', + ) + .andWhere('page.tsv @@ to_tsquery(:searchQuery)', { searchQuery }) + .orderBy('rank', 'DESC'); + + if (searchParams?.creatorId) { + searchQueryBuilder.andWhere('page.creatorId = :creatorId', { + creatorId: searchParams.creatorId, + }); + } + + searchQueryBuilder + .take(searchParams.limit || 20) + .offset(searchParams.offset || 0); + + const results = await searchQueryBuilder.getRawMany(); + + const searchResults = results.map((result) => { + if (result.highlight) { + result.highlight = result.highlight + .replace(/\r\n|\r|\n/g, ' ') + .replace(/\s+/g, ' '); + } + return result; + }); + + return searchResults; + } +} diff --git a/package.json b/package.json index f134de48..77f5bcf0 100644 --- a/package.json +++ b/package.json @@ -7,9 +7,9 @@ }, "dependencies": { "@docmost/editor-ext": "workspace:*", + "@hocuspocus/provider": "^2.9.0", "@hocuspocus/server": "^2.9.0", "@hocuspocus/transformer": "^2.9.0", - "@hocuspocus/provider": "^2.9.0", "@tiptap/extension-code-block": "^2.1.12", "@tiptap/extension-collaboration": "^2.1.12", "@tiptap/extension-collaboration-cursor": "^2.1.12", @@ -31,6 +31,7 @@ "@tiptap/extension-text-style": "^2.1.12", "@tiptap/extension-typography": "^2.1.12", "@tiptap/extension-underline": "^2.1.12", + "@tiptap/html": "^2.1.12", "@tiptap/pm": "^2.1.12", "@tiptap/react": "^2.1.12", "@tiptap/starter-kit": "^2.1.12", diff --git a/packages/editor-ext/src/lib/comment/comment.ts b/packages/editor-ext/src/lib/comment/comment.ts index 1fa7dbaf..2c338ee8 100644 --- a/packages/editor-ext/src/lib/comment/comment.ts +++ b/packages/editor-ext/src/lib/comment/comment.ts @@ -1,137 +1,158 @@ -import { Mark, mergeAttributes } from '@tiptap/core'; -import { commentDecoration } from './comment-decoration'; +import { Mark, mergeAttributes } from "@tiptap/core"; +import { commentDecoration } from "./comment-decoration"; export interface ICommentOptions { - HTMLAttributes: Record, + HTMLAttributes: Record; } export interface ICommentStorage { activeCommentId: string | null; } -export const commentMarkClass = 'comment-mark'; -export const commentDecorationMetaKey = 'decorateComment'; +export const commentMarkClass = "comment-mark"; +export const commentDecorationMetaKey = "decorateComment"; -declare module '@tiptap/core' { +declare module "@tiptap/core" { interface Commands { comment: { - setCommentDecoration: () => ReturnType, - unsetCommentDecoration: () => ReturnType, - setComment: (commentId: string) => ReturnType, - unsetComment: (commentId: string) => ReturnType, + setCommentDecoration: () => ReturnType; + unsetCommentDecoration: () => ReturnType; + setComment: (commentId: string) => ReturnType; + unsetComment: (commentId: string) => ReturnType; }; } } export const Comment = Mark.create({ - name: 'comment', - exitable: true, - inclusive: false, + name: "comment", + exitable: true, + inclusive: false, - addOptions() { - return { - HTMLAttributes: {}, - }; - }, + addOptions() { + return { + HTMLAttributes: {}, + }; + }, - addStorage() { - return { - activeCommentId: null, - }; - }, + addStorage() { + return { + activeCommentId: null, + }; + }, - addAttributes() { - return { - commentId: { - default: null, - parseHTML: element => element.getAttribute('data-comment-id'), - renderHTML: (attributes) => { - if (!attributes.commentId) return; + addAttributes() { + return { + commentId: { + default: null, + parseHTML: (element) => element.getAttribute("data-comment-id"), + renderHTML: (attributes) => { + if (!attributes.commentId) return; - return { - 'data-comment-id': attributes.commentId, - }; - }, + return { + "data-comment-id": attributes.commentId, + }; }, - }; - }, + }, + }; + }, - parseHTML() { - return [ - { - tag: 'span[data-comment-id]', - getAttrs: (el) => !!(el as HTMLSpanElement).getAttribute('data-comment-id')?.trim() && null, - }, - ]; - }, + parseHTML() { + return [ + { + tag: "span[data-comment-id]", + getAttrs: (el) => + !!(el as HTMLSpanElement).getAttribute("data-comment-id")?.trim() && + null, + }, + ]; + }, - addCommands() { - return { - setCommentDecoration: () => ({ tr, dispatch }) => { + addCommands() { + return { + setCommentDecoration: + () => + ({ tr, dispatch }) => { tr.setMeta(commentDecorationMetaKey, true); if (dispatch) dispatch(tr); return true; }, - unsetCommentDecoration: () => ({ tr, dispatch }) => { + unsetCommentDecoration: + () => + ({ tr, dispatch }) => { tr.setMeta(commentDecorationMetaKey, false); if (dispatch) dispatch(tr); return true; }, - setComment: (commentId) => ({ commands }) => { + setComment: + (commentId) => + ({ commands }) => { if (!commentId) return false; return commands.setMark(this.name, { commentId }); }, - unsetComment: - (commentId) => - ({ tr, dispatch }) => { - if (!commentId) return false; + unsetComment: + (commentId) => + ({ tr, dispatch }) => { + if (!commentId) return false; - tr.doc.descendants((node, pos) => { - const from = pos; - const to = pos + node.nodeSize; + tr.doc.descendants((node, pos) => { + const from = pos; + const to = pos + node.nodeSize; - const commentMark = node.marks.find(mark => - mark.type.name === this.name && mark.attrs.commentId === commentId); + const commentMark = node.marks.find( + (mark) => + mark.type.name === this.name && + mark.attrs.commentId === commentId, + ); - if (commentMark) { - tr = tr.removeMark(from, to, commentMark); - } - }); + if (commentMark) { + tr = tr.removeMark(from, to, commentMark); + } + }); - return dispatch?.(tr); - }, - }; - }, + return dispatch?.(tr); + }, + }; + }, - renderHTML({ HTMLAttributes }) { - const commentId = HTMLAttributes?.['data-comment-id'] || null; - const elem = document.createElement('span'); + renderHTML({ HTMLAttributes }) { + const commentId = HTMLAttributes?.["data-comment-id"] || null; - Object.entries( - mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), - ).forEach(([attr, val]) => elem.setAttribute(attr, val)); + if (typeof window === "undefined" || typeof document === "undefined") { + return [ + "span", + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { + class: 'comment-mark', + "data-comment-id": commentId, + }), + 0, + ]; + } - elem.addEventListener('click', (e) => { - const selection = document.getSelection(); - if (selection.type === 'Range') return; + const elem = document.createElement("span"); - this.storage.activeCommentId = commentId; - const commentEventClick = new CustomEvent('ACTIVE_COMMENT_EVENT', { - bubbles: true, - detail: { commentId }, - }); + Object.entries( + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), + ).forEach(([attr, val]) => elem.setAttribute(attr, val)); - elem.dispatchEvent(commentEventClick); + elem.addEventListener("click", (e) => { + const selection = document.getSelection(); + if (selection.type === "Range") return; + + this.storage.activeCommentId = commentId; + const commentEventClick = new CustomEvent("ACTIVE_COMMENT_EVENT", { + bubbles: true, + detail: { commentId }, }); - return elem; - }, - - // @ts-ignore - addProseMirrorPlugins(): Plugin[] { - // @ts-ignore - return [commentDecoration()]; - }, + elem.dispatchEvent(commentEventClick); + }); + return elem; }, -); + + // @ts-ignore + addProseMirrorPlugins(): Plugin[] { + // @ts-ignore + return [commentDecoration()]; + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d58226e9..a3322980 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,6 +83,9 @@ importers: '@tiptap/extension-underline': specifier: ^2.1.12 version: 2.1.16(@tiptap/core@2.1.16) + '@tiptap/html': + specifier: ^2.1.12 + version: 2.1.16(@tiptap/core@2.1.16)(@tiptap/pm@2.1.16) '@tiptap/pm': specifier: ^2.1.12 version: 2.1.16 @@ -313,6 +316,9 @@ importers: pg: specifier: ^8.11.3 version: 8.11.3 + pg-tsquery: + specifier: ^8.4.1 + version: 8.4.1 reflect-metadata: specifier: ^0.1.13 version: 0.1.14 @@ -4801,6 +4807,17 @@ packages: '@tiptap/core': 2.1.16(@tiptap/pm@2.1.16) dev: false + /@tiptap/html@2.1.16(@tiptap/core@2.1.16)(@tiptap/pm@2.1.16): + resolution: {integrity: sha512-I7yOBRxLFJookfOUCLT13mh03F1EOSnJe6Lv5LFVTLv0ThGN+/bNuDE7WwevyeEC8Il/9a/GWPDdaGKTAqiXbg==} + peerDependencies: + '@tiptap/core': ^2.0.0 + '@tiptap/pm': ^2.0.0 + dependencies: + '@tiptap/core': 2.1.16(@tiptap/pm@2.1.16) + '@tiptap/pm': 2.1.16 + zeed-dom: 0.9.26 + dev: false + /@tiptap/pm@2.1.16: resolution: {integrity: sha512-yibLkjtgbBSnWCXbDyKM5kgIGLfMvfbRfFzb8T0uz4PI/L54o0a4fiWSW5Fg10B5+o+NAXW2wMxoId8/Tw91lQ==} dependencies: @@ -6403,6 +6420,11 @@ packages: shebang-command: 2.0.0 which: 2.0.2 + /css-what@6.1.0: + resolution: {integrity: sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==} + engines: {node: '>= 6'} + dev: false + /cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -9181,6 +9203,11 @@ packages: resolution: {integrity: sha512-M+PDm637OY5WM307051+bsDia5Xej6d9IR4GwJse1qA1DIhiKlksvrneZOYQq42OM+spubpcNYEo2FcKQrDk+Q==} dev: false + /pg-tsquery@8.4.1: + resolution: {integrity: sha512-GoeRhw6o4Bpt7awdUwHq6ITOw40IWSrb5IC2qR6dF9ZECtHFGdSpnjHOl9Rumd8Rx12kLI2T9TGV0gvxD5pFgA==} + engines: {node: '>=10'} + dev: false + /pg-types@2.2.0: resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} engines: {node: '>=4'} @@ -11395,6 +11422,13 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + /zeed-dom@0.9.26: + resolution: {integrity: sha512-HWjX8rA3Y/RI32zby3KIN1D+mgskce+She4K7kRyyx62OiVxJ5FnYm8vWq0YVAja3Tf2S1M0XAc6O2lRFcMgcQ==} + engines: {node: '>=14.13.1'} + dependencies: + css-what: 6.1.0 + dev: false + /zod@3.22.4: resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==} dev: false