Mobile: Add support for plugin panels and dialogs (#10121)

pull/10127/head
Henry Heino 2024-03-14 12:04:32 -07:00 committed by GitHub
parent b9eb4522f5
commit b3ec92a57e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 1013 additions and 44 deletions

View File

@ -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

14
.gitignore vendored
View File

@ -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

View File

@ -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);

View File

@ -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) {

View File

@ -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>;
}

View File

@ -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`

View File

@ -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);
}
},
};
};

View File

@ -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);
},
};
};

View File

@ -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,
});
},
};
};

View File

@ -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;

View File

@ -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);

View File

@ -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;
}

View File

@ -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>
);
}

View File

@ -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

View File

@ -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"
}
}

View File

@ -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);

View File

@ -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;

View File

@ -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';

View File

@ -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)});

View File

@ -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;

View File

@ -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;

View File

@ -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);

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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,
};
};

View File

@ -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);
};

View File

@ -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() {

View File

@ -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);
})();
`));

View File

@ -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;

View File

@ -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-

View File

@ -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(