Mobile: Implement redesigned notebook long-press menu (#16027)

This commit is contained in:
Henry Heino authored and GitHub committed 2026-07-31 11:31:51 +02:00
1 parent d7c29c1d1a
commit 1d7c5c0a44
10 files changed
+241 -170

No files matched your search

+1
View File
@@ -700,6 +700,7 @@ packages/app-mobile/commands/util/goToNote.js
packages/app-mobile/commands/util/showResource.js
packages/app-mobile/components/BetaChip.js
packages/app-mobile/components/BottomDrawer.js
packages/app-mobile/components/BottomDrawerMenu.js
packages/app-mobile/components/CameraView/ActionButtons.js
packages/app-mobile/components/CameraView/Camera/index.jest.js
packages/app-mobile/components/CameraView/Camera/index.js
+1
View File
@@ -726,6 +726,7 @@ packages/app-mobile/commands/util/goToNote.js
packages/app-mobile/commands/util/showResource.js
packages/app-mobile/components/BetaChip.js
packages/app-mobile/components/BottomDrawer.js
packages/app-mobile/components/BottomDrawerMenu.js
packages/app-mobile/components/CameraView/ActionButtons.js
packages/app-mobile/components/CameraView/Camera/index.jest.js
packages/app-mobile/components/CameraView/Camera/index.js
@@ -9,9 +9,15 @@ import { AppState } from '../utils/types';
import useReduceMotionEnabled from '../utils/hooks/useReduceMotionEnabled';
import { _ } from '@joplin/lib/locale';
export enum MenuAlignment {
Center,
Right,
}
interface Props {
themeId: number;
style: ViewStyle;
alignment: MenuAlignment;
children: React.ReactNode;
visible: boolean;
draggable: boolean;
@@ -20,6 +26,7 @@ interface Props {
}
interface UseStylesProps {
alignment: MenuAlignment;
theme: ThemeStyle;
dragging: boolean;
draggable: boolean;
@@ -27,7 +34,7 @@ interface UseStylesProps {
dragOffset: Animated.AnimatedInterpolation<number>;
}
const useStyles = ({ theme, dragging, draggable, dragOffset, backgroundOpacity }: UseStylesProps) => {
const useStyles = ({ theme, dragging, alignment, draggable, dragOffset, backgroundOpacity }: UseStylesProps) => {
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const safeAreaPadding = useSafeAreaPadding();
@@ -61,7 +68,7 @@ const useStyles = ({ theme, dragging, draggable, dragOffset, backgroundOpacity }
},
menuStyle: {
zIndex: 1,
alignSelf: 'flex-end',
alignSelf: alignment === MenuAlignment.Center ? 'center' : 'flex-end',
...(isSmallWidthScreen ? {
// Center on small screens, rather than float right.
alignSelf: 'center',
@@ -153,7 +160,7 @@ const useStyles = ({ theme, dragging, draggable, dragOffset, backgroundOpacity }
zIndex: 2,
},
});
}, [theme, safeAreaPadding, windowWidth, dragging, draggable, dragOffset, windowHeight, backgroundOpacity, menuMarginTop]);
}, [theme, safeAreaPadding, windowWidth, dragging, draggable, alignment, dragOffset, windowHeight, backgroundOpacity, menuMarginTop]);
};
interface UsePanResponderProps {
@@ -206,6 +213,13 @@ const usePanResponder = ({
[onStartEvent]: (event: GestureResponderEvent) => {
return isInDragHandle(event.nativeEvent.pageY);
},
// On Android, we need to set the pan responder immediately to allow dragging the menu on non-Pressable elements
...(Platform.OS === 'android' ? {
onStartShouldSetPanResponder: () => isScrolledToTopRef.current,
onShouldBlockNativeResponder: event => isInDragHandle(event.nativeEvent.pageY),
} : {}),
[onMoveEvent]: (_event: GestureResponderEvent, gestureState: PanResponderGestureState) => {
if (!isScrolledToTopRef.current) {
return false;
@@ -313,7 +327,7 @@ const BottomDrawer: React.FC<Props> = props => {
const [animating, setAnimating] = useState(false);
const menuYOffset = useMemo(() => menuDragOffset, [menuDragOffset]);
const styles = useStyles({
theme, dragging, draggable: props.draggable, dragOffset: menuYOffset, backgroundOpacity,
theme, dragging, alignment: props.alignment, draggable: props.draggable, dragOffset: menuYOffset, backgroundOpacity,
});
const reduceMotionEnabled = useReduceMotionEnabled();
@@ -0,0 +1,165 @@
import * as React from 'react';
import { useMemo } from 'react';
import { StyleSheet, View, useWindowDimensions, TextStyle } from 'react-native';
import { themeStyle } from './global-style';
import BottomDrawer, { MenuAlignment } from './BottomDrawer';
import { TouchableRipple, Text } from 'react-native-paper';
import Icon from './Icon';
interface MenuOptionDivider {
isDivider: true;
}
export enum MenuOptionStyle {
Normal = 'normal',
Destructive = 'destructive',
}
export interface MenuOptionButton {
key?: string;
isDivider?: false|undefined;
disabled?: boolean;
style?: MenuOptionStyle;
onPress: ()=> void;
icon?: string;
title: string;
}
export type MenuOption = MenuOptionDivider|MenuOptionButton;
interface Props {
themeId: number;
visible: boolean;
alignment: MenuAlignment;
title?: string;
onDismiss: ()=> void;
options: MenuOption[];
}
const useStyles = (themeId: number) => {
const windowWidth = useWindowDimensions().width;
return useMemo(() => {
const theme = themeStyle(themeId);
const dividerMargin = theme.marginSmall;
return StyleSheet.create({
title: {
paddingLeft: theme.marginLeft,
paddingRight: theme.marginRight,
// Match the space from button text to a neighbouring divider:
paddingVertical: theme.marginMedium + dividerMargin,
marginBottom: theme.marginSmall,
lineHeight: theme.fontSizeLarger,
fontSize: theme.fontSizeLarger,
borderColor: theme.dividerColor,
borderBottomWidth: 1,
},
menu: {
paddingHorizontal: 0,
},
menuContent: { flexDirection: 'column', width: '100%' },
menuItem: {
alignItems: 'flex-start',
padding: theme.marginMedium,
paddingLeft: theme.marginLeft,
paddingRight: theme.marginRight,
minWidth: Math.min(350, windowWidth),
},
menuItemContent: {
flexDirection: 'row',
gap: theme.marginSmall,
alignItems: 'center',
},
menuItemText: {
color: theme.color,
fontSize: theme.fontSize,
},
menuItemTextDisabled: {
opacity: theme.disabledOpacity,
},
menuItemTextDestructive: {
color: theme.destructiveColor,
},
divider: {
height: 0,
borderBottomWidth: 1,
borderColor: theme.dividerColor,
marginVertical: dividerMargin,
marginHorizontal: theme.margin,
},
});
}, [themeId, windowWidth]);
};
const BottomDrawerMenu: React.FC<Props> = props => {
const styles = useStyles(props.themeId);
const menuOptionComponents: React.ReactNode[] = [];
let keyCounter = 0;
for (const option of props.options) {
if (option.isDivider === true) {
menuOptionComponents.push(
<View key={`menuOption_divider_${keyCounter++}`} style={styles.divider} />,
);
} else {
const key = `menuOption_${option.key ?? keyCounter++}`;
const textStyles: TextStyle[] = [styles.menuItemText];
if (option.disabled) {
textStyles.push(styles.menuItemTextDisabled);
}
if (option.style === MenuOptionStyle.Destructive) {
textStyles.push(styles.menuItemTextDestructive);
}
menuOptionComponents.push(
<TouchableRipple
borderless={true}
role='button'
style={styles.menuItem}
onPress={() => {
option.onPress();
props.onDismiss();
}}
key={key}
disabled={!!option.disabled}
>
<View style={styles.menuItemContent}>
{option.icon && <Icon name={option.icon} style={textStyles} accessibilityLabel={null} />}
<Text
style={textStyles}
disabled={option.disabled}
>{option.title}</Text>
</View>
</TouchableRipple>,
);
}
}
const titleComponent = props.title ? <>
<Text
role='heading'
variant='titleMedium'
style={styles.title}
>{props.title}</Text>
</> : null;
return <BottomDrawer
visible={props.visible}
onDismiss={props.onDismiss}
alignment={props.alignment}
draggable={true}
style={styles.menu}
>
<View
style={styles.menuContent}
testID={`menu-content-${props.visible ? 'open' : 'closed'}`}
>
{titleComponent}
{menuOptionComponents}
</View>
</BottomDrawer>;
};
export default BottomDrawerMenu;
@@ -1,129 +1,21 @@
import * as React from 'react';
import { useCallback, useMemo, useState } from 'react';
import { StyleSheet, View, Text, useWindowDimensions, TextStyle } from 'react-native';
import { useCallback, useState } from 'react';
import { themeStyle } from '../global-style';
import BottomDrawer from '../BottomDrawer';
import { TouchableRipple } from 'react-native-paper';
import Icon from '../Icon';
interface MenuOptionDivider {
isDivider: true;
}
export enum MenuOptionStyle {
Normal = 'normal',
Destructive = 'destructive',
}
export interface MenuOptionButton {
key?: string;
isDivider?: false|undefined;
disabled?: boolean;
style?: MenuOptionStyle;
onPress: ()=> void;
icon?: string;
title: string;
}
export type MenuOptionType = MenuOptionDivider|MenuOptionButton;
import BottomDrawerMenu, { MenuOption } from '../BottomDrawerMenu';
import { MenuAlignment } from '../BottomDrawer';
interface Props {
themeId: number;
options: MenuOptionType[];
options: MenuOption[];
children: React.ReactNode;
}
const useStyles = (themeId: number) => {
const windowWidth = useWindowDimensions().width;
return useMemo(() => {
const theme = themeStyle(themeId);
return StyleSheet.create({
menu: {
paddingHorizontal: 0,
},
menuContent: { flexDirection: 'column', width: '100%' },
menuItem: {
alignItems: 'flex-start',
padding: theme.marginMedium,
paddingLeft: theme.marginLeft,
paddingRight: theme.marginRight,
minWidth: Math.min(350, windowWidth),
},
menuItemContent: {
flexDirection: 'row',
gap: theme.marginSmall,
alignItems: 'center',
},
menuItemText: {
color: theme.color,
fontSize: theme.fontSize,
},
menuItemTextDisabled: {
opacity: theme.disabledOpacity,
},
menuItemTextDestructive: {
color: theme.destructiveColor,
},
divider: {
height: 0,
borderBottomWidth: 1,
borderColor: theme.dividerColor,
marginVertical: theme.marginSmall,
marginHorizontal: theme.margin,
},
});
}, [themeId, windowWidth]);
};
const MenuComponent: React.FC<Props> = props => {
const styles = useStyles(props.themeId);
const theme = themeStyle(props.themeId);
const menuOptionComponents: React.ReactNode[] = [];
const [open, setOpen] = useState(false);
let keyCounter = 0;
for (const option of props.options) {
if (option.isDivider === true) {
menuOptionComponents.push(
<View key={`menuOption_divider_${keyCounter++}`} style={styles.divider} />,
);
} else {
const key = `menuOption_${option.key ?? keyCounter++}`;
const textStyles: TextStyle[] = [styles.menuItemText];
if (option.disabled) {
textStyles.push(styles.menuItemTextDisabled);
}
if (option.style === MenuOptionStyle.Destructive) {
textStyles.push(styles.menuItemTextDestructive);
}
menuOptionComponents.push(
<TouchableRipple
borderless={true}
role='button'
style={styles.menuItem}
onPress={() => {
option.onPress();
setOpen(false);
}}
key={key}
disabled={!!option.disabled}
>
<View style={styles.menuItemContent}>
{option.icon && <Icon name={option.icon} style={textStyles} accessibilityLabel={null} />}
<Text
style={textStyles}
disabled={option.disabled}
>{option.title}</Text>
</View>
</TouchableRipple>,
);
}
}
const onMenuClosed = useCallback(() => {
setOpen(false);
}, []);
@@ -138,19 +30,14 @@ const MenuComponent: React.FC<Props> = props => {
>
{props.children}
</TouchableRipple>
<BottomDrawer
<BottomDrawerMenu
visible={open}
onDismiss={onMenuClosed}
draggable={true}
style={styles.menu}
>
<View
style={styles.menuContent}
testID={`menu-content-${open ? 'open' : 'closed'}`}
>
{menuOptionComponents}
</View>
</BottomDrawer>
// To match the side of the screen with the action button:
alignment={MenuAlignment.Right}
themeId={props.themeId}
options={props.options}
/>
</>;
};
@@ -24,8 +24,9 @@ import WarningBanner from './WarningBanner';
import shim from '@joplin/lib/shim';
import CommandService from '@joplin/lib/services/CommandService';
import Icon from '../Icon';
import Menu, { MenuOptionType } from './Menu';
export { MenuOptionType };
import Menu from './Menu';
import { MenuOption, MenuOptionStyle } from '../BottomDrawerMenu';
export { MenuOption, MenuOptionStyle };
// Rather than applying a padding to the whole bar, it is applied to each
// individual component (button, picker, etc.) so that the touchable areas
@@ -58,7 +59,7 @@ interface ScreenHeaderProps {
showUndoButton: boolean;
undoButtonDisabled?: boolean;
showRedoButton: boolean;
menuOptions: MenuOptionType[];
menuOptions: MenuOption[];
title?: string|null;
folders: FolderEntity[];
folderPickerOptions?: FolderPickerOptions;
@@ -559,7 +560,7 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
);
}
const menuOptions: MenuOptionType[] = [...this.props.menuOptions];
const menuOptions: MenuOption[] = [...this.props.menuOptions];
const selectedFolder = this.props.notesParentType === 'Folder' ? Folder.byId(this.props.folders, this.props.selectedFolderId) : null;
const selectedFolderInTrash = itemIsInTrash(selectedFolder);
@@ -9,7 +9,7 @@ import { themeStyle } from '../global-style';
import Logger, { LogEntry } from '@joplin/utils/Logger';
import { BaseScreenComponent } from '../base-screen';
import { _ } from '@joplin/lib/locale';
import { MenuOptionType } from '../ScreenHeader';
import { MenuOption } from '../ScreenHeader';
import { AppState } from '../../utils/types';
import { writeTextToCacheFile } from '../../utils/ShareUtils';
import shim from '@joplin/lib/shim';
@@ -30,7 +30,7 @@ interface State {
}
class LogScreenComponent extends BaseScreenComponent<Props, State> {
private readonly menuOptions_: MenuOptionType[];
private readonly menuOptions_: MenuOption[];
private styles_: Record<number, ReturnType<typeof StyleSheet.create>>;
private readonly logListRef_ = React.createRef<FlatList>();
@@ -22,7 +22,7 @@ import NavService, { OnNavigateCallback as OnNavigateCallback } from '@joplin/li
import { ModelType } from '@joplin/lib/BaseModel';
import { fileExtension, safeFileExtension } from '@joplin/lib/path-utils';
import * as mimeUtils from '@joplin/lib/mime-utils';
import ScreenHeader, { FolderPickerOptions, MenuOptionType, ViewToggleButtonMode } from '../../ScreenHeader';
import ScreenHeader, { FolderPickerOptions, MenuOption, ViewToggleButtonMode } from '../../ScreenHeader';
import NoteTagsDialog from '../NoteTagsDialog';
import time from '@joplin/lib/time';
import Checkbox from '../../Checkbox';
@@ -84,7 +84,7 @@ import { Second } from '@joplin/utils/time';
import TextWrapCalculator from '../Notes/TextWrapCalculator';
import SearchEngine from '@joplin/lib/services/search/SearchEngine';
import { ALL_NOTES_FILTER_ID } from '@joplin/lib/reserved-ids';
import { MenuOptionButton, MenuOptionStyle } from '../../ScreenHeader/Menu';
import { MenuOptionStyle, MenuOptionButton } from '../../BottomDrawerMenu';
const emptyArray: never[] = [];
@@ -184,7 +184,7 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
private noteTagDialog_closeRequested: ()=> void;
private refreshResource: (resource: ResourceEntity, noteBody?: string)=> Promise<void>;
private selection: SelectionRange;
private menuOptionsCache_: Record<string, MenuOptionType[]>;
private menuOptionsCache_: Record<string, MenuOption[]>;
private focusUpdateIID_: ReturnType<typeof setTimeout> | null;
private folderPickerOptions_: FolderPickerOptions;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- dialogbox is the react-native-dialogbox ref; the library ships no types
@@ -1292,7 +1292,7 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
if (this.menuOptionsCache_[cacheKey]) return this.menuOptionsCache_[cacheKey];
const output: MenuOptionType[] = [];
const output: MenuOption[] = [];
// The file attachment modules only work in Android >= 5 (Version 21)
// https://github.com/react-community/react-native-image-picker/issues/606
@@ -7,7 +7,7 @@ import Revision from '@joplin/lib/models/Revision';
import BaseModel, { ModelType } from '@joplin/lib/BaseModel';
import { IconButton, Text } from 'react-native-paper';
import Dropdown from '../Dropdown';
import ScreenHeader, { MenuOptionType } from '../ScreenHeader';
import ScreenHeader, { MenuOption } from '../ScreenHeader';
import { formatMsToLocal } from '@joplin/utils/time';
import { useCallback, useContext, useMemo, useState } from 'react';
import { PrimaryButton } from '../buttons';
@@ -24,7 +24,7 @@ import { DialogContext } from '../DialogManager';
import useDeleteHistoryClick from '@joplin/lib/components/shared/NoteRevisionViewer/useDeleteHistoryClick';
import { OnScrollCallback } from '../NoteBodyViewer/types';
import TextWrapCalculator from './Notes/TextWrapCalculator';
import { MenuOptionStyle } from '../ScreenHeader/Menu';
import { MenuOptionStyle } from '../BottomDrawerMenu';
interface Props {
themeId: number;
@@ -194,7 +194,7 @@ const NoteRevisionViewer: React.FC<Props> = props => {
const disableDeleteHistory = deleting || !hasRevisions;
const menuOptions = useMemo(() => {
const output: MenuOptionType[] = [{
const output: MenuOption[] = [{
title: _('Delete history'),
icon: 'material delete-outline',
style: MenuOptionStyle.Destructive,
@@ -1,6 +1,6 @@
import * as React from 'react';
import { useMemo, useEffect, useCallback, useContext } from 'react';
import { Easing, Animated, TouchableOpacity, Text, StyleSheet, ScrollView, View, Image, ImageStyle, Platform } from 'react-native';
import { useMemo, useEffect, useCallback, useContext, useState } from 'react';
import { Easing, Animated, TouchableOpacity, Text, StyleSheet, ScrollView, View, Image, ImageStyle } from 'react-native';
import { Dispatch } from 'redux';
import { connect } from 'react-redux';
import Icon from './Icon';
@@ -20,7 +20,6 @@ import restoreItems from '@joplin/lib/services/trash/restoreItems';
import emptyTrash from '@joplin/lib/services/trash/emptyTrash';
import { ModelType } from '@joplin/lib/BaseModel';
import { DialogContext } from './DialogManager';
import { PromptButtonSpec } from './DialogManager/types';
import { TextStyle, ViewStyle } from 'react-native';
import { StateDecryptionWorker, StateResourceFetcher } from '@joplin/lib/reducer';
import useOnLongPressProps from '../utils/hooks/useOnLongPressProps';
@@ -28,6 +27,8 @@ import { TouchableRipple } from 'react-native-paper';
import shim from '@joplin/lib/shim';
import getConflictFolderId from '@joplin/lib/models/utils/getConflictFolderId';
import { substrWithEllipsis } from '@joplin/lib/string-utils';
import BottomDrawerMenu, { MenuOption, MenuOptionStyle } from './BottomDrawerMenu';
import { MenuAlignment } from './BottomDrawer';
interface Props {
syncStarted: boolean;
@@ -343,23 +344,22 @@ const SideMenuContentComponent = (props: Props) => {
});
};
const [folderMenuVisible, setFolderMenuVisible] = useState(false);
const [folderMenuTitle, setFolderMenuTitle] = useState('');
const [folderMenuOptions, setFolderMenuOptions] = useState<MenuOption[]>([]);
const folder_longPress = async (folderOrAll: FolderEntity | string) => {
if (folderOrAll === 'all') return;
const folder = folderOrAll as FolderEntity;
const menuItems: PromptButtonSpec[] = [];
menuItems.push({
text: _('Cancel'),
onPress: () => {},
style: 'cancel',
});
const menuItems: MenuOption[] = [];
if (folder && folder.id === getConflictFolderId()) return;
if (folder && folder.id === getTrashFolderId()) {
menuItems.push({
text: _('Empty trash'),
title: _('Empty trash'),
icon: 'material delete-outline',
onPress: async () => {
dialogs.prompt('', _('This will permanently delete all items in the trash. Continue?'), [
{
@@ -375,16 +375,16 @@ const SideMenuContentComponent = (props: Props) => {
},
]);
},
style: 'destructive',
style: MenuOptionStyle.Destructive,
});
} else if (folder && !!folder.deleted_time) {
menuItems.push({
text: _('Restore'),
title: _('Restore'),
icon: 'material delete-off-outline',
onPress: async () => {
await restoreItems(ModelType.Folder, [folder.id]);
},
style: 'destructive',
});
// Alert.alert(
@@ -434,13 +434,15 @@ const SideMenuContentComponent = (props: Props) => {
return folderDeletion(_('Move notebook "%s" to the trash?\n\nAll notes and sub-notebooks within this notebook will also be moved to the trash.', substrWithEllipsis(folder.title, 0, 32)));
};
const deleteButton: PromptButtonSpec = {
text: _('Delete'),
const deleteButton: MenuOption = {
title: _('Delete'),
onPress: generateFolderDeletion,
style: 'destructive',
icon: 'material delete-outline',
style: MenuOptionStyle.Destructive,
};
const editButton: PromptButtonSpec = {
text: _('Edit'),
const editButton: MenuOption = {
title: _('Edit'),
icon: 'material file-edit-outline',
onPress: () => {
props.dispatch({ type: 'SIDE_MENU_CLOSE' });
@@ -452,20 +454,12 @@ const SideMenuContentComponent = (props: Props) => {
},
};
// On iOS, "cancel" is always shown at the bottom. Push the edit button first
// so that "delete" is still shown in the middle.
if (Platform.OS === 'ios') {
menuItems.push(editButton, deleteButton);
} else {
menuItems.push(deleteButton, editButton);
}
menuItems.push(editButton, deleteButton);
}
dialogs.prompt(
'',
_('Notebook: %s', folder.title),
menuItems,
);
setFolderMenuOptions(menuItems);
setFolderMenuTitle(_('Notebook: %s', folder.title));
setFolderMenuVisible(true);
};
const folder_togglePress = (folder: FolderEntity) => {
@@ -740,6 +734,14 @@ const SideMenuContentComponent = (props: Props) => {
</ScrollView>
{renderBottomPanel()}
</View>
<BottomDrawerMenu
visible={folderMenuVisible}
onDismiss={() => setFolderMenuVisible(false)}
themeId={props.themeId}
alignment={MenuAlignment.Center}
title={folderMenuTitle}
options={folderMenuOptions}
/>
</View>
);
};