1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-11-23 22:36:32 +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,67 @@
import { AbstractComponent, Editor, BackgroundComponentBackgroundType, Erase, Vec2, Rect2 } from 'js-draw';
const applyTemplateToEditor = async (editor: Editor, templateData: string) => {
let backgroundComponent: AbstractComponent|null = null;
let imageSize = editor.getImportExportRect().size;
let autoresize = true;
try {
const templateJSON = JSON.parse(templateData);
const isEmptyTemplate =
!('imageSize' in templateJSON) && !('backgroundData' in templateJSON);
// If the template is empty, add a default background
if (isEmptyTemplate) {
templateJSON.backgroundData = {
'name': 'image-background',
'zIndex': 0,
'data': {
'mainColor': '#ffffff',
'backgroundType': BackgroundComponentBackgroundType.SolidColor,
},
};
}
if ('backgroundData' in templateJSON) {
backgroundComponent = AbstractComponent.deserialize(
templateJSON['backgroundData'],
);
}
if ('imageSize' in templateJSON) {
imageSize = Vec2.ofXY(templateJSON.imageSize);
}
if ('autoresize' in templateJSON) {
autoresize = !!templateJSON.autoresize;
}
} catch (e) {
console.error('Warning: Invalid image template data: ', e);
}
if (backgroundComponent) {
// Remove the old background (if any)
const previousBackground = editor.image.getBackgroundComponents();
if (previousBackground.length > 0) {
const removeBackgroundCommand = new Erase(previousBackground);
await editor.dispatchNoAnnounce(removeBackgroundCommand, false);
}
// Add the new background
const addBackgroundCommand = editor.image.addElement(backgroundComponent);
await editor.dispatchNoAnnounce(addBackgroundCommand, false);
}
// Set the image size
const imageSizeCommand = editor.setImportExportRect(new Rect2(0, 0, imageSize.x, imageSize.y));
await editor.dispatchNoAnnounce(imageSizeCommand, false);
// Enable/disable autoresize
await editor.dispatchNoAnnounce(editor.image.setAutoresizeEnabled(autoresize), false);
// And zoom to the template (false = don't make undoable)
await editor.dispatchNoAnnounce(editor.viewport.zoomTo(editor.getImportExportRect()), false);
};
export default applyTemplateToEditor;

View File

@@ -0,0 +1,115 @@
/** @jest-environment jsdom */
// Hide warnings from js-draw.
// jsdom doesn't support ResizeObserver and HTMLCanvasElement.getContext.
HTMLCanvasElement.prototype.getContext = () => null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
window.ResizeObserver = class { public observe() { } } as any;
import { describe, it, expect, jest } from '@jest/globals';
import { Color4, EditorImage, EditorSettings, Path, pathToRenderable, StrokeComponent } from 'js-draw';
import { RenderingMode } from 'js-draw';
import { createJsDrawEditor } from './index';
import { BackgroundComponent } from 'js-draw';
import { BackgroundComponentBackgroundType } from 'js-draw';
import { MainProcessApi } from './types';
import applyTemplateToEditor from './applyTemplateToEditor';
const createEditorWithCallbacks = (callbacks: Partial<MainProcessApi>) => {
const toolbarState = '';
const locale = 'en';
const allCallbacks: MainProcessApi = {
save: () => {},
saveThenClose: ()=> {},
closeEditor: ()=> {},
setImageHasChanges: ()=> {},
updateEditorTemplate: ()=> {},
updateToolbarState: ()=> {},
onLoadedEditor: ()=> {},
writeClipboardText: async ()=>{},
readClipboardText: async ()=> '',
...callbacks,
};
const editorOptions: Partial<EditorSettings> = {
// Don't use a CanvasRenderer: jsdom doesn't support DrawingContext2D
renderingMode: RenderingMode.DummyRenderer,
};
const localizations = {
save: 'Save',
close: 'Close',
undo: 'Undo',
redo: 'Redo',
};
return createJsDrawEditor(allCallbacks, toolbarState, locale, localizations, editorOptions);
};
describe('imageEditor/contentScript/index', () => {
it('should trigger autosave callback every few minutes', async () => {
let calledAutosaveCount = 0;
jest.useFakeTimers();
const editorControl = createEditorWithCallbacks({
save: (_drawing: string, isAutosave: boolean) => {
if (isAutosave) {
calledAutosaveCount ++;
}
},
});
// Load no image and an empty template so that autosave can start
await editorControl.loadImageOrTemplate('', '{}', undefined);
expect(calledAutosaveCount).toBe(0);
// Using the synchronous version of advanceTimersByTime seems to not
// run the asynchronous code used to autosave drawings in createJsDrawEditor.ts.
await jest.advanceTimersByTimeAsync(1000 * 60 * 4);
const lastAutosaveCount = calledAutosaveCount;
expect(calledAutosaveCount).toBeGreaterThanOrEqual(1);
expect(calledAutosaveCount).toBeLessThan(10);
await jest.advanceTimersByTimeAsync(1000 * 60 * 10);
expect(calledAutosaveCount).toBeGreaterThan(lastAutosaveCount);
});
it('should fire has changes callback on first change', () => {
let hasChanges = false;
const editorControl = createEditorWithCallbacks({
setImageHasChanges: (newHasChanges: boolean) => {
hasChanges = newHasChanges;
},
});
expect(hasChanges).toBe(false);
const stroke = new StrokeComponent([
// A filled shape
pathToRenderable(Path.fromString('m0,0 l10,0 l0,10'), { fill: Color4.red }),
]);
void editorControl.editor.dispatch(EditorImage.addElement(stroke));
expect(hasChanges).toBe(true);
});
it('default template should be a white grid background', async () => {
const editorControl = createEditorWithCallbacks({});
const editor = editorControl.editor;
await applyTemplateToEditor(editor, '{}');
expect(editor.image.getBackgroundComponents()).toHaveLength(1);
// Should have a white, solid background
const background = editor.image.getBackgroundComponents()[0] as BackgroundComponent;
expect(editor.estimateBackgroundColor().eq(Color4.white)).toBe(true);
expect(background.getBackgroundType()).toBe(BackgroundComponentBackgroundType.SolidColor);
});
});

