From b5313067cdc52b5ec43e23d9e4bacd1b2d9587b8 Mon Sep 17 00:00:00 2001 From: Henry Heino <46334387+personalizedrefrigerator@users.noreply.github.com> Date: Thu, 15 Aug 2024 08:01:52 -0700 Subject: [PATCH] Desktop: Allow searching when only the note viewer is visible and sync search with editor (#10866) --- .eslintignore | 2 + .gitignore | 2 + .../utils/useEditorSearchExtension.ts | 23 ++++--- .../utils/useEditorSearchHandler.ts | 37 ++++++----- .../NoteBody/CodeMirror/v5/CodeMirror.tsx | 1 + .../NoteBody/CodeMirror/v6/CodeMirror.tsx | 22 ++++++- .../NoteBody/CodeMirror/v6/Editor.tsx | 22 +++++++ .../app-desktop/gui/NoteEditor/NoteEditor.tsx | 4 ++ .../app-desktop/gui/NoteEditor/utils/types.ts | 6 +- packages/app-desktop/gui/NoteSearchBar.tsx | 17 +++-- .../integration-tests/markdownEditor.spec.ts | 56 ++++++++++++++++ .../models/NoteEditorScreen.ts | 9 ++- .../editor/CodeMirror/CodeMirrorControl.ts | 16 ++++- packages/editor/CodeMirror/createEditor.ts | 64 +++---------------- .../editor/CodeMirror/utils/getSearchState.ts | 17 +++++ .../CodeMirror/utils/searchExtension.ts | 40 ++++++++++++ 16 files changed, 246 insertions(+), 92 deletions(-) create mode 100644 packages/editor/CodeMirror/utils/getSearchState.ts create mode 100644 packages/editor/CodeMirror/utils/searchExtension.ts diff --git a/.eslintignore b/.eslintignore index eda5726a1..2ea63469b 100644 --- a/.eslintignore +++ b/.eslintignore @@ -841,9 +841,11 @@ packages/editor/CodeMirror/utils/formatting/toggleRegionFormatGlobally.test.js packages/editor/CodeMirror/utils/formatting/toggleRegionFormatGlobally.js packages/editor/CodeMirror/utils/formatting/toggleSelectedLinesStartWith.js packages/editor/CodeMirror/utils/formatting/types.js +packages/editor/CodeMirror/utils/getSearchState.js packages/editor/CodeMirror/utils/growSelectionToNode.js packages/editor/CodeMirror/utils/handlePasteEvent.js packages/editor/CodeMirror/utils/isInSyntaxNode.js +packages/editor/CodeMirror/utils/searchExtension.js packages/editor/CodeMirror/utils/setupVim.js packages/editor/SelectionFormatting.js packages/editor/events.js diff --git a/.gitignore b/.gitignore index 70c7e8eb3..0a45f28cf 100644 --- a/.gitignore +++ b/.gitignore @@ -818,9 +818,11 @@ packages/editor/CodeMirror/utils/formatting/toggleRegionFormatGlobally.test.js packages/editor/CodeMirror/utils/formatting/toggleRegionFormatGlobally.js packages/editor/CodeMirror/utils/formatting/toggleSelectedLinesStartWith.js packages/editor/CodeMirror/utils/formatting/types.js +packages/editor/CodeMirror/utils/getSearchState.js packages/editor/CodeMirror/utils/growSelectionToNode.js packages/editor/CodeMirror/utils/handlePasteEvent.js packages/editor/CodeMirror/utils/isInSyntaxNode.js +packages/editor/CodeMirror/utils/searchExtension.js packages/editor/CodeMirror/utils/setupVim.js packages/editor/SelectionFormatting.js packages/editor/events.js diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useEditorSearchExtension.ts b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useEditorSearchExtension.ts index 5bb66a2af..bfb9207f6 100644 --- a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useEditorSearchExtension.ts +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useEditorSearchExtension.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import shim from '@joplin/lib/shim'; import Logger from '@joplin/utils/Logger'; import CodeMirror5Emulation from '@joplin/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation'; @@ -20,16 +20,15 @@ export default function useEditorSearchExtension(CodeMirror: CodeMirror5Emulatio const overlayTimeoutRef = useRef(null); overlayTimeoutRef.current = overlayTimeout; - function clearMarkers() { + const clearMarkers = useCallback(() => { for (let i = 0; i < markers.length; i++) { markers[i].clear(); } setMarkers([]); - } + }, [markers]); - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - function clearOverlay(cm: any) { + const clearOverlay = useCallback((cm: CodeMirror5Emulation) => { if (overlay) cm.removeOverlay(overlay); if (scrollbarMarks) { try { @@ -47,10 +46,10 @@ export default function useEditorSearchExtension(CodeMirror: CodeMirror5Emulatio setOverlay(null); setScrollbarMarks(null); setOverlayTimeout(null); - } + }, [scrollbarMarks, overlay, overlayTimeout]); // Modified from codemirror/addons/search/search.js - function searchOverlay(query: RegExp) { + const searchOverlay = useCallback((query: RegExp) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied return { token: function(stream: any) { query.lastIndex = stream.pos; @@ -65,13 +64,13 @@ export default function useEditorSearchExtension(CodeMirror: CodeMirror5Emulatio } return null; } }; - } + }, []); // Highlights the currently active found work // It's possible to get tricky with this functions and just use findNext/findPrev // but this is fast enough and works more naturally with the current search logic // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - function highlightSearch(cm: any, searchTerm: RegExp, index: number, scrollTo: boolean, withSelection: boolean) { + function highlightSearch(cm: CodeMirror5Emulation, searchTerm: RegExp, index: number, scrollTo: boolean, withSelection: boolean) { const cursor = cm.getSearchCursor(searchTerm); // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied @@ -122,6 +121,12 @@ export default function useEditorSearchExtension(CodeMirror: CodeMirror5Emulatio options = { selectedIndex: 0, searchTimestamp: 0 }; } + if (options.showEditorMarkers === false) { + clearMarkers(); + clearOverlay(this); + return; + } + clearMarkers(); // HIGHLIGHT KEYWORDS diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useEditorSearchHandler.ts b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useEditorSearchHandler.ts index b39c61303..58a86934e 100644 --- a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useEditorSearchHandler.ts +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useEditorSearchHandler.ts @@ -1,6 +1,7 @@ -import { RefObject, useEffect } from 'react'; +import { RefObject, useEffect, useMemo, useRef } from 'react'; import usePrevious from '../../../../hooks/usePrevious'; import { RenderedBody } from './types'; +import { SearchMarkers } from '../../../utils/useSearchMarkers'; const debounce = require('debounce'); interface Props { @@ -14,14 +15,31 @@ interface Props { noteContent: string; renderedBody: RenderedBody; + showEditorMarkers: boolean; } const useEditorSearchHandler = (props: Props) => { - const { webviewRef, editorRef, renderedBody, noteContent, searchMarkers } = props; + const { + webviewRef, editorRef, renderedBody, noteContent, searchMarkers, showEditorMarkers, + } = props; const previousContent = usePrevious(noteContent); const previousRenderedBody = usePrevious(renderedBody); const previousSearchMarkers = usePrevious(searchMarkers); + const showEditorMarkersRef = useRef(showEditorMarkers); + showEditorMarkersRef.current = showEditorMarkers; + + // Fixes https://github.com/laurent22/joplin/issues/7565 + const debouncedMarkers = useMemo(() => debounce((searchMarkers: SearchMarkers) => { + if (!editorRef.current) return; + + if (showEditorMarkersRef.current) { + const matches = editorRef.current.setMarkers(searchMarkers.keywords, searchMarkers.options); + props.setLocalSearchResultCount(matches); + } else { + editorRef.current.setMarkers(searchMarkers.keywords, { ...searchMarkers.options, showEditorMarkers: false }); + } + }, 50), [editorRef, props.setLocalSearchResultCount]); useEffect(() => { if (!searchMarkers) return () => {}; @@ -37,19 +55,7 @@ const useEditorSearchHandler = (props: Props) => { if (webviewRef.current && (searchMarkers !== previousSearchMarkers || textChanged)) { webviewRef.current.send('setMarkers', searchMarkers.keywords, searchMarkers.options); - - if (editorRef.current) { - // Fixes https://github.com/laurent22/joplin/issues/7565 - const debouncedMarkers = debounce(() => { - const matches = editorRef.current.setMarkers(searchMarkers.keywords, searchMarkers.options); - - props.setLocalSearchResultCount(matches); - }, 50); - debouncedMarkers(); - return () => { - debouncedMarkers.clear(); - }; - } + debouncedMarkers(searchMarkers); } return () => {}; }, [ @@ -62,6 +68,7 @@ const useEditorSearchHandler = (props: Props) => { previousContent, previousRenderedBody, renderedBody, + debouncedMarkers, ]); }; diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v5/CodeMirror.tsx b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v5/CodeMirror.tsx index fadcafaa4..80acf4528 100644 --- a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v5/CodeMirror.tsx +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v5/CodeMirror.tsx @@ -686,6 +686,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: ForwardedRef { diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/CodeMirror.tsx b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/CodeMirror.tsx index a66f38e1d..1fb50f65f 100644 --- a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/CodeMirror.tsx +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/CodeMirror.tsx @@ -16,7 +16,7 @@ import { MarkupToHtml } from '@joplin/renderer'; const { clipboard } = require('electron'); import { reg } from '@joplin/lib/registry'; import ErrorBoundary from '../../../../ErrorBoundary'; -import { EditorKeymap, EditorLanguageType, EditorSettings, UserEventSource } from '@joplin/editor/types'; +import { EditorKeymap, EditorLanguageType, EditorSettings, SearchState, UserEventSource } from '@joplin/editor/types'; import useStyles from '../utils/useStyles'; import { EditorEvent, EditorEventType } from '@joplin/editor/events'; import useScrollHandler from '../utils/useScrollHandler'; @@ -175,6 +175,9 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef { + if (name === 'search' && !props.visiblePanes.includes('editor')) { + return false; + } return name in commands || editorRef.current.supportsCommand(name); }, execCommand: async (cmd: EditorCommand) => { @@ -197,7 +200,7 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef { setWebviewReady(true); @@ -321,6 +324,7 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef(null); const onEditorEvent = useCallback((event: EditorEvent) => { if (event.kind === EditorEventType.Scroll) { editor_scroll(); @@ -337,8 +342,17 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef { const isHTMLNote = props.contentMarkupLanguage === MarkupToHtml.MARKUP_LANGUAGE_HTML; @@ -389,6 +403,8 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef ); diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/Editor.tsx b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/Editor.tsx index d1a137692..c8d196830 100644 --- a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/Editor.tsx +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/Editor.tsx @@ -13,12 +13,15 @@ import { dirname } from 'path'; import useKeymap from './utils/useKeymap'; import useEditorSearch from '../utils/useEditorSearchExtension'; import CommandService from '@joplin/lib/services/CommandService'; +import { SearchMarkers } from '../../../utils/useSearchMarkers'; interface Props extends EditorProps { style: React.CSSProperties; pluginStates: PluginStates; onEditorPaste: (event: Event)=> void; + externalSearch: SearchMarkers; + useLocalSearch: boolean; } const Editor = (props: Props, ref: ForwardedRef) => { @@ -117,6 +120,25 @@ const Editor = (props: Props, ref: ForwardedRef) => { // eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Should run just once }, []); + useEffect(() => { + if (!editor) { + return; + } + + const searchState = editor.getSearchState(); + const externalSearchText = props.externalSearch.keywords.map(k => k.value).join(' ') || searchState.searchText; + + if (externalSearchText === searchState.searchText && searchState.dialogVisible === props.useLocalSearch) { + return; + } + + editor.setSearchState({ + ...searchState, + dialogVisible: props.useLocalSearch, + searchText: externalSearchText, + }); + }, [editor, props.externalSearch, props.useLocalSearch]); + const theme = props.settings.themeData; useEffect(() => { if (!editor) return () => {}; diff --git a/packages/app-desktop/gui/NoteEditor/NoteEditor.tsx b/packages/app-desktop/gui/NoteEditor/NoteEditor.tsx index e45146078..d832166ba 100644 --- a/packages/app-desktop/gui/NoteEditor/NoteEditor.tsx +++ b/packages/app-desktop/gui/NoteEditor/NoteEditor.tsx @@ -411,6 +411,9 @@ function NoteEditor(props: NoteEditorProps) { noteToolbar: null, onScroll: onScroll, setLocalSearchResultCount: setLocalSearchResultCount, + setLocalSearch: localSearch_change, + setShowLocalSearch, + useLocalSearch: showLocalSearch, searchMarkers: searchMarkers, visiblePanes: props.noteVisiblePanes || ['editor', 'viewer'], keyboardMode: Setting.value('editor.keyboardMode'), @@ -506,6 +509,7 @@ function NoteEditor(props: NoteEditorProps) { onPrevious={localSearch_previous} onClose={localSearch_close} visiblePanes={props.noteVisiblePanes} + editorType={props.bodyEditor} /> ); } diff --git a/packages/app-desktop/gui/NoteEditor/utils/types.ts b/packages/app-desktop/gui/NoteEditor/utils/types.ts index 85d0cb069..1dcbcd834 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 { Dispatch } from 'redux'; import { ProcessResultsRow } from '@joplin/lib/services/search/SearchEngine'; import { DropHandler } from './useDropHandler'; +import { SearchMarkers } from './useSearchMarkers'; export interface AllAssetsOptions { contentMaxWidthTarget?: string; @@ -119,8 +120,11 @@ export interface NoteBodyEditorProps { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied noteToolbar: any; setLocalSearchResultCount(count: number): void; + setLocalSearch(search: string): void; + setShowLocalSearch(show: boolean): void; + useLocalSearch: boolean; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - searchMarkers: any; + searchMarkers: SearchMarkers; visiblePanes: string[]; keyboardMode: string; resourceInfos: ResourceInfos; diff --git a/packages/app-desktop/gui/NoteSearchBar.tsx b/packages/app-desktop/gui/NoteSearchBar.tsx index d4e5068ca..4610ac59b 100644 --- a/packages/app-desktop/gui/NoteSearchBar.tsx +++ b/packages/app-desktop/gui/NoteSearchBar.tsx @@ -18,6 +18,7 @@ interface Props { resultCount: number; selectedIndex: number; visiblePanes: string[]; + editorType: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied style: any; } @@ -26,6 +27,7 @@ class NoteSearchBar extends React.Component { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied private backgroundColor: any; + private searchInputRef: React.RefObject; public constructor(props: Props) { super(props); @@ -40,6 +42,7 @@ class NoteSearchBar extends React.Component { this.focus = this.focus.bind(this); this.backgroundColor = undefined; + this.searchInputRef = React.createRef(); } public style() { @@ -133,10 +136,8 @@ class NoteSearchBar extends React.Component { } public focus() { - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - focus('NoteSearchBar::focus', this.refs.searchInput as any); - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - (this.refs.searchInput as any).select(); + focus('NoteSearchBar::focus', this.searchInputRef.current); + this.searchInputRef.current?.select(); } public render() { @@ -173,7 +174,9 @@ class NoteSearchBar extends React.Component { ) : null; - const allowScrolling = this.props.visiblePanes.indexOf('editor') >= 0; + const editorVisible = this.props.visiblePanes.includes('editor'); + const usesEditorSearch = this.props.editorType === 'CodeMirror6' && editorVisible; + const allowScrolling = editorVisible; const viewerWarning = (
@@ -181,6 +184,8 @@ class NoteSearchBar extends React.Component {
); + if (usesEditorSearch) return null; + return (
@@ -190,7 +195,7 @@ class NoteSearchBar extends React.Component { value={query} onChange={this.searchInput_change} onKeyDown={this.searchInput_keyDown} - ref="searchInput" + ref={this.searchInputRef} type="text" style={{ width: 200, marginRight: 5, backgroundColor: this.backgroundColor, color: theme.color }} /> diff --git a/packages/app-desktop/integration-tests/markdownEditor.spec.ts b/packages/app-desktop/integration-tests/markdownEditor.spec.ts index 02fb983ae..dd5126f1c 100644 --- a/packages/app-desktop/integration-tests/markdownEditor.spec.ts +++ b/packages/app-desktop/integration-tests/markdownEditor.spec.ts @@ -63,5 +63,61 @@ test.describe('markdownEditor', () => { await mainWindow.keyboard.press('Home'); await expect(firstItemLocator).toBeFocused(); }); + + test('should sync local search between the viewer and editor', async ({ mainWindow }) => { + const mainScreen = new MainScreen(mainWindow); + await mainScreen.waitFor(); + const noteEditor = mainScreen.noteEditor; + + await mainScreen.createNewNote('Note'); + + await noteEditor.focusCodeMirrorEditor(); + + await mainWindow.keyboard.type('# Testing'); + await mainWindow.keyboard.press('Enter'); + await mainWindow.keyboard.press('Enter'); + await mainWindow.keyboard.type('This is a test of search. `Test inline code`'); + + const viewer = noteEditor.getNoteViewerIframe(); + await expect(viewer.locator('h1')).toHaveText('Testing'); + + const matches = viewer.locator('mark'); + await expect(matches).toHaveCount(0); + + await mainWindow.keyboard.press(process.platform === 'darwin' ? 'Meta+f' : 'Control+f'); + await expect(noteEditor.editorSearchInput).toBeVisible(); + + await noteEditor.editorSearchInput.click(); + await noteEditor.editorSearchInput.fill('test'); + await mainWindow.keyboard.press('Enter'); + + // Should show at least one match in the viewer + await expect(matches).toHaveCount(3); + await expect(matches.first()).toBeAttached(); + + // Should show matches in code regions + await noteEditor.editorSearchInput.fill('inline code'); + await mainWindow.keyboard.press('Enter'); + await expect(matches).toHaveCount(1); + + // Should continue searching after switching to view-only mode + await noteEditor.toggleEditorLayoutButton.click(); + await noteEditor.toggleEditorLayoutButton.click(); + await expect(noteEditor.codeMirrorEditor).not.toBeVisible(); + await expect(noteEditor.editorSearchInput).not.toBeVisible(); + await expect(noteEditor.viewerSearchInput).toBeVisible(); + + // Should stop searching after closing the search input + await noteEditor.viewerSearchInput.click(); + await expect(matches).toHaveCount(1); + await mainWindow.keyboard.press('Escape'); + await expect(noteEditor.viewerSearchInput).not.toBeVisible(); + await expect(matches).toHaveCount(0); + + // After showing the viewer again, search should still be hidden + await noteEditor.toggleEditorLayoutButton.click(); + await expect(noteEditor.codeMirrorEditor).toBeVisible(); + await expect(noteEditor.editorSearchInput).not.toBeVisible(); + }); }); diff --git a/packages/app-desktop/integration-tests/models/NoteEditorScreen.ts b/packages/app-desktop/integration-tests/models/NoteEditorScreen.ts index eaff679d3..8f4da25e3 100644 --- a/packages/app-desktop/integration-tests/models/NoteEditorScreen.ts +++ b/packages/app-desktop/integration-tests/models/NoteEditorScreen.ts @@ -7,6 +7,9 @@ export default class NoteEditorPage { public readonly noteTitleInput: Locator; public readonly attachFileButton: Locator; public readonly toggleEditorsButton: Locator; + public readonly toggleEditorLayoutButton: Locator; + public readonly editorSearchInput: Locator; + public readonly viewerSearchInput: Locator; private readonly containerLocator: Locator; public constructor(private readonly page: Page) { @@ -16,6 +19,10 @@ export default class NoteEditorPage { this.noteTitleInput = this.containerLocator.locator('.title-input'); this.attachFileButton = this.containerLocator.getByRole('button', { name: 'Attach file' }); this.toggleEditorsButton = this.containerLocator.getByRole('button', { name: 'Toggle editors' }); + this.toggleEditorLayoutButton = this.containerLocator.getByRole('button', { name: 'Toggle editor layout' }); + // The editor and viewer have slightly different search UI + this.editorSearchInput = this.containerLocator.getByPlaceholder('Find'); + this.viewerSearchInput = this.containerLocator.getByPlaceholder('Search...'); } public toolbarButtonLocator(title: string) { @@ -26,7 +33,7 @@ export default class NoteEditorPage { // The note viewer can change content when the note re-renders. As such, // a new locator needs to be created after re-renders (and this can't be a // static property). - return this.page.frame({ url: /.*note-viewer[/\\]index.html.*/ }); + return this.page.frameLocator('[src$="note-viewer/index.html"]'); } public getTinyMCEFrameLocator() { diff --git a/packages/editor/CodeMirror/CodeMirrorControl.ts b/packages/editor/CodeMirror/CodeMirrorControl.ts index 6a3109322..a67fe9687 100644 --- a/packages/editor/CodeMirror/CodeMirrorControl.ts +++ b/packages/editor/CodeMirror/CodeMirrorControl.ts @@ -4,12 +4,13 @@ import CodeMirror5Emulation from './CodeMirror5Emulation/CodeMirror5Emulation'; import editorCommands from './editorCommands/editorCommands'; import { Compartment, EditorSelection, Extension, StateEffect } from '@codemirror/state'; import { updateLink } from './markdown/markdownCommands'; -import { SearchQuery, setSearchQuery } from '@codemirror/search'; +import { searchPanelOpen, SearchQuery, setSearchQuery } from '@codemirror/search'; import PluginLoader from './pluginApi/PluginLoader'; import customEditorCompletion, { editorCompletionSource, enableLanguageDataAutocomplete } from './pluginApi/customEditorCompletion'; import { CompletionSource } from '@codemirror/autocomplete'; import { RegionSpec } from './utils/formatting/RegionSpec'; import toggleInlineSelectionFormat from './utils/formatting/toggleInlineSelectionFormat'; +import getSearchState from './utils/getSearchState'; interface Callbacks { onUndoRedo(): void; @@ -153,7 +154,15 @@ export default class CodeMirrorControl extends CodeMirror5Emulation implements E this._callbacks.onSettingsChange(newSettings); } + public getSearchState(): SearchState { + return getSearchState(this.editor.state); + } + public setSearchState(newState: SearchState) { + if (newState.dialogVisible !== searchPanelOpen(this.editor.state)) { + this.execCommand(newState.dialogVisible ? EditorCommandType.ShowSearch : EditorCommandType.HideSearch); + } + const query = new SearchQuery({ search: newState.searchText, caseSensitive: newState.caseSensitive, @@ -161,8 +170,11 @@ export default class CodeMirrorControl extends CodeMirror5Emulation implements E replace: newState.replaceText, }); this.editor.dispatch({ - effects: setSearchQuery.of(query), + effects: [ + setSearchQuery.of(query), + ], }); + } public addStyles(...styles: Parameters) { diff --git a/packages/editor/CodeMirror/createEditor.ts b/packages/editor/CodeMirror/createEditor.ts index c6079b36a..472be91f5 100644 --- a/packages/editor/CodeMirror/createEditor.ts +++ b/packages/editor/CodeMirror/createEditor.ts @@ -1,8 +1,6 @@ import { Compartment, EditorState, Prec } from '@codemirror/state'; import { indentOnInput, syntaxHighlighting } from '@codemirror/language'; -import { - openSearchPanel, closeSearchPanel, getSearchQuery, search, -} from '@codemirror/search'; +import { openSearchPanel, closeSearchPanel, searchPanelOpen } from '@codemirror/search'; import { classHighlighter } from '@lezer/highlight'; @@ -15,7 +13,7 @@ import { keymap, KeyBinding } from '@codemirror/view'; import { searchKeymap } from '@codemirror/search'; import { historyKeymap } from '@codemirror/commands'; -import { SearchState, EditorProps, EditorSettings } from '../types'; +import { EditorProps, EditorSettings } from '../types'; import { EditorEventType, SelectionRangeChangeEvent } from '../events'; import { decreaseIndent, increaseIndent, @@ -32,6 +30,7 @@ import CodeMirrorControl from './CodeMirrorControl'; import insertLineAfter from './editorCommands/insertLineAfter'; import handlePasteEvent from './utils/handlePasteEvent'; import biDirectionalTextExtension from './utils/biDirectionalTextExtension'; +import searchExtension from './utils/searchExtension'; const createEditor = ( parentElement: HTMLElement, props: EditorProps, @@ -41,7 +40,6 @@ const createEditor = ( props.onLogMessage('Initializing CodeMirror...'); - let searchVisible = false; // Handles firing an event when the undo/redo stack changes let schedulePostUndoRedoDepthChangeId_: ReturnType|null = null; @@ -92,36 +90,6 @@ const createEditor = ( }); }; - const onSearchDialogUpdate = () => { - const query = getSearchQuery(editor.state); - const searchState: SearchState = { - searchText: query.search, - replaceText: query.replace, - useRegex: query.regexp, - caseSensitive: query.caseSensitive, - dialogVisible: searchVisible, - }; - props.onEvent({ - kind: EditorEventType.UpdateSearchDialog, - searchState, - }); - }; - - const showSearchDialog = () => { - if (!searchVisible) { - openSearchPanel(editor); - } - searchVisible = true; - onSearchDialogUpdate(); - }; - - const hideSearchDialog = () => { - if (searchVisible) { - closeSearchPanel(editor); - } - searchVisible = false; - onSearchDialogUpdate(); - }; const globalSpellcheckEnabled = () => { return editor.contentDOM.spellcheck; @@ -188,11 +156,11 @@ const createEditor = ( const keymapConfig = Prec.low(keymap.of([ // Custom mod-f binding: Toggle the external dialog implementation // (don't show/hide the Panel dialog). - keyCommand('Mod-f', (_: EditorView) => { - if (searchVisible) { - hideSearchDialog(); + keyCommand('Mod-f', (editor: EditorView) => { + if (searchPanelOpen(editor.state)) { + closeSearchPanel(editor); } else { - showSearchDialog(); + openSearchPanel(editor); } return true; }), @@ -226,22 +194,7 @@ const createEditor = ( dynamicConfig.of(configFromSettings(props.settings)), historyCompartment.of(history()), - - search(settings.useExternalSearch ? { - createPanel(_: EditorView) { - return { - // The actual search dialog is implemented with react native, - // use a dummy element. - dom: document.createElement('div'), - mount() { - showSearchDialog(); - }, - destroy() { - hideSearchDialog(); - }, - }; - }, - } : undefined), + searchExtension(props.onEvent, props.settings), // Allows multiple selections and allows selecting a rectangle // with ctrl (as in CodeMirror 5) @@ -295,6 +248,7 @@ const createEditor = ( notifySelectionChange(viewUpdate); notifySelectionFormattingChange(viewUpdate); }), + ], doc: initialText, }), diff --git a/packages/editor/CodeMirror/utils/getSearchState.ts b/packages/editor/CodeMirror/utils/getSearchState.ts new file mode 100644 index 000000000..38f5f38b6 --- /dev/null +++ b/packages/editor/CodeMirror/utils/getSearchState.ts @@ -0,0 +1,17 @@ +import { EditorState } from '@codemirror/state'; +import { SearchState } from '../../types'; +import { getSearchQuery, searchPanelOpen } from '@codemirror/search'; + +const getSearchState = (state: EditorState) => { + const query = getSearchQuery(state); + const searchState: SearchState = { + searchText: query.search, + replaceText: query.replace, + useRegex: query.regexp, + caseSensitive: query.caseSensitive, + dialogVisible: searchPanelOpen(state), + }; + return searchState; +}; + +export default getSearchState; diff --git a/packages/editor/CodeMirror/utils/searchExtension.ts b/packages/editor/CodeMirror/utils/searchExtension.ts new file mode 100644 index 000000000..1804e74c8 --- /dev/null +++ b/packages/editor/CodeMirror/utils/searchExtension.ts @@ -0,0 +1,40 @@ +import { EditorState, Extension } from '@codemirror/state'; +import { EditorView } from '@codemirror/view'; +import { EditorSettings, OnEventCallback } from '../../types'; +import getSearchState from './getSearchState'; +import { EditorEventType } from '../../events'; +import { search, searchPanelOpen, setSearchQuery } from '@codemirror/search'; + +const searchExtension = (onEvent: OnEventCallback, settings: EditorSettings): Extension => { + const onSearchDialogUpdate = (state: EditorState) => { + const newSearchState = getSearchState(state); + + onEvent({ + kind: EditorEventType.UpdateSearchDialog, + searchState: newSearchState, + }); + }; + + return [ + search(settings.useExternalSearch ? { + createPanel(_editor: EditorView) { + return { + // The actual search dialog is implemented with react native, + // use a dummy element. + dom: document.createElement('div'), + mount() { }, + destroy() { }, + }; + }, + } : undefined), + + EditorState.transactionExtender.of((tr) => { + if (tr.effects.some(e => e.is(setSearchQuery)) || searchPanelOpen(tr.state) !== searchPanelOpen(tr.startState)) { + onSearchDialogUpdate(tr.state); + } + return null; + }), + ]; +}; + +export default searchExtension;