1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-21 09:38:01 +02:00

Chore: Mobile: Refactor markdown toolbar (#9708)

This commit is contained in:
Henry Heino 2024-01-18 03:22:20 -08:00 committed by GitHub
parent bc1165be46
commit 4636d1539c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 454 additions and 334 deletions

View File

@ -485,10 +485,15 @@ packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleSpaceButton.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/Toolbar.js packages/app-mobile/components/NoteEditor/MarkdownToolbar/Toolbar.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarButton.js packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarButton.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarOverflowRows.js packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarOverflowRows.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/buttons/useActionButtons.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/buttons/useHeaderButtons.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/buttons/useInlineFormattingButtons.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/buttons/useListButtons.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/types.js packages/app-mobile/components/NoteEditor/MarkdownToolbar/types.js
packages/app-mobile/components/NoteEditor/NoteEditor.test.js packages/app-mobile/components/NoteEditor/NoteEditor.test.js
packages/app-mobile/components/NoteEditor/NoteEditor.js packages/app-mobile/components/NoteEditor/NoteEditor.js
packages/app-mobile/components/NoteEditor/SearchPanel.js packages/app-mobile/components/NoteEditor/SearchPanel.js
packages/app-mobile/components/NoteEditor/hooks/useKeyboardVisible.js
packages/app-mobile/components/NoteEditor/types.js packages/app-mobile/components/NoteEditor/types.js
packages/app-mobile/components/NoteList.js packages/app-mobile/components/NoteList.js
packages/app-mobile/components/ProfileSwitcher/ProfileEditor.js packages/app-mobile/components/ProfileSwitcher/ProfileEditor.js

5
.gitignore vendored
View File

@ -465,10 +465,15 @@ packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleSpaceButton.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/Toolbar.js packages/app-mobile/components/NoteEditor/MarkdownToolbar/Toolbar.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarButton.js packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarButton.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarOverflowRows.js packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarOverflowRows.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/buttons/useActionButtons.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/buttons/useHeaderButtons.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/buttons/useInlineFormattingButtons.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/buttons/useListButtons.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/types.js packages/app-mobile/components/NoteEditor/MarkdownToolbar/types.js
packages/app-mobile/components/NoteEditor/NoteEditor.test.js packages/app-mobile/components/NoteEditor/NoteEditor.test.js
packages/app-mobile/components/NoteEditor/NoteEditor.js packages/app-mobile/components/NoteEditor/NoteEditor.js
packages/app-mobile/components/NoteEditor/SearchPanel.js packages/app-mobile/components/NoteEditor/SearchPanel.js
packages/app-mobile/components/NoteEditor/hooks/useKeyboardVisible.js
packages/app-mobile/components/NoteEditor/types.js packages/app-mobile/components/NoteEditor/types.js
packages/app-mobile/components/NoteList.js packages/app-mobile/components/NoteList.js
packages/app-mobile/components/ProfileSwitcher/ProfileEditor.js packages/app-mobile/components/ProfileSwitcher/ProfileEditor.js

View File

@ -1,7 +1,11 @@
import * as React from 'react'; import * as React from 'react';
import { TextStyle } from 'react-native'; import { TextStyle, Text } from 'react-native';
const FontAwesomeIcon = require('react-native-vector-icons/FontAwesome5').default; const FontAwesomeIcon = require('react-native-vector-icons/FontAwesome5').default;
const AntIcon = require('react-native-vector-icons/AntDesign').default;
const MaterialIcon = require('react-native-vector-icons/MaterialIcons').default;
interface Props { interface Props {
name: string; name: string;
@ -9,6 +13,8 @@ interface Props {
// If `null` is given, the content must be labeled elsewhere. // If `null` is given, the content must be labeled elsewhere.
accessibilityLabel: string|null; accessibilityLabel: string|null;
allowFontScaling?: boolean;
} }
const Icon: React.FC<Props> = props => { const Icon: React.FC<Props> = props => {
@ -25,20 +31,42 @@ const Icon: React.FC<Props> = props => {
// to read the characters from the icon font (they don't make sense // to read the characters from the icon font (they don't make sense
// without the icon font applied). // without the icon font applied).
const accessibilityHidden = props.accessibilityLabel === null; const accessibilityHidden = props.accessibilityLabel === null;
const importantForAccessibility = accessibilityHidden ? 'no-hide-descendants' : 'yes';
const sharedProps = {
importantForAccessibility,
'aria-hidden': accessibilityHidden,
accessibilityLabel: props.accessibilityLabel,
style: props.style,
allowFontScaling: props.allowFontScaling,
};
if (namePrefix.match(/^fa[bsr]?$/)) {
return ( return (
<FontAwesomeIcon <FontAwesomeIcon
brand={namePrefix.startsWith('fab')} brand={namePrefix.startsWith('fab')}
solid={namePrefix.startsWith('fas')} solid={namePrefix.startsWith('fas')}
accessibilityLabel={props.accessibilityLabel}
aria-hidden={accessibilityHidden}
importantForAccessibility={
accessibilityHidden ? 'no-hide-descendants' : 'yes'
}
name={nameSuffix} name={nameSuffix}
style={props.style} {...sharedProps}
/> />
); );
} else if (namePrefix === 'ant') {
return <AntIcon name={nameSuffix} {...sharedProps}/>;
} else if (namePrefix === 'material') {
return <MaterialIcon name={nameSuffix} {...sharedProps}/>;
} else if (namePrefix === 'text') {
return (
<Text
style={props.style}
aria-hidden={accessibilityHidden}
importantForAccessibility={importantForAccessibility}
>
{nameSuffix}
</Text>
);
} else {
return <FontAwesomeIcon name='cog' {...sharedProps}/>;
}
}; };
export default Icon; export default Icon;

View File

@ -1,288 +1,45 @@
// A toolbar for the markdown editor. // A toolbar for the markdown editor.
const React = require('react'); import * as React from 'react';
import { Platform, StyleSheet } from 'react-native'; import { Platform, StyleSheet } from 'react-native';
import { useMemo, useState, useCallback } from 'react'; import { useMemo } from 'react';
// See https://oblador.github.io/react-native-vector-icons/ for a list of
// available icons.
const AntIcon = require('react-native-vector-icons/AntDesign').default;
const FontAwesomeIcon = require('react-native-vector-icons/FontAwesome5').default;
const MaterialIcon = require('react-native-vector-icons/MaterialIcons').default;
import { _ } from '@joplin/lib/locale'; import { _ } from '@joplin/lib/locale';
import time from '@joplin/lib/time'; import { MarkdownToolbarProps, StyleSheetData } from './types';
import { useEffect } from 'react';
import { Keyboard, ViewStyle } from 'react-native';
import { EditorControl, EditorSettings } from '../types';
import { ButtonSpec, StyleSheetData } from './types';
import Toolbar from './Toolbar'; import Toolbar from './Toolbar';
import { buttonSize } from './ToolbarButton'; import { buttonSize } from './ToolbarButton';
import { Theme } from '@joplin/lib/themes/type'; import { Theme } from '@joplin/lib/themes/type';
import ToggleSpaceButton from './ToggleSpaceButton'; import ToggleSpaceButton from './ToggleSpaceButton';
import { SearchState } from '@joplin/editor/types'; import useHeaderButtons from './buttons/useHeaderButtons';
import SelectionFormatting from '@joplin/editor/SelectionFormatting'; import useInlineFormattingButtons from './buttons/useInlineFormattingButtons';
import useActionButtons from './buttons/useActionButtons';
import useListButtons from './buttons/useListButtons';
import useKeyboardVisible from '../hooks/useKeyboardVisible';
type OnAttachCallback = ()=> void;
interface MarkdownToolbarProps { const MarkdownToolbar: React.FC<MarkdownToolbarProps> = (props: MarkdownToolbarProps) => {
editorControl: EditorControl;
selectionState: SelectionFormatting;
searchState: SearchState;
editorSettings: EditorSettings;
onAttach: OnAttachCallback;
style?: ViewStyle;
readOnly: boolean;
}
const MarkdownToolbar = (props: MarkdownToolbarProps) => {
const themeData = props.editorSettings.themeData; const themeData = props.editorSettings.themeData;
const styles = useStyles(props.style, themeData); const styles = useStyles(props.style, themeData);
const selState = props.selectionState;
const editorControl = props.editorControl;
const readOnly = props.readOnly;
const headerButtons: ButtonSpec[] = []; const { keyboardVisible, hasSoftwareKeyboard } = useKeyboardVisible();
for (let level = 1; level <= 5; level++) { const buttonProps = {
const active = selState.headerLevel === level; ...props,
iconStyle: styles.text,
keyboardVisible,
hasSoftwareKeyboard,
};
const headerButtons = useHeaderButtons(buttonProps);
const inlineFormattingBtns = useInlineFormattingButtons(buttonProps);
const actionButtons = useActionButtons(buttonProps);
const listButtons = useListButtons(buttonProps);
headerButtons.push({ const styleData: StyleSheetData = useMemo(() => ({
icon: `H${level}`,
description: _('Header %d', level),
active,
// We only call addHeaderButton 5 times and in the same order, so
// the linter error is safe to ignore.
// eslint-disable-next-line @seiyab/react-hooks/rules-of-hooks
onPress: useCallback(() => {
editorControl.toggleHeaderLevel(level);
}, [editorControl, level]),
// Make it likely for the first three header buttons to show, less likely for
// the others.
priority: level < 3 ? 2 : 0,
disabled: readOnly,
});
}
const listButtons: ButtonSpec[] = [];
listButtons.push({
icon: (
<FontAwesomeIcon name="list-ul" style={styles.text}/>
),
description: _('Unordered list'),
active: selState.inUnorderedList,
onPress: editorControl.toggleUnorderedList,
priority: -2,
disabled: readOnly,
});
listButtons.push({
icon: (
<FontAwesomeIcon name="list-ol" style={styles.text}/>
),
description: _('Ordered list'),
active: selState.inOrderedList,
onPress: editorControl.toggleOrderedList,
priority: -2,
disabled: readOnly,
});
listButtons.push({
icon: (
<FontAwesomeIcon name="tasks" style={styles.text}/>
),
description: _('Task list'),
active: selState.inChecklist,
onPress: editorControl.toggleTaskList,
priority: -2,
disabled: readOnly,
});
listButtons.push({
icon: (
<AntIcon name="indent-left" style={styles.text}/>
),
description: _('Decrease indent level'),
onPress: editorControl.decreaseIndent,
priority: -1,
disabled: readOnly,
});
listButtons.push({
icon: (
<AntIcon name="indent-right" style={styles.text}/>
),
description: _('Increase indent level'),
onPress: editorControl.increaseIndent,
priority: -1,
disabled: readOnly,
});
// Inline formatting
const inlineFormattingBtns: ButtonSpec[] = [];
inlineFormattingBtns.push({
icon: (
<FontAwesomeIcon name="bold" style={styles.text}/>
),
description: _('Bold'),
active: selState.bolded,
onPress: editorControl.toggleBolded,
priority: 3,
disabled: readOnly,
});
inlineFormattingBtns.push({
icon: (
<FontAwesomeIcon name="italic" style={styles.text}/>
),
description: _('Italic'),
active: selState.italicized,
onPress: editorControl.toggleItalicized,
priority: 2,
disabled: readOnly,
});
inlineFormattingBtns.push({
icon: '{;}',
description: _('Code'),
active: selState.inCode,
onPress: editorControl.toggleCode,
priority: 2,
disabled: readOnly,
});
if (props.editorSettings.katexEnabled) {
inlineFormattingBtns.push({
icon: '∑',
description: _('KaTeX'),
active: selState.inMath,
onPress: editorControl.toggleMath,
priority: 1,
disabled: readOnly,
});
}
inlineFormattingBtns.push({
icon: (
<FontAwesomeIcon name="link" style={styles.text}/>
),
description: _('Link'),
active: selState.inLink,
onPress: editorControl.showLinkDialog,
priority: -3,
disabled: readOnly,
});
// Actions
const actionButtons: ButtonSpec[] = [];
actionButtons.push({
icon: (
<FontAwesomeIcon name="calendar-plus" style={styles.text}/>
),
description: _('Insert time'),
onPress: useCallback(() => {
editorControl.insertText(time.formatDateToLocal(new Date()));
}, [editorControl]),
disabled: readOnly,
});
const onDismissKeyboard = useCallback(() => {
// Keyboard.dismiss() doesn't dismiss the keyboard if it's editing the WebView.
Keyboard.dismiss();
// As such, dismiss the keyboard by sending a message to the View.
editorControl.hideKeyboard();
}, [editorControl]);
actionButtons.push({
icon: (
<MaterialIcon name="attachment" style={styles.text}/>
),
description: _('Attach'),
onPress: useCallback(() => {
onDismissKeyboard();
props.onAttach();
}, [props.onAttach, onDismissKeyboard]),
disabled: readOnly,
});
actionButtons.push({
icon: (
<MaterialIcon name="search" style={styles.text}/>
),
description: (
props.searchState.dialogVisible ? _('Close') : _('Find and replace')
),
active: props.searchState.dialogVisible,
onPress: useCallback(() => {
if (props.searchState.dialogVisible) {
editorControl.searchControl.hideSearch();
} else {
editorControl.searchControl.showSearch();
}
}, [editorControl, props.searchState.dialogVisible]),
priority: -3,
disabled: readOnly,
});
const [keyboardVisible, setKeyboardVisible] = useState(false);
const [hasSoftwareKeyboard, setHasSoftwareKeyboard] = useState(false);
useEffect(() => {
const showListener = Keyboard.addListener('keyboardDidShow', () => {
setKeyboardVisible(true);
setHasSoftwareKeyboard(true);
});
const hideListener = Keyboard.addListener('keyboardDidHide', () => {
setKeyboardVisible(false);
});
return (() => {
showListener.remove();
hideListener.remove();
});
});
actionButtons.push({
icon: (
<MaterialIcon name="keyboard-hide" style={styles.text}/>
),
description: _('Hide keyboard'),
disabled: !keyboardVisible,
visible: hasSoftwareKeyboard && Platform.OS === 'ios',
onPress: onDismissKeyboard,
priority: -3,
});
const styleData: StyleSheetData = {
styles: styles, styles: styles,
themeId: props.editorSettings.themeId, themeId: props.editorSettings.themeId,
}; }), [styles, props.editorSettings.themeId]);
return ( const toolbarButtons = useMemo(() => {
<ToggleSpaceButton const buttons = [
spaceApplicable={ Platform.OS === 'ios' && keyboardVisible }
themeId={props.editorSettings.themeId}
style={styles.container}
>
<Toolbar
styleSheet={styleData}
buttons={[
{ {
title: _('Formatting'), title: _('Formatting'),
items: inlineFormattingBtns, items: inlineFormattingBtns,
@ -299,7 +56,20 @@ const MarkdownToolbar = (props: MarkdownToolbarProps) => {
title: _('Actions'), title: _('Actions'),
items: actionButtons, items: actionButtons,
}, },
]} ];
return buttons;
}, [headerButtons, inlineFormattingBtns, listButtons, actionButtons]);
return (
<ToggleSpaceButton
spaceApplicable={ Platform.OS === 'ios' && keyboardVisible }
themeId={props.editorSettings.themeId}
style={styles.container}
>
<Toolbar
styleSheet={styleData}
buttons={toolbarButtons}
/> />
</ToggleSpaceButton> </ToggleSpaceButton>
); );

View File

@ -1,9 +1,8 @@
const React = require('react'); import * as React from 'react';
import { _ } from '@joplin/lib/locale'; import { _ } from '@joplin/lib/locale';
import ToolbarButton from './ToolbarButton'; import ToolbarButton from './ToolbarButton';
import { ButtonSpec, StyleSheetData } from './types'; import { ButtonSpec, StyleSheetData } from './types';
const MaterialIcon = require('react-native-vector-icons/MaterialIcons').default;
type OnToggleOverflowCallback = ()=> void; type OnToggleOverflowCallback = ()=> void;
interface ToggleOverflowButtonProps { interface ToggleOverflowButtonProps {
@ -13,11 +12,9 @@ interface ToggleOverflowButtonProps {
} }
// Button that shows/hides the overflow menu. // Button that shows/hides the overflow menu.
const ToggleOverflowButton = (props: ToggleOverflowButtonProps) => { const ToggleOverflowButton: React.FC<ToggleOverflowButtonProps> = (props: ToggleOverflowButtonProps) => {
const spec: ButtonSpec = { const spec: ButtonSpec = {
icon: ( icon: 'material more-horiz',
<MaterialIcon name="more-horiz" style={props.styleSheet.styles.text}/>
),
description: description:
props.overflowVisible ? _('Hide more actions') : _('Show more actions'), props.overflowVisible ? _('Hide more actions') : _('Show more actions'),
active: props.overflowVisible, active: props.overflowVisible,

View File

@ -1,4 +1,4 @@
const React = require('react'); import * as React from 'react';
import { ReactElement, useCallback, useMemo, useState } from 'react'; import { ReactElement, useCallback, useMemo, useState } from 'react';
import { LayoutChangeEvent, ScrollView, View, ViewStyle } from 'react-native'; import { LayoutChangeEvent, ScrollView, View, ViewStyle } from 'react-native';
@ -14,7 +14,7 @@ interface ToolbarProps {
} }
// Displays a list of buttons with an overflow menu. // Displays a list of buttons with an overflow menu.
const Toolbar = (props: ToolbarProps) => { const Toolbar: React.FC<ToolbarProps> = (props: ToolbarProps) => {
const [overflowButtonsVisible, setOverflowPopupVisible] = useState(false); const [overflowButtonsVisible, setOverflowPopupVisible] = useState(false);
const [maxButtonsEachSide, setMaxButtonsEachSide] = useState(0); const [maxButtonsEachSide, setMaxButtonsEachSide] = useState(0);

View File

@ -1,8 +1,9 @@
import React = require('react'); import * as React from 'react';
import { useCallback } from 'react'; import { useCallback, useMemo } from 'react';
import { Text, TextStyle } from 'react-native'; import { TextStyle, StyleSheet } from 'react-native';
import { ButtonSpec, StyleSheetData } from './types'; import { ButtonSpec, StyleSheetData } from './types';
import CustomButton from '../../CustomButton'; import CustomButton from '../../CustomButton';
import Icon from '../../Icon';
export const buttonSize = 54; export const buttonSize = 54;
@ -13,28 +14,39 @@ interface ToolbarButtonProps {
onActionComplete?: ()=> void; onActionComplete?: ()=> void;
} }
const useStyles = (baseStyleSheet: any, baseButtonStyle: any, buttonSpec: ButtonSpec, visible: boolean, disabled: boolean) => {
return useMemo(() => {
const activatedStyle = buttonSpec.active ? baseStyleSheet.buttonActive : {};
const disabledStyle = disabled ? baseStyleSheet.buttonDisabled : {};
const activatedTextStyle = buttonSpec.active ? baseStyleSheet.buttonActiveContent : {};
const disabledTextStyle = disabled ? baseStyleSheet.buttonDisabledContent : {};
return StyleSheet.create({
iconStyle: {
...activatedTextStyle,
...disabledTextStyle,
...baseStyleSheet.text,
},
buttonStyle: {
...baseStyleSheet.button,
...activatedStyle,
...disabledStyle,
...baseButtonStyle,
...(!visible ? { opacity: 0 } : null),
},
});
}, [
baseStyleSheet.button, baseStyleSheet.text, baseButtonStyle, baseStyleSheet.buttonActive,
baseStyleSheet.buttonDisabled, baseStyleSheet.buttonActiveContent, baseStyleSheet.buttonDisabledContent,
buttonSpec.active, visible, disabled,
]);
};
const ToolbarButton = ({ styleSheet, spec, onActionComplete, style }: ToolbarButtonProps) => { const ToolbarButton = ({ styleSheet, spec, onActionComplete, style }: ToolbarButtonProps) => {
const visible = spec.visible ?? true; const visible = spec.visible ?? true;
const disabled = (spec.disabled ?? false) && visible; const disabled = (spec.disabled ?? false) && visible;
const styles = styleSheet.styles; const styles = useStyles(styleSheet.styles, style, spec, visible, disabled);
// Additional styles if activated
const activatedStyle = spec.active ? styles.buttonActive : {};
const activatedTextStyle = spec.active ? styles.buttonActiveContent : {};
const disabledStyle = disabled ? styles.buttonDisabled : {};
const disabledTextStyle = disabled ? styles.buttonDisabledContent : {};
let content;
if (typeof spec.icon === 'string') {
content = (
<Text style={{ ...styles.text, ...activatedTextStyle, ...disabledTextStyle }}>
{spec.icon}
</Text>
);
} else {
content = spec.icon;
}
const sourceOnPress = spec.onPress; const sourceOnPress = spec.onPress;
const onPress = useCallback(() => { const onPress = useCallback(() => {
@ -46,17 +58,14 @@ const ToolbarButton = ({ styleSheet, spec, onActionComplete, style }: ToolbarBut
return ( return (
<CustomButton <CustomButton
style={{ style={styles.buttonStyle}
...styles.button, ...activatedStyle, ...disabledStyle, ...style,
...(!visible ? { opacity: 0 } : null),
}}
themeId={styleSheet.themeId} themeId={styleSheet.themeId}
onPress={onPress} onPress={onPress}
description={ spec.description } description={ spec.description }
accessibilityRole="button" accessibilityRole="button"
disabled={ disabled } disabled={ disabled }
> >
{ content } <Icon name={spec.icon} style={styles.iconStyle} accessibilityLabel={null}/>
</CustomButton> </CustomButton>
); );
}; };

View File

@ -1,3 +1,5 @@
import * as React from 'react';
import { _ } from '@joplin/lib/locale'; import { _ } from '@joplin/lib/locale';
import { ReactElement, useCallback, useState } from 'react'; import { ReactElement, useCallback, useState } from 'react';
import { LayoutChangeEvent, ScrollView, View } from 'react-native'; import { LayoutChangeEvent, ScrollView, View } from 'react-native';
@ -5,8 +7,6 @@ import ToggleOverflowButton from './ToggleOverflowButton';
import ToolbarButton, { buttonSize } from './ToolbarButton'; import ToolbarButton, { buttonSize } from './ToolbarButton';
import { ButtonGroup, ButtonSpec, StyleSheetData } from './types'; import { ButtonGroup, ButtonSpec, StyleSheetData } from './types';
const React = require('react');
type OnToggleOverflowCallback = ()=> void; type OnToggleOverflowCallback = ()=> void;
interface OverflowPopupProps { interface OverflowPopupProps {
buttonGroups: ButtonGroup[]; buttonGroups: ButtonGroup[];
@ -17,10 +17,13 @@ interface OverflowPopupProps {
onToggleOverflow: OnToggleOverflowCallback; onToggleOverflow: OnToggleOverflowCallback;
} }
// Specification for a button that acts as padding.
const paddingButtonSpec = { visible: false, icon: '', onPress: ()=>{}, description: '' };
// Contains buttons that overflow the available space. // Contains buttons that overflow the available space.
// Displays all buttons in [props.buttonGroups] if [props.visible]. // Displays all buttons in [props.buttonGroups] if [props.visible].
// Otherwise, displays nothing. // Otherwise, displays nothing.
const ToolbarOverflowRows = (props: OverflowPopupProps) => { const ToolbarOverflowRows: React.FC<OverflowPopupProps> = (props: OverflowPopupProps) => {
const overflowRows: ReactElement[] = []; const overflowRows: ReactElement[] = [];
let key = 0; let key = 0;
@ -47,7 +50,7 @@ const ToolbarOverflowRows = (props: OverflowPopupProps) => {
// Show the "hide overflow" button if in the center of the last row // Show the "hide overflow" button if in the center of the last row
const isLastRow = i === props.buttonGroups.length - 1; const isLastRow = i === props.buttonGroups.length - 1;
const isCenterOfRow = j + 1 === Math.floor(group.items.length / 2); const isCenterOfRow = j + 1 === Math.floor(group.items.length / 2);
if (isLastRow && isCenterOfRow) { if (isLastRow && (isCenterOfRow || group.items.length === 1)) {
row.push( row.push(
<ToggleOverflowButton <ToggleOverflowButton
key={(++key).toString()} key={(++key).toString()}
@ -59,6 +62,17 @@ const ToolbarOverflowRows = (props: OverflowPopupProps) => {
} }
} }
// Pad to an odd number of items to ensure that buttons are centered properly
if (row.length % 2 === 0) {
row.push(
<ToolbarButton
key={`padding-${i}`}
styleSheet={props.styleSheet}
spec={paddingButtonSpec}
/>,
);
}
overflowRows.push( overflowRows.push(
<View <View
key={key.toString()} key={key.toString()}
@ -87,7 +101,7 @@ const ToolbarOverflowRows = (props: OverflowPopupProps) => {
}, [setHasSpaceForCloseBtn, props.buttonGroups]); }, [setHasSpaceForCloseBtn, props.buttonGroups]);
const closeButtonSpec: ButtonSpec = { const closeButtonSpec: ButtonSpec = {
icon: '⨉', icon: 'text ⨉',
description: _('Close'), description: _('Close'),
onPress: props.onToggleOverflow, onPress: props.onToggleOverflow,
}; };
@ -112,6 +126,7 @@ const ToolbarOverflowRows = (props: OverflowPopupProps) => {
height: props.buttonGroups.length * buttonSize, height: props.buttonGroups.length * buttonSize,
flexDirection: 'column', flexDirection: 'column',
flexGrow: 1, flexGrow: 1,
display: !props.visible ? 'none' : 'flex',
}} }}
onLayout={onContainerLayout} onLayout={onContainerLayout}
> >

View File

@ -0,0 +1,83 @@
import { useCallback, useMemo } from 'react';
import { ButtonSpec } from '../types';
import { _ } from '@joplin/lib/locale';
import { ButtonRowProps } from '../types';
import time from '@joplin/lib/time';
import { Keyboard, Platform } from 'react-native';
export interface ActionButtonRowProps extends ButtonRowProps {
keyboardVisible: boolean;
hasSoftwareKeyboard: boolean;
}
const useActionButtons = (props: ActionButtonRowProps) => {
const onDismissKeyboard = useCallback(() => {
// Keyboard.dismiss() doesn't dismiss the keyboard if it's editing the WebView.
Keyboard.dismiss();
// As such, dismiss the keyboard by sending a message to the View.
props.editorControl.hideKeyboard();
}, [props.editorControl]);
const onSearch = useCallback(() => {
if (props.searchState.dialogVisible) {
props.editorControl.searchControl.hideSearch();
} else {
props.editorControl.searchControl.showSearch();
}
}, [props.editorControl, props.searchState.dialogVisible]);
const onAttach = useCallback(() => {
onDismissKeyboard();
props.onAttach();
}, [props.onAttach, onDismissKeyboard]);
return useMemo(() => {
const actionButtons: ButtonSpec[] = [];
actionButtons.push({
icon: 'fa calendar-plus',
description: _('Insert time'),
onPress: () => {
props.editorControl.insertText(time.formatDateToLocal(new Date()));
},
disabled: props.readOnly,
});
actionButtons.push({
icon: 'material attachment',
description: _('Attach'),
onPress: onAttach,
disabled: props.readOnly,
});
actionButtons.push({
icon: 'material search',
description: (
props.searchState.dialogVisible ? _('Close') : _('Find and replace')
),
active: props.searchState.dialogVisible,
onPress: onSearch,
priority: -3,
disabled: props.readOnly,
});
actionButtons.push({
icon: 'material keyboard-hide',
description: _('Hide keyboard'),
disabled: !props.keyboardVisible,
visible: props.hasSoftwareKeyboard && Platform.OS === 'ios',
onPress: onDismissKeyboard,
priority: -3,
});
return actionButtons;
}, [
props.editorControl, props.keyboardVisible, props.hasSoftwareKeyboard,
props.readOnly, props.searchState.dialogVisible,
onAttach, onDismissKeyboard, onSearch,
]);
};
export default useActionButtons;

View File

@ -0,0 +1,34 @@
import { useMemo } from 'react';
import { ButtonSpec } from '../types';
import { _ } from '@joplin/lib/locale';
import { ButtonRowProps } from '../types';
const useHeaderButtons = ({ selectionState, editorControl, readOnly }: ButtonRowProps) => {
return useMemo(() => {
const headerButtons: ButtonSpec[] = [];
for (let level = 1; level <= 5; level++) {
const active = selectionState.headerLevel === level;
headerButtons.push({
icon: `text H${level}`,
description: _('Header %d', level),
active,
// We only call addHeaderButton 5 times and in the same order, so
// the linter error is safe to ignore.
// eslint-disable-next-line @seiyab/react-hooks/rules-of-hooks
onPress: () => {
editorControl.toggleHeaderLevel(level);
},
// Make it likely for the first three header buttons to show, less likely for
// the others.
priority: level < 3 ? 2 : 0,
disabled: readOnly,
});
}
return headerButtons;
}, [selectionState, editorControl, readOnly]);
};
export default useHeaderButtons;

View File

@ -0,0 +1,67 @@
import { useMemo } from 'react';
import { ButtonSpec } from '../types';
import { _ } from '@joplin/lib/locale';
import { ButtonRowProps } from '../types';
const useInlineFormattingButtons = ({ selectionState, editorControl, readOnly, editorSettings }: ButtonRowProps) => {
const { bolded, italicized, inCode, inMath, inLink } = selectionState;
return useMemo(() => {
const inlineFormattingBtns: ButtonSpec[] = [];
inlineFormattingBtns.push({
icon: 'fa bold',
description: _('Bold'),
active: bolded,
onPress: editorControl.toggleBolded,
priority: 3,
disabled: readOnly,
});
inlineFormattingBtns.push({
icon: 'fa italic',
description: _('Italic'),
active: italicized,
onPress: editorControl.toggleItalicized,
priority: 2,
disabled: readOnly,
});
inlineFormattingBtns.push({
icon: 'text {;}',
description: _('Code'),
active: inCode,
onPress: editorControl.toggleCode,
priority: 2,
disabled: readOnly,
});
if (editorSettings.katexEnabled) {
inlineFormattingBtns.push({
icon: 'text ∑',
description: _('KaTeX'),
active: inMath,
onPress: editorControl.toggleMath,
priority: 1,
disabled: readOnly,
});
}
inlineFormattingBtns.push({
icon: 'fa link',
description: _('Link'),
active: inLink,
onPress: editorControl.showLinkDialog,
priority: -3,
disabled: readOnly,
});
return inlineFormattingBtns;
}, [readOnly, editorControl, editorSettings.katexEnabled, inLink, inMath, inCode, italicized, bolded]);
};
export default useInlineFormattingButtons;

View File

@ -0,0 +1,63 @@
import { useMemo } from 'react';
import { ButtonSpec } from '../types';
import { _ } from '@joplin/lib/locale';
import { ButtonRowProps } from '../types';
const useListButtons = ({ selectionState, editorControl, readOnly }: ButtonRowProps) => {
return useMemo(() => {
const listButtons: ButtonSpec[] = [];
listButtons.push({
icon: 'fa list-ul',
description: _('Unordered list'),
active: selectionState.inUnorderedList,
onPress: editorControl.toggleUnorderedList,
priority: -2,
disabled: readOnly,
});
listButtons.push({
icon: 'fa list-ol',
description: _('Ordered list'),
active: selectionState.inOrderedList,
onPress: editorControl.toggleOrderedList,
priority: -2,
disabled: readOnly,
});
listButtons.push({
icon: 'fa tasks',
description: _('Task list'),
active: selectionState.inChecklist,
onPress: editorControl.toggleTaskList,
priority: -2,
disabled: readOnly,
});
listButtons.push({
icon: 'ant indent-left',
description: _('Decrease indent level'),
onPress: editorControl.decreaseIndent,
priority: -1,
disabled: readOnly,
});
listButtons.push({
icon: 'ant indent-right',
description: _('Increase indent level'),
onPress: editorControl.increaseIndent,
priority: -1,
disabled: readOnly,
});
return listButtons;
}, [readOnly, editorControl, selectionState]);
};
export default useListButtons;

View File

@ -1,11 +1,14 @@
import { ReactElement } from 'react'; import { TextStyle, ViewStyle } from 'react-native';
import { EditorControl, EditorSettings } from '../types';
import SelectionFormatting from '@joplin/editor/SelectionFormatting';
import { SearchState } from '@joplin/editor/types';
export type OnPressListener = ()=> void; export type OnPressListener = ()=> void;
export interface ButtonSpec { export interface ButtonSpec {
// Either text that will be shown in place of an icon or a component. // Name of an icon, as accepted by components/Icon.tsx
icon: string | ReactElement; icon: string;
// Tooltip/accessibility label // Tooltip/accessibility label
description: string; description: string;
@ -23,7 +26,6 @@ export interface ButtonSpec {
disabled?: boolean; disabled?: boolean;
visible?: boolean; visible?: boolean;
} }
export interface ButtonGroup { export interface ButtonGroup {
title: string; title: string;
items: ButtonSpec[]; items: ButtonSpec[];
@ -33,3 +35,18 @@ export interface StyleSheetData {
themeId: number; themeId: number;
styles: any; styles: any;
} }
type OnAttachCallback = ()=> void;
export interface MarkdownToolbarProps {
editorControl: EditorControl;
selectionState: SelectionFormatting;
searchState: SearchState;
editorSettings: EditorSettings;
onAttach: OnAttachCallback;
style?: ViewStyle;
readOnly: boolean;
}
export interface ButtonRowProps extends MarkdownToolbarProps {
iconStyle: TextStyle;
}

View File

@ -0,0 +1,27 @@
import { useEffect, useMemo, useState } from 'react';
import { Keyboard } from 'react-native';
const useKeyboardVisible = () => {
const [keyboardVisible, setKeyboardVisible] = useState(false);
const [hasSoftwareKeyboard, setHasSoftwareKeyboard] = useState(false);
useEffect(() => {
const showListener = Keyboard.addListener('keyboardDidShow', () => {
setKeyboardVisible(true);
setHasSoftwareKeyboard(true);
});
const hideListener = Keyboard.addListener('keyboardDidHide', () => {
setKeyboardVisible(false);
});
return (() => {
showListener.remove();
hideListener.remove();
});
});
return useMemo(() => {
return { keyboardVisible, hasSoftwareKeyboard };
}, [keyboardVisible, hasSoftwareKeyboard]);
};
export default useKeyboardVisible;