import Setting from '@joplin/lib/models/Setting'; import shim from '@joplin/lib/shim'; import { themeStyle } from '@joplin/lib/theme'; const React = require('react'); const { forwardRef, useImperativeHandle, useEffect, useMemo, useState, useCallback, useRef } = require('react'); const { WebView } = require('react-native-webview'); const { editorFont } = require('../global-style'); export interface ChangeEvent { value: string; } export interface UndoRedoDepthChangeEvent { undoDepth: number; redoDepth: number; } export interface Selection { start: number; end: number; } export interface SelectionChangeEvent { selection: Selection; } 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(', '); } // Obsolete with CodeMirror 6. See ./CodeMirror.ts for styling. // function useCss(themeId:number):string { // const [css, setCss] = useState(''); // // useEffect(() => { // // const theme = themeStyle(themeId); // // // Selection in dark mode is hard to see so make it brighter. // // // https://discourse.joplinapp.org/t/dragging-in-dark-theme/12433/4?u=laurent // // const selectionColorCss = theme.appearance === ThemeAppearance.Dark ? // // `.CodeMirror-selected { // // background: #6b6b6b !important; // // }` : ''; // // const monospaceFonts = []; // // // if (Setting.value('style.editor.monospaceFontFamily')) monospaceFonts.push(`"${Setting.value('style.editor.monospaceFontFamily')}"`); // // monospaceFonts.push('monospace'); // // const fontSize = 15; // // const fontFamily = fontFamilyFromSettings(); // // // BUG: caret-color seems to be ignored for some reason // // const caretColor = theme.appearance === ThemeAppearance.Dark ? "white" : 'black'; // // setCss(` // // /* These must be important to prevent the codemirror defaults from taking over*/ // // .CodeMirror { // // font-family: ${fontFamily}; // // font-size: ${fontSize}px; // // height: 100% !important; // // width: 100% !important; // // color: ${theme.color}; // // background-color: ${theme.backgroundColor}; // // position: absolute !important; // // -webkit-box-shadow: none !important; // Some themes add a box shadow for some reason // // } // // .CodeMirror-lines { // // /* This is used to enable the scroll-past end behaviour. The same height should */ // // /* be applied to the viewer. */ // // padding-bottom: 400px !important; // // } // // /* Left padding is applied at the editor component level, so we should remove it from the lines */ // // .CodeMirror pre.CodeMirror-line, // // .CodeMirror pre.CodeMirror-line-like { // // padding-left: 0; // // } // // .CodeMirror-sizer { // // /* Add a fixed right padding to account for the appearance (and disappearance) */ // // /* of the sidebar */ // // padding-right: 10px !important; // // } // // /* This enforces monospace for certain elements (code, tables, etc.) */ // // .cm-jn-monospace { // // font-family: ${monospaceFonts.join(', ')} !important; // // } // // .cm-header-1 { // // font-size: 1.5em; // // } // // .cm-header-2 { // // font-size: 1.3em; // // } // // .cm-header-3 { // // font-size: 1.1em; // // } // // .cm-header-4, .cm-header-5, .cm-header-6 { // // font-size: 1em; // // } // // .cm-header-1, .cm-header-2, .cm-header-3, .cm-header-4, .cm-header-5, .cm-header-6 { // // line-height: 1.5em; // // } // // .cm-search-marker { // // background: ${theme.searchMarkerBackgroundColor}; // // color: ${theme.searchMarkerColor} !important; // // } // // .cm-search-marker-selected { // // background: ${theme.selectedColor2}; // // color: ${theme.color2} !important; // // } // // .cm-search-marker-scrollbar { // // background: ${theme.searchMarkerBackgroundColor}; // // -moz-box-sizing: border-box; // // box-sizing: border-box; // // opacity: .5; // // } // // /* We need to use important to override theme specific values */ // // .cm-error { // // color: inherit !important; // // background-color: inherit !important; // // border-bottom: 1px dotted #dc322f; // // } // // /* The default dark theme colors don't have enough contrast with the background */ // // .cm-s-nord span.cm-comment { // // color: #9aa4b6 !important; // // } // // .cm-s-dracula span.cm-comment { // // color: #a1abc9 !important; // // } // // .cm-s-monokai span.cm-comment { // // color: #908b74 !important; // // } // // .cm-s-material-darker span.cm-comment { // // color: #878787 !important; // // } // // .cm-s-solarized.cm-s-dark span.cm-comment { // // color: #8ba1a7 !important; // // } // // /* MOBILE SPECIFIC */ // // .CodeMirror .cm-scroller, // // .CodeMirror .cm-line { // // font-family: ${fontFamily}; // // caret-color: ${caretColor}; // // } // // ${selectionColorCss} // // `); // // }, [themeId]); // return css; // } function useCss(themeId: number): string { return useMemo(() => { const theme = themeStyle(themeId); return ` :root { background-color: ${theme.backgroundColor}; } `; }, [themeId]); } function useHtml(css: string): string { const [html, setHtml] = useState(''); useEffect(() => { setHtml( `
` ); }, [css]); return html; } function editorTheme(themeId: number) { return { ...themeStyle(themeId), fontSize: 15, 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 injectedJavaScript = ` function postMessage(name, data) { window.ReactNativeWebView.postMessage(JSON.stringify({ data, name, })); } function logMessage(...msg) { postMessage('onLog', { value: msg }); } // 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 theme = ${JSON.stringify(editorTheme(props.themeId))}; const initialText = ${JSON.stringify(props.initialText)}; cm = codeMirrorBundle.initCodeMirror(parentElement, initialText, theme); ${setInitialSelectionJS} // Fixes https://github.com/laurent22/joplin/issues/5949 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); useImperativeHandle(ref, () => { return { undo: function() { webviewRef.current.injectJavaScript('cm.undo(); true;'); }, redo: function() { webviewRef.current.injectJavaScript('cm.redo(); true;'); }, select: (anchor: number, head: number) => { webviewRef.current.injectJavaScript( `cm.select(${JSON.stringify(anchor)}, ${JSON.stringify(head)}); true;` ); }, insertText: (text: string) => { webviewRef.current.injectJavaScript(`cm.insertText(${JSON.stringify(text)}); true;`); }, }; }); 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 = { 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); }, }; 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 return ; } export default forwardRef(NoteEditor);