Skip to content

Commit e4b0948

Browse files
committed
fix: match multiple occurrences
1 parent 3de5eaa commit e4b0948

File tree

3 files changed

+219
-106
lines changed

3 files changed

+219
-106
lines changed

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

+88
Original file line numberDiff line numberDiff line change
@@ -107,3 +107,91 @@ it('should decorate matching consecutive text nodes', () => {
107107
},
108108
]);
109109
});
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+
type: 'p',
126+
children: [
127+
{ text: 'tes' },
128+
{ text: 'ts and tests and t', bold: true },
129+
{ text: 'ests' },
130+
],
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

+61-36
Original file line numberDiff line numberDiff line change
@@ -17,51 +17,76 @@ export const decorateFindReplace: Decorate<FindReplaceConfig> = ({
1717
}
1818

1919
const texts = node.children.map((it) => it.text);
20+
const str = texts.join('').toLowerCase();
21+
const searchLower = search.toLowerCase();
22+
23+
let start = 0;
24+
const matches: number[] = [];
25+
while ((start = str.indexOf(searchLower, start)) !== -1) {
26+
matches.push(start);
27+
start += searchLower.length;
28+
}
2029

21-
// Try to find a match
22-
const matchStart = texts.join('').toLowerCase().indexOf(search.toLowerCase());
23-
if (matchStart === -1) {
30+
if (!matches.length) {
2431
return [];
2532
}
2633

27-
const matchEnd = matchStart + search.length;
28-
let cumulativePosition = 0;
2934
const ranges: SearchRange[] = [];
35+
let cumulativePosition = 0;
36+
let matchIndex = 0; // Index in the matches array
3037

31-
for (const [i, text] of texts.entries()) {
38+
for (const [textIndex, text] of texts.entries()) {
3239
const textStart = cumulativePosition;
33-
const textEnd = cumulativePosition + text.length;
34-
35-
// Corresponding offsets within the text string
36-
const overlapStart = Math.max(matchStart, textStart);
37-
const overlapEnd = Math.min(matchEnd, textEnd);
38-
39-
if (overlapStart < overlapEnd) {
40-
// Overlapping region exists
41-
const anchorOffset = overlapStart - textStart;
42-
const focusOffset = overlapEnd - textStart;
43-
44-
// Corresponding offsets within the search string
45-
const searchOverlapStart = overlapStart - matchStart;
46-
const searchOverlapEnd = overlapEnd - matchStart;
47-
48-
const textNodePath = [...path, i];
49-
50-
ranges.push({
51-
anchor: {
52-
path: textNodePath,
53-
offset: anchorOffset,
54-
},
55-
focus: {
56-
path: textNodePath,
57-
offset: focusOffset,
58-
},
59-
search: search.substring(searchOverlapStart, searchOverlapEnd),
60-
[type]: true,
61-
});
40+
const textEnd = textStart + text.length;
41+
42+
// Process matches that overlap with the current text node
43+
while (matchIndex < matches.length && matches[matchIndex] < textEnd) {
44+
const matchStart = matches[matchIndex];
45+
const matchEnd = matchStart + search.length;
46+
47+
// If the match ends before the start of the current text, move to the next match
48+
if (matchEnd <= textStart) {
49+
matchIndex++;
50+
continue;
51+
}
52+
53+
// Calculate overlap between the text and the current match
54+
const overlapStart = Math.max(matchStart, textStart);
55+
const overlapEnd = Math.min(matchEnd, textEnd);
56+
57+
if (overlapStart < overlapEnd) {
58+
const anchorOffset = overlapStart - textStart;
59+
const focusOffset = overlapEnd - textStart;
60+
61+
// Corresponding offsets within the search string
62+
const searchOverlapStart = overlapStart - matchStart;
63+
const searchOverlapEnd = overlapEnd - matchStart;
64+
65+
const textNodePath = [...path, textIndex];
66+
67+
ranges.push({
68+
anchor: {
69+
path: textNodePath,
70+
offset: anchorOffset,
71+
},
72+
focus: {
73+
path: textNodePath,
74+
offset: focusOffset,
75+
},
76+
search: search.substring(searchOverlapStart, searchOverlapEnd),
77+
[type]: true,
78+
});
79+
}
80+
81+
// If the match ends within the current text, move to the next match
82+
if (matchEnd <= textEnd) {
83+
matchIndex++;
84+
} else {
85+
// The match continues in the next text node
86+
break;
87+
}
6288
}
6389

64-
// Update the cumulative position for the next iteration
6590
cumulativePosition = textEnd;
6691
}
6792

0 commit comments

Comments
 (0)