snapshot of https://github.com/ueberdosis/tiptap.git (main, 2026-09-15; blobs > 131072B omitted)
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
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
This commit is contained in:
71
packages/extension-find-and-replace/CHANGELOG.md
Normal file
71
packages/extension-find-and-replace/CHANGELOG.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# @tiptap/extension-find-and-replace
|
||||
|
||||
## 3.30.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [965a880]
|
||||
- @tiptap/core@3.30.3
|
||||
- @tiptap/pm@3.30.3
|
||||
|
||||
## 3.30.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3dffed5]
|
||||
- Updated dependencies [214a140]
|
||||
- @tiptap/core@3.30.2
|
||||
- @tiptap/pm@3.30.2
|
||||
|
||||
## 3.30.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [abc8828]
|
||||
- @tiptap/core@3.30.1
|
||||
- @tiptap/pm@3.30.1
|
||||
|
||||
## 3.30.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [0247d39]
|
||||
- Updated dependencies [58a8953]
|
||||
- Updated dependencies [51909d3]
|
||||
- Updated dependencies [3099eef]
|
||||
- @tiptap/core@3.30.0
|
||||
- @tiptap/pm@3.30.0
|
||||
|
||||
## 3.29.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 07415fe: Ensure only the active find-and-replace result keeps the current-result highlight while navigating matches.
|
||||
- @tiptap/core@3.29.2
|
||||
- @tiptap/pm@3.29.2
|
||||
|
||||
## 3.29.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @tiptap/core@3.29.1
|
||||
- @tiptap/pm@3.29.1
|
||||
|
||||
## 3.29.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- e9942fc: 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.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [d26840f]
|
||||
- Updated dependencies [e150ee0]
|
||||
- Updated dependencies [935e63f]
|
||||
- Updated dependencies [b4c5a2d]
|
||||
- Updated dependencies [a963d48]
|
||||
- Updated dependencies [51f45b6]
|
||||
- Updated dependencies [0f63969]
|
||||
- Updated dependencies [9acaa65]
|
||||
- @tiptap/core@3.29.0
|
||||
- @tiptap/pm@3.29.0
|
||||
18
packages/extension-find-and-replace/README.md
Normal file
18
packages/extension-find-and-replace/README.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# @tiptap/extension-find-and-replace
|
||||
|
||||
[](https://www.npmjs.com/package/@tiptap/extension-find-and-replace)
|
||||
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||
[](https://www.npmjs.com/package/@tiptap/extension-find-and-replace)
|
||||
[](https://github.com/sponsors/ueberdosis)
|
||||
|
||||
## Introduction
|
||||
|
||||
Tiptap is a headless wrapper around [ProseMirror](https://ProseMirror.net) – a toolkit for building rich text WYSIWYG editors, which is already in use at many well-known companies such as _New York Times_, _The Guardian_ or _Atlassian_.
|
||||
|
||||
## Official Documentation
|
||||
|
||||
Documentation can be found on the [Tiptap website](https://tiptap.dev).
|
||||
|
||||
## License
|
||||
|
||||
Tiptap is open sourced software licensed under the [MIT license](https://github.com/ueberdosis/tiptap/blob/main/LICENSE.md).
|
||||
55
packages/extension-find-and-replace/package.json
Normal file
55
packages/extension-find-and-replace/package.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "@tiptap/extension-find-and-replace",
|
||||
"version": "3.30.3",
|
||||
"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": "vp pack"
|
||||
},
|
||||
"dependencies": {
|
||||
"re2js": "^2.8.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tiptap/core": "workspace:^",
|
||||
"@tiptap/pm": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "workspace:*",
|
||||
"@tiptap/pm": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const resultClass = 'find-and-replace-result'
|
||||
export const currentResultClass = 'find-and-replace-result-current'
|
||||
434
packages/extension-find-and-replace/src/find-and-replace.ts
Normal file
434
packages/extension-find-and-replace/src/find-and-replace.ts
Normal file
@@ -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<ReturnType> {
|
||||
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<FindAndReplaceOptions, FindAndReplaceStorage>({
|
||||
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
|
||||
8
packages/extension-find-and-replace/src/index.ts
Normal file
8
packages/extension-find-and-replace/src/index.ts
Normal file
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
60
packages/extension-find-and-replace/src/plugin/plugin.ts
Normal file
60
packages/extension-find-and-replace/src/plugin/plugin.ts
Normal file
@@ -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<FindAndReplacePluginState>('findAndReplace')
|
||||
|
||||
export const FindAndReplacePlugin = (
|
||||
options: FindAndReplaceOptions,
|
||||
onCreate: (pluginState: FindAndReplacePluginState) => void,
|
||||
) => {
|
||||
return new Plugin<FindAndReplacePluginState>({
|
||||
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
|
||||
26
packages/extension-find-and-replace/src/search/matches.ts
Normal file
26
packages/extension-find-and-replace/src/search/matches.ts
Normal file
@@ -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<TextMatch> {
|
||||
for (const match of text.matchAll(regex)) {
|
||||
yield { index: match.index, value: match[0] }
|
||||
}
|
||||
}
|
||||
|
||||
function* findSafeMatches(regex: RE2JS, text: string): Generator<TextMatch> {
|
||||
const matcher = regex.matcher(text)
|
||||
|
||||
while (matcher.find()) {
|
||||
yield { index: matcher.start(), value: matcher.group() ?? '' }
|
||||
}
|
||||
}
|
||||
|
||||
export function findMatches(regex: SearchRegex, text: string): Generator<TextMatch> {
|
||||
return regex instanceof RegExp ? findNativeMatches(regex, text) : findSafeMatches(regex, text)
|
||||
}
|
||||
67
packages/extension-find-and-replace/src/search/regex.ts
Normal file
67
packages/extension-find-and-replace/src/search/regex.ts
Normal file
@@ -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<SearchOptions, 'caseSensitive' | 'useRegex'>,
|
||||
): 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
|
||||
? `(?<!${unicodeWordCharacter})${escaped}(?!${unicodeWordCharacter})`
|
||||
: escaped
|
||||
}
|
||||
|
||||
return compileRegex(source, options)
|
||||
}
|
||||
70
packages/extension-find-and-replace/src/search/search.ts
Normal file
70
packages/extension-find-and-replace/src/search/search.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { Node as PMNode } from '@tiptap/pm/model'
|
||||
|
||||
import { createSearchRegex } from './regex.js'
|
||||
import { searchTextblock } from './textblock-search.js'
|
||||
import type { SearchOptions, SearchResult, TextblockSearchTarget } from './types.js'
|
||||
|
||||
export type { SearchOptions, SearchResult, TextblockSearchTarget } from './types.js'
|
||||
export { createSearchRegex } from './regex.js'
|
||||
|
||||
/**
|
||||
* Searches textblocks for a term and returns their matched ranges.
|
||||
* @param textblocks The textblocks and their document positions.
|
||||
* @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 textblock order.
|
||||
*/
|
||||
export function searchTextblocks(
|
||||
textblocks: readonly TextblockSearchTarget[],
|
||||
term: string,
|
||||
options: SearchOptions,
|
||||
): SearchResult[] {
|
||||
const regex = createSearchRegex(term, options)
|
||||
|
||||
if (!regex) {
|
||||
return []
|
||||
}
|
||||
|
||||
return textblocks.flatMap(({ node, pos }) => 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: PMNode, 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
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { Node as PMNode } 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: PMNode, 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,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Node as PMNode } 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: PMNode,
|
||||
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
|
||||
}
|
||||
17
packages/extension-find-and-replace/src/search/types.ts
Normal file
17
packages/extension-find-and-replace/src/search/types.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { Node as PMNode } from '@tiptap/pm/model'
|
||||
|
||||
export interface SearchResult {
|
||||
from: number
|
||||
to: number
|
||||
}
|
||||
|
||||
export interface TextblockSearchTarget {
|
||||
node: PMNode
|
||||
pos: number
|
||||
}
|
||||
|
||||
export interface SearchOptions {
|
||||
caseSensitive: boolean
|
||||
useRegex: boolean
|
||||
wholeWord: boolean
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Editor } from '@tiptap/core'
|
||||
|
||||
export interface DebouncedSearchState {
|
||||
timeout: ReturnType<typeof setTimeout> | null
|
||||
pendingTerm: string | null
|
||||
}
|
||||
|
||||
const debouncedSearchState = new WeakMap<Editor, DebouncedSearchState>()
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export const createStyleTag = (style: string, nonce?: string): HTMLStyleElement => {
|
||||
const existing = document.querySelector<HTMLStyleElement>(
|
||||
'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
|
||||
10
packages/extension-find-and-replace/src/style/style.ts
Normal file
10
packages/extension-find-and-replace/src/style/style.ts
Normal file
@@ -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
|
||||
96
packages/extension-find-and-replace/src/types.ts
Normal file
96
packages/extension-find-and-replace/src/types.ts
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Node as PMNode } 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: PMNode,
|
||||
results: SearchResult[],
|
||||
currentIndex: number | null,
|
||||
): DecorationSet {
|
||||
const decorations = results.map((result, index) => createDecoration(result, currentIndex, index))
|
||||
|
||||
return DecorationSet.create(doc, decorations)
|
||||
}
|
||||
26
packages/extension-find-and-replace/src/utils/createState.ts
Normal file
26
packages/extension-find-and-replace/src/utils/createState.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { Node as PMNode } 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: PMNode,
|
||||
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),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
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<Decoration>()
|
||||
|
||||
textblocks.forEach(textblock => {
|
||||
decorations.find(textblock.from, textblock.to).forEach(decoration => found.add(decoration))
|
||||
})
|
||||
|
||||
results.forEach(result => {
|
||||
decorations
|
||||
.find(result.from, result.to)
|
||||
.filter(decoration => {
|
||||
return decoration.from === result.from && decoration.to === result.to
|
||||
})
|
||||
.forEach(decoration => found.add(decoration))
|
||||
})
|
||||
|
||||
return [...found]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Node as PMNode } from '@tiptap/pm/model'
|
||||
import type { Mapping } from '@tiptap/pm/transform'
|
||||
|
||||
import type { TextblockRange } from './types.js'
|
||||
|
||||
export function getChangedTextblocks(doc: PMNode, mapping: Mapping): TextblockRange[] {
|
||||
const textblocks = new Map<number, TextblockRange>()
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { FindAndReplaceMeta } from '../plugin/plugin-state.js'
|
||||
|
||||
export function hasCurrentIndex(meta: FindAndReplaceMeta | undefined): boolean {
|
||||
return !!meta && 'currentIndex' in meta
|
||||
}
|
||||
2
packages/extension-find-and-replace/src/utils/index.ts
Normal file
2
packages/extension-find-and-replace/src/utils/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { createState } from './createState.js'
|
||||
export { updateState } from './updateState.js'
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { FindAndReplaceMeta } from '../plugin/plugin-state.js'
|
||||
|
||||
export function isNewSearch(meta: FindAndReplaceMeta | undefined): boolean {
|
||||
return !!meta && 'searchTerm' in meta
|
||||
}
|
||||
10
packages/extension-find-and-replace/src/utils/mapResult.ts
Normal file
10
packages/extension-find-and-replace/src/utils/mapResult.ts
Normal file
@@ -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
|
||||
}
|
||||
19
packages/extension-find-and-replace/src/utils/mapResults.ts
Normal file
19
packages/extension-find-and-replace/src/utils/mapResults.ts
Normal file
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Node as PMNode } 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: PMNode,
|
||||
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,
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Node as PMNode } 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: PMNode,
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Node as PMNode } 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: PMNode,
|
||||
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,
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Node as PMNode } 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: PMNode,
|
||||
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),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
11
packages/extension-find-and-replace/src/utils/types.ts
Normal file
11
packages/extension-find-and-replace/src/utils/types.ts
Normal file
@@ -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
|
||||
}
|
||||
33
packages/extension-find-and-replace/src/utils/updateState.ts
Normal file
33
packages/extension-find-and-replace/src/utils/updateState.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { Node as PMNode } 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: PMNode,
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
9
packages/extension-find-and-replace/vite.config.ts
Normal file
9
packages/extension-find-and-replace/vite.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vite-plus'
|
||||
import { basePackConfig } from '../../pack.config.mjs'
|
||||
|
||||
export default defineConfig({
|
||||
pack: {
|
||||
entry: ['src/index.ts'],
|
||||
...basePackConfig(),
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user