1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-24 10:27:10 +02:00

Desktop: Enable searching in editor rather than the viewer for CodeMirror (#3360)

This commit is contained in:
Caleb John 2020-07-22 16:13:23 -06:00 committed by GitHub
parent e68eb196b7
commit 44d3a4213f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 278 additions and 156 deletions

View File

@ -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
View File

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

View File

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

View File

@ -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 = {

View File

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

View File

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

View File

@ -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'),

View File

@ -43,6 +43,7 @@ export interface NoteBodyEditorProps {
disabled: boolean;
dispatch: Function;
noteToolbar: any;
setLocalSearchResultCount(count: number): void,
searchMarkers: any,
visiblePanes: string[],
keyboardMode: string,

View File

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

View File

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

View File

@ -21,6 +21,8 @@ const lightStyle = {
raisedBackgroundColor: '#e5e5e5',
raisedColor: '#222222',
searchMarkerBackgroundColor: '#F7D26E',
searchMarkerColor: 'black',
warningBackgroundColor: '#FFD08D',