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

Mobile: Add a Rich Text Editor (#12748)

This commit is contained in:
Henry Heino
2025-07-29 12:25:43 -07:00
committed by GitHub
parent c899f63a41
commit 4c3eca1f18
154 changed files with 6405 additions and 1805 deletions

View File

@@ -0,0 +1,131 @@
import '../utils/polyfills';
import { createEditor } from '@joplin/editor/ProseMirror';
import { EditorProcessApi, EditorProps, MainProcessApi } from './types';
import WebViewToRNMessenger from '../../utils/ipc/WebViewToRNMessenger';
import { MarkupLanguage } from '@joplin/renderer';
import '@joplin/editor/ProseMirror/styles';
import HtmlToMd from '@joplin/lib/HtmlToMd';
import readFileToBase64 from '../utils/readFileToBase64';
import { EditorLanguageType } from '@joplin/editor/types';
const postprocessHtml = (html: HTMLElement) => {
// Fix resource URLs
const resources = html.querySelectorAll<HTMLImageElement>('img[data-resource-id]');
for (const resource of resources) {
const resourceId = resource.getAttribute('data-resource-id');
resource.src = `:/${resourceId}`;
}
// Re-add newlines to data-joplin-source-* that were removed
// by ProseMirror.
// TODO: Try to find a better solution
const sourceBlocks = html.querySelectorAll<HTMLPreElement>(
'pre[data-joplin-source-open][data-joplin-source-close].joplin-source',
);
for (const sourceBlock of sourceBlocks) {
const isBlock = sourceBlock.parentElement.tagName !== 'SPAN';
if (isBlock) {
const originalOpen = sourceBlock.getAttribute('data-joplin-source-open');
const originalClose = sourceBlock.getAttribute('data-joplin-source-close');
sourceBlock.setAttribute('data-joplin-source-open', `${originalOpen}\n`);
sourceBlock.setAttribute('data-joplin-source-close', `\n${originalClose}`);
}
}
return html;
};
const wrapHtmlForMarkdownConversion = (html: HTMLElement) => {
// Add a container element -- when converting to HTML, Turndown
// sometimes doesn't process the toplevel element in the same way
// as other elements (e.g. in the case of Joplin source blocks).
const wrapper = html.ownerDocument.createElement('div');
wrapper.appendChild(html.cloneNode(true));
return wrapper;
};
const htmlToMd = new HtmlToMd();
const htmlToMarkdown = (html: HTMLElement): string => {
html = postprocessHtml(html);
return htmlToMd.parse(html, { preserveColorStyles: true });
};
export const initialize = async ({
settings,
initialText,
initialNoteId,
parentElementClassName,
}: EditorProps) => {
const messenger = new WebViewToRNMessenger<EditorProcessApi, MainProcessApi>('rich-text-editor', null);
const parentElement = document.getElementsByClassName(parentElementClassName)[0];
if (!parentElement) throw new Error('Parent element not found');
if (!(parentElement instanceof HTMLElement)) {
throw new Error('Parent node is not an element.');
}
const assetContainer = document.createElement('div');
assetContainer.id = 'joplin-container-pluginAssetsContainer';
document.body.appendChild(assetContainer);
const editor = await createEditor(parentElement, {
settings,
initialText,
initialNoteId,
onPasteFile: async (data) => {
const base64 = await readFileToBase64(data);
await messenger.remoteApi.onPasteFile(data.type, base64);
},
onLogMessage: (message: string) => {
void messenger.remoteApi.logMessage(message);
},
onEvent: (event) => {
void messenger.remoteApi.onEditorEvent(event);
},
}, {
renderMarkupToHtml: async (markup) => {
return await messenger.remoteApi.onRender({
markup,
language: settings.language === EditorLanguageType.Html ? MarkupLanguage.Html : MarkupLanguage.Markdown,
}, {
pluginAssetContainerSelector: `#${assetContainer.id}`,
splitted: true,
mapsToLine: true,
});
},
renderHtmlToMarkup: (node) => {
// By default, if `src` is specified on an image, the browser will try to load the image, even if it isn't added
// to the DOM. (A similar problem is described here: https://stackoverflow.com/q/62019538).
// Since :/resourceId isn't a valid image URI, this results in a large number of warnings. As a workaround,
// move the element to a temporary document before processing:
const dom = document.implementation.createHTMLDocument();
node = dom.importNode(node, true);
let html: HTMLElement;
if ((node instanceof HTMLElement)) {
html = node;
} else {
const container = document.createElement('div');
container.appendChild(html);
html = container;
}
if (settings.language === EditorLanguageType.Markdown) {
return htmlToMarkdown(wrapHtmlForMarkdownConversion(html));
} else {
return postprocessHtml(html).outerHTML;
}
},
});
messenger.setLocalInterface({
editor,
});
return editor;
};
export { default as setUpLogger } from '../utils/setUpLogger';

View File

@@ -0,0 +1,33 @@
import { EditorEvent } from '@joplin/editor/events';
import { EditorControl, EditorSettings } from '@joplin/editor/types';
import { MarkupRecord, RendererControl } from '../rendererBundle/types';
import { RenderResult } from '@joplin/renderer/types';
export interface EditorProps {
initialText: string;
initialNoteId: string;
parentElementClassName: string;
settings: EditorSettings;
}
export interface EditorProcessApi {
editor: EditorControl;
}
type RenderOptionsSlice = {
pluginAssetContainerSelector: string;
splitted: boolean;
mapsToLine: true;
};
export interface MainProcessApi {
onEditorEvent(event: EditorEvent): Promise<void>;
logMessage(message: string): Promise<void>;
onRender(markup: MarkupRecord, options: RenderOptionsSlice): Promise<RenderResult>;
onPasteFile(type: string, base64: string): Promise<void>;
}
export interface RichTextEditorControl {
editor: EditorControl;
renderer: RendererControl;
}

View File

@@ -0,0 +1,166 @@
import { RefObject, useEffect, useMemo, useRef } from 'react';
import { WebViewControl } from '../../components/ExtendedWebView/types';
import { SetUpResult } from '../types';
import { EditorControl, EditorSettings } from '@joplin/editor/types';
import RNToWebViewMessenger from '../../utils/ipc/RNToWebViewMessenger';
import { EditorProcessApi, EditorProps, MainProcessApi } from './types';
import useRendererSetup from '../rendererBundle/useWebViewSetup';
import { EditorEvent } from '@joplin/editor/events';
import Logger from '@joplin/utils/Logger';
import shim from '@joplin/lib/shim';
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import { RendererControl, RenderOptions } from '../rendererBundle/types';
import { ResourceInfos } from '@joplin/renderer/types';
const logger = Logger.create('useWebViewSetup');
interface Props {
initialText: string;
noteId: string;
settings: EditorSettings;
parentElementClassName: string;
themeId: number;
pluginStates: PluginStates;
noteResources: ResourceInfos;
onAttachFile: (mime: string, base64: string)=> void;
onPostMessage: (message: string)=> void;
onEditorEvent: (event: EditorEvent)=> void;
webviewRef: RefObject<WebViewControl>;
}
type UseMessengerProps = Props & { renderer: SetUpResult<RendererControl> };
const useMessenger = (props: UseMessengerProps) => {
const onEditorEventRef = useRef(props.onEditorEvent);
onEditorEventRef.current = props.onEditorEvent;
const rendererRef = useRef(props.renderer);
rendererRef.current = props.renderer;
const onAttachRef = useRef(props.onAttachFile);
onAttachRef.current = props.onAttachFile;
const markupRenderingSettings = useRef<RenderOptions>(null);
markupRenderingSettings.current = {
themeId: props.themeId,
highlightedKeywords: [],
resources: props.noteResources,
themeOverrides: {},
noteHash: '',
initialScroll: 0,
pluginAssetContainerSelector: null,
};
return useMemo(() => {
const api: MainProcessApi = {
onEditorEvent: (event: EditorEvent) => {
onEditorEventRef.current(event);
return Promise.resolve();
},
logMessage: (message: string) => {
logger.info(message);
return Promise.resolve();
},
onRender: async (markup, options) => {
const renderResult = await rendererRef.current.api.render(
markup,
{
...markupRenderingSettings.current,
splitted: options.splitted,
pluginAssetContainerSelector: options.pluginAssetContainerSelector,
mapsToLine: options.mapsToLine,
},
);
return renderResult;
},
onPasteFile: async (type: string, base64: string) => {
onAttachRef.current(type, base64);
},
};
const messenger = new RNToWebViewMessenger<MainProcessApi, EditorProcessApi>(
'rich-text-editor',
props.webviewRef,
api,
);
return messenger;
}, [props.webviewRef]);
};
type UseSourceProps = Props & { renderer: SetUpResult<RendererControl> };
const useSource = (props: UseSourceProps) => {
const propsRef = useRef(props);
propsRef.current = props;
const rendererJs = props.renderer.pageSetup.js;
const rendererCss = props.renderer.pageSetup.css;
return useMemo(() => {
const editorOptions: EditorProps = {
parentElementClassName: propsRef.current.parentElementClassName,
initialText: propsRef.current.initialText,
initialNoteId: propsRef.current.noteId,
settings: propsRef.current.settings,
};
return {
css: `
${shim.injectedCss('richTextEditorBundle')}
${rendererCss}
/* Increase the size of the editor to make it easier to focus the editor. */
.prosemirror-editor {
min-height: 75vh;
}
`,
js: `
${rendererJs}
if (!window.richTextEditorCreated) {
window.richTextEditorCreated = true;
${shim.injectedJs('richTextEditorBundle')}
richTextEditorBundle.setUpLogger();
richTextEditorBundle.initialize(${JSON.stringify(editorOptions)}).then(function(editor) {
/* For testing */
window.joplinRichTextEditor_ = editor;
});
}
`,
};
}, [rendererJs, rendererCss]);
};
const useWebViewSetup = (props: Props): SetUpResult<EditorControl> => {
const renderer = useRendererSetup({
webviewRef: props.webviewRef,
onBodyScroll: null,
onPostMessage: props.onPostMessage,
pluginStates: props.pluginStates,
themeId: props.themeId,
});
const messenger = useMessenger({ ...props, renderer });
const pageSetup = useSource({ ...props, renderer });
useEffect(() => {
void messenger.remoteApi.editor.updateSettings(props.settings);
}, [props.settings, messenger]);
return useMemo(() => {
return {
api: messenger.remoteApi.editor,
pageSetup: pageSetup,
webViewEventHandlers: {
onLoadEnd: () => {
messenger.onWebViewLoaded();
renderer.webViewEventHandlers.onLoadEnd();
},
onMessage: (event) => {
messenger.onWebViewMessage(event);
renderer.webViewEventHandlers.onMessage(event);
},
},
};
}, [messenger, pageSetup, renderer.webViewEventHandlers]);
};
export default useWebViewSetup;