1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-11-26 22:41:17 +02:00

Mobile: Improve inline search performance in large documents (#13259)

This commit is contained in:
Henry Heino
2025-09-30 09:22:55 -07:00
committed by GitHub
parent 91dc23c23f
commit f832eb38ff
10 changed files with 62 additions and 31 deletions

View File

@@ -90,6 +90,8 @@ function editorTheme(themeId: number) {
};
}
const noteEditorSearchChangeSource = 'joplin.noteEditor.setSearchState';
type OnSetVisibleCallback = (visible: boolean)=> void;
type OnSearchStateChangeCallback = (state: SearchState)=> void;
const useEditorControl = (
@@ -104,7 +106,7 @@ const useEditorControl = (
};
const setSearchStateCallback = (state: SearchState) => {
editorRef.current.setSearchState(state);
editorRef.current.setSearchState(state, noteEditorSearchChangeSource);
setSearchState(state);
};
@@ -310,15 +312,26 @@ function NoteEditor(props: Props) {
case EditorEventType.FollowLink:
void CommandService.instance().execute('openItem', event.link);
break;
case EditorEventType.UpdateSearchDialog:
setSearchState(event.searchState);
case EditorEventType.UpdateSearchDialog: {
const hasExternalChange = (
event.changeSources.length !== 1
|| event.changeSources[0] !== noteEditorSearchChangeSource
);
if (event.searchState.dialogVisible) {
editorControl.searchControl.showSearch();
} else {
editorControl.searchControl.hideSearch();
// If the change to the search was done by this editor, it was already applied to the
// search state. Skipping the update in this case also helps avoid overwriting the
// search state with an older value.
if (hasExternalChange) {
setSearchState(event.searchState);
if (event.searchState.dialogVisible) {
editorControl.searchControl.showSearch();
} else {
editorControl.searchControl.hideSearch();
}
}
break;
}
case EditorEventType.Remove:
case EditorEventType.Scroll:
// Not handled

View File

@@ -158,8 +158,7 @@ export const SearchPanel = (props: SearchPanelProps) => {
const state = props.searchState;
const control = props.searchControl;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const updateSearchState = (changedData: any) => {
const updateSearchState = (changedData: Partial<SearchState>) => {
const newState = { ...state, ...changedData };
control.setSearchState(newState);
};

View File

@@ -56,13 +56,11 @@ const useWebViewSetup = ({
})})
` : '';
const injectedJavaScript = useMemo(() => `
if (typeof markdownEditorBundle === 'undefined') {
${shim.injectedJs('markdownEditorBundle')};
window.markdownEditorBundle = markdownEditorBundle;
markdownEditorBundle.setUpLogger();
}
const afterLoadFinishedJs = useRef((): string => '');
// Store as a callback to avoid rebuilding the string on each content change.
// Since the editor content is included in editorOptions, for large documents,
// creating the initial injected JS is potentially expensive.
afterLoadFinishedJs.current = () => `
if (!window.cm) {
const parentClassName = ${JSON.stringify(editorOptions?.parentElementOrClassName)};
const foundParent = !!parentClassName && document.getElementsByClassName(parentClassName).length > 0;
@@ -74,6 +72,7 @@ const useWebViewSetup = ({
window.cm = markdownEditorBundle.createMainEditor(${JSON.stringify(editorOptions)});
${jumpToHashJs}
// Set the initial selection after jumping to the header -- the initial selection,
// if specified, should take precedence.
${setInitialSelectionJs}
@@ -86,7 +85,15 @@ const useWebViewSetup = ({
console.log('No parent element found with class name ', parentClassName);
}
}
`, [jumpToHashJs, setInitialSearchJs, setInitialSelectionJs, editorOptions]);
`;
const injectedJavaScript = useMemo(() => `
if (typeof markdownEditorBundle === 'undefined') {
${shim.injectedJs('markdownEditorBundle')};
window.markdownEditorBundle = markdownEditorBundle;
markdownEditorBundle.setUpLogger();
}
`, []);
// Scroll to the new hash, if it changes.
const isFirstScrollRef = useRef(true);
@@ -158,13 +165,14 @@ const useWebViewSetup = ({
const webViewEventHandlers = useMemo(() => {
return {
onLoadEnd: () => {
webviewRef.current?.injectJS(afterLoadFinishedJs.current());
editorMessenger.onWebViewLoaded();
},
onMessage: (event: OnMessageEvent) => {
editorMessenger.onWebViewMessage(event);
},
};
}, [editorMessenger]);
}, [editorMessenger, webviewRef]);
const api = useMemo(() => {
return editorMessenger.remoteApi;

View File

@@ -98,7 +98,7 @@ export const initialize = async (
},
});
});
editor.setSearchState(initialSearch);
editor.setSearchState(initialSearch, 'initialSearch');
messenger.setLocalInterface({
editor,

View File

@@ -15,6 +15,7 @@ import { noteIdFacet, setNoteIdEffect } from './extensions/selectedNoteIdExtensi
import jumpToHash from './editorCommands/jumpToHash';
import { resetImageResourceEffect } from './extensions/rendering/renderBlockImages';
import Logger from '@joplin/utils/Logger';
import { searchChangeSourceEffect } from './extensions/searchExtension';
const logger = Logger.create('CodeMirrorControl');
@@ -181,7 +182,7 @@ export default class CodeMirrorControl extends CodeMirror5Emulation implements E
return getSearchState(this.editor.state);
}
public setSearchState(newState: SearchState) {
public setSearchState(newState: SearchState, changeSource = 'setSearchState') {
if (newState.dialogVisible !== searchPanelOpen(this.editor.state)) {
this.execCommand(newState.dialogVisible ? EditorCommandType.ShowSearch : EditorCommandType.HideSearch);
}
@@ -194,6 +195,7 @@ export default class CodeMirrorControl extends CodeMirror5Emulation implements E
});
this.editor.dispatch({
effects: [
searchChangeSourceEffect.of(changeSource),
setSearchQuery.of(query),
],
});

View File

@@ -1,17 +1,20 @@
import { EditorState, Extension } from '@codemirror/state';
import { EditorState, Extension, StateEffect } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import { EditorSettings, OnEventCallback } from '../../types';
import getSearchState from '../utils/getSearchState';
import { EditorEventType } from '../../events';
import { search, searchPanelOpen, setSearchQuery } from '@codemirror/search';
export const searchChangeSourceEffect = StateEffect.define<string>();
const searchExtension = (onEvent: OnEventCallback, settings: EditorSettings): Extension => {
const onSearchDialogUpdate = (state: EditorState) => {
const onSearchDialogUpdate = (state: EditorState, changeSources: string[]) => {
const newSearchState = getSearchState(state);
onEvent({
kind: EditorEventType.UpdateSearchDialog,
searchState: newSearchState,
changeSources,
});
};
@@ -30,7 +33,10 @@ const searchExtension = (onEvent: OnEventCallback, settings: EditorSettings): Ex
EditorState.transactionExtender.of((tr) => {
if (tr.effects.some(e => e.is(setSearchQuery)) || searchPanelOpen(tr.state) !== searchPanelOpen(tr.startState)) {
onSearchDialogUpdate(tr.state);
const changeSourceEffects = tr.effects.filter(effect => effect.is(searchChangeSourceEffect));
const changeSources = changeSourceEffects.map(effect => effect.value);
onSearchDialogUpdate(tr.state, changeSources);
}
return null;
}),

View File

@@ -265,8 +265,8 @@ const createEditor = async (
view.dispatch(transaction);
},
setSearchState: (newState: SearchState) => {
view.dispatch(updateSearchState(view.state, newState));
setSearchState: (newState: SearchState, changeSource = 'setSearchState') => {
view.dispatch(updateSearchState(view.state, newState, changeSource));
},
setContentScripts: (_plugins: ContentScriptData[]) => {
throw new Error('setContentScripts not implemented.');

View File

@@ -1,6 +1,6 @@
import { getSearchState, search, SearchQuery, setSearchState } from 'prosemirror-search';
import { SearchState } from '../../types';
import { Plugin, EditorState, Command } from 'prosemirror-state';
import { Plugin, EditorState, Command, Transaction } from 'prosemirror-state';
import { EditorEvent, EditorEventType } from '../../events';
const visiblePlugin = new Plugin({
@@ -32,7 +32,7 @@ export const setSearchVisible = (visible: boolean): Command => (state, dispatch)
const searchExtension = (onEditorEvent: (event: EditorEvent)=> void) => {
let lastState: SearchState|null = null;
const checkSearchStateChange = (state: EditorState) => {
const checkSearchStateChange = (state: EditorState, transaction: Transaction) => {
const currentQuery = getSearchState(state).query;
const currentVisible = getSearchVisible(state);
@@ -58,6 +58,7 @@ const searchExtension = (onEditorEvent: (event: EditorEvent)=> void) => {
onEditorEvent({
kind: EditorEventType.UpdateSearchDialog,
searchState: currentState,
changeSources: [transaction.getMeta(visiblePlugin)?.changeSource ?? 'unknown'],
});
}
};
@@ -65,8 +66,8 @@ const searchExtension = (onEditorEvent: (event: EditorEvent)=> void) => {
const checkStateChangePlugin = new Plugin<null>({
state: {
init: ()=>null,
apply: (_transaction, oldValue, _oldState, state) => {
checkSearchStateChange(state);
apply: (transaction, oldValue, _oldState, state) => {
checkSearchStateChange(state, transaction);
return oldValue;
},
},
@@ -78,7 +79,7 @@ const searchExtension = (onEditorEvent: (event: EditorEvent)=> void) => {
visiblePlugin,
checkStateChangePlugin,
],
updateState: (editorState: EditorState, searchState: SearchState) => {
updateState: (editorState: EditorState, searchState: SearchState, changeSource: string) => {
let transaction = editorState.tr;
setSearchVisible(searchState.dialogVisible)(editorState, (newTransaction) => {
transaction = newTransaction;
@@ -89,6 +90,7 @@ const searchExtension = (onEditorEvent: (event: EditorEvent)=> void) => {
regexp: searchState.useRegex,
replace: searchState.replaceText,
}));
transaction.setMeta(visiblePlugin, { changeSource });
lastState = { ...searchState };
return transaction;

View File

@@ -52,6 +52,7 @@ export interface EditorScrolledEvent {
export interface UpdateSearchDialogEvent {
kind: EditorEventType.UpdateSearchDialog;
searchState: SearchState;
changeSources: string[];
}
export interface RequestEditLinkEvent {

View File

@@ -122,7 +122,7 @@ export interface EditorControl {
// the given [label] and [url].
updateLink(label: string, url: string): void;
setSearchState(state: SearchState): void;
setSearchState(state: SearchState, changeSource: string): void;
setContentScripts(plugins: ContentScriptData[]): Promise<void>;