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

This commit is contained in:
upstream-mirror
2026-09-15 21:11:02 +05:00
commit 083a46e93b
2115 changed files with 245958 additions and 0 deletions

View 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

View File

@@ -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).

View 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:*"
}
}

View File

@@ -0,0 +1,2 @@
export const resultClass = 'find-and-replace-result'
export const currentResultClass = 'find-and-replace-result-current'

View 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

View 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

View File

@@ -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
}

View 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

View 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)
}

View 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)
}

View 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
}

View File

@@ -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,
)
}

View File

@@ -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
}

View 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
}

View File

@@ -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)
}

View File

@@ -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

View 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

View 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
}
}

View File

@@ -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 })
}

View File

@@ -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)
}

View 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),
}
}

View File

@@ -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]
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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]
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -0,0 +1,5 @@
import type { FindAndReplaceMeta } from '../plugin/plugin-state.js'
export function hasCurrentIndex(meta: FindAndReplaceMeta | undefined): boolean {
return !!meta && 'currentIndex' in meta
}

View File

@@ -0,0 +1,2 @@
export { createState } from './createState.js'
export { updateState } from './updateState.js'

View File

@@ -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)
}

View File

@@ -0,0 +1,5 @@
import type { FindAndReplaceMeta } from '../plugin/plugin-state.js'
export function isNewSearch(meta: FindAndReplaceMeta | undefined): boolean {
return !!meta && 'searchTerm' in meta
}

View 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
}

View 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
}

View File

@@ -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))
}

View File

@@ -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))
}

View File

@@ -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,
),
}
}

View File

@@ -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)
}

View File

@@ -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,
),
}
}

View File

@@ -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),
}
}

View File

@@ -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)
}
}

View File

@@ -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,
)
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
)
}

View File

@@ -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)
}

View 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
}

View 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
}

View File

@@ -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)
})
})

View 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(),
},
})