From 84e1e56cf6a5135ac700c254887ed172eca1fe26 Mon Sep 17 00:00:00 2001 From: Salihu <91833785+salihudickson@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:06:52 +0100 Subject: [PATCH] feat: jump to search (#2453) * jump to search init * support multiple text matching * search navigation dialog * remove search text * typesense support * typesense support * add search guard * use search key check * minor fix * minor fix * fix: clean up jump search state on close --------- Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com> --- .../search-and-replace-dialog.tsx | 10 +- .../search-navigation-dialog.tsx | 205 ++++++++++++++++++ .../use-search-navigation-params.ts | 47 ++++ .../src/features/editor/page-editor.tsx | 14 +- apps/client/src/features/page/page.utils.ts | 36 ++- .../search/components/search-result-item.tsx | 3 + .../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 +- apps/server/src/ee | 2 +- .../search-and-replace/search-and-replace.ts | 94 +++++--- 11 files changed, 401 insertions(+), 36 deletions(-) create mode 100644 apps/client/src/features/editor/components/search-and-replace/search-navigation-dialog.tsx create mode 100644 apps/client/src/features/editor/components/search-and-replace/use-search-navigation-params.ts 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 d64d2614c..f473f8ff5 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]); @@ -181,8 +181,10 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo const location = useLocation(); useEffect(() => { - closeDialog(); - }, [location]); + if (pageFindState.isOpen) { + closeDialog(); + } + }, [location.pathname]); return ( ; +} + +interface SearchNavigationEvent extends CustomEvent { + detail: { + searchTerms: string[]; + wholeWord?: boolean; + }; +} + +function SearchNavigationDialog({ editor }: SearchNavigationDialogProps) { + const { t } = useTranslation(); + const location = useLocation(); + const navigate = useNavigate(); + const [open, setOpen] = useState(false); + const openRef = useRef(false); + const [resultState, setResultState] = useState({ + resultIndex: 0, + resultsLength: 0, + }); + + const goToSelection = () => { + if (!isEditorReady(editor)) return; + + const { results, resultIndex } = editor.storage.searchAndReplace; + const position = results[resultIndex]; + + setResultState({ + resultsLength: results.length, + resultIndex, + }); + + if (!position) return; + requestAnimationFrame(() => { + document + .querySelector(".search-result-current") + ?.scrollIntoView({ behavior: "smooth", block: "center" }); + }); + }; + + const next = () => { + if (!isEditorReady(editor)) return; + editor.commands.nextSearchResult(); + goToSelection(); + }; + + const previous = () => { + if (!isEditorReady(editor)) return; + editor.commands.previousSearchResult(); + goToSelection(); + }; + + const close = useCallback(() => { + if (!openRef.current) return; + + openRef.current = false; + setOpen(false); + if (isEditorReady(editor)) { + editor.commands.setSearchTerms([""]); + } + const nextParams = new URLSearchParams(location.search); + nextParams.delete("q"); + nextParams.delete("m"); + const nextSearch = nextParams.toString(); + navigate( + { + pathname: location.pathname, + search: nextSearch ? `?${nextSearch}` : "", + hash: location.hash, + }, + { replace: true }, + ); + }, [editor, location.hash, location.pathname, location.search, navigate]); + + useEffect(() => { + const handleOpen = (event: Event) => { + const { searchTerms: terms, wholeWord = true } = ( + event as SearchNavigationEvent + ).detail; + + if (!terms?.length || !isEditorReady(editor)) return; + + openRef.current = false; + editor.commands.setSearchTerms(terms); + editor.commands.setWholeWord(wholeWord); + editor.commands.resetIndex(); + + const { results, resultIndex } = editor.storage.searchAndReplace; + openRef.current = true; + if (results.length === 0) { + close(); + return; + } + + setOpen(true); + setResultState({ + resultIndex, + resultsLength: results.length, + }); + + goToSelection(); + }; + + const handleClose = () => { + if (openRef.current) { + close(); + } + }; + + document.addEventListener("openSearchNavigationDialog", handleOpen); + document.addEventListener("openFindDialogFromEditor", handleClose); + document.addEventListener("closeFindDialogFromEditor", handleClose); + + return () => { + document.removeEventListener("openSearchNavigationDialog", handleOpen); + document.removeEventListener("openFindDialogFromEditor", handleClose); + document.removeEventListener("closeFindDialogFromEditor", handleClose); + }; + }, [close, editor]); + + useEffect(() => { + const handleTransaction = () => { + if (!openRef.current || editor.isDestroyed) return; + + const { results } = editor.storage.searchAndReplace; + if (results.length === 0) { + close(); + } + }; + + editor.on("transaction", handleTransaction); + return () => { + editor.off("transaction", handleTransaction); + }; + }, [close, editor]); + + return ( + + + + {resultState.resultsLength > 0 + ? `${resultState.resultIndex + 1}/${resultState.resultsLength}` + : t("Not found")} + + + + + + + + + + + + + + + + + + + ); +} + +export default SearchNavigationDialog; diff --git a/apps/client/src/features/editor/components/search-and-replace/use-search-navigation-params.ts b/apps/client/src/features/editor/components/search-and-replace/use-search-navigation-params.ts new file mode 100644 index 000000000..cbb8ee23a --- /dev/null +++ b/apps/client/src/features/editor/components/search-and-replace/use-search-navigation-params.ts @@ -0,0 +1,47 @@ +import { useEffect, useRef } from "react"; +import type { useEditor } from "@tiptap/react"; + +interface UseSearchNavigationParamsProps { + editor: ReturnType; + isSynced: boolean; + pageId: string; + searchParams: URLSearchParams; + showStatic: boolean; +} + +export function useSearchNavigationParams({ + editor, + isSynced, + pageId, + searchParams, + showStatic, +}: UseSearchNavigationParamsProps) { + const appliedSearchKeyRef = useRef(null); + const searchKey = `${pageId}:${searchParams.toString()}`; + + useEffect(() => { + const searchQueries = searchParams.getAll("q"); + if (!searchQueries.length) { + appliedSearchKeyRef.current = null; + return; + } + + if ( + !editor || + editor.isDestroyed || + !editor.view.dom.isConnected || + appliedSearchKeyRef.current === searchKey + ) { + return; + } + + const match = searchParams.get("m"); + appliedSearchKeyRef.current = searchKey; + + document.dispatchEvent( + new CustomEvent("openSearchNavigationDialog", { + detail: { searchTerms: searchQueries, wholeWord: match === "whole" }, + }), + ); + }, [editor, isSynced, searchKey, searchParams, showStatic]); +} diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index c926a7317..8e6e1036a 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -65,11 +65,13 @@ import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu-lazy"; import DrawioMenu from "./components/drawio/drawio-menu"; import { useCollabToken } from "@/features/auth/queries/auth-query.tsx"; import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx"; +import SearchNavigationDialog from "@/features/editor/components/search-and-replace/search-navigation-dialog.tsx"; +import { useSearchNavigationParams } from "@/features/editor/components/search-and-replace/use-search-navigation-params.ts"; import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks"; import { useIdle } from "@/hooks/use-idle.ts"; import { queryClient } from "@/main.tsx"; import { IPage } from "@/features/page/types/page.types.ts"; -import { useParams } from "react-router-dom"; +import { useParams, useSearchParams } from "react-router-dom"; import { extractPageSlugId, platformModifierKey } from "@/lib"; import { FIVE_MINUTES } from "@/lib/constants.ts"; import { PageEditMode } from "@/features/user/types/user.types.ts"; @@ -199,6 +201,7 @@ function CollabPageEditor({ const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false }); const documentState = useDocumentVisibility(); const { pageSlug } = useParams(); + const [searchParams] = useSearchParams(); const slugId = extractPageSlugId(pageSlug); const currentPageEditMode = useAtomValue(currentPageEditModeAtom); const canScroll = useCallback( @@ -437,6 +440,14 @@ function CollabPageEditor({ const hasConnectedOnceRef = useRef(false); const [showStatic, setShowStatic] = useState(true); + useSearchNavigationParams({ + editor, + isSynced, + pageId, + searchParams, + showStatic, + }); + useEffect(() => { if ( !hasConnectedOnceRef.current && @@ -460,6 +471,7 @@ function CollabPageEditor({ {editor && ( )} + {editor && } {editor && editorIsEditable && (
diff --git a/apps/client/src/features/page/page.utils.ts b/apps/client/src/features/page/page.utils.ts index 638ba506a..6c83e19de 100644 --- a/apps/client/src/features/page/page.utils.ts +++ b/apps/client/src/features/page/page.utils.ts @@ -25,11 +25,37 @@ const buildPageSlug = (pageSlugId: string, pageTitle?: string): string => { return `${titleSlug}-${pageSlugId}`; }; +function appendSearchParams( + url: string, + search?: string[], + wholeWord?: boolean, +): string { + if(search?.length === 0){ + return url; + } + + const params = new URLSearchParams(); + search + ?.map((term) => term.trim()) + .filter(Boolean) + .forEach((term) => params.append("q", term)); + + if (wholeWord) { + params.set("m", "whole"); + } + + const queryString = params.toString(); + + return queryString ? `${url}?${queryString}` : url; +} + export const buildPageUrl = ( spaceName: string, pageSlugId: string, pageTitle?: string, anchorId?: string, + search?: string[], + wholeWord?: boolean, ): string => { let url: string; if (spaceName === undefined) { @@ -37,6 +63,9 @@ export const buildPageUrl = ( } else { url = `/s/${spaceName}/p/${buildPageSlug(pageSlugId, pageTitle)}`; } + + url = appendSearchParams(url, search, wholeWord); + return anchorId ? `${url}#${anchorId}` : url; }; @@ -45,14 +74,19 @@ export const buildSharedPageUrl = (opts: { pageSlugId: string; pageTitle?: string; anchorId?: string; + search?: string[]; + wholeWord?: boolean; }): string => { - const { shareId, pageSlugId, pageTitle, anchorId } = opts; + const { shareId, pageSlugId, pageTitle, anchorId, search, wholeWord } = opts; let url: string; if (!shareId) { url = `/share/p/${buildPageSlug(pageSlugId, pageTitle)}`; } else { url = `/share/${shareId}/p/${buildPageSlug(pageSlugId, pageTitle)}`; } + + url = appendSearchParams(url, search, wholeWord); + return anchorId ? `${url}#${anchorId}` : url; }; diff --git a/apps/client/src/features/search/components/search-result-item.tsx b/apps/client/src/features/search/components/search-result-item.tsx index 4412b9a97..edb2ef8b7 100644 --- a/apps/client/src/features/search/components/search-result-item.tsx +++ b/apps/client/src/features/search/components/search-result-item.tsx @@ -117,6 +117,9 @@ export function SearchResultItem({ pageResult.space.slug, pageResult.slugId, pageResult.title, + undefined, + pageResult.matchedText, + pageResult.wholeWord )} style={{ userSelect: "none" }} > diff --git a/apps/client/src/features/search/types/search.types.ts b/apps/client/src/features/search/types/search.types.ts index 60ae5d985..e90f06f3f 100644 --- a/apps/client/src/features/search/types/search.types.ts +++ b/apps/client/src/features/search/types/search.types.ts @@ -14,6 +14,8 @@ export interface IPageSearch { updatedAt: Date; rank: string; highlight: string; + matchedText: string[]; + wholeWord: boolean; 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 8f5b343dd..e16ff8898 100644 --- a/apps/server/src/core/search/dto/search-response.dto.ts +++ b/apps/server/src/core/search/dto/search-response.dto.ts @@ -8,6 +8,8 @@ export class SearchResponseDto { creatorId: string; rank: number; highlight: string; + matchedText: string[]; + wholeWord: boolean; 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 bfb3ca918..f8e8e5517 100644 --- a/apps/server/src/core/search/search.service.ts +++ b/apps/server/src/core/search/search.service.ts @@ -191,11 +191,25 @@ 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, ' '); + result.wholeWord = true + if (!result.highlight) { + result.matchedText = []; + return result; } + + result.highlight = result.highlight + .replace(/\r\n|\r|\n/g, ' ') + .replace(/\s+/g, ' '); + + result.matchedText = [ + ...new Set( + Array.from( + result.highlight.matchAll(/([^<]*)<\/b>/gi), + (match) => match[1], + ), + ), + ]; + return result; }); diff --git a/apps/server/src/ee b/apps/server/src/ee index 5b873a53c..5e7120dcc 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 5b873a53c835f81bdd2f2e4ce827288417ffa26b +Subproject commit 5e7120dcc86a344f5af09ccda0a29d5784659938 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 1e77732a6..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,11 @@ 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. + */ + setWholeWord: (wholeWord: boolean) => ReturnType; /** * @description Set replace term in extension. */ @@ -82,13 +86,26 @@ interface TextNodesWithPosition { } const getRegex = ( - s: string, + searchTerms: string[], disableRegex: boolean, caseSensitive: boolean, + wholeWord: boolean, ): RegExp => { - return RegExp( - disableRegex ? s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : s, - caseSensitive ? "gu" : "gui", + 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 + editor.storage.searchAndReplace.wholeWord = false; + + return false; + }, + setWholeWord: + (wholeWord: boolean) => + ({ editor }) => { + editor.storage.searchAndReplace.wholeWord = wholeWord; return false; }, @@ -409,8 +441,10 @@ 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) => (editor.storage.searchAndReplace.lastCaseSensitive = t); const setLastResultIndex = (t: number) => @@ -425,37 +459,47 @@ 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, lastResultIndex, } = storage; 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), - searchResultClass, - resultIndex, - ); + doc, + getRegex( + searchTerms, + disableRegex, + caseSensitive, + wholeWord, + ), + searchResultClass, + resultIndex, + ); editor.storage.searchAndReplace.results = results;