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

这个提交包含在:
chen
2026-11-04 11:29:39 +08:00
当前提交 301194963b
共有 8663 个文件被更改,包括 1962691 次插入 和 0 次删除

查看文件

@@ -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);