Skip to content

Commit 7199ed1

Browse files
authored
Merge pull request #3815 from yf-yang/3813
fix: #3813
2 parents a12a277 + 6c10766 commit 7199ed1

File tree

4 files changed

+215
-20
lines changed

4 files changed

+215
-20
lines changed

.changeset/fresh-carrots-smile.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@udecode/plate-find-replace': patch
3+
---
4+
5+
fix: FindReplacePlugin supports matching consecutive text nodes

packages/find-replace/src/lib/__tests__/decorateSearchHighlight/search/empty.spec.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ it('should be', () => {
1616
expect(
1717
decorateFindReplace({
1818
...getEditorPlugin(editor, FindReplacePlugin),
19-
entry: [{ text: '' }, [0, 0]],
19+
entry: [{ children: [{ text: '' }], type: 'p' }, [0]],
2020
})
2121
).toEqual(output);
2222
});

packages/find-replace/src/lib/__tests__/decorateSearchHighlight/search/text.spec.ts

+135-2
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ it('should decorate matching text', () => {
1515
expect(
1616
plugin.decorate?.({
1717
...getEditorPlugin(editor, plugin),
18-
entry: [{ text: 'test' }, [0, 0]],
18+
entry: [{ children: [{ text: 'test' }], type: 'p' }, [0]],
1919
})
2020
).toEqual([
2121
{
@@ -45,7 +45,7 @@ it('should decorate matching text case-insensitively', () => {
4545
expect(
4646
plugin.decorate?.({
4747
...getEditorPlugin(editor, plugin),
48-
entry: [{ text: 'test' }, [0, 0]],
48+
entry: [{ children: [{ text: 'test' }], type: 'p' }, [0]],
4949
})
5050
).toEqual([
5151
{
@@ -62,3 +62,136 @@ it('should decorate matching text case-insensitively', () => {
6262
},
6363
]);
6464
});
65+
66+
it('should decorate matching consecutive text nodes', () => {
67+
const editor = createSlateEditor({
68+
plugins: [FindReplacePlugin],
69+
});
70+
71+
const plugin = editor.getPlugin(FindReplacePlugin);
72+
73+
editor.setOption(FindReplacePlugin, 'search', 'test');
74+
75+
expect(
76+
plugin.decorate?.({
77+
...getEditorPlugin(editor, plugin),
78+
entry: [
79+
{ children: [{ text: 'tes' }, { bold: true, text: 't' }], type: 'p' },
80+
[0],
81+
],
82+
})
83+
).toEqual([
84+
{
85+
[FindReplacePlugin.key]: true,
86+
anchor: {
87+
offset: 0,
88+
path: [0, 0],
89+
},
90+
focus: {
91+
offset: 3,
92+
path: [0, 0],
93+
},
94+
search: 'tes',
95+
},
96+
{
97+
[FindReplacePlugin.key]: true,
98+
anchor: {
99+
offset: 0,
100+
path: [0, 1],
101+
},
102+
focus: {
103+
offset: 1,
104+
path: [0, 1],
105+
},
106+
search: 't',
107+
},
108+
]);
109+
});
110+
111+
it('should decorate matching multiple occurrences', () => {
112+
const editor = createSlateEditor({
113+
plugins: [FindReplacePlugin],
114+
});
115+
116+
const plugin = editor.getPlugin(FindReplacePlugin);
117+
118+
editor.setOption(FindReplacePlugin, 'search', 'test');
119+
120+
expect(
121+
plugin.decorate?.({
122+
...getEditorPlugin(editor, plugin),
123+
entry: [
124+
{
125+
children: [
126+
{ text: 'tes' },
127+
{ bold: true, text: 'ts and tests and t' },
128+
{ text: 'ests' },
129+
],
130+
type: 'p',
131+
},
132+
[0],
133+
],
134+
})
135+
).toEqual([
136+
{
137+
[FindReplacePlugin.key]: true,
138+
anchor: {
139+
offset: 0,
140+
path: [0, 0],
141+
},
142+
focus: {
143+
offset: 3,
144+
path: [0, 0],
145+
},
146+
search: 'tes',
147+
},
148+
{
149+
[FindReplacePlugin.key]: true,
150+
anchor: {
151+
offset: 0,
152+
path: [0, 1],
153+
},
154+
focus: {
155+
offset: 1,
156+
path: [0, 1],
157+
},
158+
search: 't',
159+
},
160+
{
161+
[FindReplacePlugin.key]: true,
162+
anchor: {
163+
offset: 7,
164+
path: [0, 1],
165+
},
166+
focus: {
167+
offset: 11,
168+
path: [0, 1],
169+
},
170+
search: 'test',
171+
},
172+
{
173+
[FindReplacePlugin.key]: true,
174+
anchor: {
175+
offset: 17,
176+
path: [0, 1],
177+
},
178+
focus: {
179+
offset: 18,
180+
path: [0, 1],
181+
},
182+
search: 't',
183+
},
184+
{
185+
[FindReplacePlugin.key]: true,
186+
anchor: {
187+
offset: 0,
188+
path: [0, 2],
189+
},
190+
focus: {
191+
offset: 3,
192+
path: [0, 2],
193+
},
194+
search: 'est',
195+
},
196+
]);
197+
});

packages/find-replace/src/lib/decorateFindReplace.ts

+74-17
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Decorate } from '@udecode/plate-common';
22
import type { Range } from 'slate';
33

4-
import { isText } from '@udecode/plate-common';
4+
import { isElement, isText } from '@udecode/plate-common';
55

66
import type { FindReplaceConfig } from './FindReplacePlugin';
77

@@ -12,27 +12,84 @@ export const decorateFindReplace: Decorate<FindReplaceConfig> = ({
1212
}) => {
1313
const { search } = getOptions();
1414

15-
const ranges: SearchRange[] = [];
15+
if (!(search && isElement(node) && node.children.every(isText))) {
16+
return [];
17+
}
18+
19+
const texts = node.children.map((it) => it.text);
20+
const str = texts.join('').toLowerCase();
21+
const searchLower = search.toLowerCase();
1622

17-
if (!search || !isText(node)) {
18-
return ranges;
23+
let start = 0;
24+
const matches: number[] = [];
25+
26+
while ((start = str.indexOf(searchLower, start)) !== -1) {
27+
matches.push(start);
28+
start += searchLower.length;
1929
}
2030

21-
const { text } = node;
22-
const parts = text.toLowerCase().split(search.toLowerCase());
23-
let offset = 0;
24-
parts.forEach((part, i) => {
25-
if (i !== 0) {
26-
ranges.push({
27-
anchor: { offset: offset - search.length, path },
28-
focus: { offset, path },
29-
search,
30-
[type]: true,
31-
});
31+
if (matches.length === 0) {
32+
return [];
33+
}
34+
35+
const ranges: SearchRange[] = [];
36+
let cumulativePosition = 0;
37+
let matchIndex = 0; // Index in the matches array
38+
39+
for (const [textIndex, text] of texts.entries()) {
40+
const textStart = cumulativePosition;
41+
const textEnd = textStart + text.length;
42+
43+
// Process matches that overlap with the current text node
44+
while (matchIndex < matches.length && matches[matchIndex] < textEnd) {
45+
const matchStart = matches[matchIndex];
46+
const matchEnd = matchStart + search.length;
47+
48+
// If the match ends before the start of the current text, move to the next match
49+
if (matchEnd <= textStart) {
50+
matchIndex++;
51+
52+
continue;
53+
}
54+
55+
// Calculate overlap between the text and the current match
56+
const overlapStart = Math.max(matchStart, textStart);
57+
const overlapEnd = Math.min(matchEnd, textEnd);
58+
59+
if (overlapStart < overlapEnd) {
60+
const anchorOffset = overlapStart - textStart;
61+
const focusOffset = overlapEnd - textStart;
62+
63+
// Corresponding offsets within the search string
64+
const searchOverlapStart = overlapStart - matchStart;
65+
const searchOverlapEnd = overlapEnd - matchStart;
66+
67+
const textNodePath = [...path, textIndex];
68+
69+
ranges.push({
70+
anchor: {
71+
offset: anchorOffset,
72+
path: textNodePath,
73+
},
74+
focus: {
75+
offset: focusOffset,
76+
path: textNodePath,
77+
},
78+
search: search.slice(searchOverlapStart, searchOverlapEnd),
79+
[type]: true,
80+
});
81+
}
82+
// If the match ends within the current text, move to the next match
83+
if (matchEnd <= textEnd) {
84+
matchIndex++;
85+
} else {
86+
// The match continues in the next text node
87+
break;
88+
}
3289
}
3390

34-
offset = offset + part.length + search.length;
35-
});
91+
cumulativePosition = textEnd;
92+
}
3693

3794
return ranges;
3895
};

0 commit comments

Comments
 (0)