diff --git a/.changeset/add-find-and-replace-extension.md b/.changeset/add-find-and-replace-extension.md
new file mode 100644
index 0000000000..ae92c416d7
--- /dev/null
+++ b/.changeset/add-find-and-replace-extension.md
@@ -0,0 +1,5 @@
+---
+'@tiptap/extension-find-and-replace': minor
+---
+
+Added a new `@tiptap/extension-find-and-replace` extension. It searches the document for a term, highlights all matches with decorations, and replaces the current or all matches. Supports case-sensitive, whole-word, and RE2-compatible regex search, plus commands to jump between results. Regex search avoids catastrophic backtracking but does not support lookarounds or backreferences.
diff --git a/.fallowrc.json b/.fallowrc.json
index c8bc7eb2ab..14440cc1cd 100644
--- a/.fallowrc.json
+++ b/.fallowrc.json
@@ -1,5 +1,6 @@
{
"$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json",
"ignorePatterns": ["**/*.spec.ts"],
- "ignoreExports": [{ "file": "**/*", "exports": ["default"] }]
+ "ignoreExports": [{ "file": "**/*", "exports": ["default"] }],
+ "dynamicallyLoaded": ["demos/src/**/index.*"]
}
diff --git a/demos/src/Extensions/FindAndReplace/React/hooks/useFindAndReplace.js b/demos/src/Extensions/FindAndReplace/React/hooks/useFindAndReplace.js
new file mode 100644
index 0000000000..7ae4032f5b
--- /dev/null
+++ b/demos/src/Extensions/FindAndReplace/React/hooks/useFindAndReplace.js
@@ -0,0 +1,82 @@
+import { useEditorState } from '@tiptap/react'
+import { useEffect, useState } from 'react'
+
+export function useFindAndReplace(editor) {
+ const { searchTerm, replaceTerm, caseSensitive, useRegex, wholeWord, resultCount, currentIndex } =
+ useEditorState({
+ editor,
+ selector: ctx => ({
+ searchTerm: ctx.editor.storage.findAndReplace.searchTerm,
+ replaceTerm: ctx.editor.storage.findAndReplace.replaceTerm,
+ caseSensitive: ctx.editor.storage.findAndReplace.caseSensitive,
+ useRegex: ctx.editor.storage.findAndReplace.useRegex,
+ wholeWord: ctx.editor.storage.findAndReplace.wholeWord,
+ resultCount: ctx.editor.storage.findAndReplace.results.length,
+ currentIndex: ctx.editor.storage.findAndReplace.currentIndex,
+ }),
+ })
+ const [searchInput, setSearchInput] = useState(searchTerm)
+
+ useEffect(() => {
+ setSearchInput(searchTerm)
+ }, [searchTerm])
+
+ const setSearchTerm = term => {
+ setSearchInput(term)
+ editor.commands.setSearchTerm(term)
+ }
+
+ const goToNextResult = () => {
+ editor.commands.goToNextResult()
+ }
+
+ const goToPreviousResult = () => {
+ editor.commands.goToPreviousResult()
+ }
+
+ const replace = () => {
+ editor.commands.replace()
+ }
+
+ const replaceAll = () => {
+ editor.commands.replaceAll()
+ }
+
+ const clearSearch = () => {
+ setSearchInput('')
+ editor.commands.clearSearch()
+ }
+
+ const onSearchKeyDown = event => {
+ if (event.key !== 'Enter') {
+ return
+ }
+
+ if (event.shiftKey) {
+ editor.commands.goToPreviousResult()
+ } else {
+ editor.commands.goToNextResult()
+ }
+ }
+
+ return {
+ searchInput,
+ replaceTerm,
+ caseSensitive,
+ useRegex,
+ wholeWord,
+ resultCount,
+ currentIndex,
+ setSearchTerm,
+ setReplaceTerm: term => editor.commands.setReplaceTerm(term),
+ setCaseSensitive: value => editor.commands.setCaseSensitive(value),
+ setUseRegex: value => editor.commands.setUseRegex(value),
+ setWholeWord: value => editor.commands.setWholeWord(value),
+ goToNextResult,
+ goToPreviousResult,
+ replace,
+ replaceAll,
+ clearSearch,
+ onSearchKeyDown,
+ }
+}
diff --git a/demos/src/Extensions/FindAndReplace/React/index.html b/demos/src/Extensions/FindAndReplace/React/index.html
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/demos/src/Extensions/FindAndReplace/React/index.jsx b/demos/src/Extensions/FindAndReplace/React/index.jsx
new file mode 100644
index 0000000000..7b8437f165
--- /dev/null
+++ b/demos/src/Extensions/FindAndReplace/React/index.jsx
@@ -0,0 +1,136 @@
+import './styles.scss'
+
+import Document from '@tiptap/extension-document'
+import FindAndReplace from '@tiptap/extension-find-and-replace'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { EditorContent, useEditor } from '@tiptap/react'
+
+import { useFindAndReplace } from './hooks/useFindAndReplace'
+
+export default () => {
+ const editor = useEditor({
+ extensions: [Document, Paragraph, Text, FindAndReplace],
+ content: `
+
+ Find and replace is one of those features you only miss once it is gone.
+ Try searching for "tiptap" in this text. Tiptap highlights every match
+ right inside the editor, no matter if it is written as tiptap, Tiptap
+ or even TIPTAP.
+
+
+ Toggle "Match case" to only find exact matches. Enable "Regex" and search
+ for colou?r to find both color and colour at once. Hit "Replace" to swap
+ the current match and jump straight to the next one.
+
+ `,
+ })
+
+ const {
+ searchInput,
+ replaceTerm,
+ caseSensitive,
+ useRegex,
+ wholeWord,
+ resultCount,
+ currentIndex,
+ setSearchTerm,
+ setReplaceTerm,
+ setCaseSensitive,
+ setUseRegex,
+ setWholeWord,
+ goToNextResult,
+ goToPreviousResult,
+ replace,
+ replaceAll,
+ clearSearch,
+ onSearchKeyDown,
+ } = useFindAndReplace(editor)
+
+ if (!editor) {
+ return null
+ }
+
+ return (
+ <>
+
+
+ >
+ )
+}
diff --git a/demos/src/Extensions/FindAndReplace/React/styles.scss b/demos/src/Extensions/FindAndReplace/React/styles.scss
new file mode 100644
index 0000000000..9ed2f121c0
--- /dev/null
+++ b/demos/src/Extensions/FindAndReplace/React/styles.scss
@@ -0,0 +1,21 @@
+/* Basic editor styles */
+.tiptap {
+ :first-child {
+ margin-top: 0;
+ }
+}
+
+.control-group {
+ label {
+ align-items: center;
+ display: flex;
+ gap: 0.25rem;
+ font-size: 0.875rem;
+ }
+
+ .result-count {
+ align-self: center;
+ color: var(--gray-5);
+ font-size: 0.875rem;
+ }
+}
diff --git a/demos/src/Extensions/FindAndReplace/Vue/index.html b/demos/src/Extensions/FindAndReplace/Vue/index.html
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/demos/src/Extensions/FindAndReplace/Vue/index.vue b/demos/src/Extensions/FindAndReplace/Vue/index.vue
new file mode 100644
index 0000000000..e16820b166
--- /dev/null
+++ b/demos/src/Extensions/FindAndReplace/Vue/index.vue
@@ -0,0 +1,171 @@
+
+
+
+
+
+
+
diff --git a/demos/src/Extensions/FindAndReplace/Vue/utils.ts b/demos/src/Extensions/FindAndReplace/Vue/utils.ts
new file mode 100644
index 0000000000..6417286220
--- /dev/null
+++ b/demos/src/Extensions/FindAndReplace/Vue/utils.ts
@@ -0,0 +1,76 @@
+import type { Editor } from '@tiptap/vue-3'
+import type { FindAndReplaceStorage } from '@tiptap/extension-find-and-replace'
+import { computed, ref } from 'vue'
+
+function createFindAndReplaceState(): FindAndReplaceStorage {
+ return {
+ searchTerm: '',
+ replaceTerm: '',
+ caseSensitive: false,
+ useRegex: false,
+ wholeWord: false,
+ results: [],
+ currentIndex: null,
+ }
+}
+
+export function useFindAndReplace() {
+ const findAndReplace = ref(createFindAndReplaceState())
+ const resultCount = computed(() => {
+ const { results, currentIndex } = findAndReplace.value
+
+ return results.length === 0 ? 'No results' : `${(currentIndex ?? 0) + 1} of ${results.length}`
+ })
+ let editor: Editor | null = null
+
+ const updateFindAndReplace = () => {
+ if (editor) {
+ findAndReplace.value = { ...editor.storage.findAndReplace }
+ }
+ }
+
+ return {
+ findAndReplace,
+ resultCount,
+ connect(nextEditor: Editor) {
+ editor = nextEditor
+ updateFindAndReplace()
+ editor.on('transaction', updateFindAndReplace)
+ },
+ disconnect() {
+ editor?.off('transaction', updateFindAndReplace)
+ editor = null
+ },
+ setSearchTerm(term: string) {
+ findAndReplace.value.searchTerm = term
+ editor?.commands.setSearchTerm(term)
+ },
+ setReplaceTerm(term: string) {
+ editor?.commands.setReplaceTerm(term)
+ },
+ setCaseSensitive(value: boolean) {
+ editor?.commands.setCaseSensitive(value)
+ },
+ setUseRegex(value: boolean) {
+ editor?.commands.setUseRegex(value)
+ },
+ setWholeWord(value: boolean) {
+ editor?.commands.setWholeWord(value)
+ },
+ goToNextResult() {
+ editor?.commands.goToNextResult()
+ },
+ goToPreviousResult() {
+ editor?.commands.goToPreviousResult()
+ },
+ replace() {
+ editor?.commands.replace()
+ },
+ replaceAll() {
+ editor?.commands.replaceAll()
+ },
+ clearSearch() {
+ editor?.commands.clearSearch()
+ },
+ }
+}
diff --git a/packages/extension-find-and-replace/README.md b/packages/extension-find-and-replace/README.md
new file mode 100644
index 0000000000..1de3f75e0e
--- /dev/null
+++ b/packages/extension-find-and-replace/README.md
@@ -0,0 +1,18 @@
+# @tiptap/extension-find-and-replace
+
+[](https://www.npmjs.com/package/@tiptap/extension-find-and-replace)
+[](https://npmcharts.com/compare/tiptap?minimal=true)
+[](https://www.npmjs.com/package/@tiptap/extension-find-and-replace)
+[](https://github.com/sponsors/ueberdosis)
+
+## Introduction
+
+Tiptap is a headless wrapper around [ProseMirror](https://ProseMirror.net) – a toolkit for building rich text WYSIWYG editors, which is already in use at many well-known companies such as _New York Times_, _The Guardian_ or _Atlassian_.
+
+## Official Documentation
+
+Documentation can be found on the [Tiptap website](https://tiptap.dev).
+
+## License
+
+Tiptap is open sourced software licensed under the [MIT license](https://github.com/ueberdosis/tiptap/blob/main/LICENSE.md).
diff --git a/packages/extension-find-and-replace/__tests__/find-and-replace.spec.ts b/packages/extension-find-and-replace/__tests__/find-and-replace.spec.ts
new file mode 100644
index 0000000000..58ed36b4e7
--- /dev/null
+++ b/packages/extension-find-and-replace/__tests__/find-and-replace.spec.ts
@@ -0,0 +1,524 @@
+import { Editor } from '@tiptap/core'
+import Bold from '@tiptap/extension-bold'
+import Document from '@tiptap/extension-document'
+import FindAndReplace, {
+ createSearchRegex,
+ searchDocument,
+} from '@tiptap/extension-find-and-replace'
+import type { FindAndReplaceOptions } from '@tiptap/extension-find-and-replace'
+import HardBreak from '@tiptap/extension-hard-break'
+import Paragraph from '@tiptap/extension-paragraph'
+import Text from '@tiptap/extension-text'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { normalizeCurrentIndex } from '../src/utils/normalizeCurrentIndex.js'
+
+describe('FindAndReplace', () => {
+ let editor: Editor
+
+ const createEditor = (
+ content: string,
+ options: Partial = { searchDebounceMs: 0 },
+ ) => {
+ const element = document.createElement('div')
+ document.body.appendChild(element)
+
+ return new Editor({
+ element,
+ extensions: [Document, Paragraph, Text, Bold, FindAndReplace.configure(options)],
+ content,
+ })
+ }
+
+ beforeEach(() => {
+ editor = createEditor('Hello hello HELLO
')
+ })
+
+ afterEach(() => {
+ editor.destroy()
+ vi.useRealTimers()
+ })
+
+ it('debounces setSearchTerm by default', () => {
+ vi.useFakeTimers()
+
+ editor.destroy()
+ editor = createEditor('Hello hello HELLO
', { searchDebounceMs: 250 })
+
+ editor.commands.setSearchTerm('hel')
+ editor.commands.setSearchTerm('hello')
+
+ expect(editor.storage.findAndReplace.searchTerm).toBe('')
+ expect(editor.storage.findAndReplace.results).toEqual([])
+
+ vi.advanceTimersByTime(249)
+
+ expect(editor.storage.findAndReplace.searchTerm).toBe('')
+ expect(editor.storage.findAndReplace.results).toEqual([])
+
+ vi.advanceTimersByTime(1)
+
+ expect(editor.storage.findAndReplace.searchTerm).toBe('hello')
+ expect(editor.storage.findAndReplace.results).toEqual([
+ { from: 1, to: 6 },
+ { from: 7, to: 12 },
+ { from: 13, to: 18 },
+ ])
+ })
+
+ it('flushes pending search before replace', () => {
+ vi.useFakeTimers()
+
+ editor.destroy()
+ editor = createEditor('foo bar foo
', { searchDebounceMs: 250 })
+
+ editor.commands.setSearchTerm('foo')
+ editor.commands.setReplaceTerm('baz')
+
+ expect(editor.commands.replace()).toBe(true)
+ expect(editor.getText()).toBe('baz bar foo')
+ })
+
+ it('finds matches case insensitively by default', () => {
+ editor.commands.setSearchTerm('hello')
+
+ expect(editor.storage.findAndReplace.results).toEqual([
+ { from: 1, to: 6 },
+ { from: 7, to: 12 },
+ { from: 13, to: 18 },
+ ])
+ expect(editor.storage.findAndReplace.currentIndex).toBe(0)
+ })
+
+ it('syncs the search term to the storage', () => {
+ editor.commands.setSearchTerm('hello')
+
+ expect(editor.storage.findAndReplace.searchTerm).toBe('hello')
+ })
+
+ it('syncs initial search results to the storage', () => {
+ editor.destroy()
+ editor = new Editor({
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ Bold,
+ FindAndReplace.configure({ searchTerm: 'hello', searchDebounceMs: 0 }),
+ ],
+ content: 'Hello hello HELLO
',
+ })
+
+ expect(editor.storage.findAndReplace.results).toEqual([
+ { from: 1, to: 6 },
+ { from: 7, to: 12 },
+ { from: 13, to: 18 },
+ ])
+ })
+
+ it('finds only exact matches when case sensitive', () => {
+ editor.commands.setSearchTerm('hello')
+ editor.commands.setCaseSensitive(true)
+
+ expect(editor.storage.findAndReplace.results).toEqual([{ from: 7, to: 12 }])
+ })
+
+ it('supports regular expressions', () => {
+ editor.commands.setUseRegex(true)
+ editor.commands.setSearchTerm('h.llo')
+
+ expect(editor.storage.findAndReplace.results).toHaveLength(3)
+ })
+
+ it('supports Unicode property escapes in regex mode', () => {
+ editor.destroy()
+ editor = createEditor('café 123
')
+ editor.commands.setUseRegex(true)
+ editor.commands.setSearchTerm('\\p{L}+')
+
+ expect(editor.storage.findAndReplace.results).toEqual([{ from: 1, to: 5 }])
+ })
+
+ it('matches an emoji as one character in regex mode', () => {
+ editor.destroy()
+ editor = createEditor('😀
')
+ editor.commands.setUseRegex(true)
+ editor.commands.setSearchTerm('.')
+
+ expect(editor.storage.findAndReplace.results).toEqual([{ from: 1, to: 3 }])
+ })
+
+ it('returns no results for an invalid regular expression', () => {
+ editor.commands.setUseRegex(true)
+ editor.commands.setSearchTerm('([')
+
+ expect(editor.storage.findAndReplace.results).toEqual([])
+ })
+
+ it('finds matches across marks', () => {
+ editor.destroy()
+ editor = createEditor('hello world
')
+ editor.commands.setSearchTerm('hello')
+
+ expect(editor.storage.findAndReplace.results).toEqual([{ from: 1, to: 6 }])
+ })
+
+ it('does not match across hard breaks', () => {
+ editor.destroy()
+ editor = new Editor({
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ HardBreak,
+ FindAndReplace.configure({ searchDebounceMs: 0 }),
+ ],
+ content: 'foo
bar
',
+ })
+ editor.commands.setSearchTerm('foo\nbar')
+
+ expect(editor.storage.findAndReplace.results).toEqual([])
+ })
+
+ it('finds results in a mark-heavy paragraph', () => {
+ const segmentCount = 10_000
+ const content = Array.from({ length: segmentCount }, (_, index) =>
+ index % 2 === 0 ? 'a' : 'a',
+ ).join('')
+
+ editor.destroy()
+ editor = createEditor(`${content}
`)
+
+ const results = searchDocument(editor.state.doc, 'a', {
+ caseSensitive: false,
+ useRegex: false,
+ wholeWord: false,
+ })
+
+ expect(results).toHaveLength(segmentCount)
+ expect(results[0]).toEqual({ from: 1, to: 2 })
+ expect(results.at(-1)).toEqual({ from: segmentCount, to: segmentCount + 1 })
+ })
+
+ it('renders decorations for all results', () => {
+ editor.commands.setSearchTerm('hello')
+
+ const results = editor.view.dom.querySelectorAll('.find-and-replace-result')
+ const current = editor.view.dom.querySelectorAll('.find-and-replace-result-current')
+
+ expect(results).toHaveLength(3)
+ expect(current).toHaveLength(1)
+ })
+
+ it('updates results when the document changes', () => {
+ editor.commands.setSearchTerm('hello')
+ editor.commands.setContent('hello hello
')
+
+ expect(editor.storage.findAndReplace.results).toHaveLength(2)
+ })
+
+ it('updates only the matches in a changed textblock', () => {
+ editor.destroy()
+ editor = createEditor('foo
foo
foo
')
+ editor.commands.setSearchTerm('foo')
+
+ editor.commands.insertContentAt(2, 'x')
+
+ expect(editor.storage.findAndReplace.results).toEqual([
+ { from: 7, to: 10 },
+ { from: 12, to: 15 },
+ ])
+ expect(editor.view.dom.querySelectorAll('.find-and-replace-result')).toHaveLength(2)
+ })
+
+ it('removes mapped matches from a deleted textblock', () => {
+ editor.destroy()
+ editor = createEditor('foo
foo
')
+ editor.commands.setSearchTerm('foo')
+
+ editor.view.dispatch(editor.state.tr.delete(0, 5))
+
+ expect(editor.storage.findAndReplace.results).toEqual([{ from: 1, to: 4 }])
+ expect(editor.view.dom.querySelectorAll('.find-and-replace-result')).toHaveLength(1)
+ })
+
+ it('updates whole-word matches at a changed textblock boundary', () => {
+ editor.destroy()
+ editor = createEditor('hello
hello
')
+ editor.commands.setSearchTerm('hello')
+ editor.commands.setWholeWord(true)
+
+ editor.commands.insertContentAt(6, 'x')
+
+ expect(editor.storage.findAndReplace.results).toEqual([{ from: 9, to: 14 }])
+ })
+
+ it('selects the first result created by a document change', () => {
+ editor.commands.setSearchTerm('world')
+ editor.commands.setContent('world
')
+
+ expect(editor.storage.findAndReplace.currentIndex).toBe(0)
+ expect(editor.view.dom.querySelectorAll('.find-and-replace-result-current')).toHaveLength(1)
+ })
+
+ it('selects the first result when text is inserted after setting the search term', () => {
+ editor.destroy()
+ editor = createEditor('')
+ editor.commands.setSearchTerm('hello')
+
+ expect(editor.storage.findAndReplace.currentIndex).toBeNull()
+
+ editor.commands.insertContent('hello')
+
+ expect(editor.storage.findAndReplace.results).toEqual([{ from: 1, to: 6 }])
+ expect(editor.storage.findAndReplace.currentIndex).toBe(0)
+ expect(editor.view.dom.querySelectorAll('.find-and-replace-result-current')).toHaveLength(1)
+ })
+
+ it('clears results and decorations on clearSearch', () => {
+ editor.commands.setSearchTerm('hello')
+ editor.commands.clearSearch()
+
+ expect(editor.storage.findAndReplace.results).toEqual([])
+ expect(editor.view.dom.querySelectorAll('.find-and-replace-result')).toHaveLength(0)
+ })
+
+ it('navigates results with wrap around', () => {
+ editor.destroy()
+ editor = createEditor('one two one two one
')
+ editor.commands.setSearchTerm('one')
+
+ expect(editor.storage.findAndReplace.currentIndex).toBe(0)
+
+ editor.commands.goToNextResult()
+ expect(editor.storage.findAndReplace.currentIndex).toBe(1)
+ expect(editor.state.selection.from).toBe(9)
+ expect(editor.state.selection.to).toBe(12)
+
+ editor.commands.goToNextResult()
+ editor.commands.goToNextResult()
+ expect(editor.storage.findAndReplace.currentIndex).toBe(0)
+
+ editor.commands.goToPreviousResult()
+ expect(editor.storage.findAndReplace.currentIndex).toBe(2)
+ })
+
+ it('replaces the current result and jumps to the next one', () => {
+ editor.destroy()
+ editor = createEditor('foo bar foo
')
+ editor.commands.setSearchTerm('foo')
+ editor.commands.setReplaceTerm('baz')
+ editor.commands.replace()
+
+ expect(editor.getText()).toBe('baz bar foo')
+ expect(editor.storage.findAndReplace.results).toEqual([{ from: 9, to: 12 }])
+ expect(editor.storage.findAndReplace.currentIndex).toBe(0)
+ expect(editor.state.selection.from).toBe(9)
+ expect(editor.state.selection.to).toBe(12)
+ })
+
+ it('skips a replacement that still matches the search term', () => {
+ editor.destroy()
+ editor = createEditor('foo bar foo
')
+ editor.commands.setSearchTerm('foo')
+ editor.commands.setReplaceTerm('foobar')
+ editor.commands.replace()
+
+ expect(editor.getText()).toBe('foobar bar foo')
+ expect(editor.storage.findAndReplace.currentIndex).toBe(1)
+ expect(editor.state.selection.from).toBe(12)
+ expect(editor.state.selection.to).toBe(15)
+ })
+
+ it('wraps around when replacing the last result', () => {
+ editor.destroy()
+ editor = createEditor('foo bar foo
')
+ editor.commands.setSearchTerm('foo')
+ editor.commands.setReplaceTerm('foo')
+ editor.commands.goToNextResult()
+ editor.commands.replace()
+
+ expect(editor.getText()).toBe('foo bar foo')
+ expect(editor.storage.findAndReplace.currentIndex).toBe(0)
+ })
+
+ it('replaces all results at once', () => {
+ editor.commands.setSearchTerm('hello')
+ editor.commands.setReplaceTerm('world')
+ editor.commands.replaceAll()
+
+ expect(editor.getText()).toBe('world world world')
+ expect(editor.storage.findAndReplace.results).toEqual([])
+ expect(editor.storage.findAndReplace.currentIndex).toBeNull()
+ })
+
+ it('replaces results across textblocks without removing trailing text', () => {
+ editor.destroy()
+ editor = createEditor('foo end
foo end
')
+ editor.commands.setSearchTerm('foo')
+ editor.commands.setReplaceTerm('long replacement')
+ editor.commands.replaceAll()
+
+ expect(editor.getHTML()).toBe('long replacement end
long replacement end
')
+ })
+
+ it('replaces results across marks', () => {
+ editor.destroy()
+ editor = createEditor('hello hello
')
+ editor.commands.setSearchTerm('hello')
+ editor.commands.setReplaceTerm('world')
+ editor.commands.replaceAll()
+
+ expect(editor.getHTML()).toBe('world world
')
+ })
+
+ it('replaces many results with one transaction step', () => {
+ const resultCount = 20_000
+ const content = Array.from({ length: resultCount }, () => 'foo').join(' ')
+ const transactions: number[] = []
+
+ editor.destroy()
+ editor = createEditor(`${content}
`)
+ editor.commands.setSearchTerm('foo')
+ editor.commands.setReplaceTerm('bar')
+ editor.on('transaction', ({ transaction }) => {
+ transactions.push(transaction.steps.length)
+ })
+ editor.commands.replaceAll()
+
+ expect(editor.getText()).toBe(content.replaceAll('foo', 'bar'))
+ expect(transactions).toEqual([1])
+ })
+
+ it('keeps new results when the replacement still matches', () => {
+ editor.commands.setSearchTerm('hello')
+ editor.commands.setReplaceTerm('hello!')
+ editor.commands.replaceAll()
+
+ expect(editor.getText()).toBe('hello! hello! hello!')
+ expect(editor.storage.findAndReplace.results).toEqual([
+ { from: 1, to: 6 },
+ { from: 8, to: 13 },
+ { from: 15, to: 20 },
+ ])
+ })
+
+ it('does nothing on replace without results', () => {
+ expect(editor.commands.replace()).toBe(false)
+ expect(editor.commands.replaceAll()).toBe(false)
+ })
+
+ it('handles nested quantifiers with the safe regex engine', () => {
+ editor.destroy()
+ editor = createEditor('aaaa
')
+ editor.commands.setUseRegex(true)
+ editor.commands.setSearchTerm('(a+)+')
+
+ expect(editor.storage.findAndReplace.results).toEqual([{ from: 1, to: 5 }])
+ })
+
+ it('handles overlapping alternatives without catastrophic backtracking', () => {
+ editor.destroy()
+ editor = createEditor(`${'a'.repeat(40)}!
`)
+ editor.commands.setUseRegex(true)
+ editor.commands.setSearchTerm('^(a|aa)+$')
+
+ expect(editor.storage.findAndReplace.results).toEqual([])
+ })
+
+ it('creates a safe matcher for regex mode', () => {
+ const regex = createSearchRegex('^(a|aa)+$', {
+ caseSensitive: true,
+ useRegex: true,
+ wholeWord: false,
+ })
+
+ expect(regex).not.toBeNull()
+ expect(regex).not.toBeInstanceOf(RegExp)
+ })
+
+ it('returns no results for unsupported regex syntax', () => {
+ editor.commands.setUseRegex(true)
+ editor.commands.setSearchTerm('(?=hello)hello')
+
+ expect(editor.storage.findAndReplace.results).toEqual([])
+
+ editor.commands.setSearchTerm('(hello)\\1')
+
+ expect(editor.storage.findAndReplace.results).toEqual([])
+ })
+
+ it('finds only whole words when wholeWord is enabled', () => {
+ editor.destroy()
+ editor = createEditor('hello helloworld worldhello hello
')
+ editor.commands.setSearchTerm('hello')
+ editor.commands.setWholeWord(true)
+
+ expect(editor.storage.findAndReplace.results).toEqual([
+ { from: 1, to: 6 },
+ { from: 29, to: 34 },
+ ])
+ })
+
+ it('finds Unicode whole words when wholeWord is enabled', () => {
+ editor.destroy()
+ editor = createEditor('café caféine café
')
+ editor.commands.setSearchTerm('café')
+ editor.commands.setWholeWord(true)
+
+ expect(editor.storage.findAndReplace.results).toEqual([
+ { from: 1, to: 5 },
+ { from: 14, to: 18 },
+ ])
+ })
+
+ it('ignores wholeWord when regex mode is enabled', () => {
+ editor.destroy()
+ editor = createEditor('hello helloworld worldhello hello
')
+ editor.commands.setUseRegex(true)
+ editor.commands.setSearchTerm('hello')
+ editor.commands.setWholeWord(true)
+
+ expect(editor.storage.findAndReplace.results).toHaveLength(4)
+ })
+
+ it('syncs wholeWord to the storage', () => {
+ editor.commands.setWholeWord(true)
+
+ expect(editor.storage.findAndReplace.wholeWord).toBe(true)
+ })
+
+ it('keeps the active result when a new match is inserted before it', () => {
+ editor.commands.setSearchTerm('hello')
+ editor.commands.goToNextResult()
+
+ expect(editor.storage.findAndReplace.currentIndex).toBe(1)
+
+ editor.commands.insertContentAt(7, 'hello ')
+
+ expect(editor.storage.findAndReplace.results).toHaveLength(4)
+ expect(editor.storage.findAndReplace.currentIndex).toBe(2)
+ expect(editor.storage.findAndReplace.results[2]).toEqual({ from: 13, to: 18 })
+ })
+
+ it('keeps the active result when a new match is inserted after it', () => {
+ editor.commands.setSearchTerm('hello')
+ editor.commands.goToNextResult()
+
+ expect(editor.storage.findAndReplace.currentIndex).toBe(1)
+
+ editor.commands.insertContentAt(12, 'hello ')
+
+ expect(editor.storage.findAndReplace.results).toHaveLength(4)
+ expect(editor.storage.findAndReplace.currentIndex).toBe(1)
+ expect(editor.storage.findAndReplace.results[1]).toEqual({ from: 7, to: 12 })
+ })
+
+ it('normalizes search result indices', () => {
+ expect(normalizeCurrentIndex(undefined, 1)).toBeNull()
+ expect(normalizeCurrentIndex(null, 1)).toBeNull()
+ expect(normalizeCurrentIndex(0, 0)).toBeNull()
+ expect(normalizeCurrentIndex(-1, 2)).toBe(0)
+ expect(normalizeCurrentIndex(2, 2)).toBe(1)
+ })
+})
diff --git a/packages/extension-find-and-replace/package.json b/packages/extension-find-and-replace/package.json
new file mode 100644
index 0000000000..6613348323
--- /dev/null
+++ b/packages/extension-find-and-replace/package.json
@@ -0,0 +1,55 @@
+{
+ "name": "@tiptap/extension-find-and-replace",
+ "version": "3.28.0",
+ "description": "find and replace extension for tiptap",
+ "keywords": [
+ "tiptap",
+ "tiptap extension"
+ ],
+ "homepage": "https://tiptap.dev",
+ "bugs": {
+ "url": "https://github.com/ueberdosis/tiptap/issues"
+ },
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/ueberdosis/tiptap",
+ "directory": "packages/extension-find-and-replace"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "files": [
+ "src",
+ "dist"
+ ],
+ "type": "module",
+ "main": "dist/index.cjs",
+ "module": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": {
+ "import": "./dist/index.d.ts",
+ "require": "./dist/index.d.cts"
+ },
+ "import": "./dist/index.js",
+ "require": "./dist/index.cjs"
+ }
+ },
+ "scripts": {
+ "build": "tsup"
+ },
+ "dependencies": {
+ "re2js": "^2.8.6"
+ },
+ "devDependencies": {
+ "@tiptap/core": "workspace:^",
+ "@tiptap/pm": "workspace:^"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "workspace:*",
+ "@tiptap/pm": "workspace:*"
+ }
+}
diff --git a/packages/extension-find-and-replace/src/constants/constants.ts b/packages/extension-find-and-replace/src/constants/constants.ts
new file mode 100644
index 0000000000..9b92055e2d
--- /dev/null
+++ b/packages/extension-find-and-replace/src/constants/constants.ts
@@ -0,0 +1,2 @@
+export const resultClass = 'find-and-replace-result'
+export const currentResultClass = 'find-and-replace-result-current'
diff --git a/packages/extension-find-and-replace/src/find-and-replace.ts b/packages/extension-find-and-replace/src/find-and-replace.ts
new file mode 100644
index 0000000000..2153653642
--- /dev/null
+++ b/packages/extension-find-and-replace/src/find-and-replace.ts
@@ -0,0 +1,434 @@
+import type { Command, Editor } from '@tiptap/core'
+import { Extension } from '@tiptap/core'
+import type { EditorState, Transaction } from '@tiptap/pm/state'
+import { TextSelection } from '@tiptap/pm/state'
+
+import {
+ clearDebouncedSearchState,
+ getDebouncedSearchState,
+} from './state/debounced-search-state.js'
+import type { FindAndReplaceMeta, FindAndReplacePluginState } from './plugin/plugin.js'
+import { FindAndReplacePlugin, FindAndReplacePluginKey } from './plugin/plugin.js'
+import { findNextIndex, searchDocument } from './search/search.js'
+import type { SearchResult } from './search/search.js'
+import type { FindAndReplaceOptions, FindAndReplaceStorage } from './types.js'
+import { replaceAllResults } from './utils/replaceAllResults.js'
+
+declare module '@tiptap/core' {
+ interface Commands {
+ findAndReplace: {
+ /**
+ * Set the search term and highlight all matches in the document.
+ * @param term The search term, treated as regex source when regex mode is enabled.
+ * @example editor.commands.setSearchTerm('Hello')
+ */
+ setSearchTerm: (term: string) => ReturnType
+
+ /**
+ * Set the replace term used by the replace commands.
+ * @param term The replacement text.
+ * @example editor.commands.setReplaceTerm('World')
+ */
+ setReplaceTerm: (term: string) => ReturnType
+
+ /**
+ * Set whether the search is case sensitive.
+ * @param caseSensitive The new case sensitivity.
+ * @example editor.commands.setCaseSensitive(true)
+ */
+ setCaseSensitive: (caseSensitive: boolean) => ReturnType
+
+ /**
+ * Set whether the search term is treated as an RE2-compatible regular expression.
+ * Lookarounds and backreferences are not supported.
+ * @param useRegex The new regex mode.
+ * @example editor.commands.setUseRegex(true)
+ */
+ setUseRegex: (useRegex: boolean) => ReturnType
+
+ /**
+ * Set whether to match whole words only. Ignored when regex mode is enabled.
+ * @param wholeWord The new whole word mode.
+ * @example editor.commands.setWholeWord(true)
+ */
+ setWholeWord: (wholeWord: boolean) => ReturnType
+
+ /**
+ * Replace the current search result and jump to the next one.
+ * @example editor.commands.replace()
+ */
+ replace: () => ReturnType
+
+ /**
+ * Replace all search results at once.
+ * @example editor.commands.replaceAll()
+ */
+ replaceAll: () => ReturnType
+
+ /**
+ * Jump to the next search result, wrapping around at the end.
+ * @example editor.commands.goToNextResult()
+ */
+ goToNextResult: () => ReturnType
+
+ /**
+ * Jump to the previous search result, wrapping around at the start.
+ * @example editor.commands.goToPreviousResult()
+ */
+ goToPreviousResult: () => ReturnType
+
+ /**
+ * Clear the search term and remove all search result highlights.
+ * @example editor.commands.clearSearch()
+ */
+ clearSearch: () => ReturnType
+ }
+ }
+}
+
+function mergePluginMeta(tr: Transaction, meta: FindAndReplaceMeta): void {
+ const currentMeta = tr.getMeta(FindAndReplacePluginKey) as FindAndReplaceMeta | undefined
+
+ tr.setMeta(FindAndReplacePluginKey, { ...currentMeta, ...meta })
+}
+
+function setPluginMeta(meta: FindAndReplaceMeta): Command {
+ return ({ tr, dispatch }) => {
+ if (dispatch) {
+ mergePluginMeta(tr, meta)
+ }
+
+ return true
+ }
+}
+
+function applySearchTerm(editor: Editor, term: string): void {
+ const tr = editor.state.tr
+
+ mergePluginMeta(tr, { searchTerm: term })
+ editor.view.dispatch(tr)
+}
+
+function consumePendingSearch(editor: Editor): string | null {
+ const state = getDebouncedSearchState(editor)
+
+ if (state.pendingTerm === null) {
+ return null
+ }
+
+ const pendingTerm = state.pendingTerm
+
+ state.pendingTerm = null
+
+ if (state.timeout !== null) {
+ clearTimeout(state.timeout)
+ state.timeout = null
+ }
+
+ return pendingTerm
+}
+
+function getPendingAwarePluginState(
+ state: EditorState,
+ pendingTerm: string | null,
+): FindAndReplacePluginState | null {
+ const pluginState = FindAndReplacePluginKey.getState(state)
+
+ if (!pluginState) {
+ return null
+ }
+
+ if (pendingTerm === null) {
+ return pluginState.results.length === 0 ? null : pluginState
+ }
+
+ const results = searchDocument(state.doc, pendingTerm, {
+ caseSensitive: pluginState.caseSensitive,
+ useRegex: pluginState.useRegex,
+ wholeWord: pluginState.wholeWord,
+ })
+
+ if (results.length === 0) {
+ return null
+ }
+
+ return {
+ ...pluginState,
+ searchTerm: pendingTerm,
+ results,
+ currentIndex: 0,
+ }
+}
+
+function currentResult(pluginState: FindAndReplacePluginState): SearchResult | undefined {
+ return pluginState.results[pluginState.currentIndex ?? 0]
+}
+
+function shiftIndex(currentIndex: number | null, length: number, direction: 1 | -1): number {
+ if (currentIndex === null) {
+ return direction === 1 ? 0 : length - 1
+ }
+
+ return (currentIndex + direction + length) % length
+}
+
+function selectResult(tr: Transaction, result: SearchResult, index: number): void {
+ tr.setSelection(TextSelection.create(tr.doc, result.from, result.to))
+ mergePluginMeta(tr, { currentIndex: index })
+ tr.scrollIntoView()
+}
+
+function replaceResult(
+ tr: Transaction,
+ pluginState: FindAndReplacePluginState,
+ result: SearchResult,
+): void {
+ const { searchTerm, replaceTerm, caseSensitive, useRegex, wholeWord } = pluginState
+
+ tr.insertText(replaceTerm, result.from, result.to)
+
+ // Jump to the first result behind the inserted text, so a replacement
+ // that still matches the search (e.g. "foo" -> "foobar") is skipped.
+ const results = searchDocument(tr.doc, searchTerm, { caseSensitive, useRegex, wholeWord })
+ const nextIndex = findNextIndex(results, result.from + replaceTerm.length)
+
+ if (nextIndex === null) {
+ mergePluginMeta(tr, { currentIndex: null })
+ return
+ }
+
+ selectResult(tr, results[nextIndex], nextIndex)
+}
+
+export const FindAndReplace = Extension.create({
+ name: 'findAndReplace',
+
+ addOptions() {
+ return {
+ searchTerm: '',
+ replaceTerm: '',
+ caseSensitive: false,
+ useRegex: false,
+ wholeWord: false,
+ searchDebounceMs: 250,
+ injectCSS: true,
+ injectNonce: undefined,
+ }
+ },
+
+ addStorage() {
+ return {
+ searchTerm: this.options.searchTerm,
+ replaceTerm: this.options.replaceTerm,
+ caseSensitive: this.options.caseSensitive,
+ useRegex: this.options.useRegex,
+ wholeWord: this.options.wholeWord,
+ results: [],
+ currentIndex: null,
+ }
+ },
+
+ addProseMirrorPlugins() {
+ return [
+ FindAndReplacePlugin(this.options, pluginState => {
+ this.storage.results = pluginState.results
+ this.storage.currentIndex = pluginState.currentIndex
+ }),
+ ]
+ },
+
+ onTransaction() {
+ const pluginState = FindAndReplacePluginKey.getState(this.editor.state)
+
+ if (!pluginState) {
+ return
+ }
+
+ this.storage.searchTerm = pluginState.searchTerm
+ this.storage.replaceTerm = pluginState.replaceTerm
+ this.storage.caseSensitive = pluginState.caseSensitive
+ this.storage.useRegex = pluginState.useRegex
+ this.storage.wholeWord = pluginState.wholeWord
+ this.storage.results = pluginState.results
+ this.storage.currentIndex = pluginState.currentIndex
+ },
+
+ addCommands() {
+ const scheduleSearchTerm = (term: string): boolean => {
+ const state = getDebouncedSearchState(this.editor)
+
+ if (this.options.searchDebounceMs <= 0) {
+ if (state.timeout !== null) {
+ clearTimeout(state.timeout)
+ state.timeout = null
+ }
+
+ state.pendingTerm = null
+
+ applySearchTerm(this.editor, term)
+
+ return true
+ }
+
+ if (state.timeout !== null) {
+ clearTimeout(state.timeout)
+ }
+
+ state.pendingTerm = term
+ state.timeout = setTimeout(() => {
+ const debouncedState = getDebouncedSearchState(this.editor)
+
+ debouncedState.timeout = null
+
+ if (debouncedState.pendingTerm === null) {
+ return
+ }
+
+ const pendingTerm = debouncedState.pendingTerm
+
+ debouncedState.pendingTerm = null
+ applySearchTerm(this.editor, pendingTerm)
+ }, this.options.searchDebounceMs)
+
+ return true
+ }
+
+ return {
+ setSearchTerm:
+ term =>
+ ({ dispatch }) => {
+ if (!dispatch) {
+ return true
+ }
+
+ return scheduleSearchTerm(term)
+ },
+ setReplaceTerm: term => setPluginMeta({ replaceTerm: term }),
+ setCaseSensitive: caseSensitive => setPluginMeta({ caseSensitive }),
+ setUseRegex: useRegex => setPluginMeta({ useRegex }),
+ setWholeWord: wholeWord => setPluginMeta({ wholeWord }),
+ clearSearch:
+ () =>
+ ({ tr, dispatch }) => {
+ const state = getDebouncedSearchState(this.editor)
+
+ if (state.timeout !== null) {
+ clearTimeout(state.timeout)
+ state.timeout = null
+ }
+
+ state.pendingTerm = null
+
+ if (dispatch) {
+ mergePluginMeta(tr, { searchTerm: '' })
+ }
+
+ return true
+ },
+
+ replace:
+ () =>
+ ({ tr, state, dispatch }) => {
+ const pendingTerm = dispatch ? consumePendingSearch(this.editor) : null
+
+ if (dispatch && pendingTerm !== null) {
+ mergePluginMeta(tr, { searchTerm: pendingTerm })
+ }
+
+ const pluginState = getPendingAwarePluginState(state, pendingTerm)
+
+ if (!pluginState) {
+ return false
+ }
+
+ const result = currentResult(pluginState)
+
+ if (!result) {
+ return false
+ }
+
+ if (dispatch) {
+ replaceResult(tr, pluginState, result)
+ }
+
+ return true
+ },
+
+ replaceAll:
+ () =>
+ ({ tr, state, dispatch }) => {
+ const pendingTerm = dispatch ? consumePendingSearch(this.editor) : null
+
+ if (dispatch && pendingTerm !== null) {
+ mergePluginMeta(tr, { searchTerm: pendingTerm })
+ }
+
+ const pluginState = getPendingAwarePluginState(state, pendingTerm)
+
+ if (!pluginState) {
+ return false
+ }
+
+ if (dispatch) {
+ replaceAllResults(tr, state, pluginState.results, pluginState.replaceTerm)
+ }
+
+ return true
+ },
+
+ goToNextResult:
+ () =>
+ ({ tr, state, dispatch }) => {
+ const pendingTerm = dispatch ? consumePendingSearch(this.editor) : null
+
+ if (dispatch && pendingTerm !== null) {
+ mergePluginMeta(tr, { searchTerm: pendingTerm })
+ }
+
+ const pluginState = getPendingAwarePluginState(state, pendingTerm)
+
+ if (!pluginState) {
+ return false
+ }
+
+ const nextIndex = shiftIndex(pluginState.currentIndex, pluginState.results.length, 1)
+
+ if (dispatch) {
+ selectResult(tr, pluginState.results[nextIndex], nextIndex)
+ }
+
+ return true
+ },
+
+ goToPreviousResult:
+ () =>
+ ({ tr, state, dispatch }) => {
+ const pendingTerm = dispatch ? consumePendingSearch(this.editor) : null
+
+ if (dispatch && pendingTerm !== null) {
+ mergePluginMeta(tr, { searchTerm: pendingTerm })
+ }
+
+ const pluginState = getPendingAwarePluginState(state, pendingTerm)
+
+ if (!pluginState) {
+ return false
+ }
+
+ const previousIndex = shiftIndex(pluginState.currentIndex, pluginState.results.length, -1)
+
+ if (dispatch) {
+ selectResult(tr, pluginState.results[previousIndex], previousIndex)
+ }
+
+ return true
+ },
+ }
+ },
+
+ onDestroy() {
+ clearDebouncedSearchState(this.editor)
+ },
+})
+
+export default FindAndReplace
diff --git a/packages/extension-find-and-replace/src/index.ts b/packages/extension-find-and-replace/src/index.ts
new file mode 100644
index 0000000000..5140699eef
--- /dev/null
+++ b/packages/extension-find-and-replace/src/index.ts
@@ -0,0 +1,8 @@
+import { FindAndReplace } from './find-and-replace.js'
+
+export * from './find-and-replace.js'
+export * from './plugin/plugin.js'
+export * from './search/search.js'
+export * from './types.js'
+
+export default FindAndReplace
diff --git a/packages/extension-find-and-replace/src/plugin/plugin-state.ts b/packages/extension-find-and-replace/src/plugin/plugin-state.ts
new file mode 100644
index 0000000000..6891a7999b
--- /dev/null
+++ b/packages/extension-find-and-replace/src/plugin/plugin-state.ts
@@ -0,0 +1,27 @@
+import type { DecorationSet } from '@tiptap/pm/view'
+
+import type { SearchResult } from '../search/search.js'
+
+export interface FindAndReplacePluginState {
+ searchTerm: string
+ replaceTerm: string
+ caseSensitive: boolean
+ useRegex: boolean
+ wholeWord: boolean
+ results: SearchResult[]
+ currentIndex: number | null
+ decorations: DecorationSet
+}
+
+/**
+ * Transaction meta to patch the plugin state. Setting `searchTerm`,
+ * `caseSensitive`, `useRegex` or `wholeWord` re-runs the search.
+ */
+export interface FindAndReplaceMeta {
+ searchTerm?: string
+ replaceTerm?: string
+ caseSensitive?: boolean
+ useRegex?: boolean
+ wholeWord?: boolean
+ currentIndex?: number | null
+}
diff --git a/packages/extension-find-and-replace/src/plugin/plugin.ts b/packages/extension-find-and-replace/src/plugin/plugin.ts
new file mode 100644
index 0000000000..f757e42479
--- /dev/null
+++ b/packages/extension-find-and-replace/src/plugin/plugin.ts
@@ -0,0 +1,60 @@
+import { Plugin, PluginKey } from '@tiptap/pm/state'
+
+import { currentResultClass, resultClass } from '../constants/constants.js'
+import { createStyleTag } from '../style/create-style-tag.js'
+import type { FindAndReplaceMeta, FindAndReplacePluginState } from './plugin-state.js'
+import { style } from '../style/style.js'
+import type { FindAndReplaceOptions } from '../types.js'
+import { createState, updateState } from '../utils/index.js'
+
+export { currentResultClass, resultClass }
+export type { FindAndReplaceMeta, FindAndReplacePluginState }
+
+export const FindAndReplacePluginKey = new PluginKey('findAndReplace')
+
+export const FindAndReplacePlugin = (
+ options: FindAndReplaceOptions,
+ onCreate: (pluginState: FindAndReplacePluginState) => void,
+) => {
+ return new Plugin({
+ key: FindAndReplacePluginKey,
+
+ state: {
+ init: (_config, state) => {
+ if (options.injectCSS && typeof document !== 'undefined') {
+ createStyleTag(style, options.injectNonce)
+ }
+
+ const pluginState = createState(state.doc, options)
+ onCreate(pluginState)
+
+ return pluginState
+ },
+
+ apply: (tr, state, _oldState, newState) => {
+ const meta = tr.getMeta(FindAndReplacePluginKey) as FindAndReplaceMeta | undefined
+
+ if (!meta && !tr.docChanged) {
+ return state
+ }
+
+ return updateState(
+ state,
+ { ...state, ...meta },
+ newState.doc,
+ meta,
+ tr.docChanged,
+ tr.mapping,
+ )
+ },
+ },
+
+ props: {
+ decorations(state) {
+ return FindAndReplacePluginKey.getState(state)?.decorations
+ },
+ },
+ })
+}
+
+export default FindAndReplacePlugin
diff --git a/packages/extension-find-and-replace/src/search/matches.ts b/packages/extension-find-and-replace/src/search/matches.ts
new file mode 100644
index 0000000000..b36dd79a5d
--- /dev/null
+++ b/packages/extension-find-and-replace/src/search/matches.ts
@@ -0,0 +1,26 @@
+import { RE2JS } from 're2js'
+
+import type { SearchRegex } from './regex.js'
+
+interface TextMatch {
+ index: number
+ value: string
+}
+
+function* findNativeMatches(regex: RegExp, text: string): Generator {
+ for (const match of text.matchAll(regex)) {
+ yield { index: match.index, value: match[0] }
+ }
+}
+
+function* findSafeMatches(regex: RE2JS, text: string): Generator {
+ const matcher = regex.matcher(text)
+
+ while (matcher.find()) {
+ yield { index: matcher.start(), value: matcher.group() ?? '' }
+ }
+}
+
+export function findMatches(regex: SearchRegex, text: string): Generator {
+ return regex instanceof RegExp ? findNativeMatches(regex, text) : findSafeMatches(regex, text)
+}
diff --git a/packages/extension-find-and-replace/src/search/regex.ts b/packages/extension-find-and-replace/src/search/regex.ts
new file mode 100644
index 0000000000..8122f18c32
--- /dev/null
+++ b/packages/extension-find-and-replace/src/search/regex.ts
@@ -0,0 +1,67 @@
+import { RE2JS } from 're2js'
+
+import type { SearchOptions } from './types.js'
+
+export type SearchRegex = RegExp | RE2JS
+
+const escapeRegExp = (value: string): string => {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+}
+
+/** Unicode word characters like letters, accents, numbers, and underscore-like punctuation. */
+const unicodeWordCharacter = '[\\p{L}\\p{M}\\p{N}\\p{Pc}]'
+
+function compileNativeRegex(source: string, caseSensitive: boolean): RegExp | null {
+ try {
+ return new RegExp(source, caseSensitive ? 'gu' : 'giu')
+ } catch {
+ return null
+ }
+}
+
+function compileSafeRegex(source: string, caseSensitive: boolean): RE2JS | null {
+ try {
+ const flags = caseSensitive ? 0 : RE2JS.CASE_INSENSITIVE
+
+ return RE2JS.compile(source, flags)
+ } catch {
+ return null
+ }
+}
+
+function compileRegex(
+ source: string,
+ options: Pick,
+): SearchRegex | null {
+ return options.useRegex
+ ? compileSafeRegex(source, options.caseSensitive)
+ : compileNativeRegex(source, options.caseSensitive)
+}
+
+/**
+ * Creates a search matcher for a term.
+ * Regex mode uses RE2-compatible syntax to avoid catastrophic backtracking.
+ * @param term The search term, treated as regex source when `useRegex` is enabled.
+ * @param options Case sensitivity, regex mode, and whole word matching.
+ * @returns A compiled matcher, or `null` when the term is invalid or unsupported.
+ */
+export function createSearchRegex(term: string, options: SearchOptions): SearchRegex | null {
+ if (!term) {
+ return null
+ }
+
+ let source: string
+
+ if (options.useRegex) {
+ source = term
+ } else {
+ const escaped = escapeRegExp(term)
+
+ // Whole-word searches reject terms next to another Unicode word character.
+ source = options.wholeWord
+ ? `(? searchTextblock(regex, node, pos))
+}
+
+/**
+ * Searches all textblocks of a document for a term and returns the matched ranges.
+ * Matches may span multiple text nodes (e.g. across marks), but never leave a textblock.
+ * @param doc The document to search in.
+ * @param term The search term, treated as regex source when `useRegex` is enabled.
+ * @param options Case sensitivity, regex mode, and whole word matching.
+ * @returns The list of matched ranges in document order.
+ */
+export function searchDocument(doc: Node, term: string, options: SearchOptions): SearchResult[] {
+ const textblocks: TextblockSearchTarget[] = []
+
+ doc.descendants((node, pos) => {
+ if (!node.isTextblock) {
+ return true
+ }
+
+ textblocks.push({ node, pos })
+
+ return false
+ })
+
+ return searchTextblocks(textblocks, term, options)
+}
+
+/**
+ * Finds the index of the first result at or after a position, wrapping around
+ * to the first result when the position is behind the last result.
+ * @param results The current search results.
+ * @param from The position to search from.
+ * @returns The result index, or `null` when there are no results.
+ */
+export function findNextIndex(results: SearchResult[], from: number): number | null {
+ if (results.length === 0) {
+ return null
+ }
+
+ const index = results.findIndex(result => result.from >= from)
+
+ return index === -1 ? 0 : index
+}
diff --git a/packages/extension-find-and-replace/src/search/text-segments.ts b/packages/extension-find-and-replace/src/search/text-segments.ts
new file mode 100644
index 0000000000..536536a835
--- /dev/null
+++ b/packages/extension-find-and-replace/src/search/text-segments.ts
@@ -0,0 +1,63 @@
+import type { Node } from '@tiptap/pm/model'
+
+interface TextSegment {
+ isText: boolean
+ pos: number
+ length: number
+ text: string
+ textOffset: number
+}
+
+// Non-text inline nodes (hard break, mention, ...) contribute a placeholder
+// so matches never silently span across them.
+export function getTextSegments(textblock: Node, pos: number): TextSegment[] {
+ const segments: TextSegment[] = []
+ let textOffset = 0
+
+ textblock.forEach((child, offset) => {
+ const text = child.isText ? (child.text ?? '') : '\n'
+
+ segments.push({
+ isText: child.isText,
+ pos: pos + 1 + offset,
+ length: child.nodeSize,
+ text,
+ textOffset,
+ })
+ textOffset += text.length
+ })
+
+ return segments
+}
+
+function findOffsetSegment(segments: TextSegment[], offset: number): TextSegment | undefined {
+ let low = 0
+ let high = segments.length
+
+ while (low < high) {
+ const middle = Math.floor((low + high) / 2)
+ const segment = segments[middle]
+ const segmentEnd = segment.textOffset + segment.text.length
+
+ if (offset < segmentEnd) {
+ high = middle
+ } else {
+ low = middle + 1
+ }
+ }
+
+ return segments[low] ?? segments.at(-1)
+}
+
+export function offsetToPos(segments: TextSegment[], offset: number): number {
+ const segment = findOffsetSegment(segments, offset)
+
+ return segment ? segment.pos + Math.min(offset - segment.textOffset, segment.length) : 0
+}
+
+export function overlapsNonTextSegment(segments: TextSegment[], from: number, to: number): boolean {
+ return segments.some(
+ segment =>
+ !segment.isText && from < segment.textOffset + segment.text.length && to > segment.textOffset,
+ )
+}
diff --git a/packages/extension-find-and-replace/src/search/textblock-search.ts b/packages/extension-find-and-replace/src/search/textblock-search.ts
new file mode 100644
index 0000000000..ff6de01d06
--- /dev/null
+++ b/packages/extension-find-and-replace/src/search/textblock-search.ts
@@ -0,0 +1,31 @@
+import type { Node } from '@tiptap/pm/model'
+
+import { findMatches } from './matches.js'
+import type { SearchRegex } from './regex.js'
+import { getTextSegments, offsetToPos, overlapsNonTextSegment } from './text-segments.js'
+import type { SearchResult } from './types.js'
+
+export function searchTextblock(regex: SearchRegex, textblock: Node, pos: number): SearchResult[] {
+ const segments = getTextSegments(textblock, pos)
+ const text = segments.map(segment => segment.text).join('')
+ const results: SearchResult[] = []
+
+ for (const match of findMatches(regex, text)) {
+ if (match.value.length === 0) {
+ continue
+ }
+
+ const matchEnd = match.index + match.value.length
+
+ if (overlapsNonTextSegment(segments, match.index, matchEnd)) {
+ continue
+ }
+
+ results.push({
+ from: offsetToPos(segments, match.index),
+ to: offsetToPos(segments, matchEnd),
+ })
+ }
+
+ return results
+}
diff --git a/packages/extension-find-and-replace/src/search/types.ts b/packages/extension-find-and-replace/src/search/types.ts
new file mode 100644
index 0000000000..a63f6328b6
--- /dev/null
+++ b/packages/extension-find-and-replace/src/search/types.ts
@@ -0,0 +1,17 @@
+import type { Node } from '@tiptap/pm/model'
+
+export interface SearchResult {
+ from: number
+ to: number
+}
+
+export interface TextblockSearchTarget {
+ node: Node
+ pos: number
+}
+
+export interface SearchOptions {
+ caseSensitive: boolean
+ useRegex: boolean
+ wholeWord: boolean
+}
diff --git a/packages/extension-find-and-replace/src/state/debounced-search-state.ts b/packages/extension-find-and-replace/src/state/debounced-search-state.ts
new file mode 100644
index 0000000000..13d7770cfa
--- /dev/null
+++ b/packages/extension-find-and-replace/src/state/debounced-search-state.ts
@@ -0,0 +1,39 @@
+import type { Editor } from '@tiptap/core'
+
+export interface DebouncedSearchState {
+ timeout: ReturnType | null
+ pendingTerm: string | null
+}
+
+const debouncedSearchState = new WeakMap()
+
+export function getDebouncedSearchState(editor: Editor): DebouncedSearchState {
+ const existingState = debouncedSearchState.get(editor)
+
+ if (existingState) {
+ return existingState
+ }
+
+ const nextState: DebouncedSearchState = {
+ timeout: null,
+ pendingTerm: null,
+ }
+
+ debouncedSearchState.set(editor, nextState)
+
+ return nextState
+}
+
+export function clearDebouncedSearchState(editor: Editor): void {
+ const state = debouncedSearchState.get(editor)
+
+ if (!state) {
+ return
+ }
+
+ if (state.timeout !== null) {
+ clearTimeout(state.timeout)
+ }
+
+ debouncedSearchState.delete(editor)
+}
diff --git a/packages/extension-find-and-replace/src/style/create-style-tag.ts b/packages/extension-find-and-replace/src/style/create-style-tag.ts
new file mode 100644
index 0000000000..e1ac2143aa
--- /dev/null
+++ b/packages/extension-find-and-replace/src/style/create-style-tag.ts
@@ -0,0 +1,23 @@
+export const createStyleTag = (style: string, nonce?: string): HTMLStyleElement => {
+ const existing = document.querySelector(
+ 'style[data-tiptap-extension-find-and-replace-style]',
+ )
+
+ if (existing !== null) {
+ return existing
+ }
+
+ const styleNode = document.createElement('style')
+
+ if (nonce) {
+ styleNode.setAttribute('nonce', nonce)
+ }
+
+ styleNode.setAttribute('data-tiptap-extension-find-and-replace-style', '')
+ styleNode.textContent = style
+ document.getElementsByTagName('head')[0].appendChild(styleNode)
+
+ return styleNode
+}
+
+export default createStyleTag
diff --git a/packages/extension-find-and-replace/src/style/style.ts b/packages/extension-find-and-replace/src/style/style.ts
new file mode 100644
index 0000000000..16b6180596
--- /dev/null
+++ b/packages/extension-find-and-replace/src/style/style.ts
@@ -0,0 +1,10 @@
+export const style = `.find-and-replace-result {
+ background-color: rgb(255 225 0 / 0.4);
+}
+
+.find-and-replace-result-current {
+ background-color: rgb(255 165 0 / 0.55);
+}
+`
+
+export default style
diff --git a/packages/extension-find-and-replace/src/types.ts b/packages/extension-find-and-replace/src/types.ts
new file mode 100644
index 0000000000..a8fd4a3d58
--- /dev/null
+++ b/packages/extension-find-and-replace/src/types.ts
@@ -0,0 +1,96 @@
+import type { SearchResult } from './search/search.js'
+
+export interface FindAndReplaceOptions {
+ /**
+ * The initial search term.
+ * @default ''
+ */
+ searchTerm: string
+
+ /**
+ * The initial replace term.
+ * @default ''
+ */
+ replaceTerm: string
+
+ /**
+ * Whether the search is case sensitive.
+ * @default false
+ */
+ caseSensitive: boolean
+
+ /**
+ * Whether the search term is treated as an RE2-compatible regular expression.
+ * Lookarounds and backreferences are not supported.
+ * @default false
+ */
+ useRegex: boolean
+
+ /**
+ * Whether to match whole words only. Ignored when `useRegex` is enabled.
+ * @default false
+ */
+ wholeWord: boolean
+
+ /**
+ * Debounce delay in milliseconds applied to `setSearchTerm`.
+ * Set to `0` to disable debouncing.
+ * @default 250
+ */
+ searchDebounceMs: number
+
+ /**
+ * Whether the default result highlight styles are injected.
+ * @default true
+ */
+ injectCSS: boolean
+
+ /**
+ * A nonce for the injected style tag, needed for strict CSP setups.
+ * @default undefined
+ */
+ injectNonce: string | undefined
+}
+
+export interface FindAndReplaceStorage {
+ /**
+ * The current search term.
+ */
+ searchTerm: string
+
+ /**
+ * The current replace term.
+ */
+ replaceTerm: string
+
+ /**
+ * Whether the search is case sensitive.
+ */
+ caseSensitive: boolean
+
+ /**
+ * Whether the search term is treated as an RE2-compatible regular expression.
+ */
+ useRegex: boolean
+
+ /**
+ * Whether to match whole words only. Ignored when `useRegex` is enabled.
+ */
+ wholeWord: boolean
+
+ /**
+ * The current search results in document order.
+ */
+ results: SearchResult[]
+
+ /**
+ * The index of the current result, or `null` when no result is selected.
+ */
+ currentIndex: number | null
+}
+
+declare module '@tiptap/core' {
+ interface Storage {
+ findAndReplace: FindAndReplaceStorage
+ }
+}
diff --git a/packages/extension-find-and-replace/src/utils/createDecoration.ts b/packages/extension-find-and-replace/src/utils/createDecoration.ts
new file mode 100644
index 0000000000..df332780ef
--- /dev/null
+++ b/packages/extension-find-and-replace/src/utils/createDecoration.ts
@@ -0,0 +1,14 @@
+import { Decoration } from '@tiptap/pm/view'
+
+import { currentResultClass, resultClass } from '../constants/constants.js'
+import type { SearchResult } from '../search/search.js'
+
+export function createDecoration(
+ result: SearchResult,
+ currentIndex: number | null,
+ index: number,
+): Decoration {
+ const className = index === currentIndex ? `${resultClass} ${currentResultClass}` : resultClass
+
+ return Decoration.inline(result.from, result.to, { class: className })
+}
diff --git a/packages/extension-find-and-replace/src/utils/createDecorations.ts b/packages/extension-find-and-replace/src/utils/createDecorations.ts
new file mode 100644
index 0000000000..ca1016376e
--- /dev/null
+++ b/packages/extension-find-and-replace/src/utils/createDecorations.ts
@@ -0,0 +1,16 @@
+import type { Node } from '@tiptap/pm/model'
+import { DecorationSet } from '@tiptap/pm/view'
+
+import type { SearchResult } from '../search/search.js'
+
+import { createDecoration } from './createDecoration.js'
+
+export function createDecorations(
+ doc: Node,
+ results: SearchResult[],
+ currentIndex: number | null,
+): DecorationSet {
+ const decorations = results.map((result, index) => createDecoration(result, currentIndex, index))
+
+ return DecorationSet.create(doc, decorations)
+}
diff --git a/packages/extension-find-and-replace/src/utils/createState.ts b/packages/extension-find-and-replace/src/utils/createState.ts
new file mode 100644
index 0000000000..a5d0bec585
--- /dev/null
+++ b/packages/extension-find-and-replace/src/utils/createState.ts
@@ -0,0 +1,23 @@
+import type { Node } from '@tiptap/pm/model'
+
+import type { FindAndReplaceOptions } from '../types.js'
+import type { FindAndReplacePluginState } from '../plugin/plugin-state.js'
+import { searchDocument } from '../search/search.js'
+
+import { createDecorations } from './createDecorations.js'
+
+export function createState(doc: Node, options: FindAndReplaceOptions): FindAndReplacePluginState {
+ const results = searchDocument(doc, options.searchTerm, options)
+ const currentIndex = results.length > 0 ? 0 : null
+
+ return {
+ searchTerm: options.searchTerm,
+ replaceTerm: options.replaceTerm,
+ caseSensitive: options.caseSensitive,
+ useRegex: options.useRegex,
+ wholeWord: options.wholeWord,
+ results,
+ currentIndex,
+ decorations: createDecorations(doc, results, currentIndex),
+ }
+}
diff --git a/packages/extension-find-and-replace/src/utils/findDecorations.ts b/packages/extension-find-and-replace/src/utils/findDecorations.ts
new file mode 100644
index 0000000000..dd6ba47600
--- /dev/null
+++ b/packages/extension-find-and-replace/src/utils/findDecorations.ts
@@ -0,0 +1,27 @@
+import { Decoration, DecorationSet } from '@tiptap/pm/view'
+
+import type { SearchResult } from '../search/search.js'
+
+import type { TextblockRange } from './types.js'
+
+export function findDecorations(
+ decorations: DecorationSet,
+ results: readonly SearchResult[],
+ textblocks: readonly TextblockRange[],
+): Decoration[] {
+ const found = new Set()
+
+ textblocks.forEach(textblock => {
+ decorations.find(textblock.from, textblock.to).forEach(decoration => found.add(decoration))
+ })
+
+ results.forEach(result => {
+ decorations
+ .find(result.from, result.to, decoration => {
+ return decoration.from === result.from && decoration.to === result.to
+ })
+ .forEach(decoration => found.add(decoration))
+ })
+
+ return [...found]
+}
diff --git a/packages/extension-find-and-replace/src/utils/getChangedTextblocks.ts b/packages/extension-find-and-replace/src/utils/getChangedTextblocks.ts
new file mode 100644
index 0000000000..87f9ce32f5
--- /dev/null
+++ b/packages/extension-find-and-replace/src/utils/getChangedTextblocks.ts
@@ -0,0 +1,29 @@
+import type { Node } from '@tiptap/pm/model'
+import type { Mapping } from '@tiptap/pm/transform'
+
+import type { TextblockRange } from './types.js'
+
+export function getChangedTextblocks(doc: Node, mapping: Mapping): TextblockRange[] {
+ const textblocks = new Map()
+
+ mapping.maps.forEach((stepMap, index) => {
+ const remaining = mapping.slice(index + 1)
+
+ stepMap.forEach((_oldStart, _oldEnd, newStart, newEnd) => {
+ const from = Math.max(0, remaining.map(newStart, -1) - 1)
+ const to = Math.min(doc.content.size, remaining.map(newEnd, 1) + 1)
+
+ doc.nodesBetween(from, to, (node, pos) => {
+ if (!node.isTextblock) {
+ return true
+ }
+
+ textblocks.set(pos, { node, pos, from: pos, to: pos + node.nodeSize })
+
+ return false
+ })
+ })
+ })
+
+ return [...textblocks.values()].sort((first, second) => first.pos - second.pos)
+}
diff --git a/packages/extension-find-and-replace/src/utils/getFallbackCurrentIndex.ts b/packages/extension-find-and-replace/src/utils/getFallbackCurrentIndex.ts
new file mode 100644
index 0000000000..026276fe78
--- /dev/null
+++ b/packages/extension-find-and-replace/src/utils/getFallbackCurrentIndex.ts
@@ -0,0 +1,8 @@
+import type { FindAndReplacePluginState } from '../plugin/plugin-state.js'
+
+export function getFallbackCurrentIndex(
+ previousState: FindAndReplacePluginState,
+ length: number,
+): number {
+ return previousState.currentIndex === null ? 0 : Math.min(previousState.currentIndex, length - 1)
+}
diff --git a/packages/extension-find-and-replace/src/utils/getMappedCurrentIndex.ts b/packages/extension-find-and-replace/src/utils/getMappedCurrentIndex.ts
new file mode 100644
index 0000000000..2d839911ce
--- /dev/null
+++ b/packages/extension-find-and-replace/src/utils/getMappedCurrentIndex.ts
@@ -0,0 +1,32 @@
+import type { Mapping } from '@tiptap/pm/transform'
+
+import type { FindAndReplacePluginState } from '../plugin/plugin-state.js'
+import type { SearchResult } from '../search/search.js'
+import { findNextIndex } from '../search/search.js'
+
+import { getPreviousCurrentResult } from './getPreviousCurrentResult.js'
+
+export function getMappedCurrentIndex(
+ previousState: FindAndReplacePluginState,
+ results: SearchResult[],
+ docChanged: boolean,
+ mapping: Mapping | undefined,
+): number | null | undefined {
+ if (!docChanged) {
+ return undefined
+ }
+
+ if (!mapping) {
+ return undefined
+ }
+
+ const previousResult = getPreviousCurrentResult(previousState)
+
+ if (!previousResult) {
+ return undefined
+ }
+
+ const mappedFrom = mapping.map(previousResult.from, 1)
+
+ return findNextIndex(results, mappedFrom)
+}
diff --git a/packages/extension-find-and-replace/src/utils/getMetaCurrentIndex.ts b/packages/extension-find-and-replace/src/utils/getMetaCurrentIndex.ts
new file mode 100644
index 0000000000..2a076eef31
--- /dev/null
+++ b/packages/extension-find-and-replace/src/utils/getMetaCurrentIndex.ts
@@ -0,0 +1,19 @@
+import type { FindAndReplaceMeta } from '../plugin/plugin-state.js'
+
+import { hasCurrentIndex } from './hasCurrentIndex.js'
+import { normalizeCurrentIndex } from './normalizeCurrentIndex.js'
+
+export function getMetaCurrentIndex(
+ meta: FindAndReplaceMeta | undefined,
+ length: number,
+): number | null | undefined {
+ if (!meta) {
+ return undefined
+ }
+
+ if (!hasCurrentIndex(meta)) {
+ return undefined
+ }
+
+ return normalizeCurrentIndex(meta.currentIndex, length)
+}
diff --git a/packages/extension-find-and-replace/src/utils/getPreviousCurrentResult.ts b/packages/extension-find-and-replace/src/utils/getPreviousCurrentResult.ts
new file mode 100644
index 0000000000..f1891e974c
--- /dev/null
+++ b/packages/extension-find-and-replace/src/utils/getPreviousCurrentResult.ts
@@ -0,0 +1,10 @@
+import type { FindAndReplacePluginState } from '../plugin/plugin-state.js'
+import type { SearchResult } from '../search/search.js'
+
+export function getPreviousCurrentResult(
+ previousState: FindAndReplacePluginState,
+): SearchResult | undefined {
+ return previousState.currentIndex === null
+ ? undefined
+ : previousState.results[previousState.currentIndex]
+}
diff --git a/packages/extension-find-and-replace/src/utils/getResultsToRefresh.ts b/packages/extension-find-and-replace/src/utils/getResultsToRefresh.ts
new file mode 100644
index 0000000000..655d2a8328
--- /dev/null
+++ b/packages/extension-find-and-replace/src/utils/getResultsToRefresh.ts
@@ -0,0 +1,22 @@
+import type { SearchResult } from '../search/search.js'
+
+import { shouldRefreshResult } from './shouldRefreshResult.js'
+import type { IndexedResult, TextblockRange } from './types.js'
+
+export function getResultsToRefresh(
+ results: SearchResult[],
+ textblocks: readonly TextblockRange[],
+ previousCurrent: SearchResult | null,
+ currentIndex: number | null,
+): IndexedResult[] {
+ const current = currentIndex === null ? null : (results[currentIndex] ?? null)
+ const refreshedResults: IndexedResult[] = []
+
+ results.forEach((result, index) => {
+ if (shouldRefreshResult(result, textblocks, previousCurrent, current)) {
+ refreshedResults.push({ result, index })
+ }
+ })
+
+ return refreshedResults
+}
diff --git a/packages/extension-find-and-replace/src/utils/getSearchCurrentIndex.ts b/packages/extension-find-and-replace/src/utils/getSearchCurrentIndex.ts
new file mode 100644
index 0000000000..a84a1ee4cb
--- /dev/null
+++ b/packages/extension-find-and-replace/src/utils/getSearchCurrentIndex.ts
@@ -0,0 +1,24 @@
+import type { Mapping } from '@tiptap/pm/transform'
+
+import type { FindAndReplaceMeta, FindAndReplacePluginState } from '../plugin/plugin-state.js'
+import type { SearchResult } from '../search/search.js'
+
+import { getFallbackCurrentIndex } from './getFallbackCurrentIndex.js'
+import { getMappedCurrentIndex } from './getMappedCurrentIndex.js'
+import { isNewSearch } from './isNewSearch.js'
+
+export function getSearchCurrentIndex(
+ previousState: FindAndReplacePluginState,
+ results: SearchResult[],
+ meta: FindAndReplaceMeta | undefined,
+ docChanged: boolean,
+ mapping: Mapping | undefined,
+): number {
+ if (isNewSearch(meta)) {
+ return 0
+ }
+
+ const mappedIndex = getMappedCurrentIndex(previousState, results, docChanged, mapping)
+
+ return mappedIndex ?? getFallbackCurrentIndex(previousState, results.length)
+}
diff --git a/packages/extension-find-and-replace/src/utils/getTextNodeResult.ts b/packages/extension-find-and-replace/src/utils/getTextNodeResult.ts
new file mode 100644
index 0000000000..64f3201625
--- /dev/null
+++ b/packages/extension-find-and-replace/src/utils/getTextNodeResult.ts
@@ -0,0 +1,21 @@
+import type { EditorState } from '@tiptap/pm/state'
+
+import type { SearchResult } from '../search/search.js'
+
+export interface TextNodeResult {
+ from: number
+ text: string
+}
+
+export function getTextNodeResult(state: EditorState, result: SearchResult): TextNodeResult | null {
+ const $from = state.doc.resolve(result.from)
+ const node = $from.parent.child($from.index())
+
+ if (!node.isText || !node.text) {
+ return null
+ }
+
+ const from = result.from - $from.textOffset
+
+ return result.to <= from + node.nodeSize ? { from, text: node.text } : null
+}
diff --git a/packages/extension-find-and-replace/src/utils/groupResults.ts b/packages/extension-find-and-replace/src/utils/groupResults.ts
new file mode 100644
index 0000000000..3bc6099e6d
--- /dev/null
+++ b/packages/extension-find-and-replace/src/utils/groupResults.ts
@@ -0,0 +1,68 @@
+import type { EditorState } from '@tiptap/pm/state'
+
+import type { SearchResult } from '../search/search.js'
+import type { TextNodeResult } from './getTextNodeResult.js'
+import { getTextNodeResult } from './getTextNodeResult.js'
+
+export interface ResultGroup {
+ from: number
+ text: string | null
+ results: SearchResult[]
+}
+
+function shouldMergeResult(
+ group: ResultGroup | undefined,
+ textNode: TextNodeResult | null,
+): boolean {
+ if (!group || !textNode) {
+ return false
+ }
+
+ return group.from === textNode.from && group.text !== null
+}
+
+function createTextResultGroup(result: SearchResult, textNode: TextNodeResult): ResultGroup {
+ return {
+ from: textNode.from,
+ text: textNode.text,
+ results: [result],
+ }
+}
+
+function createResultGroup(result: SearchResult): ResultGroup {
+ return {
+ from: result.from,
+ text: null,
+ results: [result],
+ }
+}
+
+function appendResult(
+ groups: ResultGroup[],
+ result: SearchResult,
+ textNode: TextNodeResult | null,
+): void {
+ const group = groups.at(-1)
+
+ if (shouldMergeResult(group, textNode)) {
+ group!.results.push(result)
+ return
+ }
+
+ if (textNode) {
+ groups.push(createTextResultGroup(result, textNode))
+ return
+ }
+
+ groups.push(createResultGroup(result))
+}
+
+export function groupResults(state: EditorState, results: SearchResult[]): ResultGroup[] {
+ const groups: ResultGroup[] = []
+
+ for (const result of results) {
+ appendResult(groups, result, getTextNodeResult(state, result))
+ }
+
+ return groups
+}
diff --git a/packages/extension-find-and-replace/src/utils/hasCurrentIndex.ts b/packages/extension-find-and-replace/src/utils/hasCurrentIndex.ts
new file mode 100644
index 0000000000..a39c22d7fb
--- /dev/null
+++ b/packages/extension-find-and-replace/src/utils/hasCurrentIndex.ts
@@ -0,0 +1,5 @@
+import type { FindAndReplaceMeta } from '../plugin/plugin-state.js'
+
+export function hasCurrentIndex(meta: FindAndReplaceMeta | undefined): boolean {
+ return !!meta && 'currentIndex' in meta
+}
diff --git a/packages/extension-find-and-replace/src/utils/index.ts b/packages/extension-find-and-replace/src/utils/index.ts
new file mode 100644
index 0000000000..9f55c76b7c
--- /dev/null
+++ b/packages/extension-find-and-replace/src/utils/index.ts
@@ -0,0 +1,2 @@
+export { createState } from './createState.js'
+export { updateState } from './updateState.js'
diff --git a/packages/extension-find-and-replace/src/utils/isCurrentResult.ts b/packages/extension-find-and-replace/src/utils/isCurrentResult.ts
new file mode 100644
index 0000000000..d3b77fbf8c
--- /dev/null
+++ b/packages/extension-find-and-replace/src/utils/isCurrentResult.ts
@@ -0,0 +1,7 @@
+import type { SearchResult } from '../search/search.js'
+
+import { sameResult } from './sameResult.js'
+
+export function isCurrentResult(result: SearchResult, current: SearchResult | null): boolean {
+ return current !== null && sameResult(result, current)
+}
diff --git a/packages/extension-find-and-replace/src/utils/isNewSearch.ts b/packages/extension-find-and-replace/src/utils/isNewSearch.ts
new file mode 100644
index 0000000000..455551735d
--- /dev/null
+++ b/packages/extension-find-and-replace/src/utils/isNewSearch.ts
@@ -0,0 +1,5 @@
+import type { FindAndReplaceMeta } from '../plugin/plugin-state.js'
+
+export function isNewSearch(meta: FindAndReplaceMeta | undefined): boolean {
+ return !!meta && 'searchTerm' in meta
+}
diff --git a/packages/extension-find-and-replace/src/utils/mapResult.ts b/packages/extension-find-and-replace/src/utils/mapResult.ts
new file mode 100644
index 0000000000..314b1f66af
--- /dev/null
+++ b/packages/extension-find-and-replace/src/utils/mapResult.ts
@@ -0,0 +1,10 @@
+import type { Mapping } from '@tiptap/pm/transform'
+
+import type { SearchResult } from '../search/search.js'
+
+export function mapResult(result: SearchResult, mapping: Mapping): SearchResult | null {
+ const from = mapping.map(result.from, 1)
+ const to = mapping.map(result.to, -1)
+
+ return from < to ? { from, to } : null
+}
diff --git a/packages/extension-find-and-replace/src/utils/mapResults.ts b/packages/extension-find-and-replace/src/utils/mapResults.ts
new file mode 100644
index 0000000000..f794d8e2e5
--- /dev/null
+++ b/packages/extension-find-and-replace/src/utils/mapResults.ts
@@ -0,0 +1,19 @@
+import type { Mapping } from '@tiptap/pm/transform'
+
+import type { SearchResult } from '../search/search.js'
+
+import { mapResult } from './mapResult.js'
+
+export function mapResults(results: SearchResult[], mapping: Mapping): SearchResult[] {
+ const mappedResults: SearchResult[] = []
+
+ results.forEach(result => {
+ const mapped = mapResult(result, mapping)
+
+ if (mapped) {
+ mappedResults.push(mapped)
+ }
+ })
+
+ return mappedResults
+}
diff --git a/packages/extension-find-and-replace/src/utils/mergeResults.ts b/packages/extension-find-and-replace/src/utils/mergeResults.ts
new file mode 100644
index 0000000000..bb89c3b0fc
--- /dev/null
+++ b/packages/extension-find-and-replace/src/utils/mergeResults.ts
@@ -0,0 +1,18 @@
+import type { SearchResult } from '../search/search.js'
+
+export function mergeResults(first: SearchResult[], second: SearchResult[]): SearchResult[] {
+ const results: SearchResult[] = []
+ let firstIndex = 0
+ let secondIndex = 0
+
+ while (firstIndex < first.length && secondIndex < second.length) {
+ if (first[firstIndex].from < second[secondIndex].from) {
+ results.push(first[firstIndex++])
+ continue
+ }
+
+ results.push(second[secondIndex++])
+ }
+
+ return results.concat(first.slice(firstIndex), second.slice(secondIndex))
+}
diff --git a/packages/extension-find-and-replace/src/utils/normalizeCurrentIndex.ts b/packages/extension-find-and-replace/src/utils/normalizeCurrentIndex.ts
new file mode 100644
index 0000000000..97dc76cd4e
--- /dev/null
+++ b/packages/extension-find-and-replace/src/utils/normalizeCurrentIndex.ts
@@ -0,0 +1,10 @@
+export function normalizeCurrentIndex(
+ currentIndex: number | null | undefined,
+ length: number,
+): number | null {
+ if (currentIndex === undefined || currentIndex === null || length === 0) {
+ return null
+ }
+
+ return Math.max(0, Math.min(currentIndex, length - 1))
+}
diff --git a/packages/extension-find-and-replace/src/utils/refreshCurrentIndexState.ts b/packages/extension-find-and-replace/src/utils/refreshCurrentIndexState.ts
new file mode 100644
index 0000000000..fd7ef175a3
--- /dev/null
+++ b/packages/extension-find-and-replace/src/utils/refreshCurrentIndexState.ts
@@ -0,0 +1,30 @@
+import type { Node } from '@tiptap/pm/model'
+
+import type { FindAndReplaceMeta, FindAndReplacePluginState } from '../plugin/plugin-state.js'
+
+import { refreshDecorations } from './refreshDecorations.js'
+import { resolveCurrentIndex } from './resolveCurrentIndex.js'
+
+export function refreshCurrentIndexState(
+ previousState: FindAndReplacePluginState,
+ state: FindAndReplacePluginState,
+ doc: Node,
+ meta: FindAndReplaceMeta | undefined,
+): FindAndReplacePluginState {
+ const currentIndex = resolveCurrentIndex(previousState, state.results, meta, false, undefined)
+ const previousCurrent =
+ previousState.currentIndex === null ? null : previousState.results[previousState.currentIndex]
+
+ return {
+ ...state,
+ currentIndex,
+ decorations: refreshDecorations(
+ previousState.decorations,
+ doc,
+ state.results,
+ currentIndex,
+ [],
+ previousCurrent,
+ ),
+ }
+}
diff --git a/packages/extension-find-and-replace/src/utils/refreshDecorations.ts b/packages/extension-find-and-replace/src/utils/refreshDecorations.ts
new file mode 100644
index 0000000000..ca46b70b41
--- /dev/null
+++ b/packages/extension-find-and-replace/src/utils/refreshDecorations.ts
@@ -0,0 +1,28 @@
+import type { Node } from '@tiptap/pm/model'
+import { DecorationSet } from '@tiptap/pm/view'
+
+import type { SearchResult } from '../search/search.js'
+
+import { createDecoration } from './createDecoration.js'
+import { findDecorations } from './findDecorations.js'
+import { getResultsToRefresh } from './getResultsToRefresh.js'
+import type { TextblockRange } from './types.js'
+
+export function refreshDecorations(
+ decorations: DecorationSet,
+ doc: Node,
+ results: SearchResult[],
+ currentIndex: number | null,
+ textblocks: readonly TextblockRange[],
+ previousCurrent: SearchResult | null,
+): DecorationSet {
+ const resultsToRefresh = getResultsToRefresh(results, textblocks, previousCurrent, currentIndex)
+ const refreshedResults = resultsToRefresh.map(({ result }) => result)
+ const staleResults = previousCurrent ? [...refreshedResults, previousCurrent] : refreshedResults
+ const decorationsToRemove = findDecorations(decorations, staleResults, textblocks)
+ const decorationsToAdd = resultsToRefresh.map(({ result, index }) => {
+ return createDecoration(result, currentIndex, index)
+ })
+
+ return decorations.remove(decorationsToRemove).add(doc, decorationsToAdd)
+}
diff --git a/packages/extension-find-and-replace/src/utils/refreshDocumentState.ts b/packages/extension-find-and-replace/src/utils/refreshDocumentState.ts
new file mode 100644
index 0000000000..9624734c8c
--- /dev/null
+++ b/packages/extension-find-and-replace/src/utils/refreshDocumentState.ts
@@ -0,0 +1,47 @@
+import type { Node } from '@tiptap/pm/model'
+import type { Mapping } from '@tiptap/pm/transform'
+
+import type { FindAndReplaceMeta, FindAndReplacePluginState } from '../plugin/plugin-state.js'
+import { searchTextblocks } from '../search/search.js'
+
+import { getChangedTextblocks } from './getChangedTextblocks.js'
+import { mapResult } from './mapResult.js'
+import { mapResults } from './mapResults.js'
+import { mergeResults } from './mergeResults.js'
+import { refreshDecorations } from './refreshDecorations.js'
+import { resolveCurrentIndex } from './resolveCurrentIndex.js'
+import { resultInTextblocks } from './resultInTextblocks.js'
+
+export function refreshDocumentState(
+ previousState: FindAndReplacePluginState,
+ state: FindAndReplacePluginState,
+ doc: Node,
+ meta: FindAndReplaceMeta | undefined,
+ mapping: Mapping,
+): FindAndReplacePluginState {
+ const textblocks = getChangedTextblocks(doc, mapping)
+ const mappedResults = mapResults(previousState.results, mapping)
+ const unaffectedResults = mappedResults.filter(result => !resultInTextblocks(result, textblocks))
+ const changedResults = searchTextblocks(textblocks, state.searchTerm, state)
+ const results = mergeResults(unaffectedResults, changedResults)
+ const currentIndex = resolveCurrentIndex(previousState, results, meta, true, mapping)
+ const previousCurrent =
+ previousState.currentIndex === null
+ ? null
+ : mapResult(previousState.results[previousState.currentIndex], mapping)
+ const decorations = previousState.decorations.map(mapping, doc)
+
+ return {
+ ...state,
+ results,
+ currentIndex,
+ decorations: refreshDecorations(
+ decorations,
+ doc,
+ results,
+ currentIndex,
+ textblocks,
+ previousCurrent,
+ ),
+ }
+}
diff --git a/packages/extension-find-and-replace/src/utils/refreshState.ts b/packages/extension-find-and-replace/src/utils/refreshState.ts
new file mode 100644
index 0000000000..9a62a5a417
--- /dev/null
+++ b/packages/extension-find-and-replace/src/utils/refreshState.ts
@@ -0,0 +1,27 @@
+import type { Node } from '@tiptap/pm/model'
+import type { Mapping } from '@tiptap/pm/transform'
+
+import type { FindAndReplaceMeta, FindAndReplacePluginState } from '../plugin/plugin-state.js'
+import { searchDocument } from '../search/search.js'
+
+import { createDecorations } from './createDecorations.js'
+import { resolveCurrentIndex } from './resolveCurrentIndex.js'
+
+export function refreshState(
+ previousState: FindAndReplacePluginState,
+ state: FindAndReplacePluginState,
+ doc: Node,
+ meta: FindAndReplaceMeta | undefined,
+ docChanged: boolean,
+ mapping: Mapping | undefined,
+): FindAndReplacePluginState {
+ const results = searchDocument(doc, state.searchTerm, state)
+ const currentIndex = resolveCurrentIndex(previousState, results, meta, docChanged, mapping)
+
+ return {
+ ...state,
+ results,
+ currentIndex,
+ decorations: createDecorations(doc, results, currentIndex),
+ }
+}
diff --git a/packages/extension-find-and-replace/src/utils/replaceAllResults.ts b/packages/extension-find-and-replace/src/utils/replaceAllResults.ts
new file mode 100644
index 0000000000..c5c739f99e
--- /dev/null
+++ b/packages/extension-find-and-replace/src/utils/replaceAllResults.ts
@@ -0,0 +1,18 @@
+import type { EditorState, Transaction } from '@tiptap/pm/state'
+
+import type { SearchResult } from '../search/search.js'
+import { groupResults } from './groupResults.js'
+import { replaceGroup } from './replaceGroup.js'
+
+export function replaceAllResults(
+ tr: Transaction,
+ state: EditorState,
+ results: SearchResult[],
+ replaceTerm: string,
+): void {
+ const groups = groupResults(state, results)
+
+ for (const group of groups.reverse()) {
+ replaceGroup(tr, group, replaceTerm)
+ }
+}
diff --git a/packages/extension-find-and-replace/src/utils/replaceGroup.ts b/packages/extension-find-and-replace/src/utils/replaceGroup.ts
new file mode 100644
index 0000000000..8b4ab3c93d
--- /dev/null
+++ b/packages/extension-find-and-replace/src/utils/replaceGroup.ts
@@ -0,0 +1,54 @@
+import type { Transaction } from '@tiptap/pm/state'
+
+import type { SearchResult } from '../search/search.js'
+import type { ResultGroup } from './groupResults.js'
+
+function replaceSeparateResults(
+ tr: Transaction,
+ results: SearchResult[],
+ replaceTerm: string,
+): void {
+ for (const result of [...results].reverse()) {
+ tr.insertText(replaceTerm, result.from, result.to)
+ }
+}
+
+function createReplacement(
+ group: ResultGroup,
+ text: string,
+ firstResult: SearchResult,
+ replaceTerm: string,
+): string {
+ let offset = firstResult.from - group.from
+ const replacement: string[] = []
+
+ for (const result of group.results) {
+ const from = result.from - group.from
+ const to = result.to - group.from
+
+ replacement.push(text.slice(offset, from), replaceTerm)
+ offset = to
+ }
+
+ return replacement.join('')
+}
+
+export function replaceGroup(tr: Transaction, group: ResultGroup, replaceTerm: string): void {
+ const firstResult = group.results[0]
+ const lastResult = group.results.at(-1)
+
+ if (!lastResult) {
+ return
+ }
+
+ if (group.text === null) {
+ replaceSeparateResults(tr, group.results, replaceTerm)
+ return
+ }
+
+ tr.insertText(
+ createReplacement(group, group.text, firstResult, replaceTerm),
+ firstResult.from,
+ lastResult.to,
+ )
+}
diff --git a/packages/extension-find-and-replace/src/utils/resolveCurrentIndex.ts b/packages/extension-find-and-replace/src/utils/resolveCurrentIndex.ts
new file mode 100644
index 0000000000..3a395bfbca
--- /dev/null
+++ b/packages/extension-find-and-replace/src/utils/resolveCurrentIndex.ts
@@ -0,0 +1,27 @@
+import type { Mapping } from '@tiptap/pm/transform'
+
+import type { FindAndReplaceMeta, FindAndReplacePluginState } from '../plugin/plugin-state.js'
+import type { SearchResult } from '../search/search.js'
+
+import { getMetaCurrentIndex } from './getMetaCurrentIndex.js'
+import { getSearchCurrentIndex } from './getSearchCurrentIndex.js'
+
+export function resolveCurrentIndex(
+ previousState: FindAndReplacePluginState,
+ results: SearchResult[],
+ meta: FindAndReplaceMeta | undefined,
+ docChanged: boolean,
+ mapping: Mapping | undefined,
+): number | null {
+ if (results.length === 0) {
+ return null
+ }
+
+ const metaIndex = getMetaCurrentIndex(meta, results.length)
+
+ if (metaIndex !== undefined) {
+ return metaIndex
+ }
+
+ return getSearchCurrentIndex(previousState, results, meta, docChanged, mapping)
+}
diff --git a/packages/extension-find-and-replace/src/utils/resultInTextblocks.ts b/packages/extension-find-and-replace/src/utils/resultInTextblocks.ts
new file mode 100644
index 0000000000..d55a671b2a
--- /dev/null
+++ b/packages/extension-find-and-replace/src/utils/resultInTextblocks.ts
@@ -0,0 +1,30 @@
+import type { SearchResult } from '../search/search.js'
+
+import type { TextblockRange } from './types.js'
+
+export function resultInTextblocks(
+ result: SearchResult,
+ textblocks: readonly TextblockRange[],
+): boolean {
+ let start = 0
+ let end = textblocks.length - 1
+
+ while (start <= end) {
+ const index = Math.floor((start + end) / 2)
+ const textblock = textblocks[index]
+
+ if (result.from < textblock.from) {
+ end = index - 1
+ continue
+ }
+
+ if (result.to > textblock.to) {
+ start = index + 1
+ continue
+ }
+
+ return true
+ }
+
+ return false
+}
diff --git a/packages/extension-find-and-replace/src/utils/sameResult.ts b/packages/extension-find-and-replace/src/utils/sameResult.ts
new file mode 100644
index 0000000000..0ef4101b66
--- /dev/null
+++ b/packages/extension-find-and-replace/src/utils/sameResult.ts
@@ -0,0 +1,5 @@
+import type { SearchResult } from '../search/search.js'
+
+export function sameResult(first: SearchResult, second: SearchResult): boolean {
+ return first.from === second.from && first.to === second.to
+}
diff --git a/packages/extension-find-and-replace/src/utils/shouldRefreshResult.ts b/packages/extension-find-and-replace/src/utils/shouldRefreshResult.ts
new file mode 100644
index 0000000000..51a8acfc79
--- /dev/null
+++ b/packages/extension-find-and-replace/src/utils/shouldRefreshResult.ts
@@ -0,0 +1,18 @@
+import type { SearchResult } from '../search/search.js'
+
+import { isCurrentResult } from './isCurrentResult.js'
+import { resultInTextblocks } from './resultInTextblocks.js'
+import type { TextblockRange } from './types.js'
+
+export function shouldRefreshResult(
+ result: SearchResult,
+ textblocks: readonly TextblockRange[],
+ previousCurrent: SearchResult | null,
+ current: SearchResult | null,
+): boolean {
+ return (
+ resultInTextblocks(result, textblocks) ||
+ isCurrentResult(result, previousCurrent) ||
+ isCurrentResult(result, current)
+ )
+}
diff --git a/packages/extension-find-and-replace/src/utils/touchesSearch.ts b/packages/extension-find-and-replace/src/utils/touchesSearch.ts
new file mode 100644
index 0000000000..7b741c9e46
--- /dev/null
+++ b/packages/extension-find-and-replace/src/utils/touchesSearch.ts
@@ -0,0 +1,7 @@
+import type { FindAndReplaceMeta } from '../plugin/plugin-state.js'
+
+const searchKeys = ['searchTerm', 'caseSensitive', 'useRegex', 'wholeWord'] as const
+
+export function touchesSearch(meta: FindAndReplaceMeta | undefined): boolean {
+ return !!meta && searchKeys.some(key => key in meta)
+}
diff --git a/packages/extension-find-and-replace/src/utils/types.ts b/packages/extension-find-and-replace/src/utils/types.ts
new file mode 100644
index 0000000000..bac46efa5f
--- /dev/null
+++ b/packages/extension-find-and-replace/src/utils/types.ts
@@ -0,0 +1,11 @@
+import type { TextblockSearchTarget } from '../search/search.js'
+
+export interface TextblockRange extends TextblockSearchTarget {
+ from: number
+ to: number
+}
+
+export interface IndexedResult {
+ result: import('../search/search.js').SearchResult
+ index: number
+}
diff --git a/packages/extension-find-and-replace/src/utils/updateState.ts b/packages/extension-find-and-replace/src/utils/updateState.ts
new file mode 100644
index 0000000000..607bd8ee83
--- /dev/null
+++ b/packages/extension-find-and-replace/src/utils/updateState.ts
@@ -0,0 +1,33 @@
+import type { Node } from '@tiptap/pm/model'
+import type { Mapping } from '@tiptap/pm/transform'
+
+import type { FindAndReplaceMeta, FindAndReplacePluginState } from '../plugin/plugin-state.js'
+
+import { hasCurrentIndex } from './hasCurrentIndex.js'
+import { refreshCurrentIndexState } from './refreshCurrentIndexState.js'
+import { refreshDocumentState } from './refreshDocumentState.js'
+import { refreshState } from './refreshState.js'
+import { touchesSearch } from './touchesSearch.js'
+
+export function updateState(
+ previousState: FindAndReplacePluginState,
+ state: FindAndReplacePluginState,
+ doc: Node,
+ meta: FindAndReplaceMeta | undefined,
+ docChanged: boolean,
+ mapping: Mapping,
+): FindAndReplacePluginState {
+ if (touchesSearch(meta)) {
+ return refreshState(previousState, state, doc, meta, docChanged, mapping)
+ }
+
+ if (docChanged) {
+ return refreshDocumentState(previousState, state, doc, meta, mapping)
+ }
+
+ if (hasCurrentIndex(meta)) {
+ return refreshCurrentIndexState(previousState, state, doc, meta)
+ }
+
+ return state
+}
diff --git a/packages/extension-find-and-replace/tsup.config.ts b/packages/extension-find-and-replace/tsup.config.ts
new file mode 100644
index 0000000000..03b7c8d0b6
--- /dev/null
+++ b/packages/extension-find-and-replace/tsup.config.ts
@@ -0,0 +1,11 @@
+import { defineConfig } from 'tsup'
+
+export default defineConfig({
+ entry: ['src/index.ts'],
+ tsconfig: '../../tsconfig.build.json',
+ outDir: 'dist',
+ dts: true,
+ clean: true,
+ sourcemap: true,
+ format: ['esm', 'cjs'],
+})
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9bc607c836..800c2b581c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -694,6 +694,19 @@ importers:
specifier: workspace:^
version: link:../pm
+ packages/extension-find-and-replace:
+ dependencies:
+ re2js:
+ specifier: ^2.8.6
+ version: 2.8.6
+ devDependencies:
+ '@tiptap/core':
+ specifier: workspace:^
+ version: link:../core
+ '@tiptap/pm':
+ specifier: workspace:^
+ version: link:../pm
+
packages/extension-floating-menu:
devDependencies:
'@floating-ui/dom':
@@ -5969,6 +5982,10 @@ packages:
randombytes@2.1.0:
resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
+ re2js@2.8.6:
+ resolution: {integrity: sha512-xLgQil4kIUCrAzVk9fRSkxkFNwmygLFjVxXrLc65aE1F0+Zsb8rxumFBy4XKyvgMCTL6kilDq3EZ0piE2dP/Dg==}
+ engines: {node: '>=18.0.0'}
+
react-dom@19.1.0:
resolution: {integrity: sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==}
peerDependencies:
@@ -11977,6 +11994,8 @@ snapshots:
dependencies:
safe-buffer: 5.2.1
+ re2js@2.8.6: {}
+
react-dom@19.1.0(react@19.1.0):
dependencies:
react: 19.1.0