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:
parent
bc1165be46
commit
4636d1539c
@ -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/ToolbarButton.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/NoteEditor.test.js
|
||||
packages/app-mobile/components/NoteEditor/NoteEditor.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/NoteList.js
|
||||
packages/app-mobile/components/ProfileSwitcher/ProfileEditor.js
|
||||
|
5
.gitignore
vendored
5
.gitignore
vendored
@ -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/ToolbarButton.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/NoteEditor.test.js
|
||||
packages/app-mobile/components/NoteEditor/NoteEditor.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/NoteList.js
|
||||
packages/app-mobile/components/ProfileSwitcher/ProfileEditor.js
|
||||
|
@ -1,7 +1,11 @@
|
||||
|
||||
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 AntIcon = require('react-native-vector-icons/AntDesign').default;
|
||||
const MaterialIcon = require('react-native-vector-icons/MaterialIcons').default;
|
||||
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
@ -9,6 +13,8 @@ interface Props {
|
||||
|
||||
// If `null` is given, the content must be labeled elsewhere.
|
||||
accessibilityLabel: string|null;
|
||||
|
||||
allowFontScaling?: boolean;
|
||||
}
|
||||
|
||||
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
|
||||
// without the icon font applied).
|
||||
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 (
|
||||
<FontAwesomeIcon
|
||||
brand={namePrefix.startsWith('fab')}
|
||||
solid={namePrefix.startsWith('fas')}
|
||||
accessibilityLabel={props.accessibilityLabel}
|
||||
aria-hidden={accessibilityHidden}
|
||||
importantForAccessibility={
|
||||
accessibilityHidden ? 'no-hide-descendants' : 'yes'
|
||||
}
|
||||
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;
|
||||
|
@ -1,288 +1,45 @@
|
||||
// A toolbar for the markdown editor.
|
||||
|
||||
const React = require('react');
|
||||
import * as React from 'react';
|
||||
import { Platform, StyleSheet } from 'react-native';
|
||||
import { useMemo, useState, useCallback } 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 { useMemo } from 'react';
|
||||
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import time from '@joplin/lib/time';
|
||||
import { useEffect } from 'react';
|
||||
import { Keyboard, ViewStyle } from 'react-native';
|
||||
import { EditorControl, EditorSettings } from '../types';
|
||||
import { ButtonSpec, StyleSheetData } from './types';
|
||||
import { MarkdownToolbarProps, StyleSheetData } from './types';
|
||||
import Toolbar from './Toolbar';
|
||||
import { buttonSize } from './ToolbarButton';
|
||||
import { Theme } from '@joplin/lib/themes/type';
|
||||
import ToggleSpaceButton from './ToggleSpaceButton';
|
||||
import { SearchState } from '@joplin/editor/types';
|
||||
import SelectionFormatting from '@joplin/editor/SelectionFormatting';
|
||||
import useHeaderButtons from './buttons/useHeaderButtons';
|
||||
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 {
|
||||
editorControl: EditorControl;
|
||||
selectionState: SelectionFormatting;
|
||||
searchState: SearchState;
|
||||
editorSettings: EditorSettings;
|
||||
onAttach: OnAttachCallback;
|
||||
style?: ViewStyle;
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
const MarkdownToolbar = (props: MarkdownToolbarProps) => {
|
||||
const MarkdownToolbar: React.FC<MarkdownToolbarProps> = (props: MarkdownToolbarProps) => {
|
||||
const themeData = props.editorSettings.themeData;
|
||||
const styles = useStyles(props.style, themeData);
|
||||
const selState = props.selectionState;
|
||||
const editorControl = props.editorControl;
|
||||
const readOnly = props.readOnly;
|
||||
|
||||
const headerButtons: ButtonSpec[] = [];
|
||||
for (let level = 1; level <= 5; level++) {
|
||||
const active = selState.headerLevel === level;
|
||||
const { keyboardVisible, hasSoftwareKeyboard } = useKeyboardVisible();
|
||||
const buttonProps = {
|
||||
...props,
|
||||
iconStyle: styles.text,
|
||||
keyboardVisible,
|
||||
hasSoftwareKeyboard,
|
||||
};
|
||||
const headerButtons = useHeaderButtons(buttonProps);
|
||||
const inlineFormattingBtns = useInlineFormattingButtons(buttonProps);
|
||||
const actionButtons = useActionButtons(buttonProps);
|
||||
const listButtons = useListButtons(buttonProps);
|
||||
|
||||
headerButtons.push({
|
||||
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 = {
|
||||
const styleData: StyleSheetData = useMemo(() => ({
|
||||
styles: styles,
|
||||
themeId: props.editorSettings.themeId,
|
||||
};
|
||||
}), [styles, props.editorSettings.themeId]);
|
||||
|
||||
return (
|
||||
<ToggleSpaceButton
|
||||
spaceApplicable={ Platform.OS === 'ios' && keyboardVisible }
|
||||
themeId={props.editorSettings.themeId}
|
||||
style={styles.container}
|
||||
>
|
||||
<Toolbar
|
||||
styleSheet={styleData}
|
||||
buttons={[
|
||||
const toolbarButtons = useMemo(() => {
|
||||
const buttons = [
|
||||
{
|
||||
title: _('Formatting'),
|
||||
items: inlineFormattingBtns,
|
||||
@ -299,7 +56,20 @@ const MarkdownToolbar = (props: MarkdownToolbarProps) => {
|
||||
title: _('Actions'),
|
||||
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>
|
||||
);
|
||||
|
@ -1,9 +1,8 @@
|
||||
const React = require('react');
|
||||
import * as React from 'react';
|
||||
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import ToolbarButton from './ToolbarButton';
|
||||
import { ButtonSpec, StyleSheetData } from './types';
|
||||
const MaterialIcon = require('react-native-vector-icons/MaterialIcons').default;
|
||||
|
||||
type OnToggleOverflowCallback = ()=> void;
|
||||
interface ToggleOverflowButtonProps {
|
||||
@ -13,11 +12,9 @@ interface ToggleOverflowButtonProps {
|
||||
}
|
||||
|
||||
// Button that shows/hides the overflow menu.
|
||||
const ToggleOverflowButton = (props: ToggleOverflowButtonProps) => {
|
||||
const ToggleOverflowButton: React.FC<ToggleOverflowButtonProps> = (props: ToggleOverflowButtonProps) => {
|
||||
const spec: ButtonSpec = {
|
||||
icon: (
|
||||
<MaterialIcon name="more-horiz" style={props.styleSheet.styles.text}/>
|
||||
),
|
||||
icon: 'material more-horiz',
|
||||
description:
|
||||
props.overflowVisible ? _('Hide more actions') : _('Show more actions'),
|
||||
active: props.overflowVisible,
|
||||
|
@ -1,4 +1,4 @@
|
||||
const React = require('react');
|
||||
import * as React from 'react';
|
||||
|
||||
import { ReactElement, useCallback, useMemo, useState } from 'react';
|
||||
import { LayoutChangeEvent, ScrollView, View, ViewStyle } from 'react-native';
|
||||
@ -14,7 +14,7 @@ interface ToolbarProps {
|
||||
}
|
||||
|
||||
// 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 [maxButtonsEachSide, setMaxButtonsEachSide] = useState(0);
|
||||
|
||||
|
@ -1,8 +1,9 @@
|
||||
import React = require('react');
|
||||
import { useCallback } from 'react';
|
||||
import { Text, TextStyle } from 'react-native';
|
||||
import * as React from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { TextStyle, StyleSheet } from 'react-native';
|
||||
import { ButtonSpec, StyleSheetData } from './types';
|
||||
import CustomButton from '../../CustomButton';
|
||||
import Icon from '../../Icon';
|
||||
|
||||
export const buttonSize = 54;
|
||||
|
||||
@ -13,28 +14,39 @@ interface ToolbarButtonProps {
|
||||
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 visible = spec.visible ?? true;
|
||||
const disabled = (spec.disabled ?? false) && visible;
|
||||
const styles = styleSheet.styles;
|
||||
|
||||
// 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 styles = useStyles(styleSheet.styles, style, spec, visible, disabled);
|
||||
|
||||
const sourceOnPress = spec.onPress;
|
||||
const onPress = useCallback(() => {
|
||||
@ -46,17 +58,14 @@ const ToolbarButton = ({ styleSheet, spec, onActionComplete, style }: ToolbarBut
|
||||
|
||||
return (
|
||||
<CustomButton
|
||||
style={{
|
||||
...styles.button, ...activatedStyle, ...disabledStyle, ...style,
|
||||
...(!visible ? { opacity: 0 } : null),
|
||||
}}
|
||||
style={styles.buttonStyle}
|
||||
themeId={styleSheet.themeId}
|
||||
onPress={onPress}
|
||||
description={ spec.description }
|
||||
accessibilityRole="button"
|
||||
disabled={ disabled }
|
||||
>
|
||||
{ content }
|
||||
<Icon name={spec.icon} style={styles.iconStyle} accessibilityLabel={null}/>
|
||||
</CustomButton>
|
||||
);
|
||||
};
|
||||
|
@ -1,3 +1,5 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { ReactElement, useCallback, useState } from 'react';
|
||||
import { LayoutChangeEvent, ScrollView, View } from 'react-native';
|
||||
@ -5,8 +7,6 @@ import ToggleOverflowButton from './ToggleOverflowButton';
|
||||
import ToolbarButton, { buttonSize } from './ToolbarButton';
|
||||
import { ButtonGroup, ButtonSpec, StyleSheetData } from './types';
|
||||
|
||||
const React = require('react');
|
||||
|
||||
type OnToggleOverflowCallback = ()=> void;
|
||||
interface OverflowPopupProps {
|
||||
buttonGroups: ButtonGroup[];
|
||||
@ -17,10 +17,13 @@ interface OverflowPopupProps {
|
||||
onToggleOverflow: OnToggleOverflowCallback;
|
||||
}
|
||||
|
||||
// Specification for a button that acts as padding.
|
||||
const paddingButtonSpec = { visible: false, icon: '', onPress: ()=>{}, description: '' };
|
||||
|
||||
// Contains buttons that overflow the available space.
|
||||
// Displays all buttons in [props.buttonGroups] if [props.visible].
|
||||
// Otherwise, displays nothing.
|
||||
const ToolbarOverflowRows = (props: OverflowPopupProps) => {
|
||||
const ToolbarOverflowRows: React.FC<OverflowPopupProps> = (props: OverflowPopupProps) => {
|
||||
const overflowRows: ReactElement[] = [];
|
||||
|
||||
let key = 0;
|
||||
@ -47,7 +50,7 @@ const ToolbarOverflowRows = (props: OverflowPopupProps) => {
|
||||
// Show the "hide overflow" button if in the center of the last row
|
||||
const isLastRow = i === props.buttonGroups.length - 1;
|
||||
const isCenterOfRow = j + 1 === Math.floor(group.items.length / 2);
|
||||
if (isLastRow && isCenterOfRow) {
|
||||
if (isLastRow && (isCenterOfRow || group.items.length === 1)) {
|
||||
row.push(
|
||||
<ToggleOverflowButton
|
||||
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(
|
||||
<View
|
||||
key={key.toString()}
|
||||
@ -87,7 +101,7 @@ const ToolbarOverflowRows = (props: OverflowPopupProps) => {
|
||||
}, [setHasSpaceForCloseBtn, props.buttonGroups]);
|
||||
|
||||
const closeButtonSpec: ButtonSpec = {
|
||||
icon: '⨉',
|
||||
icon: 'text ⨉',
|
||||
description: _('Close'),
|
||||
onPress: props.onToggleOverflow,
|
||||
};
|
||||
@ -112,6 +126,7 @@ const ToolbarOverflowRows = (props: OverflowPopupProps) => {
|
||||
height: props.buttonGroups.length * buttonSize,
|
||||
flexDirection: 'column',
|
||||
flexGrow: 1,
|
||||
display: !props.visible ? 'none' : 'flex',
|
||||
}}
|
||||
onLayout={onContainerLayout}
|
||||
>
|
||||
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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 interface ButtonSpec {
|
||||
// Either text that will be shown in place of an icon or a component.
|
||||
icon: string | ReactElement;
|
||||
// Name of an icon, as accepted by components/Icon.tsx
|
||||
icon: string;
|
||||
|
||||
// Tooltip/accessibility label
|
||||
description: string;
|
||||
@ -23,7 +26,6 @@ export interface ButtonSpec {
|
||||
disabled?: boolean;
|
||||
visible?: boolean;
|
||||
}
|
||||
|
||||
export interface ButtonGroup {
|
||||
title: string;
|
||||
items: ButtonSpec[];
|
||||
@ -33,3 +35,18 @@ export interface StyleSheetData {
|
||||
themeId: number;
|
||||
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;
|
||||
}
|
||||
|
@ -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;
|
Loading…
Reference in New Issue
Block a user