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; type OnCancelCallback = ()=> void; interface Props { themeId: number; resourceFilename: string|null; onSave: OnSaveCallback; onAutoSave: OnSaveCallback; onRequestCloseEditor: OnCancelCallback; onSetImageChanged: (changed: boolean)=> void; webViewRef: React.RefObject; } export interface ImageEditorControl { saveThenExit(): Promise; } 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 => { 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;