View File

@@ -0,0 +1,233 @@
import '../../utils/polyfills';
import { Editor, AbstractToolbar, EditorEventType, EditorSettings, getLocalizationTable, adjustEditorThemeForContrast, BaseWidget } from 'js-draw';
import { MaterialIconProvider } from '@js-draw/material-icons';
import 'js-draw/bundledStyles';
import applyTemplateToEditor from './applyTemplateToEditor';
import watchEditorForTemplateChanges from './watchEditorForTemplateChanges';
import { MainProcessApi, LocalizedStrings, EditorProcessApi } from './types';
import startAutosaveLoop from './startAutosaveLoop';
import WebViewToRNMessenger from '../../../utils/ipc/WebViewToRNMessenger';
const restoreToolbarState = (toolbar: AbstractToolbar, state: string) => {
if (state) {
// deserializeState throws on invalid argument.
try {
toolbar.deserializeState(state);
} catch (e) {
console.warn('Error deserializing toolbar state: ', e);
}
}
};
export const createJsDrawEditor = (
callbacks: MainProcessApi,
initialToolbarState: string,
locale: string,
defaultLocalizations: LocalizedStrings,
// Intended for automated tests.
editorSettings: Partial<EditorSettings> = {},
) => {
const parentElement = document.body;
const editor = new Editor(parentElement, {
// Try to use the Joplin locale, but fall back to the system locale if
// js-draw doesn't support it.
localization: {
...getLocalizationTable([locale, ...navigator.languages]),
...defaultLocalizations,
},
iconProvider: new MaterialIconProvider(),
clipboardApi: {
read: async () => {
const result = new Map<string, string>();
const clipboardText = await callbacks.readClipboardText();
if (clipboardText) {
result.set('text/plain', clipboardText);
}
return result;
},
write: async (data) => {
const getTextForMime = async (mime: string) => {
const text = data.get(mime);
if (typeof text === 'string') {
return text;
}
if (text) {
return await (await text).text();
}
return null;
};
const svgData = await getTextForMime('image/svg+xml');
if (svgData) {
return callbacks.writeClipboardText(svgData);
}
const textData = await getTextForMime('text/plain');
return callbacks.writeClipboardText(textData);
},
},
...editorSettings,
});
const toolbar = editor.addToolbar();
const maxSpacerSize = '20px';
toolbar.addSpacer({
grow: 1,
maxSize: maxSpacerSize,
});
// Override the default "Exit" label:
toolbar.addExitButton(
() => callbacks.closeEditor(true), {
label: defaultLocalizations.close,
},
);
toolbar.addSpacer({
grow: 1,
maxSize: maxSpacerSize,
});
// saveButton needs to be defined after the following callbacks.
// As such, this variable can't be made const.
// eslint-disable-next-line prefer-const
let saveButton: BaseWidget;
let lastHadChanges: boolean|null = null;
const setImageHasChanges = (hasChanges: boolean) => {
if (lastHadChanges !== hasChanges) {
saveButton.setDisabled(!hasChanges);
callbacks.setImageHasChanges(hasChanges);
lastHadChanges = hasChanges;
}
};
const getEditorSVG = () => {
return editor.toSVG({
// Grow small images to this minimum size
minDimension: 50,
}).outerHTML;
};
const saveNow = () => {
callbacks.save(getEditorSVG(), false);
// The image is now up-to-date with the resource
setImageHasChanges(false);
};
saveButton = toolbar.addSaveButton(saveNow);
// Load and save toolbar-related state (e.g. pen sizes/colors).
restoreToolbarState(toolbar, initialToolbarState);
editor.notifier.on(EditorEventType.ToolUpdated, () => {
callbacks.updateToolbarState(toolbar.serializeState());
});
setImageHasChanges(false);
editor.notifier.on(EditorEventType.UndoRedoStackUpdated, () => {
setImageHasChanges(true);
});
// Disable save (a full save can't be done until the entire image
// has been loaded).
saveButton.setDisabled(true);
// Show a loading message until the template is loaded.
editor.showLoadingWarning(0);
editor.setReadOnly(true);
const fetchInitialSvgData = (resourceUrl: string) => {
return new Promise<string>((resolve, reject) => {
if (!resourceUrl) {
resolve('');
return;
}
// fetch seems to be unable to request file:// URLs.
// https://github.com/react-native-webview/react-native-webview/issues/1560#issuecomment-1783611805
const request = new XMLHttpRequest();
const onError = () => {
reject(new Error(`Failed to load initial SVG data: ${request.status}, ${request.statusText}, ${request.responseText}`));
};
request.addEventListener('load', _ => {
resolve(request.responseText);
});
request.addEventListener('error', onError);
request.addEventListener('abort', onError);
request.open('GET', resourceUrl);
request.send();
});
};
const themeStyles = document.createElement('style');
parentElement.appendChild(themeStyles);
const editorControl = {
editor,
loadImageOrTemplate: async (resourceUrl: string, templateData: string, svgData: string|undefined) => {
// loadFromSVG shows its own loading message. Hide the original.
editor.hideLoadingWarning();
// On mobile, fetching the SVG data is much faster than transferring it via IPC. However, fetch
// doesn't work for this when running in a web browser (virtual file system).
svgData ??= await fetchInitialSvgData(resourceUrl);
// Load from a template if no initial data
if (svgData === '') {
await applyTemplateToEditor(editor, templateData);
} else {
await editor.loadFromSVG(svgData);
}
// We can now edit and save safely (without data loss).
editor.setReadOnly(false);
void startAutosaveLoop(editor, callbacks.save);
watchEditorForTemplateChanges(editor, templateData, callbacks.updateEditorTemplate);
},
onThemeUpdate: (css: string|null) => {
if (css) {
themeStyles.textContent = css;
}
// Slightly adjusts the given editor's theme colors. This ensures that the colors chosen for
// the editor have proper contrast.
adjustEditorThemeForContrast(editor);
},
saveNow,
saveThenExit: async () => {
callbacks.saveThenClose(getEditorSVG());
},
};
editorControl.onThemeUpdate(null);
callbacks.onLoadedEditor();
return editorControl;
};
type EditorControl = ReturnType<typeof createJsDrawEditor>;
export const createMessenger = (getEditor: ()=> EditorControl) => {
const messenger = new WebViewToRNMessenger<EditorProcessApi, MainProcessApi>(
'image-editor', {
onThemeUpdate: async (css: string) => {
getEditor().onThemeUpdate(css);
},
saveThenExit: () => getEditor().saveThenExit(),
},
);
return messenger;
};
export default createJsDrawEditor;

