mirror of
https://github.com/laurent22/joplin.git
synced 2024-12-21 09:38:01 +02:00
506 lines
13 KiB
TypeScript
506 lines
13 KiB
TypeScript
import Setting from '@joplin/lib/models/Setting';
|
|
import shim from '@joplin/lib/shim';
|
|
import { themeStyle } from '@joplin/lib/theme';
|
|
import EditLinkDialog from './EditLinkDialog';
|
|
import { defaultSearchState, SearchPanel } from './SearchPanel';
|
|
import ExtendedWebView from '../ExtendedWebView';
|
|
|
|
import * as React from 'react';
|
|
import { forwardRef, useImperativeHandle } from 'react';
|
|
import { useMemo, useState, useCallback, useRef } from 'react';
|
|
import { LayoutChangeEvent, View, ViewStyle } from 'react-native';
|
|
const { editorFont } = require('../global-style');
|
|
|
|
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: SelectionRangeChangeEvent)=> void;
|
|
type OnAttachCallback = ()=> void;
|
|
|
|
interface Props {
|
|
themeId: number;
|
|
initialText: string;
|
|
initialSelection?: SelectionRange;
|
|
style: ViewStyle;
|
|
contentStyle?: ViewStyle;
|
|
toolbarEnabled: boolean;
|
|
readOnly: boolean;
|
|
|
|
onChange: ChangeEventHandler;
|
|
onSelectionChange: SelectionChangeEventHandler;
|
|
onUndoRedoDepthChange: UndoRedoDepthChangeHandler;
|
|
onAttach: OnAttachCallback;
|
|
}
|
|
|
|
function fontFamilyFromSettings() {
|
|
const font = editorFont(Setting.value('style.editor.fontFamily'));
|
|
return font ? `${font}, sans-serif` : 'sans-serif';
|
|
}
|
|
|
|
function useCss(themeId: number): string {
|
|
return useMemo(() => {
|
|
const theme = themeStyle(themeId);
|
|
return `
|
|
:root {
|
|
background-color: ${theme.backgroundColor};
|
|
}
|
|
|
|
body {
|
|
margin: 0;
|
|
height: 100vh;
|
|
width: 100vh;
|
|
width: 100vw;
|
|
min-width: 100vw;
|
|
box-sizing: border-box;
|
|
|
|
padding-left: 1px;
|
|
padding-right: 1px;
|
|
padding-bottom: 1px;
|
|
padding-top: 10px;
|
|
|
|
font-size: 13pt;
|
|
}
|
|
`;
|
|
}, [themeId]);
|
|
}
|
|
|
|
function useHtml(css: string): string {
|
|
const [html, setHtml] = useState('');
|
|
|
|
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>
|
|
.cm-editor {
|
|
height: 100%;
|
|
}
|
|
|
|
${css}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="CodeMirror" style="height:100%;" autocapitalize="on"></div>
|
|
</body>
|
|
</html>
|
|
`);
|
|
}, [css]);
|
|
|
|
return html;
|
|
}
|
|
|
|
function editorTheme(themeId: number) {
|
|
const fontSizeInPx = Setting.value('style.editor.fontSize');
|
|
|
|
// Convert from `px` to `em`. To support font size scaling based on
|
|
// system accessibility settings, we need to provide font sizes in `em`.
|
|
// 16px is about 1em with the default root font size.
|
|
const estimatedFontSizeInEm = fontSizeInPx / 16;
|
|
|
|
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(),
|
|
};
|
|
}
|
|
|
|
type OnInjectJSCallback = (js: string)=> void;
|
|
type OnSetVisibleCallback = (visible: boolean)=> void;
|
|
type OnSearchStateChangeCallback = (state: SearchState)=> void;
|
|
const useEditorControl = (
|
|
injectJS: OnInjectJSCallback, setLinkDialogVisible: OnSetVisibleCallback,
|
|
setSearchState: OnSearchStateChangeCallback,
|
|
): EditorControl => {
|
|
return useMemo(() => {
|
|
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()');
|
|
},
|
|
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() {
|
|
execCommand(EditorCommandType.ToggleBolded);
|
|
},
|
|
toggleItalicized() {
|
|
execCommand(EditorCommandType.ToggleItalicized);
|
|
},
|
|
toggleOrderedList() {
|
|
execCommand(EditorCommandType.ToggleNumberedList);
|
|
},
|
|
toggleUnorderedList() {
|
|
execCommand(EditorCommandType.ToggleBulletedList);
|
|
},
|
|
toggleTaskList() {
|
|
execCommand(EditorCommandType.ToggleCheckList);
|
|
},
|
|
toggleCode() {
|
|
execCommand(EditorCommandType.ToggleCode);
|
|
},
|
|
toggleMath() {
|
|
execCommand(EditorCommandType.ToggleMath);
|
|
},
|
|
toggleHeaderLevel(level: number) {
|
|
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() {
|
|
execCommand(EditorCommandType.IndentMore);
|
|
},
|
|
decreaseIndent() {
|
|
execCommand(EditorCommandType.IndentLess);
|
|
},
|
|
updateLink(label: string, url: string) {
|
|
injectJS(`cm.updateLink(
|
|
${JSON.stringify(label)},
|
|
${JSON.stringify(url)}
|
|
);`);
|
|
},
|
|
scrollSelectionIntoView() {
|
|
execCommand(EditorCommandType.ScrollSelectionIntoView);
|
|
},
|
|
showLinkDialog() {
|
|
setLinkDialogVisible(true);
|
|
},
|
|
hideLinkDialog() {
|
|
setLinkDialogVisible(false);
|
|
},
|
|
hideKeyboard() {
|
|
injectJS('document.activeElement?.blur();');
|
|
},
|
|
|
|
setPlugins: async (plugins: PluginData[]) => {
|
|
injectJS(`cm.setPlugins(${JSON.stringify(plugins)});`);
|
|
},
|
|
|
|
setSearchState: setSearchStateCallback,
|
|
|
|
searchControl: {
|
|
findNext() {
|
|
execCommand(EditorCommandType.FindNext);
|
|
},
|
|
findPrevious() {
|
|
execCommand(EditorCommandType.FindPrevious);
|
|
},
|
|
replaceNext() {
|
|
execCommand(EditorCommandType.ReplaceNext);
|
|
},
|
|
replaceAll() {
|
|
execCommand(EditorCommandType.ReplaceAll);
|
|
},
|
|
|
|
showSearch() {
|
|
execCommand(EditorCommandType.ShowSearch);
|
|
},
|
|
hideSearch() {
|
|
execCommand(EditorCommandType.HideSearch);
|
|
},
|
|
|
|
setSearchState: setSearchStateCallback,
|
|
},
|
|
};
|
|
|
|
return control;
|
|
}, [injectJS, setLinkDialogVisible, setSearchState]);
|
|
};
|
|
|
|
function NoteEditor(props: Props, ref: any) {
|
|
const webviewRef = useRef(null);
|
|
|
|
const setInitialSelectionJS = props.initialSelection ? `
|
|
cm.select(${props.initialSelection.start}, ${props.initialSelection.end});
|
|
` : '';
|
|
|
|
const editorSettings: EditorSettings = {
|
|
themeId: props.themeId,
|
|
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 = `
|
|
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
|
|
);
|
|
};
|
|
|
|
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
|
|
// the wrapper component.
|
|
window.cm = null;
|
|
|
|
try {
|
|
${shim.injectedJs('codeMirrorBundle')};
|
|
|
|
const parentElement = document.getElementsByClassName('CodeMirror')[0];
|
|
const initialText = ${JSON.stringify(props.initialText)};
|
|
const settings = ${JSON.stringify(editorSettings)};
|
|
|
|
cm = codeMirrorBundle.initCodeMirror(parentElement, initialText, settings);
|
|
${setInitialSelectionJS}
|
|
|
|
window.onresize = () => {
|
|
cm.execCommand('scrollSelectionIntoView');
|
|
};
|
|
} catch (e) {
|
|
window.ReactNativeWebView.postMessage("error:" + e.message + ": " + JSON.stringify(e))
|
|
}
|
|
}
|
|
true;
|
|
`;
|
|
|
|
const css = useCss(props.themeId);
|
|
const html = useHtml(css);
|
|
const [selectionState, setSelectionState] = useState<SelectionFormatting>(defaultSelectionFormatting);
|
|
const [linkDialogVisible, setLinkDialogVisible] = useState(false);
|
|
const [searchState, setSearchState] = useState(defaultSearchState);
|
|
|
|
// Runs [js] in the context of the CodeMirror frame.
|
|
const injectJS = (js: string) => {
|
|
webviewRef.current.injectJS(js);
|
|
};
|
|
|
|
const editorControl = useEditorControl(
|
|
injectJS, setLinkDialogVisible, setSearchState,
|
|
);
|
|
|
|
useImperativeHandle(ref, () => {
|
|
return editorControl;
|
|
});
|
|
|
|
const onMessage = useCallback((event: any) => {
|
|
const data = event.nativeEvent.data;
|
|
|
|
if (data.indexOf('error:') === 0) {
|
|
console.error('CodeMirror:', 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) => {
|
|
// eslint-disable-next-line no-console
|
|
console.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 {
|
|
// eslint-disable-next-line no-console
|
|
console.info('Unsupported CodeMirror message:', msg);
|
|
}
|
|
}, [props.onSelectionChange, props.onUndoRedoDepthChange, props.onChange, editorControl]);
|
|
|
|
const onError = useCallback(() => {
|
|
console.error('NoteEditor: webview error');
|
|
}, []);
|
|
|
|
const [hasSpaceForToolbar, setHasSpaceForToolbar] = useState(true);
|
|
const toolbarEnabled = props.toolbarEnabled && hasSpaceForToolbar;
|
|
|
|
const onContainerLayout = useCallback((event: LayoutChangeEvent) => {
|
|
const containerHeight = event.nativeEvent.layout.height;
|
|
|
|
if (containerHeight < 140) {
|
|
setHasSpaceForToolbar(false);
|
|
} else {
|
|
setHasSpaceForToolbar(true);
|
|
}
|
|
}, []);
|
|
|
|
const toolbar = <MarkdownToolbar
|
|
style={{
|
|
// Don't show the markdown toolbar if there isn't enough space
|
|
// for it:
|
|
flexShrink: 1,
|
|
}}
|
|
editorSettings={editorSettings}
|
|
editorControl={editorControl}
|
|
selectionState={selectionState}
|
|
searchState={searchState}
|
|
onAttach={props.onAttach}
|
|
readOnly={props.readOnly}
|
|
/>;
|
|
|
|
// - `scrollEnabled` prevents iOS from scrolling the document (has no effect on Android)
|
|
// when an editable region (e.g. a the full-screen NoteEditor) is focused.
|
|
return (
|
|
<View
|
|
testID='note-editor-root'
|
|
onLayout={onContainerLayout}
|
|
style={{
|
|
...props.style,
|
|
flexDirection: 'column',
|
|
}}
|
|
>
|
|
<EditLinkDialog
|
|
visible={linkDialogVisible}
|
|
themeId={props.themeId}
|
|
editorControl={editorControl}
|
|
selectionState={selectionState}
|
|
/>
|
|
<View style={{
|
|
flexGrow: 1,
|
|
flexShrink: 0,
|
|
minHeight: '30%',
|
|
...props.contentStyle,
|
|
}}>
|
|
<ExtendedWebView
|
|
webviewInstanceId='NoteEditor'
|
|
themeId={props.themeId}
|
|
scrollEnabled={false}
|
|
ref={webviewRef}
|
|
html={html}
|
|
injectedJavaScript={injectedJavaScript}
|
|
onMessage={onMessage}
|
|
onError={onError}
|
|
/>
|
|
</View>
|
|
|
|
<SearchPanel
|
|
editorSettings={editorSettings}
|
|
searchControl={editorControl.searchControl}
|
|
searchState={searchState}
|
|
/>
|
|
|
|
{toolbarEnabled ? toolbar : null}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
export default forwardRef(NoteEditor);
|