diff --git a/packages/app-mobile/components/Modal.tsx b/packages/app-mobile/components/Modal.tsx index 62aed4f0b..69d6963d0 100644 --- a/packages/app-mobile/components/Modal.tsx +++ b/packages/app-mobile/components/Modal.tsx @@ -13,8 +13,12 @@ const ModalElement: React.FC = ({ containerStyle, ...modalProps }) => { + // supportedOrientations: On iOS, this allows the dialog to be shown in non-portrait orientations. return ( - + {children} diff --git a/packages/app-mobile/components/ScreenHeader.tsx b/packages/app-mobile/components/ScreenHeader.tsx index 539ec2291..021f79f83 100644 --- a/packages/app-mobile/components/ScreenHeader.tsx +++ b/packages/app-mobile/components/ScreenHeader.tsx @@ -24,6 +24,8 @@ import FolderPicker from './FolderPicker'; import { itemIsInTrash } from '@joplin/lib/services/trash'; import restoreItems from '@joplin/lib/services/trash/restoreItems'; import { ModelType } from '@joplin/lib/BaseModel'; +import { PluginStates } from '@joplin/lib/services/plugins/reducer'; +import { ContainerType } from '@joplin/lib/services/plugins/WebviewController'; // We need this to suppress the useless warning // https://github.com/oblador/react-native-vector-icons/issues/1465 @@ -69,6 +71,7 @@ interface ScreenHeaderProps { onValueChange?: OnValueChangedListener; mustSelect?: boolean; }; + plugins: PluginStates; dispatch: DispatchCommandType; onUndoButtonPress: OnPressCallback; @@ -256,6 +259,10 @@ class ScreenHeaderComponent extends PureComponent { + 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) return null; + + return ( + + + + ); + }; + function deleteButton(styles: any, onPress: OnPressCallback, disabled: boolean) { return ( this.backButton_press(), backButtonDisabled); const selectAllButtonComp = !showSelectAllButton ? null : selectAllButton(this.styles(), () => this.selectAllButton_press()); const searchButtonComp = !showSearchButton ? null : searchButton(this.styles(), () => this.searchButton_press()); + const pluginPanelsComp = pluginPanelToggleButton(this.styles(), () => this.pluginPanelToggleButton_press()); const deleteButtonComp = !selectedFolderInTrash && this.props.noteSelectionEnabled ? deleteButton(this.styles(), () => this.deleteButton_press(), headerItemDisabled) : null; const restoreButtonComp = selectedFolderInTrash && this.props.noteSelectionEnabled ? restoreButton(this.styles(), () => this.restoreButton_press(), headerItemDisabled) : null; const duplicateButtonComp = !selectedFolderInTrash && this.props.noteSelectionEnabled ? duplicateButton(this.styles(), () => this.duplicateButton_press(), headerItemDisabled) : null; @@ -667,6 +692,7 @@ class ScreenHeaderComponent extends PureComponent { hasDisabledSyncItems: state.hasDisabledSyncItems, shouldUpgradeSyncTarget: state.settings['sync.upgradeState'] === Setting.SYNC_UPGRADE_STATE_SHOULD_DO, mustUpgradeAppMessage: state.mustUpgradeAppMessage, + plugins: state.pluginService.plugins, }; })(ScreenHeaderComponent); diff --git a/packages/app-mobile/components/screens/Note.tsx b/packages/app-mobile/components/screens/Note.tsx index c68ed0437..192c9f8a3 100644 --- a/packages/app-mobile/components/screens/Note.tsx +++ b/packages/app-mobile/components/screens/Note.tsx @@ -60,8 +60,6 @@ import restoreItems from '@joplin/lib/services/trash/restoreItems'; import { getDisplayParentTitle } from '@joplin/lib/services/trash'; 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'; import debounce from '../../utils/debounce'; const urlUtils = require('@joplin/lib/urlUtils'); @@ -104,7 +102,6 @@ interface State { imageEditorResourceFilepath: string; noteResources: Record; newAndNoTitleChangeNoteId: boolean|null; - pluginPanelsVisible: boolean; HACK_webviewLoadingState: number; @@ -163,7 +160,6 @@ class NoteScreenComponent extends BaseScreenComponent 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 @@ -1302,18 +1298,6 @@ class NoteScreenComponent extends BaseScreenComponent 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; @@ -1626,10 +1610,6 @@ class NoteScreenComponent extends BaseScreenComponent implements B }} /> {noteTagDialog} - this.setState({ pluginPanelsVisible: false })} - /> ); } diff --git a/packages/app-mobile/plugins/PluginRunner/dialogs/PluginDialogManager.tsx b/packages/app-mobile/plugins/PluginRunner/dialogs/PluginDialogManager.tsx index 93f890a6e..b09d665f1 100644 --- a/packages/app-mobile/plugins/PluginRunner/dialogs/PluginDialogManager.tsx +++ b/packages/app-mobile/plugins/PluginRunner/dialogs/PluginDialogManager.tsx @@ -6,6 +6,7 @@ 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'; +import PluginPanelViewer from './PluginPanelViewer'; interface Props { themeId: number; @@ -53,6 +54,7 @@ const PluginDialogManager: React.FC = props => { return ( <> {dialogs} + ); }; diff --git a/packages/app-mobile/plugins/PluginRunner/dialogs/PluginPanelViewer.tsx b/packages/app-mobile/plugins/PluginRunner/dialogs/PluginPanelViewer.tsx index 0db3fbb7f..ea6102f23 100644 --- a/packages/app-mobile/plugins/PluginRunner/dialogs/PluginPanelViewer.tsx +++ b/packages/app-mobile/plugins/PluginRunner/dialogs/PluginPanelViewer.tsx @@ -1,19 +1,21 @@ 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 { Button, IconButton, 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 { useCallback, 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 { View, useWindowDimensions, StyleSheet, AccessibilityInfo } 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'; +import { Dispatch } from 'redux'; +import Modal from '../../../components/Modal'; interface Props { themeId: number; @@ -21,7 +23,7 @@ interface Props { pluginHtmlContents: PluginHtmlContents; pluginStates: PluginStates; visible: boolean; - onClose: ()=> void; + dispatch: Dispatch; } @@ -60,8 +62,63 @@ const useStyles = (themeId: number) => { }, [themeId, windowSize.width, windowSize.height]); }; +type ButtonInfo = { + value: string; + label: string; + icon: string; +}; + +const useSelectedTabId = (buttonInfos: ButtonInfo[], viewInfoById: Record) => { + const getDefaultSelectedTabId = useCallback((): string|undefined => { + const lastSelectedId = Setting.value('ui.lastSelectedPluginPanel'); + if (lastSelectedId && viewInfoById[lastSelectedId]) { + return lastSelectedId; + } else { + return buttonInfos[0]?.value; + } + }, [buttonInfos, viewInfoById]); + + const [selectedTabId, setSelectedTabId] = useState(getDefaultSelectedTabId); + + useEffect(() => { + if (!selectedTabId) { + setSelectedTabId(getDefaultSelectedTabId()); + } + }, [selectedTabId, getDefaultSelectedTabId]); + + useEffect(() => { + if (!selectedTabId) return () => {}; + + const info = viewInfoById[selectedTabId]; + + let controller: WebviewController|null = null; + if (info && info.view.opened) { + const plugin = PluginService.instance().pluginById(info.plugin.id); + controller = plugin.viewController(info.view.id) as WebviewController; + controller.setIsShownInModal(true); + } + + Setting.setValue('ui.lastSelectedPluginPanel', selectedTabId); + AccessibilityInfo.announceForAccessibility(_('%s tab opened', getTabLabel(info))); + + return () => { + controller?.setIsShownInModal(false); + }; + }, [viewInfoById, selectedTabId]); + + return { setSelectedTabId, selectedTabId }; +}; + const emptyCallback = () => {}; +const getTabLabel = (info: ViewInfo) => { + // Handles the case where a plugin just unloaded or hasn't loaded yet. + if (!info) { + return '...'; + } + + return PluginService.instance().pluginById(info.plugin.id).manifest.name; +}; const PluginPanelViewer: React.FC = props => { const viewInfos = useViewInfos(props.pluginStates); const viewInfoById = useMemo(() => { @@ -76,40 +133,16 @@ const PluginPanelViewer: React.FC = props => { 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, + label: getTabLabel(info), 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 { selectedTabId, setSelectedTabId } = useSelectedTabId(buttonInfos, viewInfoById); const viewInfo = viewInfoById[selectedTabId]; @@ -138,7 +171,11 @@ const PluginPanelViewer: React.FC = props => { // As such, we include a special case: if (buttonInfos.length === 1) { const buttonInfo = buttonInfos[0]; - return ; + return ( + + ); } return ( @@ -150,12 +187,18 @@ const PluginPanelViewer: React.FC = props => { ); }; + const onClose = useCallback(() => { + props.dispatch({ + type: 'TOGGLE_PLUGIN_PANELS_DIALOG', + }); + }, [props.dispatch]); + const closeButton = ( ); @@ -164,8 +207,11 @@ const PluginPanelViewer: React.FC = props => { {closeButton} {renderTabContent()} @@ -179,6 +225,7 @@ export default connect((state: AppState) => { return { themeId: state.settings.theme, pluginHtmlContents: state.pluginService.pluginHtmlContents, + visible: state.showPanelsDialog, pluginStates: state.pluginService.plugins, }; })(PluginPanelViewer); diff --git a/packages/app-mobile/root.tsx b/packages/app-mobile/root.tsx index 4cf18bcac..a4cbb7f61 100644 --- a/packages/app-mobile/root.tsx +++ b/packages/app-mobile/root.tsx @@ -259,6 +259,7 @@ const appDefaultState: AppState = { ...defaultState, sideMenuOpenPercent: 0, noteSideMenuOptions: null, isOnMobileData: false, disableSideMenuGestures: false, + showPanelsDialog: false, }; const appReducer = (state = appDefaultState, action: any) => { @@ -379,6 +380,11 @@ const appReducer = (state = appDefaultState, action: any) => { newState.sideMenuOpenPercent = action.value; break; + case 'TOGGLE_PLUGIN_PANELS_DIALOG': + newState = { ...state }; + newState.showPanelsDialog = !newState.showPanelsDialog; + break; + case 'NOTE_SELECTION_TOGGLE': { diff --git a/packages/app-mobile/utils/types.ts b/packages/app-mobile/utils/types.ts index 8c90f2095..f76377647 100644 --- a/packages/app-mobile/utils/types.ts +++ b/packages/app-mobile/utils/types.ts @@ -2,6 +2,7 @@ import { State } from '@joplin/lib/reducer'; export interface AppState extends State { sideMenuOpenPercent: number; + showPanelsDialog: boolean; isOnMobileData: boolean; route: any; smartFilterId: string;