View File

@@ -0,0 +1,26 @@
import { Editor } from 'js-draw';
import { SaveDrawingCallback } from './types';
const startAutosaveLoop = async (
editor: Editor,
saveDrawing: SaveDrawingCallback,
) => {
// Autosave every two minutes.
const delayTime = 1000 * 60 * 2; // ms
const createAutosave = async () => {
const savedSVG = await editor.toSVGAsync();
saveDrawing(savedSVG.outerHTML, true);
};
while (true) {
await (new Promise<void>(resolve => {
setTimeout(() => resolve(), delayTime);
}));
await createAutosave();
}
};
export default startAutosaveLoop;

View File

@@ -0,0 +1,33 @@
export type SaveDrawingCallback = (svgData: string, isAutosave: boolean)=> void;
export type UpdateEditorTemplateCallback = (newTemplate: string)=> void;
export type UpdateToolbarCallback = (toolbarData: string)=> void;
export interface MainProcessApi {
onLoadedEditor: ()=> void;
save: SaveDrawingCallback;
updateEditorTemplate: UpdateEditorTemplateCallback;
updateToolbarState: UpdateToolbarCallback;
saveThenClose: (svgData: string)=> void;
closeEditor: (promptIfUnsaved: boolean)=> void;
setImageHasChanges: (hasChanges: boolean)=> void;
writeClipboardText: (text: string)=> Promise<void>;
readClipboardText: ()=> Promise<string>;
}
export interface EditorProcessApi {
saveThenExit(): Promise<void>;
onThemeUpdate(newCss: string): Promise<void>;
}
// Overrides translations in js-draw -- as of the time of this writing,
// Joplin has many common strings localized better than js-draw.
export interface LocalizedStrings {
save: string;
close: string;
undo: string;
redo: string;
}

