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 'vite-plus/test' 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') const currentResultPositions = () => { return Array.from(editor.view.dom.querySelectorAll('.find-and-replace-result-current')).map( element => editor.view.posAtDOM(element, 0), ) } expect(editor.storage.findAndReplace.currentIndex).toBe(0) expect(currentResultPositions()).toEqual([1]) editor.commands.goToNextResult() expect(editor.storage.findAndReplace.currentIndex).toBe(1) expect(editor.state.selection.from).toBe(9) expect(editor.state.selection.to).toBe(12) expect(currentResultPositions()).toEqual([9]) editor.commands.goToNextResult() expect(currentResultPositions()).toEqual([17]) editor.commands.goToNextResult() expect(editor.storage.findAndReplace.currentIndex).toBe(0) expect(currentResultPositions()).toEqual([1]) editor.commands.goToPreviousResult() expect(editor.storage.findAndReplace.currentIndex).toBe(2) expect(currentResultPositions()).toEqual([17]) }) 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]) }, 10_000) 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) }) })