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'; const React = require('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 { _ } from '@joplin/lib/locale'; import MarkdownToolbar from './MarkdownToolbar/MarkdownToolbar'; type ChangeEventHandler = (event: ChangeEvent)=> void; type UndoRedoDepthChangeHandler = (event: UndoRedoDepthChangeEvent)=> void; type SelectionChangeEventHandler = (event: SelectionChangeEvent)=> void; type OnAttachCallback = ()=> void; interface Props { themeId: number; initialText: string; initialSelection?: Selection; 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(` ${_('Note editor')}
`); }, [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), 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, searchStateRef: RefObject ): EditorControl => { return useMemo(() => { return { 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();'); }, 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() { setSearchState({ ...searchStateRef.current, dialogVisible: true, }); }, hideSearch() { setSearchState({ ...searchStateRef.current, dialogVisible: false, }); }, }, }; }, [injectJS, searchStateRef, 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'), readOnly: 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 ); }; 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 [linkDialogVisible, setLinkDialogVisible] = useState(false); const [searchState, setSearchState] = useState(defaultSearchState); // Having a [searchStateRef] allows [editorControl] to not be re-created // whenever [searchState] changes. const searchStateRef = useRef(defaultSearchState); // Keep the reference and the [searchState] in sync useEffect(() => { searchStateRef.current = searchState; }, [searchState]); // / Runs [js] in the context of the CodeMirror frame. const injectJS = (js: string) => { webviewRef.current.injectJS(js); }; const editorControl = useEditorControl( injectJS, setLinkDialogVisible, setSearchState, searchStateRef ); 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 = { onLog: (event: any) => { // eslint-disable-next-line no-console console.info('CodeMirror:', ...event.value); }, onChange: (event: ChangeEvent) => { props.onChange(event); }, 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 (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 = ; // - `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 ( {toolbarEnabled ? toolbar : null} ); } export default forwardRef(NoteEditor);