diff --git a/.changeset/fresh-carrots-smile.md b/.changeset/fresh-carrots-smile.md new file mode 100644 index 0000000000..325f97544c --- /dev/null +++ b/.changeset/fresh-carrots-smile.md @@ -0,0 +1,5 @@ +--- +'@udecode/plate-find-replace': patch +--- + +fix: FindReplacePlugin supports matching consecutive text nodes diff --git a/packages/find-replace/src/lib/__tests__/decorateSearchHighlight/search/empty.spec.ts b/packages/find-replace/src/lib/__tests__/decorateSearchHighlight/search/empty.spec.ts index 97cea9079a..6c2a2b9c2d 100644 --- a/packages/find-replace/src/lib/__tests__/decorateSearchHighlight/search/empty.spec.ts +++ b/packages/find-replace/src/lib/__tests__/decorateSearchHighlight/search/empty.spec.ts @@ -16,7 +16,7 @@ it('should be', () => { expect( decorateFindReplace({ ...getEditorPlugin(editor, FindReplacePlugin), - entry: [{ text: '' }, [0, 0]], + entry: [{ children: [{ text: '' }], type: 'p' }, [0]], }) ).toEqual(output); }); diff --git a/packages/find-replace/src/lib/__tests__/decorateSearchHighlight/search/text.spec.ts b/packages/find-replace/src/lib/__tests__/decorateSearchHighlight/search/text.spec.ts index 4003432bd6..98dc2525f0 100644 --- a/packages/find-replace/src/lib/__tests__/decorateSearchHighlight/search/text.spec.ts +++ b/packages/find-replace/src/lib/__tests__/decorateSearchHighlight/search/text.spec.ts @@ -15,7 +15,7 @@ it('should decorate matching text', () => { expect( plugin.decorate?.({ ...getEditorPlugin(editor, plugin), - entry: [{ text: 'test' }, [0, 0]], + entry: [{ children: [{ text: 'test' }], type: 'p' }, [0]], }) ).toEqual([ { @@ -45,7 +45,7 @@ it('should decorate matching text case-insensitively', () => { expect( plugin.decorate?.({ ...getEditorPlugin(editor, plugin), - entry: [{ text: 'test' }, [0, 0]], + entry: [{ children: [{ text: 'test' }], type: 'p' }, [0]], }) ).toEqual([ { @@ -62,3 +62,136 @@ it('should decorate matching text case-insensitively', () => { }, ]); }); + +it('should decorate matching consecutive text nodes', () => { + const editor = createSlateEditor({ + plugins: [FindReplacePlugin], + }); + + const plugin = editor.getPlugin(FindReplacePlugin); + + editor.setOption(FindReplacePlugin, 'search', 'test'); + + expect( + plugin.decorate?.({ + ...getEditorPlugin(editor, plugin), + entry: [ + { children: [{ text: 'tes' }, { bold: true, text: 't' }], type: 'p' }, + [0], + ], + }) + ).toEqual([ + { + [FindReplacePlugin.key]: true, + anchor: { + offset: 0, + path: [0, 0], + }, + focus: { + offset: 3, + path: [0, 0], + }, + search: 'tes', + }, + { + [FindReplacePlugin.key]: true, + anchor: { + offset: 0, + path: [0, 1], + }, + focus: { + offset: 1, + path: [0, 1], + }, + search: 't', + }, + ]); +}); + +it('should decorate matching multiple occurrences', () => { + const editor = createSlateEditor({ + plugins: [FindReplacePlugin], + }); + + const plugin = editor.getPlugin(FindReplacePlugin); + + editor.setOption(FindReplacePlugin, 'search', 'test'); + + expect( + plugin.decorate?.({ + ...getEditorPlugin(editor, plugin), + entry: [ + { + children: [ + { text: 'tes' }, + { bold: true, text: 'ts and tests and t' }, + { text: 'ests' }, + ], + type: 'p', + }, + [0], + ], + }) + ).toEqual([ + { + [FindReplacePlugin.key]: true, + anchor: { + offset: 0, + path: [0, 0], + }, + focus: { + offset: 3, + path: [0, 0], + }, + search: 'tes', + }, + { + [FindReplacePlugin.key]: true, + anchor: { + offset: 0, + path: [0, 1], + }, + focus: { + offset: 1, + path: [0, 1], + }, + search: 't', + }, + { + [FindReplacePlugin.key]: true, + anchor: { + offset: 7, + path: [0, 1], + }, + focus: { + offset: 11, + path: [0, 1], + }, + search: 'test', + }, + { + [FindReplacePlugin.key]: true, + anchor: { + offset: 17, + path: [0, 1], + }, + focus: { + offset: 18, + path: [0, 1], + }, + search: 't', + }, + { + [FindReplacePlugin.key]: true, + anchor: { + offset: 0, + path: [0, 2], + }, + focus: { + offset: 3, + path: [0, 2], + }, + search: 'est', + }, + ]); +}); diff --git a/packages/find-replace/src/lib/decorateFindReplace.ts b/packages/find-replace/src/lib/decorateFindReplace.ts index 4970078e31..2b7a640a2b 100644 --- a/packages/find-replace/src/lib/decorateFindReplace.ts +++ b/packages/find-replace/src/lib/decorateFindReplace.ts @@ -1,7 +1,7 @@ import type { Decorate } from '@udecode/plate-common'; import type { Range } from 'slate'; -import { isText } from '@udecode/plate-common'; +import { isElement, isText } from '@udecode/plate-common'; import type { FindReplaceConfig } from './FindReplacePlugin'; @@ -12,27 +12,84 @@ export const decorateFindReplace: Decorate = ({ }) => { const { search } = getOptions(); - const ranges: SearchRange[] = []; + if (!(search && isElement(node) && node.children.every(isText))) { + return []; + } + + const texts = node.children.map((it) => it.text); + const str = texts.join('').toLowerCase(); + const searchLower = search.toLowerCase(); - if (!search || !isText(node)) { - return ranges; + let start = 0; + const matches: number[] = []; + + while ((start = str.indexOf(searchLower, start)) !== -1) { + matches.push(start); + start += searchLower.length; } - const { text } = node; - const parts = text.toLowerCase().split(search.toLowerCase()); - let offset = 0; - parts.forEach((part, i) => { - if (i !== 0) { - ranges.push({ - anchor: { offset: offset - search.length, path }, - focus: { offset, path }, - search, - [type]: true, - }); + if (matches.length === 0) { + return []; + } + + const ranges: SearchRange[] = []; + let cumulativePosition = 0; + let matchIndex = 0; // Index in the matches array + + for (const [textIndex, text] of texts.entries()) { + const textStart = cumulativePosition; + const textEnd = textStart + text.length; + + // Process matches that overlap with the current text node + while (matchIndex < matches.length && matches[matchIndex] < textEnd) { + const matchStart = matches[matchIndex]; + const matchEnd = matchStart + search.length; + + // If the match ends before the start of the current text, move to the next match + if (matchEnd <= textStart) { + matchIndex++; + + continue; + } + + // Calculate overlap between the text and the current match + const overlapStart = Math.max(matchStart, textStart); + const overlapEnd = Math.min(matchEnd, textEnd); + + if (overlapStart < overlapEnd) { + const anchorOffset = overlapStart - textStart; + const focusOffset = overlapEnd - textStart; + + // Corresponding offsets within the search string + const searchOverlapStart = overlapStart - matchStart; + const searchOverlapEnd = overlapEnd - matchStart; + + const textNodePath = [...path, textIndex]; + + ranges.push({ + anchor: { + offset: anchorOffset, + path: textNodePath, + }, + focus: { + offset: focusOffset, + path: textNodePath, + }, + search: search.slice(searchOverlapStart, searchOverlapEnd), + [type]: true, + }); + } + // If the match ends within the current text, move to the next match + if (matchEnd <= textEnd) { + matchIndex++; + } else { + // The match continues in the next text node + break; + } } - offset = offset + part.length + search.length; - }); + cumulativePosition = textEnd; + } return ranges; };