mirror of
https://github.com/laurent22/joplin.git
synced 2025-01-02 12:47:41 +02:00
Mobile: Add Markdown toolbar (#6753)
This commit is contained in:
parent
c6b91cdc5d
commit
b174fcf17b
@ -908,6 +908,24 @@ packages/app-mobile/components/NoteEditor/CodeMirror/webviewLogger.js.map
|
|||||||
packages/app-mobile/components/NoteEditor/EditLinkDialog.d.ts
|
packages/app-mobile/components/NoteEditor/EditLinkDialog.d.ts
|
||||||
packages/app-mobile/components/NoteEditor/EditLinkDialog.js
|
packages/app-mobile/components/NoteEditor/EditLinkDialog.js
|
||||||
packages/app-mobile/components/NoteEditor/EditLinkDialog.js.map
|
packages/app-mobile/components/NoteEditor/EditLinkDialog.js.map
|
||||||
|
packages/app-mobile/components/NoteEditor/MarkdownToolbar/MarkdownToolbar.d.ts
|
||||||
|
packages/app-mobile/components/NoteEditor/MarkdownToolbar/MarkdownToolbar.js
|
||||||
|
packages/app-mobile/components/NoteEditor/MarkdownToolbar/MarkdownToolbar.js.map
|
||||||
|
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleOverflowButton.d.ts
|
||||||
|
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleOverflowButton.js
|
||||||
|
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleOverflowButton.js.map
|
||||||
|
packages/app-mobile/components/NoteEditor/MarkdownToolbar/Toolbar.d.ts
|
||||||
|
packages/app-mobile/components/NoteEditor/MarkdownToolbar/Toolbar.js
|
||||||
|
packages/app-mobile/components/NoteEditor/MarkdownToolbar/Toolbar.js.map
|
||||||
|
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarButton.d.ts
|
||||||
|
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarButton.js
|
||||||
|
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarButton.js.map
|
||||||
|
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarOverflowRows.d.ts
|
||||||
|
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarOverflowRows.js
|
||||||
|
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarOverflowRows.js.map
|
||||||
|
packages/app-mobile/components/NoteEditor/MarkdownToolbar/types.d.ts
|
||||||
|
packages/app-mobile/components/NoteEditor/MarkdownToolbar/types.js
|
||||||
|
packages/app-mobile/components/NoteEditor/MarkdownToolbar/types.js.map
|
||||||
packages/app-mobile/components/NoteEditor/NoteEditor.d.ts
|
packages/app-mobile/components/NoteEditor/NoteEditor.d.ts
|
||||||
packages/app-mobile/components/NoteEditor/NoteEditor.js
|
packages/app-mobile/components/NoteEditor/NoteEditor.js
|
||||||
packages/app-mobile/components/NoteEditor/NoteEditor.js.map
|
packages/app-mobile/components/NoteEditor/NoteEditor.js.map
|
||||||
|
18
.gitignore
vendored
18
.gitignore
vendored
@ -897,6 +897,24 @@ packages/app-mobile/components/NoteEditor/CodeMirror/webviewLogger.js.map
|
|||||||
packages/app-mobile/components/NoteEditor/EditLinkDialog.d.ts
|
packages/app-mobile/components/NoteEditor/EditLinkDialog.d.ts
|
||||||
packages/app-mobile/components/NoteEditor/EditLinkDialog.js
|
packages/app-mobile/components/NoteEditor/EditLinkDialog.js
|
||||||
packages/app-mobile/components/NoteEditor/EditLinkDialog.js.map
|
packages/app-mobile/components/NoteEditor/EditLinkDialog.js.map
|
||||||
|
packages/app-mobile/components/NoteEditor/MarkdownToolbar/MarkdownToolbar.d.ts
|
||||||
|
packages/app-mobile/components/NoteEditor/MarkdownToolbar/MarkdownToolbar.js
|
||||||
|
packages/app-mobile/components/NoteEditor/MarkdownToolbar/MarkdownToolbar.js.map
|
||||||
|
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleOverflowButton.d.ts
|
||||||
|
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleOverflowButton.js
|
||||||
|
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleOverflowButton.js.map
|
||||||
|
packages/app-mobile/components/NoteEditor/MarkdownToolbar/Toolbar.d.ts
|
||||||
|
packages/app-mobile/components/NoteEditor/MarkdownToolbar/Toolbar.js
|
||||||
|
packages/app-mobile/components/NoteEditor/MarkdownToolbar/Toolbar.js.map
|
||||||
|
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarButton.d.ts
|
||||||
|
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarButton.js
|
||||||
|
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarButton.js.map
|
||||||
|
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarOverflowRows.d.ts
|
||||||
|
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarOverflowRows.js
|
||||||
|
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarOverflowRows.js.map
|
||||||
|
packages/app-mobile/components/NoteEditor/MarkdownToolbar/types.d.ts
|
||||||
|
packages/app-mobile/components/NoteEditor/MarkdownToolbar/types.js
|
||||||
|
packages/app-mobile/components/NoteEditor/MarkdownToolbar/types.js.map
|
||||||
packages/app-mobile/components/NoteEditor/NoteEditor.d.ts
|
packages/app-mobile/components/NoteEditor/NoteEditor.d.ts
|
||||||
packages/app-mobile/components/NoteEditor/NoteEditor.js
|
packages/app-mobile/components/NoteEditor/NoteEditor.js
|
||||||
packages/app-mobile/components/NoteEditor/NoteEditor.js.map
|
packages/app-mobile/components/NoteEditor/NoteEditor.js.map
|
||||||
|
@ -20,7 +20,7 @@ interface LinkDialogProps {
|
|||||||
|
|
||||||
const EditLinkDialog = (props: LinkDialogProps) => {
|
const EditLinkDialog = (props: LinkDialogProps) => {
|
||||||
// The content of the link selected in the editor (if any)
|
// The content of the link selected in the editor (if any)
|
||||||
const editorLinkData = props.selectionState.linkData;
|
const editorLinkData = props.selectionState.linkData ?? {};
|
||||||
const [linkLabel, setLinkLabel] = useState('');
|
const [linkLabel, setLinkLabel] = useState('');
|
||||||
const [linkURL, setLinkURL] = useState('');
|
const [linkURL, setLinkURL] = useState('');
|
||||||
|
|
||||||
|
@ -0,0 +1,365 @@
|
|||||||
|
// A toolbar for the markdown editor.
|
||||||
|
|
||||||
|
const React = require('react');
|
||||||
|
import { Platform, StyleSheet, View } 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 { _ } from '@joplin/lib/locale';
|
||||||
|
import time from '@joplin/lib/time';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Keyboard, ViewStyle } from 'react-native';
|
||||||
|
import { EditorControl, EditorSettings, ListType, SearchState } from '../types';
|
||||||
|
import SelectionFormatting from '../SelectionFormatting';
|
||||||
|
import { ButtonSpec, StyleSheetData } from './types';
|
||||||
|
import Toolbar from './Toolbar';
|
||||||
|
import { buttonSize } from './ToolbarButton';
|
||||||
|
import { Theme } from '@joplin/lib/themes/type';
|
||||||
|
|
||||||
|
type OnAttachCallback = ()=> void;
|
||||||
|
|
||||||
|
interface MarkdownToolbarProps {
|
||||||
|
editorControl: EditorControl;
|
||||||
|
selectionState: SelectionFormatting;
|
||||||
|
searchState: SearchState;
|
||||||
|
editorSettings: EditorSettings;
|
||||||
|
onAttach: OnAttachCallback;
|
||||||
|
style?: ViewStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MarkdownToolbar = (props: MarkdownToolbarProps) => {
|
||||||
|
const themeData = props.editorSettings.themeData;
|
||||||
|
const styles = useStyles(props.style, themeData);
|
||||||
|
const selState = props.selectionState;
|
||||||
|
const editorControl = props.editorControl;
|
||||||
|
|
||||||
|
const headerButtons: ButtonSpec[] = [];
|
||||||
|
for (let level = 1; level <= 5; level++) {
|
||||||
|
const active = selState.headerLevel === level;
|
||||||
|
let label;
|
||||||
|
if (!active) {
|
||||||
|
label = _('Create header level %d', level);
|
||||||
|
} else {
|
||||||
|
label = _('Remove level %d header', level);
|
||||||
|
}
|
||||||
|
|
||||||
|
headerButtons.push({
|
||||||
|
icon: `H${level}`,
|
||||||
|
description: label,
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const listButtons: ButtonSpec[] = [];
|
||||||
|
listButtons.push({
|
||||||
|
icon: (
|
||||||
|
<FontAwesomeIcon name="list-ul" style={styles.text}/>
|
||||||
|
),
|
||||||
|
description:
|
||||||
|
selState.inUnorderedList ? _('Remove unordered list') : _('Create unordered list'),
|
||||||
|
active: selState.inUnorderedList,
|
||||||
|
onPress: useCallback(() => {
|
||||||
|
editorControl.toggleList(ListType.UnorderedList);
|
||||||
|
}, [editorControl]),
|
||||||
|
|
||||||
|
priority: -2,
|
||||||
|
});
|
||||||
|
|
||||||
|
listButtons.push({
|
||||||
|
icon: (
|
||||||
|
<FontAwesomeIcon name="list-ol" style={styles.text}/>
|
||||||
|
),
|
||||||
|
description:
|
||||||
|
selState.inOrderedList ? _('Remove ordered list') : _('Create ordered list'),
|
||||||
|
active: selState.inOrderedList,
|
||||||
|
onPress: useCallback(() => {
|
||||||
|
editorControl.toggleList(ListType.OrderedList);
|
||||||
|
}, [editorControl]),
|
||||||
|
|
||||||
|
priority: -2,
|
||||||
|
});
|
||||||
|
|
||||||
|
listButtons.push({
|
||||||
|
icon: (
|
||||||
|
<FontAwesomeIcon name="tasks" style={styles.text}/>
|
||||||
|
),
|
||||||
|
description:
|
||||||
|
selState.inChecklist ? _('Remove task list') : _('Create task list'),
|
||||||
|
active: selState.inChecklist,
|
||||||
|
onPress: useCallback(() => {
|
||||||
|
editorControl.toggleList(ListType.CheckList);
|
||||||
|
}, [editorControl]),
|
||||||
|
|
||||||
|
priority: -2,
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
listButtons.push({
|
||||||
|
icon: (
|
||||||
|
<AntIcon name="indent-left" style={styles.text}/>
|
||||||
|
),
|
||||||
|
description: _('Decrease indent level'),
|
||||||
|
onPress: editorControl.decreaseIndent,
|
||||||
|
|
||||||
|
priority: -1,
|
||||||
|
});
|
||||||
|
|
||||||
|
listButtons.push({
|
||||||
|
icon: (
|
||||||
|
<AntIcon name="indent-right" style={styles.text}/>
|
||||||
|
),
|
||||||
|
description: _('Increase indent level'),
|
||||||
|
onPress: editorControl.increaseIndent,
|
||||||
|
|
||||||
|
priority: -1,
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// Inline formatting
|
||||||
|
const inlineFormattingBtns: ButtonSpec[] = [];
|
||||||
|
inlineFormattingBtns.push({
|
||||||
|
icon: (
|
||||||
|
<FontAwesomeIcon name="bold" style={styles.text}/>
|
||||||
|
),
|
||||||
|
description:
|
||||||
|
selState.bolded ? _('Unbold') : _('Bold text'),
|
||||||
|
active: selState.bolded,
|
||||||
|
onPress: editorControl.toggleBolded,
|
||||||
|
|
||||||
|
priority: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
inlineFormattingBtns.push({
|
||||||
|
icon: (
|
||||||
|
<FontAwesomeIcon name="italic" style={styles.text}/>
|
||||||
|
),
|
||||||
|
description:
|
||||||
|
selState.italicized ? _('Unitalicize') : _('Italicize'),
|
||||||
|
active: selState.italicized,
|
||||||
|
onPress: editorControl.toggleItalicized,
|
||||||
|
|
||||||
|
priority: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
inlineFormattingBtns.push({
|
||||||
|
icon: '{;}',
|
||||||
|
description:
|
||||||
|
selState.inCode ? _('Remove code formatting') : _('Format as code'),
|
||||||
|
active: selState.inCode,
|
||||||
|
onPress: editorControl.toggleCode,
|
||||||
|
|
||||||
|
priority: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (props.editorSettings.katexEnabled) {
|
||||||
|
inlineFormattingBtns.push({
|
||||||
|
icon: '∑',
|
||||||
|
description:
|
||||||
|
selState.inMath ? _('Remove TeX region') : _('Create TeX region'),
|
||||||
|
active: selState.inMath,
|
||||||
|
onPress: editorControl.toggleMath,
|
||||||
|
|
||||||
|
priority: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
inlineFormattingBtns.push({
|
||||||
|
icon: (
|
||||||
|
<FontAwesomeIcon name="link" style={styles.text}/>
|
||||||
|
),
|
||||||
|
description:
|
||||||
|
selState.inLink ? _('Edit link') : _('Create link'),
|
||||||
|
active: selState.inLink,
|
||||||
|
onPress: editorControl.showLinkDialog,
|
||||||
|
|
||||||
|
priority: -3,
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// 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]),
|
||||||
|
});
|
||||||
|
|
||||||
|
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]),
|
||||||
|
});
|
||||||
|
|
||||||
|
actionButtons.push({
|
||||||
|
icon: (
|
||||||
|
<MaterialIcon name="search" style={styles.text}/>
|
||||||
|
),
|
||||||
|
description: (
|
||||||
|
props.searchState.dialogVisible ? _('Close find and replace') : _('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,
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
themeId: props.editorSettings.themeId,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Toolbar
|
||||||
|
styleSheet={styleData}
|
||||||
|
buttons={[
|
||||||
|
{
|
||||||
|
title: _('Formatting'),
|
||||||
|
items: inlineFormattingBtns,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: _('Headers'),
|
||||||
|
items: headerButtons,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: _('Lists'),
|
||||||
|
items: listButtons,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: _('Actions'),
|
||||||
|
items: actionButtons,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={{
|
||||||
|
// The keyboard on iOS can overlap the markdown toolbar.
|
||||||
|
// Add additional padding to prevent this.
|
||||||
|
height: (
|
||||||
|
Platform.OS === 'ios' && keyboardVisible ? 16 : 0
|
||||||
|
),
|
||||||
|
}}/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const useStyles = (styleProps: any, theme: Theme) => {
|
||||||
|
return useMemo(() => {
|
||||||
|
return StyleSheet.create({
|
||||||
|
button: {
|
||||||
|
width: buttonSize,
|
||||||
|
height: buttonSize,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: theme.backgroundColor,
|
||||||
|
},
|
||||||
|
buttonDisabled: {
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
|
buttonDisabledContent: {
|
||||||
|
},
|
||||||
|
buttonActive: {
|
||||||
|
backgroundColor: theme.backgroundColor3,
|
||||||
|
color: theme.color3,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.color3,
|
||||||
|
borderRadius: 6,
|
||||||
|
},
|
||||||
|
buttonActiveContent: {
|
||||||
|
color: theme.color3,
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
fontSize: 22,
|
||||||
|
color: theme.color,
|
||||||
|
},
|
||||||
|
toolbarRow: {
|
||||||
|
flex: 0,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'baseline',
|
||||||
|
justifyContent: 'center',
|
||||||
|
|
||||||
|
// Add a small amount of additional padding for button borders
|
||||||
|
height: buttonSize + 6,
|
||||||
|
...styleProps,
|
||||||
|
},
|
||||||
|
toolbarContainer: {
|
||||||
|
maxHeight: '65%',
|
||||||
|
flexShrink: 1,
|
||||||
|
},
|
||||||
|
toolbarContent: {
|
||||||
|
flexGrow: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [styleProps, theme]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MarkdownToolbar;
|
@ -0,0 +1,34 @@
|
|||||||
|
const React = require('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 {
|
||||||
|
overflowVisible: boolean;
|
||||||
|
onToggleOverflowVisible: OnToggleOverflowCallback;
|
||||||
|
styleSheet: StyleSheetData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Button that shows/hides the overflow menu.
|
||||||
|
const ToggleOverflowButton = (props: ToggleOverflowButtonProps) => {
|
||||||
|
const spec: ButtonSpec = {
|
||||||
|
icon: (
|
||||||
|
<MaterialIcon name="more-horiz" style={props.styleSheet.styles.text}/>
|
||||||
|
),
|
||||||
|
description:
|
||||||
|
props.overflowVisible ? _('Hide more actions') : _('Show more actions'),
|
||||||
|
active: props.overflowVisible,
|
||||||
|
onPress: props.onToggleOverflowVisible,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToolbarButton
|
||||||
|
styleSheet={props.styleSheet}
|
||||||
|
spec={spec}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default ToggleOverflowButton;
|
@ -0,0 +1,119 @@
|
|||||||
|
const React = require('react');
|
||||||
|
|
||||||
|
import { _ } from '@joplin/lib/locale';
|
||||||
|
import { ReactElement, useCallback, useState } from 'react';
|
||||||
|
import { AccessibilityInfo, LayoutChangeEvent, ScrollView, View } from 'react-native';
|
||||||
|
import ToggleOverflowButton from './ToggleOverflowButton';
|
||||||
|
import ToolbarButton, { buttonSize } from './ToolbarButton';
|
||||||
|
import ToolbarOverflowRows from './ToolbarOverflowRows';
|
||||||
|
import { ButtonGroup, ButtonSpec, StyleSheetData } from './types';
|
||||||
|
|
||||||
|
interface ToolbarProps {
|
||||||
|
buttons: ButtonGroup[];
|
||||||
|
styleSheet: StyleSheetData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Displays a list of buttons with an overflow menu.
|
||||||
|
const Toolbar = (props: ToolbarProps) => {
|
||||||
|
const [overflowButtonsVisible, setOverflowPopupVisible] = useState(false);
|
||||||
|
const [maxButtonsEachSide, setMaxButtonsEachSide] = useState(0);
|
||||||
|
|
||||||
|
const allButtonSpecs = props.buttons.reduce((accumulator: ButtonSpec[], current: ButtonGroup) => {
|
||||||
|
const newItems: ButtonSpec[] = [];
|
||||||
|
for (const item of current.items) {
|
||||||
|
if (item.visible ?? true) {
|
||||||
|
newItems.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return accumulator.concat(...newItems);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Sort from highest priority to lowest
|
||||||
|
allButtonSpecs.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
|
||||||
|
|
||||||
|
const allButtonComponents: ReactElement[] = [];
|
||||||
|
let key = 0;
|
||||||
|
for (const spec of allButtonSpecs) {
|
||||||
|
key++;
|
||||||
|
allButtonComponents.push(
|
||||||
|
<ToolbarButton
|
||||||
|
key={key.toString()}
|
||||||
|
styleSheet={props.styleSheet}
|
||||||
|
spec={spec}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const onContainerLayout = useCallback((event: LayoutChangeEvent) => {
|
||||||
|
const containerWidth = event.nativeEvent.layout.width;
|
||||||
|
const maxButtonsTotal = Math.floor(containerWidth / buttonSize);
|
||||||
|
setMaxButtonsEachSide(Math.floor(
|
||||||
|
Math.min((maxButtonsTotal - 1) / 2, allButtonSpecs.length / 2)
|
||||||
|
));
|
||||||
|
}, [allButtonSpecs.length]);
|
||||||
|
|
||||||
|
const onToggleOverflowVisible = useCallback(() => {
|
||||||
|
AccessibilityInfo.announceForAccessibility(
|
||||||
|
!overflowButtonsVisible
|
||||||
|
? _('Opened toolbar overflow menu')
|
||||||
|
: _('Closed toolbar overflow menu')
|
||||||
|
);
|
||||||
|
setOverflowPopupVisible(!overflowButtonsVisible);
|
||||||
|
}, [overflowButtonsVisible]);
|
||||||
|
|
||||||
|
const toggleOverflowButton = (
|
||||||
|
<ToggleOverflowButton
|
||||||
|
key={(++key).toString()}
|
||||||
|
styleSheet={props.styleSheet}
|
||||||
|
overflowVisible={overflowButtonsVisible}
|
||||||
|
onToggleOverflowVisible={onToggleOverflowVisible}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const mainButtons: ReactElement[] = [];
|
||||||
|
if (maxButtonsEachSide < allButtonComponents.length) {
|
||||||
|
// We want the menu to look something like this:
|
||||||
|
// B I (…) 🔍 ⌨
|
||||||
|
// where (…) shows/hides overflow.
|
||||||
|
// Add from the left and right of [allButtonComponents] to ensure that
|
||||||
|
// the (…) button is in the center:
|
||||||
|
mainButtons.push(...allButtonComponents.slice(0, maxButtonsEachSide));
|
||||||
|
mainButtons.push(toggleOverflowButton);
|
||||||
|
mainButtons.push(...allButtonComponents.slice(-maxButtonsEachSide));
|
||||||
|
} else {
|
||||||
|
mainButtons.push(...allButtonComponents);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = props.styleSheet.styles;
|
||||||
|
const mainButtonRow = (
|
||||||
|
<View style={styles.toolbarRow}>
|
||||||
|
{!overflowButtonsVisible ? mainButtons : null }
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
...styles.toolbarContainer,
|
||||||
|
|
||||||
|
// The number of buttons displayed is based on the width of the
|
||||||
|
// container. As such, we can't base the container's width on the
|
||||||
|
// size of its content.
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
onLayout={onContainerLayout}
|
||||||
|
>
|
||||||
|
<ScrollView>
|
||||||
|
<ToolbarOverflowRows
|
||||||
|
buttonGroups={props.buttons}
|
||||||
|
styleSheet={props.styleSheet}
|
||||||
|
visible={overflowButtonsVisible}
|
||||||
|
onToggleOverflow={onToggleOverflowVisible}
|
||||||
|
/>
|
||||||
|
{ !overflowButtonsVisible ? mainButtonRow : null }
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default Toolbar;
|
@ -0,0 +1,64 @@
|
|||||||
|
import React = require('react');
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { Text, TextStyle } from 'react-native';
|
||||||
|
import { ButtonSpec, StyleSheetData } from './types';
|
||||||
|
import CustomButton from '../../CustomButton';
|
||||||
|
|
||||||
|
export const buttonSize = 54;
|
||||||
|
|
||||||
|
interface ToolbarButtonProps {
|
||||||
|
styleSheet: StyleSheetData;
|
||||||
|
style?: TextStyle;
|
||||||
|
spec: ButtonSpec;
|
||||||
|
onActionComplete?: ()=> void;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 sourceOnPress = spec.onPress;
|
||||||
|
const onPress = useCallback(() => {
|
||||||
|
if (!disabled) {
|
||||||
|
sourceOnPress();
|
||||||
|
onActionComplete?.();
|
||||||
|
}
|
||||||
|
}, [disabled, sourceOnPress, onActionComplete]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CustomButton
|
||||||
|
style={{
|
||||||
|
...styles.button, ...activatedStyle, ...disabledStyle, ...style,
|
||||||
|
...(!visible ? { opacity: 0 } : null),
|
||||||
|
}}
|
||||||
|
themeId={styleSheet.themeId}
|
||||||
|
onPress={onPress}
|
||||||
|
description={ spec.description }
|
||||||
|
accessibilityRole="button"
|
||||||
|
disabled={ disabled }
|
||||||
|
>
|
||||||
|
{ content }
|
||||||
|
</CustomButton>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ToolbarButton;
|
@ -0,0 +1,122 @@
|
|||||||
|
import { _ } from '@joplin/lib/locale';
|
||||||
|
import { ReactElement, useCallback, useState } from 'react';
|
||||||
|
import { LayoutChangeEvent, ScrollView, View } from 'react-native';
|
||||||
|
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[];
|
||||||
|
styleSheet: StyleSheetData;
|
||||||
|
visible: boolean;
|
||||||
|
|
||||||
|
// Should be created using useCallback
|
||||||
|
onToggleOverflow: OnToggleOverflowCallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contains buttons that overflow the available space.
|
||||||
|
// Displays all buttons in [props.buttonGroups] if [props.visible].
|
||||||
|
// Otherwise, displays nothing.
|
||||||
|
const ToolbarOverflowRows = (props: OverflowPopupProps) => {
|
||||||
|
const overflowRows: ReactElement[] = [];
|
||||||
|
|
||||||
|
let key = 0;
|
||||||
|
for (let i = 0; i < props.buttonGroups.length; i++) {
|
||||||
|
key++;
|
||||||
|
const row: ReactElement[] = [];
|
||||||
|
|
||||||
|
const group = props.buttonGroups[i];
|
||||||
|
for (let j = 0; j < group.items.length; j++) {
|
||||||
|
key++;
|
||||||
|
|
||||||
|
const buttonSpec = group.items[j];
|
||||||
|
row.push(
|
||||||
|
<ToolbarButton
|
||||||
|
key={key.toString()}
|
||||||
|
styleSheet={props.styleSheet}
|
||||||
|
spec={buttonSpec}
|
||||||
|
|
||||||
|
// After invoking this button's action, hide the overflow menu
|
||||||
|
onActionComplete={props.onToggleOverflow}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
row.push(
|
||||||
|
<ToggleOverflowButton
|
||||||
|
key={(++key).toString()}
|
||||||
|
styleSheet={props.styleSheet}
|
||||||
|
overflowVisible={true}
|
||||||
|
onToggleOverflowVisible={props.onToggleOverflow}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
overflowRows.push(
|
||||||
|
<View
|
||||||
|
key={key.toString()}
|
||||||
|
>
|
||||||
|
<ScrollView
|
||||||
|
horizontal={true}
|
||||||
|
contentContainerStyle={props.styleSheet.styles.toolbarContent}
|
||||||
|
>
|
||||||
|
{row}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [hasSpaceForCloseBtn, setHasSpaceForCloseBtn] = useState(true);
|
||||||
|
const onContainerLayout = useCallback((event: LayoutChangeEvent) => {
|
||||||
|
if (props.buttonGroups.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add 1 to account for the close button
|
||||||
|
const totalButtonCount = props.buttonGroups[0].items.length + 1;
|
||||||
|
|
||||||
|
const newWidth = event.nativeEvent.layout.width;
|
||||||
|
setHasSpaceForCloseBtn(newWidth > totalButtonCount * buttonSize);
|
||||||
|
}, [setHasSpaceForCloseBtn, props.buttonGroups]);
|
||||||
|
|
||||||
|
const closeButtonSpec: ButtonSpec = {
|
||||||
|
icon: '⨉',
|
||||||
|
description: _('Close'),
|
||||||
|
onPress: props.onToggleOverflow,
|
||||||
|
};
|
||||||
|
const closeButton = (
|
||||||
|
<ToolbarButton
|
||||||
|
styleSheet={props.styleSheet}
|
||||||
|
spec={closeButtonSpec}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: 0,
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!props.visible) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: props.buttonGroups.length * buttonSize,
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
onLayout={onContainerLayout}
|
||||||
|
>
|
||||||
|
{hasSpaceForCloseBtn ? closeButton : null}
|
||||||
|
{overflowRows}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default ToolbarOverflowRows;
|
@ -0,0 +1,35 @@
|
|||||||
|
|
||||||
|
import { ReactElement } from 'react';
|
||||||
|
|
||||||
|
export type OnPressListener = ()=> void;
|
||||||
|
|
||||||
|
export interface ButtonSpec {
|
||||||
|
// Either text that will be shown in place of an icon or a component.
|
||||||
|
icon: string | ReactElement;
|
||||||
|
|
||||||
|
// Tooltip/accessibility label
|
||||||
|
description: string;
|
||||||
|
onPress: OnPressListener;
|
||||||
|
|
||||||
|
// Priority for showing the button in the main toolbar.
|
||||||
|
// Higher priority => more likely to be shown on the left of the toolbar
|
||||||
|
// Lower (negative) priority => more likely to be shown on the right side of the
|
||||||
|
// toolbar.
|
||||||
|
priority?: number;
|
||||||
|
|
||||||
|
// True if the button is connected to an enabled action.
|
||||||
|
// E.g. the cursor is in a header and the button is a header button.
|
||||||
|
active?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
visible?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ButtonGroup {
|
||||||
|
title: string;
|
||||||
|
items: ButtonSpec[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StyleSheetData {
|
||||||
|
themeId: number;
|
||||||
|
styles: any;
|
||||||
|
}
|
@ -5,36 +5,36 @@ import EditLinkDialog from './EditLinkDialog';
|
|||||||
import { defaultSearchState, SearchPanel } from './SearchPanel';
|
import { defaultSearchState, SearchPanel } from './SearchPanel';
|
||||||
|
|
||||||
const React = require('react');
|
const React = require('react');
|
||||||
const { forwardRef, useImperativeHandle } = require('react');
|
import { forwardRef, RefObject, useImperativeHandle } from 'react';
|
||||||
const { useEffect, useMemo, useState, useCallback, useRef } = require('react');
|
import { useEffect, useMemo, useState, useCallback, useRef } from 'react';
|
||||||
const { WebView } = require('react-native-webview');
|
const { WebView } = require('react-native-webview');
|
||||||
const { View } = require('react-native');
|
import { View, ViewStyle } from 'react-native';
|
||||||
const { editorFont } = require('../global-style');
|
const { editorFont } = require('../global-style');
|
||||||
|
|
||||||
import SelectionFormatting from './SelectionFormatting';
|
import SelectionFormatting from './SelectionFormatting';
|
||||||
import {
|
import {
|
||||||
EditorSettings,
|
EditorSettings, EditorControl,
|
||||||
EditorControl,
|
ChangeEvent, UndoRedoDepthChangeEvent, Selection, SelectionChangeEvent, ListType, SearchState,
|
||||||
|
|
||||||
ChangeEvent, UndoRedoDepthChangeEvent, Selection, SelectionChangeEvent,
|
|
||||||
ListType,
|
|
||||||
SearchState,
|
|
||||||
} from './types';
|
} from './types';
|
||||||
import { _ } from '@joplin/lib/locale';
|
import { _ } from '@joplin/lib/locale';
|
||||||
|
import MarkdownToolbar from './MarkdownToolbar/MarkdownToolbar';
|
||||||
|
|
||||||
type ChangeEventHandler = (event: ChangeEvent)=> void;
|
type ChangeEventHandler = (event: ChangeEvent)=> void;
|
||||||
type UndoRedoDepthChangeHandler = (event: UndoRedoDepthChangeEvent)=> void;
|
type UndoRedoDepthChangeHandler = (event: UndoRedoDepthChangeEvent)=> void;
|
||||||
type SelectionChangeEventHandler = (event: SelectionChangeEvent)=> void;
|
type SelectionChangeEventHandler = (event: SelectionChangeEvent)=> void;
|
||||||
|
type OnAttachCallback = ()=> void;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
themeId: number;
|
themeId: number;
|
||||||
initialText: string;
|
initialText: string;
|
||||||
initialSelection?: Selection;
|
initialSelection?: Selection;
|
||||||
style: any;
|
style: ViewStyle;
|
||||||
|
contentStyle?: ViewStyle;
|
||||||
|
|
||||||
onChange: ChangeEventHandler;
|
onChange: ChangeEventHandler;
|
||||||
onSelectionChange: SelectionChangeEventHandler;
|
onSelectionChange: SelectionChangeEventHandler;
|
||||||
onUndoRedoDepthChange: UndoRedoDepthChangeHandler;
|
onUndoRedoDepthChange: UndoRedoDepthChangeHandler;
|
||||||
|
onAttach: OnAttachCallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fontFamilyFromSettings() {
|
function fontFamilyFromSettings() {
|
||||||
@ -106,6 +106,106 @@ function editorTheme(themeId: number) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OnInjectJSCallback = (js: string)=> void;
|
||||||
|
type OnSetVisibleCallback = (visible: boolean)=> void;
|
||||||
|
type OnSearchStateChangeCallback = (state: SearchState)=> void;
|
||||||
|
const useEditorControl = (
|
||||||
|
injectJS: OnInjectJSCallback, setLinkDialogVisible: OnSetVisibleCallback,
|
||||||
|
setSearchState: OnSearchStateChangeCallback, searchStateRef: RefObject<SearchState>
|
||||||
|
): EditorControl => {
|
||||||
|
return useMemo(() => {
|
||||||
|
return {
|
||||||
|
undo() {
|
||||||
|
injectJS('cm.undo();');
|
||||||
|
},
|
||||||
|
redo() {
|
||||||
|
injectJS('cm.redo();');
|
||||||
|
},
|
||||||
|
select(anchor: number, head: number) {
|
||||||
|
injectJS(
|
||||||
|
`cm.select(${JSON.stringify(anchor)}, ${JSON.stringify(head)});`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
insertText(text: string) {
|
||||||
|
injectJS(`cm.insertText(${JSON.stringify(text)});`);
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleBolded() {
|
||||||
|
injectJS('cm.toggleBolded();');
|
||||||
|
},
|
||||||
|
toggleItalicized() {
|
||||||
|
injectJS('cm.toggleItalicized();');
|
||||||
|
},
|
||||||
|
toggleList(listType: ListType) {
|
||||||
|
injectJS(`cm.toggleList(${JSON.stringify(listType)});`);
|
||||||
|
},
|
||||||
|
toggleCode() {
|
||||||
|
injectJS('cm.toggleCode();');
|
||||||
|
},
|
||||||
|
toggleMath() {
|
||||||
|
injectJS('cm.toggleMath();');
|
||||||
|
},
|
||||||
|
toggleHeaderLevel(level: number) {
|
||||||
|
injectJS(`cm.toggleHeaderLevel(${level});`);
|
||||||
|
},
|
||||||
|
increaseIndent() {
|
||||||
|
injectJS('cm.increaseIndent();');
|
||||||
|
},
|
||||||
|
decreaseIndent() {
|
||||||
|
injectJS('cm.decreaseIndent();');
|
||||||
|
},
|
||||||
|
updateLink(label: string, url: string) {
|
||||||
|
injectJS(`cm.updateLink(
|
||||||
|
${JSON.stringify(label)},
|
||||||
|
${JSON.stringify(url)}
|
||||||
|
);`);
|
||||||
|
},
|
||||||
|
scrollSelectionIntoView() {
|
||||||
|
injectJS('cm.scrollSelectionIntoView();');
|
||||||
|
},
|
||||||
|
showLinkDialog() {
|
||||||
|
setLinkDialogVisible(true);
|
||||||
|
},
|
||||||
|
hideLinkDialog() {
|
||||||
|
setLinkDialogVisible(false);
|
||||||
|
},
|
||||||
|
hideKeyboard() {
|
||||||
|
injectJS('document.activeElement?.blur();');
|
||||||
|
},
|
||||||
|
searchControl: {
|
||||||
|
findNext() {
|
||||||
|
injectJS('cm.searchControl.findNext();');
|
||||||
|
},
|
||||||
|
findPrevious() {
|
||||||
|
injectJS('cm.searchControl.findPrevious();');
|
||||||
|
},
|
||||||
|
replaceCurrent() {
|
||||||
|
injectJS('cm.searchControl.replaceCurrent();');
|
||||||
|
},
|
||||||
|
replaceAll() {
|
||||||
|
injectJS('cm.searchControl.replaceAll();');
|
||||||
|
},
|
||||||
|
setSearchState(state: SearchState) {
|
||||||
|
injectJS(`cm.searchControl.setSearchState(${JSON.stringify(state)})`);
|
||||||
|
setSearchState(state);
|
||||||
|
},
|
||||||
|
showSearch() {
|
||||||
|
setSearchState({
|
||||||
|
...searchStateRef.current,
|
||||||
|
dialogVisible: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
hideSearch() {
|
||||||
|
setSearchState({
|
||||||
|
...searchStateRef.current,
|
||||||
|
dialogVisible: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, [injectJS, searchStateRef, setLinkDialogVisible, setSearchState]);
|
||||||
|
};
|
||||||
|
|
||||||
function NoteEditor(props: Props, ref: any) {
|
function NoteEditor(props: Props, ref: any) {
|
||||||
const [source, setSource] = useState(undefined);
|
const [source, setSource] = useState(undefined);
|
||||||
const webviewRef = useRef(null);
|
const webviewRef = useRef(null);
|
||||||
@ -172,10 +272,19 @@ function NoteEditor(props: Props, ref: any) {
|
|||||||
const css = useCss(props.themeId);
|
const css = useCss(props.themeId);
|
||||||
const html = useHtml(css);
|
const html = useHtml(css);
|
||||||
const [selectionState, setSelectionState] = useState(new SelectionFormatting());
|
const [selectionState, setSelectionState] = useState(new SelectionFormatting());
|
||||||
const [searchState, setSearchState] = useState(defaultSearchState);
|
|
||||||
const [linkDialogVisible, setLinkDialogVisible] = useState(false);
|
const [linkDialogVisible, setLinkDialogVisible] = useState(false);
|
||||||
|
const [searchState, setSearchState] = useState(defaultSearchState);
|
||||||
|
|
||||||
// / Runs [js] in the context of the CodeMirror frame.
|
// Having a [searchStateRef] allows [editorControl] to not be re-created
|
||||||
|
// whenever [searchState] changes.
|
||||||
|
const searchStateRef = useRef(defaultSearchState);
|
||||||
|
|
||||||
|
// Keep the reference and the [searchState] in sync
|
||||||
|
useEffect(() => {
|
||||||
|
searchStateRef.current = searchState;
|
||||||
|
}, [searchState]);
|
||||||
|
|
||||||
|
// Runs [js] in the context of the CodeMirror frame.
|
||||||
const injectJS = (js: string) => {
|
const injectJS = (js: string) => {
|
||||||
webviewRef.current.injectJavaScript(`
|
webviewRef.current.injectJavaScript(`
|
||||||
try {
|
try {
|
||||||
@ -189,96 +298,9 @@ function NoteEditor(props: Props, ref: any) {
|
|||||||
true;`);
|
true;`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const editorControl = useEditorControl(
|
||||||
const editorControl: EditorControl = {
|
injectJS, setLinkDialogVisible, setSearchState, searchStateRef
|
||||||
undo() {
|
);
|
||||||
injectJS('cm.undo();');
|
|
||||||
},
|
|
||||||
redo() {
|
|
||||||
injectJS('cm.redo();');
|
|
||||||
},
|
|
||||||
select(anchor: number, head: number) {
|
|
||||||
injectJS(
|
|
||||||
`cm.select(${JSON.stringify(anchor)}, ${JSON.stringify(head)});`
|
|
||||||
);
|
|
||||||
},
|
|
||||||
insertText(text: string) {
|
|
||||||
injectJS(`cm.insertText(${JSON.stringify(text)});`);
|
|
||||||
},
|
|
||||||
|
|
||||||
toggleBolded() {
|
|
||||||
injectJS('cm.toggleBolded();');
|
|
||||||
},
|
|
||||||
toggleItalicized() {
|
|
||||||
injectJS('cm.toggleItalicized();');
|
|
||||||
},
|
|
||||||
toggleList(listType: ListType) {
|
|
||||||
injectJS(`cm.toggleList(${JSON.stringify(listType)});`);
|
|
||||||
},
|
|
||||||
toggleCode() {
|
|
||||||
injectJS('cm.toggleCode();');
|
|
||||||
},
|
|
||||||
toggleMath() {
|
|
||||||
injectJS('cm.toggleMath();');
|
|
||||||
},
|
|
||||||
toggleHeaderLevel(level: number) {
|
|
||||||
injectJS(`cm.toggleHeaderLevel(${level});`);
|
|
||||||
},
|
|
||||||
increaseIndent() {
|
|
||||||
injectJS('cm.increaseIndent();');
|
|
||||||
},
|
|
||||||
decreaseIndent() {
|
|
||||||
injectJS('cm.decreaseIndent();');
|
|
||||||
},
|
|
||||||
updateLink(label: string, url: string) {
|
|
||||||
injectJS(`cm.updateLink(
|
|
||||||
${JSON.stringify(label)},
|
|
||||||
${JSON.stringify(url)}
|
|
||||||
);`);
|
|
||||||
},
|
|
||||||
scrollSelectionIntoView() {
|
|
||||||
injectJS('cm.scrollSelectionIntoView();');
|
|
||||||
},
|
|
||||||
showLinkDialog() {
|
|
||||||
setLinkDialogVisible(true);
|
|
||||||
},
|
|
||||||
hideLinkDialog() {
|
|
||||||
setLinkDialogVisible(false);
|
|
||||||
},
|
|
||||||
hideKeyboard() {
|
|
||||||
injectJS('document.activeElement?.blur();');
|
|
||||||
},
|
|
||||||
searchControl: {
|
|
||||||
findNext() {
|
|
||||||
injectJS('cm.searchControl.findNext();');
|
|
||||||
},
|
|
||||||
findPrevious() {
|
|
||||||
injectJS('cm.searchControl.findPrevious();');
|
|
||||||
},
|
|
||||||
replaceCurrent() {
|
|
||||||
injectJS('cm.searchControl.replaceCurrent();');
|
|
||||||
},
|
|
||||||
replaceAll() {
|
|
||||||
injectJS('cm.searchControl.replaceAll();');
|
|
||||||
},
|
|
||||||
setSearchState(state: SearchState) {
|
|
||||||
injectJS(`cm.searchControl.setSearchState(${JSON.stringify(state)})`);
|
|
||||||
setSearchState(state);
|
|
||||||
},
|
|
||||||
showSearch() {
|
|
||||||
const newSearchState: SearchState = Object.assign({}, searchState);
|
|
||||||
newSearchState.dialogVisible = true;
|
|
||||||
|
|
||||||
setSearchState(newSearchState);
|
|
||||||
},
|
|
||||||
hideSearch() {
|
|
||||||
const newSearchState: SearchState = Object.assign({}, searchState);
|
|
||||||
newSearchState.dialogVisible = false;
|
|
||||||
|
|
||||||
setSearchState(newSearchState);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
useImperativeHandle(ref, () => {
|
useImperativeHandle(ref, () => {
|
||||||
return editorControl;
|
return editorControl;
|
||||||
@ -358,13 +380,12 @@ function NoteEditor(props: Props, ref: any) {
|
|||||||
} else {
|
} else {
|
||||||
console.info('Unsupported CodeMirror message:', msg);
|
console.info('Unsupported CodeMirror message:', msg);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
}, [props.onSelectionChange, props.onUndoRedoDepthChange, props.onChange, editorControl]);
|
||||||
}, [props.onChange]);
|
|
||||||
|
|
||||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||||
const onError = useCallback(() => {
|
const onError = useCallback(() => {
|
||||||
console.error('NoteEditor: webview error');
|
console.error('NoteEditor: webview error');
|
||||||
});
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
// - `setSupportMultipleWindows` must be `true` for security reasons:
|
// - `setSupportMultipleWindows` must be `true` for security reasons:
|
||||||
@ -385,7 +406,8 @@ function NoteEditor(props: Props, ref: any) {
|
|||||||
<View style={{
|
<View style={{
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
minHeight: '40%',
|
minHeight: '30%',
|
||||||
|
...props.contentStyle,
|
||||||
}}>
|
}}>
|
||||||
<WebView
|
<WebView
|
||||||
style={{
|
style={{
|
||||||
@ -411,6 +433,19 @@ function NoteEditor(props: Props, ref: any) {
|
|||||||
searchControl={editorControl.searchControl}
|
searchControl={editorControl.searchControl}
|
||||||
searchState={searchState}
|
searchState={searchState}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<MarkdownToolbar
|
||||||
|
style={{
|
||||||
|
// Don't show the markdown toolbar if there isn't enough space
|
||||||
|
// for it:
|
||||||
|
flexShrink: 1,
|
||||||
|
}}
|
||||||
|
editorSettings={editorSettings}
|
||||||
|
editorControl={editorControl}
|
||||||
|
selectionState={selectionState}
|
||||||
|
searchState={searchState}
|
||||||
|
onAttach={props.onAttach}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -656,7 +656,7 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static defaultProps: Partial<ScreenHeaderProps> ={
|
public static defaultProps: Partial<ScreenHeaderProps> = {
|
||||||
menuOptions: [],
|
menuOptions: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -867,6 +867,27 @@ class NoteScreenComponent extends BaseScreenComponent {
|
|||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async showAttachMenu() {
|
||||||
|
const buttons = [];
|
||||||
|
|
||||||
|
// On iOS, it will show "local files", which means certain files saved from the browser
|
||||||
|
// and the iCloud files, but it doesn't include photos and images from the CameraRoll
|
||||||
|
//
|
||||||
|
// On Android, it will depend on the phone, but usually it will allow browing all files and photos.
|
||||||
|
buttons.push({ text: _('Attach file'), id: 'attachFile' });
|
||||||
|
|
||||||
|
// Disabled on Android because it doesn't work due to permission issues, but enabled on iOS
|
||||||
|
// because that's only way to browse photos from the camera roll.
|
||||||
|
if (Platform.OS === 'ios') buttons.push({ text: _('Attach photo'), id: 'attachPhoto' });
|
||||||
|
buttons.push({ text: _('Take photo'), id: 'takePhoto' });
|
||||||
|
|
||||||
|
const buttonId = await dialogs.pop(this, _('Choose an option'), buttons);
|
||||||
|
|
||||||
|
if (buttonId === 'takePhoto') this.takePhoto_onPress();
|
||||||
|
if (buttonId === 'attachFile') void this.attachFile_onPress();
|
||||||
|
if (buttonId === 'attachPhoto') void this.attachPhoto_onPress();
|
||||||
|
}
|
||||||
|
|
||||||
menuOptions() {
|
menuOptions() {
|
||||||
const note = this.state.note;
|
const note = this.state.note;
|
||||||
const isTodo = note && !!note.is_todo;
|
const isTodo = note && !!note.is_todo;
|
||||||
@ -891,26 +912,7 @@ class NoteScreenComponent extends BaseScreenComponent {
|
|||||||
if (canAttachPicture) {
|
if (canAttachPicture) {
|
||||||
output.push({
|
output.push({
|
||||||
title: _('Attach...'),
|
title: _('Attach...'),
|
||||||
onPress: async () => {
|
onPress: () => this.showAttachMenu(),
|
||||||
const buttons = [];
|
|
||||||
|
|
||||||
// On iOS, it will show "local files", which means certain files saved from the browser
|
|
||||||
// and the iCloud files, but it doesn't include photos and images from the CameraRoll
|
|
||||||
//
|
|
||||||
// On Android, it will depend on the phone, but usually it will allow browing all files and photos.
|
|
||||||
buttons.push({ text: _('Attach file'), id: 'attachFile' });
|
|
||||||
|
|
||||||
// Disabled on Android because it doesn't work due to permission issues, but enabled on iOS
|
|
||||||
// because that's only way to browse photos from the camera roll.
|
|
||||||
if (Platform.OS === 'ios') buttons.push({ text: _('Attach photo'), id: 'attachPhoto' });
|
|
||||||
buttons.push({ text: _('Take photo'), id: 'takePhoto' });
|
|
||||||
|
|
||||||
const buttonId = await dialogs.pop(this, _('Choose an option'), buttons);
|
|
||||||
|
|
||||||
if (buttonId === 'takePhoto') this.takePhoto_onPress();
|
|
||||||
if (buttonId === 'attachFile') void this.attachFile_onPress();
|
|
||||||
if (buttonId === 'attachPhoto') void this.attachPhoto_onPress();
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1129,6 +1131,8 @@ class NoteScreenComponent extends BaseScreenComponent {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
const editorStyle = this.styles().bodyTextInput;
|
||||||
|
|
||||||
bodyComponent = <NoteEditor
|
bodyComponent = <NoteEditor
|
||||||
ref={this.editorRef}
|
ref={this.editorRef}
|
||||||
themeId={this.props.themeId}
|
themeId={this.props.themeId}
|
||||||
@ -1137,7 +1141,17 @@ class NoteScreenComponent extends BaseScreenComponent {
|
|||||||
onChange={this.onBodyChange}
|
onChange={this.onBodyChange}
|
||||||
onSelectionChange={this.body_selectionChange}
|
onSelectionChange={this.body_selectionChange}
|
||||||
onUndoRedoDepthChange={this.onUndoRedoDepthChange}
|
onUndoRedoDepthChange={this.onUndoRedoDepthChange}
|
||||||
style={this.styles().bodyTextInput}
|
onAttach={() => this.showAttachMenu()}
|
||||||
|
style={{
|
||||||
|
...editorStyle,
|
||||||
|
paddingLeft: 0,
|
||||||
|
paddingRight: 0,
|
||||||
|
}}
|
||||||
|
contentStyle={{
|
||||||
|
// Apply padding to the editor's content, but not the toolbar.
|
||||||
|
paddingLeft: editorStyle.paddingLeft,
|
||||||
|
paddingRight: editorStyle.paddingRight,
|
||||||
|
}}
|
||||||
/>;
|
/>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user