View File

@@ -0,0 +1,61 @@
import { Editor, BackgroundComponent, EditorEventType, Vec2 } from 'js-draw';
const watchEditorForTemplateChanges = (
editor: Editor, initialTemplate: string, updateTemplate: (templateData: string)=> void,
) => {
const computeTemplate = (): string => {
let backgroundSize: Vec2|null = null;
// Only store the background size if the size isn't determined
// by the editor content. In this case, the background always
// appears to be full screen.
if (!editor.image.getAutoresizeEnabled()) {
backgroundSize = editor.getImportExportRect().size;
// Constrain the size: Don't allow an extremely small or extremely large template.
// Map components to constrained components.
backgroundSize = backgroundSize.map(component => {
const minDimen = 45;
const maxDimen = 5000;
return Math.max(Math.min(component, maxDimen), minDimen);
});
}
// Find the topmost background component (if any)
let backgroundComponent: BackgroundComponent|null = null;
for (const component of editor.image.getBackgroundComponents()) {
if (component instanceof BackgroundComponent) {
backgroundComponent = component;
}
}
const templateData = {
imageSize: backgroundSize?.xy,
backgroundData: backgroundComponent?.serialize(),
autoresize: editor.image.getAutoresizeEnabled(),
};
return JSON.stringify(templateData);
};
let lastTemplate = initialTemplate;
const updateTemplateIfNecessary = () => {
const newTemplate = computeTemplate();
if (newTemplate !== lastTemplate) {
updateTemplate(newTemplate);
lastTemplate = newTemplate;
}
};
// Whenever a command is done/undone, re-calculate the template & save.
editor.notifier.on(EditorEventType.CommandDone, () => {
updateTemplateIfNecessary();
});
editor.notifier.on(EditorEventType.CommandUndone, () => {
updateTemplateIfNecessary();
});
};
export default watchEditorForTemplateChanges;

View File

