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 ( + <> +
+
+ setSearchTerm(event.currentTarget.value)} + onKeyDown={onSearchKeyDown} + data-testid="search-input" + /> + setReplaceTerm(event.currentTarget.value)} + data-testid="replace-input" + /> + + + +
+
+ + + + + + + {resultCount === 0 ? 'No results' : `${(currentIndex ?? 0) + 1} of ${resultCount}`} + +
+
+ + + ) +} 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 + +[![Version](https://img.shields.io/npm/v/@tiptap/extension-find-and-replace.svg?label=version)](https://www.npmjs.com/package/@tiptap/extension-find-and-replace) +[![Downloads](https://img.shields.io/npm/dm/@tiptap/extension-find-and-replace.svg)](https://npmcharts.com/compare/tiptap?minimal=true) +[![License](https://img.shields.io/npm/l/@tiptap/extension-find-and-replace.svg)](https://www.npmjs.com/package/@tiptap/extension-find-and-replace) +[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](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