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