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

Desktop: Resolves #9927: Beta editor: Fix search results not highlighted (#9928)

This commit is contained in:
Henry Heino
2024-03-06 01:53:07 -08:00
committed by GitHub
parent 5e4c35a18f
commit 20f8bb76f7
12 changed files with 295 additions and 67 deletions

View File

@@ -258,7 +258,8 @@ packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/types.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useContextMenu.js packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useContextMenu.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useCursorUtils.test.js packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useCursorUtils.test.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useCursorUtils.js packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useCursorUtils.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useEditorSearch.js packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useEditorSearchExtension.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useEditorSearchHandler.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useExternalPlugins.js packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useExternalPlugins.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useJoplinCommands.js packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useJoplinCommands.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useJoplinMode.js packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useJoplinMode.js

3
.gitignore vendored
View File

@@ -238,7 +238,8 @@ packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/types.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useContextMenu.js packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useContextMenu.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useCursorUtils.test.js packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useCursorUtils.test.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useCursorUtils.js packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useCursorUtils.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useEditorSearch.js packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useEditorSearchExtension.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useEditorSearchHandler.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useExternalPlugins.js packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useExternalPlugins.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useJoplinCommands.js packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useJoplinCommands.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useJoplinMode.js packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useJoplinMode.js

View File

