diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index b9287dc9..769ff61a 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -389,5 +389,15 @@ "Failed to share page": "Failed to share page", "Copy page": "Copy page", "Copy page to a different space.": "Copy page to a different space.", - "Page copied successfully": "Page copied successfully" + "Page copied successfully": "Page copied successfully", + "Find": "Find", + "Not found": "Not found", + "Previous Match (Shift+Enter)": "Previous Match (Shift+Enter)", + "Next match (Enter)": "Next match (Enter)", + "Match case (Alt+C)": "Match case (Alt+C)", + "Replace": "Replace", + "Close (Escape)": "Close (Escape)", + "Replace (Enter)": "Replace (Enter)", + "Replace all (Ctrl+Alt+Enter)": "Replace all (Ctrl+Alt+Enter)", + "Replace all": "Replace all" } diff --git a/apps/client/src/features/editor/components/search-and-replace/atoms/search-and-replace-state-atom.ts b/apps/client/src/features/editor/components/search-and-replace/atoms/search-and-replace-state-atom.ts new file mode 100644 index 00000000..e9760ef3 --- /dev/null +++ b/apps/client/src/features/editor/components/search-and-replace/atoms/search-and-replace-state-atom.ts @@ -0,0 +1,9 @@ +import { atom } from "jotai"; + +type SearchAndReplaceAtomType = { + isOpen: boolean; +}; + +export const searchAndReplaceStateAtom = atom({ + isOpen: false, +}); 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 new file mode 100644 index 00000000..df6f0031 --- /dev/null +++ b/apps/client/src/features/editor/components/search-and-replace/search-and-replace-dialog.tsx @@ -0,0 +1,312 @@ +import { + ActionIcon, + Button, + Dialog, + Flex, + Input, + Stack, + Text, + Tooltip, +} from "@mantine/core"; +import { + IconArrowNarrowDown, + IconArrowNarrowUp, + IconLetterCase, + IconReplace, + IconSearch, + IconX, +} from "@tabler/icons-react"; +import { useEditor } from "@tiptap/react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { searchAndReplaceStateAtom } from "@/features/editor/components/search-and-replace/atoms/search-and-replace-state-atom.ts"; +import { useAtom } from "jotai"; +import { useTranslation } from "react-i18next"; +import { getHotkeyHandler, useToggle } from "@mantine/hooks"; +import { useLocation } from "react-router-dom"; +import classes from "./search-replace.module.css"; + +interface PageFindDialogDialogProps { + editor: ReturnType; + editable?: boolean; +} + +function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialogProps) { + const { t } = useTranslation(); + const [searchText, setSearchText] = useState(""); + const [replaceText, setReplaceText] = useState(""); + const [pageFindState, setPageFindState] = useAtom(searchAndReplaceStateAtom); + const inputRef = useRef(null); + + const [replaceButton, replaceButtonToggle] = useToggle([ + { isReplaceShow: false, color: "gray" }, + { isReplaceShow: true, color: "blue" }, + ]); + + const [caseSensitive, caseSensitiveToggle] = useToggle([ + { isCaseSensitive: false, color: "gray" }, + { isCaseSensitive: true, color: "blue" }, + ]); + + const searchInputEvent = (event: React.ChangeEvent) => { + setSearchText(event.target.value); + }; + + const replaceInputEvent = (event: React.ChangeEvent) => { + setReplaceText(event.target.value); + }; + + const closeDialog = () => { + setSearchText(""); + setReplaceText(""); + setPageFindState({ isOpen: false }); + // Reset replace button state when closing + if (replaceButton.isReplaceShow) { + replaceButtonToggle(); + } + // Clear search term in editor + if (editor) { + editor.commands.setSearchTerm(""); + } + }; + + const goToSelection = () => { + if (!editor) return; + + const { results, resultIndex } = editor.storage.searchAndReplace; + const position: Range = results[resultIndex]; + + if (!position) return; + + // @ts-ignore + editor.commands.setTextSelection(position); + + const element = document.querySelector(".search-result-current"); + if (element) + element.scrollIntoView({ behavior: "smooth", block: "center" }); + + editor.commands.setTextSelection(0); + }; + + const next = () => { + editor.commands.nextSearchResult(); + goToSelection(); + }; + + const previous = () => { + editor.commands.previousSearchResult(); + goToSelection(); + }; + + const replace = () => { + editor.commands.setReplaceTerm(replaceText); + editor.commands.replace(); + goToSelection(); + }; + + const replaceAll = () => { + editor.commands.setReplaceTerm(replaceText); + editor.commands.replaceAll(); + }; + + useEffect(() => { + editor.commands.setSearchTerm(searchText); + editor.commands.resetIndex(); + editor.commands.selectCurrentItem(); + }, [searchText]); + + const handleOpenEvent = (e) => { + setPageFindState({ isOpen: true }); + const selectedText = editor.state.doc.textBetween( + editor.state.selection.from, + editor.state.selection.to, + ); + if (selectedText !== "") { + setSearchText(selectedText); + } + inputRef.current?.focus(); + inputRef.current?.select(); + }; + + const handleCloseEvent = (e) => { + closeDialog(); + }; + + useEffect(() => { + !pageFindState.isOpen && closeDialog(); + + document.addEventListener("openFindDialogFromEditor", handleOpenEvent); + document.addEventListener("closeFindDialogFromEditor", handleCloseEvent); + + return () => { + document.removeEventListener("openFindDialogFromEditor", handleOpenEvent); + document.removeEventListener( + "closeFindDialogFromEditor", + handleCloseEvent, + ); + }; + }, [pageFindState.isOpen]); + + useEffect(() => { + editor.commands.setCaseSensitive(caseSensitive.isCaseSensitive); + editor.commands.resetIndex(); + goToSelection(); + }, [caseSensitive]); + + const resultsCount = useMemo( + () => + searchText.trim() === "" + ? "" + : editor?.storage?.searchAndReplace?.results.length > 0 + ? editor?.storage?.searchAndReplace?.resultIndex + + 1 + + "/" + + editor?.storage?.searchAndReplace?.results.length + : t("Not found"), + [ + searchText, + editor?.storage?.searchAndReplace?.resultIndex, + editor?.storage?.searchAndReplace?.results.length, + ], + ); + + const location = useLocation(); + useEffect(() => { + closeDialog(); + }, [location]); + + return ( + + + + } + rightSection={ + + {resultsCount} + + } + rightSectionWidth="70" + rightSectionPointerEvents="all" + size="xs" + w={220} + onChange={searchInputEvent} + value={searchText} + autoFocus + onKeyDown={getHotkeyHandler([ + ["Enter", next], + ["shift+Enter", previous], + ["alt+C", caseSensitiveToggle], + //@ts-ignore + ...(editable ? [["alt+R", replaceButtonToggle]] : []), + ])} + /> + + + + + + + + + + + + + + caseSensitiveToggle()} + > + + + + {editable && ( + + replaceButtonToggle()} + > + + + + )} + + + + + + + + {replaceButton.isReplaceShow && editable && ( + + } + rightSection={
} + rightSectionPointerEvents="all" + size="xs" + w={180} + autoFocus + onChange={replaceInputEvent} + value={replaceText} + onKeyDown={getHotkeyHandler([ + ["Enter", replace], + ["ctrl+alt+Enter", replaceAll], + ])} + /> + + + + + + + + +
+ )} +
+
+ ); +} + +export default SearchAndReplaceDialog; diff --git a/apps/client/src/features/editor/components/search-and-replace/search-replace.module.css b/apps/client/src/features/editor/components/search-and-replace/search-replace.module.css new file mode 100644 index 00000000..f864991c --- /dev/null +++ b/apps/client/src/features/editor/components/search-and-replace/search-replace.module.css @@ -0,0 +1,10 @@ +.findDialog{ + @media print { + display: none; + } +} + +.findDialog div[data-position="right"].mantine-Input-section { + justify-content: right; + padding-right: 8px; +} diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index 96b520ad..7b83fd31 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -36,6 +36,7 @@ import { Drawio, Excalidraw, Embed, + SearchAndReplace, Mention, } from "@docmost/editor-ext"; import { @@ -217,6 +218,22 @@ export const mainExtensions = [ CharacterCount.configure({ wordCounter: (text) => countWords(text), }), + SearchAndReplace.extend({ + addKeyboardShortcuts() { + return { + 'Mod-f': () => { + const event = new CustomEvent("openFindDialogFromEditor", {}); + document.dispatchEvent(event); + return true; + }, + 'Escape': () => { + const event = new CustomEvent("closeFindDialogFromEditor", {}); + document.dispatchEvent(event); + return true; + }, + } + }, + }).configure(), ] as any; type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[]; diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index ec937ff9..0114a687 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -44,6 +44,7 @@ import LinkMenu from "@/features/editor/components/link/link-menu.tsx"; import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu"; 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 { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks"; import { useIdle } from "@/hooks/use-idle.ts"; import { queryClient } from "@/main.tsx"; @@ -350,6 +351,8 @@ export default function PageEditor({
+ + {editor && editor.isEditable && (
diff --git a/apps/client/src/features/editor/styles/details.css b/apps/client/src/features/editor/styles/details.css index 567118b8..5c5d151b 100644 --- a/apps/client/src/features/editor/styles/details.css +++ b/apps/client/src/features/editor/styles/details.css @@ -71,4 +71,12 @@ [data-type="details"][open] > [data-type="detailsButton"] .ProseMirror-icon{ transform: rotateZ(90deg); } -} \ No newline at end of file + + [data-type="details"]:has(.search-result) > [data-type="detailsContainer"] > [data-type="detailsContent"]{ + display: block; + } + + [data-type="details"]:has(.search-result) > [data-type="detailsButton"] .ProseMirror-icon{ + transform: rotateZ(90deg); + } +} diff --git a/apps/client/src/features/editor/styles/find.css b/apps/client/src/features/editor/styles/find.css new file mode 100644 index 00000000..77b72f25 --- /dev/null +++ b/apps/client/src/features/editor/styles/find.css @@ -0,0 +1,9 @@ +.search-result{ + background: #ffff65; + color: #212529; +} + +.search-result-current{ + background: #ffc266 !important; + color: #212529; +} diff --git a/apps/client/src/features/editor/styles/index.css b/apps/client/src/features/editor/styles/index.css index cf979957..44793724 100644 --- a/apps/client/src/features/editor/styles/index.css +++ b/apps/client/src/features/editor/styles/index.css @@ -9,5 +9,5 @@ @import "./media.css"; @import "./code.css"; @import "./print.css"; +@import "./find.css"; @import "./mention.css"; - diff --git a/apps/client/src/features/editor/title-editor.tsx b/apps/client/src/features/editor/title-editor.tsx index e695867f..937ae374 100644 --- a/apps/client/src/features/editor/title-editor.tsx +++ b/apps/client/src/features/editor/title-editor.tsx @@ -10,8 +10,11 @@ import { pageEditorAtom, titleEditorAtom, } from "@/features/editor/atoms/editor-atoms"; -import { updatePageData, useUpdateTitlePageMutation } from "@/features/page/queries/page-query"; -import { useDebouncedCallback } from "@mantine/hooks"; +import { + updatePageData, + useUpdateTitlePageMutation, +} from "@/features/page/queries/page-query"; +import { useDebouncedCallback, getHotkeyHandler } from "@mantine/hooks"; import { useAtom } from "jotai"; import { useQueryEmit } from "@/features/websocket/use-query-emit.ts"; import { History } from "@tiptap/extension-history"; @@ -40,7 +43,8 @@ export function TitleEditor({ editable, }: TitleEditorProps) { const { t } = useTranslation(); - const { mutateAsync: updateTitlePageMutationAsync } = useUpdateTitlePageMutation(); + const { mutateAsync: updateTitlePageMutationAsync } = + useUpdateTitlePageMutation(); const pageEditor = useAtomValue(pageEditorAtom); const [, setTitleEditor] = useAtom(titleEditorAtom); const emit = useQueryEmit(); @@ -108,7 +112,12 @@ export function TitleEditor({ spaceId: page.spaceId, entity: ["pages"], id: page.id, - payload: { title: page.title, slugId: page.slugId, parentPageId: page.parentPageId, icon: page.icon }, + payload: { + title: page.title, + slugId: page.slugId, + parentPageId: page.parentPageId, + icon: page.icon, + }, }; if (page.title !== titleEditor.getText()) return; @@ -152,13 +161,19 @@ export function TitleEditor({ } }, [userPageEditMode, titleEditor, editable]); + const openSearchDialog = () => { + const event = new CustomEvent("openFindDialogFromEditor", {}); + document.dispatchEvent(event); + }; + function handleTitleKeyDown(event: any) { if (!titleEditor || !pageEditor || event.shiftKey) return; - - // Prevent focus shift when IME composition is active + + // Prevent focus shift when IME composition is active // `keyCode === 229` is added to support Safari where `isComposing` may not be reliable - if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return; - + if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) + return; + const { key } = event; const { $head } = titleEditor.state.selection; @@ -172,5 +187,16 @@ export function TitleEditor({ } } - return ; + return ( + { + // First handle the search hotkey + getHotkeyHandler([["mod+F", openSearchDialog]])(event); + + // Then handle other key events + handleTitleKeyDown(event); + }} + /> + ); } diff --git a/apps/client/src/features/page/components/header/page-header-menu.tsx b/apps/client/src/features/page/components/header/page-header-menu.tsx index 816cc502..934be3af 100644 --- a/apps/client/src/features/page/components/header/page-header-menu.tsx +++ b/apps/client/src/features/page/components/header/page-header-menu.tsx @@ -9,6 +9,7 @@ import { IconList, IconMessage, IconPrinter, + IconSearch, IconTrash, IconWifiOff, } from "@tabler/icons-react"; @@ -16,7 +17,12 @@ import React from "react"; import useToggleAside from "@/hooks/use-toggle-aside.tsx"; import { useAtom } from "jotai"; import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts"; -import { useClipboard, useDisclosure } from "@mantine/hooks"; +import { + getHotkeyHandler, + useClipboard, + useDisclosure, + useHotkeys, +} from "@mantine/hooks"; import { useParams } from "react-router-dom"; import { usePageQuery } from "@/features/page/queries/page-query.ts"; import { buildPageUrl } from "@/features/page/page.utils.ts"; @@ -32,6 +38,7 @@ import { pageEditorAtom, yjsConnectionStatusAtom, } from "@/features/editor/atoms/editor-atoms.ts"; +import { searchAndReplaceStateAtom } from "@/features/editor/components/search-and-replace/atoms/search-and-replace-state-atom.ts"; import { formattedDate, timeAgo } from "@/lib/time.ts"; import { PageStateSegmentedControl } from "@/features/user/components/page-state-pref.tsx"; import MovePageModal from "@/features/page/components/move-page-modal.tsx"; @@ -46,6 +53,26 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) { const toggleAside = useToggleAside(); const [yjsConnectionStatus] = useAtom(yjsConnectionStatusAtom); + useHotkeys( + [ + [ + "mod+F", + () => { + const event = new CustomEvent("openFindDialogFromEditor", {}); + document.dispatchEvent(event); + }, + ], + [ + "Escape", + () => { + const event = new CustomEvent("closeFindDialogFromEditor", {}); + document.dispatchEvent(event); + }, + ], + ], + [], + ); + return ( <> {yjsConnectionStatus === "disconnected" && ( diff --git a/packages/editor-ext/src/index.ts b/packages/editor-ext/src/index.ts index f2cb776b..d3e1d53d 100644 --- a/packages/editor-ext/src/index.ts +++ b/packages/editor-ext/src/index.ts @@ -17,4 +17,5 @@ export * from "./lib/excalidraw"; export * from "./lib/embed"; export * from "./lib/mention"; export * from "./lib/markdown"; +export * from "./lib/search-and-replace"; export * from "./lib/embed-provider"; diff --git a/packages/editor-ext/src/lib/search-and-replace/index.ts b/packages/editor-ext/src/lib/search-and-replace/index.ts new file mode 100644 index 00000000..d082e4f8 --- /dev/null +++ b/packages/editor-ext/src/lib/search-and-replace/index.ts @@ -0,0 +1,3 @@ +import { SearchAndReplace } from './search-and-replace' +export * from './search-and-replace' +export default SearchAndReplace \ No newline at end of file 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 new file mode 100644 index 00000000..ca66958f --- /dev/null +++ b/packages/editor-ext/src/lib/search-and-replace/search-and-replace.ts @@ -0,0 +1,455 @@ +/*** + MIT License + Copyright (c) 2023 - 2024 Jeet Mandaliya (Github Username: sereneinserenade) + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + ***/ + +import { Extension, Range, type Dispatch } from "@tiptap/core"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; +import { + Plugin, + PluginKey, + type EditorState, + type Transaction, +} from "@tiptap/pm/state"; +import { Node as PMNode, Mark } from "@tiptap/pm/model"; + +declare module "@tiptap/core" { + interface Commands { + search: { + /** + * @description Set search term in extension. + */ + setSearchTerm: (searchTerm: string) => ReturnType; + /** + * @description Set replace term in extension. + */ + setReplaceTerm: (replaceTerm: string) => ReturnType; + /** + * @description Set case sensitivity in extension. + */ + setCaseSensitive: (caseSensitive: boolean) => ReturnType; + /** + * @description Reset current search result to first instance. + */ + resetIndex: () => ReturnType; + /** + * @description Find next instance of search result. + */ + nextSearchResult: () => ReturnType; + /** + * @description Find previous instance of search result. + */ + previousSearchResult: () => ReturnType; + /** + * @description Replace first instance of search result with given replace term. + */ + replace: () => ReturnType; + /** + * @description Replace all instances of search result with given replace term. + */ + replaceAll: () => ReturnType; + /** + * @description Find selected instance of search result. + */ + selectCurrentItem: () => ReturnType; + }; + } +} + +interface TextNodesWithPosition { + text: string; + pos: number; +} + +const getRegex = ( + s: string, + disableRegex: boolean, + caseSensitive: boolean, +): RegExp => { + return RegExp( + disableRegex ? s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : s, + caseSensitive ? "gu" : "gui", + ); +}; + +interface ProcessedSearches { + decorationsToReturn: DecorationSet; + results: Range[]; +} + +function processSearches( + doc: PMNode, + searchTerm: RegExp, + searchResultClass: string, + resultIndex: number, +): ProcessedSearches { + const decorations: Decoration[] = []; + const results: Range[] = []; + + let textNodesWithPosition: TextNodesWithPosition[] = []; + let index = 0; + + if (!searchTerm) { + return { + decorationsToReturn: DecorationSet.empty, + results: [], + }; + } + + doc?.descendants((node, pos) => { + if (node.isText) { + if (textNodesWithPosition[index]) { + textNodesWithPosition[index] = { + text: textNodesWithPosition[index].text + node.text, + pos: textNodesWithPosition[index].pos, + }; + } else { + textNodesWithPosition[index] = { + text: `${node.text}`, + pos, + }; + } + } else { + index += 1; + } + }); + + textNodesWithPosition = textNodesWithPosition.filter(Boolean); + + for (const element of textNodesWithPosition) { + const { text, pos } = element; + const matches = Array.from(text.matchAll(searchTerm)).filter( + ([matchText]) => matchText.trim(), + ); + + for (const m of matches) { + if (m[0] === "") break; + + if (m.index !== undefined) { + results.push({ + from: pos + m.index, + to: pos + m.index + m[0].length, + }); + } + } + } + + for (let i = 0; i < results.length; i += 1) { + const r = results[i]; + const className = + i === resultIndex + ? `${searchResultClass} ${searchResultClass}-current` + : searchResultClass; + const decoration: Decoration = Decoration.inline(r.from, r.to, { + class: className, + }); + + decorations.push(decoration); + } + + return { + decorationsToReturn: DecorationSet.create(doc, decorations), + results, + }; +} + +const replace = ( + replaceTerm: string, + results: Range[], + resultIndex: number, + { state, dispatch }: { state: EditorState; dispatch: Dispatch }, +) => { + const firstResult = results[resultIndex]; + + if (!firstResult) return; + + const { from, to } = results[resultIndex]; + + if (dispatch) { + const tr = state.tr; + + // Get all marks that span the text being replaced + const marksSet = new Set(); + state.doc.nodesBetween(from, to, (node) => { + if (node.isText && node.marks) { + node.marks.forEach(mark => marksSet.add(mark)); + } + }); + + const marks = Array.from(marksSet); + + // Delete the old text and insert new text with preserved marks + tr.delete(from, to); + tr.insert(from, state.schema.text(replaceTerm, marks)); + + dispatch(tr); + } +}; + +const replaceAll = ( + replaceTerm: string, + results: Range[], + { tr, dispatch }: { tr: Transaction; dispatch: Dispatch }, +) => { + const resultsCopy = results.slice(); + + if (!resultsCopy.length) return; + + // Process replacements in reverse order to avoid position shifting issues + for (let i = resultsCopy.length - 1; i >= 0; i -= 1) { + const { from, to } = resultsCopy[i]; + + // Get all marks that span the text being replaced + const marksSet = new Set(); + tr.doc.nodesBetween(from, to, (node) => { + if (node.isText && node.marks) { + node.marks.forEach(mark => marksSet.add(mark)); + } + }); + + const marks = Array.from(marksSet); + + // Delete and insert with preserved marks + tr.delete(from, to); + tr.insert(from, tr.doc.type.schema.text(replaceTerm, marks)); + } + + dispatch(tr); +}; + +export const searchAndReplacePluginKey = new PluginKey( + "searchAndReplacePlugin", +); + +export interface SearchAndReplaceOptions { + searchResultClass: string; + disableRegex: boolean; +} + +export interface SearchAndReplaceStorage { + searchTerm: string; + replaceTerm: string; + results: Range[]; + lastSearchTerm: string; + caseSensitive: boolean; + lastCaseSensitive: boolean; + resultIndex: number; + lastResultIndex: number; +} + +export const SearchAndReplace = Extension.create< + SearchAndReplaceOptions, + SearchAndReplaceStorage +>({ + name: "searchAndReplace", + + addOptions() { + return { + searchResultClass: "search-result", + disableRegex: true, + }; + }, + + addStorage() { + return { + searchTerm: "", + replaceTerm: "", + results: [], + lastSearchTerm: "", + caseSensitive: false, + lastCaseSensitive: false, + resultIndex: 0, + lastResultIndex: 0, + }; + }, + + addCommands() { + return { + setSearchTerm: + (searchTerm: string) => + ({ editor }) => { + editor.storage.searchAndReplace.searchTerm = searchTerm; + + return false; + }, + setReplaceTerm: + (replaceTerm: string) => + ({ editor }) => { + editor.storage.searchAndReplace.replaceTerm = replaceTerm; + + return false; + }, + setCaseSensitive: + (caseSensitive: boolean) => + ({ editor }) => { + editor.storage.searchAndReplace.caseSensitive = caseSensitive; + + return false; + }, + resetIndex: + () => + ({ editor }) => { + editor.storage.searchAndReplace.resultIndex = 0; + + return false; + }, + nextSearchResult: + () => + ({ editor }) => { + const { results, resultIndex } = editor.storage.searchAndReplace; + + const nextIndex = resultIndex + 1; + + if (results[nextIndex]) { + editor.storage.searchAndReplace.resultIndex = nextIndex; + } else { + editor.storage.searchAndReplace.resultIndex = 0; + } + + return false; + }, + previousSearchResult: + () => + ({ editor }) => { + const { results, resultIndex } = editor.storage.searchAndReplace; + + const prevIndex = resultIndex - 1; + + if (results[prevIndex]) { + editor.storage.searchAndReplace.resultIndex = prevIndex; + } else { + editor.storage.searchAndReplace.resultIndex = results.length - 1; + } + + return false; + }, + replace: + () => + ({ editor, state, dispatch }) => { + const { replaceTerm, results, resultIndex } = + editor.storage.searchAndReplace; + + replace(replaceTerm, results, resultIndex, { state, dispatch }); + + // After replace, adjust index if needed + // The results will be recalculated by the plugin, but we need to ensure + // the index doesn't exceed the new bounds + setTimeout(() => { + const newResultsLength = editor.storage.searchAndReplace.results.length; + if (newResultsLength > 0 && editor.storage.searchAndReplace.resultIndex >= newResultsLength) { + // Keep the same position if possible, otherwise go to the last result + editor.storage.searchAndReplace.resultIndex = Math.min(resultIndex, newResultsLength - 1); + } + }, 0); + + return false; + }, + replaceAll: + () => + ({ editor, tr, dispatch }) => { + const { replaceTerm, results } = editor.storage.searchAndReplace; + + replaceAll(replaceTerm, results, { tr, dispatch }); + + return false; + }, + selectCurrentItem: + () => + ({ editor }) => { + const { results } = editor.storage.searchAndReplace; + for (let i = 0; i < results.length; i++) { + if ( + results[i].from == editor.state.selection.from && + results[i].to == editor.state.selection.to + ) { + editor.storage.searchAndReplace.resultIndex = i; + } + } + return false; + }, + }; + }, + + addProseMirrorPlugins() { + const editor = this.editor; + const { searchResultClass, disableRegex } = this.options; + + const setLastSearchTerm = (t: string) => + (editor.storage.searchAndReplace.lastSearchTerm = t); + const setLastCaseSensitive = (t: boolean) => + (editor.storage.searchAndReplace.lastCaseSensitive = t); + const setLastResultIndex = (t: number) => + (editor.storage.searchAndReplace.lastResultIndex = t); + + return [ + new Plugin({ + key: searchAndReplacePluginKey, + state: { + init: () => DecorationSet.empty, + apply({ doc, docChanged }, oldState) { + const { + searchTerm, + lastSearchTerm, + caseSensitive, + lastCaseSensitive, + resultIndex, + lastResultIndex, + } = editor.storage.searchAndReplace; + + if ( + !docChanged && + lastSearchTerm === searchTerm && + lastCaseSensitive === caseSensitive && + lastResultIndex === resultIndex + ) + return oldState; + + setLastSearchTerm(searchTerm); + setLastCaseSensitive(caseSensitive); + setLastResultIndex(resultIndex); + + if (!searchTerm) { + editor.storage.searchAndReplace.results = []; + return DecorationSet.empty; + } + + const { decorationsToReturn, results } = processSearches( + doc, + getRegex(searchTerm, disableRegex, caseSensitive), + searchResultClass, + resultIndex, + ); + + editor.storage.searchAndReplace.results = results; + + return decorationsToReturn; + }, + }, + props: { + decorations(state) { + return this.getState(state); + }, + }, + }), + ]; + }, +}); + +export default SearchAndReplace;