diff --git a/.gitignore b/.gitignore index 360bcce61c..a76261e01f 100644 --- a/.gitignore +++ b/.gitignore @@ -303,6 +303,7 @@ packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useContextMenu.js packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useCursorPositioning.js packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useEditDialog.js packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useEditDialogEventListeners.js +packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useHighlightedSearchTerms.js packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useKeyboardRefocusHandler.js packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useLinkTooltips.js packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useScroll.js diff --git a/.ignore.eslint b/.ignore.eslint index 2d87675a39..afecfe85a6 100644 --- a/.ignore.eslint +++ b/.ignore.eslint @@ -328,6 +328,7 @@ packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useContextMenu.js packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useCursorPositioning.js packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useEditDialog.js packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useEditDialogEventListeners.js +packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useHighlightedSearchTerms.js packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useKeyboardRefocusHandler.js packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useLinkTooltips.js packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useScroll.js diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx b/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx index 8ddad668f2..6f3e050cc9 100644 --- a/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx @@ -34,6 +34,7 @@ import Logger from '@joplin/utils/Logger'; import useWebViewApi from './utils/useWebViewApi'; import useLinkTooltips from './utils/useLinkTooltips'; import { blur, focus } from '@joplin/lib/utils/focusHandler'; +import useHighlightedSearchTerms from './utils/useHighlightedSearchTerms'; const md5 = require('md5'); import { clipboard } from 'electron'; const supportedLocales = require('./supportedLocales'); @@ -1202,6 +1203,8 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: Ref) => { }; }, [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 0000000000..f7a40e0601 --- /dev/null +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useHighlightedSearchTerms.ts @@ -0,0 +1,224 @@ +import { HighlightedWord } from '@joplin/lib/reducer'; +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, EditorEvent } from 'tinymce'; + +declare global { + interface Window { + Highlight: typeof Highlight; + Range: typeof Range; + CSS: typeof CSS; + } +} + +const useSearchRegexes = (searchTerms: HighlightedWord[]) => { + return useMemo(() => { + return searchTerms.map((term: HighlightedWord) => { + let text; + if (typeof term === 'string') { + text = term; + } else { + text = term.value; + } + + // Only highlight short matches at word boundaries (roughly matching the note viewer + // behavior) + const regexText = SearchEngine.instance().queryTermToRegex(text, { anchorToWordBoundary: text.length <= 2 }); + return regexText ? new RegExp(regexText, 'ig') : null; + }).filter(regex => !!regex); + }, [searchTerms]); +}; + +const searchHighlightKey = 'jop-search-highlight'; + +const useHighlightStyleSheet = (editor: Editor, themeId: number) => { + useEffect(() => { + if (!editor) { + return () => {}; + } + + const theme: Theme = themeStyle(themeId); + const style = editor.dom.create('style', {}, ` + /* Avoid showing highlights when the user has the find dialog open and + there are TinyMCE-provided search results */ + body:not(:has(span.mce-match-marker)) { + ::highlight(${searchHighlightKey}) { + background-color: ${theme.searchMarkerBackgroundColor}; + color: ${theme.searchMarkerColor}; + } + } + `); + editor.getDoc().head.appendChild(style); + + return () => { + style.remove(); + }; + }, [editor, themeId]); +}; + +const useHighlighter = (editor: Editor, searchRegexes: RegExp[]) => { + return useMemo(() => { + if (!editor) { + return { canHighlight: false }; + } + + const editorWindow = editor.getWin(); + const ranges: Map = new Map(); + let highlight: Highlight = undefined; + + const processNode = (node: Node) => { + ranges.delete(node); + + type FoundChildAndOffset = { node: Node; offset: number }; + const findChildAtTextOffset = (parent: Node, offset: number): FoundChildAndOffset => { + if (offset > parent.textContent.length) return null; + if (parent.nodeName === '#text') { + return { node: parent, offset }; + } + + let start = 0; + for (const child of parent.childNodes) { + if (child.nodeName === '#comment') continue; + + const found = findChildAtTextOffset(child, offset - start); + start += child.textContent.length; + if (found) return found; + } + return null; + }; + + const buildRange = (parent: Node, startIndex: number, endIndex: number) => { + const range: Range = new editorWindow.Range(); + const start = findChildAtTextOffset(parent, startIndex); + const end = findChildAtTextOffset(parent, endIndex); + range.setStart(start?.node ?? parent, start?.offset ?? 0); + range.setEnd(end?.node ?? parent, end?.offset ?? parent.textContent.length); + return range; + }; + + // Process highlights at the paragraph level, where possible, to pick up formatting that crosses Markdown boundaries + const isLeaf = node.nodeName === '#text' || node.nodeName === 'P'; + if (isLeaf) { + const childRanges = []; + for (const term of searchRegexes) { + const matches = node.textContent.matchAll(term); + + for (const match of matches) { + const startIndex = match.index ?? 0; + const endIndex = (match.index ?? 0) + match[0].length; + if (startIndex !== endIndex) { + childRanges.push(buildRange(node, startIndex, endIndex)); + } + } + } + + if (childRanges.length > 0) { + ranges.set(node, childRanges); + } + } else { + for (const child of node.childNodes) { + processNode(child); + } + } + }; + + const closestParagraph = (node: Node) => node.parentElement?.closest('p'); + + const onNodeChanged = (node: Node) => { + highlight?.clear(); + + processNode(closestParagraph(node) ?? node); + + highlight = new editorWindow.Highlight(...[...ranges.values()].flat()); + editorWindow.CSS.highlights.set(searchHighlightKey, highlight); + }; + + const onNodeRemoved = (node: Node, parent: Node) => { + const removeFromRanges = (node: Node) => { + ranges.delete(node); + for (const child of node.childNodes) { + removeFromRanges(child); + } + }; + removeFromRanges(node); + + // Handle the case where removing a node created a match: + onNodeChanged(closestParagraph(parent) ?? parent); + }; + + const clearHighlights = () => { + highlight?.clear(); + editorWindow.CSS.highlights.delete(searchHighlightKey); + ranges.clear(); + }; + + return { onNodeChanged, onNodeRemoved, clearHighlights, canHighlight: searchRegexes.length > 0 }; + }, [editor, searchRegexes]); +}; + +const useHighlightedSearchTerms = (editor: Editor, searchTerms: HighlightedWord[], themeId: number) => { + const searchRegexes = useSearchRegexes(searchTerms); + useHighlightStyleSheet(editor, themeId); + const highlighter = useHighlighter(editor, searchRegexes); + + useEffect(() => { + if (!editor || !highlighter.canHighlight) { + return () => {}; + } + + const editorBody = editor.getBody(); + + type NodeChangeEvent = { element: Element }; + const onNodeChange = ({ element }: EditorEvent) => { + highlighter.onNodeChanged(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) { + highlighter.onNodeChanged(selectedNode); + } + }; + + const onSetContent = () => { + highlighter.onNodeChanged(editorBody); + }; + + editor.on('NodeChange', onNodeChange); + editor.on('SetContent', onSetContent); + + // NodeChange doesn't fire while typing, so we also need keyup + editor.on('keyup', onKeyUp); + + // NodeChange also doesn't fire for certain DOM changes (e.g. when TinyMCE-provided search highlights are added/removed): + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + for (const node of mutation.removedNodes) { + highlighter.onNodeRemoved(node, mutation.target); + } + + for (const node of mutation.addedNodes) { + highlighter.onNodeChanged(node); + } + } + }); + observer.observe(editorBody, { subtree: true, childList: true }); + + onSetContent(); + + return () => { + observer.disconnect(); + highlighter.clearHighlights(); + + editor.off('NodeChange', onNodeChange); + editor.off('keyup', onKeyUp); + editor.off('SetContent', onSetContent); + }; + }, [editor, highlighter]); +}; + +export default useHighlightedSearchTerms; diff --git a/packages/app-desktop/integration-tests/models/NoteEditorScreen.ts b/packages/app-desktop/integration-tests/models/NoteEditorScreen.ts index 62633ab138..4ac908bca9 100644 --- a/packages/app-desktop/integration-tests/models/NoteEditorScreen.ts +++ b/packages/app-desktop/integration-tests/models/NoteEditorScreen.ts @@ -54,6 +54,12 @@ export default class NoteEditorScreen { return this.containerLocator.getByRole('button', { name: title }); } + public async switchToRichTextEditor() { + await expect(this.codeMirrorEditor).toBeAttached(); + await this.toggleEditorsButton.click(); + await this.richTextEditor.waitFor(); + } + public async contentLocator() { const richTextBody = this.getRichTextFrameLocator().locator('body'); const markdownEditor = this.codeMirrorEditor; @@ -145,4 +151,16 @@ export default class NoteEditorScreen { await this.toggleEditorLayout(); await expect(this.noteViewerContainer).not.toBeVisible(); } + + public async getRichTextEditorSearchMatches() { + const body = this.getRichTextEditorBody(); + return body.evaluate(() => { + const highlights = CSS.highlights.get('jop-search-highlight') ?? new Set(); + const result = []; + for (const highlight of highlights) { + result.push(highlight.toString()); + } + return result; + }); + } } diff --git a/packages/app-desktop/integration-tests/richTextEditor.spec.ts b/packages/app-desktop/integration-tests/richTextEditor.spec.ts index 6bb02569cc..a54174afd9 100644 --- a/packages/app-desktop/integration-tests/richTextEditor.spec.ts +++ b/packages/app-desktop/integration-tests/richTextEditor.spec.ts @@ -310,5 +310,32 @@ test.describe('richTextEditor', () => { await expect(editorBody).toHaveText('Unsaved text'); }); + test('should highlight search matches', async ({ mainWindow }) => { + const mainScreen = await new MainScreen(mainWindow).setup(); + await mainScreen.createNewNote('Testing'); + + const editor = mainScreen.noteEditor; + await editor.switchToRichTextEditor(); + const editorBody = editor.getRichTextEditorBody(); + await editorBody.pressSequentially('search-match1 note search-match2'); + + // Should highlight initial matches + await mainScreen.search('/search-match'); + await expect.poll( + () => editor.getRichTextEditorSearchMatches(), + ).toEqual(['search-match', 'search-match']); + + // Should highlight new matches + await editorBody.pressSequentially('search-match'); + await expect.poll( + () => editor.getRichTextEditorSearchMatches(), + ).toHaveLength(3); + + // Should stop highlighting old matches + await editorBody.press('Backspace'); + await expect.poll( + () => editor.getRichTextEditorSearchMatches(), + ).toHaveLength(2); + }); }); diff --git a/packages/lib/services/search/SearchEngine.test.ts b/packages/lib/services/search/SearchEngine.test.ts index f3cf5644bd..6f8b46a6e5 100644 --- a/packages/lib/services/search/SearchEngine.test.ts +++ b/packages/lib/services/search/SearchEngine.test.ts @@ -640,4 +640,19 @@ describe('services/SearchEngine', () => { ] as (ComplexTerm | string)[]; expect(engine.createQueryFromTerms(terms)).toBe('hello world test query*'); }); + + test('queryTermToRegex should support anchoring to word boundaries', () => { + const matchAnchoredTerm = (term: string, text: string) => { + const regex = engine.queryTermToRegex(term, { anchorToWordBoundary: true }); + const matches = text.matchAll(new RegExp(regex, 'ig')); + const result = []; + for (const match of matches) { + result.push(match[0]); + } + return result; + }; + + expect(matchAnchoredTerm('test', 'testing')).toEqual([]); + expect(matchAnchoredTerm('test', 'test...')).toEqual(['test']); + }); }); diff --git a/packages/lib/services/search/SearchEngine.ts b/packages/lib/services/search/SearchEngine.ts index 49a61bbb7a..0974f1a311 100644 --- a/packages/lib/services/search/SearchEngine.ts +++ b/packages/lib/services/search/SearchEngine.ts @@ -89,6 +89,10 @@ export interface ParsedQuery { any: boolean; } +interface QueryTermToRegexOptions { + anchorToWordBoundary?: boolean; +} + export default class SearchEngine { public static instance_: SearchEngine = null; @@ -501,7 +505,7 @@ export default class SearchEngine { } // https://stackoverflow.com/a/13818704/561309 - public queryTermToRegex(term: string) { + public queryTermToRegex(term: string, { anchorToWordBoundary }: QueryTermToRegexOptions = {}) { while (term.length && term.indexOf('*') === 0) { term = term.substr(1); } @@ -512,6 +516,12 @@ export default class SearchEngine { // regexString = regexString.substr(0, regexString.length - 2) + '.*?'; } + if (regexString && anchorToWordBoundary) { + // (?