diff --git a/.eslintignore b/.eslintignore index cd2be4a5540..1e56d98615a 100644 --- a/.eslintignore +++ b/.eslintignore @@ -277,6 +277,7 @@ packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/shouldPasteResources. packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/shouldPasteResources.js packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/types.js packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useContextMenu.js +packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useHighlightedSearchTerms.js packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useScroll.js packages/app-desktop/gui/NoteEditor/NoteEditor.js packages/app-desktop/gui/NoteEditor/NoteTitle/NoteTitleBar.js diff --git a/.gitignore b/.gitignore index ca8193cc9c9..2bccc800f43 100644 --- a/.gitignore +++ b/.gitignore @@ -257,6 +257,7 @@ packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/shouldPasteResources. packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/shouldPasteResources.js packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/types.js packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useContextMenu.js +packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useHighlightedSearchTerms.js packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useScroll.js packages/app-desktop/gui/NoteEditor/NoteEditor.js packages/app-desktop/gui/NoteEditor/NoteTitle/NoteTitleBar.js diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx b/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx index 1f178ad6d9d..f4e059dad5f 100644 --- a/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx @@ -30,6 +30,7 @@ import { joplinCommandToTinyMceCommands, TinyMceCommand } from './utils/joplinCo import shouldPasteResources from './utils/shouldPasteResources'; import lightTheme from '@joplin/lib/themes/light'; import { Options as NoteStyleOptions } from '@joplin/renderer/noteStyle'; +import useHighlightedSearchTerms from './utils/useHighlightedSearchTerms'; const md5 = require('md5'); const { clipboard } = require('electron'); const supportedLocales = require('./supportedLocales'); @@ -938,6 +939,8 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => { }; }, [editor, onEditorContentClick]); + useHighlightedSearchTerms(editor, props.searchMarkers.keywords, props.themeId); + // This is to handle dropping notes on the editor. In this case, we add an // overlay over the editor, which makes it a valid drop target. This in // turn makes NoteEditor get the drop event and dispatch it. diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useHighlightedSearchTerms.ts b/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useHighlightedSearchTerms.ts new file mode 100644 index 00000000000..c8d3b73c5d2 --- /dev/null +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useHighlightedSearchTerms.ts @@ -0,0 +1,128 @@ +import SearchEngine from '@joplin/lib/services/search/SearchEngine'; +import { themeStyle } from '@joplin/lib/theme'; +import { Theme } from '@joplin/lib/themes/type'; +import { useEffect, useMemo } from 'react'; +import { Editor } from 'tinymce'; + +// TODO: Remove after upgrading TypeScript. +// NOTE: While Highlight is Set-like, its API may be slightly different. +declare global { + interface Window { + Highlight: any; + Range: any; + CSS: any; + } +} + +const useHighlightedSearchTerms = (editor: Editor, searchTerms: string[], themeId: number) => { + const searchRegexes = useMemo(() => { + return searchTerms.map(term => { + if (typeof term === 'object') { + term = (term as any).value; + } + return new RegExp(SearchEngine.instance().queryTermToRegex(term), 'ig'); + }); + }, [searchTerms]); + + useEffect(() => { + if (!editor) { + return () => {}; + } + + const theme: Theme = themeStyle(themeId); + const style = editor.dom.create('style', {}, ` + ::highlight(jop-search-highlight) { + background-color: ${theme.searchMarkerBackgroundColor}; + color: ${theme.searchMarkerColor}; + } + + /* Try to work around a bug on chrome where misspellings also have the + same color as search markers. */ + ::spelling-error, ::highlight(none) { + color: inherit; + } + `); + editor.getDoc().head.appendChild(style); + + return () => { + style.remove(); + }; + }, [editor, themeId]); + + useEffect(() => { + if (!editor) { + return () => {}; + } + + const editorWindow = editor.getWin(); + const ranges: Map = new Map(); + let highlight: any = undefined; + + const processNode = (node: Node) => { + for (const child of node.childNodes) { + if (child.nodeName === '#text') { + for (const term of searchRegexes) { + const matches = child.textContent.matchAll(term); + const childRanges = []; + + for (const match of matches) { + const range: Range = new editorWindow.Range(); + range.setStart(child, match.index ?? 0); + range.setEnd(child, (match.index ?? 0) + match[0].length); + childRanges.push(range); + } + + ranges.set(child, childRanges); + } + } else { + processNode(child); + } + } + }; + + const rebuildHighlights = (element: Node) => { + highlight?.clear(); + + processNode(element); + + highlight = new editorWindow.Highlight(...[...ranges.values()].flat()); + editorWindow.CSS.highlights.set('jop-search-highlight', highlight); + }; + + const onNodeChange = ({ element }: any) => { + rebuildHighlights(element); + }; + + const onKeyUp = (_event: KeyboardEvent) => { + // Use selectedNode and not event.target -- event.target seems to always point + // to the body. + const selectedNode = editor.selection.getNode(); + if (selectedNode) { + rebuildHighlights(selectedNode); + } + }; + + const onSetContent = () => { + rebuildHighlights(editorWindow.document.body); + }; + + editor.on('NodeChange', onNodeChange); + editor.on('SetContent', onSetContent); + + // NodeChange doesn't fire while typing, so we also need keyup + editor.on('keyup', onKeyUp); + + rebuildHighlights(editorWindow.document.body); + + return () => { + highlight?.clear(); + editorWindow.CSS.highlights.delete('jop-search-highlight'); + + editor.off('NodeChange', onNodeChange); + editor.off('keyup', onKeyUp); + editor.off('SetContent', onSetContent); + }; + }, [searchRegexes, editor]); +}; + +export default useHighlightedSearchTerms; diff --git a/packages/app-desktop/gui/NoteEditor/utils/types.ts b/packages/app-desktop/gui/NoteEditor/utils/types.ts index 5a4029610ad..57299e63a0e 100644 --- a/packages/app-desktop/gui/NoteEditor/utils/types.ts +++ b/packages/app-desktop/gui/NoteEditor/utils/types.ts @@ -6,6 +6,7 @@ import { RenderResult, RenderResultPluginAsset } from '@joplin/renderer/types'; import { MarkupToHtmlOptions } from './useMarkupToHtml'; import { Dispatch } from 'redux'; import { ProcessResultsRow } from '@joplin/lib/services/search/SearchEngine'; +import { SearchMarkers } from './useSearchMarkers'; export interface AllAssetsOptions { contentMaxWidthTarget?: string; @@ -90,7 +91,7 @@ export interface NoteBodyEditorProps { dispatch: Function; noteToolbar: any; setLocalSearchResultCount(count: number): void; - searchMarkers: any; + searchMarkers: SearchMarkers; visiblePanes: string[]; keyboardMode: string; resourceInfos: ResourceInfos; diff --git a/packages/lib/services/search/SearchEngine.ts b/packages/lib/services/search/SearchEngine.ts index 2326d9a4e2e..007c8558bd2 100644 --- a/packages/lib/services/search/SearchEngine.ts +++ b/packages/lib/services/search/SearchEngine.ts @@ -57,7 +57,7 @@ export interface ComplexTerm { type: 'regex' | 'text'; value: string; scriptType: any; - valueRegex?: RegExp; + valueRegex?: string; } export interface Terms { @@ -491,7 +491,7 @@ export default class SearchEngine { } // https://stackoverflow.com/a/13818704/561309 - public queryTermToRegex(term: any) { + public queryTermToRegex(term: any): string { while (term.length && term.indexOf('*') === 0) { term = term.substr(1); }