@tiptap/extension-find-and-replace 3.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +21 -0
- package/README.md +18 -0
- package/dist/index.cjs +950 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +230 -0
- package/dist/index.d.ts +230 -0
- package/dist/index.js +915 -0
- package/dist/index.js.map +1 -0
- package/package.json +55 -0
- package/src/constants/constants.ts +2 -0
- package/src/find-and-replace.ts +434 -0
- package/src/index.ts +8 -0
- package/src/plugin/plugin-state.ts +27 -0
- package/src/plugin/plugin.ts +60 -0
- package/src/search/matches.ts +26 -0
- package/src/search/regex.ts +67 -0
- package/src/search/search.ts +70 -0
- package/src/search/text-segments.ts +63 -0
- package/src/search/textblock-search.ts +31 -0
- package/src/search/types.ts +17 -0
- package/src/state/debounced-search-state.ts +39 -0
- package/src/style/create-style-tag.ts +23 -0
- package/src/style/style.ts +10 -0
- package/src/types.ts +96 -0
- package/src/utils/createDecoration.ts +14 -0
- package/src/utils/createDecorations.ts +16 -0
- package/src/utils/createState.ts +23 -0
- package/src/utils/findDecorations.ts +27 -0
- package/src/utils/getChangedTextblocks.ts +29 -0
- package/src/utils/getFallbackCurrentIndex.ts +8 -0
- package/src/utils/getMappedCurrentIndex.ts +32 -0
- package/src/utils/getMetaCurrentIndex.ts +19 -0
- package/src/utils/getPreviousCurrentResult.ts +10 -0
- package/src/utils/getResultsToRefresh.ts +22 -0
- package/src/utils/getSearchCurrentIndex.ts +24 -0
- package/src/utils/getTextNodeResult.ts +21 -0
- package/src/utils/groupResults.ts +68 -0
- package/src/utils/hasCurrentIndex.ts +5 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/isCurrentResult.ts +7 -0
- package/src/utils/isNewSearch.ts +5 -0
- package/src/utils/mapResult.ts +10 -0
- package/src/utils/mapResults.ts +19 -0
- package/src/utils/mergeResults.ts +18 -0
- package/src/utils/normalizeCurrentIndex.ts +10 -0
- package/src/utils/refreshCurrentIndexState.ts +30 -0
- package/src/utils/refreshDecorations.ts +28 -0
- package/src/utils/refreshDocumentState.ts +47 -0
- package/src/utils/refreshState.ts +27 -0
- package/src/utils/replaceAllResults.ts +18 -0
- package/src/utils/replaceGroup.ts +54 -0
- package/src/utils/resolveCurrentIndex.ts +27 -0
- package/src/utils/resultInTextblocks.ts +30 -0
- package/src/utils/sameResult.ts +5 -0
- package/src/utils/shouldRefreshResult.ts +18 -0
- package/src/utils/touchesSearch.ts +7 -0
- package/src/utils/types.ts +11 -0
- package/src/utils/updateState.ts +33 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { DecorationSet } from '@tiptap/pm/view'
|
|
2
|
+
|
|
3
|
+
import type { SearchResult } from '../search/search.js'
|
|
4
|
+
|
|
5
|
+
export interface FindAndReplacePluginState {
|
|
6
|
+
searchTerm: string
|
|
7
|
+
replaceTerm: string
|
|
8
|
+
caseSensitive: boolean
|
|
9
|
+
useRegex: boolean
|
|
10
|
+
wholeWord: boolean
|
|
11
|
+
results: SearchResult[]
|
|
12
|
+
currentIndex: number | null
|
|
13
|
+
decorations: DecorationSet
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Transaction meta to patch the plugin state. Setting `searchTerm`,
|
|
18
|
+
* `caseSensitive`, `useRegex` or `wholeWord` re-runs the search.
|
|
19
|
+
*/
|
|
20
|
+
export interface FindAndReplaceMeta {
|
|
21
|
+
searchTerm?: string
|
|
22
|
+
replaceTerm?: string
|
|
23
|
+
caseSensitive?: boolean
|
|
24
|
+
useRegex?: boolean
|
|
25
|
+
wholeWord?: boolean
|
|
26
|
+
currentIndex?: number | null
|
|
27
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
|
2
|
+
|
|
3
|
+
import { currentResultClass, resultClass } from '../constants/constants.js'
|
|
4
|
+
import { createStyleTag } from '../style/create-style-tag.js'
|
|
5
|
+
import type { FindAndReplaceMeta, FindAndReplacePluginState } from './plugin-state.js'
|
|
6
|
+
import { style } from '../style/style.js'
|
|
7
|
+
import type { FindAndReplaceOptions } from '../types.js'
|
|
8
|
+
import { createState, updateState } from '../utils/index.js'
|
|
9
|
+
|
|
10
|
+
export { currentResultClass, resultClass }
|
|
11
|
+
export type { FindAndReplaceMeta, FindAndReplacePluginState }
|
|
12
|
+
|
|
13
|
+
export const FindAndReplacePluginKey = new PluginKey<FindAndReplacePluginState>('findAndReplace')
|
|
14
|
+
|
|
15
|
+
export const FindAndReplacePlugin = (
|
|
16
|
+
options: FindAndReplaceOptions,
|
|
17
|
+
onCreate: (pluginState: FindAndReplacePluginState) => void,
|
|
18
|
+
) => {
|
|
19
|
+
return new Plugin<FindAndReplacePluginState>({
|
|
20
|
+
key: FindAndReplacePluginKey,
|
|
21
|
+
|
|
22
|
+
state: {
|
|
23
|
+
init: (_config, state) => {
|
|
24
|
+
if (options.injectCSS && typeof document !== 'undefined') {
|
|
25
|
+
createStyleTag(style, options.injectNonce)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const pluginState = createState(state.doc, options)
|
|
29
|
+
onCreate(pluginState)
|
|
30
|
+
|
|
31
|
+
return pluginState
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
apply: (tr, state, _oldState, newState) => {
|
|
35
|
+
const meta = tr.getMeta(FindAndReplacePluginKey) as FindAndReplaceMeta | undefined
|
|
36
|
+
|
|
37
|
+
if (!meta && !tr.docChanged) {
|
|
38
|
+
return state
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return updateState(
|
|
42
|
+
state,
|
|
43
|
+
{ ...state, ...meta },
|
|
44
|
+
newState.doc,
|
|
45
|
+
meta,
|
|
46
|
+
tr.docChanged,
|
|
47
|
+
tr.mapping,
|
|
48
|
+
)
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
props: {
|
|
53
|
+
decorations(state) {
|
|
54
|
+
return FindAndReplacePluginKey.getState(state)?.decorations
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export default FindAndReplacePlugin
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { RE2JS } from 're2js'
|
|
2
|
+
|
|
3
|
+
import type { SearchRegex } from './regex.js'
|
|
4
|
+
|
|
5
|
+
interface TextMatch {
|
|
6
|
+
index: number
|
|
7
|
+
value: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function* findNativeMatches(regex: RegExp, text: string): Generator<TextMatch> {
|
|
11
|
+
for (const match of text.matchAll(regex)) {
|
|
12
|
+
yield { index: match.index, value: match[0] }
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function* findSafeMatches(regex: RE2JS, text: string): Generator<TextMatch> {
|
|
17
|
+
const matcher = regex.matcher(text)
|
|
18
|
+
|
|
19
|
+
while (matcher.find()) {
|
|
20
|
+
yield { index: matcher.start(), value: matcher.group() ?? '' }
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function findMatches(regex: SearchRegex, text: string): Generator<TextMatch> {
|
|
25
|
+
return regex instanceof RegExp ? findNativeMatches(regex, text) : findSafeMatches(regex, text)
|
|
26
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { RE2JS } from 're2js'
|
|
2
|
+
|
|
3
|
+
import type { SearchOptions } from './types.js'
|
|
4
|
+
|
|
5
|
+
export type SearchRegex = RegExp | RE2JS
|
|
6
|
+
|
|
7
|
+
const escapeRegExp = (value: string): string => {
|
|
8
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Unicode word characters like letters, accents, numbers, and underscore-like punctuation. */
|
|
12
|
+
const unicodeWordCharacter = '[\\p{L}\\p{M}\\p{N}\\p{Pc}]'
|
|
13
|
+
|
|
14
|
+
function compileNativeRegex(source: string, caseSensitive: boolean): RegExp | null {
|
|
15
|
+
try {
|
|
16
|
+
return new RegExp(source, caseSensitive ? 'gu' : 'giu')
|
|
17
|
+
} catch {
|
|
18
|
+
return null
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function compileSafeRegex(source: string, caseSensitive: boolean): RE2JS | null {
|
|
23
|
+
try {
|
|
24
|
+
const flags = caseSensitive ? 0 : RE2JS.CASE_INSENSITIVE
|
|
25
|
+
|
|
26
|
+
return RE2JS.compile(source, flags)
|
|
27
|
+
} catch {
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function compileRegex(
|
|
33
|
+
source: string,
|
|
34
|
+
options: Pick<SearchOptions, 'caseSensitive' | 'useRegex'>,
|
|
35
|
+
): SearchRegex | null {
|
|
36
|
+
return options.useRegex
|
|
37
|
+
? compileSafeRegex(source, options.caseSensitive)
|
|
38
|
+
: compileNativeRegex(source, options.caseSensitive)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Creates a search matcher for a term.
|
|
43
|
+
* Regex mode uses RE2-compatible syntax to avoid catastrophic backtracking.
|
|
44
|
+
* @param term The search term, treated as regex source when `useRegex` is enabled.
|
|
45
|
+
* @param options Case sensitivity, regex mode, and whole word matching.
|
|
46
|
+
* @returns A compiled matcher, or `null` when the term is invalid or unsupported.
|
|
47
|
+
*/
|
|
48
|
+
export function createSearchRegex(term: string, options: SearchOptions): SearchRegex | null {
|
|
49
|
+
if (!term) {
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let source: string
|
|
54
|
+
|
|
55
|
+
if (options.useRegex) {
|
|
56
|
+
source = term
|
|
57
|
+
} else {
|
|
58
|
+
const escaped = escapeRegExp(term)
|
|
59
|
+
|
|
60
|
+
// Whole-word searches reject terms next to another Unicode word character.
|
|
61
|
+
source = options.wholeWord
|
|
62
|
+
? `(?<!${unicodeWordCharacter})${escaped}(?!${unicodeWordCharacter})`
|
|
63
|
+
: escaped
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return compileRegex(source, options)
|
|
67
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { Node } from '@tiptap/pm/model'
|
|
2
|
+
|
|
3
|
+
import { createSearchRegex } from './regex.js'
|
|
4
|
+
import { searchTextblock } from './textblock-search.js'
|
|
5
|
+
import type { SearchOptions, SearchResult, TextblockSearchTarget } from './types.js'
|
|
6
|
+
|
|
7
|
+
export type { SearchOptions, SearchResult, TextblockSearchTarget } from './types.js'
|
|
8
|
+
export { createSearchRegex } from './regex.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Searches textblocks for a term and returns their matched ranges.
|
|
12
|
+
* @param textblocks The textblocks and their document positions.
|
|
13
|
+
* @param term The search term, treated as regex source when `useRegex` is enabled.
|
|
14
|
+
* @param options Case sensitivity, regex mode, and whole word matching.
|
|
15
|
+
* @returns The list of matched ranges in textblock order.
|
|
16
|
+
*/
|
|
17
|
+
export function searchTextblocks(
|
|
18
|
+
textblocks: readonly TextblockSearchTarget[],
|
|
19
|
+
term: string,
|
|
20
|
+
options: SearchOptions,
|
|
21
|
+
): SearchResult[] {
|
|
22
|
+
const regex = createSearchRegex(term, options)
|
|
23
|
+
|
|
24
|
+
if (!regex) {
|
|
25
|
+
return []
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return textblocks.flatMap(({ node, pos }) => searchTextblock(regex, node, pos))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Searches all textblocks of a document for a term and returns the matched ranges.
|
|
33
|
+
* Matches may span multiple text nodes (e.g. across marks), but never leave a textblock.
|
|
34
|
+
* @param doc The document to search in.
|
|
35
|
+
* @param term The search term, treated as regex source when `useRegex` is enabled.
|
|
36
|
+
* @param options Case sensitivity, regex mode, and whole word matching.
|
|
37
|
+
* @returns The list of matched ranges in document order.
|
|
38
|
+
*/
|
|
39
|
+
export function searchDocument(doc: Node, term: string, options: SearchOptions): SearchResult[] {
|
|
40
|
+
const textblocks: TextblockSearchTarget[] = []
|
|
41
|
+
|
|
42
|
+
doc.descendants((node, pos) => {
|
|
43
|
+
if (!node.isTextblock) {
|
|
44
|
+
return true
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
textblocks.push({ node, pos })
|
|
48
|
+
|
|
49
|
+
return false
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
return searchTextblocks(textblocks, term, options)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Finds the index of the first result at or after a position, wrapping around
|
|
57
|
+
* to the first result when the position is behind the last result.
|
|
58
|
+
* @param results The current search results.
|
|
59
|
+
* @param from The position to search from.
|
|
60
|
+
* @returns The result index, or `null` when there are no results.
|
|
61
|
+
*/
|
|
62
|
+
export function findNextIndex(results: SearchResult[], from: number): number | null {
|
|
63
|
+
if (results.length === 0) {
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const index = results.findIndex(result => result.from >= from)
|
|
68
|
+
|
|
69
|
+
return index === -1 ? 0 : index
|
|
70
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { Node } from '@tiptap/pm/model'
|
|
2
|
+
|
|
3
|
+
interface TextSegment {
|
|
4
|
+
isText: boolean
|
|
5
|
+
pos: number
|
|
6
|
+
length: number
|
|
7
|
+
text: string
|
|
8
|
+
textOffset: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Non-text inline nodes (hard break, mention, ...) contribute a placeholder
|
|
12
|
+
// so matches never silently span across them.
|
|
13
|
+
export function getTextSegments(textblock: Node, pos: number): TextSegment[] {
|
|
14
|
+
const segments: TextSegment[] = []
|
|
15
|
+
let textOffset = 0
|
|
16
|
+
|
|
17
|
+
textblock.forEach((child, offset) => {
|
|
18
|
+
const text = child.isText ? (child.text ?? '') : '\n'
|
|
19
|
+
|
|
20
|
+
segments.push({
|
|
21
|
+
isText: child.isText,
|
|
22
|
+
pos: pos + 1 + offset,
|
|
23
|
+
length: child.nodeSize,
|
|
24
|
+
text,
|
|
25
|
+
textOffset,
|
|
26
|
+
})
|
|
27
|
+
textOffset += text.length
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
return segments
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function findOffsetSegment(segments: TextSegment[], offset: number): TextSegment | undefined {
|
|
34
|
+
let low = 0
|
|
35
|
+
let high = segments.length
|
|
36
|
+
|
|
37
|
+
while (low < high) {
|
|
38
|
+
const middle = Math.floor((low + high) / 2)
|
|
39
|
+
const segment = segments[middle]
|
|
40
|
+
const segmentEnd = segment.textOffset + segment.text.length
|
|
41
|
+
|
|
42
|
+
if (offset < segmentEnd) {
|
|
43
|
+
high = middle
|
|
44
|
+
} else {
|
|
45
|
+
low = middle + 1
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return segments[low] ?? segments.at(-1)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function offsetToPos(segments: TextSegment[], offset: number): number {
|
|
53
|
+
const segment = findOffsetSegment(segments, offset)
|
|
54
|
+
|
|
55
|
+
return segment ? segment.pos + Math.min(offset - segment.textOffset, segment.length) : 0
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function overlapsNonTextSegment(segments: TextSegment[], from: number, to: number): boolean {
|
|
59
|
+
return segments.some(
|
|
60
|
+
segment =>
|
|
61
|
+
!segment.isText && from < segment.textOffset + segment.text.length && to > segment.textOffset,
|
|
62
|
+
)
|
|
63
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Node } from '@tiptap/pm/model'
|
|
2
|
+
|
|
3
|
+
import { findMatches } from './matches.js'
|
|
4
|
+
import type { SearchRegex } from './regex.js'
|
|
5
|
+
import { getTextSegments, offsetToPos, overlapsNonTextSegment } from './text-segments.js'
|
|
6
|
+
import type { SearchResult } from './types.js'
|
|
7
|
+
|
|
8
|
+
export function searchTextblock(regex: SearchRegex, textblock: Node, pos: number): SearchResult[] {
|
|
9
|
+
const segments = getTextSegments(textblock, pos)
|
|
10
|
+
const text = segments.map(segment => segment.text).join('')
|
|
11
|
+
const results: SearchResult[] = []
|
|
12
|
+
|
|
13
|
+
for (const match of findMatches(regex, text)) {
|
|
14
|
+
if (match.value.length === 0) {
|
|
15
|
+
continue
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const matchEnd = match.index + match.value.length
|
|
19
|
+
|
|
20
|
+
if (overlapsNonTextSegment(segments, match.index, matchEnd)) {
|
|
21
|
+
continue
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
results.push({
|
|
25
|
+
from: offsetToPos(segments, match.index),
|
|
26
|
+
to: offsetToPos(segments, matchEnd),
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return results
|
|
31
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Node } from '@tiptap/pm/model'
|
|
2
|
+
|
|
3
|
+
export interface SearchResult {
|
|
4
|
+
from: number
|
|
5
|
+
to: number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface TextblockSearchTarget {
|
|
9
|
+
node: Node
|
|
10
|
+
pos: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SearchOptions {
|
|
14
|
+
caseSensitive: boolean
|
|
15
|
+
useRegex: boolean
|
|
16
|
+
wholeWord: boolean
|
|
17
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Editor } from '@tiptap/core'
|
|
2
|
+
|
|
3
|
+
export interface DebouncedSearchState {
|
|
4
|
+
timeout: ReturnType<typeof setTimeout> | null
|
|
5
|
+
pendingTerm: string | null
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const debouncedSearchState = new WeakMap<Editor, DebouncedSearchState>()
|
|
9
|
+
|
|
10
|
+
export function getDebouncedSearchState(editor: Editor): DebouncedSearchState {
|
|
11
|
+
const existingState = debouncedSearchState.get(editor)
|
|
12
|
+
|
|
13
|
+
if (existingState) {
|
|
14
|
+
return existingState
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const nextState: DebouncedSearchState = {
|
|
18
|
+
timeout: null,
|
|
19
|
+
pendingTerm: null,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
debouncedSearchState.set(editor, nextState)
|
|
23
|
+
|
|
24
|
+
return nextState
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function clearDebouncedSearchState(editor: Editor): void {
|
|
28
|
+
const state = debouncedSearchState.get(editor)
|
|
29
|
+
|
|
30
|
+
if (!state) {
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (state.timeout !== null) {
|
|
35
|
+
clearTimeout(state.timeout)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
debouncedSearchState.delete(editor)
|
|
39
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export const createStyleTag = (style: string, nonce?: string): HTMLStyleElement => {
|
|
2
|
+
const existing = document.querySelector<HTMLStyleElement>(
|
|
3
|
+
'style[data-tiptap-extension-find-and-replace-style]',
|
|
4
|
+
)
|
|
5
|
+
|
|
6
|
+
if (existing !== null) {
|
|
7
|
+
return existing
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const styleNode = document.createElement('style')
|
|
11
|
+
|
|
12
|
+
if (nonce) {
|
|
13
|
+
styleNode.setAttribute('nonce', nonce)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
styleNode.setAttribute('data-tiptap-extension-find-and-replace-style', '')
|
|
17
|
+
styleNode.textContent = style
|
|
18
|
+
document.getElementsByTagName('head')[0].appendChild(styleNode)
|
|
19
|
+
|
|
20
|
+
return styleNode
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export default createStyleTag
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { SearchResult } from './search/search.js'
|
|
2
|
+
|
|
3
|
+
export interface FindAndReplaceOptions {
|
|
4
|
+
/**
|
|
5
|
+
* The initial search term.
|
|
6
|
+
* @default ''
|
|
7
|
+
*/
|
|
8
|
+
searchTerm: string
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The initial replace term.
|
|
12
|
+
* @default ''
|
|
13
|
+
*/
|
|
14
|
+
replaceTerm: string
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Whether the search is case sensitive.
|
|
18
|
+
* @default false
|
|
19
|
+
*/
|
|
20
|
+
caseSensitive: boolean
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Whether the search term is treated as an RE2-compatible regular expression.
|
|
24
|
+
* Lookarounds and backreferences are not supported.
|
|
25
|
+
* @default false
|
|
26
|
+
*/
|
|
27
|
+
useRegex: boolean
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Whether to match whole words only. Ignored when `useRegex` is enabled.
|
|
31
|
+
* @default false
|
|
32
|
+
*/
|
|
33
|
+
wholeWord: boolean
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Debounce delay in milliseconds applied to `setSearchTerm`.
|
|
37
|
+
* Set to `0` to disable debouncing.
|
|
38
|
+
* @default 250
|
|
39
|
+
*/
|
|
40
|
+
searchDebounceMs: number
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Whether the default result highlight styles are injected.
|
|
44
|
+
* @default true
|
|
45
|
+
*/
|
|
46
|
+
injectCSS: boolean
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A nonce for the injected style tag, needed for strict CSP setups.
|
|
50
|
+
* @default undefined
|
|
51
|
+
*/
|
|
52
|
+
injectNonce: string | undefined
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface FindAndReplaceStorage {
|
|
56
|
+
/**
|
|
57
|
+
* The current search term.
|
|
58
|
+
*/
|
|
59
|
+
searchTerm: string
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The current replace term.
|
|
63
|
+
*/
|
|
64
|
+
replaceTerm: string
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Whether the search is case sensitive.
|
|
68
|
+
*/
|
|
69
|
+
caseSensitive: boolean
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Whether the search term is treated as an RE2-compatible regular expression.
|
|
73
|
+
*/
|
|
74
|
+
useRegex: boolean
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Whether to match whole words only. Ignored when `useRegex` is enabled.
|
|
78
|
+
*/
|
|
79
|
+
wholeWord: boolean
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The current search results in document order.
|
|
83
|
+
*/
|
|
84
|
+
results: SearchResult[]
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The index of the current result, or `null` when no result is selected.
|
|
88
|
+
*/
|
|
89
|
+
currentIndex: number | null
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
declare module '@tiptap/core' {
|
|
93
|
+
interface Storage {
|
|
94
|
+
findAndReplace: FindAndReplaceStorage
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Decoration } from '@tiptap/pm/view'
|
|
2
|
+
|
|
3
|
+
import { currentResultClass, resultClass } from '../constants/constants.js'
|
|
4
|
+
import type { SearchResult } from '../search/search.js'
|
|
5
|
+
|
|
6
|
+
export function createDecoration(
|
|
7
|
+
result: SearchResult,
|
|
8
|
+
currentIndex: number | null,
|
|
9
|
+
index: number,
|
|
10
|
+
): Decoration {
|
|
11
|
+
const className = index === currentIndex ? `${resultClass} ${currentResultClass}` : resultClass
|
|
12
|
+
|
|
13
|
+
return Decoration.inline(result.from, result.to, { class: className })
|
|
14
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Node } from '@tiptap/pm/model'
|
|
2
|
+
import { DecorationSet } from '@tiptap/pm/view'
|
|
3
|
+
|
|
4
|
+
import type { SearchResult } from '../search/search.js'
|
|
5
|
+
|
|
6
|
+
import { createDecoration } from './createDecoration.js'
|
|
7
|
+
|
|
8
|
+
export function createDecorations(
|
|
9
|
+
doc: Node,
|
|
10
|
+
results: SearchResult[],
|
|
11
|
+
currentIndex: number | null,
|
|
12
|
+
): DecorationSet {
|
|
13
|
+
const decorations = results.map((result, index) => createDecoration(result, currentIndex, index))
|
|
14
|
+
|
|
15
|
+
return DecorationSet.create(doc, decorations)
|
|
16
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Node } from '@tiptap/pm/model'
|
|
2
|
+
|
|
3
|
+
import type { FindAndReplaceOptions } from '../types.js'
|
|
4
|
+
import type { FindAndReplacePluginState } from '../plugin/plugin-state.js'
|
|
5
|
+
import { searchDocument } from '../search/search.js'
|
|
6
|
+
|
|
7
|
+
import { createDecorations } from './createDecorations.js'
|
|
8
|
+
|
|
9
|
+
export function createState(doc: Node, options: FindAndReplaceOptions): FindAndReplacePluginState {
|
|
10
|
+
const results = searchDocument(doc, options.searchTerm, options)
|
|
11
|
+
const currentIndex = results.length > 0 ? 0 : null
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
searchTerm: options.searchTerm,
|
|
15
|
+
replaceTerm: options.replaceTerm,
|
|
16
|
+
caseSensitive: options.caseSensitive,
|
|
17
|
+
useRegex: options.useRegex,
|
|
18
|
+
wholeWord: options.wholeWord,
|
|
19
|
+
results,
|
|
20
|
+
currentIndex,
|
|
21
|
+
decorations: createDecorations(doc, results, currentIndex),
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
|
2
|
+
|
|
3
|
+
import type { SearchResult } from '../search/search.js'
|
|
4
|
+
|
|
5
|
+
import type { TextblockRange } from './types.js'
|
|
6
|
+
|
|
7
|
+
export function findDecorations(
|
|
8
|
+
decorations: DecorationSet,
|
|
9
|
+
results: readonly SearchResult[],
|
|
10
|
+
textblocks: readonly TextblockRange[],
|
|
11
|
+
): Decoration[] {
|
|
12
|
+
const found = new Set<Decoration>()
|
|
13
|
+
|
|
14
|
+
textblocks.forEach(textblock => {
|
|
15
|
+
decorations.find(textblock.from, textblock.to).forEach(decoration => found.add(decoration))
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
results.forEach(result => {
|
|
19
|
+
decorations
|
|
20
|
+
.find(result.from, result.to, decoration => {
|
|
21
|
+
return decoration.from === result.from && decoration.to === result.to
|
|
22
|
+
})
|
|
23
|
+
.forEach(decoration => found.add(decoration))
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
return [...found]
|
|
27
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Node } from '@tiptap/pm/model'
|
|
2
|
+
import type { Mapping } from '@tiptap/pm/transform'
|
|
3
|
+
|
|
4
|
+
import type { TextblockRange } from './types.js'
|
|
5
|
+
|
|
6
|
+
export function getChangedTextblocks(doc: Node, mapping: Mapping): TextblockRange[] {
|
|
7
|
+
const textblocks = new Map<number, TextblockRange>()
|
|
8
|
+
|
|
9
|
+
mapping.maps.forEach((stepMap, index) => {
|
|
10
|
+
const remaining = mapping.slice(index + 1)
|
|
11
|
+
|
|
12
|
+
stepMap.forEach((_oldStart, _oldEnd, newStart, newEnd) => {
|
|
13
|
+
const from = Math.max(0, remaining.map(newStart, -1) - 1)
|
|
14
|
+
const to = Math.min(doc.content.size, remaining.map(newEnd, 1) + 1)
|
|
15
|
+
|
|
16
|
+
doc.nodesBetween(from, to, (node, pos) => {
|
|
17
|
+
if (!node.isTextblock) {
|
|
18
|
+
return true
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
textblocks.set(pos, { node, pos, from: pos, to: pos + node.nodeSize })
|
|
22
|
+
|
|
23
|
+
return false
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
return [...textblocks.values()].sort((first, second) => first.pos - second.pos)
|
|
29
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { FindAndReplacePluginState } from '../plugin/plugin-state.js'
|
|
2
|
+
|
|
3
|
+
export function getFallbackCurrentIndex(
|
|
4
|
+
previousState: FindAndReplacePluginState,
|
|
5
|
+
length: number,
|
|
6
|
+
): number {
|
|
7
|
+
return previousState.currentIndex === null ? 0 : Math.min(previousState.currentIndex, length - 1)
|
|
8
|
+
}
|