1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-12 08:54:00 +02:00
joplin/packages/app-mobile/components/NoteEditor/NoteEditor.tsx

418 lines
10 KiB
TypeScript
Raw Normal View History

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';
const React = require('react');
const { forwardRef, useImperativeHandle } = require('react');
const { useEffect, useMemo, useState, useCallback, useRef } = require('react');
const { WebView } = require('react-native-webview');
const { View } = require('react-native');
const { editorFont } = require('../global-style');
import SelectionFormatting from './SelectionFormatting';
import {
EditorSettings,
EditorControl,
ChangeEvent, UndoRedoDepthChangeEvent, Selection, SelectionChangeEvent,
ListType,
SearchState,
} from './types';
import { _ } from '@joplin/lib/locale';
type ChangeEventHandler = (event: ChangeEvent)=> void;
type UndoRedoDepthChangeHandler = (event: UndoRedoDepthChangeEvent)=> void;
type SelectionChangeEventHandler = (event: SelectionChangeEvent)=> void;
interface Props {
themeId: number;
initialText: string;
initialSelection?: Selection;
style: any;
onChange: ChangeEventHandler;
onSelectionChange: SelectionChangeEventHandler;
onUndoRedoDepthChange: UndoRedoDepthChangeHandler;
}
function fontFamilyFromSettings() {
const f = editorFont(Setting.value('style.editor.fontFamily'));
return [f, 'sans-serif'].join(', ');
}
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) {
return {
...themeStyle(themeId),
fontSize: 0.85, // em
fontFamily: fontFamilyFromSettings(),
};
}
function NoteEditor(props: Props, ref: any) {
const [source, setSource] = useState(undefined);
const webviewRef = useRef(null);
const setInitialSelectionJS = props.initialSelection ? `
cm.select(${props.initialSelection.start}, ${props.initialSelection.end});
` : '';
const editorSettings: EditorSettings = {
themeData: editorTheme(props.themeId),
katexEnabled: Setting.value('markdown.plugin.katex') as boolean,
};
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
);
};
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.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(new SelectionFormatting());
const [searchState, setSearchState] = useState(defaultSearchState);
const [linkDialogVisible, setLinkDialogVisible] = useState(false);
// / Runs [js] in the context of the CodeMirror frame.
const injectJS = (js: string) => {
webviewRef.current.injectJavaScript(`
try {
${js}
}
catch(e) {
logMessage('Error in injected JS:' + e, e);
throw e;
};
true;`);
};
const editorControl: EditorControl = {
undo() {
injectJS('cm.undo();');
},
redo() {
injectJS('cm.redo();');
},
select(anchor: number, head: number) {
injectJS(
`cm.select(${JSON.stringify(anchor)}, ${JSON.stringify(head)});`
);
},
insertText(text: string) {
injectJS(`cm.insertText(${JSON.stringify(text)});`);
},
toggleBolded() {
injectJS('cm.toggleBolded();');
},
toggleItalicized() {
injectJS('cm.toggleItalicized();');
},
toggleList(listType: ListType) {
injectJS(`cm.toggleList(${JSON.stringify(listType)});`);
},
toggleCode() {
injectJS('cm.toggleCode();');
},
toggleMath() {
injectJS('cm.toggleMath();');
},
toggleHeaderLevel(level: number) {
injectJS(`cm.toggleHeaderLevel(${level});`);
},
increaseIndent() {
injectJS('cm.increaseIndent();');
},
decreaseIndent() {
injectJS('cm.decreaseIndent();');
},
updateLink(label: string, url: string) {
injectJS(`cm.updateLink(
${JSON.stringify(label)},
${JSON.stringify(url)}
);`);
},
scrollSelectionIntoView() {
injectJS('cm.scrollSelectionIntoView();');
},
showLinkDialog() {
setLinkDialogVisible(true);
},
hideLinkDialog() {
setLinkDialogVisible(false);
},
hideKeyboard() {
injectJS('document.activeElement?.blur();');
},
setSpellcheckEnabled(enabled: boolean) {
injectJS(`cm.setSpellcheckEnabled(${enabled ? 'true' : 'false'});`);
},
searchControl: {
findNext() {
injectJS('cm.searchControl.findNext();');
},
findPrevious() {
injectJS('cm.searchControl.findPrevious();');
},
replaceCurrent() {
injectJS('cm.searchControl.replaceCurrent();');
},
replaceAll() {
injectJS('cm.searchControl.replaceAll();');
},
setSearchState(state: SearchState) {
injectJS(`cm.searchControl.setSearchState(${JSON.stringify(state)})`);
setSearchState(state);
},
showSearch() {
const newSearchState: SearchState = Object.assign({}, searchState);
newSearchState.dialogVisible = true;
setSearchState(newSearchState);
},
hideSearch() {
const newSearchState: SearchState = Object.assign({}, searchState);
newSearchState.dialogVisible = false;
setSearchState(newSearchState);
},
},
};
useImperativeHandle(ref, () => {
return editorControl;
});
useEffect(() => {
let cancelled = false;
async function createHtmlFile() {
const tempFile = `${Setting.value('resourceDir')}/NoteEditor.html`;
await shim.fsDriver().writeFile(tempFile, html, 'utf8');
if (cancelled) return;
setSource({
uri: `file://${tempFile}?r=${Math.round(Math.random() * 100000000)}`,
baseUrl: `file://${Setting.value('resourceDir')}/`,
});
}
void createHtmlFile();
return () => {
cancelled = true;
};
}, [html]);
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);
const handlers: Record<string, Function> = {
onLog: (event: any) => {
console.info('CodeMirror:', ...event.value);
},
onChange: (event: ChangeEvent) => {
props.onChange(event);
},
onUndoRedoDepthChange: (event: UndoRedoDepthChangeEvent) => {
console.info('onUndoRedoDepthChange', event);
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 (handlers[msg.name]) {
handlers[msg.name](msg.data);
} else {
console.info('Unsupported CodeMirror message:', msg);
}
}, [props.onChange]);
const onError = useCallback(() => {
console.error('NoteEditor: webview error');
});
// - `setSupportMultipleWindows` must be `true` for security reasons:
// https://github.com/react-native-webview/react-native-webview/releases/tag/v11.0.0
// - `scrollEnabled` prevents iOS from scrolling the document (has no effect on Android)
// when the editor is focused.
return (
<View style={{
...props.style,
flexDirection: 'column',
}}>
<EditLinkDialog
visible={linkDialogVisible}
themeId={props.themeId}
editorControl={editorControl}
selectionState={selectionState}
/>
<View style={{
flexGrow: 1,
flexShrink: 0,
minHeight: '40%',
}}>
<WebView
style={{
backgroundColor: editorSettings.themeData.backgroundColor,
}}
ref={webviewRef}
scrollEnabled={false}
useWebKit={true}
source={source}
setSupportMultipleWindows={true}
hideKeyboardAccessoryView={true}
allowingReadAccessToURL={`file://${Setting.value('resourceDir')}`}
originWhitelist={['file://*', './*', 'http://*', 'https://*']}
allowFileAccess={true}
injectedJavaScript={injectedJavaScript}
onMessage={onMessage}
onError={onError}
/>
</View>
<SearchPanel
editorSettings={editorSettings}
searchControl={editorControl.searchControl}
searchState={searchState}
/>
</View>
);
}
export default forwardRef(NoteEditor);