1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-08-10 22:11:50 +02:00

Mobile: Implement new note menu redesign (#11780)

This commit is contained in:
Henry Heino
2025-04-07 12:12:10 -07:00
committed by GitHub
parent fe88703488
commit a29e30e442
23 changed files with 582 additions and 240 deletions

View File

@@ -593,6 +593,7 @@ packages/app-mobile/commands/scrollToHash.js
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/CameraView/ActionButtons.js
packages/app-mobile/components/CameraView/Camera/index.jest.js
packages/app-mobile/components/CameraView/Camera/index.js
@@ -686,7 +687,6 @@ packages/app-mobile/components/SelectDateTimeDialog.js
packages/app-mobile/components/SideMenu.js
packages/app-mobile/components/SideMenuContentNote.js
packages/app-mobile/components/TextInput.js
packages/app-mobile/components/accessibility/AccessibleModalMenu.js
packages/app-mobile/components/accessibility/AccessibleView.test.js
packages/app-mobile/components/accessibility/AccessibleView.js
packages/app-mobile/components/accessibility/FocusControl/AutoFocusProvider.js
@@ -701,6 +701,7 @@ packages/app-mobile/components/biometrics/BiometricPopup.js
packages/app-mobile/components/biometrics/biometricAuthenticate.js
packages/app-mobile/components/biometrics/sensorInfo.js
packages/app-mobile/components/buttons/FloatingActionButton.js
packages/app-mobile/components/buttons/LabelledIconButton.js
packages/app-mobile/components/buttons/TextButton.js
packages/app-mobile/components/buttons/index.js
packages/app-mobile/components/getResponsiveValue.test.js
@@ -791,7 +792,9 @@ packages/app-mobile/components/screens/Note/commands/setTags.js
packages/app-mobile/components/screens/Note/commands/toggleVisiblePanes.js
packages/app-mobile/components/screens/Note/types.js
packages/app-mobile/components/screens/NoteTagsDialog.js
packages/app-mobile/components/screens/Notes.js
packages/app-mobile/components/screens/Notes/NewNoteButton.test.js
packages/app-mobile/components/screens/Notes/NewNoteButton.js
packages/app-mobile/components/screens/Notes/Notes.js
packages/app-mobile/components/screens/SearchScreen/SearchResults.js
packages/app-mobile/components/screens/SearchScreen/index.js
packages/app-mobile/components/screens/ShareManager/AcceptedShareItem.js
@@ -846,6 +849,7 @@ packages/app-mobile/utils/createRootStyle.js
packages/app-mobile/utils/database-driver-react-native.js
packages/app-mobile/utils/database-driver-react-native.web.js
packages/app-mobile/utils/debounce.js
packages/app-mobile/utils/focusView.js
packages/app-mobile/utils/fs-driver/constants.js
packages/app-mobile/utils/fs-driver/fs-driver-rn.js
packages/app-mobile/utils/fs-driver/fs-driver-rn.web.js
@@ -861,6 +865,7 @@ packages/app-mobile/utils/getVersionInfoText.js
packages/app-mobile/utils/hooks/useKeyboardState.js
packages/app-mobile/utils/hooks/useOnLongPressProps.js
packages/app-mobile/utils/hooks/useReduceMotionEnabled.js
packages/app-mobile/utils/hooks/useSafeAreaPadding.js
packages/app-mobile/utils/image/fileToImage.web.js
packages/app-mobile/utils/image/getImageDimensions.js
packages/app-mobile/utils/image/resizeImage.js

9
.gitignore vendored
View File

@@ -568,6 +568,7 @@ packages/app-mobile/commands/scrollToHash.js
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/CameraView/ActionButtons.js
packages/app-mobile/components/CameraView/Camera/index.jest.js
packages/app-mobile/components/CameraView/Camera/index.js
@@ -661,7 +662,6 @@ packages/app-mobile/components/SelectDateTimeDialog.js
packages/app-mobile/components/SideMenu.js
packages/app-mobile/components/SideMenuContentNote.js
packages/app-mobile/components/TextInput.js
packages/app-mobile/components/accessibility/AccessibleModalMenu.js
packages/app-mobile/components/accessibility/AccessibleView.test.js
packages/app-mobile/components/accessibility/AccessibleView.js
packages/app-mobile/components/accessibility/FocusControl/AutoFocusProvider.js
@@ -676,6 +676,7 @@ packages/app-mobile/components/biometrics/BiometricPopup.js
packages/app-mobile/components/biometrics/biometricAuthenticate.js
packages/app-mobile/components/biometrics/sensorInfo.js
packages/app-mobile/components/buttons/FloatingActionButton.js
packages/app-mobile/components/buttons/LabelledIconButton.js
packages/app-mobile/components/buttons/TextButton.js
packages/app-mobile/components/buttons/index.js
packages/app-mobile/components/getResponsiveValue.test.js
@@ -766,7 +767,9 @@ packages/app-mobile/components/screens/Note/commands/setTags.js
packages/app-mobile/components/screens/Note/commands/toggleVisiblePanes.js
packages/app-mobile/components/screens/Note/types.js
packages/app-mobile/components/screens/NoteTagsDialog.js
packages/app-mobile/components/screens/Notes.js
packages/app-mobile/components/screens/Notes/NewNoteButton.test.js
packages/app-mobile/components/screens/Notes/NewNoteButton.js
packages/app-mobile/components/screens/Notes/Notes.js
packages/app-mobile/components/screens/SearchScreen/SearchResults.js
packages/app-mobile/components/screens/SearchScreen/index.js
packages/app-mobile/components/screens/ShareManager/AcceptedShareItem.js
@@ -821,6 +824,7 @@ packages/app-mobile/utils/createRootStyle.js
packages/app-mobile/utils/database-driver-react-native.js
packages/app-mobile/utils/database-driver-react-native.web.js
packages/app-mobile/utils/debounce.js
packages/app-mobile/utils/focusView.js
packages/app-mobile/utils/fs-driver/constants.js
packages/app-mobile/utils/fs-driver/fs-driver-rn.js
packages/app-mobile/utils/fs-driver/fs-driver-rn.web.js
@@ -836,6 +840,7 @@ packages/app-mobile/utils/getVersionInfoText.js
packages/app-mobile/utils/hooks/useKeyboardState.js
packages/app-mobile/utils/hooks/useOnLongPressProps.js
packages/app-mobile/utils/hooks/useReduceMotionEnabled.js
packages/app-mobile/utils/hooks/useSafeAreaPadding.js
packages/app-mobile/utils/image/fileToImage.web.js
packages/app-mobile/utils/image/getImageDimensions.js
packages/app-mobile/utils/image/resizeImage.js

View File

@@ -0,0 +1,106 @@
import * as React from 'react';
import { connect } from 'react-redux';
import { useCallback, useMemo } from 'react';
import { NativeScrollEvent, NativeSyntheticEvent, StyleSheet, useWindowDimensions, View } from 'react-native';
import useSafeAreaPadding from '../utils/hooks/useSafeAreaPadding';
import { themeStyle, ThemeStyle } from './global-style';
import Modal from './Modal';
import { AppState } from '../utils/types';
interface Props {
themeId: number;
children: React.ReactNode;
visible: boolean;
onDismiss: ()=> void;
onShow: ()=> void;
}
const useStyles = (theme: ThemeStyle) => {
const { width: windowWidth } = useWindowDimensions();
const safeAreaPadding = useSafeAreaPadding();
return useMemo(() => {
const isSmallWidthScreen = windowWidth < 500;
const menuGapLeft = safeAreaPadding.paddingLeft + 6;
const menuGapRight = safeAreaPadding.paddingRight + 6;
return StyleSheet.create({
menuStyle: {
alignSelf: 'flex-end',
...(isSmallWidthScreen ? {
// Center on small screens, rather than float right.
alignSelf: 'center',
} : {}),
flexDirection: 'row',
marginRight: menuGapRight,
marginLeft: menuGapLeft,
paddingBottom: 0,
backgroundColor: theme.backgroundColor,
borderRadius: 16,
borderBottomRightRadius: 0,
borderBottomLeftRadius: 0,
maxWidth: Math.min(400, windowWidth - menuGapRight - menuGapLeft),
},
contentContainer: {
padding: 20,
paddingBottom: 14,
gap: 8,
flexDirection: 'row',
flexWrap: 'wrap',
},
modalBackground: {
paddingTop: 0,
paddingLeft: 0,
paddingRight: 0,
paddingBottom: 0,
justifyContent: 'flex-end',
flexDirection: 'column',
},
dismissButton: {
top: 0,
bottom: undefined,
height: 12,
},
});
}, [theme, safeAreaPadding, windowWidth]);
};
const BottomDrawer: React.FC<Props> = props => {
const theme = themeStyle(props.themeId);
const styles = useStyles(theme);
const onContainerScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
const offsetY = event.nativeEvent.contentOffset.y;
if (offsetY < -50) {
props.onDismiss();
}
}, [props.onDismiss]);
return <Modal
visible={props.visible}
onDismiss={props.onDismiss}
onRequestClose={props.onDismiss}
onShow={props.onShow}
animationType='fade'
backgroundColor={theme.backgroundColorTransparent2}
transparent
modalBackgroundStyle={styles.modalBackground}
dismissButtonStyle={styles.dismissButton}
containerStyle={styles.menuStyle}
scrollOverflow={{
overScrollMode: 'always',
onScroll: onContainerScroll,
}}
>
<View style={styles.contentContainer}>
{props.children}
</View>
</Modal>;
};
export default connect((state: AppState) => {
return {
themeId: state.settings.theme,
};
})(BottomDrawer);

