support multiple text matching

This commit is contained in:
Salihu
2026-08-29 19:38:02 +01:00
parent 70c8c6cfda
commit 9797a3671f
7 changed files with 83 additions and 48 deletions
@@ -66,7 +66,7 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
} }
// Clear search term in editor // Clear search term in editor
if (isEditorReady(editor)) { if (isEditorReady(editor)) {
editor.commands.setSearchTerm(""); editor.commands.setSearchTerms([""]);
} }
}; };
@@ -117,7 +117,7 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
useEffect(() => { useEffect(() => {
if (!isEditorReady(editor)) return; if (!isEditorReady(editor)) return;
editor.commands.setSearchTerm(searchText); editor.commands.setSearchTerms([searchText]);
editor.commands.resetIndex(); editor.commands.resetIndex();
editor.commands.selectCurrentItem(); editor.commands.selectCurrentItem();
}, [searchText]); }, [searchText]);
@@ -195,7 +195,7 @@ function CollabPageEditor({
const documentState = useDocumentVisibility(); const documentState = useDocumentVisibility();
const { pageSlug } = useParams(); const { pageSlug } = useParams();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const q = searchParams.get("q"); const searchQueries = searchParams.getAll("q")
const slugId = extractPageSlugId(pageSlug); const slugId = extractPageSlugId(pageSlug);
const currentPageEditMode = useAtomValue(currentPageEditModeAtom); const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
const canScroll = useCallback( const canScroll = useCallback(
@@ -422,12 +422,13 @@ function CollabPageEditor({
useEffect(() => { useEffect(() => {
if (!editor || editor.isDestroyed) return; if (!editor || editor.isDestroyed) return;
if (showStatic || !isSynced) return; if (showStatic || !isSynced) return;
if (!q?.trim() || appliedSearchRef.current) return; if (!searchQueries.length || appliedSearchRef.current) return;
const frame = requestAnimationFrame(() => { const frame = requestAnimationFrame(() => {
if (!editor || editor.isDestroyed || !editor.view.dom.isConnected) return; if (!editor || editor.isDestroyed || !editor.view.dom.isConnected) return;
editor.commands.setSearchTerm(q); editor.commands.setSearchTerms(searchQueries);
editor.commands.setWholeWord(true);
editor.commands.resetIndex(); editor.commands.resetIndex();
const { results, resultIndex } = editor.storage.searchAndReplace; const { results, resultIndex } = editor.storage.searchAndReplace;
@@ -437,8 +438,6 @@ function CollabPageEditor({
appliedSearchRef.current = true; appliedSearchRef.current = true;
editor.commands.setSearchTerm(q)
editor.commands.setWholeWord(true);
const element = document.querySelector(".search-result-current"); const element = document.querySelector(".search-result-current");
element?.scrollIntoView({ behavior: "smooth", block: "center" }); element?.scrollIntoView({ behavior: "smooth", block: "center" });
editor.commands.setTextSelection(0) editor.commands.setTextSelection(0)
@@ -446,7 +445,7 @@ function CollabPageEditor({
return () => cancelAnimationFrame(frame); return () => cancelAnimationFrame(frame);
}, [editor, isSynced, showStatic, q]); }, [editor, isSynced, showStatic, searchQueries]);
useEffect(() => { useEffect(() => {
if ( if (
+29 -7
View File
@@ -30,7 +30,7 @@ export const buildPageUrl = (
pageSlugId: string, pageSlugId: string,
pageTitle?: string, pageTitle?: string,
anchorId?: string, anchorId?: string,
searchString?: string search?: string[],
): string => { ): string => {
let url: string; let url: string;
if (spaceName === undefined) { if (spaceName === undefined) {
@@ -39,8 +39,19 @@ export const buildPageUrl = (
url = `/s/${spaceName}/p/${buildPageSlug(pageSlugId, pageTitle)}`; url = `/s/${spaceName}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
} }
if (searchString) { if (search?.length > 0) {
url += `?q=${encodeURIComponent(searchString)}`; 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; return anchorId ? `${url}#${anchorId}` : url;
@@ -51,9 +62,9 @@ export const buildSharedPageUrl = (opts: {
pageSlugId: string; pageSlugId: string;
pageTitle?: string; pageTitle?: string;
anchorId?: string; anchorId?: string;
searchString?: string search?: string[]
}): string => { }): string => {
const { shareId, pageSlugId, pageTitle, anchorId, searchString } = opts; const { shareId, pageSlugId, pageTitle, anchorId, search } = opts;
let url: string; let url: string;
if (!shareId) { if (!shareId) {
url = `/share/p/${buildPageSlug(pageSlugId, pageTitle)}`; url = `/share/p/${buildPageSlug(pageSlugId, pageTitle)}`;
@@ -61,8 +72,19 @@ export const buildSharedPageUrl = (opts: {
url = `/share/${shareId}/p/${buildPageSlug(pageSlugId, pageTitle)}`; url = `/share/${shareId}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
} }
if (searchString) { if (search?.length > 0) {
url += `?q=${encodeURIComponent(searchString)}`; 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; return anchorId ? `${url}#${anchorId}` : url;
@@ -14,7 +14,7 @@ export interface IPageSearch {
updatedAt: Date; updatedAt: Date;
rank: string; rank: string;
highlight: string; highlight: string;
matchedText: string; matchedText: string[];
space: Partial<ISpace>; space: Partial<ISpace>;
} }
@@ -8,7 +8,7 @@ export class SearchResponseDto {
creatorId: string; creatorId: string;
rank: number; rank: number;
highlight: string; highlight: string;
matchedText?: string; matchedText?: string[];
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
space: Partial<Space>; space: Partial<Space>;
+13 -9
View File
@@ -140,16 +140,20 @@ export class SearchService {
//@ts-ignore //@ts-ignore
const searchResults = results.map((result: SearchResponseDto) => { const searchResults = results.map((result: SearchResponseDto) => {
if (result.highlight) { if (!result.highlight) {
result.highlight = result.highlight result.matchedText = [];
.replace(/\r\n|\r|\n/g, ' ') return result;
.replace(/\s+/g, ' ');
const matchedText = result.highlight.match(/<b>([^<]*)<\/b>/i);
result.matchedText = matchedText?.[1] ?? null;
} else {
result.matchedText = null;
} }
result.highlight = result.highlight
.replace(/\r\n|\r|\n/g, ' ')
.replace(/\s+/g, ' ');
result.matchedText = Array.from(
result.highlight.matchAll(/<b>([^<]*)<\/b>/gi),
(match) => match[1],
);
return result; return result;
}); });
@@ -39,7 +39,7 @@ declare module "@tiptap/core" {
/** /**
* @description Set search term in extension. * @description Set search term in extension.
*/ */
setSearchTerm: (searchTerm: string) => ReturnType; setSearchTerms: (searchTerms: string[]) => ReturnType;
/** /**
* @description Set whole word search in extension. * @description Set whole word search in extension.
*/ */
@@ -86,14 +86,18 @@ interface TextNodesWithPosition {
} }
const getRegex = ( const getRegex = (
s: string, searchTerms: string[],
disableRegex: boolean, disableRegex: boolean,
caseSensitive: boolean, caseSensitive: boolean,
wholeWord: boolean, wholeWord: boolean,
): RegExp => { ): RegExp => {
const pattern = disableRegex const terms = searchTerms.filter(Boolean).sort((a, b) => b.length - a.length);
? s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
: s; const pattern = terms
.map((term) =>
disableRegex ? term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : term,
)
.join("|");
const finalPattern = wholeWord const finalPattern = wholeWord
? `(?<![\\p{L}\\p{N}_])(?:${pattern})(?![\\p{L}\\p{N}_])` ? `(?<![\\p{L}\\p{N}_])(?:${pattern})(?![\\p{L}\\p{N}_])`
@@ -268,10 +272,10 @@ export interface SearchAndReplaceOptions {
} }
export interface SearchAndReplaceStorage { export interface SearchAndReplaceStorage {
searchTerm: string; searchTerms: string[];
replaceTerm: string; replaceTerm: string;
results: Range[]; results: Range[];
lastSearchTerm: string; lastSearchTerms: string[];
caseSensitive: boolean; caseSensitive: boolean;
lastCaseSensitive: boolean; lastCaseSensitive: boolean;
wholeWord: boolean; wholeWord: boolean;
@@ -295,10 +299,10 @@ export const SearchAndReplace = Extension.create<
addStorage() { addStorage() {
return { return {
searchTerm: "", searchTerms: [],
replaceTerm: "", replaceTerm: "",
results: [], results: [],
lastSearchTerm: "", lastSearchTerms: [],
caseSensitive: false, caseSensitive: false,
wholeWord: false, wholeWord: false,
lastWholeWord: false, lastWholeWord: false,
@@ -310,10 +314,10 @@ export const SearchAndReplace = Extension.create<
addCommands() { addCommands() {
return { return {
setSearchTerm: setSearchTerms:
(searchTerm: string) => (searchTerms: string[]) =>
({ editor }) => { ({ editor }) => {
editor.storage.searchAndReplace.searchTerm = searchTerm; editor.storage.searchAndReplace.searchTerms = searchTerms.filter(Boolean);
// clear whole word by default // clear whole word by default
// should remove if whole word toggle is added to search and replace dialog // should remove if whole word toggle is added to search and replace dialog
@@ -321,7 +325,7 @@ export const SearchAndReplace = Extension.create<
return false; return false;
}, },
setWholeWord: setWholeWord:
(wholeWord: boolean) => (wholeWord: boolean) =>
({ editor }) => { ({ editor }) => {
editor.storage.searchAndReplace.wholeWord = wholeWord; editor.storage.searchAndReplace.wholeWord = wholeWord;
@@ -437,8 +441,8 @@ export const SearchAndReplace = Extension.create<
const editor = this.editor; const editor = this.editor;
const { searchResultClass, disableRegex } = this.options; const { searchResultClass, disableRegex } = this.options;
const setLastSearchTerm = (t: string) => const setLastSearchTerms = (terms: string[]) =>
(editor.storage.searchAndReplace.lastSearchTerm = t); (editor.storage.searchAndReplace.lastSearchTerms = [...terms]);
const setLastWholeWord = (t: boolean) => const setLastWholeWord = (t: boolean) =>
(editor.storage.searchAndReplace.lastWholeWord = t); (editor.storage.searchAndReplace.lastWholeWord = t);
const setLastCaseSensitive = (t: boolean) => const setLastCaseSensitive = (t: boolean) =>
@@ -455,10 +459,10 @@ export const SearchAndReplace = Extension.create<
const storage = editor.storage.searchAndReplace; const storage = editor.storage.searchAndReplace;
if (!storage) return oldState; if (!storage) return oldState;
const { const {
searchTerm, searchTerms,
lastSearchTerm,
caseSensitive,
lastCaseSensitive, lastCaseSensitive,
lastSearchTerms,
caseSensitive,
wholeWord, wholeWord,
lastWholeWord, lastWholeWord,
resultIndex, resultIndex,
@@ -467,26 +471,32 @@ export const SearchAndReplace = Extension.create<
if ( if (
!docChanged && !docChanged &&
lastSearchTerm === searchTerm && searchTerms.length === lastSearchTerms.length &&
searchTerms.every((term, index) => term === lastSearchTerms[index]) &&
lastCaseSensitive === caseSensitive && lastCaseSensitive === caseSensitive &&
lastWholeWord === wholeWord && lastWholeWord === wholeWord &&
lastResultIndex === resultIndex lastResultIndex === resultIndex
) )
return oldState; return oldState;
setLastSearchTerm(searchTerm); setLastSearchTerms(searchTerms);
setLastCaseSensitive(caseSensitive); setLastCaseSensitive(caseSensitive);
setLastWholeWord(wholeWord); setLastWholeWord(wholeWord);
setLastResultIndex(resultIndex); setLastResultIndex(resultIndex);
if (!searchTerm) { if (searchTerms.length === 0) {
editor.storage.searchAndReplace.results = []; editor.storage.searchAndReplace.results = [];
return DecorationSet.empty; return DecorationSet.empty;
} }
const { decorationsToReturn, results } = processSearches( const { decorationsToReturn, results } = processSearches(
doc, doc,
getRegex(searchTerm, disableRegex, caseSensitive, wholeWord), getRegex(
searchTerms,
disableRegex,
caseSensitive,
wholeWord,
),
searchResultClass, searchResultClass,
resultIndex, resultIndex,
); );