mirror of
https://github.com/docmost/docmost.git
synced 2026-09-11 07:56:54 +08:00
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:
+6
-4
@@ -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(() => {
|
||||||
closeDialog();
|
if (pageFindState.isOpen) {
|
||||||
}, [location]);
|
closeDialog();
|
||||||
|
}
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
|
|||||||
+205
@@ -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;
|
||||||
+47
@@ -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>
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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
|
||||||
result.highlight = result.highlight
|
if (!result.highlight) {
|
||||||
.replace(/\r\n|\r|\n/g, ' ')
|
result.matchedText = [];
|
||||||
.replace(/\s+/g, ' ');
|
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>([^<]*)<\/b>/gi),
|
||||||
|
(match) => match[1],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+1
-1
Submodule apps/server/src/ee updated: 5b873a53c8...5e7120dcc8
@@ -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,37 +459,47 @@ 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(
|
||||||
searchResultClass,
|
searchTerms,
|
||||||
resultIndex,
|
disableRegex,
|
||||||
);
|
caseSensitive,
|
||||||
|
wholeWord,
|
||||||
|
),
|
||||||
|
searchResultClass,
|
||||||
|
resultIndex,
|
||||||
|
);
|
||||||
|
|
||||||
editor.storage.searchAndReplace.results = results;
|
editor.storage.searchAndReplace.results = results;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user