Skip to content

Commit c5fbfd9

Browse files
authored
Improve search performance (#68)
Use debounce for searching Only render decorations within view range
1 parent 41ef630 commit c5fbfd9

2 files changed

Lines changed: 166 additions & 33 deletions

File tree

src/core/plugins/search.js

Lines changed: 162 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,20 @@ class Search {
1717
this.results = [];
1818
this.selectedResultIndex = 0;
1919
this.triggerUpdate = true;
20+
this.debounceTimer = null;
21+
this.scrollTimer = null;
22+
// Debounce delays for search updates in ms
23+
this.updateDebounceDelay = 300;
24+
this.selectionDebounceDelay = 10;
25+
// Debounce delay for scroll updates in ms
26+
this.scrollDebounceDelay = 100;
27+
// Height buffer for range of decorations beyond viewport in px
28+
this.decorationBuffer = 500;
29+
// Maximum number of decorations to render at once
30+
this.maxDecorations = 500;
31+
this.handleScroll = this._handleScroll.bind(this);
32+
this.scrollListenerAttached = false;
33+
this.scrollContainer = null;
2034
}
2135

2236
focusSelectedResult() {
@@ -76,7 +90,7 @@ class Search {
7690
let result = this.results[this.selectedResultIndex];
7791
tr.setSelection(TextSelection.between(state.doc.resolve(result.from), state.doc.resolve(result.from)));
7892

79-
this.triggerUpdate = true;
93+
this.triggerDecorations = true;
8094
dispatch(tr);
8195

8296
this.focusSelectedResult();
@@ -96,7 +110,7 @@ class Search {
96110
let result = this.results[this.selectedResultIndex];
97111
tr.setSelection(TextSelection.between(state.doc.resolve(result.from), state.doc.resolve(result.from)));
98112

99-
this.triggerUpdate = true;
113+
this.triggerDecorations = true;
100114
dispatch(tr);
101115
this.focusSelectedResult();
102116
}
@@ -122,12 +136,16 @@ class Search {
122136
if (node.isText) {
123137
let chars = removeDiacritics(node.text);
124138
if (mergedTextNodes[index]) {
125-
let shift = [...new Set(mergedTextNodes[index].chars.map(x => x[0]))].length;
126-
chars = chars.map(x => [x[0] + shift, x[1]]);
127-
mergedTextNodes[index].chars = [...mergedTextNodes[index].chars, ...chars];
139+
let currentTextNode = mergedTextNodes[index];
140+
let shift = currentTextNode.textLength;
141+
for (let i = 0; i < chars.length; i++) {
142+
chars[i][0] += shift;
143+
currentTextNode.chars.push(chars[i]);
144+
}
145+
currentTextNode.textLength += node.text.length;
128146
}
129147
else {
130-
mergedTextNodes[index] = { chars, pos };
148+
mergedTextNodes[index] = { chars, pos, textLength: node.text.length };
131149
}
132150
}
133151
else {
@@ -207,39 +225,144 @@ class Search {
207225
if (tr.docChanged) {
208226
this.triggerUpdate = true;
209227
}
228+
else if (tr.selectionSet && this.results.length) {
229+
// Update selectedResultIndex based on current selection
230+
let pos = tr.selection.from;
231+
let index = this.results.findIndex(r => r.from <= pos && r.to >= pos);
232+
if (index === -1) {
233+
index = this.results.findIndex(r => r.from > pos);
234+
if (index === -1) index = 0;
235+
}
236+
if (index !== this.selectedResultIndex) {
237+
this.selectedResultIndex = index;
238+
this.triggerDecorations = true;
239+
}
240+
}
210241
}
211242
else {
212243
this.decorations = DecorationSet.empty;
213244
}
214245
}
215246

216247
updateView() {
217-
if (this.triggerUpdate) {
218-
this.triggerUpdate = false;
219-
220-
let { state, dispatch } = this.view;
221-
let { tr } = state;
222-
223-
this.search(tr.doc);
224-
225-
if (this.triggerFocus && this.results.length) {
226-
this.triggerFocus = false;
227-
let pos = state.selection.from;
228-
let index = this.results.findIndex(x => x.from >= pos);
229-
this.selectedResultIndex = index === -1 ? 0 : index;
230-
let result = this.results[this.selectedResultIndex];
231-
tr.setSelection(TextSelection.between(state.doc.resolve(result.from), state.doc.resolve(result.from)));
232-
this.focusSelectedResult();
248+
if (!this.active || !this.searchTerm) {
249+
// If search is not active, clear decorations
250+
if (!this.active && this.decorations !== DecorationSet.empty) {
251+
this.decorations = DecorationSet.empty;
252+
let { state, dispatch } = this.view;
253+
dispatch(state.tr);
254+
}
255+
return;
256+
}
257+
258+
if (this.debounceTimer) {
259+
clearTimeout(this.debounceTimer);
260+
}
261+
this.debounceTimer = setTimeout(() => {
262+
if (this.triggerUpdate) {
263+
this.triggerUpdate = false;
264+
this.triggerDecorations = false;
265+
266+
let { state, dispatch } = this.view;
267+
let { tr } = state;
268+
269+
this.search(tr.doc);
270+
271+
if (this.triggerFocus && this.results.length) {
272+
this.triggerFocus = false;
273+
let pos = state.selection.from;
274+
let index = this.results.findIndex(x => x.from >= pos);
275+
this.selectedResultIndex = index === -1 ? 0 : index;
276+
let result = this.results[this.selectedResultIndex];
277+
tr.setSelection(TextSelection.between(state.doc.resolve(result.from), state.doc.resolve(result.from)));
278+
this.focusSelectedResult();
279+
}
280+
281+
this.updateDecorations(tr.doc);
282+
283+
dispatch(tr.setMeta('addToHistory', false));
284+
}
285+
else if (this.triggerDecorations) {
286+
this.triggerDecorations = false;
287+
this.updateDecorations(this.view.state.doc);
288+
this.view.dispatch(this.view.state.tr.setMeta('addToHistory', false));
289+
}
290+
}, this.triggerUpdate ? this.updateDebounceDelay : this.selectionDebounceDelay);
291+
}
292+
293+
updateScrollListener() {
294+
if (this.active && !this.scrollListenerAttached) {
295+
let container = this.view.dom.closest('.editor-core') || this.view.dom.parentElement;
296+
if (container) {
297+
container.addEventListener('scroll', this._handleScroll);
298+
this.scrollListenerAttached = true;
299+
this.scrollContainer = container;
233300
}
234-
let list = this.results.map((deco, index) => (
235-
Decoration.inline(deco.from, deco.to, {
236-
class: index === this.selectedResultIndex
237-
? this.findSelectedClass : this.findClass
238-
})
239-
));
240-
this.decorations = DecorationSet.create(tr.doc, list);
241-
dispatch(tr);
242301
}
302+
else if (!this.active && this.scrollListenerAttached) {
303+
if (this.scrollContainer) {
304+
this.scrollContainer.removeEventListener('scroll', this._handleScroll);
305+
}
306+
this.scrollListenerAttached = false;
307+
this.scrollContainer = null;
308+
}
309+
}
310+
311+
_handleScroll = () => {
312+
if (!this.active || !this.results.length) return;
313+
314+
if (this.scrollTimer) clearTimeout(this.scrollTimer);
315+
this.scrollTimer = setTimeout(() => {
316+
this.updateDecorations(this.view.state.doc);
317+
this.view.dispatch(this.view.state.tr.setMeta('addToHistory', false));
318+
}, this.scrollDebounceDelay);
319+
};
320+
321+
updateDecorations(doc) {
322+
let { view } = this;
323+
if (!view) return;
324+
325+
let visibleFrom = 0;
326+
let visibleTo = doc.content.size;
327+
328+
let container = view.dom.closest('.editor-core') || view.dom.parentElement;
329+
if (container) {
330+
let rect = container.getBoundingClientRect();
331+
let editorRect = view.dom.getBoundingClientRect();
332+
let left = editorRect.left + (editorRect.width / 2);
333+
334+
// Extend decoration range beyond viewport for smoother scrolling
335+
let startObj = view.posAtCoords({ left, top: rect.top - this.decorationBuffer });
336+
let endObj = view.posAtCoords({ left, top: rect.bottom + this.decorationBuffer });
337+
338+
if (startObj) visibleFrom = startObj.pos;
339+
if (endObj) visibleTo = endObj.pos;
340+
}
341+
342+
let list = [];
343+
344+
// Always render selected result
345+
if (this.results[this.selectedResultIndex]) {
346+
let res = this.results[this.selectedResultIndex];
347+
list.push(Decoration.inline(res.from, res.to, { class: this.findSelectedClass }));
348+
}
349+
350+
// Render decorations within visible range
351+
let startIndex = this.results.findIndex(r => r.to >= visibleFrom);
352+
if (startIndex === -1) startIndex = this.results.length;
353+
354+
for (let i = startIndex; i < this.results.length; i++) {
355+
let res = this.results[i];
356+
if (res.from > visibleTo) break;
357+
358+
if (i === this.selectedResultIndex) continue;
359+
360+
list.push(Decoration.inline(res.from, res.to, { class: this.findClass }));
361+
362+
if (list.length > this.maxDecorations) break;
363+
}
364+
365+
this.decorations = DecorationSet.create(doc, list);
243366
}
244367
}
245368

@@ -260,9 +383,18 @@ export function search() {
260383
view: (view) => {
261384
let pluginState = searchKey.getState(view.state);
262385
pluginState.view = view;
386+
pluginState.updateScrollListener();
387+
263388
return {
264389
update(view, lastState) {
390+
let pluginState = searchKey.getState(view.state);
265391
pluginState.updateView(view.state, lastState);
392+
pluginState.updateScrollListener();
393+
},
394+
destroy() {
395+
if (pluginState.scrollListenerAttached && pluginState.scrollContainer) {
396+
pluginState.scrollContainer.removeEventListener('scroll', pluginState.onScroll);
397+
}
266398
}
267399
};
268400
},

src/core/utils.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -328,8 +328,7 @@ export class SetAttrsStep extends Step {
328328
*
329329
* From http://lehelk.com/2011/05/06/script-to-remove-diacritics/
330330
*/
331-
export function removeDiacritics(str) {
332-
let map = {
331+
const DIACRITICS_MAP = {
333332
'A':'A','Ⓐ':'A','A':'A','À':'A','Á':'A','Â':'A','Ầ':'A','Ấ':'A','Ẫ':'A','Ẩ':'A','Ã':'A','Ā':'A','Ă':'A',
334333
'Ằ':'A','Ắ':'A','Ẵ':'A','Ẳ':'A','Ȧ':'A','Ǡ':'A','Ä':'A','Ǟ':'A','Ả':'A','Å':'A','Ǻ':'A','Ǎ':'A','Ȁ':'A',
335334
'Ȃ':'A','Ạ':'A','Ậ':'A','Ặ':'A','Ḁ':'A','Ą':'A','Ⱥ':'A','Ɐ':'A','Ꜳ':'AA','Æ':'AE','Ǽ':'AE','Ǣ':'AE',
@@ -397,9 +396,11 @@ export function removeDiacritics(str) {
397396
'ẉ':'w','ⱳ':'w','x':'x','ⓧ':'x','x':'x','ẋ':'x','ẍ':'x','y':'y','ⓨ':'y','y':'y','ỳ':'y','ý':'y','ŷ':'y',
398397
'ỹ':'y','ȳ':'y','ẏ':'y','ÿ':'y','ỷ':'y','ẙ':'y','ỵ':'y','ƴ':'y','ɏ':'y','ỿ':'y','z':'z','ⓩ':'z','z':'z',
399398
'ź':'z','ẑ':'z','ż':'z','ž':'z','ẓ':'z','ẕ':'z','ƶ':'z','ȥ':'z','ɀ':'z','ⱬ':'z','ꝣ':'z'};
399+
400+
export function removeDiacritics(str) {
400401
let chars = [];
401402
for (let i = 0; i < str.length; i++) {
402-
let plain = map[str[i]];
403+
let plain = DIACRITICS_MAP[str[i]];
403404
if (!plain) {
404405
chars.push([i, str[i]]);
405406
}

0 commit comments

Comments
 (0)