mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-09-01 19:49:54 +02:00
editor: use global search store for managing search state
This commit is contained in:
@@ -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<SearchOptions, SearchStorage>({
|
||||
|
||||
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<SearchOptions, SearchStorage>({
|
||||
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<SearchOptions, SearchStorage>({
|
||||
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<SearchOptions, SearchStorage>({
|
||||
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<SearchOptions, SearchStorage>({
|
||||
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<SearchOptions, SearchStorage>({
|
||||
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"
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -17,21 +17,28 @@ You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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 (
|
||||
<ResponsivePresenter
|
||||
mobile="sheet"
|
||||
desktop="menu"
|
||||
desktop="popup"
|
||||
isOpen={isSearching}
|
||||
container={document.body}
|
||||
onClose={() => editor.commands.endSearch()}
|
||||
position={{
|
||||
target: editor.isEditable ? getToolbarElement() : getEditorContainer(),
|
||||
|
||||
@@ -18,24 +18,28 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<HTMLInputElement>(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) {
|
||||
>
|
||||
<Input
|
||||
variant={"clean"}
|
||||
defaultValue={selectedText}
|
||||
ref={searchInputRef}
|
||||
autoFocus
|
||||
placeholder="Find"
|
||||
sx={{ p: 0 }}
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
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"}
|
||||
/>
|
||||
<ToolButton
|
||||
@@ -164,7 +149,11 @@ export function SearchReplacePopup(props: SearchReplacePopupProps) {
|
||||
title="Match whole word"
|
||||
id="matchWholeWord"
|
||||
icon="wholeWord"
|
||||
onClick={() => setMatchWholeWord((s) => !s)}
|
||||
onClick={() =>
|
||||
useEditorSearchStore.setState({
|
||||
matchWholeWord: !matchWholeWord
|
||||
})
|
||||
}
|
||||
iconSize={"medium"}
|
||||
/>
|
||||
<ToolButton
|
||||
@@ -175,7 +164,11 @@ export function SearchReplacePopup(props: SearchReplacePopupProps) {
|
||||
title="Enable regex"
|
||||
id="enableRegex"
|
||||
icon="regex"
|
||||
onClick={() => 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"}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
@@ -197,7 +192,10 @@ export function SearchReplacePopup(props: SearchReplacePopupProps) {
|
||||
<Input
|
||||
sx={{ mt: 1, p: "7px" }}
|
||||
placeholder="Replace"
|
||||
onChange={(e) => (replaceText.current = e.target.value)}
|
||||
value={replaceTerm}
|
||||
onChange={(e) =>
|
||||
useEditorSearchStore.setState({ replaceTerm: e.target.value })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
47
packages/editor/src/toolbar/stores/search-store.ts
Normal file
47
packages/editor/src/toolbar/stores/search-store.ts
Normal file
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<SearchState>(() => ({
|
||||
focusNonce: 0,
|
||||
isSearching: false,
|
||||
searchTerm: "",
|
||||
replaceTerm: "",
|
||||
enableRegex: false,
|
||||
matchCase: false,
|
||||
matchWholeWord: false,
|
||||
isExpanded: false,
|
||||
isReplacing: false
|
||||
}));
|
||||
Reference in New Issue
Block a user