1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-11-23 22:36:32 +02:00
Files
joplin/packages/app-mobile/contentScripts/imageEditorBundle/contentScript/index.ts
2025-07-29 20:25:43 +01:00

234 lines
6.5 KiB
TypeScript

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;