View File

@@ -8,6 +8,7 @@ import makeShowMessageBox from '../../utils/makeShowMessageBox';
import { DialogControl, PromptDialogData } from './types';
import useDialogControl from './hooks/useDialogControl';
import PromptDialog from './PromptDialog';
import { themeStyle } from '../global-style';
export type { DialogControl } from './types';
export const DialogContext = createContext<DialogControl>(null);
@@ -49,6 +50,7 @@ const DialogManager: React.FC<Props> = props => {
};
}, []);
const theme = themeStyle(props.themeId);
const styles = useStyles();
const dialogComponents: React.ReactNode[] = [];
@@ -73,7 +75,7 @@ const DialogManager: React.FC<Props> = props => {
scrollOverflow={true}
containerStyle={styles.modalContainer}
animationType='fade'
backgroundColor='rgba(0, 0, 0, 0.1)'
backgroundColor={theme.backgroundColorTransparent2}
transparent={true}
onRequestClose={dialogModels[dialogComponents.length - 1]?.onDismiss}
>

View File

@@ -69,6 +69,7 @@ const useStyles = (themeId: number, containerStyle: ViewStyle, size: DialogSize)
};
const DismissibleDialog: React.FC<Props> = props => {
const theme = themeStyle(props.themeId);
const styles = useStyles(props.themeId, props.containerStyle, props.size);
const heading = props.heading ? (
@@ -92,7 +93,7 @@ const DismissibleDialog: React.FC<Props> = props => {
onRequestClose={props.onDismiss}
containerStyle={styles.dialogContainer}
animationType='fade'
backgroundColor='rgba(0, 0, 0, 0.1)'
backgroundColor={theme.backgroundColorTransparent2}
transparent={true}
>
<Surface style={styles.dialogSurface} elevation={1}>

View File

@@ -1,40 +1,35 @@
import * as React from 'react';
import { RefObject, useCallback, useMemo, useRef, useState } from 'react';
import { GestureResponderEvent, Modal, ModalProps, Platform, Pressable, ScrollView, StyleSheet, View, ViewStyle, useWindowDimensions } from 'react-native';
import { hasNotch } from 'react-native-device-info';
import { GestureResponderEvent, Modal, ModalProps, Platform, Pressable, ScrollView, ScrollViewProps, StyleSheet, View, ViewStyle } from 'react-native';
import FocusControl from './accessibility/FocusControl/FocusControl';
import { msleep, Second } from '@joplin/utils/time';
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
import { ModalState } from './accessibility/FocusControl/types';
import useSafeAreaPadding from '../utils/hooks/useSafeAreaPadding';
import { _ } from '@joplin/lib/locale';
interface ModalElementProps extends ModalProps {
children: React.ReactNode;
containerStyle?: ViewStyle;
backgroundColor?: string;
modalBackgroundStyle?: ViewStyle;
// Extra styles for the accessibility tools dismiss button. For example,
// this might be used to display the dismiss button near the top of the
// screen (rather than the bottom).
dismissButtonStyle?: ViewStyle;
// If scrollOverflow is provided, the modal is wrapped in a vertical
// ScrollView. This allows the user to scroll parts of dialogs into
// view that would otherwise be clipped by the screen edge.
scrollOverflow?: boolean;
scrollOverflow?: boolean|ScrollViewProps;
}
const useStyles = (hasScrollView: boolean, backgroundColor: string|undefined) => {
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const isLandscape = windowWidth > windowHeight;
const safeAreaPadding = useSafeAreaPadding();
return useMemo(() => {
const backgroundPadding: ViewStyle = isLandscape ? {
paddingRight: hasNotch() ? 60 : 0,
paddingLeft: hasNotch() ? 60 : 0,
paddingTop: 15,
paddingBottom: 15,
} : {
paddingTop: hasNotch() ? 65 : 15,
paddingBottom: hasNotch() ? 35 : 15,
};
return StyleSheet.create({
modalBackground: {
...backgroundPadding,
...safeAreaPadding,
flexGrow: 1,
flexShrink: 1,
@@ -62,7 +57,7 @@ const useStyles = (hasScrollView: boolean, backgroundColor: string|undefined) =>
zIndex: -1,
},
});
}, [hasScrollView, isLandscape, backgroundColor]);
}, [hasScrollView, safeAreaPadding, backgroundColor]);
};
const useBackgroundTouchListeners = (onRequestClose: (event: GestureResponderEvent)=> void, backdropRef: RefObject<View>) => {
@@ -114,9 +109,11 @@ const ModalElement: React.FC<ModalElementProps> = ({
containerStyle,
backgroundColor,
scrollOverflow,
modalBackgroundStyle: extraModalBackgroundStyles,
dismissButtonStyle,
...modalProps
}) => {
const styles = useStyles(scrollOverflow, backgroundColor);
const styles = useStyles(!!scrollOverflow, backgroundColor);
// contentWrapper adds padding. To allow styling the region outside of the modal
// (e.g. to add a background), the content is wrapped twice.
@@ -134,18 +131,18 @@ const ModalElement: React.FC<ModalElementProps> = ({
containerRef.current = containerComponent;
const { onShouldBackgroundCaptureTouch, onBackgroundTouchFinished } = useBackgroundTouchListeners(modalProps.onRequestClose, containerRef);
// A close button for accessibility tools. Since iOS accessibility focus order is based on the position
// of the element on the screen, the close button is placed after the modal content, rather than behind.
const closeButton = modalProps.onRequestClose ? <Pressable
style={styles.dismissButton}
style={[styles.dismissButton, dismissButtonStyle]}
onPress={modalProps.onRequestClose}
accessibilityLabel={_('Close dialog')}
accessibilityRole='button'
/> : null;
const contentAndBackdrop = <View
ref={setContainerComponent}
style={styles.modalBackground}
style={[styles.modalBackground, extraModalBackgroundStyles]}
onStartShouldSetResponder={onShouldBackgroundCaptureTouch}
onResponderRelease={onBackgroundTouchFinished}
>
@@ -153,6 +150,7 @@ const ModalElement: React.FC<ModalElementProps> = ({
{closeButton}
</View>;
const extraScrollViewProps = (typeof scrollOverflow === 'object' ? scrollOverflow : {});
return (
<FocusControl.ModalWrapper state={modalStatus}>
<Modal
@@ -162,8 +160,9 @@ const ModalElement: React.FC<ModalElementProps> = ({
>
{scrollOverflow ? (
<ScrollView
style={styles.modalScrollView}
contentContainerStyle={styles.modalScrollViewContent}
{...extraScrollViewProps}
style={[styles.modalScrollView, extraScrollViewProps.style]}
contentContainerStyle={[styles.modalScrollViewContent, extraScrollViewProps.contentContainerStyle]}
>{contentAndBackdrop}</ScrollView>
) : contentAndBackdrop}
</Modal>

View File

@@ -1,56 +0,0 @@
import * as React from 'react';
import { View } from 'react-native';
import Modal from '../Modal';
import { useCallback, useState } from 'react';
import { _ } from '@joplin/lib/locale';
import { PrimaryButton, SecondaryButton } from '../buttons';
interface MenuItem {
label: string;
onPress?: ()=> void;
}
interface Props {
label: string;
onPress: ()=> void;
actions: MenuItem[]|null;
}
// react-native-paper's floating action button menu is inaccessible on web
// (can't be activated by a screen reader, and, in some cases, by the tab key).
// This component provides an alternative.
const AccessibleModalMenu: React.FC<Props> = props => {
const [open, setOpen] = useState(false);
const onClick = useCallback(() => {
if (props.onPress) {
props.onPress();
} else {
setOpen(!open);
}
}, [open, props.onPress]);
const options: React.ReactElement[] = [];
for (const action of (props.actions ?? [])) {
options.push(
<PrimaryButton key={action.label} onPress={action.onPress}>
{action.label}
</PrimaryButton>,
);
}
const modal = (
<Modal visible={open}>
{options}
<SecondaryButton onPress={onClick}>{_('Close menu')}</SecondaryButton>
</Modal>
);
return <View style={{ height: 0, overflow: 'visible' }}>
{modal}
<SecondaryButton onPress={onClick}>{props.label}</SecondaryButton>
</View>;
};
export default AccessibleModalMenu;

View File

@@ -1,9 +1,9 @@
import { focus } from '@joplin/lib/utils/focusHandler';
import Logger from '@joplin/utils/Logger';
import * as React from 'react';
import { useContext, useEffect, useRef, useState } from 'react';
import { AccessibilityInfo, findNodeHandle, Platform, UIManager, View, ViewProps } from 'react-native';
import { Platform, View, ViewProps } from 'react-native';
import { AutoFocusContext } from './FocusControl/AutoFocusProvider';
import Logger from '@joplin/utils/Logger';
import focusView from '../../utils/focusView';
const logger = Logger.create('AccessibleView');
@@ -29,33 +29,7 @@ const useAutoFocus = (refocusCounter: number|null, containerNode: View|HTMLEleme
if (!containerNode) return () => {};
const focusContainer = () => {
const doFocus = () => {
if (Platform.OS === 'web') {
// react-native-web defines UIManager.focus for setting the keyboard focus. However,
// this property is not available in standard react-native. As such, access it using type
// narrowing:
// eslint-disable-next-line no-restricted-properties
if (!('focus' in UIManager) || typeof UIManager.focus !== 'function') {
throw new Error('Failed to focus sidebar. UIManager.focus is not a function.');
}
// Disable the "use focusHandler for all focus calls" rule -- UIManager.focus requires
// an argument, which is not supported by focusHandler.
// eslint-disable-next-line no-restricted-properties
UIManager.focus(containerNode);
} else {
const handle = findNodeHandle(containerNode as View);
if (handle !== null) {
AccessibilityInfo.setAccessibilityFocus(handle);
} else {
logger.warn('Couldn\'t find a view to focus.');
}
}
};
focus(`AccessibleView::${debugLabelRef.current}`, {
focus: doFocus,
});
focusView(`AccessibleView::${debugLabelRef.current}`, containerNode);
};
const canFocusNow = !autoFocusControlRef.current || autoFocusControlRef.current.canAutoFocus();

View File

@@ -1,6 +1,6 @@
import * as React from 'react';
import { connect } from 'react-redux';
import NotesScreen from './screens/Notes';
import NotesScreen from './screens/Notes/Notes';
import SearchScreen from './screens/SearchScreen';
import { KeyboardAvoidingView, Platform, View } from 'react-native';
import { AppState } from '../utils/types';

View File

@@ -1,16 +1,13 @@
const React = require('react');
import { useState, useCallback, useMemo } from 'react';
import { FAB, Portal } from 'react-native-paper';
import * as React from 'react';
import { useState, useCallback, useMemo, useRef } from 'react';
import { FAB } from 'react-native-paper';
import { _ } from '@joplin/lib/locale';
import { Dispatch } from 'redux';
import { Platform, View, ViewStyle } from 'react-native';
import shim from '@joplin/lib/shim';
import AccessibleWebMenu from '../accessibility/AccessibleModalMenu';
import { AccessibilityActionEvent, AccessibilityActionInfo, View } from 'react-native';
import { connect } from 'react-redux';
import BottomDrawer from '../BottomDrawer';
const Icon = require('react-native-vector-icons/Ionicons').default;
// eslint-disable-next-line no-undef -- Don't know why it says React is undefined when it's defined above
type FABGroupProps = React.ComponentProps<typeof FAB.Group>;
type OnButtonPress = ()=> void;
interface ButtonSpec {
icon: string;
@@ -20,14 +17,18 @@ interface ButtonSpec {
}
interface ActionButtonProps {
buttons?: ButtonSpec[];
// If not given, an "add" button will be used.
mainButton?: ButtonSpec;
mainButton: ButtonSpec;
dispatch: Dispatch;
}
const defaultOnPress = () => {};
menuContent?: React.ReactNode;
onMenuShow?: ()=> void;
accessibilityActions?: readonly AccessibilityActionInfo[];
// Can return a Promise to simplify unit testing
onAccessibilityAction?: (event: AccessibilityActionEvent)=> void|Promise<void>;
accessibilityHint?: string;
}
// Returns a render function compatible with React Native Paper.
const getIconRenderFunction = (iconName: string) => {
@@ -43,95 +44,55 @@ const useIcon = (iconName: string) => {
const FloatingActionButton = (props: ActionButtonProps) => {
const [open, setOpen] = useState(false);
const onMenuToggled: FABGroupProps['onStateChange'] = useCallback(state => {
const onMenuToggled = useCallback(() => {
props.dispatch({
type: 'SIDE_MENU_CLOSE',
});
setOpen(state.open);
}, [setOpen, props.dispatch]);
const newOpen = !open;
setOpen(newOpen);
}, [setOpen, open, props.dispatch]);
const actions = useMemo(() => (props.buttons ?? []).map(button => {
return {
...button,
icon: getIconRenderFunction(button.icon),
onPress: button.onPress ?? defaultOnPress,
};
}), [props.buttons]);
const onDismiss = useCallback(() => {
if (open) onMenuToggled();
}, [open, onMenuToggled]);
const mainButtonRef = useRef<View>();
const closedIcon = useIcon(props.mainButton?.icon ?? 'add');
const openIcon = useIcon('close');
// To work around an Android accessibility bug, we decrease the
// size of the container for the FAB. According to the documentation for
// RN Paper, a large action button has size 96x96. As such, we allocate
// a larger than this space for the button.
//
// To prevent the accessibility issue from regressing (which makes it
// very hard to access some UI features), we also enable this when Talkback
// is disabled.
//
// See https://github.com/callstack/react-native-paper/issues/4064
// May be possible to remove if https://github.com/callstack/react-native-paper/pull/4514
// is merged.
const adjustMargins = !open && shim.mobilePlatform() === 'android';
const marginStyles = useMemo((): ViewStyle => {
if (!adjustMargins) {
return {};
}
// Internally, React Native Paper uses absolute positioning to make its
// (usually invisible) view fill the screen. Setting top and left to
// undefined causes the view to take up only part of the screen.
return {
top: undefined,
left: undefined,
};
}, [adjustMargins]);
const label = props.mainButton?.label ?? _('Add new');
// On Web, FAB.Group can't be used at all with accessibility tools. Work around this
// by hiding the FAB for accessibility, and providing a screen-reader-only custom menu.
const isWeb = Platform.OS === 'web';
const accessibleMenu = isWeb ? (
<AccessibleWebMenu
label={label}
onPress={props.mainButton?.onPress}
actions={props.buttons}
/>
) : null;
const menuContent = <FAB.Group
open={open}
const menuButton = <FAB
ref={mainButtonRef}
icon={open ? openIcon : closedIcon}
accessibilityLabel={label}
style={marginStyles}
icon={ open ? openIcon : closedIcon }
fabStyle={{
backgroundColor: props.mainButton?.color ?? 'rgba(231,76,60,1)',
onPress={props.mainButton?.onPress ?? onMenuToggled}
style={{
alignSelf: 'flex-end',
}}
onStateChange={onMenuToggled}
actions={actions}
onPress={props.mainButton?.onPress ?? defaultOnPress}
// The long press delay is too short by default (and we don't use the long press event). See https://github.com/laurent22/joplin/issues/11183.
// Increase to a large value:
delayLongPress={10_000}
visible={true}
accessibilityActions={props.accessibilityActions}
onAccessibilityAction={props.onAccessibilityAction}
/>;
const mainMenu = isWeb ? (
<View
aria-hidden={true}
pointerEvents='box-none'
tabIndex={-1}
style={{ flex: 1 }}
>{menuContent}</View>
) : menuContent;
return (
<Portal>
{mainMenu}
{accessibleMenu}
</Portal>
);
return <>
<View
style={{
position: 'absolute',
bottom: 10,
right: 10,
}}
>
{menuButton}
</View>
<BottomDrawer
visible={open}
onDismiss={onDismiss}
onShow={props.onMenuShow}
>
{props.menuContent}
</BottomDrawer>
</>;
};
export default FloatingActionButton;
export default connect()(FloatingActionButton);

View File

@@ -0,0 +1,67 @@
import * as React from 'react';
import { Text, TouchableRipple } from 'react-native-paper';
import Icon from '../Icon';
import { themeStyle } from '../global-style';
import { connect } from 'react-redux';
import { AppState } from '../../utils/types';
import { StyleSheet, View, ViewProps } from 'react-native';
import { useMemo } from 'react';
interface Props extends ViewProps {
themeId: number;
title: string;
icon: string;
onPress: ()=> void;
}
const useStyles = (themeId: number) => {
return useMemo(() => {
const theme = themeStyle(themeId);
return StyleSheet.create({
icon: {
fontSize: 27,
width: 44,
height: 44,
textAlign: 'center',
overflow: 'hidden',
color: theme.color3,
borderColor: theme.codeBorderColor, // TODO: Use a different theme variable
borderRadius: 22,
padding: 6,
borderWidth: 2,
backgroundColor: theme.backgroundColor3,
},
buttonContent: {
flexDirection: 'column',
alignItems: 'center',
gap: 6,
},
button: {
borderRadius: 8,
padding: 8,
},
});
}, [themeId]);
};
const LabelledIconButton: React.FC<Props> = ({ title, icon, style, themeId, ...otherProps }) => {
const styles = useStyles(themeId);
return <TouchableRipple
borderless={true}
role='button'
accessibilityRole='button'
{...otherProps}
style={[styles.button, style]}
>
<View style={styles.buttonContent}>
<Icon style={styles.icon} accessibilityLabel={null} name={icon}/>
<Text variant='labelMedium'>{title}</Text>
</View>
</TouchableRipple>;
};
export default connect((state: AppState) => {
return { themeId: state.settings.theme };
})(LabelledIconButton);

View File

@@ -4,6 +4,7 @@ import { themeStyle } from '../global-style';
import { Button, ButtonProps } from 'react-native-paper';
import { connect } from 'react-redux';
import { AppState } from '../../utils/types';
import { TextStyle, StyleSheet, ViewStyle, StyleProp } from 'react-native';
export enum ButtonType {
Primary,
@@ -12,9 +13,16 @@ export enum ButtonType {
Link,
}
interface Props extends Omit<ButtonProps, 'item'|'onPress'|'children'> {
export enum ButtonSize {
Normal,
Larger,
}
interface Props extends Omit<ButtonProps, 'item'|'onPress'|'children'|'style'> {
themeId: number;
type: ButtonType;
size?: ButtonSize;
style?: TextStyle;
onPress: ()=> void;
children: ReactNode;
}
@@ -41,12 +49,25 @@ const useStyles = ({ themeId }: Props) => {
primaryButton: { },
};
return { themeOverride };
return {
themeOverride,
styles: StyleSheet.create({
largeContainer: {
paddingVertical: 2,
borderWidth: 2,
borderRadius: 10,
},
largeLabel: {
fontSize: theme.fontSize,
fontWeight: 'bold',
},
}),
};
}, [themeId]);
};
const TextButton: React.FC<Props> = props => {
const { themeOverride } = useStyles(props);
const { themeOverride, styles } = useStyles(props);
let mode: ButtonProps['mode'];
let theme: ButtonProps['theme'];
@@ -68,8 +89,19 @@ const TextButton: React.FC<Props> = props => {
return exhaustivenessCheck;
}
let labelStyle: TextStyle|undefined = undefined;
const containerStyle: StyleProp<ViewStyle>[] = [];
if (props.size === ButtonSize.Larger) {
labelStyle = styles.largeLabel;
containerStyle.push(styles.largeContainer);
}
if (props.style) containerStyle.push(props.style);
return <Button
labelStyle={labelStyle}
{...props}
style={containerStyle}
theme={theme}
mode={mode}
onPress={props.onPress}

View File

@@ -8,6 +8,7 @@ const Color = require('color');
const baseStyle = {
appearance: 'light',
fontSize: 16,
fontSizeLarger: 18,
fontSizeLarge: 20,
margin: 15, // No text and no interactive component should be within this margin
itemMarginTop: 10,

View File

@@ -1622,7 +1622,7 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
if (this.state.mode === 'edit') return null;
return <FloatingActionButton mainButton={editButton} dispatch={this.props.dispatch} />;
return <FloatingActionButton mainButton={editButton} />;
};
// Save button is not really needed anymore with the improved save logic

View File

@@ -0,0 +1,60 @@
import * as React from 'react';
import TestProviderStack from '../../testing/TestProviderStack';
import NewNoteButton from './NewNoteButton';
import { AppState } from '../../../utils/types';
import { Store } from 'redux';
import createMockReduxStore from '../../../utils/testing/createMockReduxStore';
import setupGlobalStore from '../../../utils/testing/setupGlobalStore';
import { act, render, screen, waitFor } from '@testing-library/react-native';
import '@testing-library/jest-native/extend-expect';
import { AccessibilityActionInfo } from 'react-native';
import { setupDatabaseAndSynchronizer } from '@joplin/lib/testing/test-utils';
import Folder from '@joplin/lib/models/Folder';
import NavService from '@joplin/lib/services/NavService';
import Setting from '@joplin/lib/models/Setting';
import Note from '@joplin/lib/models/Note';
let testStore: Store<AppState>;
interface WrappedNewNoteButtonProps {}
const WrappedNewNoteButton: React.FC<WrappedNewNoteButtonProps> = () => {
return <TestProviderStack store={testStore}>
<NewNoteButton/>
</TestProviderStack>;
};
describe('NewNoteButton', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(0);
testStore = createMockReduxStore();
setupGlobalStore(testStore);
// Set an initial folder
const folder = await Folder.save({ title: 'Test folder' });
Setting.setValue('activeFolderId', folder.id);
await NavService.go('Notes', { folderId: folder.id });
});
test('should be possible to create a note using accessibility actions', async () => {
const wrapper = render(<WrappedNewNoteButton/>);
const toggleButton = screen.getByRole('button', { name: 'Add new' });
expect(toggleButton).toBeVisible();
const actions: AccessibilityActionInfo[] = toggleButton.props.accessibilityActions;
const newNoteAction = actions.find(action => action.label === 'New note');
expect(newNoteAction).toBeTruthy();
const onAction = toggleButton.props.onAccessibilityAction;
await act(() => {
return onAction({ nativeEvent: { actionName: newNoteAction.name } });
});
await waitFor(async () => {
expect(await Note.allIds()).toHaveLength(1);
});
wrapper.unmount();
});
});

View File

@@ -0,0 +1,140 @@
import * as React from 'react';
import { _ } from '@joplin/lib/locale';
import CommandService from '@joplin/lib/services/CommandService';
import { Divider } from 'react-native-paper';
import FloatingActionButton from '../../buttons/FloatingActionButton';
import { AccessibilityActionEvent, AccessibilityActionInfo, StyleSheet, View } from 'react-native';
import { AttachFileAction } from '../Note/commands/attachFile';
import LabelledIconButton from '../../buttons/LabelledIconButton';
import TextButton, { ButtonSize, ButtonType } from '../../buttons/TextButton';
import { useCallback, useMemo, useRef } from 'react';
import Logger from '@joplin/utils/Logger';
import focusView from '../../../utils/focusView';
const logger = Logger.create('NewNoteButton');
interface Props {
}
const makeNewNote = (isTodo: boolean, action?: AttachFileAction) => {
logger.debug(`New ${isTodo ? 'to-do' : 'note'} with action`, action);
const body = '';
return CommandService.instance().execute('newNote', body, isTodo, { attachFileAction: action });
};
const styles = StyleSheet.create({
buttonRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 2,
},
mainButtonRow: {
flexWrap: 'nowrap',
},
spacer: {
flexShrink: 1,
flexGrow: 0,
width: 12,
},
shortcutButton: {
flexGrow: 1,
},
mainButton: {
flexShrink: 1,
},
mainButtonLabel: {
fontSize: 16,
fontWeight: 'bold',
},
menuContent: {
gap: 12,
flexShrink: 1,
flexDirection: 'column',
},
});
const NewNoteButton: React.FC<Props> = _props => {
const newNoteRef = useRef<View|null>(null);
const renderShortcutButton = (action: AttachFileAction, icon: string, title: string) => {
return <LabelledIconButton
onPress={() => makeNewNote(false, action)}
style={styles.shortcutButton}
title={title}
accessibilityHint={_('Creates a new note with an attachment of type %s', title)}
icon={icon}
/>;
};
const menuContent = <View style={styles.menuContent}>
<View style={styles.buttonRow}>
{renderShortcutButton(AttachFileAction.AttachFile, 'material attachment', _('Attachment'))}
{renderShortcutButton(AttachFileAction.RecordAudio, 'material microphone', _('Recording'))}
{renderShortcutButton(AttachFileAction.TakePhoto, 'material camera', _('Camera'))}
{renderShortcutButton(AttachFileAction.AttachDrawing, 'material draw', _('Drawing'))}
</View>
<Divider/>
<View style={[styles.buttonRow, styles.mainButtonRow]}>
<TextButton
icon='checkbox-outline'
style={styles.mainButton}
onPress={() => {
void makeNewNote(true);
}}
type={ButtonType.Secondary}
size={ButtonSize.Larger}
>{_('New to-do')}</TextButton>
<View style={styles.spacer}/>
<TextButton
touchableRef={newNoteRef}
icon='file-document-outline'
style={styles.mainButton}
onPress={() => {
void makeNewNote(false);
}}
type={ButtonType.Primary}
size={ButtonSize.Larger}
>{_('New note')}</TextButton>
</View>
</View>;
// Android and iOS: Accessibility actions simplify creating new notes and to-dos. These
// are extra important because the "note with attachment" items are annoyingly first in
// the focus order (and it doesn't seem possible to change this without adding a new
// dependency).
const accessibilityActions = useMemo((): AccessibilityActionInfo[] => {
return [{
name: 'new-note',
label: _('New note'),
}, {
name: 'new-to-do',
label: _('New to-do'),
}];
}, []);
const onAccessibilityAction = useCallback((event: AccessibilityActionEvent) => {
if (event.nativeEvent.actionName === 'new-note') {
return makeNewNote(false);
} else if (event.nativeEvent.actionName === 'new-to-do') {
return makeNewNote(true);
}
return Promise.resolve();
}, []);
const onMenuShown = useCallback(() => {
// Note: May apply only to web:
focusView('NewNoteButton', newNoteRef.current);
}, []);
return <FloatingActionButton
mainButton={{
icon: 'add',
label: _('Add new'),
}}
menuContent={menuContent}
onMenuShow={onMenuShown}
accessibilityActions={accessibilityActions}
onAccessibilityAction={onAccessibilityAction}
/>;
};
export default NewNoteButton;

View File

@@ -2,24 +2,24 @@ import * as React from 'react';
import { AppState as RNAppState, View, StyleSheet, NativeEventSubscription, ViewStyle, TextStyle } from 'react-native';
import { stateUtils } from '@joplin/lib/reducer';
import { connect } from 'react-redux';
import NoteList from '../NoteList';
import NoteList from '../../NoteList';
import Folder from '@joplin/lib/models/Folder';
import Tag from '@joplin/lib/models/Tag';
import Note, { PreviewsOrder } from '@joplin/lib/models/Note';
import Setting from '@joplin/lib/models/Setting';
import { themeStyle } from '../global-style';
import { FolderPickerOptions, ScreenHeader } from '../ScreenHeader';
import { themeStyle } from '../../global-style';
import { FolderPickerOptions, ScreenHeader } from '../../ScreenHeader';
import { _ } from '@joplin/lib/locale';
import ActionButton from '../buttons/FloatingActionButton';
import { BaseScreenComponent } from '../base-screen';
import { AppState } from '../../utils/types';
import { BaseScreenComponent } from '../../base-screen';
import { AppState } from '../../../utils/types';
import { FolderEntity, NoteEntity, TagEntity } from '@joplin/lib/services/database/types';
import { itemIsInTrash } from '@joplin/lib/services/trash';
import AccessibleView from '../accessibility/AccessibleView';
import AccessibleView from '../../accessibility/AccessibleView';
import { Dispatch } from 'redux';
import { DialogContext, DialogControl } from '../DialogManager';
import { DialogContext, DialogControl } from '../../DialogManager';
import { useContext } from 'react';
import { MenuChoice } from '../DialogManager/types';
import { MenuChoice } from '../../DialogManager/types';
import NewNoteButton from './NewNoteButton';
interface Props {
dispatch: Dispatch;
@@ -252,27 +252,7 @@ class NotesScreenComponent extends BaseScreenComponent<ComponentProps, State> {
if ((this.props.notesParentType === 'Folder' && itemIsInTrash(parent)) || !Folder.atLeastOneRealFolderExists(this.props.folders)) return null;
if (addFolderNoteButtons && this.props.folders.length > 0) {
const buttons = [];
buttons.push({
label: _('New to-do'),
onPress: async () => {
const isTodo = true;
void this.newNoteNavigate(buttonFolderId, isTodo);
},
color: '#9b59b6',
icon: 'checkbox-outline',
});
buttons.push({
label: _('New note'),
onPress: async () => {
const isTodo = false;
void this.newNoteNavigate(buttonFolderId, isTodo);
},
color: '#9b59b6',
icon: 'document',
});
return <ActionButton buttons={buttons} dispatch={this.props.dispatch}/>;
return <NewNoteButton />;
}
return null;
};

View File

@@ -55,7 +55,7 @@ import Revision from '@joplin/lib/models/Revision';
import RevisionService from '@joplin/lib/services/RevisionService';
import JoplinDatabase from '@joplin/lib/JoplinDatabase';
import Database from '@joplin/lib/database';
import NotesScreen from './components/screens/Notes';
import NotesScreen from './components/screens/Notes/Notes';
import TagsScreen from './components/screens/tags';
import ConfigScreen from './components/screens/ConfigScreen/ConfigScreen';
const { FolderScreen } = require('./components/screens/folder.js');

View File

@@ -0,0 +1,37 @@
import { focus } from '@joplin/lib/utils/focusHandler';
import Logger from '@joplin/utils/Logger';
import { AccessibilityInfo, findNodeHandle, Platform, UIManager, View } from 'react-native';
const logger = Logger.create('focusView');
const focusView = (source: string, view: View|HTMLElement) => {
const autoFocus = () => {
if (Platform.OS === 'web') {
// react-native-web defines UIManager.focus for setting the keyboard focus. However,
// this property is not available in standard react-native. As such, access it using type
// narrowing:
// eslint-disable-next-line no-restricted-properties
if (!('focus' in UIManager) || typeof UIManager.focus !== 'function') {
throw new Error('Failed to focus sidebar. UIManager.focus is not a function.');
}
// Disable the "use focusHandler for all focus calls" rule -- UIManager.focus requires
// an argument, which is not supported by focusHandler.
// eslint-disable-next-line no-restricted-properties
UIManager.focus(view);
} else {
const handle = findNodeHandle(view as View);
if (handle !== null) {
AccessibilityInfo.setAccessibilityFocus(handle);
} else {
logger.warn('Couldn\'t find a view to focus.');
}
}
};
focus(`focusView:${source}`, {
focus: autoFocus,
});
};
export default focusView;

View File

@@ -0,0 +1,24 @@
import { useMemo } from 'react';
import { useWindowDimensions } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
const useSafeAreaPadding = () => {
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const safeAreaInsets = useSafeAreaInsets();
const isLandscape = windowWidth > windowHeight;
return useMemo(() => {
return isLandscape ? {
paddingRight: safeAreaInsets.right,
paddingLeft: safeAreaInsets.left,
paddingTop: 15,
paddingBottom: 15,
} : {
paddingTop: safeAreaInsets.top,
paddingBottom: safeAreaInsets.bottom,
paddingLeft: 0,
paddingRight: 0,
};
}, [isLandscape, safeAreaInsets]);
};
export default useSafeAreaPadding;

View File

@@ -27,6 +27,7 @@ const input: Theme = {
colorError2: '#ff6c6c',
colorWarn2: '#ffcb81',
colorWarn3: '#ff7626',
backgroundColorTransparent2: 'rgba(0, 0, 0, 0.1)',
// Color scheme "3" is used for the config screens for example/
// It's dark text over gray background.
@@ -71,6 +72,7 @@ const expected = `
--joplin-background-color4: #ffffff;
--joplin-background-color-hover3: #CBDAF1;
--joplin-background-color-transparent: rgba(255,255,255,0.9);
--joplin-background-color-transparent2: rgba(0, 0, 0, 0.1);
--joplin-block-quote-opacity: 0.7;
--joplin-code-background-color: rgb(243, 243, 243);
--joplin-code-border-color: rgb(220, 220, 220);

View File

@@ -27,6 +27,7 @@ const theme: Theme = {
colorError2: '#ff7070',
colorWarn2: '#ffcb81',
colorWarn3: '#ff7626',
backgroundColorTransparent2: 'rgba(0, 0, 0, 0.1)',
// Color scheme "3" is used for the config screens for example/
// It's dark text over gray background.

View File

@@ -24,6 +24,7 @@ export interface Theme {
// Color scheme "2" is used for the sidebar. It's white text over
// dark blue background.
backgroundColor2: string;
backgroundColorTransparent2: string; // Used for dimmed region outside modals
color2: string;
selectedColor2: string;
colorError2: string;