From 70c8c6cfda8866d855552642a5a80393c52c0447 Mon Sep 17 00:00:00 2001 From: Salihu Date: Fri, 28 Aug 2026 23:05:14 +0100 Subject: [PATCH] jump to search init --- .../search-and-replace-dialog.tsx | 4 +- .../src/features/editor/page-editor.tsx | 35 ++++++++++++- apps/client/src/features/page/page.utils.ts | 14 +++++- .../search/components/search-result-item.tsx | 2 + .../src/features/search/types/search.types.ts | 1 + .../core/search/dto/search-response.dto.ts | 1 + apps/server/src/core/search/search.service.ts | 5 ++ .../search-and-replace/search-and-replace.ts | 50 ++++++++++++++++--- 8 files changed, 101 insertions(+), 11 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 d64d2614c..082116cd3 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 @@ -181,7 +181,9 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo const location = useLocation(); useEffect(() => { - closeDialog(); + if(pageFindState.isOpen){ + closeDialog(); + } }, [location]); return ( diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index 77233f34f..fd1f96ad3 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -65,7 +65,7 @@ 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"; @@ -194,6 +194,8 @@ function CollabPageEditor({ const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false }); const documentState = useDocumentVisibility(); const { pageSlug } = useParams(); + const [searchParams] = useSearchParams(); + const q = searchParams.get("q"); const slugId = extractPageSlugId(pageSlug); const currentPageEditMode = useAtomValue(currentPageEditModeAtom); const canScroll = useCallback( @@ -415,6 +417,37 @@ function CollabPageEditor({ const hasConnectedOnceRef = useRef(false); const [showStatic, setShowStatic] = useState(true); + const appliedSearchRef = useRef(false); + + useEffect(() => { + if (!editor || editor.isDestroyed) return; + if (showStatic || !isSynced) return; + if (!q?.trim() || appliedSearchRef.current) return; + + const frame = requestAnimationFrame(() => { + if (!editor || editor.isDestroyed || !editor.view.dom.isConnected) return; + + editor.commands.setSearchTerm(q); + editor.commands.resetIndex(); + + const { results, resultIndex } = editor.storage.searchAndReplace; + const position = results[resultIndex]; + + if (!position) return; + + 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) + }); + + + return () => cancelAnimationFrame(frame); + }, [editor, isSynced, showStatic, q]); + useEffect(() => { if ( !hasConnectedOnceRef.current && diff --git a/apps/client/src/features/page/page.utils.ts b/apps/client/src/features/page/page.utils.ts index 8b0111a28..a02364743 100644 --- a/apps/client/src/features/page/page.utils.ts +++ b/apps/client/src/features/page/page.utils.ts @@ -30,6 +30,7 @@ export const buildPageUrl = ( pageSlugId: string, pageTitle?: string, anchorId?: string, + searchString?: string ): string => { let url: string; if (spaceName === undefined) { @@ -37,6 +38,11 @@ export const buildPageUrl = ( } else { url = `/s/${spaceName}/p/${buildPageSlug(pageSlugId, pageTitle)}`; } + + if (searchString) { + url += `?q=${encodeURIComponent(searchString)}`; + } + return anchorId ? `${url}#${anchorId}` : url; }; @@ -45,13 +51,19 @@ export const buildSharedPageUrl = (opts: { pageSlugId: string; pageTitle?: string; anchorId?: string; + searchString?: string }): string => { - const { shareId, pageSlugId, pageTitle, anchorId } = opts; + const { shareId, pageSlugId, pageTitle, anchorId, searchString } = opts; let url: string; if (!shareId) { url = `/share/p/${buildPageSlug(pageSlugId, pageTitle)}`; } else { url = `/share/${shareId}/p/${buildPageSlug(pageSlugId, pageTitle)}`; } + + if (searchString) { + url += `?q=${encodeURIComponent(searchString)}`; + } + 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 24d3472b5..010b7a746 100644 --- a/apps/client/src/features/search/components/search-result-item.tsx +++ b/apps/client/src/features/search/components/search-result-item.tsx @@ -101,6 +101,8 @@ export function SearchResultItem({ pageResult.space.slug, pageResult.slugId, pageResult.title, + undefined, + pageResult.matchedText )} 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 9962b9ca2..9af62f9e9 100644 --- a/apps/client/src/features/search/types/search.types.ts +++ b/apps/client/src/features/search/types/search.types.ts @@ -14,6 +14,7 @@ export interface IPageSearch { updatedAt: Date; rank: string; highlight: 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 8f5b343dd..42a53f9f4 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,7 @@ export class SearchResponseDto { creatorId: string; rank: number; highlight: 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 9883b2654..4216abac5 100644 --- a/apps/server/src/core/search/search.service.ts +++ b/apps/server/src/core/search/search.service.ts @@ -144,6 +144,11 @@ export class SearchService { 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; } 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 1e77732a6..e2c4fa832 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 @@ -40,6 +40,10 @@ declare module "@tiptap/core" { * @description Set search term in extension. */ setSearchTerm: (searchTerm: string) => ReturnType; + /** + * @description Set whole word search in extension. + */ + setWholeWord: (wholeWord: boolean) => ReturnType; /** * @description Set replace term in extension. */ @@ -85,10 +89,19 @@ const getRegex = ( s: string, disableRegex: boolean, caseSensitive: boolean, + wholeWord: boolean, ): RegExp => { - return RegExp( - disableRegex ? s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : s, - caseSensitive ? "gu" : "gui", + const pattern = disableRegex + ? s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + : s; + + const finalPattern = wholeWord + ? `(? { editor.storage.searchAndReplace.searchTerm = searchTerm; + // 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; }, setReplaceTerm: @@ -411,6 +439,8 @@ export const SearchAndReplace = Extension.create< const setLastSearchTerm = (t: string) => (editor.storage.searchAndReplace.lastSearchTerm = t); + const setLastWholeWord = (t: boolean) => + (editor.storage.searchAndReplace.lastWholeWord = t); const setLastCaseSensitive = (t: boolean) => (editor.storage.searchAndReplace.lastCaseSensitive = t); const setLastResultIndex = (t: number) => @@ -429,6 +459,8 @@ export const SearchAndReplace = Extension.create< lastSearchTerm, caseSensitive, lastCaseSensitive, + wholeWord, + lastWholeWord, resultIndex, lastResultIndex, } = storage; @@ -437,12 +469,14 @@ export const SearchAndReplace = Extension.create< !docChanged && lastSearchTerm === searchTerm && lastCaseSensitive === caseSensitive && + lastWholeWord === wholeWord && lastResultIndex === resultIndex ) return oldState; setLastSearchTerm(searchTerm); setLastCaseSensitive(caseSensitive); + setLastWholeWord(wholeWord); setLastResultIndex(resultIndex); if (!searchTerm) { @@ -451,11 +485,11 @@ export const SearchAndReplace = Extension.create< } const { decorationsToReturn, results } = processSearches( - doc, - getRegex(searchTerm, disableRegex, caseSensitive), - searchResultClass, - resultIndex, - ); + doc, + getRegex(searchTerm, disableRegex, caseSensitive, wholeWord), + searchResultClass, + resultIndex, + ); editor.storage.searchAndReplace.results = results;