1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-11-26 22:41:17 +02:00

Mobile: Rich Text Editor: Enable syntax highlighting and auto-indent in the code block editor (#12909)

This commit is contained in:
Henry Heino
2025-08-19 23:29:30 -07:00
committed by GitHub
parent 8a811b9e78
commit af5c0135dc
20 changed files with 294 additions and 88 deletions

View File

@@ -698,7 +698,6 @@ packages/app-mobile/components/NoteEditor/RichTextEditor.js
packages/app-mobile/components/NoteEditor/SearchPanel.js packages/app-mobile/components/NoteEditor/SearchPanel.js
packages/app-mobile/components/NoteEditor/WarningBanner.js packages/app-mobile/components/NoteEditor/WarningBanner.js
packages/app-mobile/components/NoteEditor/commandDeclarations.js packages/app-mobile/components/NoteEditor/commandDeclarations.js
packages/app-mobile/components/NoteEditor/hooks/useCodeMirrorPlugins.js
packages/app-mobile/components/NoteEditor/hooks/useEditorCommandHandler.test.js packages/app-mobile/components/NoteEditor/hooks/useEditorCommandHandler.test.js
packages/app-mobile/components/NoteEditor/hooks/useEditorCommandHandler.js packages/app-mobile/components/NoteEditor/hooks/useEditorCommandHandler.js
packages/app-mobile/components/NoteEditor/testing/createTestEditorProps.js packages/app-mobile/components/NoteEditor/testing/createTestEditorProps.js
@@ -868,6 +867,7 @@ packages/app-mobile/contentScripts/imageEditorBundle/utils/useEditorMessenger.js
packages/app-mobile/contentScripts/markdownEditorBundle/contentScript.js packages/app-mobile/contentScripts/markdownEditorBundle/contentScript.js
packages/app-mobile/contentScripts/markdownEditorBundle/types.js packages/app-mobile/contentScripts/markdownEditorBundle/types.js
packages/app-mobile/contentScripts/markdownEditorBundle/useWebViewSetup.js packages/app-mobile/contentScripts/markdownEditorBundle/useWebViewSetup.js
packages/app-mobile/contentScripts/markdownEditorBundle/utils/useCodeMirrorPlugins.js
packages/app-mobile/contentScripts/rendererBundle/contentScript/Renderer.test.js packages/app-mobile/contentScripts/rendererBundle/contentScript/Renderer.test.js
packages/app-mobile/contentScripts/rendererBundle/contentScript/Renderer.js packages/app-mobile/contentScripts/rendererBundle/contentScript/Renderer.js
packages/app-mobile/contentScripts/rendererBundle/contentScript/index.js packages/app-mobile/contentScripts/rendererBundle/contentScript/index.js

2
.gitignore vendored
View File

@@ -671,7 +671,6 @@ packages/app-mobile/components/NoteEditor/RichTextEditor.js
packages/app-mobile/components/NoteEditor/SearchPanel.js packages/app-mobile/components/NoteEditor/SearchPanel.js
packages/app-mobile/components/NoteEditor/WarningBanner.js packages/app-mobile/components/NoteEditor/WarningBanner.js
packages/app-mobile/components/NoteEditor/commandDeclarations.js packages/app-mobile/components/NoteEditor/commandDeclarations.js
packages/app-mobile/components/NoteEditor/hooks/useCodeMirrorPlugins.js
packages/app-mobile/components/NoteEditor/hooks/useEditorCommandHandler.test.js packages/app-mobile/components/NoteEditor/hooks/useEditorCommandHandler.test.js
packages/app-mobile/components/NoteEditor/hooks/useEditorCommandHandler.js packages/app-mobile/components/NoteEditor/hooks/useEditorCommandHandler.js
packages/app-mobile/components/NoteEditor/testing/createTestEditorProps.js packages/app-mobile/components/NoteEditor/testing/createTestEditorProps.js
@@ -841,6 +840,7 @@ packages/app-mobile/contentScripts/imageEditorBundle/utils/useEditorMessenger.js
packages/app-mobile/contentScripts/markdownEditorBundle/contentScript.js packages/app-mobile/contentScripts/markdownEditorBundle/contentScript.js
packages/app-mobile/contentScripts/markdownEditorBundle/types.js packages/app-mobile/contentScripts/markdownEditorBundle/types.js
packages/app-mobile/contentScripts/markdownEditorBundle/useWebViewSetup.js packages/app-mobile/contentScripts/markdownEditorBundle/useWebViewSetup.js
packages/app-mobile/contentScripts/markdownEditorBundle/utils/useCodeMirrorPlugins.js
packages/app-mobile/contentScripts/rendererBundle/contentScript/Renderer.test.js packages/app-mobile/contentScripts/rendererBundle/contentScript/Renderer.test.js
packages/app-mobile/contentScripts/rendererBundle/contentScript/Renderer.js packages/app-mobile/contentScripts/rendererBundle/contentScript/Renderer.js
packages/app-mobile/contentScripts/rendererBundle/contentScript/index.js packages/app-mobile/contentScripts/rendererBundle/contentScript/index.js

View File

@@ -3,13 +3,11 @@ import themeToCss from '@joplin/lib/services/style/themeToCss';
import ExtendedWebView from '../ExtendedWebView'; import ExtendedWebView from '../ExtendedWebView';
import * as React from 'react'; import * as React from 'react';
import { useEffect } from 'react';
import { useMemo, useCallback } from 'react'; import { useMemo, useCallback } from 'react';
import { NativeSyntheticEvent } from 'react-native'; import { NativeSyntheticEvent } from 'react-native';
import { EditorProps } from './types'; import { EditorProps } from './types';
import { _ } from '@joplin/lib/locale'; import { _ } from '@joplin/lib/locale';
import useCodeMirrorPlugins from './hooks/useCodeMirrorPlugins';
import { WebViewErrorEvent } from 'react-native-webview/lib/RNCWebViewNativeComponent'; import { WebViewErrorEvent } from 'react-native-webview/lib/RNCWebViewNativeComponent';
import Logger from '@joplin/utils/Logger'; import Logger from '@joplin/utils/Logger';
import { OnMessageEvent } from '../ExtendedWebView/types'; import { OnMessageEvent } from '../ExtendedWebView/types';
@@ -117,16 +115,16 @@ const MarkdownEditor: React.FC<EditorProps> = props => {
onEditorEvent: props.onEditorEvent, onEditorEvent: props.onEditorEvent,
onAttachFile: props.onAttach, onAttachFile: props.onAttach,
editorOptions: { editorOptions: {
parentElementClassName: 'CodeMirror', parentElementOrClassName: 'CodeMirror',
initialText: props.initialText, initialText: props.initialText,
initialNoteId: props.noteId, initialNoteId: props.noteId,
settings: props.editorSettings, settings: props.editorSettings,
onLocalize: _,
}, },
webviewRef, webviewRef,
pluginStates: props.plugins,
}); });
props.editorRef.current = editorWebViewSetup.api.editor; props.editorRef.current = editorWebViewSetup.api.mainEditor;
const injectedJavaScript = ` const injectedJavaScript = `
window.onerror = (message, source, lineno) => { window.onerror = (message, source, lineno) => {
@@ -154,11 +152,6 @@ const MarkdownEditor: React.FC<EditorProps> = props => {
const css = useCss(props.themeId); const css = useCss(props.themeId);
const html = useHtml(); const html = useHtml();
const codeMirrorPlugins = useCodeMirrorPlugins(props.plugins);
useEffect(() => {
void editorWebViewSetup.api.editor.setContentScripts(codeMirrorPlugins);
}, [codeMirrorPlugins, editorWebViewSetup]);
const onMessage = useCallback((event: OnMessageEvent) => { const onMessage = useCallback((event: OnMessageEvent) => {
const data = event.nativeEvent.data; const data = event.nativeEvent.data;
@@ -183,7 +176,7 @@ const MarkdownEditor: React.FC<EditorProps> = props => {
html={html} html={html}
injectedJavaScript={injectedJavaScript} injectedJavaScript={injectedJavaScript}
css={css} css={css}
hasPluginScripts={codeMirrorPlugins.length > 0} hasPluginScripts={editorWebViewSetup.hasPlugins}
onMessage={onMessage} onMessage={onMessage}
onLoadEnd={editorWebViewSetup.webViewEventHandlers.onLoadEnd} onLoadEnd={editorWebViewSetup.webViewEventHandlers.onLoadEnd}
onError={onError} onError={onError}

View File

@@ -232,6 +232,10 @@ const useEditorControl = (
onResourceDownloaded: (id: string) => { onResourceDownloaded: (id: string) => {
editorRef.current.onResourceDownloaded(id); editorRef.current.onResourceDownloaded(id);
}, },
remove: () => {
editorRef.current.remove();
},
}; };
return control; return control;
@@ -300,6 +304,7 @@ function NoteEditor(props: Props) {
editorControl.searchControl.hideSearch(); editorControl.searchControl.hideSearch();
} }
break; break;
case EditorEventType.Remove:
case EditorEventType.Scroll: case EditorEventType.Scroll:
// Not handled // Not handled
break; break;

View File

@@ -370,6 +370,21 @@ describe('RichTextEditor', () => {
}); });
}); });
it('should be possible show an editor for math blocks', async () => {
let body = 'Test:\n\n$$3^2 + 4^2 = 5^2$$';
render(<WrappedEditor
noteBody={body}
onBodyChange={newBody => { body = newBody; }}
/>);
const editButton = await findElement<HTMLButtonElement>('button.edit');
editButton.click();
const editor = await findElement('dialog .cm-editor');
expect(editor).toBeTruthy();
expect(editor.textContent).toContain('3^2 + 4^2 = 5^2');
});
it('should preserve table of contents blocks on edit', async () => { it('should preserve table of contents blocks on edit', async () => {
let body = '# Heading\n\n# Heading 2\n\n[toc]\n\nTest.'; let body = '# Heading\n\n# Heading 2\n\n[toc]\n\nTest.';

View File

@@ -1,30 +1,57 @@
import { createEditor } from '@joplin/editor/CodeMirror'; import { createEditor } from '@joplin/editor/CodeMirror';
import { focus } from '@joplin/lib/utils/focusHandler'; import { focus } from '@joplin/lib/utils/focusHandler';
import WebViewToRNMessenger from '../../utils/ipc/WebViewToRNMessenger'; import WebViewToRNMessenger from '../../utils/ipc/WebViewToRNMessenger';
import { EditorProcessApi, EditorProps, MainProcessApi } from './types'; import { EditorProcessApi, EditorProps, EditorWithParentProps, ExportedWebViewGlobals, MainProcessApi } from './types';
import readFileToBase64 from '../utils/readFileToBase64'; import readFileToBase64 from '../utils/readFileToBase64';
import { EditorControl } from '@joplin/editor/types';
import { EditorEventType } from '@joplin/editor/events';
export { default as setUpLogger } from '../utils/setUpLogger'; export { default as setUpLogger } from '../utils/setUpLogger';
export const initializeEditor = ({ interface ExtendedWindow extends ExportedWebViewGlobals, Window { }
parentElementClassName, declare const window: ExtendedWindow;
let mainEditor: EditorControl|null = null;
let allEditors: EditorControl[] = [];
const messenger = new WebViewToRNMessenger<EditorProcessApi, MainProcessApi>('markdownEditor', {
get mainEditor() {
return mainEditor;
},
updatePlugins(contentScripts) {
for (const editor of allEditors) {
void editor.setContentScripts(contentScripts);
}
},
updateSettings(settings) {
for (const editor of allEditors) {
editor.updateSettings(settings);
}
},
});
export const createEditorWithParent = ({
parentElementOrClassName,
initialText, initialText,
initialNoteId, initialNoteId,
settings, settings,
onLocalize, onEvent,
}: EditorProps) => { }: EditorWithParentProps) => {
const messenger = new WebViewToRNMessenger<EditorProcessApi, MainProcessApi>('markdownEditor', null); const parentElement = (() => {
if (parentElementOrClassName instanceof HTMLElement) {
const parentElement = document.getElementsByClassName(parentElementClassName)[0] as HTMLElement; return parentElementOrClassName;
}
return document.getElementsByClassName(parentElementOrClassName)[0] as HTMLElement;
})();
if (!parentElement) { if (!parentElement) {
throw new Error(`Unable to find parent element for editor (class name: ${JSON.stringify(parentElementClassName)})`); throw new Error(`Unable to find parent element for editor (class name: ${JSON.stringify(parentElementOrClassName)})`);
} }
const control = createEditor(parentElement, { const control = createEditor(parentElement, {
initialText, initialText,
initialNoteId, initialNoteId,
settings, settings,
onLocalize, onLocalize: messenger.remoteApi.onLocalize,
onPasteFile: async (data) => { onPasteFile: async (data) => {
const base64 = await readFileToBase64(data); const base64 = await readFileToBase64(data);
@@ -34,14 +61,32 @@ export const initializeEditor = ({
onLogMessage: message => { onLogMessage: message => {
void messenger.remoteApi.logMessage(message); void messenger.remoteApi.logMessage(message);
}, },
onEvent: (event): void => { onEvent: (event) => {
void messenger.remoteApi.onEditorEvent(event); onEvent(event);
if (event.kind === EditorEventType.Remove) {
allEditors = allEditors.filter(other => other !== control);
}
}, },
resolveImageSrc: (src) => { resolveImageSrc: (src) => {
return messenger.remoteApi.onResolveImageSrc(src); return messenger.remoteApi.onResolveImageSrc(src);
}, },
}); });
allEditors.push(control);
void messenger.remoteApi.onEditorAdded();
return control;
};
export const createMainEditor = (props: EditorProps) => {
const control = createEditorWithParent({
...props,
onEvent: (event) => {
void messenger.remoteApi.onEditorEvent(event);
},
});
// Works around https://github.com/laurent22/joplin/issues/10047 by handling // Works around https://github.com/laurent22/joplin/issues/10047 by handling
// the text/uri-list MIME type when pasting, rather than sending the paste event // the text/uri-list MIME type when pasting, rather than sending the paste event
// to CodeMirror. // to CodeMirror.
@@ -57,6 +102,7 @@ export const initializeEditor = ({
// Note: Just adding an onclick listener seems sufficient to focus the editor when its background // Note: Just adding an onclick listener seems sufficient to focus the editor when its background
// is tapped. // is tapped.
const parentElement = control.editor.dom.parentElement;
parentElement.addEventListener('click', (event) => { parentElement.addEventListener('click', (event) => {
const activeElement = document.querySelector(':focus'); const activeElement = document.querySelector(':focus');
if (!parentElement.contains(activeElement) && event.target === parentElement) { if (!parentElement.contains(activeElement) && event.target === parentElement) {
@@ -64,8 +110,9 @@ export const initializeEditor = ({
} }
}); });
messenger.setLocalInterface({ mainEditor = control;
editor: control,
});
return control; return control;
}; };
window.createEditorWithParent = createEditorWithParent;
window.createMainEditor = createMainEditor;

View File

@@ -1,8 +1,29 @@
import { EditorEvent } from '@joplin/editor/events'; import { EditorEvent } from '@joplin/editor/events';
import { EditorControl, EditorSettings, OnLocalize } from '@joplin/editor/types'; import { ContentScriptData, EditorControl, EditorSettings, LocalizationResult } from '@joplin/editor/types';
export interface EditorProps {
parentElementOrClassName: HTMLElement|string;
initialText: string;
initialNoteId: string|null;
settings: EditorSettings;
}
export interface EditorWithParentProps extends EditorProps {
onEvent: (editorEvent: EditorEvent)=> void;
}
// The Markdown editor exposes global functions within its <WebView>.
// These functions can be used externally.
export interface ExportedWebViewGlobals {
createEditorWithParent: (options: EditorWithParentProps)=> EditorControl;
createMainEditor: (props: EditorProps)=> EditorControl;
}
export interface EditorProcessApi { export interface EditorProcessApi {
editor: EditorControl; mainEditor: EditorControl;
updateSettings: (settings: EditorSettings)=> void;
updatePlugins: (contentScripts: ContentScriptData[])=> void;
} }
export interface SelectionRange { export interface SelectionRange {
@@ -10,16 +31,10 @@ export interface SelectionRange {
end: number; end: number;
} }
export interface EditorProps {
parentElementClassName: string;
initialText: string;
initialNoteId: string;
onLocalize: OnLocalize;
settings: EditorSettings;
}
export interface MainProcessApi { export interface MainProcessApi {
onLocalize(text: string): LocalizationResult;
onEditorEvent(event: EditorEvent): Promise<void>; onEditorEvent(event: EditorEvent): Promise<void>;
onEditorAdded(): Promise<void>;
logMessage(message: string): Promise<void>; logMessage(message: string): Promise<void>;
onPasteFile(type: string, dataBase64: string): Promise<void>; onPasteFile(type: string, dataBase64: string): Promise<void>;
onResolveImageSrc(src: string): Promise<string|null>; onResolveImageSrc(src: string): Promise<string|null>;

View File

@@ -7,6 +7,9 @@ import { OnMessageEvent, WebViewControl } from '../../components/ExtendedWebView
import { EditorEvent } from '@joplin/editor/events'; import { EditorEvent } from '@joplin/editor/events';
import Logger from '@joplin/utils/Logger'; import Logger from '@joplin/utils/Logger';
import RNToWebViewMessenger from '../../utils/ipc/RNToWebViewMessenger'; import RNToWebViewMessenger from '../../utils/ipc/RNToWebViewMessenger';
import { _ } from '@joplin/lib/locale';
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import useCodeMirrorPlugins from './utils/useCodeMirrorPlugins';
import Resource from '@joplin/lib/models/Resource'; import Resource from '@joplin/lib/models/Resource';
import { parseResourceUrl } from '@joplin/lib/urlUtils'; import { parseResourceUrl } from '@joplin/lib/urlUtils';
const { isImageMimeType } = require('@joplin/lib/resourceUtils'); const { isImageMimeType } = require('@joplin/lib/resourceUtils');
@@ -15,9 +18,10 @@ const logger = Logger.create('markdownEditor');
interface Props { interface Props {
editorOptions: EditorOptions; editorOptions: EditorOptions;
initialSelection: SelectionRange; initialSelection: SelectionRange|null;
noteHash: string; noteHash: string;
globalSearch: string; globalSearch: string;
pluginStates: PluginStates;
onEditorEvent: (event: EditorEvent)=> void; onEditorEvent: (event: EditorEvent)=> void;
onAttachFile: (mime: string, base64: string)=> void; onAttachFile: (mime: string, base64: string)=> void;
@@ -33,9 +37,11 @@ const defaultSearchState: SearchState = {
dialogVisible: false, dialogVisible: false,
}; };
type Result = SetUpResult<EditorProcessApi> & { hasPlugins: boolean };
const useWebViewSetup = ({ const useWebViewSetup = ({
editorOptions, initialSelection, noteHash, globalSearch, webviewRef, onEditorEvent, onAttachFile, editorOptions, pluginStates, initialSelection, noteHash, globalSearch, webviewRef, onEditorEvent, onAttachFile,
}: Props): SetUpResult<EditorProcessApi> => { }: Props): Result => {
const setInitialSelectionJs = initialSelection ? ` const setInitialSelectionJs = initialSelection ? `
cm.select(${initialSelection.start}, ${initialSelection.end}); cm.select(${initialSelection.start}, ${initialSelection.end});
cm.execCommand('scrollSelectionIntoView'); cm.execCommand('scrollSelectionIntoView');
@@ -51,20 +57,21 @@ const useWebViewSetup = ({
` : ''; ` : '';
const injectedJavaScript = useMemo(() => ` const injectedJavaScript = useMemo(() => `
if (typeof markdownEditorBundle === 'undefined') {
${shim.injectedJs('markdownEditorBundle')};
window.markdownEditorBundle = markdownEditorBundle;
markdownEditorBundle.setUpLogger();
}
if (!window.cm) { if (!window.cm) {
const parentClassName = ${JSON.stringify(editorOptions.parentElementClassName)}; const parentClassName = ${JSON.stringify(editorOptions?.parentElementOrClassName)};
const foundParent = document.getElementsByClassName(parentClassName).length > 0; const foundParent = !!parentClassName && document.getElementsByClassName(parentClassName).length > 0;
// On Android, injectedJavaScript can be run multiple times, including once before the // On Android, injectedJavaScript can be run multiple times, including once before the
// document has loaded. To avoid logging an error each time the editor starts, don't throw // document has loaded. To avoid logging an error each time the editor starts, don't throw
// if the parent element can't be found: // if the parent element can't be found:
if (foundParent) { if (foundParent) {
${shim.injectedJs('markdownEditorBundle')}; window.cm = markdownEditorBundle.createMainEditor(${JSON.stringify(editorOptions)});
markdownEditorBundle.setUpLogger();
window.cm = markdownEditorBundle.initializeEditor(
${JSON.stringify(editorOptions)}
);
${jumpToHashJs} ${jumpToHashJs}
// Set the initial selection after jumping to the header -- the initial selection, // Set the initial selection after jumping to the header -- the initial selection,
@@ -75,7 +82,7 @@ const useWebViewSetup = ({
window.onresize = () => { window.onresize = () => {
cm.execCommand('scrollSelectionIntoView'); cm.execCommand('scrollSelectionIntoView');
}; };
} else { } else if (parentClassName) {
console.log('No parent element found with class name ', parentClassName); console.log('No parent element found with class name ', parentClassName);
} }
} }
@@ -101,6 +108,10 @@ const useWebViewSetup = ({
const onAttachRef = useRef(onAttachFile); const onAttachRef = useRef(onAttachFile);
onAttachRef.current = onAttachFile; onAttachRef.current = onAttachFile;
const codeMirrorPlugins = useCodeMirrorPlugins(pluginStates);
const codeMirrorPluginsRef = useRef(codeMirrorPlugins);
codeMirrorPluginsRef.current = codeMirrorPlugins;
const editorMessenger = useMemo(() => { const editorMessenger = useMemo(() => {
const localApi: MainProcessApi = { const localApi: MainProcessApi = {
async onEditorEvent(event) { async onEditorEvent(event) {
@@ -112,6 +123,13 @@ const useWebViewSetup = ({
async onPasteFile(type, data) { async onPasteFile(type, data) {
onAttachRef.current(type, data); onAttachRef.current(type, data);
}, },
async onLocalize(text) {
const localizationFunction = _;
return localizationFunction(text);
},
async onEditorAdded() {
messenger.remoteApi.updatePlugins(codeMirrorPluginsRef.current);
},
async onResolveImageSrc(src) { async onResolveImageSrc(src) {
const url = parseResourceUrl(src); const url = parseResourceUrl(src);
if (!url.itemId) return null; if (!url.itemId) return null;
@@ -153,17 +171,22 @@ const useWebViewSetup = ({
const editorSettings = editorOptions.settings; const editorSettings = editorOptions.settings;
useEffect(() => { useEffect(() => {
api.editor.updateSettings(editorSettings); api.updateSettings(editorSettings);
}, [api, editorSettings]); }, [api, editorSettings]);
useEffect(() => {
api.updatePlugins(codeMirrorPlugins);
}, [codeMirrorPlugins, api]);
return useMemo(() => ({ return useMemo(() => ({
pageSetup: { pageSetup: {
js: injectedJavaScript, js: injectedJavaScript,
css: '', css: '',
}, },
hasPlugins: codeMirrorPlugins.length > 0,
api, api,
webViewEventHandlers, webViewEventHandlers,
}), [injectedJavaScript, api, webViewEventHandlers]); }), [injectedJavaScript, api, webViewEventHandlers, codeMirrorPlugins]);
}; };
export default useWebViewSetup; export default useWebViewSetup;

View File

@@ -7,6 +7,8 @@ import '@joplin/editor/ProseMirror/styles';
import readFileToBase64 from '../../utils/readFileToBase64'; import readFileToBase64 from '../../utils/readFileToBase64';
import { EditorLanguageType } from '@joplin/editor/types'; import { EditorLanguageType } from '@joplin/editor/types';
import convertHtmlToMarkdown from './convertHtmlToMarkdown'; import convertHtmlToMarkdown from './convertHtmlToMarkdown';
import { ExportedWebViewGlobals as MarkdownEditorWebViewGlobals } from '../../markdownEditorBundle/types';
import { EditorEventType } from '@joplin/editor/events';
const postprocessHtml = (html: HTMLElement) => { const postprocessHtml = (html: HTMLElement) => {
// Fix resource URLs // Fix resource URLs
@@ -35,13 +37,16 @@ const htmlToMarkdown = (html: HTMLElement): string => {
return convertHtmlToMarkdown(html); return convertHtmlToMarkdown(html);
}; };
export const initialize = async ({ export const initialize = async (
{
settings, settings,
initialText, initialText,
initialNoteId, initialNoteId,
parentElementClassName, parentElementClassName,
initialSearch, initialSearch,
}: EditorProps) => { }: EditorProps,
markdownEditorApi: MarkdownEditorWebViewGlobals,
) => {
const messenger = new WebViewToRNMessenger<EditorProcessApi, MainProcessApi>('rich-text-editor', null); const messenger = new WebViewToRNMessenger<EditorProcessApi, MainProcessApi>('rich-text-editor', null);
const parentElement = document.getElementsByClassName(parentElementClassName)[0]; const parentElement = document.getElementsByClassName(parentElementClassName)[0];
if (!parentElement) throw new Error('Parent element not found'); if (!parentElement) throw new Error('Parent element not found');
@@ -109,6 +114,18 @@ export const initialize = async ({
return postprocessHtml(html).outerHTML; return postprocessHtml(html).outerHTML;
} }
}, },
}, (parent, language, onChange) => {
return markdownEditorApi.createEditorWithParent({
initialText: '',
initialNoteId: '',
parentElementOrClassName: parent,
settings: { ...editor.getSettings(), language },
onEvent: (event) => {
if (event.kind === EditorEventType.Change) {
onChange(event.value);
}
},
});
}); });
editor.setSearchState(initialSearch); editor.setSearchState(initialSearch);

View File

@@ -4,6 +4,7 @@ import { SetUpResult } from '../types';
import { EditorControl, EditorSettings } from '@joplin/editor/types'; import { EditorControl, EditorSettings } from '@joplin/editor/types';
import RNToWebViewMessenger from '../../utils/ipc/RNToWebViewMessenger'; import RNToWebViewMessenger from '../../utils/ipc/RNToWebViewMessenger';
import { EditorProcessApi, EditorProps, MainProcessApi } from './types'; import { EditorProcessApi, EditorProps, MainProcessApi } from './types';
import useMarkdownEditorSetup from '../markdownEditorBundle/useWebViewSetup';
import useRendererSetup from '../rendererBundle/useWebViewSetup'; import useRendererSetup from '../rendererBundle/useWebViewSetup';
import { EditorEvent } from '@joplin/editor/events'; import { EditorEvent } from '@joplin/editor/events';
import Logger from '@joplin/utils/Logger'; import Logger from '@joplin/utils/Logger';
@@ -92,7 +93,10 @@ const useMessenger = (props: UseMessengerProps) => {
}, [props.webviewRef]); }, [props.webviewRef]);
}; };
type UseSourceProps = Props & { renderer: SetUpResult<RendererControl> }; type UseSourceProps = Props & {
renderer: SetUpResult<RendererControl>;
markdownEditor: SetUpResult<unknown>;
};
const useSource = (props: UseSourceProps) => { const useSource = (props: UseSourceProps) => {
const propsRef = useRef(props); const propsRef = useRef(props);
@@ -100,6 +104,8 @@ const useSource = (props: UseSourceProps) => {
const rendererJs = props.renderer.pageSetup.js; const rendererJs = props.renderer.pageSetup.js;
const rendererCss = props.renderer.pageSetup.css; const rendererCss = props.renderer.pageSetup.css;
const markdownEditorJs = props.markdownEditor.pageSetup.js;
const markdownEditorCss = props.markdownEditor.pageSetup.css;
return useMemo(() => { return useMemo(() => {
const editorOptions: EditorProps = { const editorOptions: EditorProps = {
@@ -117,6 +123,7 @@ const useSource = (props: UseSourceProps) => {
css: ` css: `
${shim.injectedCss('richTextEditorBundle')} ${shim.injectedCss('richTextEditorBundle')}
${rendererCss} ${rendererCss}
${markdownEditorCss}
/* Increase the size of the editor to make it easier to focus the editor. */ /* Increase the size of the editor to make it easier to focus the editor. */
.prosemirror-editor { .prosemirror-editor {
@@ -125,19 +132,23 @@ const useSource = (props: UseSourceProps) => {
`, `,
js: ` js: `
${rendererJs} ${rendererJs}
${markdownEditorJs}
if (!window.richTextEditorCreated) { if (!window.richTextEditorCreated) {
window.richTextEditorCreated = true; window.richTextEditorCreated = true;
${shim.injectedJs('richTextEditorBundle')} ${shim.injectedJs('richTextEditorBundle')}
richTextEditorBundle.setUpLogger(); richTextEditorBundle.setUpLogger();
richTextEditorBundle.initialize(${JSON.stringify(editorOptions)}).then(function(editor) { richTextEditorBundle.initialize(
${JSON.stringify(editorOptions)},
window,
).then(function(editor) {
/* For testing */ /* For testing */
window.joplinRichTextEditor_ = editor; window.joplinRichTextEditor_ = editor;
}); });
} }
`, `,
}; };
}, [rendererJs, rendererCss]); }, [rendererJs, rendererCss, markdownEditorCss, markdownEditorJs]);
}; };
const useWebViewSetup = (props: Props): SetUpResult<EditorControl> => { const useWebViewSetup = (props: Props): SetUpResult<EditorControl> => {
@@ -148,8 +159,23 @@ const useWebViewSetup = (props: Props): SetUpResult<EditorControl> => {
pluginStates: props.pluginStates, pluginStates: props.pluginStates,
themeId: props.themeId, themeId: props.themeId,
}); });
const markdownEditor = useMarkdownEditorSetup({
webviewRef: props.webviewRef,
onAttachFile: props.onAttachFile,
initialSelection: null,
noteHash: '',
globalSearch: props.globalSearch,
editorOptions: {
settings: props.settings,
initialNoteId: null,
parentElementOrClassName: '',
initialText: '',
},
onEditorEvent: (_event)=>{},
pluginStates: props.pluginStates,
});
const messenger = useMessenger({ ...props, renderer }); const messenger = useMessenger({ ...props, renderer });
const pageSetup = useSource({ ...props, renderer }); const pageSetup = useSource({ ...props, renderer, markdownEditor });
useEffect(() => { useEffect(() => {
void messenger.remoteApi.editor.updateSettings(props.settings); void messenger.remoteApi.editor.updateSettings(props.settings);
@@ -163,14 +189,16 @@ const useWebViewSetup = (props: Props): SetUpResult<EditorControl> => {
onLoadEnd: () => { onLoadEnd: () => {
messenger.onWebViewLoaded(); messenger.onWebViewLoaded();
renderer.webViewEventHandlers.onLoadEnd(); renderer.webViewEventHandlers.onLoadEnd();
markdownEditor.webViewEventHandlers.onLoadEnd();
}, },
onMessage: (event) => { onMessage: (event) => {
messenger.onWebViewMessage(event); messenger.onWebViewMessage(event);
renderer.webViewEventHandlers.onMessage(event); renderer.webViewEventHandlers.onMessage(event);
markdownEditor.webViewEventHandlers.onMessage(event);
}, },
}, },
}; };
}, [messenger, pageSetup, renderer.webViewEventHandlers]); }, [messenger, pageSetup, renderer.webViewEventHandlers, markdownEditor.webViewEventHandlers]);
}; };
export default useWebViewSetup; export default useWebViewSetup;

View File

@@ -351,6 +351,9 @@ const createEditor = (
onLogMessage: props.onLogMessage, onLogMessage: props.onLogMessage,
onRemove: () => { onRemove: () => {
editor.destroy(); editor.destroy();
props.onEvent({
kind: EditorEventType.Remove,
});
}, },
}); });

View File

@@ -1,3 +1,4 @@
import { focus } from '@joplin/lib/utils/focusHandler';
import { ContentScriptData, EditorCommandType, EditorControl, EditorProps, EditorSettings, SearchState, UpdateBodyOptions, UserEventSource } from '../types'; import { ContentScriptData, EditorCommandType, EditorControl, EditorProps, EditorSettings, SearchState, UpdateBodyOptions, UserEventSource } from '../types';
import { EditorState, TextSelection, Transaction } from 'prosemirror-state'; import { EditorState, TextSelection, Transaction } from 'prosemirror-state';
import { EditorView } from 'prosemirror-view'; import { EditorView } from 'prosemirror-view';
@@ -21,17 +22,23 @@ import listPlugin from './plugins/listPlugin';
import searchExtension from './plugins/searchPlugin'; import searchExtension from './plugins/searchPlugin';
import joplinEditorApiPlugin, { setEditorApi } from './plugins/joplinEditorApiPlugin'; import joplinEditorApiPlugin, { setEditorApi } from './plugins/joplinEditorApiPlugin';
import linkTooltipPlugin from './plugins/linkTooltipPlugin'; import linkTooltipPlugin from './plugins/linkTooltipPlugin';
import { RendererControl } from './types'; import { OnCreateCodeEditor as OnCreateCodeEditor, RendererControl } from './types';
import resourcePlaceholderPlugin, { onResourceDownloaded } from './plugins/resourcePlaceholderPlugin'; import resourcePlaceholderPlugin, { onResourceDownloaded } from './plugins/resourcePlaceholderPlugin';
import getFileFromPasteEvent from '../utils/getFileFromPasteEvent'; import getFileFromPasteEvent from '../utils/getFileFromPasteEvent';
import { RenderResult } from '../../renderer/types'; import { RenderResult } from '../../renderer/types';
import detailsPlugin from './plugins/detailsPlugin'; import detailsPlugin from './plugins/detailsPlugin';
interface ProseMirrorControl extends EditorControl {
getSettings(): EditorSettings;
}
const createEditor = async ( const createEditor = async (
parentElement: HTMLElement, parentElement: HTMLElement,
props: EditorProps, props: EditorProps,
renderer: RendererControl, renderer: RendererControl,
): Promise<EditorControl> => { createCodeEditor: OnCreateCodeEditor,
): Promise<ProseMirrorControl> => {
const renderNodeToMarkup = (node: Node|DocumentFragment) => { const renderNodeToMarkup = (node: Node|DocumentFragment) => {
return renderer.renderHtmlToMarkup(node); return renderer.renderHtmlToMarkup(node);
}; };
@@ -91,6 +98,7 @@ const createEditor = async (
setEditorApi(state.tr, { setEditorApi(state.tr, {
onEvent: props.onEvent, onEvent: props.onEvent,
renderer, renderer,
createCodeEditor: createCodeEditor,
localize: async (input: string) => { localize: async (input: string) => {
if (cachedLocalizations.has(input)) { if (cachedLocalizations.has(input)) {
return cachedLocalizations.get(input); return cachedLocalizations.get(input);
@@ -159,7 +167,7 @@ const createEditor = async (
}, },
}); });
const editorControl: EditorControl = { const editorControl: ProseMirrorControl = {
supportsCommand: (name: EditorCommandType | string) => { supportsCommand: (name: EditorCommandType | string) => {
return name in commands && !!commands[name as keyof typeof commands]; return name in commands && !!commands[name as keyof typeof commands];
}, },
@@ -194,6 +202,9 @@ const createEditor = async (
updateBody: async (newBody: string, _updateBodyOptions?: UpdateBodyOptions) => { updateBody: async (newBody: string, _updateBodyOptions?: UpdateBodyOptions) => {
view.updateState(await createInitialState(newBody)); view.updateState(await createInitialState(newBody));
}, },
getSettings: () => {
return settings;
},
updateSettings: async (newSettings: EditorSettings) => { updateSettings: async (newSettings: EditorSettings) => {
const oldSettings = settings; const oldSettings = settings;
settings = newSettings; settings = newSettings;
@@ -273,6 +284,11 @@ const createEditor = async (
const resourceSrc = renderedImage?.src; const resourceSrc = renderedImage?.src;
onResourceDownloaded(view, resourceId, resourceSrc); onResourceDownloaded(view, resourceId, resourceSrc);
}, },
remove: () => {
view.dom.remove();
props.onEvent({ kind: EditorEventType.Remove });
},
focus: () => focus('createEditor', view),
}; };
return editorControl; return editorControl;
}; };

View File

@@ -1,6 +1,7 @@
import { focus } from '@joplin/lib/utils/focusHandler'; import { focus } from '@joplin/lib/utils/focusHandler';
import createTextNode from '../../utils/dom/createTextNode'; import createTextNode from '../../utils/dom/createTextNode';
import createTextArea from '../../utils/dom/createTextArea'; import { EditorApi } from '../joplinEditorApiPlugin';
import { EditorLanguageType } from '../../../types';
interface SourceBlockData { interface SourceBlockData {
start: string; start: string;
@@ -12,11 +13,12 @@ interface Options {
editorLabel: string|Promise<string>; editorLabel: string|Promise<string>;
doneLabel: string|Promise<string>; doneLabel: string|Promise<string>;
block: SourceBlockData; block: SourceBlockData;
editorApi: EditorApi;
onSave: (newContent: SourceBlockData)=> void; onSave: (newContent: SourceBlockData)=> void;
onDismiss: ()=> void; onDismiss: ()=> void;
} }
const createEditorDialog = ({ editorLabel, doneLabel, block, onSave, onDismiss }: Options) => { const createEditorDialog = ({ editorApi, doneLabel, block, onSave, onDismiss }: Options) => {
const dialog = document.createElement('dialog'); const dialog = document.createElement('dialog');
dialog.classList.add('editor-dialog', '-visible'); dialog.classList.add('editor-dialog', '-visible');
document.body.appendChild(dialog); document.body.appendChild(dialog);
@@ -24,21 +26,27 @@ const createEditorDialog = ({ editorLabel, doneLabel, block, onSave, onDismiss }
dialog.onclose = () => { dialog.onclose = () => {
onDismiss(); onDismiss();
dialog.remove(); dialog.remove();
editor.remove();
}; };
const { textArea, label: textAreaLabel } = createTextArea({ const editor = editorApi.createCodeEditor(
label: editorLabel, dialog,
initialContent: block.content, EditorLanguageType.Markdown,
onChange: (newContent) => { (newContent) => {
block = { block = {
...block, ...block,
start: '',
end: '',
content: newContent, content: newContent,
}; };
onSave(block); onSave(block);
}, },
spellCheck: false, );
}); editor.updateBody([
block.start,
block.content,
block.end,
].join(''));
const submitButton = document.createElement('button'); const submitButton = document.createElement('button');
submitButton.appendChild(createTextNode(doneLabel)); submitButton.appendChild(createTextNode(doneLabel));
@@ -47,14 +55,12 @@ const createEditorDialog = ({ editorLabel, doneLabel, block, onSave, onDismiss }
if (dialog.close) { if (dialog.close) {
dialog.close(); dialog.close();
} else { } else {
// .remove the dialog in browsers with limited support for // Handle the case where the dialog element is not supported by the
// HTMLDialogElement (and in JSDOM). // browser/testing environment.
dialog.remove(); dialog.onclose(new Event('close'));
} }
}; };
dialog.appendChild(textAreaLabel);
dialog.appendChild(textArea);
dialog.appendChild(submitButton); dialog.appendChild(submitButton);
@@ -63,7 +69,7 @@ const createEditorDialog = ({ editorLabel, doneLabel, block, onSave, onDismiss }
dialog.showModal(); dialog.showModal();
} else { } else {
dialog.classList.add('-fake-modal'); dialog.classList.add('-fake-modal');
focus('createEditorDialog/legacy', textArea); focus('createEditorDialog/legacy', editor);
} }
return {}; return {};

View File

@@ -145,6 +145,7 @@ class EditableSourceBlockView implements NodeView {
createEditorDialog({ createEditorDialog({
doneLabel: _('Done'), doneLabel: _('Done'),
editorLabel: _('Code:'), editorLabel: _('Code:'),
editorApi: getEditorApi(this.view.state),
block: { block: {
content: this.node.attrs.source, content: this.node.attrs.source,
start: this.node.attrs.openCharacters, start: this.node.attrs.openCharacters,

View File

@@ -1,10 +1,13 @@
import { EditorState, Plugin, Transaction } from 'prosemirror-state'; import { EditorState, Plugin, Transaction } from 'prosemirror-state';
import { OnEventCallback, OnLocalize } from '../../types'; import { OnEventCallback, OnLocalize } from '../../types';
import { RendererControl } from '../types'; import { OnCreateCodeEditor, RendererControl } from '../types';
import { focus } from '@joplin/lib/utils/focusHandler';
import createTextArea from '../utils/dom/createTextArea';
export interface EditorApi { export interface EditorApi {
renderer: RendererControl; renderer: RendererControl;
onEvent: OnEventCallback; onEvent: OnEventCallback;
createCodeEditor: OnCreateCodeEditor;
localize: OnLocalize; localize: OnLocalize;
} }
@@ -30,8 +33,23 @@ const joplinEditorApiPlugin = new Plugin<EditorApi>({
throw new Error('Not initialized'); throw new Error('Not initialized');
}, },
}, },
settings: null,
localize: input => input, localize: input => input,
// A default implementation for testing environments
createCodeEditor: (parent, _language, onChange) => {
const editor = createTextArea({ label: 'Editor', initialContent: '', onChange });
parent.appendChild(editor.textArea);
return {
focus: () => focus('joplinEditorApiPlugin', editor.textArea),
remove: () => {
editor.textArea.remove();
},
updateBody: (newValue) => {
editor.textArea.value = newValue;
},
};
},
}), }),
apply: (tr, value) => { apply: (tr, value) => {
const proposedValue = tr.getMeta(joplinEditorApiPlugin); const proposedValue = tr.getMeta(joplinEditorApiPlugin);

View File

@@ -1,4 +1,5 @@
import { RenderResult } from '../../renderer/types'; import { RenderResult } from '../../renderer/types';
import { EditorLanguageType } from '../types';
interface MarkupToHtmlOptions { interface MarkupToHtmlOptions {
isFullPageRender: boolean; isFullPageRender: boolean;
@@ -12,3 +13,15 @@ export interface RendererControl {
renderMarkupToHtml: MarkupToHtml; renderMarkupToHtml: MarkupToHtml;
renderHtmlToMarkup: HtmlToMarkup; renderHtmlToMarkup: HtmlToMarkup;
} }
export interface CodeEditorControl {
focus: ()=> void;
remove: ()=> void;
updateBody: (newValue: string)=> void;
}
export type OnCodeEditorChange = (newValue: string)=> void;
// Creates a text editor for editing code blocks
export type OnCreateCodeEditor = (
parent: HTMLElement, language: EditorLanguageType, onChange: OnCodeEditorChange,
)=> CodeEditorControl;

View File

@@ -4,14 +4,12 @@ import createUniqueId from './createUniqueId';
interface Options { interface Options {
label: LocalizationResult; label: LocalizationResult;
spellCheck: boolean;
initialContent: string; initialContent: string;
onChange: (newContent: string)=> void; onChange: (newContent: string)=> void;
} }
const createTextArea = ({ label, initialContent, spellCheck, onChange }: Options) => { const createTextArea = ({ label, initialContent, onChange }: Options) => {
const textArea = document.createElement('textarea'); const textArea = document.createElement('textarea');
textArea.spellcheck = spellCheck;
textArea.oninput = () => { textArea.oninput = () => {
onChange(textArea.value); onChange(textArea.value);
}; };

View File

@@ -10,6 +10,7 @@ export enum EditorEventType {
EditLink, EditLink,
FollowLink, FollowLink,
Scroll, Scroll,
Remove,
} }
export interface ChangeEvent { export interface ChangeEvent {
@@ -62,9 +63,13 @@ export interface FollowLinkEvent {
link: string; link: string;
} }
export interface RemoveEvent {
kind: EditorEventType.Remove;
}
export type EditorEvent = export type EditorEvent =
ChangeEvent|UndoRedoDepthChangeEvent|SelectionRangeChangeEvent| ChangeEvent|UndoRedoDepthChangeEvent|SelectionRangeChangeEvent|
EditorScrolledEvent| EditorScrolledEvent|
SelectionFormattingChangeEvent|UpdateSearchDialogEvent| SelectionFormattingChangeEvent|UpdateSearchDialogEvent|
RequestEditLinkEvent|FollowLinkEvent; RequestEditLinkEvent|FollowLinkEvent|RemoveEvent;

View File

@@ -128,6 +128,9 @@ export interface EditorControl {
// Called when a resource associated with the current note finishes downloading. // Called when a resource associated with the current note finishes downloading.
onResourceDownloaded(id: string): void; onResourceDownloaded(id: string): void;
remove(): void;
focus(): void;
} }
export enum EditorLanguageType { export enum EditorLanguageType {