diff --git a/packages/app-desktop/plugins/GotoAnything.tsx b/packages/app-desktop/plugins/GotoAnything.tsx index 7ee3f7f32..68bf2a842 100644 --- a/packages/app-desktop/plugins/GotoAnything.tsx +++ b/packages/app-desktop/plugins/GotoAnything.tsx @@ -2,11 +2,10 @@ import * as React from 'react'; import { AppState } from '../app.reducer'; import CommandService, { SearchResult as CommandSearchResult } from '@joplin/lib/services/CommandService'; import KeymapService from '@joplin/lib/services/KeymapService'; -import shim from '@joplin/lib/shim'; const { connect } = require('react-redux'); import { _ } from '@joplin/lib/locale'; import { themeStyle } from '@joplin/lib/theme'; -import SearchEngine from '@joplin/lib/services/search/SearchEngine'; +import SearchEngine, { ComplexTerm } from '@joplin/lib/services/search/SearchEngine'; import gotoAnythingStyleQuery from '@joplin/lib/services/search/gotoAnythingStyleQuery'; import BaseModel, { ModelType } from '@joplin/lib/BaseModel'; import Tag from '@joplin/lib/models/Tag'; @@ -14,7 +13,7 @@ import Folder from '@joplin/lib/models/Folder'; import Note from '@joplin/lib/models/Note'; import ItemList from '../gui/ItemList'; import HelpButton from '../gui/HelpButton'; -const { surroundKeywords, nextWhitespaceIndex, removeDiacritics } = require('@joplin/lib/string-utils.js'); +import { surroundKeywords, nextWhitespaceIndex, removeDiacritics } from '@joplin/lib/string-utils'; import { mergeOverlappingIntervals } from '@joplin/lib/ArrayUtils'; import markupLanguageUtils from '../utils/markupLanguageUtils'; import focusEditorIfEditorCommand from '@joplin/lib/services/commands/focusEditorIfEditorCommand'; @@ -23,6 +22,7 @@ import { MarkupLanguage, MarkupToHtml } from '@joplin/renderer'; import Resource from '@joplin/lib/models/Resource'; import { NoteEntity, ResourceEntity } from '@joplin/lib/services/database/types'; import Dialog from '../gui/Dialog'; +import AsyncActionQueue from '@joplin/lib/AsyncActionQueue'; const logger = Logger.create('GotoAnything'); @@ -129,8 +129,7 @@ class DialogComponent extends React.PureComponent { private inputRef: any; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied private itemListRef: any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - private listUpdateIID_: any; + private listUpdateQueue_: AsyncActionQueue; private markupToHtml_: MarkupToHtml; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied private userCallback_: any = null; @@ -141,6 +140,7 @@ class DialogComponent extends React.PureComponent { const startString = props?.userData?.startString ? props?.userData?.startString : ''; this.userCallback_ = props?.userData?.callback; + this.listUpdateQueue_ = new AsyncActionQueue(100); this.state = { query: startString, @@ -235,7 +235,7 @@ class DialogComponent extends React.PureComponent { } public componentWillUnmount() { - if (this.listUpdateIID_) shim.clearTimeout(this.listUpdateIID_); + void this.listUpdateQueue_.reset(); this.props.dispatch({ type: 'VISIBLE_DIALOGS_REMOVE', @@ -263,12 +263,7 @@ class DialogComponent extends React.PureComponent { } public scheduleListUpdate() { - if (this.listUpdateIID_) shim.clearTimeout(this.listUpdateIID_); - - this.listUpdateIID_ = shim.setTimeout(async () => { - await this.updateList(); - this.listUpdateIID_ = null; - }, 100); + this.listUpdateQueue_.push(() => this.updateList()); } public async keywords(searchQuery: string) { @@ -360,7 +355,6 @@ class DialogComponent extends React.PureComponent { } } else { const limit = 20; - const searchKeywords = await this.keywords(searchQuery); // Note: any filtering must be done **before** fetching the notes, because we're // going to apply a limit to the number of fetched notes. @@ -381,6 +375,10 @@ class DialogComponent extends React.PureComponent { results = results.filter(r => !!notesById[r.id]) .map(r => ({ ...r, title: notesById[r.id].title })); + const normalizedKeywords = (await this.keywords(searchQuery)).map( + ({ valueRegex }: ComplexTerm) => new RegExp(removeDiacritics(valueRegex), 'ig'), + ); + for (let i = 0; i < results.length; i++) { const row = results[i]; const path = Folder.folderPathString(this.props.folders, row.parent_id); @@ -388,21 +386,14 @@ class DialogComponent extends React.PureComponent { if (row.fields.includes('body')) { let fragments = '...'; - if (i < limit) { // Display note fragments of search keyword matches - const { markupLanguage, content } = getContentMarkupLanguageAndBody( - row, - notesById, - resources, - ); - + const loadFragments = (markupLanguage: MarkupLanguage, content: string) => { const indices = []; const body = this.markupToHtml().stripMarkup(markupLanguage, content, { collapseWhiteSpaces: true }); + const normalizedBody = removeDiacritics(body); // Iterate over all matches in the body for each search keyword - for (let { valueRegex } of searchKeywords) { - valueRegex = removeDiacritics(valueRegex); - - for (const match of removeDiacritics(body).matchAll(new RegExp(valueRegex, 'ig'))) { + for (const keywordRegex of normalizedKeywords) { + for (const match of normalizedBody.matchAll(keywordRegex)) { // Populate 'indices' with [begin index, end index] of each note fragment // Begins at the regex matching index, ends at the next whitespace after seeking 15 characters to the right indices.push([match.index, nextWhitespaceIndex(body, match.index + match[0].length + 15)]); @@ -418,6 +409,19 @@ class DialogComponent extends React.PureComponent { fragments = mergedIndices.map((f: any) => body.slice(f[0], f[1])).join(' ... '); // Add trailing ellipsis if the final fragment doesn't end where the note is ending if (mergedIndices.length && mergedIndices[mergedIndices.length - 1][1] !== body.length) fragments += ' ...'; + }; + + if (i < limit) { // Display note fragments of search keyword matches + const { markupLanguage, content } = getContentMarkupLanguageAndBody( + row, + notesById, + resources, + ); + + // Don't load fragments for long notes -- doing so can lead to UI freezes. + if (content.length < 100_000) { + loadFragments(markupLanguage, content); + } } results[i] = { ...row, path, fragments }; diff --git a/packages/lib/services/search/SearchEngine.ts b/packages/lib/services/search/SearchEngine.ts index 43225a531..8f762dbd5 100644 --- a/packages/lib/services/search/SearchEngine.ts +++ b/packages/lib/services/search/SearchEngine.ts @@ -16,7 +16,7 @@ import { isCallbackUrl, parseCallbackUrl } from '../../callbackUrlUtils'; import replaceUnsupportedCharacters from '../../utils/replaceUnsupportedCharacters'; import { htmlentitiesDecode } from '@joplin/utils/html'; const { sprintf } = require('sprintf-js'); -const { pregQuote, scriptType, removeDiacritics } = require('../../string-utils.js'); +import { pregQuote, scriptType, removeDiacritics } from '../../string-utils'; enum SearchType { Auto = 'auto', @@ -59,7 +59,7 @@ export interface ComplexTerm { value: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied scriptType: any; - valueRegex?: RegExp; + valueRegex?: string; } export interface Terms { diff --git a/packages/lib/string-utils.ts b/packages/lib/string-utils.ts index 419d6c30d..05d2a3ad1 100644 --- a/packages/lib/string-utils.ts +++ b/packages/lib/string-utils.ts @@ -2,7 +2,7 @@ const Entities = require('html-entities').AllHtmlEntities; const htmlentities = new Entities().encode; const stringUtilsCommon = require('./string-utils-common.js'); -export const pregQuote = stringUtilsCommon.pregQuote; +export const pregQuote = stringUtilsCommon.pregQuote as (str: string, delimiter?: string)=> string; export const replaceRegexDiacritics = stringUtilsCommon.replaceRegexDiacritics; const defaultDiacriticsRemovalMap = [