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>
This commit is contained in:
Salihu
2026-09-05 16:06:52 +01:00
committed by GitHub
co-authored by Philipinho
parent 6ff8c7cdfe
commit 84e1e56cf6
11 changed files with 401 additions and 36 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]);
@@ -181,8 +181,10 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
const location = useLocation(); const location = useLocation();
useEffect(() => { useEffect(() => {
if (pageFindState.isOpen) {
closeDialog(); closeDialog();
}, [location]); }
}, [location.pathname]);
return ( return (
<Dialog <Dialog
@@ -0,0 +1,205 @@
import { ActionIcon, Dialog, Flex, Text, Tooltip } from "@mantine/core";
import {
IconArrowNarrowDown,
IconArrowNarrowUp,
IconX,
} from "@tabler/icons-react";
import { useEditor } from "@tiptap/react";
import { isEditorReady } from "@docmost/editor-ext";
import React, { useCallback, useEffect, useRef, useState } from "react";
import classes from "./search-replace.module.css";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom";
interface SearchNavigationDialogProps {
editor: ReturnType<typeof useEditor>;
}
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 (
<Dialog
className={classes.findDialog}
opened={open}
size="xs"
radius="md"
w="auto"
position={{ top: 90, right: 50 }}
withBorder
aria-label="Search navigation"
>
<Flex align="center" gap="xs">
<Text size="xs" style={{ flex: 1 }}>
{resultState.resultsLength > 0
? `${resultState.resultIndex + 1}/${resultState.resultsLength}`
: t("Not found")}
</Text>
<Tooltip label="Previous match">
<ActionIcon
variant="subtle"
color="gray"
onClick={previous}
aria-label="Previous match"
disabled={resultState.resultsLength === 0}
>
<IconArrowNarrowUp size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Next match">
<ActionIcon
variant="subtle"
color="gray"
onClick={next}
aria-label="Next match"
disabled={resultState.resultsLength === 0}
>
<IconArrowNarrowDown size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Close">
<ActionIcon
variant="subtle"
color="gray"
onClick={close}
aria-label="Close"
>
<IconX size={16} />
</ActionIcon>
</Tooltip>
</Flex>
</Dialog>
);
}
export default SearchNavigationDialog;
@@ -0,0 +1,47 @@
import { useEffect, useRef } from "react";
import type { useEditor } from "@tiptap/react";
interface UseSearchNavigationParamsProps {
editor: ReturnType<typeof useEditor>;
isSynced: boolean;
pageId: string;
searchParams: URLSearchParams;
showStatic: boolean;
}
export function useSearchNavigationParams({
editor,
isSynced,
pageId,
searchParams,
showStatic,
}: UseSearchNavigationParamsProps) {
const appliedSearchKeyRef = useRef<string | null>(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]);
}
@@ -65,11 +65,13 @@ import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu-lazy";
import DrawioMenu from "./components/drawio/drawio-menu"; import DrawioMenu from "./components/drawio/drawio-menu";
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx"; import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.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 { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks";
import { useIdle } from "@/hooks/use-idle.ts"; import { useIdle } from "@/hooks/use-idle.ts";
import { queryClient } from "@/main.tsx"; import { queryClient } from "@/main.tsx";
import { IPage } from "@/features/page/types/page.types.ts"; 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 { extractPageSlugId, platformModifierKey } from "@/lib";
import { FIVE_MINUTES } from "@/lib/constants.ts"; import { FIVE_MINUTES } from "@/lib/constants.ts";
import { PageEditMode } from "@/features/user/types/user.types.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 { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
const documentState = useDocumentVisibility(); const documentState = useDocumentVisibility();
const { pageSlug } = useParams(); const { pageSlug } = useParams();
const [searchParams] = useSearchParams();
const slugId = extractPageSlugId(pageSlug); const slugId = extractPageSlugId(pageSlug);
const currentPageEditMode = useAtomValue(currentPageEditModeAtom); const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
const canScroll = useCallback( const canScroll = useCallback(
@@ -437,6 +440,14 @@ function CollabPageEditor({
const hasConnectedOnceRef = useRef(false); const hasConnectedOnceRef = useRef(false);
const [showStatic, setShowStatic] = useState(true); const [showStatic, setShowStatic] = useState(true);
useSearchNavigationParams({
editor,
isSynced,
pageId,
searchParams,
showStatic,
});
useEffect(() => { useEffect(() => {
if ( if (
!hasConnectedOnceRef.current && !hasConnectedOnceRef.current &&
@@ -460,6 +471,7 @@ function CollabPageEditor({
{editor && ( {editor && (
<SearchAndReplaceDialog editor={editor} editable={editable} /> <SearchAndReplaceDialog editor={editor} editable={editable} />
)} )}
{editor && <SearchNavigationDialog editor={editor} />}
{editor && editorIsEditable && ( {editor && editorIsEditable && (
<div> <div>
+35 -1
View File
@@ -25,11 +25,37 @@ const buildPageSlug = (pageSlugId: string, pageTitle?: string): string => {
return `${titleSlug}-${pageSlugId}`; 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 = ( export const buildPageUrl = (
spaceName: string, spaceName: string,
pageSlugId: string, pageSlugId: string,
pageTitle?: string, pageTitle?: string,
anchorId?: string, anchorId?: string,
search?: string[],
wholeWord?: boolean,
): string => { ): string => {
let url: string; let url: string;
if (spaceName === undefined) { if (spaceName === undefined) {
@@ -37,6 +63,9 @@ export const buildPageUrl = (
} else { } else {
url = `/s/${spaceName}/p/${buildPageSlug(pageSlugId, pageTitle)}`; url = `/s/${spaceName}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
} }
url = appendSearchParams(url, search, wholeWord);
return anchorId ? `${url}#${anchorId}` : url; return anchorId ? `${url}#${anchorId}` : url;
}; };
@@ -45,14 +74,19 @@ export const buildSharedPageUrl = (opts: {
pageSlugId: string; pageSlugId: string;
pageTitle?: string; pageTitle?: string;
anchorId?: string; anchorId?: string;
search?: string[];
wholeWord?: boolean;
}): string => { }): string => {
const { shareId, pageSlugId, pageTitle, anchorId } = opts; const { shareId, pageSlugId, pageTitle, anchorId, search, wholeWord } = opts;
let url: string; let url: string;
if (!shareId) { if (!shareId) {
url = `/share/p/${buildPageSlug(pageSlugId, pageTitle)}`; url = `/share/p/${buildPageSlug(pageSlugId, pageTitle)}`;
} else { } else {
url = `/share/${shareId}/p/${buildPageSlug(pageSlugId, pageTitle)}`; url = `/share/${shareId}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
} }
url = appendSearchParams(url, search, wholeWord);
return anchorId ? `${url}#${anchorId}` : url; return anchorId ? `${url}#${anchorId}` : url;
}; };
@@ -117,6 +117,9 @@ export function SearchResultItem({
pageResult.space.slug, pageResult.space.slug,
pageResult.slugId, pageResult.slugId,
pageResult.title, pageResult.title,
undefined,
pageResult.matchedText,
pageResult.wholeWord
)} )}
style={{ userSelect: "none" }} style={{ userSelect: "none" }}
> >
@@ -14,6 +14,8 @@ export interface IPageSearch {
updatedAt: Date; updatedAt: Date;
rank: string; rank: string;
highlight: string; highlight: string;
matchedText: string[];
wholeWord: boolean;
space: Partial<ISpace>; space: Partial<ISpace>;
} }
@@ -8,6 +8,8 @@ export class SearchResponseDto {
creatorId: string; creatorId: string;
rank: number; rank: number;
highlight: string; highlight: string;
matchedText: string[];
wholeWord: boolean;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
space: Partial<Space>; space: Partial<Space>;
+16 -2
View File
@@ -191,11 +191,25 @@ export class SearchService {
//@ts-ignore //@ts-ignore
const searchResults = results.map((result: SearchResponseDto) => { const searchResults = results.map((result: SearchResponseDto) => {
if (result.highlight) { result.wholeWord = true
if (!result.highlight) {
result.matchedText = [];
return result;
}
result.highlight = result.highlight result.highlight = result.highlight
.replace(/\r\n|\r|\n/g, ' ') .replace(/\r\n|\r|\n/g, ' ')
.replace(/\s+/g, ' '); .replace(/\s+/g, ' ');
}
result.matchedText = [
...new Set(
Array.from(
result.highlight.matchAll(/<b>([^<]*)<\/b>/gi),
(match) => match[1],
),
),
];
return result; return result;
}); });
@@ -39,7 +39,11 @@ 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.
*/
setWholeWord: (wholeWord: boolean) => ReturnType;
/** /**
* @description Set replace term in extension. * @description Set replace term in extension.
*/ */
@@ -82,13 +86,26 @@ interface TextNodesWithPosition {
} }
const getRegex = ( const getRegex = (
s: string, searchTerms: string[],
disableRegex: boolean, disableRegex: boolean,
caseSensitive: boolean, caseSensitive: boolean,
wholeWord: boolean,
): RegExp => { ): RegExp => {
return RegExp( const terms = searchTerms.filter(Boolean).sort((a, b) => b.length - a.length);
disableRegex ? s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : s,
caseSensitive ? "gu" : "gui", const pattern = terms
.map((term) =>
disableRegex ? term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : term,
)
.join("|");
const finalPattern = wholeWord
? `(?<![\\p{L}\\p{N}_])(?:${pattern})(?![\\p{L}\\p{N}_])`
: pattern;
return new RegExp(
finalPattern,
caseSensitive ? "gu" : "giu",
); );
}; };
@@ -255,12 +272,14 @@ 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;
lastWholeWord: boolean;
resultIndex: number; resultIndex: number;
lastResultIndex: number; lastResultIndex: number;
} }
@@ -280,11 +299,13 @@ export const SearchAndReplace = Extension.create<
addStorage() { addStorage() {
return { return {
searchTerm: "", searchTerms: [],
replaceTerm: "", replaceTerm: "",
results: [], results: [],
lastSearchTerm: "", lastSearchTerms: [],
caseSensitive: false, caseSensitive: false,
wholeWord: false,
lastWholeWord: false,
lastCaseSensitive: false, lastCaseSensitive: false,
resultIndex: 0, resultIndex: 0,
lastResultIndex: 0, lastResultIndex: 0,
@@ -293,10 +314,21 @@ 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
// 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; return false;
}, },
@@ -409,8 +441,10 @@ 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) =>
(editor.storage.searchAndReplace.lastWholeWord = t);
const setLastCaseSensitive = (t: boolean) => const setLastCaseSensitive = (t: boolean) =>
(editor.storage.searchAndReplace.lastCaseSensitive = t); (editor.storage.searchAndReplace.lastCaseSensitive = t);
const setLastResultIndex = (t: number) => const setLastResultIndex = (t: number) =>
@@ -425,34 +459,44 @@ 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,
lastWholeWord,
resultIndex, resultIndex,
lastResultIndex, lastResultIndex,
} = storage; } = storage;
if ( if (
!docChanged && !docChanged &&
lastSearchTerm === searchTerm && searchTerms.length === lastSearchTerms.length &&
searchTerms.every((term, index) => term === lastSearchTerms[index]) &&
lastCaseSensitive === caseSensitive && lastCaseSensitive === caseSensitive &&
lastWholeWord === wholeWord &&
lastResultIndex === resultIndex lastResultIndex === resultIndex
) )
return oldState; return oldState;
setLastSearchTerm(searchTerm); setLastSearchTerms(searchTerms);
setLastCaseSensitive(caseSensitive); setLastCaseSensitive(caseSensitive);
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), getRegex(
searchTerms,
disableRegex,
caseSensitive,
wholeWord,
),
searchResultClass, searchResultClass,
resultIndex, resultIndex,
); );