mirror of https://github.com/laurent22/joplin.git
Mobile: Add support for plugin panels and dialogs (#10121)
parent
b9eb4522f5
commit
b3ec92a57e
|
@ -480,6 +480,11 @@ packages/app-desktop/utils/markupLanguageUtils.js
|
|||
packages/app-desktop/utils/restartInSafeModeFromMain.test.js
|
||||
packages/app-desktop/utils/restartInSafeModeFromMain.js
|
||||
packages/app-mobile/PluginAssetsLoader.js
|
||||
packages/app-mobile/commands/index.js
|
||||
packages/app-mobile/commands/openItem.js
|
||||
packages/app-mobile/commands/openNote.js
|
||||
packages/app-mobile/commands/scrollToHash.js
|
||||
packages/app-mobile/commands/util/goToNote.js
|
||||
packages/app-mobile/components/ActionButton.js
|
||||
packages/app-mobile/components/BackButtonDialogBox.js
|
||||
packages/app-mobile/components/CameraView.js
|
||||
|
@ -585,12 +590,21 @@ packages/app-mobile/gulpfile.js
|
|||
packages/app-mobile/plugins/PlatformImplementation.js
|
||||
packages/app-mobile/plugins/PluginRunner/PluginRunner.js
|
||||
packages/app-mobile/plugins/PluginRunner/PluginRunnerWebView.js
|
||||
packages/app-mobile/plugins/PluginRunner/backgroundPage/initializeDialogWebView.js
|
||||
packages/app-mobile/plugins/PluginRunner/backgroundPage/initializePluginBackgroundIframe.js
|
||||
packages/app-mobile/plugins/PluginRunner/backgroundPage/pluginRunnerBackgroundPage.js
|
||||
packages/app-mobile/plugins/PluginRunner/backgroundPage/startStopPlugin.js
|
||||
packages/app-mobile/plugins/PluginRunner/backgroundPage/utils/makeSandboxedIframe.js
|
||||
packages/app-mobile/plugins/PluginRunner/backgroundPage/utils/reportUnhandledErrors.js
|
||||
packages/app-mobile/plugins/PluginRunner/backgroundPage/utils/wrapConsoleLog.js
|
||||
packages/app-mobile/plugins/PluginRunner/dialogs/PluginDialogManager.js
|
||||
packages/app-mobile/plugins/PluginRunner/dialogs/PluginDialogWebView.js
|
||||
packages/app-mobile/plugins/PluginRunner/dialogs/PluginPanelViewer.js
|
||||
packages/app-mobile/plugins/PluginRunner/dialogs/PluginUserWebView.js
|
||||
packages/app-mobile/plugins/PluginRunner/dialogs/hooks/useDialogMessenger.js
|
||||
packages/app-mobile/plugins/PluginRunner/dialogs/hooks/useDialogSize.js
|
||||
packages/app-mobile/plugins/PluginRunner/dialogs/hooks/useViewInfos.js
|
||||
packages/app-mobile/plugins/PluginRunner/dialogs/hooks/useWebViewSetup.js
|
||||
packages/app-mobile/plugins/PluginRunner/types.js
|
||||
packages/app-mobile/plugins/PluginRunner/utils/createOnLogHandler.js
|
||||
packages/app-mobile/plugins/hooks/usePlugin.js
|
||||
|
|
|
@ -460,6 +460,11 @@ packages/app-desktop/utils/markupLanguageUtils.js
|
|||
packages/app-desktop/utils/restartInSafeModeFromMain.test.js
|
||||
packages/app-desktop/utils/restartInSafeModeFromMain.js
|
||||
packages/app-mobile/PluginAssetsLoader.js
|
||||
packages/app-mobile/commands/index.js
|
||||
packages/app-mobile/commands/openItem.js
|
||||
packages/app-mobile/commands/openNote.js
|
||||
packages/app-mobile/commands/scrollToHash.js
|
||||
packages/app-mobile/commands/util/goToNote.js
|
||||
packages/app-mobile/components/ActionButton.js
|
||||
packages/app-mobile/components/BackButtonDialogBox.js
|
||||
packages/app-mobile/components/CameraView.js
|
||||
|
@ -565,12 +570,21 @@ packages/app-mobile/gulpfile.js
|
|||
packages/app-mobile/plugins/PlatformImplementation.js
|
||||
packages/app-mobile/plugins/PluginRunner/PluginRunner.js
|
||||
packages/app-mobile/plugins/PluginRunner/PluginRunnerWebView.js
|
||||
packages/app-mobile/plugins/PluginRunner/backgroundPage/initializeDialogWebView.js
|
||||
packages/app-mobile/plugins/PluginRunner/backgroundPage/initializePluginBackgroundIframe.js
|
||||
packages/app-mobile/plugins/PluginRunner/backgroundPage/pluginRunnerBackgroundPage.js
|
||||
packages/app-mobile/plugins/PluginRunner/backgroundPage/startStopPlugin.js
|
||||
packages/app-mobile/plugins/PluginRunner/backgroundPage/utils/makeSandboxedIframe.js
|
||||
packages/app-mobile/plugins/PluginRunner/backgroundPage/utils/reportUnhandledErrors.js
|
||||
packages/app-mobile/plugins/PluginRunner/backgroundPage/utils/wrapConsoleLog.js
|
||||
packages/app-mobile/plugins/PluginRunner/dialogs/PluginDialogManager.js
|
||||
packages/app-mobile/plugins/PluginRunner/dialogs/PluginDialogWebView.js
|
||||
packages/app-mobile/plugins/PluginRunner/dialogs/PluginPanelViewer.js
|
||||
packages/app-mobile/plugins/PluginRunner/dialogs/PluginUserWebView.js
|
||||
packages/app-mobile/plugins/PluginRunner/dialogs/hooks/useDialogMessenger.js
|
||||
packages/app-mobile/plugins/PluginRunner/dialogs/hooks/useDialogSize.js
|
||||
packages/app-mobile/plugins/PluginRunner/dialogs/hooks/useViewInfos.js
|
||||
packages/app-mobile/plugins/PluginRunner/dialogs/hooks/useWebViewSetup.js
|
||||
packages/app-mobile/plugins/PluginRunner/types.js
|
||||
packages/app-mobile/plugins/PluginRunner/utils/createOnLogHandler.js
|
||||
packages/app-mobile/plugins/hooks/usePlugin.js
|
||||
|
|
|
@ -243,7 +243,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
|
|||
|
||||
if (commands[cmd.name]) {
|
||||
commandOutput = commands[cmd.name](cmd.value);
|
||||
} else if (editorRef.current.supportsCommand(cmd)) {
|
||||
} else if (await editorRef.current.supportsCommand(cmd)) {
|
||||
commandOutput = editorRef.current.execCommandFromJoplin(cmd);
|
||||
} else {
|
||||
reg.logger().warn('CodeMirror: unsupported Joplin command: ', cmd);
|
||||
|
|
|
@ -9,7 +9,7 @@ export const declaration: CommandDeclaration = {
|
|||
export const runtime = (comp: any): CommandRuntime => {
|
||||
return {
|
||||
execute: async () => {
|
||||
if (comp.editorRef.current && comp.editorRef.current.supportsCommand('search')) {
|
||||
if (comp.editorRef.current && await comp.editorRef.current.supportsCommand('search')) {
|
||||
comp.editorRef.current.execCommand({ name: 'search' });
|
||||
} else {
|
||||
if (comp.noteSearchBarRef.current) {
|
||||
|
|
|
@ -57,7 +57,7 @@ export interface NoteBodyEditorRef {
|
|||
resetScroll(): void;
|
||||
scrollTo(options: ScrollOptions): void;
|
||||
|
||||
supportsCommand(name: string): boolean;
|
||||
supportsCommand(name: string): boolean|Promise<boolean>;
|
||||
execCommand(command: CommandValue): Promise<void>;
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
// AUTO-GENERATED using `gulp buildScriptIndexes`
|
||||
import * as openItem from './openItem';
|
||||
import * as openNote from './openNote';
|
||||
import * as scrollToHash from './scrollToHash';
|
||||
|
||||
const index: any[] = [
|
||||
openItem,
|
||||
openNote,
|
||||
scrollToHash,
|
||||
];
|
||||
|
||||
export default index;
|
||||
// AUTO-GENERATED using `gulp buildScriptIndexes`
|
|
@ -0,0 +1,38 @@
|
|||
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
const { parseResourceUrl, urlProtocol } = require('@joplin/lib/urlUtils');
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import goToNote from './util/goToNote';
|
||||
|
||||
const logger = Logger.create('openItemCommand');
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'openItem',
|
||||
};
|
||||
|
||||
export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
execute: async (_context: CommandContext, link: string) => {
|
||||
if (!link) throw new Error('Link cannot be empty');
|
||||
|
||||
if (link.startsWith('joplin://') || link.startsWith(':/')) {
|
||||
const parsedUrl = parseResourceUrl(link);
|
||||
if (parsedUrl) {
|
||||
const { itemId, hash } = parsedUrl;
|
||||
|
||||
logger.info(`Navigating to item ${itemId}`);
|
||||
await goToNote(itemId, hash);
|
||||
} else {
|
||||
logger.error(`Invalid Joplin link: ${link}`);
|
||||
}
|
||||
} else if (urlProtocol(link)) {
|
||||
shim.openUrl(link);
|
||||
} else {
|
||||
const errorMessage = _('Unsupported link or message: %s', link);
|
||||
logger.error(errorMessage);
|
||||
await shim.showMessageBox(errorMessage);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
|
@ -0,0 +1,18 @@
|
|||
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import goToNote from './util/goToNote';
|
||||
|
||||
const logger = Logger.create('openNoteCommand');
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'openNote',
|
||||
};
|
||||
|
||||
export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
execute: async (_context: CommandContext, noteId: string, hash: string = null) => {
|
||||
logger.info(`Navigating to note ${noteId}`);
|
||||
await goToNote(noteId, hash);
|
||||
},
|
||||
};
|
||||
};
|
|
@ -0,0 +1,22 @@
|
|||
import { CommandContext, CommandDeclaration, CommandRuntime } from '@joplin/lib/services/CommandService';
|
||||
import NavService from '@joplin/lib/services/NavService';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'scrollToHash',
|
||||
};
|
||||
|
||||
export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
execute: async (context: CommandContext, hash: string) => {
|
||||
const selectedNoteIds = context.state.selectedNoteIds;
|
||||
if (selectedNoteIds.length === 0) {
|
||||
throw new Error('Unable to scroll to hash -- no note open.');
|
||||
}
|
||||
|
||||
await NavService.go('Note', {
|
||||
noteId: selectedNoteIds[0],
|
||||
noteHash: hash,
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
|
@ -0,0 +1,15 @@
|
|||
import Note from '@joplin/lib/models/Note';
|
||||
import NavService from '@joplin/lib/services/NavService';
|
||||
|
||||
const goToNote = async (id: string, hash?: string) => {
|
||||
if (!(await Note.load(id))) {
|
||||
throw new Error(`No note with id ${id}`);
|
||||
}
|
||||
|
||||
return NavService.go('Note', {
|
||||
noteId: id,
|
||||
noteHash: hash,
|
||||
});
|
||||
};
|
||||
|
||||
export default goToNote;
|
|
@ -18,7 +18,6 @@ import { _ } from '@joplin/lib/locale';
|
|||
import MarkdownToolbar from './MarkdownToolbar/MarkdownToolbar';
|
||||
import { ChangeEvent, EditorEvent, EditorEventType, SelectionRangeChangeEvent, UndoRedoDepthChangeEvent } from '@joplin/editor/events';
|
||||
import { EditorCommandType, EditorKeymap, EditorLanguageType, SearchState } from '@joplin/editor/types';
|
||||
import supportsCommand from '@joplin/editor/CodeMirror/editorCommands/supportsCommand';
|
||||
import SelectionFormatting, { defaultSelectionFormatting } from '@joplin/editor/SelectionFormatting';
|
||||
import useCodeMirrorPlugins from './hooks/useCodeMirrorPlugins';
|
||||
import RNToWebViewMessenger from '../../utils/ipc/RNToWebViewMessenger';
|
||||
|
@ -159,7 +158,7 @@ const useEditorControl = (
|
|||
|
||||
const control: EditorControl = {
|
||||
supportsCommand(command: EditorCommandType) {
|
||||
return supportsCommand(command);
|
||||
return bodyControl.supportsCommand(command);
|
||||
},
|
||||
execCommand(command, ...args: any[]) {
|
||||
return bodyControl.execCommand(command, ...args);
|
||||
|
|
|
@ -17,7 +17,7 @@ const commandRuntime = (declaration: CommandDeclaration, editor: EditorControl)
|
|||
args = args.slice(1);
|
||||
}
|
||||
|
||||
if (!editor.supportsCommand(commandName)) {
|
||||
if (!(await editor.supportsCommand(commandName))) {
|
||||
logger.warn('Command not supported by editor: ', commandName);
|
||||
return;
|
||||
}
|
||||
|
|
|
@ -58,8 +58,10 @@ import { SelectionRange } from '../NoteEditor/types';
|
|||
import { AppState } from '../../utils/types';
|
||||
import restoreItems from '@joplin/lib/services/trash/restoreItems';
|
||||
import { getDisplayParentTitle } from '@joplin/lib/services/trash';
|
||||
import pickDocument from '../../utils/pickDocument';
|
||||
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
|
||||
import pickDocument from '../../utils/pickDocument';
|
||||
import { ContainerType } from '@joplin/lib/services/plugins/WebviewController';
|
||||
import PluginPanelViewer from '../../plugins/PluginRunner/dialogs/PluginPanelViewer';
|
||||
const urlUtils = require('@joplin/lib/urlUtils');
|
||||
|
||||
const emptyArray: any[] = [];
|
||||
|
@ -101,6 +103,7 @@ interface State {
|
|||
imageEditorResourceFilepath: string;
|
||||
noteResources: Record<string, ResourceEntity>;
|
||||
newAndNoTitleChangeNoteId: boolean|null;
|
||||
pluginPanelsVisible: boolean;
|
||||
|
||||
HACK_webviewLoadingState: number;
|
||||
|
||||
|
@ -159,6 +162,7 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B
|
|||
noteResources: {},
|
||||
imageEditorResourceFilepath: null,
|
||||
newAndNoTitleChangeNoteId: null,
|
||||
pluginPanelsVisible: false,
|
||||
|
||||
// HACK: For reasons I can't explain, when the WebView is present, the TextInput initially does not display (It's just a white rectangle with
|
||||
// no visible text). It will only appear when tapping it or doing certain action like selecting text on the webview. The bug started to
|
||||
|
@ -258,21 +262,12 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B
|
|||
if (!item) throw new Error(_('No item with ID %s', itemId));
|
||||
|
||||
if (item.type_ === BaseModel.TYPE_NOTE) {
|
||||
// Easier to just go back, then go to the note since
|
||||
// the Note screen doesn't handle reloading a different note
|
||||
|
||||
this.props.dispatch({
|
||||
type: 'NAV_BACK',
|
||||
type: 'NAV_GO',
|
||||
routeName: 'Note',
|
||||
noteId: item.id,
|
||||
noteHash: resourceUrlInfo.hash,
|
||||
});
|
||||
|
||||
shim.setTimeout(() => {
|
||||
this.props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'Note',
|
||||
noteId: item.id,
|
||||
noteHash: resourceUrlInfo.hash,
|
||||
});
|
||||
}, 5);
|
||||
} else if (item.type_ === BaseModel.TYPE_RESOURCE) {
|
||||
if (!(await Resource.isReady(item))) throw new Error(_('This attachment is not downloaded or not decrypted yet.'));
|
||||
|
||||
|
@ -556,6 +551,28 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B
|
|||
disableSideMenuGestures: this.state.showImageEditor,
|
||||
});
|
||||
}
|
||||
|
||||
if (prevProps.noteId && prevProps.noteId !== this.props.noteId) {
|
||||
// Easier to just go back, then go to the note since
|
||||
// the Note screen doesn't handle reloading a different note
|
||||
const noteId = this.props.noteId;
|
||||
const noteHash = this.props.noteHash;
|
||||
|
||||
this.props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'Notes',
|
||||
folderId: this.state.note.parent_id,
|
||||
});
|
||||
|
||||
shim.setTimeout(() => {
|
||||
this.props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'Note',
|
||||
noteId: noteId,
|
||||
noteHash: noteHash,
|
||||
});
|
||||
}, 5);
|
||||
}
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
|
@ -1283,6 +1300,18 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B
|
|||
disabled: readOnly,
|
||||
});
|
||||
|
||||
// Only show the plugin panel toggle if any plugins have panels
|
||||
const allPluginViews = Object.values(this.props.plugins).map(plugin => Object.values(plugin.views)).flat();
|
||||
const allPanels = allPluginViews.filter(view => view.containerType === ContainerType.Panel);
|
||||
if (allPanels.length > 0) {
|
||||
output.push({
|
||||
title: _('Show plugin panels'),
|
||||
onPress: () => {
|
||||
this.setState({ pluginPanelsVisible: true });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
this.menuOptionsCache_ = {};
|
||||
this.menuOptionsCache_[cacheKey] = output;
|
||||
|
||||
|
@ -1594,6 +1623,10 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B
|
|||
}}
|
||||
/>
|
||||
{noteTagDialog}
|
||||
<PluginPanelViewer
|
||||
visible={this.state.pluginPanelsVisible}
|
||||
onClose={() => this.setState({ pluginPanelsVisible: false })}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@
|
|||
// https://github.com/facebook/metro/issues/1#issuecomment-511228599
|
||||
|
||||
const path = require('path');
|
||||
const { getDefaultConfig } = require('metro-config');
|
||||
|
||||
const localPackages = {
|
||||
'@joplin/lib': path.resolve(__dirname, '../lib/'),
|
||||
|
@ -45,6 +46,8 @@ for (const [, v] of Object.entries(localPackages)) {
|
|||
watchedFolders.push(v);
|
||||
}
|
||||
|
||||
const defaultConfig = getDefaultConfig.getDefaultValues(__dirname);
|
||||
|
||||
module.exports = {
|
||||
transformer: {
|
||||
getTransformOptions: async () => ({
|
||||
|
@ -55,6 +58,13 @@ module.exports = {
|
|||
}),
|
||||
},
|
||||
resolver: {
|
||||
assetExts: [
|
||||
...defaultConfig.resolver.assetExts,
|
||||
|
||||
// Allow loading .jpl plugin files
|
||||
'jpl',
|
||||
],
|
||||
|
||||
// This configuration allows you to build React-Native modules and test
|
||||
// them without having to publish the module. Any exports provided by
|
||||
// your source should be added to the "target" parameter. Any import not
|
||||
|
|
|
@ -117,6 +117,7 @@
|
|||
"ts-node": "10.9.2",
|
||||
"typescript": "5.2.2",
|
||||
"uglify-js": "3.17.4",
|
||||
"punycode": "2.3.1",
|
||||
"webpack": "5.74.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,12 +5,14 @@ import { useCallback, useMemo, useRef, useState } from 'react';
|
|||
import shim from '@joplin/lib/shim';
|
||||
import PluginRunner from './PluginRunner';
|
||||
import loadPlugins from '../loadPlugins';
|
||||
import { useStore } from 'react-redux';
|
||||
import { connect, useStore } from 'react-redux';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { View } from 'react-native';
|
||||
import PluginService, { PluginSettings } from '@joplin/lib/services/plugins/PluginService';
|
||||
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
|
||||
import { PluginHtmlContents, PluginStates } from '@joplin/lib/services/plugins/reducer';
|
||||
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
|
||||
import PluginDialogManager from './dialogs/PluginDialogManager';
|
||||
import { AppState } from '../../utils/types';
|
||||
|
||||
const logger = Logger.create('PluginRunnerWebView');
|
||||
|
||||
|
@ -41,9 +43,11 @@ const usePlugins = (
|
|||
interface Props {
|
||||
serializedPluginSettings: string;
|
||||
pluginStates: PluginStates;
|
||||
pluginHtmlContents: PluginHtmlContents;
|
||||
themeId: number;
|
||||
}
|
||||
|
||||
const PluginRunnerWebView: React.FC<Props> = props => {
|
||||
const PluginRunnerWebViewComponent: React.FC<Props> = props => {
|
||||
const webviewRef = useRef<WebViewControl>();
|
||||
|
||||
const [webviewLoaded, setLoaded] = useState(false);
|
||||
|
@ -105,15 +109,22 @@ const PluginRunnerWebView: React.FC<Props> = props => {
|
|||
|
||||
|
||||
return (
|
||||
<ExtendedWebView
|
||||
webviewInstanceId='PluginRunner'
|
||||
html={html}
|
||||
injectedJavaScript={injectedJs}
|
||||
onMessage={pluginRunner.onWebviewMessage}
|
||||
onLoadEnd={onLoadEnd}
|
||||
onLoadStart={onLoadStart}
|
||||
ref={webviewRef}
|
||||
/>
|
||||
<>
|
||||
<ExtendedWebView
|
||||
webviewInstanceId='PluginRunner'
|
||||
html={html}
|
||||
injectedJavaScript={injectedJs}
|
||||
onMessage={pluginRunner.onWebviewMessage}
|
||||
onLoadEnd={onLoadEnd}
|
||||
onLoadStart={onLoadStart}
|
||||
ref={webviewRef}
|
||||
/>
|
||||
<PluginDialogManager
|
||||
themeId={props.themeId}
|
||||
pluginHtmlContents={props.pluginHtmlContents}
|
||||
pluginStates={props.pluginStates}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -124,4 +135,12 @@ const PluginRunnerWebView: React.FC<Props> = props => {
|
|||
);
|
||||
};
|
||||
|
||||
export default PluginRunnerWebView;
|
||||
export default connect((state: AppState) => {
|
||||
const result: Props = {
|
||||
serializedPluginSettings: state.settings['plugins.states'],
|
||||
pluginStates: state.pluginService.plugins,
|
||||
pluginHtmlContents: state.pluginService.pluginHtmlContents,
|
||||
themeId: state.settings.theme,
|
||||
};
|
||||
return result;
|
||||
})(PluginRunnerWebViewComponent);
|
||||
|
|
|
@ -0,0 +1,84 @@
|
|||
import { DialogWebViewApi, DialogMainProcessApi } from '../types';
|
||||
import reportUnhandledErrors from './utils/reportUnhandledErrors';
|
||||
import wrapConsoleLog from './utils/wrapConsoleLog';
|
||||
import WebViewToRNMessenger from '../../../utils/ipc/WebViewToRNMessenger';
|
||||
|
||||
let themeCssElement: HTMLStyleElement|null = null;
|
||||
|
||||
const initializeDialogWebView = (messageChannelId: string) => {
|
||||
const loadedPaths: Set<string> = new Set();
|
||||
|
||||
type ScriptType = 'js'|'css';
|
||||
const includeScriptsOrStyles = (type: ScriptType, paths: string[]) => {
|
||||
for (const path of paths) {
|
||||
if (loadedPaths.has(path)) {
|
||||
continue;
|
||||
}
|
||||
loadedPaths.add(path);
|
||||
|
||||
if (type === 'css') {
|
||||
const stylesheetLink = document.createElement('link');
|
||||
stylesheetLink.rel = 'stylesheet';
|
||||
stylesheetLink.href = path;
|
||||
document.head.appendChild(stylesheetLink);
|
||||
} else {
|
||||
const script = document.createElement('script');
|
||||
script.src = path;
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const localApi: DialogWebViewApi = {
|
||||
includeCssFiles: async (paths: string[]) => {
|
||||
return includeScriptsOrStyles('css', paths);
|
||||
},
|
||||
includeJsFiles: async (paths: string[]) => {
|
||||
return includeScriptsOrStyles('js', paths);
|
||||
},
|
||||
getFormData: async () => {
|
||||
const firstForm = document.querySelector('form');
|
||||
if (!firstForm) return null;
|
||||
|
||||
const formData = new FormData(firstForm);
|
||||
|
||||
const result = Object.create(null);
|
||||
for (const key of formData.keys()) {
|
||||
result[key] = formData.get(key);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
setThemeCss: async (css: string) => {
|
||||
themeCssElement?.remove?.();
|
||||
const styleElement = document.createElement('style');
|
||||
styleElement.appendChild(document.createTextNode(css));
|
||||
document.body.appendChild(styleElement);
|
||||
themeCssElement = styleElement;
|
||||
},
|
||||
getContentSize: async () => {
|
||||
// To convert to React Native pixel units from browser pixel units,
|
||||
// we need to multiply by the devicePixelRatio:
|
||||
const dpr = window.devicePixelRatio ?? 1;
|
||||
|
||||
return {
|
||||
width: document.body.clientWidth * dpr,
|
||||
height: document.body.clientHeight * dpr,
|
||||
};
|
||||
},
|
||||
};
|
||||
const messenger = new WebViewToRNMessenger<DialogWebViewApi, DialogMainProcessApi>(messageChannelId, localApi);
|
||||
|
||||
(window as any).webviewApi = {
|
||||
postMessage: messenger.remoteApi.postMessage,
|
||||
onMessage: messenger.remoteApi.onMessage,
|
||||
};
|
||||
|
||||
reportUnhandledErrors(messenger.remoteApi.onError);
|
||||
wrapConsoleLog(messenger.remoteApi.onLog);
|
||||
|
||||
// If dialog content scripts were bundled with Webpack for NodeJS,
|
||||
// they may expect a global "exports" to be present.
|
||||
(window as any).exports ??= {};
|
||||
};
|
||||
|
||||
export default initializeDialogWebView;
|
|
@ -1,12 +1,45 @@
|
|||
export { runPlugin, stopPlugin } from './startStopPlugin';
|
||||
|
||||
// Old plugins allowed to import legacy APIs
|
||||
const legacyPluginIds = [
|
||||
'outline',
|
||||
'ylc395.noteLinkSystem',
|
||||
];
|
||||
|
||||
const pathLibrary = require('path');
|
||||
export const requireModule = (moduleName: string) => {
|
||||
const punycode = require('punycode/');
|
||||
|
||||
export const requireModule = (moduleName: string, fromPluginId: string) => {
|
||||
if (moduleName === 'path') {
|
||||
return pathLibrary;
|
||||
}
|
||||
|
||||
if (legacyPluginIds.includes(fromPluginId)) {
|
||||
if (moduleName === 'punycode') {
|
||||
console.warn('Requiring punycode is deprecated. Please transition to a newer API.');
|
||||
return punycode;
|
||||
}
|
||||
if (moduleName === 'fs' || moduleName === 'fs-extra') {
|
||||
console.warn('The fs library is unavailable to mobile plugins. A non-functional mock will be returned.');
|
||||
return {
|
||||
existsSync: () => false,
|
||||
pathExists: () => false,
|
||||
readFileSync: () => '',
|
||||
readFile: () => '',
|
||||
writeFileSync: () => '',
|
||||
writeFile: () => '',
|
||||
};
|
||||
}
|
||||
if (moduleName === 'process') {
|
||||
return {};
|
||||
}
|
||||
if (moduleName === 'url') {
|
||||
return { parse: (u: string) => new URL(u) };
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Unable to require module ${moduleName} on mobile.`);
|
||||
};
|
||||
|
||||
export { default as initializePluginBackgroundIframe } from './initializePluginBackgroundIframe';
|
||||
export { default as initializeDialogWebView } from './initializeDialogWebView';
|
||||
|
|
|
@ -40,7 +40,9 @@ export const runPlugin = (
|
|||
${pluginBackgroundScript}
|
||||
|
||||
(async () => {
|
||||
window.require = pluginBackgroundPage.requireModule;
|
||||
window.require = function(module) {
|
||||
return pluginBackgroundPage.requireModule(module, ${JSON.stringify(pluginId)});
|
||||
};
|
||||
window.exports = window.exports || {};
|
||||
|
||||
await pluginBackgroundPage.initializePluginBackgroundIframe(${JSON.stringify(messageChannelId)});
|
||||
|
|
|
@ -0,0 +1,60 @@
|
|||
import * as React from 'react';
|
||||
import { ReactElement } from 'react';
|
||||
import { PluginHtmlContents, PluginStates, ViewInfo } from '@joplin/lib/services/plugins/reducer';
|
||||
import PluginDialogWebView from './PluginDialogWebView';
|
||||
import { Modal, Portal } from 'react-native-paper';
|
||||
import PluginService from '@joplin/lib/services/plugins/PluginService';
|
||||
import WebviewController, { ContainerType } from '@joplin/lib/services/plugins/WebviewController';
|
||||
import useViewInfos from './hooks/useViewInfos';
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
|
||||
pluginHtmlContents: PluginHtmlContents;
|
||||
pluginStates: PluginStates;
|
||||
}
|
||||
|
||||
const dismissDialog = (viewInfo: ViewInfo) => {
|
||||
if (!viewInfo.view.opened) return;
|
||||
|
||||
const plugin = PluginService.instance().pluginById(viewInfo.plugin.id);
|
||||
const viewController = plugin.viewController(viewInfo.view.id) as WebviewController;
|
||||
viewController.closeWithResponse(null);
|
||||
};
|
||||
|
||||
const PluginDialogManager: React.FC<Props> = props => {
|
||||
const viewInfos = useViewInfos(props.pluginStates);
|
||||
|
||||
const dialogs: ReactElement[] = [];
|
||||
for (const viewInfo of viewInfos) {
|
||||
if (viewInfo.view.containerType === ContainerType.Panel || !viewInfo.view.opened) {
|
||||
continue;
|
||||
}
|
||||
|
||||
dialogs.push(
|
||||
<Portal
|
||||
key={`${viewInfo.plugin.id}-${viewInfo.view.id}`}
|
||||
>
|
||||
<Modal
|
||||
visible={true}
|
||||
onDismiss={() => dismissDialog(viewInfo)}
|
||||
>
|
||||
<PluginDialogWebView
|
||||
viewInfo={viewInfo}
|
||||
themeId={props.themeId}
|
||||
pluginStates={props.pluginStates}
|
||||
pluginHtmlContents={props.pluginHtmlContents}
|
||||
/>
|
||||
</Modal>
|
||||
</Portal>,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{dialogs}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PluginDialogManager;
|
|
@ -0,0 +1,156 @@
|
|||
import * as React from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { PluginHtmlContents, PluginStates, ViewInfo } from '@joplin/lib/services/plugins/reducer';
|
||||
import { StyleSheet, View, useWindowDimensions } from 'react-native';
|
||||
import usePlugin from '../../hooks/usePlugin';
|
||||
import { DialogContentSize, DialogWebViewApi } from '../types';
|
||||
import { Button } from 'react-native-paper';
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
import { ButtonSpec, DialogResult } from '@joplin/lib/services/plugins/api/types';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import WebviewController, { ContainerType } from '@joplin/lib/services/plugins/WebviewController';
|
||||
import { Theme } from '@joplin/lib/themes/type';
|
||||
import useDialogSize from './hooks/useDialogSize';
|
||||
import PluginUserWebView from './PluginUserWebView';
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
pluginHtmlContents: PluginHtmlContents;
|
||||
pluginStates: PluginStates;
|
||||
viewInfo: ViewInfo;
|
||||
}
|
||||
|
||||
const useStyles = (
|
||||
themeId: number,
|
||||
dialogContentSize: DialogContentSize|null,
|
||||
fitToContent: boolean,
|
||||
) => {
|
||||
const windowSize = useWindowDimensions();
|
||||
|
||||
return useMemo(() => {
|
||||
const theme: Theme = themeStyle(themeId);
|
||||
|
||||
const useDialogSize = fitToContent && dialogContentSize;
|
||||
const dialogHasLoaded = !!dialogContentSize;
|
||||
|
||||
return StyleSheet.create({
|
||||
webView: {
|
||||
backgroundColor: 'transparent',
|
||||
display: 'flex',
|
||||
},
|
||||
webViewContainer: {
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
},
|
||||
dialog: {
|
||||
backgroundColor: theme.backgroundColor,
|
||||
borderRadius: 12,
|
||||
|
||||
maxHeight: useDialogSize ? dialogContentSize?.height : undefined,
|
||||
maxWidth: useDialogSize ? dialogContentSize?.width : undefined,
|
||||
height: windowSize.height * 0.97,
|
||||
width: windowSize.width * 0.97,
|
||||
opacity: dialogHasLoaded ? 1 : 0.1,
|
||||
|
||||
// Center
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
},
|
||||
buttonRow: {
|
||||
flexDirection: 'row',
|
||||
padding: 12,
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
});
|
||||
}, [themeId, dialogContentSize, fitToContent, windowSize.width, windowSize.height]);
|
||||
};
|
||||
|
||||
const defaultButtonSpecs: ButtonSpec[] = [
|
||||
{ id: 'ok' }, { id: 'cancel' },
|
||||
];
|
||||
|
||||
const PluginDialogWebView: React.FC<Props> = props => {
|
||||
const viewInfo = props.viewInfo;
|
||||
const view = viewInfo.view;
|
||||
const pluginId = viewInfo.plugin.id;
|
||||
const viewId = view.id;
|
||||
const plugin = usePlugin(pluginId);
|
||||
const [webViewLoadCount, setWebViewLoadCount] = useState(0);
|
||||
const [dialogControl, setDialogControl] = useState<DialogWebViewApi|null>(null);
|
||||
|
||||
const dialogSize = useDialogSize({
|
||||
dialogControl,
|
||||
webViewLoadCount,
|
||||
});
|
||||
const styles = useStyles(props.themeId, dialogSize, view.fitToContent);
|
||||
|
||||
const onButtonPress = useCallback(async (button: ButtonSpec) => {
|
||||
const closeWithResponse = (response?: DialogResult|null) => {
|
||||
const viewController = plugin.viewController(viewId) as WebviewController;
|
||||
if (view.containerType === ContainerType.Dialog) {
|
||||
viewController.closeWithResponse(response);
|
||||
} else {
|
||||
viewController.close();
|
||||
}
|
||||
};
|
||||
|
||||
let formData = undefined;
|
||||
if (button.id !== 'cancel') {
|
||||
formData = await dialogControl.getFormData();
|
||||
}
|
||||
|
||||
closeWithResponse({ id: button.id, formData });
|
||||
button.onClick?.();
|
||||
}, [dialogControl, plugin, viewId, view.containerType]);
|
||||
|
||||
const buttonComponents: React.ReactElement[] = [];
|
||||
const buttonSpecs = view.buttons ?? defaultButtonSpecs;
|
||||
|
||||
for (const button of buttonSpecs) {
|
||||
let iconName = undefined;
|
||||
let buttonTitle = button.title ?? button.id;
|
||||
|
||||
if (button.id === 'cancel') {
|
||||
iconName = 'close-outline';
|
||||
buttonTitle = button.title ?? _('Cancel');
|
||||
} else if (button.id === 'ok') {
|
||||
iconName = 'check';
|
||||
buttonTitle = button.title ?? _('OK');
|
||||
}
|
||||
|
||||
buttonComponents.push(
|
||||
<Button
|
||||
key={button.id}
|
||||
icon={iconName}
|
||||
mode='text'
|
||||
onPress={() => onButtonPress(button)}
|
||||
>
|
||||
{buttonTitle}
|
||||
</Button>,
|
||||
);
|
||||
}
|
||||
|
||||
const onWebViewLoaded = useCallback(() => {
|
||||
setWebViewLoadCount(webViewLoadCount + 1);
|
||||
}, [setWebViewLoadCount, webViewLoadCount]);
|
||||
|
||||
return (
|
||||
<View style={styles.dialog}>
|
||||
<View style={styles.webViewContainer}>
|
||||
<PluginUserWebView
|
||||
style={styles.webView}
|
||||
themeId={props.themeId}
|
||||
viewInfo={props.viewInfo}
|
||||
pluginHtmlContents={props.pluginHtmlContents}
|
||||
onLoadEnd={onWebViewLoaded}
|
||||
setDialogControl={setDialogControl}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.buttonRow}>
|
||||
{buttonComponents}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default PluginDialogWebView;
|
|
@ -0,0 +1,184 @@
|
|||
|
||||
import { PluginHtmlContents, PluginStates, ViewInfo } from '@joplin/lib/services/plugins/reducer';
|
||||
import * as React from 'react';
|
||||
import { Button, IconButton, Modal, Portal, SegmentedButtons, Text } from 'react-native-paper';
|
||||
import useViewInfos from './hooks/useViewInfos';
|
||||
import WebviewController, { ContainerType } from '@joplin/lib/services/plugins/WebviewController';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import PluginService from '@joplin/lib/services/plugins/PluginService';
|
||||
import { connect } from 'react-redux';
|
||||
import { AppState } from '../../../utils/types';
|
||||
import PluginUserWebView from './PluginUserWebView';
|
||||
import { View, useWindowDimensions, StyleSheet } from 'react-native';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { Theme } from '@joplin/lib/themes/type';
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
|
||||
pluginHtmlContents: PluginHtmlContents;
|
||||
pluginStates: PluginStates;
|
||||
visible: boolean;
|
||||
onClose: ()=> void;
|
||||
}
|
||||
|
||||
|
||||
const useStyles = (themeId: number) => {
|
||||
const windowSize = useWindowDimensions();
|
||||
|
||||
return useMemo(() => {
|
||||
const theme: Theme = themeStyle(themeId);
|
||||
|
||||
return StyleSheet.create({
|
||||
webView: {
|
||||
backgroundColor: 'transparent',
|
||||
display: 'flex',
|
||||
},
|
||||
webViewContainer: {
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
},
|
||||
closeButtonContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
dialog: {
|
||||
backgroundColor: theme.backgroundColor,
|
||||
borderRadius: 12,
|
||||
padding: 10,
|
||||
|
||||
height: windowSize.height * 0.9,
|
||||
width: windowSize.width * 0.97,
|
||||
|
||||
// Center
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
},
|
||||
});
|
||||
}, [themeId, windowSize.width, windowSize.height]);
|
||||
};
|
||||
|
||||
const emptyCallback = () => {};
|
||||
|
||||
const PluginPanelViewer: React.FC<Props> = props => {
|
||||
const viewInfos = useViewInfos(props.pluginStates);
|
||||
const viewInfoById = useMemo(() => {
|
||||
const result: Record<string, ViewInfo> = {};
|
||||
for (const info of viewInfos) {
|
||||
result[`${info.plugin.id}--${info.view.id}`] = info;
|
||||
}
|
||||
return result;
|
||||
}, [viewInfos]);
|
||||
|
||||
const buttonInfos = useMemo(() => {
|
||||
return Object.entries(viewInfoById)
|
||||
.filter(([_id, info]) => info.view.containerType === ContainerType.Panel)
|
||||
.map(([id, info]) => {
|
||||
const pluginName = PluginService.instance().pluginById(info.plugin.id).manifest.name;
|
||||
return {
|
||||
value: id,
|
||||
label: pluginName,
|
||||
icon: 'puzzle',
|
||||
};
|
||||
});
|
||||
}, [viewInfoById]);
|
||||
|
||||
const [selectedTabId, setSelectedTabId] = useState(() => {
|
||||
const lastSelectedId = Setting.value('ui.lastSelectedPluginPanel');
|
||||
if (lastSelectedId && viewInfoById[lastSelectedId]) {
|
||||
return lastSelectedId;
|
||||
} else {
|
||||
return buttonInfos[0]?.value;
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedTabId) return () => {};
|
||||
|
||||
const info = viewInfoById[selectedTabId];
|
||||
const plugin = PluginService.instance().pluginById(info.plugin.id);
|
||||
const controller = plugin.viewController(info.view.id) as WebviewController;
|
||||
controller.setIsShownInModal(true);
|
||||
Setting.setValue('ui.lastSelectedPluginPanel', selectedTabId);
|
||||
|
||||
return () => {
|
||||
controller.setIsShownInModal(false);
|
||||
};
|
||||
}, [viewInfoById, selectedTabId]);
|
||||
|
||||
|
||||
const styles = useStyles(props.themeId);
|
||||
|
||||
const viewInfo = viewInfoById[selectedTabId];
|
||||
|
||||
const renderTabContent = () => {
|
||||
if (!viewInfo) {
|
||||
return <Text>{_('No tab selected')}</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.webViewContainer}>
|
||||
<PluginUserWebView
|
||||
key={selectedTabId}
|
||||
themeId={props.themeId}
|
||||
style={styles.webView}
|
||||
viewInfo={viewInfo}
|
||||
pluginHtmlContents={props.pluginHtmlContents}
|
||||
onLoadEnd={emptyCallback}
|
||||
setDialogControl={emptyCallback}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const renderTabSelector = () => {
|
||||
// SegmentedButtons doesn't display correctly when there's only one button.
|
||||
// As such, we include a special case:
|
||||
if (buttonInfos.length === 1) {
|
||||
const buttonInfo = buttonInfos[0];
|
||||
return <Button icon={buttonInfo.icon}>{buttonInfo.label}</Button>;
|
||||
}
|
||||
|
||||
return (
|
||||
<SegmentedButtons
|
||||
value={selectedTabId}
|
||||
onValueChange={setSelectedTabId}
|
||||
buttons={buttonInfos}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const closeButton = (
|
||||
<View style={styles.closeButtonContainer}>
|
||||
<IconButton
|
||||
icon='close'
|
||||
accessibilityLabel={_('Close')}
|
||||
onPress={props.onClose}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Modal
|
||||
visible={props.visible}
|
||||
onDismiss={props.onClose}
|
||||
contentContainerStyle={styles.dialog}
|
||||
>
|
||||
{closeButton}
|
||||
{renderTabContent()}
|
||||
{renderTabSelector()}
|
||||
</Modal>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
export default connect((state: AppState) => {
|
||||
return {
|
||||
themeId: state.settings.theme,
|
||||
pluginHtmlContents: state.pluginService.pluginHtmlContents,
|
||||
pluginStates: state.pluginService.plugins,
|
||||
};
|
||||
})(PluginPanelViewer);
|
|
@ -0,0 +1,114 @@
|
|||
import * as React from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { PluginHtmlContents, ViewInfo } from '@joplin/lib/services/plugins/reducer';
|
||||
import ExtendedWebView, { WebViewControl } from '../../../components/ExtendedWebView';
|
||||
import { ViewStyle } from 'react-native';
|
||||
import usePlugin from '../../hooks/usePlugin';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import useDialogMessenger from './hooks/useDialogMessenger';
|
||||
import useWebViewSetup from './hooks/useWebViewSetup';
|
||||
import { DialogWebViewApi } from '../types';
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
pluginHtmlContents: PluginHtmlContents;
|
||||
viewInfo: ViewInfo;
|
||||
style: ViewStyle;
|
||||
onLoadEnd: ()=> void;
|
||||
setDialogControl: (dialogControl: DialogWebViewApi)=> void;
|
||||
}
|
||||
|
||||
const PluginUserWebView = (props: Props) => {
|
||||
const viewInfo = props.viewInfo;
|
||||
const view = viewInfo.view;
|
||||
const pluginId = viewInfo.plugin.id;
|
||||
const viewId = view.id;
|
||||
const plugin = usePlugin(pluginId);
|
||||
const [webViewLoadCount, setWebViewLoadCount] = useState(0);
|
||||
|
||||
const webviewRef = useRef<WebViewControl>(null);
|
||||
|
||||
const messageChannelId = `dialog-messenger-${pluginId}-${viewId}`;
|
||||
const messenger = useDialogMessenger({
|
||||
pluginId,
|
||||
viewId,
|
||||
webviewRef,
|
||||
messageChannelId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Because of how messenger.remoteApi handles message forwarding (property names
|
||||
// are not known), we need to send methods individually and can't use an object
|
||||
// spread or send messenger.remoteApi.
|
||||
props.setDialogControl({
|
||||
includeCssFiles: messenger.remoteApi.includeCssFiles,
|
||||
includeJsFiles: messenger.remoteApi.includeJsFiles,
|
||||
setThemeCss: messenger.remoteApi.setThemeCss,
|
||||
getFormData: messenger.remoteApi.getFormData,
|
||||
getContentSize: messenger.remoteApi.getContentSize,
|
||||
});
|
||||
}, [messenger, props.setDialogControl]);
|
||||
|
||||
useWebViewSetup({
|
||||
themeId: props.themeId,
|
||||
dialogControl: messenger.remoteApi,
|
||||
scriptPaths: view.scripts ?? [],
|
||||
pluginBaseDir: plugin.baseDir,
|
||||
webViewLoadCount,
|
||||
});
|
||||
|
||||
const htmlContent = props.pluginHtmlContents[pluginId]?.[viewId] ?? '';
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
|
||||
<title>Plugin Dialog</title>
|
||||
<style>
|
||||
body {
|
||||
padding: 20px;
|
||||
color: var(--joplin-color);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${htmlContent}
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const injectedJs = useMemo(() => {
|
||||
return `
|
||||
if (!window.backgroundPageLoaded) {
|
||||
${shim.injectedJs('pluginBackgroundPage')}
|
||||
pluginBackgroundPage.initializeDialogWebView(
|
||||
${JSON.stringify(messageChannelId)}
|
||||
);
|
||||
|
||||
window.backgroundPageLoaded = true;
|
||||
}
|
||||
`;
|
||||
}, [messageChannelId]);
|
||||
|
||||
const onWebViewLoaded = useCallback(() => {
|
||||
setWebViewLoadCount(webViewLoadCount + 1);
|
||||
props.onLoadEnd();
|
||||
messenger.onWebViewLoaded();
|
||||
}, [messenger, setWebViewLoadCount, webViewLoadCount, props.onLoadEnd]);
|
||||
|
||||
return (
|
||||
<ExtendedWebView
|
||||
style={props.style}
|
||||
baseUrl={plugin.baseDir}
|
||||
webviewInstanceId='joplin__PluginDialogWebView'
|
||||
html={html}
|
||||
injectedJavaScript={injectedJs}
|
||||
onMessage={messenger.onWebViewMessage}
|
||||
onLoadEnd={onWebViewLoaded}
|
||||
ref={webviewRef}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PluginUserWebView;
|
|
@ -0,0 +1,49 @@
|
|||
import { useMemo, RefObject } from 'react';
|
||||
import { DialogMainProcessApi, DialogWebViewApi } from '../../types';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { WebViewControl } from '../../../../components/ExtendedWebView';
|
||||
import createOnLogHander from '../../utils/createOnLogHandler';
|
||||
import RNToWebViewMessenger from '../../../../utils/ipc/RNToWebViewMessenger';
|
||||
import { SerializableData } from '@joplin/lib/utils/ipc/types';
|
||||
import PostMessageService, { ResponderComponentType } from '@joplin/lib/services/PostMessageService';
|
||||
import PluginService from '@joplin/lib/services/plugins/PluginService';
|
||||
|
||||
interface Props {
|
||||
pluginId: string;
|
||||
viewId: string;
|
||||
webviewRef: RefObject<WebViewControl>;
|
||||
messageChannelId: string;
|
||||
}
|
||||
|
||||
const useDialogMessenger = (props: Props) => {
|
||||
const { pluginId, webviewRef, viewId, messageChannelId } = props;
|
||||
|
||||
return useMemo(() => {
|
||||
const plugin = PluginService.instance().pluginById(pluginId);
|
||||
const logger = Logger.create(`PluginDialogWebView(${pluginId})`);
|
||||
|
||||
const dialogApi: DialogMainProcessApi = {
|
||||
postMessage: async (message: SerializableData) => {
|
||||
return await plugin.viewController(viewId).emitMessage({ message });
|
||||
},
|
||||
onMessage: async (callback) => {
|
||||
PostMessageService.instance().registerViewMessageHandler(
|
||||
ResponderComponentType.UserWebview,
|
||||
viewId,
|
||||
callback,
|
||||
);
|
||||
},
|
||||
onError: async (error: string) => {
|
||||
logger.error(`Unhandled error: ${error}`);
|
||||
plugin.hasErrors = true;
|
||||
},
|
||||
onLog: createOnLogHander(plugin, logger),
|
||||
};
|
||||
|
||||
return new RNToWebViewMessenger<DialogMainProcessApi, DialogWebViewApi>(
|
||||
messageChannelId, webviewRef, dialogApi,
|
||||
);
|
||||
}, [webviewRef, pluginId, viewId, messageChannelId]);
|
||||
};
|
||||
|
||||
export default useDialogMessenger;
|
|
@ -0,0 +1,29 @@
|
|||
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
|
||||
import { useState } from 'react';
|
||||
import { DialogContentSize, DialogWebViewApi } from '../../types';
|
||||
|
||||
interface Props {
|
||||
dialogControl: DialogWebViewApi;
|
||||
webViewLoadCount: number;
|
||||
}
|
||||
|
||||
const useDialogSize = (props: Props) => {
|
||||
const { dialogControl, webViewLoadCount } = props;
|
||||
|
||||
const [dialogSize, setDialogSize] = useState<DialogContentSize|null>(null);
|
||||
useAsyncEffect(async event => {
|
||||
if (!dialogControl) {
|
||||
// May happen if the webview is still loading.
|
||||
return;
|
||||
}
|
||||
|
||||
const contentSize = await dialogControl.getContentSize();
|
||||
if (event.cancelled) return;
|
||||
|
||||
setDialogSize(contentSize);
|
||||
}, [dialogControl, setDialogSize, webViewLoadCount]);
|
||||
|
||||
return dialogSize;
|
||||
};
|
||||
|
||||
export default useDialogSize;
|
|
@ -0,0 +1,10 @@
|
|||
import { PluginStates, utils as pluginUtils } from '@joplin/lib/services/plugins/reducer';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
const useViewInfos = (pluginStates: PluginStates) => {
|
||||
return useMemo(() => {
|
||||
return pluginUtils.viewInfosByType(pluginStates, 'webview');
|
||||
}, [pluginStates]);
|
||||
};
|
||||
|
||||
export default useViewInfos;
|
|
@ -0,0 +1,43 @@
|
|||
import { useEffect } from 'react';
|
||||
import { DialogWebViewApi } from '../../types';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { themeStyle } from '../../../../components/global-style';
|
||||
import themeToCss from '@joplin/lib/services/style/themeToCss';
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
scriptPaths: string[];
|
||||
dialogControl: DialogWebViewApi;
|
||||
pluginBaseDir: string;
|
||||
|
||||
// Whenever the WebView reloads, we need to re-inject CSS and JavaScript.
|
||||
webViewLoadCount: number;
|
||||
}
|
||||
|
||||
const useWebViewSetup = (props: Props) => {
|
||||
const { scriptPaths, dialogControl, pluginBaseDir, themeId } = props;
|
||||
|
||||
useEffect(() => {
|
||||
const jsPaths = [];
|
||||
const cssPaths = [];
|
||||
for (const rawPath of scriptPaths) {
|
||||
const resolvedPath = shim.fsDriver().resolveRelativePathWithinDir(pluginBaseDir, rawPath);
|
||||
|
||||
if (resolvedPath.match(/\.css$/i)) {
|
||||
cssPaths.push(resolvedPath);
|
||||
} else {
|
||||
jsPaths.push(resolvedPath);
|
||||
}
|
||||
}
|
||||
void dialogControl.includeCssFiles(cssPaths);
|
||||
void dialogControl.includeJsFiles(jsPaths);
|
||||
}, [dialogControl, scriptPaths, props.webViewLoadCount, pluginBaseDir]);
|
||||
|
||||
useEffect(() => {
|
||||
const theme = themeStyle(themeId);
|
||||
const themeVariableCss = themeToCss(theme);
|
||||
void dialogControl.setThemeCss(themeVariableCss);
|
||||
}, [dialogControl, themeId, props.webViewLoadCount]);
|
||||
};
|
||||
|
||||
export default useWebViewSetup;
|
|
@ -1160,10 +1160,7 @@ class AppComponent extends React.Component {
|
|||
</SafeAreaView>
|
||||
</MenuProvider>
|
||||
</SideMenu>
|
||||
<PluginRunnerWebView
|
||||
serializedPluginSettings={this.props.serializedPluginSettings}
|
||||
pluginStates={this.props.pluginStates}
|
||||
/>
|
||||
<PluginRunnerWebView />
|
||||
</View>
|
||||
);
|
||||
|
||||
|
@ -1217,8 +1214,6 @@ const mapStateToProps = (state: any) => {
|
|||
disableSideMenuGestures: state.disableSideMenuGestures,
|
||||
biometricsDone: state.biometricsDone,
|
||||
biometricsEnabled: state.settings['security.biometricsEnabled'],
|
||||
serializedPluginSettings: state.settings['plugins.states'],
|
||||
pluginStates: state.pluginService.plugins,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -4,6 +4,7 @@ import stateToWhenClauseContext from '@joplin/lib/services/commands/stateToWhenC
|
|||
import { AppState } from './types';
|
||||
import { Store } from 'redux';
|
||||
import editorCommandDeclarations from '../components/NoteEditor/commandDeclarations';
|
||||
import globalCommands from '../commands';
|
||||
import libCommands from '@joplin/lib/commands';
|
||||
|
||||
interface CommandSpecification {
|
||||
|
@ -23,6 +24,7 @@ const initializeCommandService = (store: Store<AppState, any>) => {
|
|||
for (const declaration of editorCommandDeclarations) {
|
||||
CommandService.instance().registerDeclaration(declaration);
|
||||
}
|
||||
registerCommands(globalCommands);
|
||||
registerCommands(libCommands);
|
||||
};
|
||||
|
||||
|
|
|
@ -19,6 +19,7 @@ const injectedJs = {
|
|||
codeMirrorBundle: require('../lib/rnInjectedJs/codeMirrorBundle.bundle'),
|
||||
svgEditorBundle: require('../lib/rnInjectedJs/svgEditorBundle.bundle'),
|
||||
pluginBackgroundPage: require('../lib/rnInjectedJs/pluginBackgroundPage.bundle'),
|
||||
noteBodyViewerBundle: require('../lib/rnInjectedJs/noteBodyViewerBundle.bundle'),
|
||||
};
|
||||
|
||||
function shimInit() {
|
||||
|
|
|
@ -78,6 +78,7 @@ export default class PluginLoader {
|
|||
scriptElement.appendChild(document.createTextNode(`
|
||||
(async () => {
|
||||
const exports = {};
|
||||
const module = {};
|
||||
const require = window.__pluginLoaderRequireFunctions[${JSON.stringify(this.pluginLoaderId)}];
|
||||
const joplin = {
|
||||
require,
|
||||
|
@ -85,7 +86,7 @@ export default class PluginLoader {
|
|||
|
||||
${js};
|
||||
|
||||
window.__pluginLoaderScriptLoadCallbacks[${JSON.stringify(scriptId)}](exports);
|
||||
window.__pluginLoaderScriptLoadCallbacks[${JSON.stringify(scriptId)}](module.exports || exports);
|
||||
})();
|
||||
`));
|
||||
|
||||
|
|
|
@ -83,7 +83,7 @@ export interface ContentScriptData {
|
|||
}
|
||||
|
||||
export interface EditorControl {
|
||||
supportsCommand(name: EditorCommandType|string): boolean;
|
||||
supportsCommand(name: EditorCommandType|string): boolean|Promise<boolean>;
|
||||
execCommand(name: EditorCommandType|string, ...args: any[]): void|Promise<any>;
|
||||
|
||||
undo(): void;
|
||||
|
|
|
@ -1331,6 +1331,15 @@ class Setting extends BaseModel {
|
|||
|
||||
'ui.layout': { value: {}, type: SettingItemType.Object, storage: SettingStorage.File, isGlobal: true, public: false, appTypes: [AppType.Desktop] },
|
||||
|
||||
'ui.lastSelectedPluginPanel': {
|
||||
value: '',
|
||||
type: SettingItemType.String,
|
||||
public: false,
|
||||
description: () => 'The last selected plugin panel ID in pop-up mode (mobile).',
|
||||
storage: SettingStorage.Database,
|
||||
appTypes: [AppType.Mobile],
|
||||
},
|
||||
|
||||
// TODO: Is there a better way to do this? The goal here is to simply have
|
||||
// a way to display a link to the customizable stylesheets, not for it to
|
||||
// serve as a customizable Setting. But because the Setting page is auto-
|
||||
|
|
|
@ -10,7 +10,7 @@ async function processDirectory(dir, indexFilePath = null, typeScriptType = null
|
|||
if (!importNameTemplate) importNameTemplate = '* as FILE_NAME';
|
||||
if (!exportNameTemplate) exportNameTemplate = 'FILE_NAME';
|
||||
|
||||
const tsFiles = glob.sync('{**/*.ts,**/*.tsx}', {
|
||||
const tsFiles = glob.sync('{*.ts,*.tsx}', {
|
||||
cwd: dir,
|
||||
}).filter(f => `${dir}/${f}` !== indexFilePath)
|
||||
//
|
||||
|
@ -67,6 +67,7 @@ module.exports = {
|
|||
await processDirectory(`${rootDir}/packages/app-desktop/gui/NoteList/commands`);
|
||||
await processDirectory(`${rootDir}/packages/app-desktop/gui/NoteListControls/commands`);
|
||||
await processDirectory(`${rootDir}/packages/app-desktop/gui/Sidebar/commands`);
|
||||
await processDirectory(`${rootDir}/packages/app-mobile/commands`);
|
||||
await processDirectory(`${rootDir}/packages/lib/commands`);
|
||||
|
||||
await processDirectory(
|
||||
|
|
Loading…
Reference in New Issue