diff --git a/demos/vue/src/components/Tiptap.vue b/demos/vue/src/components/Tiptap.vue
index 1df8228..e1fbde7 100644
--- a/demos/vue/src/components/Tiptap.vue
+++ b/demos/vue/src/components/Tiptap.vue
@@ -52,6 +52,22 @@
/>
+
+
@@ -62,6 +78,13 @@
>
Clear
+
+
+
+ Results: {{ editor?.storage?.searchAndReplace?.results.length }}
+
@@ -111,28 +138,67 @@ const searchTerm = ref("tiptap");
const replaceTerm = ref("ProseMirror");
-const updateSearchReplace = () => {
+const caseSensitive = ref(false);
+
+const updateSearchReplace = (clearIndex: boolean = false) => {
if (!editor.value) return;
+
+ if (clearIndex) editor.value.commands.resetIndex();
+
editor.value.commands.setSearchTerm(searchTerm.value);
editor.value.commands.setReplaceTerm(replaceTerm.value);
+ editor.value.commands.setCaseSensitive(caseSensitive.value);
+};
+
+const goToSelection = () => {
+ if (!editor.value) return;
+
+ const { results, resultIndex } = editor.value.storage.searchAndReplace;
+ const position: Range = results[resultIndex];
+
+ if (!position) return;
+
+ editor.value.commands.setTextSelection(position);
+
+ const { node } = editor.value.view.domAtPos(
+ editor.value.state.selection.anchor
+ );
+ node instanceof HTMLElement &&
+ node.scrollIntoView({ behavior: "smooth", block: "center" });
};
watch(
() => searchTerm.value.trim(),
(val, oldVal) => {
if (!val) clear();
- if (val !== oldVal) updateSearchReplace();
- },
+ if (val !== oldVal) updateSearchReplace(true);
+ }
);
watch(
() => replaceTerm.value.trim(),
- (val, oldVal) => (val === oldVal ? null : updateSearchReplace()),
+ (val, oldVal) => (val === oldVal ? null : updateSearchReplace())
);
-const replace = () => editor.value?.commands.replace();
+watch(
+ () => caseSensitive.value,
+ (val, oldVal) => (val === oldVal ? null : updateSearchReplace(true))
+);
-const clear = () => (searchTerm.value = replaceTerm.value = "");
+const replace = () => {
+ editor.value?.commands.replace();
+ goToSelection();
+};
+
+const next = () => {
+ editor.value?.commands.next();
+ goToSelection();
+};
+
+const clear = () => {
+ searchTerm.value = replaceTerm.value = "";
+ editor.value.commands.resetIndex();
+};
const replaceAll = () => editor.value?.commands.replaceAll();
@@ -151,6 +217,10 @@ onMounted(() => setTimeout(updateSearchReplace));
.search-result {
background-color: rgba(255, 217, 0, 0.5);
+
+ &-current {
+ background-color: rgba(13, 255, 0, 0.5);
+ }
}
}
}
diff --git a/src/searchAndReplace.ts b/src/searchAndReplace.ts
index c8bec6a..d571bc4 100644
--- a/src/searchAndReplace.ts
+++ b/src/searchAndReplace.ts
@@ -20,9 +20,9 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
-import { Extension, Range } from "@tiptap/core";
+import { Extension, Range, type Dispatch } from "@tiptap/core";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
-import { Plugin, PluginKey } from "@tiptap/pm/state";
+import { Plugin, PluginKey, type EditorState, type Transaction } from "@tiptap/pm/state";
import { Node as PMNode } from "@tiptap/pm/model";
declare module "@tiptap/core" {
@@ -36,6 +36,18 @@ declare module "@tiptap/core" {
* @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.
+ */
+ next: () => ReturnType;
/**
* @description Replace first instance of search result with given replace term.
*/
@@ -73,11 +85,11 @@ function processSearches(
doc: PMNode,
searchTerm: RegExp,
searchResultClass: string,
+ resultIndex: number,
): ProcessedSearches {
const decorations: Decoration[] = [];
let textNodesWithPosition: TextNodesWithPosition[] = [];
const results: Range[] = [];
-
let index = 0;
if (!searchTerm)
@@ -103,16 +115,13 @@ function processSearches(
textNodesWithPosition = textNodesWithPosition.filter(Boolean);
- for (let i = 0; i < textNodesWithPosition.length; i += 1) {
- const { text, pos } = textNodesWithPosition[i];
-
+ for (const element of textNodesWithPosition) {
+ const { text, pos } = element;
const matches = Array.from(text.matchAll(searchTerm)).filter(
([matchText]) => matchText.trim(),
);
- for (let j = 0; j < matches.length; j += 1) {
- const m = matches[j];
-
+ for (const m of matches) {
if (m[0] === "") break;
if (m.index !== undefined) {
@@ -126,29 +135,36 @@ function processSearches(
for (let i = 0; i < results.length; i += 1) {
const r = results[i];
- decorations.push(
- Decoration.inline(r.from, r.to, { class: searchResultClass }),
- );
+ 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[],
- { state, dispatch }: any,
+ index: number,
+ { state, dispatch }: { state: EditorState; dispatch: Dispatch },
) => {
- const firstResult = results[0];
+ const firstResult = results[index]
- if (!firstResult) return;
+ if (!firstResult) return ``
- const { from, to } = results[0];
+ const { from, to } = results[index]
- if (dispatch) dispatch(state.tr.insertText(replaceTerm, from, to));
+ dispatch?.(state.tr.insertText(replaceTerm, from, to))
};
const rebaseNextResult = (
@@ -178,16 +194,15 @@ const rebaseNextResult = (
const replaceAll = (
replaceTerm: string,
results: Range[],
- { tr, dispatch }: any,
+ { tr, dispatch }: { tr: Transaction; dispatch: Dispatch },
) => {
- let offset = 0;
-
- let resultsCopy = results.slice();
+ let offset = 0
+ let resultsCopy = results.slice()
if (!resultsCopy.length) return;
for (let i = 0; i < resultsCopy.length; i += 1) {
- const { from, to } = resultsCopy[i];
+ const { from, to } = resultsCopy[i]
tr.insertText(replaceTerm, from, to);
@@ -204,7 +219,7 @@ const replaceAll = (
resultsCopy = rebaseNextResultResponse[1];
}
- dispatch(tr);
+ dispatch?.(tr)
};
export const searchAndReplacePluginKey = new PluginKey(
@@ -213,7 +228,6 @@ export const searchAndReplacePluginKey = new PluginKey(
export interface SearchAndReplaceOptions {
searchResultClass: string;
- caseSensitive: boolean;
disableRegex: boolean;
}
@@ -222,6 +236,10 @@ export interface SearchAndReplaceStorage {
replaceTerm: string;
results: Range[];
lastSearchTerm: string;
+ caseSensitive: boolean;
+ lastCaseSensitive: boolean;
+ resultIndex: number;
+ lastResultIndex: number;
}
export const SearchAndReplace = Extension.create<
@@ -233,7 +251,6 @@ export const SearchAndReplace = Extension.create<
addOptions() {
return {
searchResultClass: "search-result",
- caseSensitive: false,
disableRegex: true,
};
},
@@ -244,6 +261,10 @@ export const SearchAndReplace = Extension.create<
replaceTerm: "",
results: [],
lastSearchTerm: "",
+ caseSensitive: false,
+ lastCaseSensitive: false,
+ resultIndex: 0,
+ lastResultIndex: 0,
};
},
@@ -261,14 +282,39 @@ export const SearchAndReplace = Extension.create<
({ 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;
+ },
+ next:
+ () =>
+ ({ editor }) => {
+ const { results, resultIndex } = editor.storage.searchAndReplace;
+
+ if (results[resultIndex + 1]) {
+ editor.storage.searchAndReplace.resultIndex = resultIndex + 1;
+ }
+
return false;
},
replace:
() =>
({ editor, state, dispatch }) => {
- const { replaceTerm, results } = editor.storage.searchAndReplace;
+ const { replaceTerm, results, resultIndex } = editor.storage.searchAndReplace;
- replace(replaceTerm, results, { state, dispatch });
+ replace(replaceTerm, results, resultIndex, { state, dispatch });
return false;
},
@@ -286,10 +332,14 @@ export const SearchAndReplace = Extension.create<
addProseMirrorPlugins() {
const editor = this.editor;
- const { searchResultClass, disableRegex, caseSensitive } = this.options;
+ 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({
@@ -297,19 +347,37 @@ export const SearchAndReplace = Extension.create<
state: {
init: () => DecorationSet.empty,
apply({ doc, docChanged }, oldState) {
- const { searchTerm, lastSearchTerm } =
- editor.storage.searchAndReplace;
-
- if (!docChanged && lastSearchTerm === searchTerm) return 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) return DecorationSet.empty;
+ 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;