Files
upstream-mirror 083a46e93b
Some checks failed
Verify / Install node dependencies (push) Failing after 2s
Verify / Build packages (push) Has been skipped
Verify / Check linting & formatting (push) Has been skipped
Verify / Run unit tests (push) Has been skipped
Verify / Run e2e tests (shard 1/4) (push) Has been skipped
Verify / Run e2e tests (shard 2/4) (push) Has been skipped
Verify / Run e2e tests (shard 3/4) (push) Has been skipped
Verify / Run e2e tests (shard 4/4) (push) Has been skipped
Publish / Resolve publish config (push) Failing after 2s
Publish / Send notification to Slack (push) Has been skipped
Publish / Send failure notification to Slack (push) Failing after 8s
Verify / Merge e2e reports (push) Has been skipped
Publish / Build (push) Has been skipped
Publish / Bump Versions OR Release to Registry (push) Has been skipped
snapshot of https://github.com/ueberdosis/tiptap.git (main, 2026-09-15; blobs > 131072B omitted)
2026-09-15 21:11:02 +05:00

537 lines
17 KiB
TypeScript

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<FindAndReplaceOptions> = { 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('<p>Hello hello HELLO</p>')
})
afterEach(() => {
editor.destroy()
vi.useRealTimers()
})
it('debounces setSearchTerm by default', () => {
vi.useFakeTimers()
editor.destroy()
editor = createEditor('<p>Hello hello HELLO</p>', { 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('<p>foo bar foo</p>', { 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: '<p>Hello hello HELLO</p>',
})
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('<p>café 123</p>')
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('<p>😀</p>')
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('<p>he<strong>llo</strong> world</p>')
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: '<p>foo<br>bar</p>',
})
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 ? '<strong>a</strong>' : 'a',
).join('')
editor.destroy()
editor = createEditor(`<p>${content}</p>`)
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('<p>hello hello</p>')
expect(editor.storage.findAndReplace.results).toHaveLength(2)
})
it('updates only the matches in a changed textblock', () => {
editor.destroy()
editor = createEditor('<p>foo</p><p>foo</p><p>foo</p>')
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('<p>foo</p><p>foo</p>')
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('<p>hello</p><p>hello</p>')
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('<p>world</p>')
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('<p></p>')
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('<p>one two one two one</p>')
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('<p>foo bar foo</p>')
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('<p>foo bar foo</p>')
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('<p>foo bar foo</p>')
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('<p>foo end</p><p>foo end</p>')
editor.commands.setSearchTerm('foo')
editor.commands.setReplaceTerm('long replacement')
editor.commands.replaceAll()
expect(editor.getHTML()).toBe('<p>long replacement end</p><p>long replacement end</p>')
})
it('replaces results across marks', () => {
editor.destroy()
editor = createEditor('<p>he<strong>llo</strong> he<strong>llo</strong></p>')
editor.commands.setSearchTerm('hello')
editor.commands.setReplaceTerm('world')
editor.commands.replaceAll()
expect(editor.getHTML()).toBe('<p>world world</p>')
})
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(`<p>${content}</p>`)
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('<p>aaaa</p>')
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(`<p>${'a'.repeat(40)}!</p>`)
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('<p>hello helloworld worldhello hello</p>')
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('<p>café caféine café</p>')
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('<p>hello helloworld worldhello hello</p>')
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)
})
})