@@ -0,0 +1,200 @@
import * as React from 'react';
import { _ } from '@joplin/lib/locale';
import Setting from '@joplin/lib/models/Setting';
import shim from '@joplin/lib/shim';
import { themeStyle } from '@joplin/lib/theme';
import { Theme } from '@joplin/lib/themes/type';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { Platform } from 'react-native';
import useEditorMessenger from './utils/useEditorMessenger';
import { WebViewControl } from '../../components/ExtendedWebView/types';
import { LocalizedStrings } from './contentScript/types';
import { SetUpResult } from '../types';
type OnSaveCallback = (svgData: string)=> Promise<void>;
type OnCancelCallback = ()=> void;
interface Props {
themeId: number;
resourceFilename: string|null;
onSave: OnSaveCallback;
onAutoSave: OnSaveCallback;
onRequestCloseEditor: OnCancelCallback;
onSetImageChanged: (changed: boolean)=> void;
webViewRef: React.RefObject<WebViewControl>;
}
export interface ImageEditorControl {
saveThenExit(): Promise<void>;
}
const useCss = (editorTheme: Theme) => {
return useMemo(() => {
// Ensure we have contrast between the background and selection. Some themes
// have the same backgroundColor and selectionColor2. (E.g. Aritim Dark)
let selectionBackgroundColor = editorTheme.selectedColor2;
if (selectionBackgroundColor === editorTheme.backgroundColor) {
selectionBackgroundColor = editorTheme.selectedColor;
}
return `
:root .imageEditorContainer {
--background-color-1: ${editorTheme.backgroundColor};
--foreground-color-1: ${editorTheme.color};
--background-color-2: ${editorTheme.backgroundColor3};
--foreground-color-2: ${editorTheme.color3};
--background-color-3: ${editorTheme.raisedBackgroundColor};
--foreground-color-3: ${editorTheme.raisedColor};
--selection-background-color: ${editorTheme.backgroundColorHover3};
--selection-foreground-color: ${editorTheme.color3};
--primary-action-foreground-color: ${editorTheme.color4};
--primary-shadow-color: ${editorTheme.colorFaded};
width: 100vw;
height: 100vh;
box-sizing: border-box;
}
body, html {
padding: 0;
margin: 0;
overflow: hidden;
}
/* Hide the scrollbar. See scrollbar accessibility concerns
(https://developer.mozilla.org/en-US/docs/Web/CSS/scrollbar-width#accessibility_concerns)
for why this isn't done in js-draw itself. */
.toolbar-tool-row::-webkit-scrollbar {
display: none;
height: 0;
}
/* Hide the save/close icons on small screens. This isn't done in the upstream
js-draw repository partially because it isn't as well localized as Joplin
(icons can be used to suggest the meaning of a button when a translation is
unavailable). */
.toolbar-edge-toolbar:not(.one-row) .toolwidget-tag--save .toolbar-icon,
.toolbar-edge-toolbar:not(.one-row) .toolwidget-tag--exit .toolbar-icon {
display: none;
}
`;
}, [editorTheme]);
};
const useWebViewSetup = ({
webViewRef,
themeId,
resourceFilename,
onSetImageChanged,
onSave,
onAutoSave,
onRequestCloseEditor,
}: Props): SetUpResult<ImageEditorControl> => {
const editorTheme: Theme = themeStyle(themeId);
// A set of localization overrides (Joplin is better localized than js-draw).
// All localizable strings (some unused?) can be found at
// https://github.com/personalizedrefrigerator/js-draw/blob/main/.github/ISSUE_TEMPLATE/translation-js-draw-new.yml
const localizedStrings: LocalizedStrings = useMemo(() => ({
save: _('Save'),
close: _('Close'),
undo: _('Undo'),
redo: _('Redo'),
}), []);
const appInfo = useMemo(() => {
return {
name: 'Joplin',
description: `v${shim.appVersion()}`,
};
}, []);
const injectedJavaScript = useMemo(() => `
if (window.imageEditorControl === undefined) {
${shim.injectedJs('imageEditorBundle')}
const messenger = imageEditorBundle.createMessenger(() => window.imageEditorControl);
window.imageEditorControl = imageEditorBundle.createJsDrawEditor(
messenger.remoteApi,
${JSON.stringify(Setting.value('imageeditor.jsdrawToolbar'))},
${JSON.stringify(Setting.value('locale'))},
${JSON.stringify(localizedStrings)},
${JSON.stringify({
appInfo,
...(shim.mobilePlatform() === 'web' ? {
// Use the browser-default clipboard API on web.
clipboardApi: null,
} : {}),
})},
);
}
`, [localizedStrings, appInfo]);
const onReadyToLoadData = useCallback(async () => {
const getInitialInjectedData = async () => {
// On mobile, it's faster to load the image within the WebView with an XMLHttpRequest.
// In this case, the image is loaded elsewhere.
if (Platform.OS !== 'web') {
return undefined;
}
// On web, however, this doesn't work, so the image needs to be loaded here.
if (!resourceFilename) {
return '';
}
return await shim.fsDriver().readFile(resourceFilename, 'utf-8');
};
// It can take some time for initialSVGData to be transferred to the WebView.
// Thus, do so after the main content has been loaded.
webViewRef.current.injectJS(`(async () => {
if (window.imageEditorControl) {
const initialSVGPath = ${JSON.stringify(resourceFilename)};
const initialTemplateData = ${JSON.stringify(Setting.value('imageeditor.imageTemplate'))};
const initialData = ${JSON.stringify(await getInitialInjectedData())};
imageEditorControl.loadImageOrTemplate(initialSVGPath, initialTemplateData, initialData);
}
})();`);
}, [webViewRef, resourceFilename]);
const messenger = useEditorMessenger({
webViewRef,
setImageChanged: onSetImageChanged,
onReadyToLoadData,
onSave,
onAutoSave,
onRequestCloseEditor,
});
const messengerRef = useRef(messenger);
messengerRef.current = messenger;
const css = useCss(editorTheme);
useEffect(() => {
void messengerRef.current.remoteApi.onThemeUpdate(css);
}, [css]);
const editorControl = useMemo((): ImageEditorControl => {
return {
saveThenExit: () => messenger.remoteApi.saveThenExit(),
};
}, [messenger]);
return useMemo(() => {
return {
pageSetup: {
js: injectedJavaScript,
css,
},
api: editorControl,
webViewEventHandlers: {
onLoadEnd: messenger.onWebViewLoaded,
onMessage: messenger.onWebViewMessage,
},
};
}, [editorControl, messenger, injectedJavaScript, css]);
};
export default useWebViewSetup;

