feat: Initial clean release of CHM
一些检查失败了
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
一些检查失败了
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
这个提交包含在:
@@ -0,0 +1,81 @@
|
||||
import * as React from 'react';
|
||||
import { Card, TouchableRipple } from 'react-native-paper';
|
||||
import { useMemo } from 'react';
|
||||
import { Platform, StyleSheet, View, ViewStyle } from 'react-native';
|
||||
|
||||
export enum InstallState {
|
||||
NotInstalled,
|
||||
Installing,
|
||||
Installed,
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onPress: ()=> void;
|
||||
disabled?: boolean;
|
||||
children: React.ReactNode;
|
||||
style?: ViewStyle;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
const useStyles = (disabled: boolean) => {
|
||||
return useMemo(() => {
|
||||
// For the TouchableRipple to work on Android, the card needs a transparent background.
|
||||
const borderRadius = 12;
|
||||
const baseCard = { backgroundColor: 'transparent', borderRadius };
|
||||
return StyleSheet.create({
|
||||
cardOuterWrapper: {
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
borderRadius,
|
||||
overflow: 'hidden',
|
||||
// Accessibility: Prevent the 'overflow: hidden' from hiding the focus indicator
|
||||
// on web. Only apply to web, as this causes the touchable ripple
|
||||
// from being completely contained within the card on non-web platforms.
|
||||
...(Platform.OS === 'web' ? {
|
||||
margin: -2,
|
||||
padding: 2,
|
||||
} : {}),
|
||||
},
|
||||
cardInnerWrapper: {
|
||||
width: '100%',
|
||||
borderRadius,
|
||||
},
|
||||
card: disabled ? {
|
||||
...baseCard,
|
||||
opacity: 0.7,
|
||||
} : baseCard,
|
||||
content: {
|
||||
gap: 5,
|
||||
},
|
||||
});
|
||||
}, [disabled]);
|
||||
};
|
||||
|
||||
|
||||
const CardButton: React.FC<Props> = props => {
|
||||
const containerIsButton = !!props.onPress;
|
||||
const styles = useStyles(props.disabled);
|
||||
|
||||
const CardWrapper = containerIsButton ? TouchableRipple : View;
|
||||
return (
|
||||
<View style={[styles.cardOuterWrapper, props.style]}>
|
||||
<CardWrapper
|
||||
accessibilityRole={containerIsButton ? 'button' : null}
|
||||
accessible={containerIsButton}
|
||||
onPress={props.onPress}
|
||||
disabled={props.disabled}
|
||||
style={styles.cardInnerWrapper}
|
||||
testID={props.testID}
|
||||
>
|
||||
<Card
|
||||
mode='outlined'
|
||||
style={styles.card}
|
||||
>
|
||||
{props.children}
|
||||
</Card>
|
||||
</CardWrapper>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default CardButton;
|
||||
@@ -0,0 +1,164 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useCallback, useMemo, useRef, RefObject, useLayoutEffect } from 'react';
|
||||
import { FAB } from 'react-native-paper';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { Dispatch } from 'redux';
|
||||
import { AccessibilityActionEvent, AccessibilityActionInfo, StyleSheet, useWindowDimensions, View } from 'react-native';
|
||||
import { connect } from 'react-redux';
|
||||
import { MenuAlignment, MenuType } from '../BottomDrawer';
|
||||
import { Ionicons as Icon } from '@react-native-vector-icons/ionicons';
|
||||
import BottomDrawerMenu, { MenuOption } from '../BottomDrawerMenu';
|
||||
import { AppState } from '../../utils/types';
|
||||
import { themeStyle } from '../global-style';
|
||||
|
||||
type OnButtonPress = ()=> void;
|
||||
interface ButtonSpec {
|
||||
icon: string;
|
||||
label: string;
|
||||
color?: string;
|
||||
onPress?: OnButtonPress;
|
||||
}
|
||||
|
||||
interface ActionButtonProps {
|
||||
themeId: number;
|
||||
|
||||
// If not given, an "add" button will be used.
|
||||
mainButton: ButtonSpec;
|
||||
dispatch: Dispatch;
|
||||
|
||||
menuContent?: MenuOption[];
|
||||
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) => {
|
||||
type NameProp = React.ComponentProps<typeof Icon>['name'];
|
||||
return (props: Omit<React.ComponentProps<typeof Icon>, 'name'>) => <Icon name={iconName as NameProp} {...props} />;
|
||||
};
|
||||
|
||||
const useIcon = (iconName: string) => {
|
||||
return useMemo(() => {
|
||||
return getIconRenderFunction(iconName);
|
||||
}, [iconName]);
|
||||
};
|
||||
|
||||
const useMenuMarginBottom = (buttonContainerRef: RefObject<View>, themeId: number) => {
|
||||
const { height: windowHeight } = useWindowDimensions();
|
||||
const [menuMarginBottom, setMenuMarginBottom] = useState(0);
|
||||
|
||||
const recomputeMargin = useCallback(() => {
|
||||
const theme = themeStyle(themeId);
|
||||
|
||||
buttonContainerRef.current?.measure((_x, _y, _width, _height, _pageX, pageY) => {
|
||||
const extraMargin = theme.marginBottom;
|
||||
setMenuMarginBottom(windowHeight - pageY + extraMargin);
|
||||
});
|
||||
}, [windowHeight, buttonContainerRef, themeId]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
recomputeMargin();
|
||||
}, [recomputeMargin]);
|
||||
|
||||
const recomputeMarginRef = useRef(recomputeMargin);
|
||||
recomputeMarginRef.current = recomputeMargin;
|
||||
|
||||
const onMenuContainerLayout = useCallback(() => {
|
||||
recomputeMarginRef.current();
|
||||
}, []);
|
||||
|
||||
return { onMenuContainerLayout, menuMarginBottom };
|
||||
};
|
||||
|
||||
|
||||
interface StylesProps {
|
||||
menuMarginBottom: number;
|
||||
}
|
||||
|
||||
const useStyles = ({ menuMarginBottom }: StylesProps) => {
|
||||
return useMemo(() => {
|
||||
return StyleSheet.create({
|
||||
menu: {
|
||||
marginBottom: menuMarginBottom,
|
||||
// Always float right:
|
||||
alignSelf: 'flex-end',
|
||||
},
|
||||
buttonContainer: {
|
||||
position: 'absolute',
|
||||
bottom: 10,
|
||||
right: 10,
|
||||
},
|
||||
button: {
|
||||
alignSelf: 'flex-end',
|
||||
},
|
||||
});
|
||||
}, [menuMarginBottom]);
|
||||
};
|
||||
|
||||
const FloatingActionButton = (props: ActionButtonProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const onMenuToggled = useCallback(() => {
|
||||
const newOpen = !open;
|
||||
if (newOpen) {
|
||||
props.dispatch({
|
||||
type: 'SIDE_MENU_CLOSE',
|
||||
});
|
||||
}
|
||||
setOpen(newOpen);
|
||||
}, [setOpen, open, props.dispatch]);
|
||||
|
||||
const onDismiss = useCallback(() => {
|
||||
if (open) onMenuToggled();
|
||||
}, [open, onMenuToggled]);
|
||||
|
||||
const mainButtonRef = useRef<View>(null);
|
||||
|
||||
const closedIcon = useIcon(props.mainButton?.icon ?? 'add');
|
||||
const openIcon = useIcon('close');
|
||||
|
||||
const label = props.mainButton?.label ?? _('Add new');
|
||||
|
||||
const buttonContainerRef = useRef<View|null>(null);
|
||||
|
||||
const { menuMarginBottom, onMenuContainerLayout } = useMenuMarginBottom(buttonContainerRef, props.themeId);
|
||||
const styles = useStyles({ menuMarginBottom });
|
||||
|
||||
const menuButton = <FAB
|
||||
ref={mainButtonRef}
|
||||
icon={open ? openIcon : closedIcon}
|
||||
accessibilityLabel={label}
|
||||
onPress={props.mainButton?.onPress ?? onMenuToggled}
|
||||
style={styles.button}
|
||||
accessibilityActions={props.accessibilityActions}
|
||||
onAccessibilityAction={props.onAccessibilityAction}
|
||||
/>;
|
||||
|
||||
return <>
|
||||
<View
|
||||
style={styles.buttonContainer}
|
||||
ref={buttonContainerRef}
|
||||
onLayout={onMenuContainerLayout}
|
||||
>
|
||||
{menuButton}
|
||||
</View>
|
||||
{ props.menuContent && <BottomDrawerMenu
|
||||
visible={open}
|
||||
onDismiss={onDismiss}
|
||||
alignment={MenuAlignment.Right}
|
||||
style={styles.menu}
|
||||
menuType={MenuType.Floating}
|
||||
themeId={props.themeId}
|
||||
options={props.menuContent}
|
||||
autoScrollToEnd
|
||||
/> }
|
||||
</>;
|
||||
};
|
||||
|
||||
const ConnectedComponent: React.FC<Omit<ActionButtonProps, 'themeId'|'dispatch'>> = (
|
||||
connect((state: AppState) => ({ themeId: state.settings.theme }))(FloatingActionButton)
|
||||
);
|
||||
export default ConnectedComponent;
|
||||
@@ -0,0 +1,75 @@
|
||||
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,
|
||||
},
|
||||
buttonText: {
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
}, [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'
|
||||
ellipsizeMode='tail'
|
||||
numberOfLines={2}
|
||||
style={styles.buttonText}
|
||||
>{title}</Text>
|
||||
</View>
|
||||
</TouchableRipple>;
|
||||
};
|
||||
|
||||
export default connect((state: AppState) => {
|
||||
return { themeId: state.settings.theme };
|
||||
})(LabelledIconButton);
|
||||
@@ -0,0 +1,66 @@
|
||||
import * as React from 'react';
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { Animated, StyleSheet, Pressable, ViewProps, PressableProps } from 'react-native';
|
||||
|
||||
interface Props extends PressableProps {
|
||||
// Nodes that need to change opacity but shouldn't be included in the main touchable
|
||||
beforePressable: React.ReactNode;
|
||||
// Children of the main pressable
|
||||
children: React.ReactNode;
|
||||
onPress: ()=> void;
|
||||
|
||||
containerProps?: ViewProps;
|
||||
}
|
||||
|
||||
// A TouchableOpacity that can contain multiple pressable items still within the region that
|
||||
// changes opacity
|
||||
const MultiTouchableOpacity: React.FC<Props> = ({ beforePressable, children, onPress, containerProps = {}, ...pressableProps }) => {
|
||||
// See https://blog.logrocket.com/react-native-touchable-vs-pressable-components/
|
||||
// for more about animating Pressable buttons.
|
||||
const fadeAnim = useRef(new Animated.Value(1)).current;
|
||||
|
||||
const animationDuration = 100; // ms
|
||||
const onPressIn = useCallback(() => {
|
||||
// Fade out.
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 0.5,
|
||||
duration: animationDuration,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}, [fadeAnim]);
|
||||
const onPressOut = useCallback(() => {
|
||||
// Fade in.
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration: animationDuration,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}, [fadeAnim]);
|
||||
|
||||
const button = (
|
||||
<Pressable
|
||||
accessibilityRole='button'
|
||||
{...pressableProps}
|
||||
onPress={onPress}
|
||||
onPressIn={onPressIn}
|
||||
onPressOut={onPressOut}
|
||||
>
|
||||
{children}
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
const styles = useMemo(() => {
|
||||
return StyleSheet.create({
|
||||
container: { opacity: fadeAnim },
|
||||
});
|
||||
}, [fadeAnim]);
|
||||
|
||||
return (
|
||||
<Animated.View {...containerProps} style={[styles.container, containerProps.style]}>
|
||||
{beforePressable}
|
||||
{button}
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
export default MultiTouchableOpacity;
|
||||
@@ -0,0 +1,113 @@
|
||||
import * as React from 'react';
|
||||
import { ReactNode, useMemo } from 'react';
|
||||
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,
|
||||
Secondary,
|
||||
Delete,
|
||||
Link,
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export type TextButtonProps = Omit<Props, 'themeId'>;
|
||||
|
||||
const useStyles = ({ themeId }: Props) => {
|
||||
return useMemo(() => {
|
||||
const theme = themeStyle(themeId);
|
||||
|
||||
const themeOverride = {
|
||||
secondaryButton: {
|
||||
colors: {
|
||||
primary: theme.color4,
|
||||
outline: theme.color4,
|
||||
},
|
||||
},
|
||||
deleteButton: {
|
||||
colors: {
|
||||
primary: theme.destructiveColor,
|
||||
outline: theme.destructiveColor,
|
||||
},
|
||||
},
|
||||
primaryButton: { },
|
||||
};
|
||||
|
||||
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, styles } = useStyles(props);
|
||||
|
||||
let mode: ButtonProps['mode'];
|
||||
let theme: ButtonProps['theme'];
|
||||
|
||||
if (props.type === ButtonType.Primary) {
|
||||
theme = themeOverride.primaryButton;
|
||||
mode = 'contained';
|
||||
} else if (props.type === ButtonType.Secondary) {
|
||||
theme = themeOverride.secondaryButton;
|
||||
mode = 'outlined';
|
||||
} else if (props.type === ButtonType.Delete) {
|
||||
theme = themeOverride.deleteButton;
|
||||
mode = 'outlined';
|
||||
} else if (props.type === ButtonType.Link) {
|
||||
theme = themeOverride.secondaryButton;
|
||||
mode = 'text';
|
||||
} else {
|
||||
const exhaustivenessCheck: never = props.type;
|
||||
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}
|
||||
>{props.children}</Button>;
|
||||
};
|
||||
|
||||
export default connect((state: AppState) => {
|
||||
return { themeId: state.settings.theme };
|
||||
})(TextButton);
|
||||
@@ -0,0 +1,15 @@
|
||||
import * as React from 'react';
|
||||
import TextButton, { ButtonType, TextButtonProps } from './TextButton';
|
||||
|
||||
type Props = Omit<TextButtonProps, 'type'>;
|
||||
|
||||
const makeTextButtonComponent = (type: ButtonType) => {
|
||||
return (props: Props) => {
|
||||
return <TextButton {...props} type={type} />;
|
||||
};
|
||||
};
|
||||
|
||||
export const PrimaryButton = makeTextButtonComponent(ButtonType.Primary);
|
||||
export const SecondaryButton = makeTextButtonComponent(ButtonType.Secondary);
|
||||
export const LinkButton = makeTextButtonComponent(ButtonType.Link);
|
||||
export const DeleteButton = makeTextButtonComponent(ButtonType.Delete);
|
||||
在新工单中引用
屏蔽一个用户