editor: use global search store for managing search state

This commit is contained in:
Abdullah Atta
2024-03-11 11:31:25 +05:00
parent a0f14dc3ef
commit 8abf57ae24
5 changed files with 241 additions and 110 deletions

View File

@@ -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"
});
});

View File

@@ -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 [

View File

@@ -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(),

View File

@@ -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}
/>

View 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
}));