一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
526 行
16 KiB
TypeScript
526 行
16 KiB
TypeScript
import * as React from 'react';
|
|
import { connect } from 'react-redux';
|
|
import { RefObject, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
import { Animated, Dimensions, Easing, GestureResponderEvent, LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, PanResponder, PanResponderGestureState, Platform, Pressable, ScrollView, StyleProp, StyleSheet, useWindowDimensions, View, ViewStyle } from 'react-native';
|
|
import useSafeAreaPadding from '../utils/hooks/useSafeAreaPadding';
|
|
import { themeStyle, ThemeStyle } from './global-style';
|
|
import Modal from './Modal';
|
|
import { AppState } from '../utils/types';
|
|
import useReduceMotionEnabled from '../utils/hooks/useReduceMotionEnabled';
|
|
import { _ } from '@joplin/lib/locale';
|
|
|
|
export enum MenuAlignment {
|
|
Center,
|
|
Right,
|
|
}
|
|
|
|
export enum MenuType {
|
|
Docked,
|
|
Floating,
|
|
}
|
|
|
|
interface Props {
|
|
themeId: number;
|
|
menuType?: MenuType;
|
|
style?: StyleProp<ViewStyle>;
|
|
contentStyle?: StyleProp<ViewStyle>;
|
|
alignment: MenuAlignment;
|
|
children: React.ReactNode;
|
|
visible: boolean;
|
|
draggable: boolean;
|
|
autoScrollToEnd?: boolean;
|
|
onDismiss: ()=> void;
|
|
onDismissed?: ()=> void;
|
|
onShow?: ()=> void;
|
|
}
|
|
|
|
interface UseStylesProps {
|
|
alignment: MenuAlignment;
|
|
menuType: MenuType;
|
|
theme: ThemeStyle;
|
|
dragging: boolean;
|
|
draggable: boolean;
|
|
backgroundOpacity: Animated.AnimatedInterpolation<number>;
|
|
dragOffset: Animated.AnimatedInterpolation<number>;
|
|
}
|
|
|
|
const useStyles = ({ theme, menuType, dragging, alignment, draggable, dragOffset, backgroundOpacity }: UseStylesProps) => {
|
|
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
|
const safeAreaPadding = useSafeAreaPadding();
|
|
|
|
const menuMarginTop = theme.margin + safeAreaPadding.paddingTop;
|
|
const showDragHandle = draggable && menuType === MenuType.Docked;
|
|
|
|
return useMemo(() => {
|
|
const isSmallWidthScreen = windowWidth < 500;
|
|
const menuGapLeft = safeAreaPadding.paddingLeft + 6;
|
|
const menuGapRight = safeAreaPadding.paddingRight + 6;
|
|
|
|
// On web, any spaceBelowScreenEdge results in a scrollbar and extra scroll.
|
|
const spaceBelowScreenEdge = Platform.OS === 'web' ? 0 : windowHeight;
|
|
|
|
return StyleSheet.create({
|
|
backgroundStyle: {
|
|
backgroundColor: theme.backgroundColorTransparent2,
|
|
opacity: backgroundOpacity.interpolate({
|
|
inputRange: [0, 1],
|
|
outputRange: [0, 1],
|
|
extrapolate: 'clamp',
|
|
}),
|
|
position: 'absolute',
|
|
left: 0,
|
|
right: 0,
|
|
// Add additional space to prevent the edge of the background from being visible
|
|
// during overscroll on iOS
|
|
top: -spaceBelowScreenEdge,
|
|
bottom: -spaceBelowScreenEdge,
|
|
|
|
zIndex: 0,
|
|
},
|
|
menuStyle: {
|
|
zIndex: 1,
|
|
alignSelf: alignment === MenuAlignment.Center ? 'center' : 'flex-end',
|
|
...(isSmallWidthScreen ? {
|
|
// Center on small screens, rather than float right.
|
|
alignSelf: 'center',
|
|
} : {}),
|
|
flexDirection: 'row',
|
|
marginRight: menuGapRight,
|
|
marginLeft: menuGapLeft,
|
|
|
|
backgroundColor: theme.backgroundColor,
|
|
borderRadius: 16,
|
|
...(menuType === MenuType.Docked ? {
|
|
borderBottomRightRadius: 0,
|
|
borderBottomLeftRadius: 0,
|
|
marginBottom: -spaceBelowScreenEdge,
|
|
} : {
|
|
marginBottom: safeAreaPadding.paddingBottom,
|
|
}),
|
|
maxWidth: Math.min(
|
|
menuType === MenuType.Floating ? 250 : 400,
|
|
windowWidth - menuGapRight - menuGapLeft,
|
|
),
|
|
|
|
shadowRadius: 4,
|
|
shadowColor: theme.shadowColorOpaque,
|
|
shadowOpacity: theme.shadowOpacity,
|
|
elevation: 2,
|
|
|
|
|
|
userSelect: dragging ? 'none' : 'auto',
|
|
transform: [
|
|
{
|
|
translateY: dragOffset.interpolate({
|
|
inputRange: [-spaceBelowScreenEdge, 1],
|
|
outputRange: [-spaceBelowScreenEdge, 1],
|
|
// Avoid shifting the menu up when there's no extra space below the menu
|
|
extrapolateLeft: 'clamp',
|
|
extrapolateRight: 'extend',
|
|
}),
|
|
},
|
|
{ perspective: 1000 },
|
|
],
|
|
},
|
|
contentContainer: {
|
|
flexDirection: 'row',
|
|
flexWrap: 'wrap',
|
|
flexShrink: 1,
|
|
flexGrow: 1,
|
|
|
|
padding: 20,
|
|
|
|
...(menuType === MenuType.Docked ? {
|
|
paddingBottom: 14 + safeAreaPadding.paddingBottom,
|
|
// The drag handle should be at the very top of the menu
|
|
paddingTop: showDragHandle ? 0 : undefined,
|
|
marginBottom: spaceBelowScreenEdge,
|
|
} : {
|
|
paddingTop: theme.marginSmall,
|
|
paddingBottom: theme.marginSmall,
|
|
}),
|
|
},
|
|
modalBackground: {
|
|
paddingLeft: 0,
|
|
paddingRight: 0,
|
|
paddingBottom: 0,
|
|
paddingTop: menuMarginTop,
|
|
justifyContent: 'flex-end',
|
|
flexDirection: 'column',
|
|
},
|
|
dismissButton: {
|
|
top: 0,
|
|
bottom: undefined,
|
|
height: theme.marginMedium,
|
|
},
|
|
|
|
dragHandleContainer: {
|
|
display: showDragHandle ? 'flex' : 'none',
|
|
width: '100%',
|
|
height: theme.margin,
|
|
cursor: 'auto',
|
|
},
|
|
dragHandle: {
|
|
marginLeft: 'auto',
|
|
marginRight: 'auto',
|
|
backgroundColor: theme.dividerColor,
|
|
width: '100%',
|
|
|
|
marginVertical: theme.marginSmall,
|
|
|
|
maxWidth: 88,
|
|
height: 5,
|
|
borderRadius: theme.borderRadius,
|
|
},
|
|
|
|
// An invisible overlay, prevents drags from clicking buttons on web
|
|
dragOverlay: {
|
|
position: 'absolute',
|
|
top: 0,
|
|
bottom: 0,
|
|
left: 0,
|
|
right: 0,
|
|
zIndex: 2,
|
|
},
|
|
});
|
|
}, [theme, menuType, safeAreaPadding, showDragHandle, windowWidth, dragging, alignment, dragOffset, windowHeight, backgroundOpacity, menuMarginTop]);
|
|
};
|
|
|
|
interface UsePanResponderProps {
|
|
visible: boolean;
|
|
animating: boolean;
|
|
setDragging: (dragging: boolean)=> void;
|
|
onDragEnd: (dx: number, dy: number)=> void;
|
|
dragValue: Animated.Value;
|
|
dragHandleRef: RefObject<View|null>;
|
|
}
|
|
|
|
const usePanResponder = ({
|
|
visible,
|
|
animating,
|
|
setDragging,
|
|
onDragEnd,
|
|
dragValue,
|
|
dragHandleRef,
|
|
}: UsePanResponderProps) => {
|
|
const isScrolledToTopRef = useRef(true);
|
|
const onScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
isScrolledToTopRef.current = event.nativeEvent.contentOffset.y < 5;
|
|
}, []);
|
|
|
|
const dragHandleTop = useRef(0);
|
|
const dragHandleBottom = useRef(0);
|
|
|
|
const windowSize = useWindowDimensions();
|
|
// windowSizeKey forces re-measures on window size changes:
|
|
const windowSizeKey = `${windowSize.width}x${windowSize.height}`;
|
|
useLayoutEffect(() => {
|
|
if (!visible || animating || !windowSizeKey) return;
|
|
|
|
dragHandleRef.current?.measure((_x, _y, _width, height, _pageX, pageY) => {
|
|
dragHandleTop.current = pageY;
|
|
dragHandleBottom.current = pageY + height;
|
|
});
|
|
}, [visible, animating, dragHandleRef, windowSizeKey]);
|
|
|
|
const panResponder = useMemo(() => {
|
|
const isInDragHandle = (eventY: number) => {
|
|
return eventY >= dragHandleTop.current && eventY <= dragHandleBottom.current;
|
|
};
|
|
|
|
// Don't use panResponderCapture on web to prevent buttons from incorrectly being pressed
|
|
const onMoveEvent = Platform.OS === 'android' ? 'onMoveShouldSetPanResponderCapture' as const : 'onMoveShouldSetPanResponder' as const;
|
|
const onStartEvent = Platform.OS === 'android' ? 'onStartShouldSetPanResponderCapture' as const : 'onStartShouldSetPanResponder' as const;
|
|
return PanResponder.create({
|
|
// Check onStart and onMove: On Android, starting drag onMove is unreliable when the menu has scroll
|
|
[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;
|
|
}
|
|
|
|
if (isInDragHandle(gestureState.moveY)) return true;
|
|
|
|
// Use a large tolerance so that buttons in the menu are still clickable, even
|
|
// with a noisy input source:
|
|
const tolerance = 22;
|
|
return Math.abs(gestureState.dx) < 40 && gestureState.dy >= tolerance;
|
|
},
|
|
onPanResponderGrant: () => {
|
|
setDragging(true);
|
|
},
|
|
onPanResponderTerminate: () => {
|
|
setDragging(false);
|
|
},
|
|
onPanResponderMove: Animated.event([
|
|
null,
|
|
// Updates menuDragOffset with the .dy property of the second argument:
|
|
{ dy: dragValue },
|
|
], { useNativeDriver: false }),
|
|
onPanResponderEnd: (_event, gestureState) => {
|
|
onDragEnd(gestureState.dx, gestureState.dy);
|
|
setDragging(false);
|
|
},
|
|
});
|
|
}, [dragValue, onDragEnd, setDragging]);
|
|
|
|
return { panResponder, onScroll };
|
|
};
|
|
|
|
interface UseSyncVisibleProps {
|
|
visible: boolean;
|
|
slideAnimation: boolean;
|
|
menuDragOffset: Animated.Value;
|
|
onDismiss: ()=> void;
|
|
containerRef: RefObject<View|null>;
|
|
scrollViewRef: RefObject<ScrollView|null>;
|
|
autoScrollToEnd: boolean;
|
|
}
|
|
|
|
const useUpdateOnVisibilityChange = (props: UseSyncVisibleProps) => {
|
|
const propsRef = useRef(props);
|
|
propsRef.current = props;
|
|
|
|
const screenSize = useWindowDimensions();
|
|
const screenSizeRef = useRef(screenSize);
|
|
screenSizeRef.current = screenSize;
|
|
|
|
const [animating, setAnimating] = useState(false);
|
|
const currentAnimation = useRef<Animated.CompositeAnimation|null>(null);
|
|
const dragToOffset = useCallback(async (offset: number, animate: boolean) => {
|
|
currentAnimation.current?.stop();
|
|
|
|
const baseAnimationProps = {
|
|
toValue: offset,
|
|
easing: Easing.elastic(0.5),
|
|
duration: animate ? 250 : 0,
|
|
useNativeDriver: true,
|
|
};
|
|
const animation = Animated.timing(propsRef.current.menuDragOffset, baseAnimationProps);
|
|
currentAnimation.current = animation;
|
|
|
|
setAnimating(true);
|
|
return new Promise<void>(resolve => {
|
|
animation.start(() => {
|
|
const interrupted = currentAnimation.current !== animation;
|
|
if (!interrupted) {
|
|
setAnimating(false);
|
|
currentAnimation.current = null;
|
|
}
|
|
|
|
resolve();
|
|
});
|
|
});
|
|
}, []);
|
|
|
|
const slideMenuOut = useCallback(async () => {
|
|
const getMenuDismissedOffset = () => new Promise<number>((resolve) => {
|
|
const container = propsRef.current.containerRef.current;
|
|
if (!container) {
|
|
// If the container isn't mounted use the screen height as an offset for fully-dismissed:
|
|
resolve(screenSizeRef.current.height);
|
|
return;
|
|
}
|
|
|
|
container.measure(async (_x, _y, _width, height, _pageX, pageY) => {
|
|
const menuBottom = screenSizeRef.current.height - (pageY + height);
|
|
resolve(height + menuBottom);
|
|
});
|
|
});
|
|
return await dragToOffset(await getMenuDismissedOffset(), propsRef.current.slideAnimation);
|
|
}, [dragToOffset]);
|
|
|
|
useEffect(() => {
|
|
const slideMenuIn = () => dragToOffset(0, propsRef.current.slideAnimation);
|
|
|
|
if (props.visible) {
|
|
if (propsRef.current.autoScrollToEnd) {
|
|
props.scrollViewRef.current?.scrollToEnd({ animated: false });
|
|
}
|
|
|
|
void slideMenuIn();
|
|
}
|
|
}, [props.visible, props.scrollViewRef, dragToOffset]);
|
|
|
|
const isDismissingRef = useRef(false);
|
|
|
|
const resetDrag = useCallback(() => {
|
|
void dragToOffset(0, true);
|
|
}, [dragToOffset]);
|
|
|
|
const onHide = useCallback(async () => {
|
|
// Avoid duplicate dismiss animations
|
|
if (isDismissingRef.current) return;
|
|
try {
|
|
isDismissingRef.current = true;
|
|
if (propsRef.current.slideAnimation) {
|
|
await slideMenuOut();
|
|
}
|
|
|
|
propsRef.current.onDismiss();
|
|
} finally {
|
|
isDismissingRef.current = false;
|
|
}
|
|
}, [slideMenuOut]);
|
|
|
|
return { onHide, resetDrag, animating };
|
|
};
|
|
|
|
const BottomDrawer: React.FC<Props> = props => {
|
|
const menuType = props.menuType ?? MenuType.Docked;
|
|
const theme = themeStyle(props.themeId);
|
|
const [dragging, setDragging] = useState(false);
|
|
|
|
const [menuHeight, setMenuHeight] = useState(0);
|
|
const menuHeightRef = useRef(0);
|
|
menuHeightRef.current = menuHeight;
|
|
|
|
const menuDragOffset = useMemo(() => (
|
|
// Start with the menu offscreen so that the slide-in animation works.
|
|
// Using the window height as an initial offset ensures that the menu starts completely
|
|
// offscreen.
|
|
new Animated.Value(Dimensions.get('window').height)
|
|
), []);
|
|
|
|
const onContainerLayout = useCallback((layout: LayoutChangeEvent) => {
|
|
setMenuHeight(layout.nativeEvent.layout.height);
|
|
}, []);
|
|
const backgroundOpacity = useMemo(() => {
|
|
return Animated.divide(
|
|
Animated.add(Animated.multiply(menuDragOffset, -1), menuHeight), Math.max(menuHeight, 1),
|
|
);
|
|
}, [menuHeight, menuDragOffset]);
|
|
|
|
const menuYOffset = useMemo(() => menuDragOffset, [menuDragOffset]);
|
|
const styles = useStyles({
|
|
theme, menuType: menuType, dragging, alignment: props.alignment, draggable: props.draggable, dragOffset: menuYOffset, backgroundOpacity,
|
|
});
|
|
|
|
const reduceMotion = useReduceMotionEnabled();
|
|
const slideAnimationEnabled = !reduceMotion && menuType === MenuType.Docked;
|
|
const nativeFadeAnimation = !slideAnimationEnabled;
|
|
const slideAnimationEnabledRef = useRef(false);
|
|
slideAnimationEnabledRef.current = slideAnimationEnabled;
|
|
|
|
const containerRef = useRef<View|null>(null);
|
|
const scrollViewRef = useRef<ScrollView|null>(null);
|
|
const { onHide, resetDrag, animating } = useUpdateOnVisibilityChange({
|
|
visible: props.visible, slideAnimation: slideAnimationEnabled, menuDragOffset, containerRef, onDismiss: props.onDismiss, scrollViewRef, autoScrollToEnd: props.autoScrollToEnd,
|
|
});
|
|
|
|
const onDragEnd = useCallback((_dx: number, dy: number) => {
|
|
if (dy > 50) {
|
|
void onHide();
|
|
} else {
|
|
resetDrag();
|
|
}
|
|
}, [resetDrag, onHide]);
|
|
|
|
const dragHandleRef = useRef<View|null>(null);
|
|
const { panResponder, onScroll: onPanResponderScroll } = usePanResponder({
|
|
visible: props.visible,
|
|
animating,
|
|
setDragging,
|
|
onDragEnd,
|
|
dragValue: menuDragOffset,
|
|
dragHandleRef,
|
|
});
|
|
|
|
const onScrollDragEnd = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
const offsetY = event.nativeEvent.contentOffset.y;
|
|
// Use a smaller tolerance for smaller menus to ensure that they're still dismissible
|
|
const overscrollTolerance = Math.min(80, menuHeightRef.current / 4);
|
|
|
|
// On platforms that support overscroll (iOS), support menu dismissal through the
|
|
// native scrollview's overscroll behavior:
|
|
if (offsetY < -overscrollTolerance) {
|
|
// Start the animation at the current scroll position, to avoid a jump when starting
|
|
// the animation:
|
|
menuDragOffset.setValue(-offsetY);
|
|
void onHide();
|
|
}
|
|
}, [onHide, menuDragOffset]);
|
|
|
|
return <Modal
|
|
visible={props.visible}
|
|
onClose={onHide}
|
|
onDismissed={props.onDismissed}
|
|
onShow={props.onShow}
|
|
backgroundColor='transparent'
|
|
modalBackgroundStyle={styles.modalBackground}
|
|
dismissButtonStyle={styles.dismissButton}
|
|
wrapContent={view => {
|
|
return <>
|
|
<Animated.View style={styles.backgroundStyle}/>
|
|
{view}
|
|
</>;
|
|
}}
|
|
containerStyle={[styles.menuStyle, props.style]}
|
|
animationType={nativeFadeAnimation ? 'fade' : 'none'}
|
|
scrollOverflow={{
|
|
onScroll: onPanResponderScroll,
|
|
onScrollEndDrag: onScrollDragEnd,
|
|
|
|
// Throttling scroll events avoids a warning on web
|
|
scrollEventThrottle: 30,
|
|
|
|
// Disable scrollbars during in/out animations on web to avoid layout shift
|
|
scrollEnabled: !animating,
|
|
ref: scrollViewRef,
|
|
}}
|
|
>
|
|
<View
|
|
{...panResponder.panHandlers}
|
|
onLayout={onContainerLayout}
|
|
style={[styles.contentContainer, props.contentStyle]}
|
|
ref={containerRef}
|
|
>
|
|
{dragging && <View style={styles.dragOverlay} />}
|
|
<DragHandle
|
|
containerRef={dragHandleRef}
|
|
containerStyle={styles.dragHandleContainer}
|
|
style={styles.dragHandle}
|
|
onDismiss={onHide}
|
|
/>
|
|
{props.children}
|
|
</View>
|
|
</Modal>;
|
|
};
|
|
|
|
const BottomDrawerComponent: React.FC<Omit<Props, 'themeId'>> = connect((state: AppState) => {
|
|
return {
|
|
themeId: state.settings.theme,
|
|
};
|
|
})(BottomDrawer);
|
|
export default BottomDrawerComponent;
|
|
|
|
|
|
interface DragHandleProps {
|
|
style: ViewStyle;
|
|
containerRef: RefObject<View|null>;
|
|
containerStyle: ViewStyle;
|
|
|
|
onDismiss: ()=> void;
|
|
}
|
|
|
|
const DragHandle: React.FC<DragHandleProps> = props => {
|
|
return <Pressable
|
|
onPress={props.onDismiss}
|
|
aria-label={_('Dismiss')}
|
|
style={props.containerStyle}
|
|
ref={props.containerRef}
|
|
>
|
|
<View style={props.style}/>
|
|
</Pressable>;
|
|
};
|
|
|