You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-11-29 22:48:10 +02:00
Android: Add support for Markdown editor plugins (#10086)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
This commit is contained in:
@@ -1,25 +1,32 @@
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
import themeToCss from '@joplin/lib/services/style/themeToCss';
|
||||
import EditLinkDialog from './EditLinkDialog';
|
||||
import { defaultSearchState, SearchPanel } from './SearchPanel';
|
||||
import ExtendedWebView from '../ExtendedWebView';
|
||||
import ExtendedWebView, { WebViewControl } from '../ExtendedWebView';
|
||||
|
||||
import * as React from 'react';
|
||||
import { forwardRef, useImperativeHandle } from 'react';
|
||||
import { forwardRef, useEffect, useImperativeHandle } from 'react';
|
||||
import { useMemo, useState, useCallback, useRef } from 'react';
|
||||
import { LayoutChangeEvent, NativeSyntheticEvent, View, ViewStyle } from 'react-native';
|
||||
import { editorFont } from '../global-style';
|
||||
|
||||
import { EditorControl, EditorSettings, SelectionRange } from './types';
|
||||
import { EditorControl as EditorBodyControl, ContentScriptData } from '@joplin/editor/types';
|
||||
import { EditorControl, EditorSettings, SelectionRange, WebViewToEditorApi } 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, ContentScriptData, SearchState } from '@joplin/editor/types';
|
||||
import { EditorCommandType, EditorKeymap, EditorLanguageType, SearchState } from '@joplin/editor/types';
|
||||
import supportsCommand from '@joplin/editor/CodeMirror/editorCommands/supportsCommand';
|
||||
import SelectionFormatting, { defaultSelectionFormatting } from '@joplin/editor/SelectionFormatting';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import useCodeMirrorPlugins from './hooks/useCodeMirrorPlugins';
|
||||
import RNToWebViewMessenger from '../../utils/ipc/RNToWebViewMessenger';
|
||||
import { WebViewMessageEvent } from 'react-native-webview';
|
||||
import { WebViewErrorEvent } from 'react-native-webview/lib/RNCWebViewNativeComponent';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
|
||||
import useEditorCommandHandler from './hooks/useEditorCommandHandler';
|
||||
|
||||
type ChangeEventHandler = (event: ChangeEvent)=> void;
|
||||
type UndoRedoDepthChangeHandler = (event: UndoRedoDepthChangeEvent)=> void;
|
||||
@@ -33,9 +40,9 @@ interface Props {
|
||||
initialText: string;
|
||||
initialSelection?: SelectionRange;
|
||||
style: ViewStyle;
|
||||
contentStyle?: ViewStyle;
|
||||
toolbarEnabled: boolean;
|
||||
readOnly: boolean;
|
||||
plugins: PluginStates;
|
||||
|
||||
onChange: ChangeEventHandler;
|
||||
onSelectionChange: SelectionChangeEventHandler;
|
||||
@@ -51,7 +58,10 @@ function fontFamilyFromSettings() {
|
||||
function useCss(themeId: number): string {
|
||||
return useMemo(() => {
|
||||
const theme = themeStyle(themeId);
|
||||
const themeVariableCss = themeToCss(theme);
|
||||
return `
|
||||
${themeVariableCss}
|
||||
|
||||
:root {
|
||||
background-color: ${theme.backgroundColor};
|
||||
}
|
||||
@@ -75,39 +85,38 @@ function useCss(themeId: number): string {
|
||||
}, [themeId]);
|
||||
}
|
||||
|
||||
function useHtml(css: string): string {
|
||||
const [html, setHtml] = useState('');
|
||||
const themeStyleSheetClassName = 'note-editor-styles';
|
||||
function useHtml(initialCss: string): string {
|
||||
const cssRef = useRef(initialCss);
|
||||
cssRef.current = initialCss;
|
||||
|
||||
useMemo(() => {
|
||||
setHtml(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||
<title>${_('Note editor')}</title>
|
||||
<style>
|
||||
/* For better scrolling on iOS (working scrollbar) we use external, rather than internal,
|
||||
scrolling. */
|
||||
.cm-scroller {
|
||||
overflow: none;
|
||||
return useMemo(() => `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||
<title>${_('Note editor')}</title>
|
||||
<style>
|
||||
/* For better scrolling on iOS (working scrollbar) we use external, rather than internal,
|
||||
scrolling. */
|
||||
.cm-scroller {
|
||||
overflow: none;
|
||||
|
||||
/* Ensure that the editor can be focused by clicking on the lower half of the screen.
|
||||
Don't use 100vh to prevent a scrollbar being present for empty notes. */
|
||||
min-height: 80vh;
|
||||
}
|
||||
|
||||
${css}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="CodeMirror" style="height:100%;" autocapitalize="on"></div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
}, [css]);
|
||||
|
||||
return html;
|
||||
/* Ensure that the editor can be focused by clicking on the lower half of the screen.
|
||||
Don't use 100vh to prevent a scrollbar being present for empty notes. */
|
||||
min-height: 80vh;
|
||||
}
|
||||
</style>
|
||||
<style class=${JSON.stringify(themeStyleSheetClassName)}>
|
||||
${cssRef.current}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="CodeMirror" style="height:100%;" autocapitalize="on"></div>
|
||||
</body>
|
||||
</html>
|
||||
`, []);
|
||||
}
|
||||
|
||||
function editorTheme(themeId: number) {
|
||||
@@ -134,16 +143,18 @@ type OnInjectJSCallback = (js: string)=> void;
|
||||
type OnSetVisibleCallback = (visible: boolean)=> void;
|
||||
type OnSearchStateChangeCallback = (state: SearchState)=> void;
|
||||
const useEditorControl = (
|
||||
injectJS: OnInjectJSCallback, setLinkDialogVisible: OnSetVisibleCallback,
|
||||
bodyControl: EditorBodyControl,
|
||||
injectJS: OnInjectJSCallback,
|
||||
setLinkDialogVisible: OnSetVisibleCallback,
|
||||
setSearchState: OnSearchStateChangeCallback,
|
||||
): EditorControl => {
|
||||
return useMemo(() => {
|
||||
const execCommand = (command: EditorCommandType) => {
|
||||
injectJS(`cm.execCommand(${JSON.stringify(command)})`);
|
||||
void bodyControl.execCommand(command);
|
||||
};
|
||||
|
||||
const setSearchStateCallback = (state: SearchState) => {
|
||||
injectJS(`cm.setSearchState(${JSON.stringify(state)})`);
|
||||
bodyControl.setSearchState(state);
|
||||
setSearchState(state);
|
||||
};
|
||||
|
||||
@@ -151,30 +162,30 @@ const useEditorControl = (
|
||||
supportsCommand(command: EditorCommandType) {
|
||||
return supportsCommand(command);
|
||||
},
|
||||
execCommand,
|
||||
execCommand(command, ...args: any[]) {
|
||||
return bodyControl.execCommand(command, ...args);
|
||||
},
|
||||
|
||||
undo() {
|
||||
injectJS('cm.undo()');
|
||||
bodyControl.undo();
|
||||
},
|
||||
redo() {
|
||||
injectJS('cm.redo()');
|
||||
bodyControl.redo();
|
||||
},
|
||||
select(anchor: number, head: number) {
|
||||
injectJS(
|
||||
`cm.select(${JSON.stringify(anchor)}, ${JSON.stringify(head)});`,
|
||||
);
|
||||
bodyControl.select(anchor, head);
|
||||
},
|
||||
setScrollPercent(fraction: number) {
|
||||
injectJS(`cm.setScrollFraction(${JSON.stringify(fraction)})`);
|
||||
bodyControl.setScrollPercent(fraction);
|
||||
},
|
||||
insertText(text: string) {
|
||||
injectJS(`cm.insertText(${JSON.stringify(text)});`);
|
||||
bodyControl.insertText(text);
|
||||
},
|
||||
updateBody(newBody: string) {
|
||||
injectJS(`cm.updateBody(${JSON.stringify(newBody)});`);
|
||||
bodyControl.updateBody(newBody);
|
||||
},
|
||||
updateSettings(newSettings: EditorSettings) {
|
||||
injectJS(`cm.updateSettings(${JSON.stringify(newSettings)})`);
|
||||
bodyControl.updateSettings(newSettings);
|
||||
},
|
||||
|
||||
toggleBolded() {
|
||||
@@ -222,10 +233,7 @@ const useEditorControl = (
|
||||
execCommand(EditorCommandType.IndentLess);
|
||||
},
|
||||
updateLink(label: string, url: string) {
|
||||
injectJS(`cm.updateLink(
|
||||
${JSON.stringify(label)},
|
||||
${JSON.stringify(url)}
|
||||
);`);
|
||||
bodyControl.updateLink(label, url);
|
||||
},
|
||||
scrollSelectionIntoView() {
|
||||
execCommand(EditorCommandType.ScrollSelectionIntoView);
|
||||
@@ -241,7 +249,7 @@ const useEditorControl = (
|
||||
},
|
||||
|
||||
setContentScripts: async (plugins: ContentScriptData[]) => {
|
||||
injectJS(`cm.setContentScripts(${JSON.stringify(plugins)});`);
|
||||
return bodyControl.setContentScripts(plugins);
|
||||
},
|
||||
|
||||
setSearchState: setSearchStateCallback,
|
||||
@@ -272,18 +280,18 @@ const useEditorControl = (
|
||||
};
|
||||
|
||||
return control;
|
||||
}, [injectJS, setLinkDialogVisible, setSearchState]);
|
||||
}, [injectJS, setLinkDialogVisible, setSearchState, bodyControl]);
|
||||
};
|
||||
|
||||
function NoteEditor(props: Props, ref: any) {
|
||||
const webviewRef = useRef(null);
|
||||
const webviewRef = useRef<WebViewControl>(null);
|
||||
|
||||
const setInitialSelectionJS = props.initialSelection ? `
|
||||
cm.select(${props.initialSelection.start}, ${props.initialSelection.end});
|
||||
cm.execCommand('scrollSelectionIntoView');
|
||||
` : '';
|
||||
|
||||
const editorSettings: EditorSettings = {
|
||||
const editorSettings: EditorSettings = useMemo(() => ({
|
||||
themeId: props.themeId,
|
||||
themeData: editorTheme(props.themeId),
|
||||
katexEnabled: Setting.value('markdown.plugin.katex'),
|
||||
@@ -298,24 +306,9 @@ function NoteEditor(props: Props, ref: any) {
|
||||
ignoreModifiers: false,
|
||||
|
||||
indentWithTabs: false,
|
||||
};
|
||||
}), [props.themeId, props.readOnly]);
|
||||
|
||||
const injectedJavaScript = `
|
||||
function postMessage(name, data) {
|
||||
window.ReactNativeWebView.postMessage(JSON.stringify({
|
||||
data,
|
||||
name,
|
||||
}));
|
||||
}
|
||||
|
||||
function logMessage(...msg) {
|
||||
postMessage('onLog', { value: msg });
|
||||
}
|
||||
|
||||
// Globalize logMessage, postMessage
|
||||
window.logMessage = logMessage;
|
||||
window.postMessage = postMessage;
|
||||
|
||||
window.onerror = (message, source, lineno) => {
|
||||
window.ReactNativeWebView.postMessage(
|
||||
"error: " + message + " in file://" + source + ", line " + lineno
|
||||
@@ -340,7 +333,7 @@ function NoteEditor(props: Props, ref: any) {
|
||||
const initialText = ${JSON.stringify(props.initialText)};
|
||||
const settings = ${JSON.stringify(editorSettings)};
|
||||
|
||||
cm = codeMirrorBundle.initCodeMirror(parentElement, initialText, settings);
|
||||
window.cm = codeMirrorBundle.initCodeMirror(parentElement, initialText, settings);
|
||||
|
||||
${setInitialSelectionJS}
|
||||
|
||||
@@ -355,6 +348,24 @@ function NoteEditor(props: Props, ref: any) {
|
||||
`;
|
||||
|
||||
const css = useCss(props.themeId);
|
||||
|
||||
useEffect(() => {
|
||||
if (webviewRef.current) {
|
||||
webviewRef.current.injectJS(`
|
||||
const styleClass = ${JSON.stringify(themeStyleSheetClassName)};
|
||||
for (const oldStyle of [...document.getElementsByClassName(styleClass)]) {
|
||||
oldStyle.remove();
|
||||
}
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.classList.add(styleClass);
|
||||
|
||||
style.appendChild(document.createTextNode(${JSON.stringify(css)}));
|
||||
document.head.appendChild(style);
|
||||
`);
|
||||
}
|
||||
}, [css]);
|
||||
|
||||
const html = useHtml(css);
|
||||
const [selectionState, setSelectionState] = useState<SelectionFormatting>(defaultSelectionFormatting);
|
||||
const [linkDialogVisible, setLinkDialogVisible] = useState(false);
|
||||
@@ -365,74 +376,95 @@ function NoteEditor(props: Props, ref: any) {
|
||||
webviewRef.current.injectJS(js);
|
||||
};
|
||||
|
||||
const onEditorEvent = useRef((_event: EditorEvent) => {});
|
||||
|
||||
const editorMessenger = useMemo(() => {
|
||||
const localApi: WebViewToEditorApi = {
|
||||
async onEditorEvent(event) {
|
||||
onEditorEvent.current(event);
|
||||
},
|
||||
async logMessage(message) {
|
||||
logger.debug('CodeMirror:', message);
|
||||
},
|
||||
};
|
||||
const messenger = new RNToWebViewMessenger<WebViewToEditorApi, EditorBodyControl>(
|
||||
'editor', webviewRef, localApi,
|
||||
);
|
||||
return messenger;
|
||||
}, []);
|
||||
|
||||
const editorControl = useEditorControl(
|
||||
injectJS, setLinkDialogVisible, setSearchState,
|
||||
editorMessenger.remoteApi, injectJS, setLinkDialogVisible, setSearchState,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
editorControl.updateSettings(editorSettings);
|
||||
}, [editorSettings, editorControl]);
|
||||
|
||||
useEditorCommandHandler(editorControl);
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
return editorControl;
|
||||
});
|
||||
|
||||
const onMessage = useCallback((event: any) => {
|
||||
useEffect(() => {
|
||||
onEditorEvent.current = (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);
|
||||
|
||||
if (event.searchState.dialogVisible) {
|
||||
editorControl.searchControl.showSearch();
|
||||
} else {
|
||||
editorControl.searchControl.hideSearch();
|
||||
}
|
||||
break;
|
||||
case EditorEventType.Scroll:
|
||||
// Not handled
|
||||
break;
|
||||
default:
|
||||
exhaustivenessCheck = event;
|
||||
return exhaustivenessCheck;
|
||||
}
|
||||
return;
|
||||
};
|
||||
}, [props.onChange, props.onUndoRedoDepthChange, props.onSelectionChange, editorControl]);
|
||||
|
||||
const codeMirrorPlugins = useCodeMirrorPlugins(props.plugins);
|
||||
useEffect(() => {
|
||||
void editorControl.setContentScripts(codeMirrorPlugins);
|
||||
}, [codeMirrorPlugins, editorControl]);
|
||||
|
||||
const onLoadEnd = useCallback(() => {
|
||||
editorMessenger.onWebViewLoaded();
|
||||
}, [editorMessenger]);
|
||||
|
||||
const onMessage = useCallback((event: WebViewMessageEvent) => {
|
||||
const data = event.nativeEvent.data;
|
||||
|
||||
if (data.indexOf('error:') === 0) {
|
||||
logger.error('CodeMirror:', data);
|
||||
logger.error('CodeMirror error', data);
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = JSON.parse(data);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
||||
const handlers: Record<string, Function> = {
|
||||
onLog: (event: any) => {
|
||||
logger.info('CodeMirror:', ...event.value);
|
||||
},
|
||||
|
||||
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);
|
||||
|
||||
if (event.searchState.dialogVisible) {
|
||||
editorControl.searchControl.showSearch();
|
||||
} else {
|
||||
editorControl.searchControl.hideSearch();
|
||||
}
|
||||
break;
|
||||
case EditorEventType.Scroll:
|
||||
// Not handled
|
||||
break;
|
||||
default:
|
||||
exhaustivenessCheck = event;
|
||||
return exhaustivenessCheck;
|
||||
}
|
||||
return;
|
||||
},
|
||||
};
|
||||
|
||||
if (handlers[msg.name]) {
|
||||
handlers[msg.name](msg.data);
|
||||
} else {
|
||||
logger.warn('Unsupported CodeMirror message:', msg);
|
||||
}
|
||||
}, [props.onSelectionChange, props.onUndoRedoDepthChange, props.onChange, editorControl]);
|
||||
editorMessenger.onWebViewMessage(event);
|
||||
}, [editorMessenger]);
|
||||
|
||||
const onError = useCallback((event: NativeSyntheticEvent<WebViewErrorEvent>) => {
|
||||
logger.error(`Load error: Code ${event.nativeEvent.code}: ${event.nativeEvent.description}`);
|
||||
@@ -461,6 +493,7 @@ function NoteEditor(props: Props, ref: any) {
|
||||
editorControl={editorControl}
|
||||
selectionState={selectionState}
|
||||
searchState={searchState}
|
||||
pluginStates={props.plugins}
|
||||
onAttach={props.onAttach}
|
||||
readOnly={props.readOnly}
|
||||
/>;
|
||||
@@ -484,7 +517,6 @@ function NoteEditor(props: Props, ref: any) {
|
||||
flexGrow: 1,
|
||||
flexShrink: 0,
|
||||
minHeight: '30%',
|
||||
...props.contentStyle,
|
||||
}}>
|
||||
<ExtendedWebView
|
||||
webviewInstanceId='NoteEditor'
|
||||
@@ -493,6 +525,7 @@ function NoteEditor(props: Props, ref: any) {
|
||||
html={html}
|
||||
injectedJavaScript={injectedJavaScript}
|
||||
onMessage={onMessage}
|
||||
onLoadEnd={onLoadEnd}
|
||||
onError={onError}
|
||||
/>
|
||||
</View>
|
||||
|
||||
Reference in New Issue
Block a user