You've already forked joplin
							
							
				mirror of
				https://github.com/laurent22/joplin.git
				synced 2025-10-31 00:07:48 +02:00 
			
		
		
		
	Desktop: Enable searching in editor rather than the viewer for CodeMirror (#3360)
This commit is contained in:
		| @@ -107,6 +107,7 @@ ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/Toolbar.js | ||||
| ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/index.js | ||||
| ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/types.js | ||||
| ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/useCursorUtils.js | ||||
| ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/useEditorSearch.js | ||||
| ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/useJoplinMode.js | ||||
| ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/useLineSorting.js | ||||
| ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/useListIdent.js | ||||
|   | ||||
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -98,6 +98,7 @@ ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/Toolbar.js | ||||
| ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/index.js | ||||
| ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/types.js | ||||
| ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/useCursorUtils.js | ||||
| ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/useEditorSearch.js | ||||
| ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/useJoplinMode.js | ||||
| ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/useLineSorting.js | ||||
| ElectronClient/gui/NoteEditor/NoteBody/CodeMirror/utils/useListIdent.js | ||||
|   | ||||
| @@ -25,6 +25,7 @@ const markdownUtils = require('lib/markdownUtils'); | ||||
| const { _ } = require('lib/locale'); | ||||
| const { reg } = require('lib/registry.js'); | ||||
| const dialogs = require('../../../dialogs'); | ||||
| const { themeStyle } = require('lib/theme'); | ||||
|  | ||||
| function markupRenderOptions(override: any = null) { | ||||
| 	return { ...override }; | ||||
| @@ -47,6 +48,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) { | ||||
| 	props_onChangeRef.current = props.onChange; | ||||
| 	const contentKeyHasChangedRef = useRef(false); | ||||
| 	contentKeyHasChangedRef.current = previousContentKey !== props.contentKey; | ||||
| 	const theme = themeStyle(props.theme); | ||||
|  | ||||
| 	const rootSize = useRootSize({ rootRef }); | ||||
|  | ||||
| @@ -274,6 +276,65 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) { | ||||
| 		menu.popup(bridge().window()); | ||||
| 	}, [props.content, editorCutText, editorPasteText, editorCopyText, onEditorPaste]); | ||||
|  | ||||
| 	useEffect(() => { | ||||
| 		const element = document.createElement('style'); | ||||
| 		element.setAttribute('id', 'codemirrorStyle'); | ||||
| 		document.head.appendChild(element); | ||||
| 		element.appendChild(document.createTextNode(` | ||||
| 			/* These must be important to prevent the codemirror defaults from taking over*/ | ||||
| 			.CodeMirror { | ||||
| 				font-family: monospace; | ||||
| 				height: 100% !important; | ||||
| 				width: 100% !important; | ||||
| 				color: inherit !important; | ||||
| 				background-color: inherit !important; | ||||
| 				position: absolute !important; | ||||
| 				-webkit-box-shadow: none !important; // Some themes add a box shadow for some reason | ||||
| 			} | ||||
| 			 | ||||
| 			.cm-header-1 { | ||||
| 				font-size: 1.5em; | ||||
| 			} | ||||
| 			 | ||||
| 			.cm-header-2 { | ||||
| 				font-size: 1.3em; | ||||
| 			} | ||||
| 			 | ||||
| 			.cm-header-3 { | ||||
| 				font-size: 1.1em; | ||||
| 			} | ||||
| 			 | ||||
| 			.cm-header-4, .cm-header-5, .cm-header-6 { | ||||
| 				font-size: 1em; | ||||
| 			} | ||||
| 			 | ||||
| 			.cm-header-1, .cm-header-2, .cm-header-3, .cm-header-4, .cm-header-5, .cm-header-6 { | ||||
| 				line-height: 1.5em; | ||||
| 			} | ||||
| 			 | ||||
| 			.cm-search-marker { | ||||
| 				background: ${theme.searchMarkerBackgroundColor}; | ||||
| 				color: ${theme.searchMarkerColor} !important; | ||||
| 			} | ||||
| 			 | ||||
| 			.cm-search-marker-selected { | ||||
| 				background: ${theme.selectedColor2}; | ||||
| 				color: ${theme.color2} !important; | ||||
| 			} | ||||
| 			 | ||||
| 			.cm-search-marker-scrollbar { | ||||
| 				background: ${theme.searchMarkerBackgroundColor}; | ||||
| 				-moz-box-sizing: border-box; | ||||
| 				box-sizing: border-box; | ||||
| 				opacity: .5; | ||||
| 			} | ||||
| 		`)); | ||||
|  | ||||
| 		return () => { | ||||
| 			document.head.removeChild(element); | ||||
| 		}; | ||||
| 	}, [props.theme]); | ||||
|  | ||||
| 	const webview_domReady = useCallback(() => { | ||||
| 		setWebviewReady(true); | ||||
| 	}, []); | ||||
| @@ -331,9 +392,41 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) { | ||||
|  | ||||
| 	useEffect(() => { | ||||
| 		if (props.searchMarkers !== previousSearchMarkers || renderedBody !== previousRenderedBody) { | ||||
| 			webviewRef.current.wrappedInstance.send('setMarkers', props.searchMarkers.keywords, props.searchMarkers.options); | ||||
| 			// Force both viewers to be visible during search | ||||
| 			// This view should only change when the search terms change, this means the user | ||||
| 			// is always presented with the currently highlighted text, but can revert | ||||
| 			// to the viewer if they only want to scroll through matches | ||||
| 			if (!props.visiblePanes.includes('editor') && props.searchMarkers !== previousSearchMarkers) { | ||||
| 				props.dispatch({ | ||||
| 					type: 'NOTE_VISIBLE_PANES_SET', | ||||
| 					panes: ['editor', 'viewer'], | ||||
| 				}); | ||||
| 			} | ||||
| 			// SEARCHHACK | ||||
| 			// TODO: remove this options hack when aceeditor is removed | ||||
| 			// Currently the webviewRef will send out an ipcMessage to set the results count | ||||
| 			// Also setting it here will start an infinite loop of repeating the search | ||||
| 			// Unfortunately we can't remove the function in the webview setMarkers | ||||
| 			// until the aceeditor is remove. | ||||
| 			// The below search is more accurate than the webview based one as it searches | ||||
| 			// the text and not rendered html (rendered html fails if there is a match | ||||
| 			// in a katex block) | ||||
| 			// Once AceEditor is removed the options definition below can be removed and | ||||
| 			// props.searchMarkers.options can be directly passed to as the 3rd argument below | ||||
| 			// (replacing options) | ||||
| 			let options = { notFromAce: true }; | ||||
| 			if (props.searchMarkers.options) { | ||||
| 				options = Object.assign({}, props.searchMarkers.options, options); | ||||
| 			} | ||||
| 			webviewRef.current.wrappedInstance.send('setMarkers', props.searchMarkers.keywords, options); | ||||
| 			//  SEARCHHACK | ||||
| 			if (editorRef.current) { | ||||
|  | ||||
| 				const matches = editorRef.current.setMarkers(props.searchMarkers.keywords, props.searchMarkers.options); | ||||
| 				props.setLocalSearchResultCount(matches); | ||||
| 			} | ||||
| 		} | ||||
| 	}, [props.searchMarkers, renderedBody]); | ||||
| 	}, [props.searchMarkers, props.setLocalSearchResultCount, renderedBody]); | ||||
|  | ||||
| 	const cellEditorStyle = useMemo(() => { | ||||
| 		const output = { ...styles.cellEditor }; | ||||
|   | ||||
| @@ -8,11 +8,15 @@ import 'codemirror/addon/dialog/dialog'; | ||||
| import 'codemirror/addon/edit/closebrackets'; | ||||
| import 'codemirror/addon/edit/continuelist'; | ||||
| import 'codemirror/addon/scroll/scrollpastend'; | ||||
| import 'codemirror/addon/scroll/annotatescrollbar'; | ||||
| import 'codemirror/addon/search/matchesonscrollbar'; | ||||
| import 'codemirror/addon/search/searchcursor'; | ||||
|  | ||||
| import useListIdent from './utils/useListIdent'; | ||||
| import useScrollUtils from './utils/useScrollUtils'; | ||||
| import useCursorUtils from './utils/useCursorUtils'; | ||||
| import useLineSorting from './utils/useLineSorting'; | ||||
| import useEditorSearch from './utils/useEditorSearch'; | ||||
| import useJoplinMode from './utils/useJoplinMode'; | ||||
|  | ||||
| import 'codemirror/keymap/emacs'; | ||||
| @@ -85,6 +89,7 @@ function Editor(props: EditorProps, ref: any) { | ||||
| 	useScrollUtils(CodeMirror); | ||||
| 	useCursorUtils(CodeMirror); | ||||
| 	useLineSorting(CodeMirror); | ||||
| 	useEditorSearch(CodeMirror); | ||||
| 	useJoplinMode(CodeMirror); | ||||
|  | ||||
| 	CodeMirror.keyMap.basic = { | ||||
|   | ||||
| @@ -11,7 +11,7 @@ interface ToolbarProps { | ||||
| } | ||||
|  | ||||
| function styles_(props:ToolbarProps) { | ||||
| 	return buildStyle('AceEditorToolbar', props.theme, (/* theme:any*/) => { | ||||
| 	return buildStyle('CodeMirrorToolbar', props.theme, (/* theme:any*/) => { | ||||
| 		const theme = themeStyle(props.theme); | ||||
| 		return { | ||||
| 			root: { | ||||
|   | ||||
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| @@ -0,0 +1,151 @@ | ||||
| import { useEffect, useRef, useState } from 'react'; | ||||
|  | ||||
| export default function useEditorSearch(CodeMirror: any) { | ||||
|  | ||||
| 	const [markers, setMarkers] = useState([]); | ||||
| 	const [overlay, setOverlay] = useState(null); | ||||
| 	const [scrollbarMarks, setScrollbarMarks] = useState(null); | ||||
| 	const [previousKeywordValue, setPreviousKeywordValue] = useState(null); | ||||
| 	const [overlayTimeout, setOverlayTimeout] = useState(null); | ||||
| 	const overlayTimeoutRef = useRef(null); | ||||
| 	overlayTimeoutRef.current = overlayTimeout; | ||||
|  | ||||
| 	function clearMarkers() { | ||||
| 		for (let i = 0; i < markers.length; i++) { | ||||
| 			markers[i].clear(); | ||||
| 		} | ||||
|  | ||||
| 		setMarkers([]); | ||||
| 	} | ||||
|  | ||||
| 	function clearOverlay(cm: any) { | ||||
| 		if (overlay) cm.removeOverlay(overlay); | ||||
| 		if (scrollbarMarks) scrollbarMarks.clear(); | ||||
|  | ||||
| 		if (overlayTimeout) clearTimeout(overlayTimeout); | ||||
|  | ||||
| 		setOverlay(null); | ||||
| 		setScrollbarMarks(null); | ||||
| 		setOverlayTimeout(null); | ||||
| 	} | ||||
|  | ||||
| 	// Modified from codemirror/addons/search/search.js | ||||
| 	function searchOverlay(query: RegExp) { | ||||
| 		return { token: function(stream: any) { | ||||
| 			query.lastIndex = stream.pos; | ||||
| 			const match = query.exec(stream.string); | ||||
| 			if (match && match.index == stream.pos) { | ||||
| 				stream.pos += match[0].length || 1; | ||||
| 				return 'search-marker'; | ||||
| 			} else if (match) { | ||||
| 				stream.pos = match.index; | ||||
| 			} else { | ||||
| 				stream.skipToEnd(); | ||||
| 			} | ||||
| 			return null; | ||||
| 		} }; | ||||
| 	} | ||||
|  | ||||
| 	// Highlights the currently active found work | ||||
| 	// It's possible to get tricky with this fucntions and just use findNext/findPrev | ||||
| 	// but this is fast enough and works more naturally with the current search logic | ||||
| 	function highlightSearch(cm: any, searchTerm: RegExp, index: number) { | ||||
| 		const marks: any = []; | ||||
|  | ||||
| 		const cursor = cm.getSearchCursor(searchTerm); | ||||
|  | ||||
| 		let match = null; | ||||
| 		for (let j = 0; j < index + 1; j++) { | ||||
| 			if (!cursor.findNext()) { | ||||
| 				// If we run out of matches then just highlight the final match | ||||
| 				break; | ||||
| 			} | ||||
| 			match = cursor.pos; | ||||
| 		} | ||||
|  | ||||
| 		if (match) { | ||||
| 			marks.push(cm.markText(match.from, match.to, { className: 'cm-search-marker-selected' })); | ||||
| 			cm.scrollIntoView(match); | ||||
| 		} | ||||
|  | ||||
| 		return marks; | ||||
| 	} | ||||
|  | ||||
| 	// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#Escaping | ||||
| 	function escapeRegExp(keyword: string) { | ||||
| 		return keyword.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string | ||||
| 	} | ||||
|  | ||||
| 	function getSearchTerm(keyword: any) { | ||||
| 		const value = escapeRegExp(keyword.value); | ||||
| 		return new RegExp(value, 'gi'); | ||||
| 	} | ||||
|  | ||||
| 	useEffect(() => { | ||||
| 		return () => { | ||||
| 			if (overlayTimeoutRef.current) clearTimeout(overlayTimeoutRef.current); | ||||
| 			overlayTimeoutRef.current = null; | ||||
| 		}; | ||||
| 	}, []); | ||||
|  | ||||
| 	CodeMirror.defineExtension('setMarkers', function(keywords: any, options: any) { | ||||
| 		if (!options) { | ||||
| 			options = { selectedIndex: 0 }; | ||||
| 		} | ||||
|  | ||||
| 		clearMarkers(); | ||||
|  | ||||
| 		// HIGHLIGHT KEYWORDS | ||||
| 		// When doing a global search it's possible to have multiple keywords | ||||
| 		// This means we need to highlight each one | ||||
| 		let marks: any = []; | ||||
| 		for (let i = 0; i < keywords.length; i++) { | ||||
| 			const keyword = keywords[i]; | ||||
|  | ||||
| 			if (keyword.value === '') continue; | ||||
|  | ||||
| 			const searchTerm = getSearchTerm(keyword); | ||||
|  | ||||
| 			marks = marks.concat(highlightSearch(this, searchTerm, options.selectedIndex)); | ||||
| 		} | ||||
|  | ||||
| 		setMarkers(marks); | ||||
|  | ||||
| 		// SEARCHOVERLAY | ||||
| 		// We only want to highlight all matches when there is only 1 search term | ||||
| 		if (keywords.length !== 1 || keywords[0].value == '') { | ||||
| 			clearOverlay(this); | ||||
| 			setPreviousKeywordValue(''); | ||||
| 			return 0; | ||||
| 		} | ||||
|  | ||||
| 		const searchTerm = getSearchTerm(keywords[0]); | ||||
|  | ||||
| 		// Determine the number of matches in the source, this is passed on | ||||
| 		// to the NoteEditor component | ||||
| 		const regexMatches = this.getValue().match(searchTerm); | ||||
| 		const nMatches = regexMatches ? regexMatches.length : 0; | ||||
|  | ||||
| 		// Don't bother clearing and re-calculating the overlay if the search term | ||||
| 		// hasn't changed | ||||
| 		if (keywords[0].value === previousKeywordValue) return nMatches; | ||||
|  | ||||
| 		clearOverlay(this); | ||||
| 		setPreviousKeywordValue(keywords[0].value); | ||||
|  | ||||
| 		// These operations are pretty slow, so we won't add use them until the user | ||||
| 		// has finished typing, 500ms is probably enough time | ||||
| 		const timeout = setTimeout(() => { | ||||
| 			const scrollMarks = this.showMatchesOnScrollbar(searchTerm, true, 'cm-search-marker-scrollbar'); | ||||
| 			const overlay = searchOverlay(searchTerm); | ||||
| 			this.addOverlay(overlay); | ||||
| 			setOverlay(overlay); | ||||
| 			setScrollbarMarks(scrollMarks); | ||||
| 		}, 500); | ||||
|  | ||||
| 		setOverlayTimeout(timeout); | ||||
| 		overlayTimeoutRef.current = timeout; | ||||
|  | ||||
| 		return nMatches; | ||||
| 	}); | ||||
| } | ||||
| @@ -397,6 +397,7 @@ function NoteEditor(props: NoteEditorProps) { | ||||
| 		dispatch: props.dispatch, | ||||
| 		noteToolbar: null,// renderNoteToolbar(), | ||||
| 		onScroll: onScroll, | ||||
| 		setLocalSearchResultCount: setLocalSearchResultCount, | ||||
| 		searchMarkers: searchMarkers, | ||||
| 		visiblePanes: props.noteVisiblePanes || ['editor', 'viewer'], | ||||
| 		keyboardMode: Setting.value('editor.keyboardMode'), | ||||
|   | ||||
| @@ -43,6 +43,7 @@ export interface NoteBodyEditorProps { | ||||
| 	disabled: boolean; | ||||
| 	dispatch: Function; | ||||
| 	noteToolbar: any; | ||||
| 	setLocalSearchResultCount(count: number): void, | ||||
| 	searchMarkers: any, | ||||
| 	visiblePanes: string[], | ||||
| 	keyboardMode: string, | ||||
|   | ||||
| @@ -21,7 +21,7 @@ | ||||
| 		} | ||||
|  | ||||
| 		mark { | ||||
| 			background: #F3B717; | ||||
| 			background: #F7D26E; | ||||
| 			color: black; | ||||
| 		} | ||||
|  | ||||
| @@ -272,7 +272,11 @@ | ||||
| 			let elementIndex = 0; | ||||
|  | ||||
| 			const onEachElement = (element) => { | ||||
| 				if (!('selectedIndex' in options)) return; | ||||
| 				// SEARCHHACK | ||||
| 				// TODO: remove notFromAce hack when removing aceeditor | ||||
| 				// when removing just remove the 'notFromAce' part and leave the rest alone | ||||
| 				if (!('selectedIndex' in options) || 'notFromAce' in options) return; | ||||
| 				// SEARCHHACK | ||||
|  | ||||
| 				if (('selectedIndex' in options) && elementIndex === options.selectedIndex) { | ||||
| 					markSelectedElement_ = element; | ||||
| @@ -298,13 +302,21 @@ | ||||
| 				}, markKeywordOptions); | ||||
| 			} | ||||
|  | ||||
| 			ipcProxySendToHost('setMarkerCount', elementIndex); | ||||
| 			// SEARCHHACK | ||||
| 			// TODO: Remove this block (until the other SEARCHHACK marker) when removing Ace | ||||
| 			// HACK: Aceeditor uses this view to handle all the searching | ||||
| 			// The newer editor wont and this needs to be disabled in order to  | ||||
| 			// prevent an infinite loop | ||||
| 			if (!('notFromAce' in options)) { | ||||
| 				ipcProxySendToHost('setMarkerCount', elementIndex); | ||||
|  | ||||
| 			// We only scroll the element into view if the search just happened. So when the user type the search | ||||
| 			// or select the next/previous result, we scroll into view. However for other actions that trigger a | ||||
| 			// re-render, we don't scroll as this is normally not wanted. | ||||
| 			// This is to go around this issue: https://github.com/laurent22/joplin/issues/1833 | ||||
| 			if (selectedElement && Date.now() - options.searchTimestamp <= 1000) selectedElement.scrollIntoView(); | ||||
| 				// We only scroll the element into view if the search just happened. So when the user type the search | ||||
| 				// or select the next/previous result, we scroll into view. However for other actions that trigger a | ||||
| 				// re-render, we don't scroll as this is normally not wanted. | ||||
| 				// This is to go around this issue: https://github.com/laurent22/joplin/issues/1833 | ||||
| 				if (selectedElement && Date.now() - options.searchTimestamp <= 1000) selectedElement.scrollIntoView(); | ||||
| 			} | ||||
| 			// SEARCHHACK | ||||
| 		} | ||||
|  | ||||
| 		let markLoader_ = { state: 'idle', whenDone: null }; | ||||
|   | ||||
| @@ -170,33 +170,3 @@ a { | ||||
| 	from {transform: rotate(0deg);} | ||||
| 	to {transform: rotate(360deg);} | ||||
| } | ||||
|  | ||||
| /* These must be important to prevent the codemirror defaults from taking over*/ | ||||
| .CodeMirror { | ||||
| 	font-family: monospace; | ||||
| 	height: 100% !important; | ||||
| 	width: 100% !important; | ||||
| 	color: inherit !important; | ||||
| 	background-color: inherit !important; | ||||
| 	position: absolute !important; | ||||
| } | ||||
|  | ||||
| .cm-header-1 { | ||||
| 	font-size: 1.5em; | ||||
| } | ||||
|  | ||||
| .cm-header-2 { | ||||
| 	font-size: 1.3em; | ||||
| } | ||||
|  | ||||
| .cm-header-3 { | ||||
| 	font-size: 1.1em; | ||||
| } | ||||
|  | ||||
| .cm-header-4, .cm-header-5, .cm-header-6 { | ||||
| 	font-size: 1em; | ||||
| } | ||||
|  | ||||
| .cm-header-1, .cm-header-2, .cm-header-3, .cm-header-4, .cm-header-5, .cm-header-6 { | ||||
| 	line-height: 1.5em; | ||||
| } | ||||
|   | ||||
| @@ -21,6 +21,8 @@ const lightStyle = { | ||||
|  | ||||
| 	raisedBackgroundColor: '#e5e5e5', | ||||
| 	raisedColor: '#222222', | ||||
| 	searchMarkerBackgroundColor: '#F7D26E', | ||||
| 	searchMarkerColor: 'black', | ||||
|  | ||||
| 	warningBackgroundColor: '#FFD08D', | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user