search navigation dialog

This commit is contained in:
Salihu
2026-08-29 22:33:47 +01:00
parent 9797a3671f
commit f4cdd72146
3 changed files with 196 additions and 24 deletions
@@ -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<typeof useEditor>;
}
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<string[]>([]);
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 (
<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>
{searchTerms.length > 0 && (
<Text size="xs" c="dimmed" mt="xs" lineClamp={1}>
{searchTerms.join(", ")}
</Text>
)}
</Dialog>
);
}
export default SearchNavigationDialog;
+10 -20
View File
@@ -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;
if (!editor || editor.isDestroyed || !editor.view.dom.isConnected) return;
const frame = requestAnimationFrame(() => {
if (!editor || editor.isDestroyed || !editor.view.dom.isConnected) return;
appliedSearchRef.current = true;
editor.commands.setSearchTerms(searchQueries);
editor.commands.setWholeWord(true);
editor.commands.resetIndex();
document.dispatchEvent(
new CustomEvent("openSearchNavigationDialog", {
detail: { searchTerms: searchQueries, wholeWord: true },
})
);
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);
}, [editor, isSynced, showStatic, searchQueries]);
useEffect(() => {
@@ -470,6 +459,7 @@ function CollabPageEditor({
{editor && (
<SearchAndReplaceDialog editor={editor} editable={editable} />
)}
{editor && <SearchNavigationDialog editor={editor} />}
{editor && editorIsEditable && (
<div>
+1 -1
View File
@@ -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;