1
0
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:
Henry Heino 2022-08-29 06:19:04 -07:00 committed by GitHub
parent c6b91cdc5d
commit b174fcf17b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 953 additions and 129 deletions

View File

@ -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.js
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.js
packages/app-mobile/components/NoteEditor/NoteEditor.js.map

18
.gitignore vendored
View File

@ -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.js
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.js
packages/app-mobile/components/NoteEditor/NoteEditor.js.map

View File

@ -20,7 +20,7 @@ interface LinkDialogProps {
const EditLinkDialog = (props: LinkDialogProps) => {
// 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 [linkURL, setLinkURL] = useState('');

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,36 +5,36 @@ import EditLinkDialog from './EditLinkDialog';
import { defaultSearchState, SearchPanel } from './SearchPanel';
const React = require('react');
const { forwardRef, useImperativeHandle } = require('react');
const { useEffect, useMemo, useState, useCallback, useRef } = require('react');
import { forwardRef, RefObject, useImperativeHandle } from 'react';
import { useEffect, useMemo, useState, useCallback, useRef } from 'react';
const { WebView } = require('react-native-webview');
const { View } = require('react-native');
import { View, ViewStyle } from 'react-native';
const { editorFont } = require('../global-style');
import SelectionFormatting from './SelectionFormatting';
import {
EditorSettings,
EditorControl,
ChangeEvent, UndoRedoDepthChangeEvent, Selection, SelectionChangeEvent,
ListType,
SearchState,
EditorSettings, EditorControl,
ChangeEvent, UndoRedoDepthChangeEvent, Selection, SelectionChangeEvent, ListType, SearchState,
} from './types';
import { _ } from '@joplin/lib/locale';
import MarkdownToolbar from './MarkdownToolbar/MarkdownToolbar';
type ChangeEventHandler = (event: ChangeEvent)=> void;
type UndoRedoDepthChangeHandler = (event: UndoRedoDepthChangeEvent)=> void;
type SelectionChangeEventHandler = (event: SelectionChangeEvent)=> void;
type OnAttachCallback = ()=> void;
interface Props {
themeId: number;
initialText: string;
initialSelection?: Selection;
style: any;
style: ViewStyle;
contentStyle?: ViewStyle;
onChange: ChangeEventHandler;
onSelectionChange: SelectionChangeEventHandler;
onUndoRedoDepthChange: UndoRedoDepthChangeHandler;
onAttach: OnAttachCallback;
}
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) {
const [source, setSource] = useState(undefined);
const webviewRef = useRef(null);
@ -172,10 +272,19 @@ function NoteEditor(props: Props, ref: any) {
const css = useCss(props.themeId);
const html = useHtml(css);
const [selectionState, setSelectionState] = useState(new SelectionFormatting());
const [searchState, setSearchState] = useState(defaultSearchState);
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) => {
webviewRef.current.injectJavaScript(`
try {
@ -189,96 +298,9 @@ function NoteEditor(props: Props, ref: any) {
true;`);
};
const editorControl: EditorControl = {
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);
},
},
};
const editorControl = useEditorControl(
injectJS, setLinkDialogVisible, setSearchState, searchStateRef
);
useImperativeHandle(ref, () => {
return editorControl;
@ -358,13 +380,12 @@ function NoteEditor(props: Props, ref: any) {
} else {
console.info('Unsupported CodeMirror message:', msg);
}
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [props.onChange]);
}, [props.onSelectionChange, props.onUndoRedoDepthChange, props.onChange, editorControl]);
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
const onError = useCallback(() => {
console.error('NoteEditor: webview error');
});
}, []);
// - `setSupportMultipleWindows` must be `true` for security reasons:
@ -385,7 +406,8 @@ function NoteEditor(props: Props, ref: any) {
<View style={{
flexGrow: 1,
flexShrink: 0,
minHeight: '40%',
minHeight: '30%',
...props.contentStyle,
}}>
<WebView
style={{
@ -411,6 +433,19 @@ function NoteEditor(props: Props, ref: any) {
searchControl={editorControl.searchControl}
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 File

@ -656,7 +656,7 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
);
}
public static defaultProps: Partial<ScreenHeaderProps> ={
public static defaultProps: Partial<ScreenHeaderProps> = {
menuOptions: [],
};
}

View File

@ -867,6 +867,27 @@ class NoteScreenComponent extends BaseScreenComponent {
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() {
const note = this.state.note;
const isTodo = note && !!note.is_todo;
@ -891,26 +912,7 @@ class NoteScreenComponent extends BaseScreenComponent {
if (canAttachPicture) {
output.push({
title: _('Attach...'),
onPress: async () => {
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();
},
onPress: () => this.showAttachMenu(),
});
}
@ -1129,6 +1131,8 @@ class NoteScreenComponent extends BaseScreenComponent {
/>
);
} else {
const editorStyle = this.styles().bodyTextInput;
bodyComponent = <NoteEditor
ref={this.editorRef}
themeId={this.props.themeId}
@ -1137,7 +1141,17 @@ class NoteScreenComponent extends BaseScreenComponent {
onChange={this.onBodyChange}
onSelectionChange={this.body_selectionChange}
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,
}}
/>;
}
}