1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-01-02 12:47:41 +02:00

Desktop: Allow searching when only the note viewer is visible and sync search with editor (#10866)

This commit is contained in:
Henry Heino 2024-08-15 08:01:52 -07:00 committed by GitHub
parent 1edef99811
commit b5313067cd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 246 additions and 92 deletions

View File

@ -841,9 +841,11 @@ packages/editor/CodeMirror/utils/formatting/toggleRegionFormatGlobally.test.js
packages/editor/CodeMirror/utils/formatting/toggleRegionFormatGlobally.js packages/editor/CodeMirror/utils/formatting/toggleRegionFormatGlobally.js
packages/editor/CodeMirror/utils/formatting/toggleSelectedLinesStartWith.js packages/editor/CodeMirror/utils/formatting/toggleSelectedLinesStartWith.js
packages/editor/CodeMirror/utils/formatting/types.js packages/editor/CodeMirror/utils/formatting/types.js
packages/editor/CodeMirror/utils/getSearchState.js
packages/editor/CodeMirror/utils/growSelectionToNode.js packages/editor/CodeMirror/utils/growSelectionToNode.js
packages/editor/CodeMirror/utils/handlePasteEvent.js packages/editor/CodeMirror/utils/handlePasteEvent.js
packages/editor/CodeMirror/utils/isInSyntaxNode.js packages/editor/CodeMirror/utils/isInSyntaxNode.js
packages/editor/CodeMirror/utils/searchExtension.js
packages/editor/CodeMirror/utils/setupVim.js packages/editor/CodeMirror/utils/setupVim.js
packages/editor/SelectionFormatting.js packages/editor/SelectionFormatting.js
packages/editor/events.js packages/editor/events.js

2
.gitignore vendored
View File

@ -818,9 +818,11 @@ packages/editor/CodeMirror/utils/formatting/toggleRegionFormatGlobally.test.js
packages/editor/CodeMirror/utils/formatting/toggleRegionFormatGlobally.js packages/editor/CodeMirror/utils/formatting/toggleRegionFormatGlobally.js
packages/editor/CodeMirror/utils/formatting/toggleSelectedLinesStartWith.js packages/editor/CodeMirror/utils/formatting/toggleSelectedLinesStartWith.js
packages/editor/CodeMirror/utils/formatting/types.js packages/editor/CodeMirror/utils/formatting/types.js
packages/editor/CodeMirror/utils/getSearchState.js
packages/editor/CodeMirror/utils/growSelectionToNode.js packages/editor/CodeMirror/utils/growSelectionToNode.js
packages/editor/CodeMirror/utils/handlePasteEvent.js packages/editor/CodeMirror/utils/handlePasteEvent.js
packages/editor/CodeMirror/utils/isInSyntaxNode.js packages/editor/CodeMirror/utils/isInSyntaxNode.js
packages/editor/CodeMirror/utils/searchExtension.js
packages/editor/CodeMirror/utils/setupVim.js packages/editor/CodeMirror/utils/setupVim.js
packages/editor/SelectionFormatting.js packages/editor/SelectionFormatting.js
packages/editor/events.js packages/editor/events.js

View File

@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import shim from '@joplin/lib/shim'; import shim from '@joplin/lib/shim';
import Logger from '@joplin/utils/Logger'; import Logger from '@joplin/utils/Logger';
import CodeMirror5Emulation from '@joplin/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation'; import CodeMirror5Emulation from '@joplin/editor/CodeMirror/CodeMirror5Emulation/CodeMirror5Emulation';
@ -20,16 +20,15 @@ export default function useEditorSearchExtension(CodeMirror: CodeMirror5Emulatio
const overlayTimeoutRef = useRef(null); const overlayTimeoutRef = useRef(null);
overlayTimeoutRef.current = overlayTimeout; overlayTimeoutRef.current = overlayTimeout;
function clearMarkers() { const clearMarkers = useCallback(() => {
for (let i = 0; i < markers.length; i++) { for (let i = 0; i < markers.length; i++) {
markers[i].clear(); markers[i].clear();
} }
setMarkers([]); setMarkers([]);
} }, [markers]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied const clearOverlay = useCallback((cm: CodeMirror5Emulation) => {
function clearOverlay(cm: any) {
if (overlay) cm.removeOverlay(overlay); if (overlay) cm.removeOverlay(overlay);
if (scrollbarMarks) { if (scrollbarMarks) {
try { try {
@ -47,10 +46,10 @@ export default function useEditorSearchExtension(CodeMirror: CodeMirror5Emulatio
setOverlay(null); setOverlay(null);
setScrollbarMarks(null); setScrollbarMarks(null);
setOverlayTimeout(null); setOverlayTimeout(null);
} }, [scrollbarMarks, overlay, overlayTimeout]);
// Modified from codemirror/addons/search/search.js // 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
return { token: function(stream: any) { return { token: function(stream: any) {
query.lastIndex = stream.pos; query.lastIndex = stream.pos;
@ -65,13 +64,13 @@ export default function useEditorSearchExtension(CodeMirror: CodeMirror5Emulatio
} }
return null; return null;
} }; } };
} }, []);
// Highlights the currently active found work // Highlights the currently active found work
// It's possible to get tricky with this functions and just use findNext/findPrev // 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 // 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 // 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); const cursor = cm.getSearchCursor(searchTerm);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied // 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 }; options = { selectedIndex: 0, searchTimestamp: 0 };
} }
if (options.showEditorMarkers === false) {
clearMarkers();
clearOverlay(this);
return;
}
clearMarkers(); clearMarkers();
// HIGHLIGHT KEYWORDS // HIGHLIGHT KEYWORDS

View File

@ -1,6 +1,7 @@
import { RefObject, useEffect } from 'react'; import { RefObject, useEffect, useMemo, useRef } from 'react';
import usePrevious from '../../../../hooks/usePrevious'; import usePrevious from '../../../../hooks/usePrevious';
import { RenderedBody } from './types'; import { RenderedBody } from './types';
import { SearchMarkers } from '../../../utils/useSearchMarkers';
const debounce = require('debounce'); const debounce = require('debounce');
interface Props { interface Props {
@ -14,14 +15,31 @@ interface Props {
noteContent: string; noteContent: string;
renderedBody: RenderedBody; renderedBody: RenderedBody;
showEditorMarkers: boolean;
} }
const useEditorSearchHandler = (props: Props) => { const useEditorSearchHandler = (props: Props) => {
const { webviewRef, editorRef, renderedBody, noteContent, searchMarkers } = props; const {
webviewRef, editorRef, renderedBody, noteContent, searchMarkers, showEditorMarkers,
} = props;
const previousContent = usePrevious(noteContent); const previousContent = usePrevious(noteContent);
const previousRenderedBody = usePrevious(renderedBody); const previousRenderedBody = usePrevious(renderedBody);
const previousSearchMarkers = usePrevious(searchMarkers); 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(() => { useEffect(() => {
if (!searchMarkers) return () => {}; if (!searchMarkers) return () => {};
@ -37,19 +55,7 @@ const useEditorSearchHandler = (props: Props) => {
if (webviewRef.current && (searchMarkers !== previousSearchMarkers || textChanged)) { if (webviewRef.current && (searchMarkers !== previousSearchMarkers || textChanged)) {
webviewRef.current.send('setMarkers', searchMarkers.keywords, searchMarkers.options); webviewRef.current.send('setMarkers', searchMarkers.keywords, searchMarkers.options);
debouncedMarkers(searchMarkers);
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();
};
}
} }
return () => {}; return () => {};
}, [ }, [
@ -62,6 +68,7 @@ const useEditorSearchHandler = (props: Props) => {
previousContent, previousContent,
previousRenderedBody, previousRenderedBody,
renderedBody, renderedBody,
debouncedMarkers,
]); ]);
}; };

View File

@ -686,6 +686,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
editorRef, editorRef,
noteContent: props.content, noteContent: props.content,
renderedBody, renderedBody,
showEditorMarkers: true,
}); });
const cellEditorStyle = useMemo(() => { const cellEditorStyle = useMemo(() => {

View File

@ -16,7 +16,7 @@ import { MarkupToHtml } from '@joplin/renderer';
const { clipboard } = require('electron'); const { clipboard } = require('electron');
import { reg } from '@joplin/lib/registry'; import { reg } from '@joplin/lib/registry';
import ErrorBoundary from '../../../../ErrorBoundary'; 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 useStyles from '../utils/useStyles';
import { EditorEvent, EditorEventType } from '@joplin/editor/events'; import { EditorEvent, EditorEventType } from '@joplin/editor/events';
import useScrollHandler from '../utils/useScrollHandler'; import useScrollHandler from '../utils/useScrollHandler';
@ -175,6 +175,9 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
} }
}, },
supportsCommand: (name: string) => { supportsCommand: (name: string) => {
if (name === 'search' && !props.visiblePanes.includes('editor')) {
return false;
}
return name in commands || editorRef.current.supportsCommand(name); return name in commands || editorRef.current.supportsCommand(name);
}, },
execCommand: async (cmd: EditorCommand) => { execCommand: async (cmd: EditorCommand) => {
@ -197,7 +200,7 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
return commandOutput; return commandOutput;
}, },
}; };
}, [props.content, commands, resetScroll, setEditorPercentScroll, setViewerPercentScroll]); }, [props.content, props.visiblePanes, commands, resetScroll, setEditorPercentScroll, setViewerPercentScroll]);
const webview_domReady = useCallback(() => { const webview_domReady = useCallback(() => {
setWebviewReady(true); setWebviewReady(true);
@ -321,6 +324,7 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
editorRef, editorRef,
noteContent: props.content, noteContent: props.content,
renderedBody, renderedBody,
showEditorMarkers: !props.useLocalSearch,
}); });
useContextMenu({ useContextMenu({
@ -330,6 +334,7 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
editorClassName: 'cm-editor', editorClassName: 'cm-editor',
}); });
const lastSearchState = useRef<SearchState|null>(null);
const onEditorEvent = useCallback((event: EditorEvent) => { const onEditorEvent = useCallback((event: EditorEvent) => {
if (event.kind === EditorEventType.Scroll) { if (event.kind === EditorEventType.Scroll) {
editor_scroll(); editor_scroll();
@ -337,8 +342,17 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
codeMirror_change(event.value); codeMirror_change(event.value);
} else if (event.kind === EditorEventType.SelectionRangeChange) { } else if (event.kind === EditorEventType.SelectionRangeChange) {
setSelectionRange({ from: event.from, to: event.to }); setSelectionRange({ from: event.from, to: event.to });
} else if (event.kind === EditorEventType.UpdateSearchDialog) {
if (lastSearchState.current?.searchText !== event.searchState.searchText) {
props.setLocalSearch(event.searchState.searchText);
}
if (lastSearchState.current?.dialogVisible !== event.searchState.dialogVisible) {
props.setShowLocalSearch(event.searchState.dialogVisible);
}
lastSearchState.current = event.searchState;
} }
}, [editor_scroll, codeMirror_change]); }, [editor_scroll, codeMirror_change, props.setLocalSearch, props.setShowLocalSearch]);
const editorSettings = useMemo((): EditorSettings => { const editorSettings = useMemo((): EditorSettings => {
const isHTMLNote = props.contentMarkupLanguage === MarkupToHtml.MARKUP_LANGUAGE_HTML; const isHTMLNote = props.contentMarkupLanguage === MarkupToHtml.MARKUP_LANGUAGE_HTML;
@ -389,6 +403,8 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
onEvent={onEditorEvent} onEvent={onEditorEvent}
onLogMessage={logDebug} onLogMessage={logDebug}
onEditorPaste={onEditorPaste} onEditorPaste={onEditorPaste}
externalSearch={props.searchMarkers}
useLocalSearch={props.useLocalSearch}
/> />
</div> </div>
); );

View File

@ -13,12 +13,15 @@ import { dirname } from 'path';
import useKeymap from './utils/useKeymap'; import useKeymap from './utils/useKeymap';
import useEditorSearch from '../utils/useEditorSearchExtension'; import useEditorSearch from '../utils/useEditorSearchExtension';
import CommandService from '@joplin/lib/services/CommandService'; import CommandService from '@joplin/lib/services/CommandService';
import { SearchMarkers } from '../../../utils/useSearchMarkers';
interface Props extends EditorProps { interface Props extends EditorProps {
style: React.CSSProperties; style: React.CSSProperties;
pluginStates: PluginStates; pluginStates: PluginStates;
onEditorPaste: (event: Event)=> void; onEditorPaste: (event: Event)=> void;
externalSearch: SearchMarkers;
useLocalSearch: boolean;
} }
const Editor = (props: Props, ref: ForwardedRef<CodeMirrorControl>) => { const Editor = (props: Props, ref: ForwardedRef<CodeMirrorControl>) => {
@ -117,6 +120,25 @@ const Editor = (props: Props, ref: ForwardedRef<CodeMirrorControl>) => {
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Should run just once // 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; const theme = props.settings.themeData;
useEffect(() => { useEffect(() => {
if (!editor) return () => {}; if (!editor) return () => {};

View File

@ -411,6 +411,9 @@ function NoteEditor(props: NoteEditorProps) {
noteToolbar: null, noteToolbar: null,
onScroll: onScroll, onScroll: onScroll,
setLocalSearchResultCount: setLocalSearchResultCount, setLocalSearchResultCount: setLocalSearchResultCount,
setLocalSearch: localSearch_change,
setShowLocalSearch,
useLocalSearch: showLocalSearch,
searchMarkers: searchMarkers, searchMarkers: searchMarkers,
visiblePanes: props.noteVisiblePanes || ['editor', 'viewer'], visiblePanes: props.noteVisiblePanes || ['editor', 'viewer'],
keyboardMode: Setting.value('editor.keyboardMode'), keyboardMode: Setting.value('editor.keyboardMode'),
@ -506,6 +509,7 @@ function NoteEditor(props: NoteEditorProps) {
onPrevious={localSearch_previous} onPrevious={localSearch_previous}
onClose={localSearch_close} onClose={localSearch_close}
visiblePanes={props.noteVisiblePanes} visiblePanes={props.noteVisiblePanes}
editorType={props.bodyEditor}
/> />
); );
} }

View File

@ -6,6 +6,7 @@ import { RenderResult, RenderResultPluginAsset } from '@joplin/renderer/types';
import { Dispatch } from 'redux'; import { Dispatch } from 'redux';
import { ProcessResultsRow } from '@joplin/lib/services/search/SearchEngine'; import { ProcessResultsRow } from '@joplin/lib/services/search/SearchEngine';
import { DropHandler } from './useDropHandler'; import { DropHandler } from './useDropHandler';
import { SearchMarkers } from './useSearchMarkers';
export interface AllAssetsOptions { export interface AllAssetsOptions {
contentMaxWidthTarget?: string; contentMaxWidthTarget?: string;
@ -119,8 +120,11 @@ export interface NoteBodyEditorProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
noteToolbar: any; noteToolbar: any;
setLocalSearchResultCount(count: number): void; 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
searchMarkers: any; searchMarkers: SearchMarkers;
visiblePanes: string[]; visiblePanes: string[];
keyboardMode: string; keyboardMode: string;
resourceInfos: ResourceInfos; resourceInfos: ResourceInfos;

View File

@ -18,6 +18,7 @@ interface Props {
resultCount: number; resultCount: number;
selectedIndex: number; selectedIndex: number;
visiblePanes: string[]; visiblePanes: string[];
editorType: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
style: any; style: any;
} }
@ -26,6 +27,7 @@ class NoteSearchBar extends React.Component<Props> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
private backgroundColor: any; private backgroundColor: any;
private searchInputRef: React.RefObject<HTMLInputElement>;
public constructor(props: Props) { public constructor(props: Props) {
super(props); super(props);
@ -40,6 +42,7 @@ class NoteSearchBar extends React.Component<Props> {
this.focus = this.focus.bind(this); this.focus = this.focus.bind(this);
this.backgroundColor = undefined; this.backgroundColor = undefined;
this.searchInputRef = React.createRef();
} }
public style() { public style() {
@ -133,10 +136,8 @@ class NoteSearchBar extends React.Component<Props> {
} }
public focus() { public focus() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied focus('NoteSearchBar::focus', this.searchInputRef.current);
focus('NoteSearchBar::focus', this.refs.searchInput as any); this.searchInputRef.current?.select();
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
(this.refs.searchInput as any).select();
} }
public render() { public render() {
@ -173,7 +174,9 @@ class NoteSearchBar extends React.Component<Props> {
</div> </div>
) : null; ) : 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 = ( const viewerWarning = (
<div style={textStyle}> <div style={textStyle}>
@ -181,6 +184,8 @@ class NoteSearchBar extends React.Component<Props> {
</div> </div>
); );
if (usesEditorSearch) return null;
return ( return (
<div className="note-search-bar" style={this.props.style}> <div className="note-search-bar" style={this.props.style}>
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}> <div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
@ -190,7 +195,7 @@ class NoteSearchBar extends React.Component<Props> {
value={query} value={query}
onChange={this.searchInput_change} onChange={this.searchInput_change}
onKeyDown={this.searchInput_keyDown} onKeyDown={this.searchInput_keyDown}
ref="searchInput" ref={this.searchInputRef}
type="text" type="text"
style={{ width: 200, marginRight: 5, backgroundColor: this.backgroundColor, color: theme.color }} style={{ width: 200, marginRight: 5, backgroundColor: this.backgroundColor, color: theme.color }}
/> />

View File

@ -63,5 +63,61 @@ test.describe('markdownEditor', () => {
await mainWindow.keyboard.press('Home'); await mainWindow.keyboard.press('Home');
await expect(firstItemLocator).toBeFocused(); 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();
});
}); });

View File

@ -7,6 +7,9 @@ export default class NoteEditorPage {
public readonly noteTitleInput: Locator; public readonly noteTitleInput: Locator;
public readonly attachFileButton: Locator; public readonly attachFileButton: Locator;
public readonly toggleEditorsButton: Locator; public readonly toggleEditorsButton: Locator;
public readonly toggleEditorLayoutButton: Locator;
public readonly editorSearchInput: Locator;
public readonly viewerSearchInput: Locator;
private readonly containerLocator: Locator; private readonly containerLocator: Locator;
public constructor(private readonly page: Page) { public constructor(private readonly page: Page) {
@ -16,6 +19,10 @@ export default class NoteEditorPage {
this.noteTitleInput = this.containerLocator.locator('.title-input'); this.noteTitleInput = this.containerLocator.locator('.title-input');
this.attachFileButton = this.containerLocator.getByRole('button', { name: 'Attach file' }); this.attachFileButton = this.containerLocator.getByRole('button', { name: 'Attach file' });
this.toggleEditorsButton = this.containerLocator.getByRole('button', { name: 'Toggle editors' }); 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) { 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, // 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 // a new locator needs to be created after re-renders (and this can't be a
// static property). // static property).
return this.page.frame({ url: /.*note-viewer[/\\]index.html.*/ }); return this.page.frameLocator('[src$="note-viewer/index.html"]');
} }
public getTinyMCEFrameLocator() { public getTinyMCEFrameLocator() {

View File

@ -4,12 +4,13 @@ import CodeMirror5Emulation from './CodeMirror5Emulation/CodeMirror5Emulation';
import editorCommands from './editorCommands/editorCommands'; import editorCommands from './editorCommands/editorCommands';
import { Compartment, EditorSelection, Extension, StateEffect } from '@codemirror/state'; import { Compartment, EditorSelection, Extension, StateEffect } from '@codemirror/state';
import { updateLink } from './markdown/markdownCommands'; import { updateLink } from './markdown/markdownCommands';
import { SearchQuery, setSearchQuery } from '@codemirror/search'; import { searchPanelOpen, SearchQuery, setSearchQuery } from '@codemirror/search';
import PluginLoader from './pluginApi/PluginLoader'; import PluginLoader from './pluginApi/PluginLoader';
import customEditorCompletion, { editorCompletionSource, enableLanguageDataAutocomplete } from './pluginApi/customEditorCompletion'; import customEditorCompletion, { editorCompletionSource, enableLanguageDataAutocomplete } from './pluginApi/customEditorCompletion';
import { CompletionSource } from '@codemirror/autocomplete'; import { CompletionSource } from '@codemirror/autocomplete';
import { RegionSpec } from './utils/formatting/RegionSpec'; import { RegionSpec } from './utils/formatting/RegionSpec';
import toggleInlineSelectionFormat from './utils/formatting/toggleInlineSelectionFormat'; import toggleInlineSelectionFormat from './utils/formatting/toggleInlineSelectionFormat';
import getSearchState from './utils/getSearchState';
interface Callbacks { interface Callbacks {
onUndoRedo(): void; onUndoRedo(): void;
@ -153,7 +154,15 @@ export default class CodeMirrorControl extends CodeMirror5Emulation implements E
this._callbacks.onSettingsChange(newSettings); this._callbacks.onSettingsChange(newSettings);
} }
public getSearchState(): SearchState {
return getSearchState(this.editor.state);
}
public setSearchState(newState: SearchState) { public setSearchState(newState: SearchState) {
if (newState.dialogVisible !== searchPanelOpen(this.editor.state)) {
this.execCommand(newState.dialogVisible ? EditorCommandType.ShowSearch : EditorCommandType.HideSearch);
}
const query = new SearchQuery({ const query = new SearchQuery({
search: newState.searchText, search: newState.searchText,
caseSensitive: newState.caseSensitive, caseSensitive: newState.caseSensitive,
@ -161,8 +170,11 @@ export default class CodeMirrorControl extends CodeMirror5Emulation implements E
replace: newState.replaceText, replace: newState.replaceText,
}); });
this.editor.dispatch({ this.editor.dispatch({
effects: setSearchQuery.of(query), effects: [
setSearchQuery.of(query),
],
}); });
} }
public addStyles(...styles: Parameters<typeof EditorView.theme>) { public addStyles(...styles: Parameters<typeof EditorView.theme>) {

View File

@ -1,8 +1,6 @@
import { Compartment, EditorState, Prec } from '@codemirror/state'; import { Compartment, EditorState, Prec } from '@codemirror/state';
import { indentOnInput, syntaxHighlighting } from '@codemirror/language'; import { indentOnInput, syntaxHighlighting } from '@codemirror/language';
import { import { openSearchPanel, closeSearchPanel, searchPanelOpen } from '@codemirror/search';
openSearchPanel, closeSearchPanel, getSearchQuery, search,
} from '@codemirror/search';
import { classHighlighter } from '@lezer/highlight'; import { classHighlighter } from '@lezer/highlight';
@ -15,7 +13,7 @@ import { keymap, KeyBinding } from '@codemirror/view';
import { searchKeymap } from '@codemirror/search'; import { searchKeymap } from '@codemirror/search';
import { historyKeymap } from '@codemirror/commands'; import { historyKeymap } from '@codemirror/commands';
import { SearchState, EditorProps, EditorSettings } from '../types'; import { EditorProps, EditorSettings } from '../types';
import { EditorEventType, SelectionRangeChangeEvent } from '../events'; import { EditorEventType, SelectionRangeChangeEvent } from '../events';
import { import {
decreaseIndent, increaseIndent, decreaseIndent, increaseIndent,
@ -32,6 +30,7 @@ import CodeMirrorControl from './CodeMirrorControl';
import insertLineAfter from './editorCommands/insertLineAfter'; import insertLineAfter from './editorCommands/insertLineAfter';
import handlePasteEvent from './utils/handlePasteEvent'; import handlePasteEvent from './utils/handlePasteEvent';
import biDirectionalTextExtension from './utils/biDirectionalTextExtension'; import biDirectionalTextExtension from './utils/biDirectionalTextExtension';
import searchExtension from './utils/searchExtension';
const createEditor = ( const createEditor = (
parentElement: HTMLElement, props: EditorProps, parentElement: HTMLElement, props: EditorProps,
@ -41,7 +40,6 @@ const createEditor = (
props.onLogMessage('Initializing CodeMirror...'); props.onLogMessage('Initializing CodeMirror...');
let searchVisible = false;
// Handles firing an event when the undo/redo stack changes // Handles firing an event when the undo/redo stack changes
let schedulePostUndoRedoDepthChangeId_: ReturnType<typeof setTimeout>|null = null; let schedulePostUndoRedoDepthChangeId_: ReturnType<typeof setTimeout>|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 = () => { const globalSpellcheckEnabled = () => {
return editor.contentDOM.spellcheck; return editor.contentDOM.spellcheck;
@ -188,11 +156,11 @@ const createEditor = (
const keymapConfig = Prec.low(keymap.of([ const keymapConfig = Prec.low(keymap.of([
// Custom mod-f binding: Toggle the external dialog implementation // Custom mod-f binding: Toggle the external dialog implementation
// (don't show/hide the Panel dialog). // (don't show/hide the Panel dialog).
keyCommand('Mod-f', (_: EditorView) => { keyCommand('Mod-f', (editor: EditorView) => {
if (searchVisible) { if (searchPanelOpen(editor.state)) {
hideSearchDialog(); closeSearchPanel(editor);
} else { } else {
showSearchDialog(); openSearchPanel(editor);
} }
return true; return true;
}), }),
@ -226,22 +194,7 @@ const createEditor = (
dynamicConfig.of(configFromSettings(props.settings)), dynamicConfig.of(configFromSettings(props.settings)),
historyCompartment.of(history()), historyCompartment.of(history()),
searchExtension(props.onEvent, props.settings),
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),
// Allows multiple selections and allows selecting a rectangle // Allows multiple selections and allows selecting a rectangle
// with ctrl (as in CodeMirror 5) // with ctrl (as in CodeMirror 5)
@ -295,6 +248,7 @@ const createEditor = (
notifySelectionChange(viewUpdate); notifySelectionChange(viewUpdate);
notifySelectionFormattingChange(viewUpdate); notifySelectionFormattingChange(viewUpdate);
}), }),
], ],
doc: initialText, doc: initialText,
}), }),

View File

@ -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;

View File

@ -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;