View File

@@ -0,0 +1,68 @@
import { RefObject, useMemo, useRef } from 'react';
import Setting from '@joplin/lib/models/Setting';
import Clipboard from '@react-native-clipboard/clipboard';
import { MainProcessApi, EditorProcessApi } from '../contentScript/types';
import { WebViewControl } from '../../../components/ExtendedWebView/types';
import RNToWebViewMessenger from '../../../utils/ipc/RNToWebViewMessenger';
interface Props {
webViewRef: RefObject<WebViewControl>;
setImageChanged(changed: boolean): void;
onReadyToLoadData(): void;
onSave(data: string): void;
onAutoSave(data: string): void;
onRequestCloseEditor(promptIfUnsaved: boolean): void;
}
const useEditorMessenger = ({
webViewRef: webviewRef, setImageChanged, onReadyToLoadData, onRequestCloseEditor, onSave, onAutoSave,
}: Props) => {
const events = { onRequestCloseEditor, onSave, onAutoSave, onReadyToLoadData };
// Use a ref to avoid unnecessary rerenders
const eventsRef = useRef(events);
eventsRef.current = events;
return useMemo(() => {
const localApi: MainProcessApi = {
updateEditorTemplate: newTemplate => {
Setting.setValue('imageeditor.imageTemplate', newTemplate);
},
updateToolbarState: newData => {
Setting.setValue('imageeditor.jsdrawToolbar', newData);
},
setImageHasChanges: hasChanges => {
setImageChanged(hasChanges);
},
onLoadedEditor: () => {
eventsRef.current.onReadyToLoadData();
},
saveThenClose: svgData => {
eventsRef.current.onSave(svgData);
eventsRef.current.onRequestCloseEditor(false);
},
save: (svgData, isAutosave) => {
if (isAutosave) {
return eventsRef.current.onAutoSave(svgData);
} else {
return eventsRef.current.onSave(svgData);
}
},
closeEditor: promptIfUnsaved => {
eventsRef.current.onRequestCloseEditor(promptIfUnsaved);
},
writeClipboardText: async text => {
Clipboard.setString(text);
},
readClipboardText: async () => {
return Clipboard.getString();
},
};
const messenger = new RNToWebViewMessenger<MainProcessApi, EditorProcessApi>(
'image-editor', webviewRef, localApi,
);
return messenger;
}, [webviewRef, setImageChanged]);
};
export default useEditorMessenger;