diff --git a/packages/editor/src/extensions/search-replace/search-replace.ts b/packages/editor/src/extensions/search-replace/search-replace.ts index b498df2c2..83646c7e5 100644 --- a/packages/editor/src/extensions/search-replace/search-replace.ts +++ b/packages/editor/src/extensions/search-replace/search-replace.ts @@ -26,6 +26,7 @@ import { TextSelection, Transaction } from "prosemirror-state"; +import { SearchSettings } from "../../toolbar/stores/search-store"; type DispatchFn = (tr: Transaction) => void; declare module "@tiptap/core" { @@ -33,7 +34,6 @@ declare module "@tiptap/core" { searchreplace: { startSearch: () => ReturnType; endSearch: () => ReturnType; - refreshSearch: () => ReturnType; search: (term: string, options?: SearchSettings) => ReturnType; moveToNextResult: () => ReturnType; moveToPreviousResult: () => ReturnType; @@ -50,19 +50,12 @@ interface Result { interface SearchOptions { searchResultClass: string; + onStartSearch: (term?: string) => boolean; + onEndSearch: () => boolean; } -interface SearchSettings { - matchCase: boolean; - enableRegex: boolean; - matchWholeWord: boolean; -} - -export type SearchStorage = SearchSettings & { - searchTerm: string; +export type SearchStorage = { selectedIndex: number; - isSearching: boolean; - focusNonce: number; selectedText?: string; results?: Result[]; }; @@ -75,7 +68,6 @@ interface TextNodesWithPosition { const updateView = (state: EditorState, dispatch: DispatchFn) => { if (!state.tr) return; - state.tr.setMeta("forceUpdate", true); dispatch(state.tr); }; @@ -104,7 +96,6 @@ function searchDocument( }; const doc = tr.doc; - const decorations: Decoration[] = []; const results: Result[] = []; let index = 0; @@ -158,18 +149,29 @@ function searchDocument( } } + return { + startIndex: selectedIndex || 0, + decorationSet: DecorationSet.create( + doc, + resultsToDecorations(results, searchResultClass, selectedIndex) + ), + results + }; +} + +function resultsToDecorations( + results: Result[], + searchResultClass: string, + selectedIndex?: number +) { + const decorations: Decoration[] = []; for (let i = 0; i < results.length; i++) { const { from, to } = results[i]; const resultClass = i === selectedIndex ? `${searchResultClass} selected` : searchResultClass; decorations.push(Decoration.inline(from, to, { class: resultClass })); } - - return { - startIndex: selectedIndex || 0, - decorationSet: DecorationSet.create(doc, decorations), - results - }; + return decorations; } const replaceAll = ( @@ -201,7 +203,17 @@ export const SearchReplace = Extension.create({ addOptions() { return { - searchResultClass: "search-result" + searchResultClass: "search-result", + onStartSearch: () => false, + onEndSearch: () => false + }; + }, + + addStorage() { + return { + selectedIndex: 0, + results: [], + selectedText: undefined }; }, @@ -209,41 +221,33 @@ export const SearchReplace = Extension.create({ return { startSearch: () => - ({ state }) => { - this.storage.focusNonce = Math.random(); - this.storage.isSearching = true; - if (!state.selection.empty) { - this.storage.selectedText = state.doc.textBetween( - state.selection.$from.pos, - state.selection.$to.pos - ); - } - return true; + ({ state, commands }) => { + const term = !state.selection.empty + ? state.doc.textBetween( + state.selection.$from.pos, + state.selection.$to.pos + ) + : undefined; + if (term) commands.search(term); + + return this.options.onStartSearch(term); }, endSearch: () => ({ state, dispatch, editor }) => { - this.storage.isSearching = false; - this.storage.selectedText = undefined; - this.storage.searchTerm = ""; editor.commands.focus(); + state.tr.setMeta("isSearching", false); if (dispatch) updateView(state, dispatch); - return true; - }, - refreshSearch: - () => - ({ commands }) => { - return commands.search(this.storage.searchTerm, this.storage); + return this.options.onEndSearch(); }, search: (term, options?: SearchSettings) => ({ state, dispatch }) => { - this.storage.selectedIndex = 0; - this.storage.searchTerm = term; - this.storage.enableRegex = options?.enableRegex || false; - this.storage.matchCase = options?.matchCase || false; - this.storage.matchWholeWord = options?.matchWholeWord || false; - this.storage.results = []; + state.tr.setMeta("isSearching", true); + state.tr.setMeta("searchTerm", term); + if (options?.enableRegex) state.tr.setMeta("enableRegex", true); + if (options?.matchCase) state.tr.setMeta("matchCase", true); + if (options?.matchWholeWord) state.tr.setMeta("matchWholeWord", true); if (dispatch) updateView(state, dispatch); return true; @@ -262,6 +266,7 @@ export const SearchReplace = Extension.create({ scrollIntoView(); this.storage.selectedIndex = nextIndex; + state.tr.setMeta("selectedIndex", nextIndex); if (dispatch) updateView(state, dispatch); return true; }, @@ -279,6 +284,7 @@ export const SearchReplace = Extension.create({ scrollIntoView(); this.storage.selectedIndex = prevIndex; + state.tr.setMeta("selectedIndex", prevIndex); if (dispatch) updateView(state, dispatch); return true; @@ -338,24 +344,71 @@ export const SearchReplace = Extension.create({ const key = new PluginKey("searchreplace"); return [ - new Plugin({ + new Plugin< + SearchSettings & { + searchTerm: string; + results: DecorationSet; + isSearching: boolean; + selectedIndex: number; + } + >({ key, state: { init() { - return DecorationSet.empty; + return { + results: DecorationSet.empty, + searchTerm: "", + isSearching: false, + enableRegex: false, + matchCase: false, + matchWholeWord: false, + selectedIndex: 0 + }; }, apply: (tr, value) => { const { docChanged } = tr; - const forceUpdate = tr.getMeta("forceUpdate"); - const { - searchTerm, - enableRegex, - matchCase, - matchWholeWord, - selectedIndex, - isSearching - } = this.storage; - if (docChanged || forceUpdate) { + const isSearching = tr.getMeta("isSearching") ?? value.isSearching; + if (!isSearching) + return { + ...value, + isSearching: false, + results: DecorationSet.empty + }; + + const searchTerm = tr.getMeta("searchTerm") ?? value.searchTerm; + const enableRegex = tr.getMeta("enableRegex") ?? value.enableRegex; + const matchCase = tr.getMeta("matchCase") ?? value.matchCase; + const matchWholeWord = + tr.getMeta("matchWholeWord") ?? value.matchWholeWord; + const selectedIndex = + tr.getMeta("selectedIndex") ?? value.selectedIndex; + const shouldResearch = + docChanged || + searchTerm !== value.searchTerm || + matchCase !== value.matchCase || + matchWholeWord !== value.matchWholeWord || + enableRegex !== value.enableRegex; + + if ( + selectedIndex !== value.selectedIndex && + this.storage.results && + !shouldResearch + ) { + return { + ...value, + selectedIndex, + results: DecorationSet.create( + tr.doc, + resultsToDecorations( + this.storage.results, + this.options.searchResultClass, + selectedIndex + ) + ) + }; + } + + if (shouldResearch) { const { searchResultClass } = this.options; const searchRegex = searchTerm @@ -365,21 +418,29 @@ export const SearchReplace = Extension.create({ tr, searchResultClass, searchRegex, - selectedIndex + 0 // TODO: first index should be relative to cursor position ); const { decorationSet, results, startIndex } = result; this.storage.results = results; this.storage.selectedIndex = startIndex; - return decorationSet; + return { + selectedIndex, + searchTerm, + isSearching, + results: decorationSet, + enableRegex, + matchCase, + matchWholeWord + }; } - return isSearching ? value : DecorationSet.empty; + return value; } }, props: { decorations(state) { - return key.getState(state); + return key.getState(state).results; } } }) @@ -393,7 +454,7 @@ function scrollIntoView() { if (!(domNode instanceof HTMLElement)) return; domNode.scrollIntoView({ - behavior: "smooth", + behavior: "instant", block: "center" }); }); diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index 4025f1451..3afe9c9be 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -87,6 +87,7 @@ import CheckList from "./extensions/check-list"; import CheckListItem from "./extensions/check-list-item"; import { Callout } from "./extensions/callout"; import BlockId from "./extensions/block-id"; +import { useEditorSearchStore } from "./toolbar/stores/search-store"; interface TiptapStorage { portalProviderAPI?: PortalProviderAPI; @@ -182,7 +183,20 @@ const useTiptap = ( extensions: [ ...CoreExtensions, NodeViewSelectionNotifier, - SearchReplace, + SearchReplace.configure({ + onStartSearch: (term) => { + useEditorSearchStore.setState({ + isSearching: true, + searchTerm: term, + focusNonce: Math.random() + }); + return true; + }, + onEndSearch: () => { + useEditorSearchStore.setState({ isSearching: false }); + return true; + } + }), TextStyle.extend({ parseHTML() { return [ diff --git a/packages/editor/src/toolbar/floating-menus/search-replace.tsx b/packages/editor/src/toolbar/floating-menus/search-replace.tsx index edef9bd8f..addfe4879 100644 --- a/packages/editor/src/toolbar/floating-menus/search-replace.tsx +++ b/packages/editor/src/toolbar/floating-menus/search-replace.tsx @@ -17,21 +17,28 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ -import { SearchStorage } from "../../extensions/search-replace"; +import { useLayoutEffect } from "react"; import { FloatingMenuProps } from "./types"; import { SearchReplacePopup } from "../popups/search-replace"; import { ResponsivePresenter } from "../../components/responsive"; import { getEditorContainer, getToolbarElement } from "../utils/dom"; +import { useEditorSearchStore } from "../stores/search-store"; export function SearchReplaceFloatingMenu(props: FloatingMenuProps) { const { editor } = props; - const { isSearching } = editor.storage.searchreplace as SearchStorage; + const isSearching = useEditorSearchStore((store) => store.isSearching); + + useLayoutEffect(() => { + const { searchTerm, ...options } = useEditorSearchStore.getState(); + editor.commands.search(searchTerm, options); + }, []); return ( editor.commands.endSearch()} position={{ target: editor.isEditable ? getToolbarElement() : getEditorContainer(), diff --git a/packages/editor/src/toolbar/popups/search-replace.tsx b/packages/editor/src/toolbar/popups/search-replace.tsx index e50c7050b..c23fa3cd6 100644 --- a/packages/editor/src/toolbar/popups/search-replace.tsx +++ b/packages/editor/src/toolbar/popups/search-replace.tsx @@ -18,24 +18,28 @@ along with this program. If not, see . */ import { Input } from "@theme-ui/components"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { Flex, Text } from "@theme-ui/components"; import { SearchStorage } from "../../extensions/search-replace"; import { ToolButton } from "../components/tool-button"; import { Editor } from "../../types"; +import { useEditorSearchStore } from "../stores/search-store"; export type SearchReplacePopupProps = { editor: Editor }; export function SearchReplacePopup(props: SearchReplacePopupProps) { const { editor } = props; - const { selectedText, results, selectedIndex, focusNonce } = editor.storage + const { + enableRegex, + focusNonce, + isExpanded, + isReplacing, + matchCase, + matchWholeWord, + searchTerm, + replaceTerm + } = useEditorSearchStore(); + const { results, selectedIndex } = editor.storage .searchreplace as SearchStorage; - - const [isReplacing, setIsReplacing] = useState(false); - const [isExpanded, setIsExpanded] = useState(false); - const [matchCase, setMatchCase] = useState(false); - const [matchWholeWord, setMatchWholeWord] = useState(false); - const [enableRegex, setEnableRegex] = useState(false); - const replaceText = useRef(""); const searchInputRef = useRef(null); const search = useCallback( @@ -46,35 +50,11 @@ export function SearchReplacePopup(props: SearchReplacePopupProps) { matchWholeWord }); }, - // eslint-disable-next-line react-hooks/exhaustive-deps [matchCase, enableRegex, matchWholeWord] ); useEffect(() => { - if (!searchInputRef.current) return; - search(searchInputRef.current.value); - }, [search, matchCase, matchWholeWord, enableRegex]); - - useEffect(() => { - if (selectedText) { - if (searchInputRef.current) { - const input = searchInputRef.current; - setTimeout(() => { - input.value = selectedText; - input.focus(); - }, 0); - } - search(selectedText); - } - }, [selectedText, search]); - - useEffect(() => { - if (searchInputRef.current) { - const input = searchInputRef.current; - setTimeout(() => { - input.focus(); - }, 0); - } + setTimeout(() => searchInputRef.current?.focus(), 0); }, [focusNonce]); return ( @@ -111,13 +91,14 @@ export function SearchReplacePopup(props: SearchReplacePopupProps) { > { search(e.target.value); + useEditorSearchStore.setState({ searchTerm: e.target.value }); }} onKeyDown={(e) => { if (e.key === "Enter") { @@ -140,7 +121,9 @@ export function SearchReplacePopup(props: SearchReplacePopupProps) { title="Expand" id="expand" icon={isExpanded ? "chevronRight" : "chevronLeft"} - onClick={() => setIsExpanded((s) => !s)} + onClick={() => + useEditorSearchStore.setState({ isExpanded: !isExpanded }) + } iconSize={"medium"} /> {isExpanded && ( @@ -153,7 +136,9 @@ export function SearchReplacePopup(props: SearchReplacePopupProps) { title="Match case" id="matchCase" icon="caseSensitive" - onClick={() => setMatchCase((s) => !s)} + onClick={() => + useEditorSearchStore.setState({ matchCase: !matchCase }) + } iconSize={"medium"} /> setMatchWholeWord((s) => !s)} + onClick={() => + useEditorSearchStore.setState({ + matchWholeWord: !matchWholeWord + }) + } iconSize={"medium"} /> setEnableRegex((s) => !s)} + onClick={() => + useEditorSearchStore.setState({ + enableRegex: !enableRegex + }) + } iconSize={"medium"} /> @@ -189,7 +182,9 @@ export function SearchReplacePopup(props: SearchReplacePopupProps) { px: 1 }} > - {results ? `${selectedIndex + 1}/${results.length}` : ""} + {results?.length + ? `${selectedIndex + 1}/${results.length}` + : "0/0"} @@ -197,7 +192,10 @@ export function SearchReplacePopup(props: SearchReplacePopupProps) { (replaceText.current = e.target.value)} + value={replaceTerm} + onChange={(e) => + useEditorSearchStore.setState({ replaceTerm: e.target.value }) + } /> )} @@ -209,7 +207,11 @@ export function SearchReplacePopup(props: SearchReplacePopupProps) { title="Toggle replace" id="toggleReplace" icon="replace" - onClick={() => setIsReplacing((s) => !s)} + onClick={() => + useEditorSearchStore.setState({ + isReplacing: !isReplacing + }) + } sx={{ mr: 0 }} iconSize={"big"} /> @@ -249,7 +251,7 @@ export function SearchReplacePopup(props: SearchReplacePopupProps) { title="Replace" id="replace" icon="replaceOne" - onClick={() => editor.commands.replace(replaceText.current)} + onClick={() => editor.commands.replace(replaceTerm)} sx={{ mr: 0 }} iconSize={18} /> @@ -258,7 +260,7 @@ export function SearchReplacePopup(props: SearchReplacePopupProps) { title="Replace all" id="replaceAll" icon="replaceAll" - onClick={() => editor.commands.replaceAll(replaceText.current)} + onClick={() => editor.commands.replaceAll(replaceTerm)} sx={{ mr: 0 }} iconSize={18} /> diff --git a/packages/editor/src/toolbar/stores/search-store.ts b/packages/editor/src/toolbar/stores/search-store.ts new file mode 100644 index 000000000..0d9c69eef --- /dev/null +++ b/packages/editor/src/toolbar/stores/search-store.ts @@ -0,0 +1,47 @@ +/* +This file is part of the Notesnook project (https://notesnook.com/) + +Copyright (C) 2023 Streetwriters (Private) Limited + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +import { create } from "zustand"; + +export interface SearchSettings { + matchCase: boolean; + enableRegex: boolean; + matchWholeWord: boolean; +} + +export interface SearchState extends SearchSettings { + isSearching: boolean; + searchTerm: string; + replaceTerm: string; + focusNonce: number; + isReplacing: boolean; + isExpanded: boolean; +} + +export const useEditorSearchStore = create(() => ({ + focusNonce: 0, + isSearching: false, + searchTerm: "", + replaceTerm: "", + enableRegex: false, + matchCase: false, + matchWholeWord: false, + isExpanded: false, + isReplacing: false +}));