From 9797a3671f0d73a79be066b67f97a9743fbac106 Mon Sep 17 00:00:00 2001 From: Salihu Date: Sat, 29 Aug 2026 19:38:02 +0100 Subject: [PATCH] support multiple text matching --- .../search-and-replace-dialog.tsx | 4 +- .../src/features/editor/page-editor.tsx | 11 ++-- apps/client/src/features/page/page.utils.ts | 36 ++++++++++--- .../src/features/search/types/search.types.ts | 2 +- .../core/search/dto/search-response.dto.ts | 2 +- apps/server/src/core/search/search.service.ts | 22 ++++---- .../search-and-replace/search-and-replace.ts | 54 +++++++++++-------- 7 files changed, 83 insertions(+), 48 deletions(-) diff --git a/apps/client/src/features/editor/components/search-and-replace/search-and-replace-dialog.tsx b/apps/client/src/features/editor/components/search-and-replace/search-and-replace-dialog.tsx index 082116cd3..834a949ed 100644 --- a/apps/client/src/features/editor/components/search-and-replace/search-and-replace-dialog.tsx +++ b/apps/client/src/features/editor/components/search-and-replace/search-and-replace-dialog.tsx @@ -66,7 +66,7 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo } // Clear search term in editor if (isEditorReady(editor)) { - editor.commands.setSearchTerm(""); + editor.commands.setSearchTerms([""]); } }; @@ -117,7 +117,7 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo useEffect(() => { if (!isEditorReady(editor)) return; - editor.commands.setSearchTerm(searchText); + editor.commands.setSearchTerms([searchText]); editor.commands.resetIndex(); editor.commands.selectCurrentItem(); }, [searchText]); diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index fd1f96ad3..c5dc820c9 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -195,7 +195,7 @@ function CollabPageEditor({ const documentState = useDocumentVisibility(); const { pageSlug } = useParams(); const [searchParams] = useSearchParams(); - const q = searchParams.get("q"); + const searchQueries = searchParams.getAll("q") const slugId = extractPageSlugId(pageSlug); const currentPageEditMode = useAtomValue(currentPageEditModeAtom); const canScroll = useCallback( @@ -422,12 +422,13 @@ function CollabPageEditor({ useEffect(() => { if (!editor || editor.isDestroyed) return; if (showStatic || !isSynced) return; - if (!q?.trim() || appliedSearchRef.current) return; + if (!searchQueries.length || appliedSearchRef.current) return; const frame = requestAnimationFrame(() => { if (!editor || editor.isDestroyed || !editor.view.dom.isConnected) return; - editor.commands.setSearchTerm(q); + editor.commands.setSearchTerms(searchQueries); + editor.commands.setWholeWord(true); editor.commands.resetIndex(); const { results, resultIndex } = editor.storage.searchAndReplace; @@ -437,8 +438,6 @@ function CollabPageEditor({ appliedSearchRef.current = true; - editor.commands.setSearchTerm(q) - editor.commands.setWholeWord(true); const element = document.querySelector(".search-result-current"); element?.scrollIntoView({ behavior: "smooth", block: "center" }); editor.commands.setTextSelection(0) @@ -446,7 +445,7 @@ function CollabPageEditor({ return () => cancelAnimationFrame(frame); - }, [editor, isSynced, showStatic, q]); + }, [editor, isSynced, showStatic, searchQueries]); useEffect(() => { if ( diff --git a/apps/client/src/features/page/page.utils.ts b/apps/client/src/features/page/page.utils.ts index a02364743..411c408fe 100644 --- a/apps/client/src/features/page/page.utils.ts +++ b/apps/client/src/features/page/page.utils.ts @@ -30,7 +30,7 @@ export const buildPageUrl = ( pageSlugId: string, pageTitle?: string, anchorId?: string, - searchString?: string + search?: string[], ): string => { let url: string; if (spaceName === undefined) { @@ -39,8 +39,19 @@ export const buildPageUrl = ( url = `/s/${spaceName}/p/${buildPageSlug(pageSlugId, pageTitle)}`; } - if (searchString) { - url += `?q=${encodeURIComponent(searchString)}`; + if (search?.length > 0) { + const params = new URLSearchParams(); + + search + .map((term) => term.trim()) + .filter(Boolean) + .forEach((term) => params.append("q", term)); + + const queryString = params.toString(); + + if (queryString) { + url += `?${queryString}`; + } } return anchorId ? `${url}#${anchorId}` : url; @@ -51,9 +62,9 @@ export const buildSharedPageUrl = (opts: { pageSlugId: string; pageTitle?: string; anchorId?: string; - searchString?: string + search?: string[] }): string => { - const { shareId, pageSlugId, pageTitle, anchorId, searchString } = opts; + const { shareId, pageSlugId, pageTitle, anchorId, search } = opts; let url: string; if (!shareId) { url = `/share/p/${buildPageSlug(pageSlugId, pageTitle)}`; @@ -61,8 +72,19 @@ export const buildSharedPageUrl = (opts: { url = `/share/${shareId}/p/${buildPageSlug(pageSlugId, pageTitle)}`; } - if (searchString) { - url += `?q=${encodeURIComponent(searchString)}`; + if (search?.length > 0) { + const params = new URLSearchParams(); + + search + .map((term) => term.trim()) + .filter(Boolean) + .forEach((term) => params.append("q", term)); + + const queryString = params.toString(); + + if (queryString) { + url += `?${queryString}`; + } } return anchorId ? `${url}#${anchorId}` : url; diff --git a/apps/client/src/features/search/types/search.types.ts b/apps/client/src/features/search/types/search.types.ts index 9af62f9e9..9d6f76999 100644 --- a/apps/client/src/features/search/types/search.types.ts +++ b/apps/client/src/features/search/types/search.types.ts @@ -14,7 +14,7 @@ export interface IPageSearch { updatedAt: Date; rank: string; highlight: string; - matchedText: string; + matchedText: string[]; space: Partial; } diff --git a/apps/server/src/core/search/dto/search-response.dto.ts b/apps/server/src/core/search/dto/search-response.dto.ts index 42a53f9f4..5fe63eb51 100644 --- a/apps/server/src/core/search/dto/search-response.dto.ts +++ b/apps/server/src/core/search/dto/search-response.dto.ts @@ -8,7 +8,7 @@ export class SearchResponseDto { creatorId: string; rank: number; highlight: string; - matchedText?: string; + matchedText?: string[]; createdAt: Date; updatedAt: Date; space: Partial; diff --git a/apps/server/src/core/search/search.service.ts b/apps/server/src/core/search/search.service.ts index 4216abac5..7740e5a37 100644 --- a/apps/server/src/core/search/search.service.ts +++ b/apps/server/src/core/search/search.service.ts @@ -140,16 +140,20 @@ export class SearchService { //@ts-ignore const searchResults = results.map((result: SearchResponseDto) => { - if (result.highlight) { - result.highlight = result.highlight - .replace(/\r\n|\r|\n/g, ' ') - .replace(/\s+/g, ' '); - - const matchedText = result.highlight.match(/([^<]*)<\/b>/i); - result.matchedText = matchedText?.[1] ?? null; - } else { - result.matchedText = null; + if (!result.highlight) { + result.matchedText = []; + return result; } + + result.highlight = result.highlight + .replace(/\r\n|\r|\n/g, ' ') + .replace(/\s+/g, ' '); + + result.matchedText = Array.from( + result.highlight.matchAll(/([^<]*)<\/b>/gi), + (match) => match[1], + ); + return result; }); diff --git a/packages/editor-ext/src/lib/search-and-replace/search-and-replace.ts b/packages/editor-ext/src/lib/search-and-replace/search-and-replace.ts index e2c4fa832..767c1b78b 100644 --- a/packages/editor-ext/src/lib/search-and-replace/search-and-replace.ts +++ b/packages/editor-ext/src/lib/search-and-replace/search-and-replace.ts @@ -39,7 +39,7 @@ declare module "@tiptap/core" { /** * @description Set search term in extension. */ - setSearchTerm: (searchTerm: string) => ReturnType; + setSearchTerms: (searchTerms: string[]) => ReturnType; /** * @description Set whole word search in extension. */ @@ -86,14 +86,18 @@ interface TextNodesWithPosition { } const getRegex = ( - s: string, + searchTerms: string[], disableRegex: boolean, caseSensitive: boolean, wholeWord: boolean, ): RegExp => { - const pattern = disableRegex - ? s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") - : s; + const terms = searchTerms.filter(Boolean).sort((a, b) => b.length - a.length); + + const pattern = terms + .map((term) => + disableRegex ? term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : term, + ) + .join("|"); const finalPattern = wholeWord ? `(? + setSearchTerms: + (searchTerms: string[]) => ({ editor }) => { - editor.storage.searchAndReplace.searchTerm = searchTerm; + editor.storage.searchAndReplace.searchTerms = searchTerms.filter(Boolean); // clear whole word by default // should remove if whole word toggle is added to search and replace dialog @@ -321,7 +325,7 @@ export const SearchAndReplace = Extension.create< return false; }, - setWholeWord: + setWholeWord: (wholeWord: boolean) => ({ editor }) => { editor.storage.searchAndReplace.wholeWord = wholeWord; @@ -437,8 +441,8 @@ export const SearchAndReplace = Extension.create< const editor = this.editor; const { searchResultClass, disableRegex } = this.options; - const setLastSearchTerm = (t: string) => - (editor.storage.searchAndReplace.lastSearchTerm = t); + const setLastSearchTerms = (terms: string[]) => + (editor.storage.searchAndReplace.lastSearchTerms = [...terms]); const setLastWholeWord = (t: boolean) => (editor.storage.searchAndReplace.lastWholeWord = t); const setLastCaseSensitive = (t: boolean) => @@ -455,10 +459,10 @@ export const SearchAndReplace = Extension.create< const storage = editor.storage.searchAndReplace; if (!storage) return oldState; const { - searchTerm, - lastSearchTerm, - caseSensitive, + searchTerms, lastCaseSensitive, + lastSearchTerms, + caseSensitive, wholeWord, lastWholeWord, resultIndex, @@ -467,26 +471,32 @@ export const SearchAndReplace = Extension.create< if ( !docChanged && - lastSearchTerm === searchTerm && + searchTerms.length === lastSearchTerms.length && + searchTerms.every((term, index) => term === lastSearchTerms[index]) && lastCaseSensitive === caseSensitive && lastWholeWord === wholeWord && lastResultIndex === resultIndex ) return oldState; - setLastSearchTerm(searchTerm); + setLastSearchTerms(searchTerms); setLastCaseSensitive(caseSensitive); setLastWholeWord(wholeWord); setLastResultIndex(resultIndex); - if (!searchTerm) { + if (searchTerms.length === 0) { editor.storage.searchAndReplace.results = []; return DecorationSet.empty; } const { decorationsToReturn, results } = processSearches( doc, - getRegex(searchTerm, disableRegex, caseSensitive, wholeWord), + getRegex( + searchTerms, + disableRegex, + caseSensitive, + wholeWord, + ), searchResultClass, resultIndex, );