You've already forked joplin
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:
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user