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

Desktop: Add new beta Markdown editor based on CodeMirror 6 (#8793)

This commit is contained in:
Henry Heino
2023-09-21 01:12:40 -07:00
committed by GitHub
parent c3971ff226
commit 84c6de9b56
70 changed files with 4201 additions and 1306 deletions

View File

@@ -5,29 +5,29 @@ import EditLinkDialog from './EditLinkDialog';
import { defaultSearchState, SearchPanel } from './SearchPanel';
import ExtendedWebView from '../ExtendedWebView';
const React = require('react');
import * as React from 'react';
import { forwardRef, RefObject, useImperativeHandle } from 'react';
import { useEffect, useMemo, useState, useCallback, useRef } from 'react';
import { LayoutChangeEvent, View, ViewStyle } from 'react-native';
const { editorFont } = require('../global-style');
import SelectionFormatting from './SelectionFormatting';
import {
EditorSettings, EditorControl,
ChangeEvent, UndoRedoDepthChangeEvent, Selection, SelectionChangeEvent, ListType, SearchState,
} from './types';
import { EditorControl, EditorSettings, SelectionRange } from './types';
import { _ } from '@joplin/lib/locale';
import MarkdownToolbar from './MarkdownToolbar/MarkdownToolbar';
import { ChangeEvent, EditorEvent, EditorEventType, SelectionRangeChangeEvent, UndoRedoDepthChangeEvent } from '@joplin/editor/events';
import { EditorCommandType, EditorKeymap, EditorLanguageType, PluginData, SearchState } from '@joplin/editor/types';
import supportsCommand from '@joplin/editor/CodeMirror/editorCommands/supportsCommand';
import SelectionFormatting, { defaultSelectionFormatting } from '@joplin/editor/SelectionFormatting';
type ChangeEventHandler = (event: ChangeEvent)=> void;
type UndoRedoDepthChangeHandler = (event: UndoRedoDepthChangeEvent)=> void;
type SelectionChangeEventHandler = (event: SelectionChangeEvent)=> void;
type SelectionChangeEventHandler = (event: SelectionRangeChangeEvent)=> void;
type OnAttachCallback = ()=> void;
interface Props {
themeId: number;
initialText: string;
initialSelection?: Selection;
initialSelection?: SelectionRange;
style: ViewStyle;
contentStyle?: ViewStyle;
toolbarEnabled: boolean;
@@ -110,6 +110,11 @@ function editorTheme(themeId: number) {
return {
...themeStyle(themeId),
// To allow accessibility font scaling, we also need to set the
// fontSize to a value in `em`s (relative scaling relative to
// parent font size).
fontSizeUnits: 'em',
fontSize: estimatedFontSizeInEm,
fontFamily: fontFamilyFromSettings(),
};
@@ -120,48 +125,92 @@ type OnSetVisibleCallback = (visible: boolean)=> void;
type OnSearchStateChangeCallback = (state: SearchState)=> void;
const useEditorControl = (
injectJS: OnInjectJSCallback, setLinkDialogVisible: OnSetVisibleCallback,
setSearchState: OnSearchStateChangeCallback, searchStateRef: RefObject<SearchState>,
setSearchState: OnSearchStateChangeCallback,
searchStateRef: RefObject<SearchState>,
): EditorControl => {
return useMemo(() => {
return {
const execCommand = (command: EditorCommandType) => {
injectJS(`cm.execCommand(${JSON.stringify(command)})`);
};
const setSearchStateCallback = (state: SearchState) => {
injectJS(`cm.setSearchState(${JSON.stringify(state)})`);
setSearchState(state);
};
const control: EditorControl = {
supportsCommand(command: EditorCommandType) {
return supportsCommand(command);
},
execCommand,
undo() {
injectJS('cm.undo();');
injectJS('cm.undo()');
},
redo() {
injectJS('cm.redo();');
injectJS('cm.redo()');
},
select(anchor: number, head: number) {
injectJS(
`cm.select(${JSON.stringify(anchor)}, ${JSON.stringify(head)});`,
);
},
setScrollPercent(fraction: number) {
injectJS(`cm.setScrollFraction(${JSON.stringify(fraction)})`);
},
insertText(text: string) {
injectJS(`cm.insertText(${JSON.stringify(text)});`);
},
updateBody(newBody: string) {
injectJS(`cm.updateBody(${JSON.stringify(newBody)});`);
},
updateSettings(newSettings: EditorSettings) {
injectJS(`cm.updateSettings(${JSON.stringify(newSettings)})`);
},
toggleBolded() {
injectJS('cm.toggleBolded();');
execCommand(EditorCommandType.ToggleBolded);
},
toggleItalicized() {
injectJS('cm.toggleItalicized();');
execCommand(EditorCommandType.ToggleItalicized);
},
toggleList(listType: ListType) {
injectJS(`cm.toggleList(${JSON.stringify(listType)});`);
toggleOrderedList() {
execCommand(EditorCommandType.ToggleNumberedList);
},
toggleUnorderedList() {
execCommand(EditorCommandType.ToggleCheckList);
},
toggleTaskList() {
execCommand(EditorCommandType.ToggleCheckList);
},
toggleCode() {
injectJS('cm.toggleCode();');
execCommand(EditorCommandType.ToggleCode);
},
toggleMath() {
injectJS('cm.toggleMath();');
execCommand(EditorCommandType.ToggleMath);
},
toggleHeaderLevel(level: number) {
injectJS(`cm.toggleHeaderLevel(${level});`);
const levelToCommand = [
EditorCommandType.ToggleHeading1,
EditorCommandType.ToggleHeading2,
EditorCommandType.ToggleHeading3,
EditorCommandType.ToggleHeading4,
EditorCommandType.ToggleHeading5,
];
const index = level - 1;
if (index < 0 || index >= levelToCommand.length) {
throw new Error(`Unsupported header level ${level}`);
}
execCommand(levelToCommand[index]);
},
increaseIndent() {
injectJS('cm.increaseIndent();');
execCommand(EditorCommandType.IndentMore);
},
decreaseIndent() {
injectJS('cm.decreaseIndent();');
execCommand(EditorCommandType.IndentLess);
},
updateLink(label: string, url: string) {
injectJS(`cm.updateLink(
@@ -170,7 +219,7 @@ const useEditorControl = (
);`);
},
scrollSelectionIntoView() {
injectJS('cm.scrollSelectionIntoView();');
execCommand(EditorCommandType.ScrollSelectionIntoView);
},
showLinkDialog() {
setLinkDialogVisible(true);
@@ -181,23 +230,27 @@ const useEditorControl = (
hideKeyboard() {
injectJS('document.activeElement?.blur();');
},
setPlugins: async (plugins: PluginData[]) => {
injectJS(`cm.setPlugins(${JSON.stringify(plugins)});`);
},
setSearchState: setSearchStateCallback,
searchControl: {
findNext() {
injectJS('cm.searchControl.findNext();');
execCommand(EditorCommandType.FindNext);
},
findPrevious() {
injectJS('cm.searchControl.findPrevious();');
execCommand(EditorCommandType.FindPrevious);
},
replaceCurrent() {
injectJS('cm.searchControl.replaceCurrent();');
replaceNext() {
execCommand(EditorCommandType.ReplaceNext);
},
replaceAll() {
injectJS('cm.searchControl.replaceAll();');
},
setSearchState(state: SearchState) {
injectJS(`cm.searchControl.setSearchState(${JSON.stringify(state)})`);
setSearchState(state);
execCommand(EditorCommandType.ReplaceAll);
},
showSearch() {
setSearchState({
...searchStateRef.current,
@@ -210,8 +263,12 @@ const useEditorControl = (
dialogVisible: false,
});
},
setSearchState: setSearchStateCallback,
},
};
return control;
}, [injectJS, searchStateRef, setLinkDialogVisible, setSearchState]);
};
@@ -227,7 +284,16 @@ function NoteEditor(props: Props, ref: any) {
themeData: editorTheme(props.themeId),
katexEnabled: Setting.value('markdown.plugin.katex'),
spellcheckEnabled: Setting.value('editor.mobile.spellcheckEnabled'),
language: EditorLanguageType.Markdown,
useExternalSearch: true,
readOnly: props.readOnly,
keymap: EditorKeymap.Default,
automatchBraces: false,
ignoreModifiers: false,
indentWithTabs: false,
};
const injectedJavaScript = `
@@ -252,6 +318,12 @@ function NoteEditor(props: Props, ref: any) {
);
};
window.onunhandledrejection = (event) => {
window.ReactNativeWebView.postMessage(
"error: Unhandled promise rejection: " + event
);
};
if (!window.cm) {
// This variable is not used within this script
// but is called using "injectJavaScript" from
@@ -269,7 +341,7 @@ function NoteEditor(props: Props, ref: any) {
${setInitialSelectionJS}
window.onresize = () => {
cm.scrollSelectionIntoView();
cm.execCommand('scrollSelectionIntoView');
};
} catch (e) {
window.ReactNativeWebView.postMessage("error:" + e.message + ": " + JSON.stringify(e))
@@ -280,7 +352,7 @@ function NoteEditor(props: Props, ref: any) {
const css = useCss(props.themeId);
const html = useHtml(css);
const [selectionState, setSelectionState] = useState(new SelectionFormatting());
const [selectionState, setSelectionState] = useState<SelectionFormatting>(defaultSelectionFormatting);
const [linkDialogVisible, setLinkDialogVisible] = useState(false);
const [searchState, setSearchState] = useState(defaultSearchState);
@@ -293,7 +365,7 @@ function NoteEditor(props: Props, ref: any) {
searchStateRef.current = searchState;
}, [searchState]);
// / Runs [js] in the context of the CodeMirror frame.
// Runs [js] in the context of the CodeMirror frame.
const injectJS = (js: string) => {
webviewRef.current.injectJS(js);
};
@@ -323,36 +395,41 @@ function NoteEditor(props: Props, ref: any) {
console.info('CodeMirror:', ...event.value);
},
onChange: (event: ChangeEvent) => {
props.onChange(event);
},
onEditorEvent: (event: EditorEvent) => {
let exhaustivenessCheck: never;
switch (event.kind) {
case EditorEventType.Change:
props.onChange(event);
break;
case EditorEventType.UndoRedoDepthChange:
props.onUndoRedoDepthChange(event);
break;
case EditorEventType.SelectionRangeChange:
props.onSelectionChange(event);
break;
case EditorEventType.SelectionFormattingChange:
setSelectionState(event.formatting);
break;
case EditorEventType.EditLink:
editorControl.showLinkDialog();
break;
case EditorEventType.UpdateSearchDialog:
setSearchState(event.searchState);
onUndoRedoDepthChange: (event: UndoRedoDepthChangeEvent) => {
props.onUndoRedoDepthChange(event);
},
onSelectionChange: (event: SelectionChangeEvent) => {
props.onSelectionChange(event);
},
onSelectionFormattingChange(data: string) {
// We want a SelectionFormatting object, so are
// instantiating it from JSON.
const formatting = SelectionFormatting.fromJSON(data);
setSelectionState(formatting);
},
onRequestLinkEdit() {
editorControl.showLinkDialog();
},
onRequestShowSearch(data: SearchState) {
setSearchState(data);
editorControl.searchControl.showSearch();
},
onRequestHideSearch() {
editorControl.searchControl.hideSearch();
if (event.searchState.dialogVisible) {
editorControl.searchControl.showSearch();
} else {
editorControl.searchControl.hideSearch();
}
break;
case EditorEventType.Scroll:
// Not handled
break;
default:
exhaustivenessCheck = event;
return exhaustivenessCheck;
}
return;
},
};