@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.
Files changed (58) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +18 -0
  3. package/dist/index.cjs +950 -0
  4. package/dist/index.cjs.map +1 -0
  5. package/dist/index.d.cts +230 -0
  6. package/dist/index.d.ts +230 -0
  7. package/dist/index.js +915 -0
  8. package/dist/index.js.map +1 -0
  9. package/package.json +55 -0
  10. package/src/constants/constants.ts +2 -0
  11. package/src/find-and-replace.ts +434 -0
  12. package/src/index.ts +8 -0
  13. package/src/plugin/plugin-state.ts +27 -0
  14. package/src/plugin/plugin.ts +60 -0
  15. package/src/search/matches.ts +26 -0
  16. package/src/search/regex.ts +67 -0
  17. package/src/search/search.ts +70 -0
  18. package/src/search/text-segments.ts +63 -0
  19. package/src/search/textblock-search.ts +31 -0
  20. package/src/search/types.ts +17 -0
  21. package/src/state/debounced-search-state.ts +39 -0
  22. package/src/style/create-style-tag.ts +23 -0
  23. package/src/style/style.ts +10 -0
  24. package/src/types.ts +96 -0
  25. package/src/utils/createDecoration.ts +14 -0
  26. package/src/utils/createDecorations.ts +16 -0
  27. package/src/utils/createState.ts +23 -0
  28. package/src/utils/findDecorations.ts +27 -0
  29. package/src/utils/getChangedTextblocks.ts +29 -0
  30. package/src/utils/getFallbackCurrentIndex.ts +8 -0
  31. package/src/utils/getMappedCurrentIndex.ts +32 -0
  32. package/src/utils/getMetaCurrentIndex.ts +19 -0
  33. package/src/utils/getPreviousCurrentResult.ts +10 -0
  34. package/src/utils/getResultsToRefresh.ts +22 -0
  35. package/src/utils/getSearchCurrentIndex.ts +24 -0
  36. package/src/utils/getTextNodeResult.ts +21 -0
  37. package/src/utils/groupResults.ts +68 -0
  38. package/src/utils/hasCurrentIndex.ts +5 -0
  39. package/src/utils/index.ts +2 -0
  40. package/src/utils/isCurrentResult.ts +7 -0
  41. package/src/utils/isNewSearch.ts +5 -0
  42. package/src/utils/mapResult.ts +10 -0
  43. package/src/utils/mapResults.ts +19 -0
  44. package/src/utils/mergeResults.ts +18 -0
  45. package/src/utils/normalizeCurrentIndex.ts +10 -0
  46. package/src/utils/refreshCurrentIndexState.ts +30 -0
  47. package/src/utils/refreshDecorations.ts +28 -0
  48. package/src/utils/refreshDocumentState.ts +47 -0
  49. package/src/utils/refreshState.ts +27 -0
  50. package/src/utils/replaceAllResults.ts +18 -0
  51. package/src/utils/replaceGroup.ts +54 -0
  52. package/src/utils/resolveCurrentIndex.ts +27 -0
  53. package/src/utils/resultInTextblocks.ts +30 -0
  54. package/src/utils/sameResult.ts +5 -0
  55. package/src/utils/shouldRefreshResult.ts +18 -0
  56. package/src/utils/touchesSearch.ts +7 -0
  57. package/src/utils/types.ts +11 -0
  58. 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
@@ -0,0 +1,10 @@
1
+ export const style = `.find-and-replace-result {
2
+ background-color: rgb(255 225 0 / 0.4);
3
+ }
4
+
5
+ .find-and-replace-result-current {
6
+ background-color: rgb(255 165 0 / 0.55);
7
+ }
8
+ `
9
+
10
+ export default style
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
+ }