@@ -1,10 +1,14 @@
import { useEffect, useRef, useState } from 'react'; import { 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';
const logger = Logger.create('useEditorSearch'); const logger = Logger.create('useEditorSearch');
export default function useEditorSearch(CodeMirror: any) { // Registers a helper CodeMirror extension to be used with
// useEditorSearchHandler.
export default function useEditorSearchExtension(CodeMirror: CodeMirror5Emulation) {
const [markers, setMarkers] = useState([]); const [markers, setMarkers] = useState([]);
const [overlay, setOverlay] = useState(null); const [overlay, setOverlay] = useState(null);
@@ -73,7 +77,7 @@ export default function useEditorSearch(CodeMirror: any) {
// If we run out of matches then just highlight the final match // If we run out of matches then just highlight the final match
break; break;
} }
match = cursor.pos; match = { from: cursor.from(), to: cursor.to() };
} }
if (match) { if (match) {
@@ -81,7 +85,7 @@ export default function useEditorSearch(CodeMirror: any) {
if (withSelection) { if (withSelection) {
cm.setSelection(match.from, match.to); cm.setSelection(match.from, match.to);
} else { } else {
cm.scrollTo(match); cm.scrollIntoView(match);
} }
} }
return cm.markText(match.from, match.to, { className: 'cm-search-marker-selected' }); return cm.markText(match.from, match.to, { className: 'cm-search-marker-selected' });
@@ -107,7 +111,7 @@ export default function useEditorSearch(CodeMirror: any) {
}; };
}, []); }, []);
CodeMirror.defineExtension('setMarkers', function(keywords: any, options: any) { CodeMirror?.defineExtension('setMarkers', function(keywords: any, options: any) {
if (!options) { if (!options) {
options = { selectedIndex: 0, searchTimestamp: 0 }; options = { selectedIndex: 0, searchTimestamp: 0 };
} }
@@ -172,7 +176,7 @@ export default function useEditorSearch(CodeMirror: any) {
// These operations are pretty slow, so we won't add use them until the user // These operations are pretty slow, so we won't add use them until the user
// has finished typing, 500ms is probably enough time // has finished typing, 500ms is probably enough time
const timeout = shim.setTimeout(() => { const timeout = shim.setTimeout(() => {
const scrollMarks = this.showMatchesOnScrollbar(searchTerm, true, 'cm-search-marker-scrollbar'); const scrollMarks = this.showMatchesOnScrollbar?.(searchTerm, true, 'cm-search-marker-scrollbar');
const overlay = searchOverlay(searchTerm); const overlay = searchOverlay(searchTerm);
this.addOverlay(overlay); this.addOverlay(overlay);
setOverlay(overlay); setOverlay(overlay);

View File

@@ -0,0 +1,66 @@
import { RefObject, useEffect } from 'react';
import usePrevious from '../../../../hooks/usePrevious';
import { RenderedBody } from './types';
const debounce = require('debounce');
interface Props {
setLocalSearchResultCount(count: number): void;
searchMarkers: any;
webviewRef: RefObject<any>;
editorRef: RefObject<any>;
noteContent: string;
renderedBody: RenderedBody;
}
const useEditorSearchHandler = (props: Props) => {
const { webviewRef, editorRef, renderedBody, noteContent, searchMarkers } = props;
const previousContent = usePrevious(noteContent);
const previousRenderedBody = usePrevious(renderedBody);
const previousSearchMarkers = usePrevious(searchMarkers);
useEffect(() => {
if (!searchMarkers) return () => {};
// If there is a currently active search, it's important to re-search the text as the user
// types. However this is slow for performance so we ONLY want it to happen when there is
// a search
// Note that since the CodeMirror component also needs to handle the viewer pane, we need
// to check if the rendered body has changed too (it will be changed with a delay after
// props.content has been updated).
const textChanged = searchMarkers.keywords.length > 0 && (noteContent !== previousContent || renderedBody !== previousRenderedBody);
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();
};
}
}
return () => {};
}, [
editorRef,
webviewRef,
searchMarkers,
previousSearchMarkers,
props.setLocalSearchResultCount,
noteContent,
previousContent,
previousRenderedBody,
renderedBody,
]);
};
export default useEditorSearchHandler;

View File

@@ -6,7 +6,7 @@ import { EditorCommand, MarkupToHtmlOptions, NoteBodyEditorProps, NoteBodyEditor
import { commandAttachFileToBody, getResourcesFromPasteEvent } from '../../../utils/resourceHandling'; import { commandAttachFileToBody, getResourcesFromPasteEvent } from '../../../utils/resourceHandling';
import { ScrollOptions, ScrollOptionTypes } from '../../../utils/types'; import { ScrollOptions, ScrollOptionTypes } from '../../../utils/types';
import { CommandValue } from '../../../utils/types'; import { CommandValue } from '../../../utils/types';
import { usePrevious, cursorPositionToTextOffset } from '../utils'; import { cursorPositionToTextOffset } from '../utils';
import useScrollHandler from '../utils/useScrollHandler'; import useScrollHandler from '../utils/useScrollHandler';
import useElementSize from '@joplin/lib/hooks/useElementSize'; import useElementSize from '@joplin/lib/hooks/useElementSize';
import Toolbar from '../Toolbar'; import Toolbar from '../Toolbar';
@@ -25,13 +25,13 @@ import { ThemeAppearance } from '@joplin/lib/themes/type';
import dialogs from '../../../../dialogs'; import dialogs from '../../../../dialogs';
import { MarkupToHtml } from '@joplin/renderer'; import { MarkupToHtml } from '@joplin/renderer';
const { clipboard } = require('electron'); const { clipboard } = require('electron');
const debounce = require('debounce');
import { reg } from '@joplin/lib/registry'; import { reg } from '@joplin/lib/registry';
import ErrorBoundary from '../../../../ErrorBoundary'; import ErrorBoundary from '../../../../ErrorBoundary';
import useStyles from '../utils/useStyles'; import useStyles from '../utils/useStyles';
import useContextMenu from '../utils/useContextMenu'; import useContextMenu from '../utils/useContextMenu';
import useWebviewIpcMessage from '../utils/useWebviewIpcMessage'; import useWebviewIpcMessage from '../utils/useWebviewIpcMessage';
import useEditorSearchHandler from '../utils/useEditorSearchHandler';
function markupRenderOptions(override: MarkupToHtmlOptions = null): MarkupToHtmlOptions { function markupRenderOptions(override: MarkupToHtmlOptions = null): MarkupToHtmlOptions {
return { ...override }; return { ...override };
@@ -45,10 +45,6 @@ function CodeMirror(props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
const [webviewReady, setWebviewReady] = useState(false); const [webviewReady, setWebviewReady] = useState(false);
const previousContent = usePrevious(props.content);
const previousRenderedBody = usePrevious(renderedBody);
const previousSearchMarkers = usePrevious(props.searchMarkers);
const editorRef = useRef(null); const editorRef = useRef(null);
const rootRef = useRef(null); const rootRef = useRef(null);
const webviewRef = useRef(null); const webviewRef = useRef(null);
@@ -675,37 +671,14 @@ function CodeMirror(props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied // eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [renderedBody, webviewReady]); }, [renderedBody, webviewReady]);
useEffect(() => { useEditorSearchHandler({
if (!props.searchMarkers) return () => {}; setLocalSearchResultCount: props.setLocalSearchResultCount,
searchMarkers: props.searchMarkers,
// If there is a currently active search, it's important to re-search the text as the user webviewRef,
// types. However this is slow for performance so we ONLY want it to happen when there is editorRef,
// a search noteContent: props.content,
renderedBody,
// Note that since the CodeMirror component also needs to handle the viewer pane, we need });
// to check if the rendered body has changed too (it will be changed with a delay after
// props.content has been updated).
const textChanged = props.searchMarkers.keywords.length > 0 && (props.content !== previousContent || renderedBody !== previousRenderedBody);
if (webviewRef.current && (props.searchMarkers !== previousSearchMarkers || textChanged)) {
webviewRef.current.send('setMarkers', props.searchMarkers.keywords, props.searchMarkers.options);
if (editorRef.current) {
// Fixes https://github.com/laurent22/joplin/issues/7565
const debouncedMarkers = debounce(() => {
const matches = editorRef.current.setMarkers(props.searchMarkers.keywords, props.searchMarkers.options);
props.setLocalSearchResultCount(matches);
}, 50);
debouncedMarkers();
return () => {
debouncedMarkers.clear();
};
}
}
return () => {};
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [props.searchMarkers, previousSearchMarkers, props.setLocalSearchResultCount, props.content, previousContent, renderedBody, previousRenderedBody, renderedBody]);
const cellEditorStyle = useMemo(() => { const cellEditorStyle = useMemo(() => {
const output = { ...styles.cellEditor }; const output = { ...styles.cellEditor };

View File

@@ -16,7 +16,7 @@ import useListIdent from '../utils/useListIdent';
import useScrollUtils from '../utils/useScrollUtils'; import useScrollUtils from '../utils/useScrollUtils';
import useCursorUtils from '../utils/useCursorUtils'; import useCursorUtils from '../utils/useCursorUtils';
import useLineSorting from '../utils/useLineSorting'; import useLineSorting from '../utils/useLineSorting';
import useEditorSearch from '../utils/useEditorSearch'; import useEditorSearch from '../utils/useEditorSearchExtension';
import useJoplinMode from '../utils/useJoplinMode'; import useJoplinMode from '../utils/useJoplinMode';
import useKeymap from '../utils/useKeymap'; import useKeymap from '../utils/useKeymap';
import useExternalPlugins from '../utils/useExternalPlugins'; import useExternalPlugins from '../utils/useExternalPlugins';

View File

@@ -26,6 +26,7 @@ import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl';
import useContextMenu from '../utils/useContextMenu'; import useContextMenu from '../utils/useContextMenu';
import useWebviewIpcMessage from '../utils/useWebviewIpcMessage'; import useWebviewIpcMessage from '../utils/useWebviewIpcMessage';
import Toolbar from '../Toolbar'; import Toolbar from '../Toolbar';
import useEditorSearchHandler from '../utils/useEditorSearchHandler';
const logger = Logger.create('CodeMirror6'); const logger = Logger.create('CodeMirror6');
const logDebug = (message: string) => logger.debug(message); const logDebug = (message: string) => logger.debug(message);
@@ -334,6 +335,15 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
// } // }
// }, [editorPaneVisible]); // }, [editorPaneVisible]);
useEditorSearchHandler({
setLocalSearchResultCount: props.setLocalSearchResultCount,
searchMarkers: props.searchMarkers,
webviewRef,
editorRef,
noteContent: props.content,
renderedBody,
});
useContextMenu({ useContextMenu({
plugins: props.plugins, plugins: props.plugins,
editorCutText, editorCopyText, editorPaste, editorCutText, editorCopyText, editorPaste,

View File

@@ -10,6 +10,7 @@ import shim from '@joplin/lib/shim';
import PluginService from '@joplin/lib/services/plugins/PluginService'; import PluginService from '@joplin/lib/services/plugins/PluginService';
import setupVim from '@joplin/editor/CodeMirror/util/setupVim'; import setupVim from '@joplin/editor/CodeMirror/util/setupVim';
import { dirname } from 'path'; import { dirname } from 'path';
import useEditorSearch from '../utils/useEditorSearchExtension';
interface Props extends EditorProps { interface Props extends EditorProps {
style: React.CSSProperties; style: React.CSSProperties;
@@ -32,6 +33,8 @@ const Editor = (props: Props, ref: ForwardedRef<CodeMirrorControl>) => {
onLogMessageRef.current = props.onLogMessage; onLogMessageRef.current = props.onLogMessage;
}, [props.onEvent, props.onLogMessage]); }, [props.onEvent, props.onLogMessage]);
useEditorSearch(editor);
useEffect(() => { useEffect(() => {
if (!editor) { if (!editor) {
return () => {}; return () => {};
@@ -104,6 +107,26 @@ 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
}, []); }, []);
const theme = props.settings.themeData;
useEffect(() => {
if (!editor) return () => {};
const styles = editor.addStyles({
'& .cm-search-marker *, & .cm-search-marker': {
color: theme.searchMarkerColor,
backgroundColor: theme.searchMarkerBackgroundColor,
},
'& .cm-search-marker-selected *, & .cm-search-marker-selected': {
background: `${theme.selectedColor2} !important`,
color: `${theme.color2} !important`,
},
});
return () => {
styles.remove();
};
}, [editor, theme]);
useEffect(() => { useEffect(() => {
editor?.updateSettings(props.settings); editor?.updateSettings(props.settings);
}, [props.settings, editor]); }, [props.settings, editor]);

View File

@@ -109,6 +109,41 @@ describe('CodeMirror5Emulation', () => {
expect(onOtherOptionUpdate).toHaveBeenCalledTimes(1); expect(onOtherOptionUpdate).toHaveBeenCalledTimes(1);
}); });
it('markText decorations should be removable', () => {
const codeMirror = makeCodeMirrorEmulation('Test 1\nTest 2');
const markDecoration = codeMirror.markText(
{ line: 0, ch: 0 },
{ line: 0, ch: 6 },
{ className: 'test-mark-decoration' },
);
const markDecoration2 = codeMirror.markText(
{ line: 1, ch: 0 },
{ line: 1, ch: 1 },
{ className: 'test-decoration-2' },
);
const editorDom = codeMirror.cm6.dom;
expect(editorDom.querySelectorAll('.test-mark-decoration')).toHaveLength(1);
expect(editorDom.querySelectorAll('.test-decoration-2')).toHaveLength(1);
codeMirror.setCursor(0, 2);
codeMirror.replaceSelection('!Test!');
// Editing the document shouldn't remove the mark
expect(codeMirror.editor.state.doc.toString()).toBe('Te!Test!st 1\nTest 2');
expect(editorDom.querySelectorAll('.test-mark-decoration')).toHaveLength(1);
// Clearing should remove only the decoration that was cleared.
markDecoration.clear();
expect(editorDom.querySelectorAll('.test-mark-decoration')).toHaveLength(0);
expect(editorDom.querySelectorAll('.test-decoration-2')).toHaveLength(1);
markDecoration2.clear();
expect(editorDom.querySelectorAll('.test-decoration-2')).toHaveLength(0);
});
it('defineExtension should override previous extensions with the same name', () => { it('defineExtension should override previous extensions with the same name', () => {
const codeMirror = makeCodeMirrorEmulation('Test...'); const codeMirror = makeCodeMirrorEmulation('Test...');
const testExtensionFn1 = jest.fn(); const testExtensionFn1 = jest.fn();

View File

@@ -6,7 +6,7 @@ import { LogMessageCallback } from '../../types';
import editorCommands from '../editorCommands/editorCommands'; import editorCommands from '../editorCommands/editorCommands';
import { StateEffect } from '@codemirror/state'; import { StateEffect } from '@codemirror/state';
import { StreamParser } from '@codemirror/language'; import { StreamParser } from '@codemirror/language';
import Decorator, { LineWidgetOptions } from './Decorator'; import Decorator, { LineWidgetOptions, MarkTextOptions } from './Decorator';
import insertLineAfter from '../editorCommands/insertLineAfter'; import insertLineAfter from '../editorCommands/insertLineAfter';
const { pregQuote } = require('@joplin/lib/string-utils-common'); const { pregQuote } = require('@joplin/lib/string-utils-common');
@@ -16,6 +16,8 @@ type CodeMirror5Command = (codeMirror: CodeMirror5Emulation)=> void;
type EditorEventCallback = (editor: CodeMirror5Emulation, ...args: any[])=> void; type EditorEventCallback = (editor: CodeMirror5Emulation, ...args: any[])=> void;
type OptionUpdateCallback = (editor: CodeMirror5Emulation, newVal: any, oldVal: any)=> void; type OptionUpdateCallback = (editor: CodeMirror5Emulation, newVal: any, oldVal: any)=> void;
type OverlayType<State> = StreamParser<State>|{ query: RegExp };
interface CodeMirror5OptionRecord { interface CodeMirror5OptionRecord {
onUpdate: OptionUpdateCallback; onUpdate: OptionUpdateCallback;
value: any; value: any;
@@ -26,6 +28,11 @@ interface DocumentPosition {
ch: number; ch: number;
} }
interface DocumentPositionRange {
from: DocumentPosition;
to: DocumentPosition;
}
const documentPositionFromPos = (doc: Text, pos: number): DocumentPosition => { const documentPositionFromPos = (doc: Text, pos: number): DocumentPosition => {
const line = doc.lineAt(pos); const line = doc.lineAt(pos);
return { return {
@@ -35,6 +42,11 @@ const documentPositionFromPos = (doc: Text, pos: number): DocumentPosition => {
}; };
}; };
const posFromDocumentPosition = (doc: Text, pos: DocumentPosition) => {
const line = doc.line(pos.line + 1);
return line.from + pos.ch;
};
export default class CodeMirror5Emulation extends BaseCodeMirror5Emulation { export default class CodeMirror5Emulation extends BaseCodeMirror5Emulation {
private _events: Record<string, EditorEventCallback[]> = {}; private _events: Record<string, EditorEventCallback[]> = {};
private _options: Record<string, CodeMirror5OptionRecord> = Object.create(null); private _options: Record<string, CodeMirror5OptionRecord> = Object.create(null);
@@ -271,6 +283,21 @@ export default class CodeMirror5Emulation extends BaseCodeMirror5Emulation {
return getScrollFraction(this.editor); return getScrollFraction(this.editor);
} }
// CodeMirror-Vim's scrollIntoView only supports pos as a DocumentPosition.
public override scrollIntoView(
pos: DocumentPosition|DocumentPositionRange, margin?: number,
): void {
const isPosition = (arg: unknown): arg is DocumentPosition => {
return (arg as any).line !== undefined && (arg as any).ch !== undefined;
};
if (isPosition(pos)) {
return super.scrollIntoView(pos, margin);
} else {
return super.scrollIntoView(pos.from, margin);
}
}
public defineExtension(name: string, value: any) { public defineExtension(name: string, value: any) {
(CodeMirror5Emulation.prototype as any)[name] = value; (CodeMirror5Emulation.prototype as any)[name] = value;
} }
@@ -304,12 +331,17 @@ export default class CodeMirror5Emulation extends BaseCodeMirror5Emulation {
// codemirror-vim's API doesn't match the API docs here -- it expects addOverlay // codemirror-vim's API doesn't match the API docs here -- it expects addOverlay
// to return a SearchQuery. As such, this override returns "any". // to return a SearchQuery. As such, this override returns "any".
public override addOverlay<State>(modeObject: StreamParser<State>|{ query: RegExp }): any { public override addOverlay<State>(modeObject: OverlayType<State>): any {
if ('query' in modeObject) { if ('query' in modeObject) {
return super.addOverlay(modeObject); return super.addOverlay(modeObject);
} }
this._decorator.addOverlay(modeObject); return this._decorator.addOverlay(modeObject);
}
public override removeOverlay(overlay?: OverlayType<any>): void {
super.removeOverlay(overlay);
this._decorator.removeOverlay(overlay);
} }
public addLineClass(lineNumber: number, where: string, className: string) { public addLineClass(lineNumber: number, where: string, className: string) {
@@ -324,6 +356,16 @@ export default class CodeMirror5Emulation extends BaseCodeMirror5Emulation {
this._decorator.addLineWidget(lineNumber, node, options); this._decorator.addLineWidget(lineNumber, node, options);
} }
public markText(from: DocumentPosition, to: DocumentPosition, options?: MarkTextOptions) {
const doc = this.editor.state.doc;
return this._decorator.markText(
posFromDocumentPosition(doc, from),
posFromDocumentPosition(doc, to),
options,
);
}
// TODO: Currently copied from useCursorUtils.ts. // TODO: Currently copied from useCursorUtils.ts.
// TODO: Remove the duplicate code when CodeMirror 5 is eventually removed. // TODO: Remove the duplicate code when CodeMirror 5 is eventually removed.
public wrapSelections(string1: string, string2: string) { public wrapSelections(string1: string, string2: string) {

View File

@@ -24,15 +24,16 @@ const mapRangeConfig = {
}, },
}; };
interface LineCssDecorationSpec extends DecorationRange { interface CssDecorationSpec extends DecorationRange {
cssClass: string; cssClass: string;
id?: number;
} }
const addLineDecorationEffect = StateEffect.define<LineCssDecorationSpec>(mapRangeConfig); const addLineDecorationEffect = StateEffect.define<CssDecorationSpec>(mapRangeConfig);
const removeLineDecorationEffect = StateEffect.define<LineCssDecorationSpec>(mapRangeConfig); const removeLineDecorationEffect = StateEffect.define<CssDecorationSpec>(mapRangeConfig);
const addMarkDecorationEffect = StateEffect.define<LineCssDecorationSpec>(mapRangeConfig); const addMarkDecorationEffect = StateEffect.define<CssDecorationSpec>(mapRangeConfig);
// TODO: Support removing mark decorations const removeMarkDecorationEffect = StateEffect.define<CssDecorationSpec>(mapRangeConfig);
// const removeMarkDecorationEffect = StateEffect.define<LineDecorationSpec>(mapRangeConfig); const refreshOverlaysEffect = StateEffect.define();
export interface LineWidgetOptions { export interface LineWidgetOptions {
className?: string; className?: string;
@@ -46,6 +47,9 @@ interface LineWidgetDecorationSpec extends DecorationRange {
const addLineWidgetEffect = StateEffect.define<LineWidgetDecorationSpec>(mapRangeConfig); const addLineWidgetEffect = StateEffect.define<LineWidgetDecorationSpec>(mapRangeConfig);
const removeLineWidgetEffect = StateEffect.define<{ element: HTMLElement }>(); const removeLineWidgetEffect = StateEffect.define<{ element: HTMLElement }>();
export interface MarkTextOptions {
className: string;
}
class WidgetDecorationWrapper extends WidgetType { class WidgetDecorationWrapper extends WidgetType {
public constructor( public constructor(
@@ -78,6 +82,7 @@ interface LineWidgetControl {
export default class Decorator { export default class Decorator {
private _extension: Extension; private _extension: Extension;
private _effectDecorations: DecorationSet = Decoration.none; private _effectDecorations: DecorationSet = Decoration.none;
private _nextLineWidgetId = 0;
private constructor(private editor: EditorView) { private constructor(private editor: EditorView) {
const decorator = this; const decorator = this;
@@ -93,8 +98,24 @@ export default class Decorator {
} }
public update(update: ViewUpdate) { public update(update: ViewUpdate) {
if (update.viewportChanged || update.docChanged) { const updated = false;
const doUpdate = () => {
if (updated) return;
this.decorations = decorator.createOverlayDecorations(update.view); this.decorations = decorator.createOverlayDecorations(update.view);
};
if (update.viewportChanged || update.docChanged) {
doUpdate();
} else {
for (const transaction of update.transactions) {
for (const effect of transaction.effects) {
if (effect.is(refreshOverlaysEffect)) {
doUpdate();
break;
}
}
}
} }
} }
}, { }, {
@@ -120,18 +141,18 @@ export default class Decorator {
private _decorationCache: Record<string, Decoration> = Object.create(null); private _decorationCache: Record<string, Decoration> = Object.create(null);
private _overlays: (StreamParser<any>)[] = []; private _overlays: (StreamParser<any>)[] = [];
private classNameToCssDecoration(className: string, isLineDecoration: boolean) { private classNameToCssDecoration(className: string, isLineDecoration: boolean, id?: number) {
let decoration; let decoration;
if (className in this._decorationCache) { if (className in this._decorationCache && id === undefined) {
decoration = this._decorationCache[className]; decoration = this._decorationCache[className];
} else { } else {
const attributes = { class: className }; const attributes = { class: className };
if (isLineDecoration) { if (isLineDecoration) {
decoration = Decoration.line({ attributes }); decoration = Decoration.line({ attributes, id });
} else { } else {
decoration = Decoration.mark({ attributes }); decoration = Decoration.mark({ attributes, id });
} }
this._decorationCache[className] = decoration; this._decorationCache[className] = decoration;
@@ -153,7 +174,7 @@ export default class Decorator {
const isLineDecoration = effect.is(addLineDecorationEffect); const isLineDecoration = effect.is(addLineDecorationEffect);
if (isMarkDecoration || isLineDecoration) { if (isMarkDecoration || isLineDecoration) {
const decoration = this.classNameToCssDecoration( const decoration = this.classNameToCssDecoration(
effect.value.cssClass, isLineDecoration, effect.value.cssClass, isLineDecoration, effect.value.id,
); );
const value = effect.value; const value = effect.value;
@@ -165,21 +186,25 @@ export default class Decorator {
decorations = decorations.update({ decorations = decorations.update({
add: [decoration.range(from, to)], add: [decoration.range(from, to)],
}); });
} else if (effect.is(removeLineDecorationEffect)) { } else if (effect.is(removeLineDecorationEffect) || effect.is(removeMarkDecorationEffect)) {
const doc = transaction.state.doc; const doc = transaction.state.doc;
const targetFrom = doc.lineAt(effect.value.from).from; const targetFrom = doc.lineAt(effect.value.from).from;
const targetTo = doc.lineAt(effect.value.to).to; const targetTo = doc.lineAt(effect.value.to).to;
const targetDecoration = this.classNameToCssDecoration(effect.value.cssClass, true); const targetId = effect.value.id;
const targetDecoration = this.classNameToCssDecoration(
effect.value.cssClass, effect.is(removeLineDecorationEffect),
);
decorations = decorations.update({ decorations = decorations.update({
// Returns true only for decorations that should be kept. // Returns true only for decorations that should be kept.
filter: (from, to, value) => { filter: (from, to, value) => {
if (from >= targetFrom && to <= targetTo && value.eq(targetDecoration)) { if (targetId !== undefined) {
return false; return value.spec.id !== effect.value.id;
} }
return true; const isInRange = from >= targetFrom && to <= targetTo;
return isInRange && value.eq(targetDecoration);
}, },
}); });
} else if (effect.is(addLineWidgetEffect)) { } else if (effect.is(addLineWidgetEffect)) {
@@ -296,6 +321,22 @@ export default class Decorator {
public addOverlay<State>(modeObject: StreamParser<State>) { public addOverlay<State>(modeObject: StreamParser<State>) {
this._overlays.push(modeObject); this._overlays.push(modeObject);
this.editor.dispatch({
effects: [refreshOverlaysEffect.of(null)],
});
return {
clear: () => this.removeOverlay(modeObject),
};
}
public removeOverlay(overlay: any) {
this._overlays = this._overlays.filter(other => other !== overlay);
this.editor.dispatch({
effects: [refreshOverlaysEffect.of(null)],
});
} }
private addRemoveLineClass(lineNumber: number, className: string, add: boolean) { private addRemoveLineClass(lineNumber: number, className: string, add: boolean) {
@@ -336,6 +377,27 @@ export default class Decorator {
return lineClasses; return lineClasses;
} }
public markText(from: number, to: number, options?: MarkTextOptions) {
const effectOptions: CssDecorationSpec = {
cssClass: options.className ?? '',
id: this._nextLineWidgetId++,
from,
to,
};
this.editor.dispatch({
effects: addMarkDecorationEffect.of(effectOptions),
});
return {
clear: () => {
this.editor.dispatch({
effects: removeMarkDecorationEffect.of(effectOptions),
});
},
};
}
private createLineWidgetControl(node: HTMLElement, options: LineWidgetOptions): LineWidgetControl { private createLineWidgetControl(node: HTMLElement, options: LineWidgetOptions): LineWidgetControl {
return { return {
node, node,

View File

@@ -2,7 +2,7 @@ import { EditorView } from '@codemirror/view';
import { EditorCommandType, EditorControl, EditorSettings, LogMessageCallback, ContentScriptData, SearchState } from '../types'; import { EditorCommandType, EditorControl, EditorSettings, LogMessageCallback, ContentScriptData, SearchState } from '../types';
import CodeMirror5Emulation from './CodeMirror5Emulation/CodeMirror5Emulation'; import CodeMirror5Emulation from './CodeMirror5Emulation/CodeMirror5Emulation';
import editorCommands from './editorCommands/editorCommands'; import editorCommands from './editorCommands/editorCommands';
import { 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 { SearchQuery, setSearchQuery } from '@codemirror/search';
import PluginLoader from './pluginApi/PluginLoader'; import PluginLoader from './pluginApi/PluginLoader';
@@ -137,9 +137,20 @@ export default class CodeMirrorControl extends CodeMirror5Emulation implements E
} }
public addStyles(...styles: Parameters<typeof EditorView.theme>) { public addStyles(...styles: Parameters<typeof EditorView.theme>) {
const compartment = new Compartment();
this.editor.dispatch({ this.editor.dispatch({
effects: StateEffect.appendConfig.of(EditorView.theme(...styles)), effects: StateEffect.appendConfig.of(
compartment.of(EditorView.theme(...styles)),
),
}); });
return {
remove: () => {
this.editor.dispatch({
effects: compartment.reconfigure([]),
});
},
};
} }
public setContentScripts(plugins: ContentScriptData[]) { public setContentScripts(plugins: ContentScriptData[]) {