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 { WebViewControl } from '../ExtendedWebView/types'; import * as React from 'react'; import { forwardRef, RefObject, 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 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, SearchState } from '@joplin/editor/types'; import SelectionFormatting, { defaultSelectionFormatting } from '@joplin/editor/SelectionFormatting'; import useCodeMirrorPlugins from './hooks/useCodeMirrorPlugins'; import RNToWebViewMessenger from '../../utils/ipc/RNToWebViewMessenger'; 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'; import { OnMessageEvent } from '../ExtendedWebView/types'; import { join, dirname } from 'path'; import * as mimeUtils from '@joplin/lib/mime-utils'; import uuid from '@joplin/lib/uuid'; type ChangeEventHandler = (event: ChangeEvent)=> void; type UndoRedoDepthChangeHandler = (event: UndoRedoDepthChangeEvent)=> void; type SelectionChangeEventHandler = (event: SelectionRangeChangeEvent)=> void; type OnAttachCallback = (filePath?: string)=> Promise; const logger = Logger.create('NoteEditor'); interface Props { themeId: number; initialText: string; initialSelection?: SelectionRange; style: ViewStyle; toolbarEnabled: boolean; readOnly: boolean; plugins: PluginStates; onChange: ChangeEventHandler; onSelectionChange: SelectionChangeEventHandler; onUndoRedoDepthChange: UndoRedoDepthChangeHandler; onAttach: OnAttachCallback; } function fontFamilyFromSettings() { const font = editorFont(Setting.value('style.editor.fontFamily') as number); return font ? `${font}, sans-serif` : 'sans-serif'; } function useCss(themeId: number): string { return useMemo(() => { const theme = themeStyle(themeId); const themeVariableCss = themeToCss(theme); return ` ${themeVariableCss} :root { background-color: ${theme.backgroundColor}; } body { margin: 0; height: 100vh; /* Prefer 100% -- 100vw shows an unnecessary horizontal scrollbar in Google Chrome (desktop). */ width: 100%; box-sizing: border-box; padding-left: 1px; padding-right: 1px; padding-bottom: 1px; padding-top: 10px; font-size: 13pt; } * { scrollbar-width: thin; scrollbar-color: rgba(100, 100, 100, 0.7) rgba(0, 0, 0, 0.1); } @supports selector(::-webkit-scrollbar) { *::-webkit-scrollbar { width: 7px; height: 7px; } *::-webkit-scrollbar-corner { background: none; } *::-webkit-scrollbar-track { border: none; } *::-webkit-scrollbar-thumb { background: rgba(100, 100, 100, 0.3); border-radius: 5px; } *::-webkit-scrollbar-track:hover { background: rgba(0, 0, 0, 0.1); } *::-webkit-scrollbar-thumb:hover { background: rgba(100, 100, 100, 0.7); } * { scrollbar-width: unset; scrollbar-color: unset; } } `; }, [themeId]); } const themeStyleSheetClassName = 'note-editor-styles'; function useHtml(initialCss: string): string { const cssRef = useRef(initialCss); cssRef.current = initialCss; return useMemo(() => ` ${_('Note editor')}
`, []); } 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 OnSetVisibleCallback = (visible: boolean)=> void; type OnSearchStateChangeCallback = (state: SearchState)=> void; const useEditorControl = ( bodyControl: EditorBodyControl, webviewRef: RefObject, setLinkDialogVisible: OnSetVisibleCallback, setSearchState: OnSearchStateChangeCallback, ): EditorControl => { return useMemo(() => { const execCommand = (command: EditorCommandType) => { void bodyControl.execCommand(command); }; const setSearchStateCallback = (state: SearchState) => { bodyControl.setSearchState(state); setSearchState(state); }; const control: EditorControl = { supportsCommand(command: EditorCommandType) { return bodyControl.supportsCommand(command); }, // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied execCommand(command, ...args: any[]) { return bodyControl.execCommand(command, ...args); }, focus() { void bodyControl.execCommand(EditorCommandType.Focus); }, undo() { bodyControl.undo(); }, redo() { bodyControl.redo(); }, select(anchor: number, head: number) { bodyControl.select(anchor, head); }, setScrollPercent(fraction: number) { bodyControl.setScrollPercent(fraction); }, insertText(text: string) { bodyControl.insertText(text); }, updateBody(newBody: string) { bodyControl.updateBody(newBody); }, updateSettings(newSettings: EditorSettings) { bodyControl.updateSettings(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) { bodyControl.updateLink(label, url); }, scrollSelectionIntoView() { execCommand(EditorCommandType.ScrollSelectionIntoView); }, showLinkDialog() { setLinkDialogVisible(true); }, hideLinkDialog() { setLinkDialogVisible(false); }, hideKeyboard() { webviewRef.current.injectJS('document.activeElement?.blur();'); }, setContentScripts: async (plugins: ContentScriptData[]) => { return bodyControl.setContentScripts(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; }, [webviewRef, bodyControl, setLinkDialogVisible, setSearchState]); }; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied function NoteEditor(props: Props, ref: any) { const webviewRef = useRef(null); const setInitialSelectionJS = props.initialSelection ? ` cm.select(${props.initialSelection.start}, ${props.initialSelection.end}); cm.execCommand('scrollSelectionIntoView'); ` : ''; const editorSettings: EditorSettings = useMemo(() => ({ 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, autocompleteMarkup: Setting.value('editor.autocompleteMarkup'), indentWithTabs: true, editorLabel: _('Markdown editor'), }), [props.themeId, props.readOnly]); const injectedJavaScript = ` 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)}; window.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); 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(defaultSelectionFormatting); const [linkDialogVisible, setLinkDialogVisible] = useState(false); const [searchState, setSearchState] = useState(defaultSearchState); const onEditorEvent = useRef((_event: EditorEvent) => {}); const onAttachRef = useRef(props.onAttach); onAttachRef.current = props.onAttach; const editorMessenger = useMemo(() => { const localApi: WebViewToEditorApi = { async onEditorEvent(event) { onEditorEvent.current(event); }, async logMessage(message) { logger.debug('CodeMirror:', message); }, async onPasteFile(type, data) { const tempFilePath = join(Setting.value('tempDir'), `paste.${uuid.createNano()}.${mimeUtils.toFileExtension(type)}`); await shim.fsDriver().mkdir(dirname(tempFilePath)); try { await shim.fsDriver().writeFile(tempFilePath, data, 'base64'); await onAttachRef.current(tempFilePath); } finally { await shim.fsDriver().remove(tempFilePath); } }, }; const messenger = new RNToWebViewMessenger( 'editor', webviewRef, localApi, ); return messenger; }, []); const editorControl = useEditorControl( editorMessenger.remoteApi, webviewRef, setLinkDialogVisible, setSearchState, ); useEffect(() => { editorControl.updateSettings(editorSettings); }, [editorSettings, editorControl]); useEditorCommandHandler(editorControl); useImperativeHandle(ref, () => { return editorControl; }); 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: OnMessageEvent) => { const data = event.nativeEvent.data; if (typeof data === 'string' && data.indexOf('error:') === 0) { logger.error('CodeMirror error', data); return; } editorMessenger.onWebViewMessage(event); }, [editorMessenger]); const onError = useCallback((event: NativeSyntheticEvent) => { logger.error(`Load error: Code ${event.nativeEvent.code}: ${event.nativeEvent.description}`); }, []); 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 = ; return ( 0} onMessage={onMessage} onLoadEnd={onLoadEnd} onError={onError} /> {toolbarEnabled ? toolbar : null} ); } export default forwardRef(NoteEditor);