diff --git a/.eslintignore b/.eslintignore index a1f722ba8..fcad4165f 100644 --- a/.eslintignore +++ b/.eslintignore @@ -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 diff --git a/.gitignore b/.gitignore index 1e090f57a..c48bef4ae 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/packages/app-mobile/components/NoteEditor/EditLinkDialog.tsx b/packages/app-mobile/components/NoteEditor/EditLinkDialog.tsx index cb388fc2c..eb7f29710 100644 --- a/packages/app-mobile/components/NoteEditor/EditLinkDialog.tsx +++ b/packages/app-mobile/components/NoteEditor/EditLinkDialog.tsx @@ -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(''); diff --git a/packages/app-mobile/components/NoteEditor/MarkdownToolbar/MarkdownToolbar.tsx b/packages/app-mobile/components/NoteEditor/MarkdownToolbar/MarkdownToolbar.tsx new file mode 100644 index 000000000..b189d1a9e --- /dev/null +++ b/packages/app-mobile/components/NoteEditor/MarkdownToolbar/MarkdownToolbar.tsx @@ -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: ( + + ), + description: + selState.inUnorderedList ? _('Remove unordered list') : _('Create unordered list'), + active: selState.inUnorderedList, + onPress: useCallback(() => { + editorControl.toggleList(ListType.UnorderedList); + }, [editorControl]), + + priority: -2, + }); + + listButtons.push({ + icon: ( + + ), + description: + selState.inOrderedList ? _('Remove ordered list') : _('Create ordered list'), + active: selState.inOrderedList, + onPress: useCallback(() => { + editorControl.toggleList(ListType.OrderedList); + }, [editorControl]), + + priority: -2, + }); + + listButtons.push({ + icon: ( + + ), + description: + selState.inChecklist ? _('Remove task list') : _('Create task list'), + active: selState.inChecklist, + onPress: useCallback(() => { + editorControl.toggleList(ListType.CheckList); + }, [editorControl]), + + priority: -2, + }); + + + listButtons.push({ + icon: ( + + ), + description: _('Decrease indent level'), + onPress: editorControl.decreaseIndent, + + priority: -1, + }); + + listButtons.push({ + icon: ( + + ), + description: _('Increase indent level'), + onPress: editorControl.increaseIndent, + + priority: -1, + }); + + + // Inline formatting + const inlineFormattingBtns: ButtonSpec[] = []; + inlineFormattingBtns.push({ + icon: ( + + ), + description: + selState.bolded ? _('Unbold') : _('Bold text'), + active: selState.bolded, + onPress: editorControl.toggleBolded, + + priority: 3, + }); + + inlineFormattingBtns.push({ + icon: ( + + ), + 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: ( + + ), + description: + selState.inLink ? _('Edit link') : _('Create link'), + active: selState.inLink, + onPress: editorControl.showLinkDialog, + + priority: -3, + }); + + + // Actions + const actionButtons: ButtonSpec[] = []; + actionButtons.push({ + icon: ( + + ), + 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: ( + + ), + description: _('Attach'), + onPress: useCallback(() => { + onDismissKeyboard(); + props.onAttach(); + }, [props.onAttach, onDismissKeyboard]), + }); + + actionButtons.push({ + icon: ( + + ), + 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: ( + + ), + description: _('Hide keyboard'), + disabled: !keyboardVisible, + visible: hasSoftwareKeyboard && Platform.OS === 'ios', + onPress: onDismissKeyboard, + + priority: -3, + }); + + const styleData: StyleSheetData = { + styles: styles, + themeId: props.editorSettings.themeId, + }; + + return ( + <> + + + + + ); +}; + +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; diff --git a/packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleOverflowButton.tsx b/packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleOverflowButton.tsx new file mode 100644 index 000000000..9c8f01be6 --- /dev/null +++ b/packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleOverflowButton.tsx @@ -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: ( + + ), + description: + props.overflowVisible ? _('Hide more actions') : _('Show more actions'), + active: props.overflowVisible, + onPress: props.onToggleOverflowVisible, + }; + + return ( + + ); +}; +export default ToggleOverflowButton; diff --git a/packages/app-mobile/components/NoteEditor/MarkdownToolbar/Toolbar.tsx b/packages/app-mobile/components/NoteEditor/MarkdownToolbar/Toolbar.tsx new file mode 100644 index 000000000..0da4227b0 --- /dev/null +++ b/packages/app-mobile/components/NoteEditor/MarkdownToolbar/Toolbar.tsx @@ -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( + + ); + } + + 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 = ( + + ); + + 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 = ( + + {!overflowButtonsVisible ? mainButtons : null } + + ); + + return ( + + + + { !overflowButtonsVisible ? mainButtonRow : null } + + + ); +}; +export default Toolbar; diff --git a/packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarButton.tsx b/packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarButton.tsx new file mode 100644 index 000000000..e2762ee5a --- /dev/null +++ b/packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarButton.tsx @@ -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 = ( + + {spec.icon} + + ); + } else { + content = spec.icon; + } + + const sourceOnPress = spec.onPress; + const onPress = useCallback(() => { + if (!disabled) { + sourceOnPress(); + onActionComplete?.(); + } + }, [disabled, sourceOnPress, onActionComplete]); + + return ( + + { content } + + ); +}; + +export default ToolbarButton; diff --git a/packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarOverflowRows.tsx b/packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarOverflowRows.tsx new file mode 100644 index 000000000..c0b5d64f1 --- /dev/null +++ b/packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarOverflowRows.tsx @@ -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( + + ); + + // 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( + + ); + } + } + + overflowRows.push( + + + {row} + + + ); + } + + 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 = ( + + ); + + if (!props.visible) { + return null; + } + return ( + + {hasSpaceForCloseBtn ? closeButton : null} + {overflowRows} + + ); +}; +export default ToolbarOverflowRows; diff --git a/packages/app-mobile/components/NoteEditor/MarkdownToolbar/types.ts b/packages/app-mobile/components/NoteEditor/MarkdownToolbar/types.ts new file mode 100644 index 000000000..fb94cced1 --- /dev/null +++ b/packages/app-mobile/components/NoteEditor/MarkdownToolbar/types.ts @@ -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; +} diff --git a/packages/app-mobile/components/NoteEditor/NoteEditor.tsx b/packages/app-mobile/components/NoteEditor/NoteEditor.tsx index 03a8e40e3..43b4cdf60 100644 --- a/packages/app-mobile/components/NoteEditor/NoteEditor.tsx +++ b/packages/app-mobile/components/NoteEditor/NoteEditor.tsx @@ -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 +): 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) { + + ); } diff --git a/packages/app-mobile/components/ScreenHeader.tsx b/packages/app-mobile/components/ScreenHeader.tsx index 5a40289b9..83b89c25d 100644 --- a/packages/app-mobile/components/ScreenHeader.tsx +++ b/packages/app-mobile/components/ScreenHeader.tsx @@ -656,7 +656,7 @@ class ScreenHeaderComponent extends PureComponent ={ + public static defaultProps: Partial = { menuOptions: [], }; } diff --git a/packages/app-mobile/components/screens/Note.tsx b/packages/app-mobile/components/screens/Note.tsx index f859ad32e..501fe7775 100644 --- a/packages/app-mobile/components/screens/Note.tsx +++ b/packages/app-mobile/components/screens/Note.tsx @@ -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 = 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, + }} />; } }