From f4cdd721461410c4928e89cd1684b22be1404a58 Mon Sep 17 00:00:00 2001 From: Salihu Date: Sat, 29 Aug 2026 22:33:47 +0100 Subject: [PATCH] search navigation dialog --- .../search-navigation-dialog.tsx | 182 ++++++++++++++++++ .../src/features/editor/page-editor.tsx | 36 ++-- apps/client/src/features/page/page.utils.ts | 2 +- 3 files changed, 196 insertions(+), 24 deletions(-) create mode 100644 apps/client/src/features/editor/components/search-and-replace/search-navigation-dialog.tsx diff --git a/apps/client/src/features/editor/components/search-and-replace/search-navigation-dialog.tsx b/apps/client/src/features/editor/components/search-and-replace/search-navigation-dialog.tsx new file mode 100644 index 000000000..d13548463 --- /dev/null +++ b/apps/client/src/features/editor/components/search-and-replace/search-navigation-dialog.tsx @@ -0,0 +1,182 @@ +import { ActionIcon, Dialog, Flex, Text, Tooltip } from "@mantine/core"; +import { IconArrowNarrowDown, IconArrowNarrowUp, IconX } from "@tabler/icons-react"; +import { Editor, useEditor } from "@tiptap/react"; +import { isEditorReady } from "@docmost/editor-ext"; +import React, { useEffect, useState } from "react"; +import classes from "./search-replace.module.css"; +import { useTranslation } from "react-i18next"; + +interface SearchNavigationDialogProps { + editor: ReturnType; +} + +interface SearchNavigationEvent extends CustomEvent { + detail: { + searchTerms: string[]; + wholeWord?: boolean; + }; +} + +function SearchNavigationDialog({ editor }: SearchNavigationDialogProps) { + const {t} = useTranslation() + const [open, setOpen] = useState(false); + const [searchTerms, setSearchTerms] = useState([]); + const [resultState, setResultState] = useState({ + resultIndex: 0, + resultsLength: 0, + }); + + const handleSetSearchTerms = (editor: Editor, terms: string[]) => { + setSearchTerms(terms); + if (isEditorReady(editor)) { + editor.commands.setSearchTerms(terms); + } + }; + + 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(); + }; + + useEffect(() => { + const handleOpen = (event: Event) => { + const { searchTerms: terms, wholeWord = true } = ( + event as SearchNavigationEvent + ).detail; + + if (!terms?.length || !isEditorReady(editor)) return; + + setOpen(true); + handleSetSearchTerms(editor, terms); + editor.commands.setWholeWord(wholeWord); + editor.commands.resetIndex(); + + const { results, resultIndex } = editor.storage.searchAndReplace; + setResultState({ + resultIndex, + resultsLength: results.length, + }); + + goToSelection(); + }; + + const handleClose = () => { + setOpen(false); + }; + + document.addEventListener("openSearchNavigationDialog", handleOpen); + document.addEventListener("openFindDialogFromEditor", handleClose); + + return () => { + document.removeEventListener("openSearchNavigationDialog", handleOpen); + document.removeEventListener("openFindDialogFromEditor", handleClose); + }; + }, [editor]); + + useEffect(() => { + const handleTransaction = () => { + if (!open || editor.isDestroyed) return; + + const { results } = editor.storage.searchAndReplace; + if (results.length === 0) { + setOpen(false); + } + }; + + editor.on("transaction", handleTransaction); + return () => { + editor.off("transaction", handleTransaction); + }; + }, [editor, open]); + + const close = () => { + handleSetSearchTerms(editor, [""]); + setOpen(false); + }; + + return ( + + + + {resultState.resultsLength > 0 + ? `${resultState.resultIndex + 1}/${resultState.resultsLength}` + : t("Not found")} + + + + + + + + + + + + + + + + + + {searchTerms.length > 0 && ( + + {searchTerms.join(", ")} + + )} + + ); +} + +export default SearchNavigationDialog; diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index c5dc820c9..91daf9b0f 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -61,6 +61,7 @@ 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 { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks"; import { useIdle } from "@/hooks/use-idle.ts"; import { queryClient } from "@/main.tsx"; @@ -195,7 +196,7 @@ function CollabPageEditor({ const documentState = useDocumentVisibility(); const { pageSlug } = useParams(); const [searchParams] = useSearchParams(); - const searchQueries = searchParams.getAll("q") + const searchQueries = searchParams.getAll("q"); const slugId = extractPageSlugId(pageSlug); const currentPageEditMode = useAtomValue(currentPageEditModeAtom); const canScroll = useCallback( @@ -423,28 +424,16 @@ function CollabPageEditor({ if (!editor || editor.isDestroyed) return; if (showStatic || !isSynced) return; if (!searchQueries.length || appliedSearchRef.current) return; - - const frame = requestAnimationFrame(() => { - if (!editor || editor.isDestroyed || !editor.view.dom.isConnected) return; - - editor.commands.setSearchTerms(searchQueries); - editor.commands.setWholeWord(true); - editor.commands.resetIndex(); - - const { results, resultIndex } = editor.storage.searchAndReplace; - const position = results[resultIndex]; - - if (!position) return; - - appliedSearchRef.current = true; - - const element = document.querySelector(".search-result-current"); - element?.scrollIntoView({ behavior: "smooth", block: "center" }); - editor.commands.setTextSelection(0) - }); - - - return () => cancelAnimationFrame(frame); + if (!editor || editor.isDestroyed || !editor.view.dom.isConnected) return; + + appliedSearchRef.current = true; + + document.dispatchEvent( + new CustomEvent("openSearchNavigationDialog", { + detail: { searchTerms: searchQueries, wholeWord: true }, + }) + ); + }, [editor, isSynced, showStatic, searchQueries]); useEffect(() => { @@ -470,6 +459,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 411c408fe..c078ee766 100644 --- a/apps/client/src/features/page/page.utils.ts +++ b/apps/client/src/features/page/page.utils.ts @@ -62,7 +62,7 @@ export const buildSharedPageUrl = (opts: { pageSlugId: string; pageTitle?: string; anchorId?: string; - search?: string[] + search?: string[]; }): string => { const { shareId, pageSlugId, pageTitle, anchorId, search } = opts; let url: string;