1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-24 10:27:10 +02:00

Merge remote-tracking branch 'upstream/dev' into pr/sync/sync-status-message-improvement

This commit is contained in:
Henry Heino 2024-12-11 07:44:53 -08:00
commit d123f38a9e
75 changed files with 1445 additions and 1165 deletions

View File

@ -596,6 +596,16 @@ packages/app-mobile/components/DialogManager/types.js
packages/app-mobile/components/DismissibleDialog.js
packages/app-mobile/components/Dropdown.test.js
packages/app-mobile/components/Dropdown.js
packages/app-mobile/components/EditorToolbar/EditorToolbar.test.js
packages/app-mobile/components/EditorToolbar/EditorToolbar.js
packages/app-mobile/components/EditorToolbar/ToolbarButton.js
packages/app-mobile/components/EditorToolbar/ToolbarEditorDialog.js
packages/app-mobile/components/EditorToolbar/testing/mockCommandRuntimes.js
packages/app-mobile/components/EditorToolbar/types.js
packages/app-mobile/components/EditorToolbar/utils/allToolbarCommandNamesFromState.js
packages/app-mobile/components/EditorToolbar/utils/isSelected.js
packages/app-mobile/components/EditorToolbar/utils/selectedCommandNamesFromState.js
packages/app-mobile/components/EditorToolbar/utils/toolbarButtonsFromState.js
packages/app-mobile/components/ExtendedWebView/index.jest.js
packages/app-mobile/components/ExtendedWebView/index.js
packages/app-mobile/components/ExtendedWebView/index.web.js
@ -634,18 +644,6 @@ packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/startAutosaveLoop.
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/types.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/watchEditorForTemplateChanges.js
packages/app-mobile/components/NoteEditor/ImageEditor/promptRestoreAutosave.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/MarkdownToolbar.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleOverflowButton.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleSpaceButton.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/Toolbar.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarButton.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarOverflowRows.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/buttons/useActionButtons.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/buttons/useHeaderButtons.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/buttons/useInlineFormattingButtons.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/buttons/useListButtons.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/buttons/usePluginButtons.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/types.js
packages/app-mobile/components/NoteEditor/NoteEditor.test.js
packages/app-mobile/components/NoteEditor/NoteEditor.js
packages/app-mobile/components/NoteEditor/SearchPanel.js
@ -653,7 +651,6 @@ packages/app-mobile/components/NoteEditor/commandDeclarations.js
packages/app-mobile/components/NoteEditor/hooks/useCodeMirrorPlugins.js
packages/app-mobile/components/NoteEditor/hooks/useEditorCommandHandler.test.js
packages/app-mobile/components/NoteEditor/hooks/useEditorCommandHandler.js
packages/app-mobile/components/NoteEditor/hooks/useKeyboardVisible.js
packages/app-mobile/components/NoteEditor/types.js
packages/app-mobile/components/NoteItem.js
packages/app-mobile/components/NoteList.js
@ -670,6 +667,7 @@ packages/app-mobile/components/SelectDateTimeDialog.js
packages/app-mobile/components/SideMenu.js
packages/app-mobile/components/SideMenuContentNote.js
packages/app-mobile/components/TextInput.js
packages/app-mobile/components/ToggleSpaceButton.js
packages/app-mobile/components/accessibility/AccessibleModalMenu.js
packages/app-mobile/components/accessibility/AccessibleView.js
packages/app-mobile/components/app-nav.js
@ -753,8 +751,13 @@ packages/app-mobile/components/screens/ConfigScreen/plugins/utils/useUpdateState
packages/app-mobile/components/screens/ConfigScreen/types.js
packages/app-mobile/components/screens/JoplinCloudLoginScreen.js
packages/app-mobile/components/screens/LogScreen.js
packages/app-mobile/components/screens/Note.test.js
packages/app-mobile/components/screens/Note.js
packages/app-mobile/components/screens/Note/Note.test.js
packages/app-mobile/components/screens/Note/Note.js
packages/app-mobile/components/screens/Note/commands/attachFile.js
packages/app-mobile/components/screens/Note/commands/hideKeyboard.js
packages/app-mobile/components/screens/Note/commands/index.js
packages/app-mobile/components/screens/Note/commands/setTags.js
packages/app-mobile/components/screens/Note/types.js
packages/app-mobile/components/screens/NoteTagsDialog.js
packages/app-mobile/components/screens/Notes.js
packages/app-mobile/components/screens/SearchScreen/SearchResults.js
@ -778,6 +781,7 @@ packages/app-mobile/services/AlarmServiceDriver.android.js
packages/app-mobile/services/AlarmServiceDriver.ios.js
packages/app-mobile/services/AlarmServiceDriver.web.js
packages/app-mobile/services/BackButtonService.js
packages/app-mobile/services/commands/stateToWhenClauseContext.js
packages/app-mobile/services/e2ee/RSA.react-native.js
packages/app-mobile/services/e2ee/crypto.js
packages/app-mobile/services/plugins/PlatformImplementation.js
@ -819,6 +823,7 @@ packages/app-mobile/utils/fs-driver/testUtil/createFilesFromPathRecord.js
packages/app-mobile/utils/fs-driver/testUtil/verifyDirectoryMatches.js
packages/app-mobile/utils/getPackageInfo.js
packages/app-mobile/utils/getVersionInfoText.js
packages/app-mobile/utils/hooks/useKeyboardVisible.js
packages/app-mobile/utils/hooks/useOnLongPressProps.js
packages/app-mobile/utils/hooks/useReduceMotionEnabled.js
packages/app-mobile/utils/image/fileToImage.web.js
@ -843,6 +848,7 @@ packages/app-mobile/utils/shim-init-react/shimInitShared.js
packages/app-mobile/utils/testing/createMockReduxStore.js
packages/app-mobile/utils/testing/getWebViewDomById.js
packages/app-mobile/utils/testing/getWebViewWindowById.js
packages/app-mobile/utils/testing/setupGlobalStore.js
packages/app-mobile/utils/types.js
packages/app-mobile/web/serviceWorker.js
packages/default-plugins/build.js
@ -915,6 +921,7 @@ packages/editor/CodeMirror/utils/formatting/toggleSelectedLinesStartWith.js
packages/editor/CodeMirror/utils/formatting/types.js
packages/editor/CodeMirror/utils/getSearchState.js
packages/editor/CodeMirror/utils/growSelectionToNode.js
packages/editor/CodeMirror/utils/handleLinkEditRequests.js
packages/editor/CodeMirror/utils/handlePasteEvent.js
packages/editor/CodeMirror/utils/isCursorAtBeginning.js
packages/editor/CodeMirror/utils/isInSyntaxNode.js
@ -1195,6 +1202,8 @@ packages/lib/services/interop/InteropService_Importer_Raw.js
packages/lib/services/interop/Module.test.js
packages/lib/services/interop/Module.js
packages/lib/services/interop/types.js
packages/lib/services/interop/utils.test.js
packages/lib/services/interop/utils.js
packages/lib/services/joplinCloudUtils.js
packages/lib/services/joplinServer/personalizedUserContentBaseUrl.js
packages/lib/services/keychain/KeychainService.test.js

39
.gitignore vendored
View File

@ -572,6 +572,16 @@ packages/app-mobile/components/DialogManager/types.js
packages/app-mobile/components/DismissibleDialog.js
packages/app-mobile/components/Dropdown.test.js
packages/app-mobile/components/Dropdown.js
packages/app-mobile/components/EditorToolbar/EditorToolbar.test.js
packages/app-mobile/components/EditorToolbar/EditorToolbar.js
packages/app-mobile/components/EditorToolbar/ToolbarButton.js
packages/app-mobile/components/EditorToolbar/ToolbarEditorDialog.js
packages/app-mobile/components/EditorToolbar/testing/mockCommandRuntimes.js
packages/app-mobile/components/EditorToolbar/types.js
packages/app-mobile/components/EditorToolbar/utils/allToolbarCommandNamesFromState.js
packages/app-mobile/components/EditorToolbar/utils/isSelected.js
packages/app-mobile/components/EditorToolbar/utils/selectedCommandNamesFromState.js
packages/app-mobile/components/EditorToolbar/utils/toolbarButtonsFromState.js
packages/app-mobile/components/ExtendedWebView/index.jest.js
packages/app-mobile/components/ExtendedWebView/index.js
packages/app-mobile/components/ExtendedWebView/index.web.js
@ -610,18 +620,6 @@ packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/startAutosaveLoop.
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/types.js
packages/app-mobile/components/NoteEditor/ImageEditor/js-draw/watchEditorForTemplateChanges.js
packages/app-mobile/components/NoteEditor/ImageEditor/promptRestoreAutosave.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/MarkdownToolbar.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleOverflowButton.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleSpaceButton.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/Toolbar.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarButton.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarOverflowRows.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/buttons/useActionButtons.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/buttons/useHeaderButtons.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/buttons/useInlineFormattingButtons.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/buttons/useListButtons.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/buttons/usePluginButtons.js
packages/app-mobile/components/NoteEditor/MarkdownToolbar/types.js
packages/app-mobile/components/NoteEditor/NoteEditor.test.js
packages/app-mobile/components/NoteEditor/NoteEditor.js
packages/app-mobile/components/NoteEditor/SearchPanel.js
@ -629,7 +627,6 @@ packages/app-mobile/components/NoteEditor/commandDeclarations.js
packages/app-mobile/components/NoteEditor/hooks/useCodeMirrorPlugins.js
packages/app-mobile/components/NoteEditor/hooks/useEditorCommandHandler.test.js
packages/app-mobile/components/NoteEditor/hooks/useEditorCommandHandler.js
packages/app-mobile/components/NoteEditor/hooks/useKeyboardVisible.js
packages/app-mobile/components/NoteEditor/types.js
packages/app-mobile/components/NoteItem.js
packages/app-mobile/components/NoteList.js
@ -646,6 +643,7 @@ packages/app-mobile/components/SelectDateTimeDialog.js
packages/app-mobile/components/SideMenu.js
packages/app-mobile/components/SideMenuContentNote.js
packages/app-mobile/components/TextInput.js
packages/app-mobile/components/ToggleSpaceButton.js
packages/app-mobile/components/accessibility/AccessibleModalMenu.js
packages/app-mobile/components/accessibility/AccessibleView.js
packages/app-mobile/components/app-nav.js
@ -729,8 +727,13 @@ packages/app-mobile/components/screens/ConfigScreen/plugins/utils/useUpdateState
packages/app-mobile/components/screens/ConfigScreen/types.js
packages/app-mobile/components/screens/JoplinCloudLoginScreen.js
packages/app-mobile/components/screens/LogScreen.js
packages/app-mobile/components/screens/Note.test.js
packages/app-mobile/components/screens/Note.js
packages/app-mobile/components/screens/Note/Note.test.js
packages/app-mobile/components/screens/Note/Note.js
packages/app-mobile/components/screens/Note/commands/attachFile.js
packages/app-mobile/components/screens/Note/commands/hideKeyboard.js
packages/app-mobile/components/screens/Note/commands/index.js
packages/app-mobile/components/screens/Note/commands/setTags.js
packages/app-mobile/components/screens/Note/types.js
packages/app-mobile/components/screens/NoteTagsDialog.js
packages/app-mobile/components/screens/Notes.js
packages/app-mobile/components/screens/SearchScreen/SearchResults.js
@ -754,6 +757,7 @@ packages/app-mobile/services/AlarmServiceDriver.android.js
packages/app-mobile/services/AlarmServiceDriver.ios.js
packages/app-mobile/services/AlarmServiceDriver.web.js
packages/app-mobile/services/BackButtonService.js
packages/app-mobile/services/commands/stateToWhenClauseContext.js
packages/app-mobile/services/e2ee/RSA.react-native.js
packages/app-mobile/services/e2ee/crypto.js
packages/app-mobile/services/plugins/PlatformImplementation.js
@ -795,6 +799,7 @@ packages/app-mobile/utils/fs-driver/testUtil/createFilesFromPathRecord.js
packages/app-mobile/utils/fs-driver/testUtil/verifyDirectoryMatches.js
packages/app-mobile/utils/getPackageInfo.js
packages/app-mobile/utils/getVersionInfoText.js
packages/app-mobile/utils/hooks/useKeyboardVisible.js
packages/app-mobile/utils/hooks/useOnLongPressProps.js
packages/app-mobile/utils/hooks/useReduceMotionEnabled.js
packages/app-mobile/utils/image/fileToImage.web.js
@ -819,6 +824,7 @@ packages/app-mobile/utils/shim-init-react/shimInitShared.js
packages/app-mobile/utils/testing/createMockReduxStore.js
packages/app-mobile/utils/testing/getWebViewDomById.js
packages/app-mobile/utils/testing/getWebViewWindowById.js
packages/app-mobile/utils/testing/setupGlobalStore.js
packages/app-mobile/utils/types.js
packages/app-mobile/web/serviceWorker.js
packages/default-plugins/build.js
@ -891,6 +897,7 @@ packages/editor/CodeMirror/utils/formatting/toggleSelectedLinesStartWith.js
packages/editor/CodeMirror/utils/formatting/types.js
packages/editor/CodeMirror/utils/getSearchState.js
packages/editor/CodeMirror/utils/growSelectionToNode.js
packages/editor/CodeMirror/utils/handleLinkEditRequests.js
packages/editor/CodeMirror/utils/handlePasteEvent.js
packages/editor/CodeMirror/utils/isCursorAtBeginning.js
packages/editor/CodeMirror/utils/isInSyntaxNode.js
@ -1171,6 +1178,8 @@ packages/lib/services/interop/InteropService_Importer_Raw.js
packages/lib/services/interop/Module.test.js
packages/lib/services/interop/Module.js
packages/lib/services/interop/types.js
packages/lib/services/interop/utils.test.js
packages/lib/services/interop/utils.js
packages/lib/services/joplinCloudUtils.js
packages/lib/services/joplinServer/personalizedUserContentBaseUrl.js
packages/lib/services/keychain/KeychainService.test.js

View File

@ -4,14 +4,14 @@ import ToolbarBase from '../../../ToolbarBase';
import { utils as pluginUtils } from '@joplin/lib/services/plugins/reducer';
import { connect } from 'react-redux';
import { AppState } from '../../../../app.reducer';
import ToolbarButtonUtils, { ToolbarButtonInfo } from '@joplin/lib/services/commands/ToolbarButtonUtils';
import ToolbarButtonUtils, { ToolbarItem } from '@joplin/lib/services/commands/ToolbarButtonUtils';
import stateToWhenClauseContext from '../../../../services/commands/stateToWhenClauseContext';
import { _ } from '@joplin/lib/locale';
const { buildStyle } = require('@joplin/lib/theme');
interface ToolbarProps {
themeId: number;
toolbarButtonInfos: ToolbarButtonInfo[];
toolbarButtonInfos: ToolbarItem[];
disabled?: boolean;
}

View File

@ -6,7 +6,7 @@ import attachedResources from '@joplin/lib/utils/attachedResources';
import useScroll from './utils/useScroll';
import styles_ from './styles';
import CommandService from '@joplin/lib/services/CommandService';
import { ToolbarButtonInfo } from '@joplin/lib/services/commands/ToolbarButtonUtils';
import { ToolbarItem } from '@joplin/lib/services/commands/ToolbarButtonUtils';
import ToggleEditorsButton, { Value as ToggleEditorsButtonValue } from '../../../ToggleEditorsButton/ToggleEditorsButton';
import ToolbarButton from '../../../../gui/ToolbarButton/ToolbarButton';
import usePluginServiceRegistration from '../../utils/usePluginServiceRegistration';
@ -1383,7 +1383,9 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
};
}, []);
function renderExtraToolbarButton(key: string, info: ToolbarButtonInfo) {
function renderExtraToolbarButton(key: string, info: ToolbarItem) {
if (info.type === 'separator') return null;
return <ToolbarButton
key={key}
themeId={props.themeId}
@ -1412,7 +1414,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
for (const info of props.noteToolbarButtonInfos) {
if (leftButtonCommandNames.includes(info.name)) continue;
if (info.name === 'toggleEditors') {
if (info.type === 'button' && info.name === 'toggleEditors') {
buttons.push(<ToggleEditorsButton
key={info.name}
value={ToggleEditorsButtonValue.RichText}

View File

@ -20,7 +20,7 @@ import ToolbarButton from '../ToolbarButton/ToolbarButton';
import Button, { ButtonLevel } from '../Button/Button';
import eventManager, { EventName } from '@joplin/lib/eventManager';
import { AppState } from '../../app.reducer';
import ToolbarButtonUtils from '@joplin/lib/services/commands/ToolbarButtonUtils';
import ToolbarButtonUtils, { ToolbarButtonInfo } from '@joplin/lib/services/commands/ToolbarButtonUtils';
import { _, _n } from '@joplin/lib/locale';
import TagList from '../TagList';
import NoteTitleBar from './NoteTitle/NoteTitleBar';
@ -742,7 +742,7 @@ const mapStateToProps = (state: AppState, ownProps: ConnectProps) => {
], whenClauseContext),
setTagsToolbarButtonInfo: toolbarButtonUtils.commandsToToolbarButtons([
'setTags',
], whenClauseContext)[0],
], whenClauseContext)[0] as ToolbarButtonInfo,
contentMaxWidth: state.settings['style.editor.contentMaxWidth'],
isSafeMode: state.settings.isSafeMode,
useCustomPdfViewer: false,

View File

@ -1,5 +1,5 @@
import AsyncActionQueue from '@joplin/lib/AsyncActionQueue';
import { ToolbarButtonInfo } from '@joplin/lib/services/commands/ToolbarButtonUtils';
import { ToolbarButtonInfo, ToolbarItem } from '@joplin/lib/services/commands/ToolbarButtonUtils';
import { PluginHtmlContents, PluginStates } from '@joplin/lib/services/plugins/reducer';
import { MarkupLanguage } from '@joplin/renderer';
import { RenderResult, RenderResultPluginAsset } from '@joplin/renderer/types';
@ -48,7 +48,7 @@ export interface NoteEditorProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
highlightedWords: any[];
plugins: PluginStates;
toolbarButtonInfos: ToolbarButtonInfo[];
toolbarButtonInfos: ToolbarItem[];
setTagsToolbarButtonInfo: ToolbarButtonInfo;
contentMaxWidth: number;
isSafeMode: boolean;
@ -136,7 +136,7 @@ export interface NoteBodyEditorProps {
locale: string;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
onDrop: DropHandler;
noteToolbarButtonInfos: ToolbarButtonInfo[];
noteToolbarButtonInfos: ToolbarItem[];
plugins: PluginStates;
fontSize: number;
contentMaxWidth: number;

View File

@ -2,7 +2,7 @@ import * as React from 'react';
import CommandService from '@joplin/lib/services/CommandService';
import ToolbarBase from '../ToolbarBase';
import { utils as pluginUtils } from '@joplin/lib/services/plugins/reducer';
import ToolbarButtonUtils, { ToolbarButtonInfo } from '@joplin/lib/services/commands/ToolbarButtonUtils';
import ToolbarButtonUtils, { ToolbarItem } from '@joplin/lib/services/commands/ToolbarButtonUtils';
import stateToWhenClauseContext from '../../services/commands/stateToWhenClauseContext';
import { connect } from 'react-redux';
import { buildStyle } from '@joplin/lib/theme';
@ -14,7 +14,7 @@ interface NoteToolbarProps {
themeId: number;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
style: any;
toolbarButtonInfos: ToolbarButtonInfo[];
toolbarButtonInfos: ToolbarItem[];
disabled: boolean;
}

View File

@ -2,29 +2,25 @@ import * as React from 'react';
import ToolbarButton from './ToolbarButton/ToolbarButton';
import ToggleEditorsButton, { Value } from './ToggleEditorsButton/ToggleEditorsButton';
import ToolbarSpace from './ToolbarSpace';
import { ToolbarButtonInfo } from '@joplin/lib/services/commands/ToolbarButtonUtils';
import { ToolbarItem } from '@joplin/lib/services/commands/ToolbarButtonUtils';
import { AppState } from '../app.reducer';
import { connect } from 'react-redux';
import { useCallback, useMemo, useRef, useState } from 'react';
import { focus } from '@joplin/lib/utils/focusHandler';
interface ToolbarItemInfo extends ToolbarButtonInfo {
type?: string;
}
interface Props {
themeId: number;
style: React.CSSProperties;
items: ToolbarItemInfo[];
items: ToolbarItem[];
disabled: boolean;
'aria-label': string;
}
const getItemType = (item: ToolbarItemInfo) => {
const getItemType = (item: ToolbarItem) => {
return item.type ?? 'button';
};
const isFocusable = (item: ToolbarItemInfo) => {
const isFocusable = (item: ToolbarItem) => {
if (!item.enabled) {
return false;
}
@ -32,11 +28,11 @@ const isFocusable = (item: ToolbarItemInfo) => {
return getItemType(item) === 'button';
};
const useCategorizedItems = (items: ToolbarItemInfo[]) => {
const useCategorizedItems = (items: ToolbarItem[]) => {
return useMemo(() => {
const itemsLeft: ToolbarItemInfo[] = [];
const itemsCenter: ToolbarItemInfo[] = [];
const itemsRight: ToolbarItemInfo[] = [];
const itemsLeft: ToolbarItem[] = [];
const itemsCenter: ToolbarItem[] = [];
const itemsRight: ToolbarItem[] = [];
if (items) {
for (const item of items) {
@ -63,7 +59,7 @@ const useCategorizedItems = (items: ToolbarItemInfo[]) => {
const useKeyboardHandler = (
setSelectedIndex: React.Dispatch<React.SetStateAction<number>>,
focusableItems: ToolbarItemInfo[],
focusableItems: ToolbarItem[],
) => {
const onKeyDown: React.KeyboardEventHandler<HTMLElement> = useCallback(event => {
let direction = 0;
@ -110,11 +106,10 @@ const ToolbarBaseComponent: React.FC<Props> = props => {
const containerHasFocus = !!containerRef.current?.contains(doc?.activeElement);
let keyCounter = 0;
const renderItem = (o: ToolbarItemInfo, indexInFocusable: number) => {
const renderItem = (o: ToolbarItem, indexInFocusable: number) => {
let key = o.iconName ? o.iconName : '';
key += o.title ? o.title : '';
key += o.name ? o.name : '';
const itemType = !('type' in o) ? 'button' : o.type;
if (!key) key = `${o.type}_${keyCounter++}`;
@ -132,7 +127,7 @@ const ToolbarBaseComponent: React.FC<Props> = props => {
}
};
if (o.name === 'toggleEditors') {
if (o.type === 'button' && o.name === 'toggleEditors') {
return <ToggleEditorsButton
key={o.name}
buttonRef={setButtonRefCallback}
@ -141,7 +136,7 @@ const ToolbarBaseComponent: React.FC<Props> = props => {
toolbarButtonInfo={o}
tabIndex={tabIndex}
/>;
} else if (itemType === 'button') {
} else if (o.type === 'button') {
return (
<ToolbarButton
tabIndex={tabIndex}
@ -149,7 +144,7 @@ const ToolbarBaseComponent: React.FC<Props> = props => {
{...buttonProps}
/>
);
} else if (itemType === 'separator') {
} else if (o.type === 'separator') {
return <ToolbarSpace {...buttonProps} />;
}
@ -157,7 +152,7 @@ const ToolbarBaseComponent: React.FC<Props> = props => {
};
let focusableIndex = 0;
const renderList = (items: ToolbarItemInfo[]) => {
const renderList = (items: ToolbarItem[]) => {
const result: React.ReactNode[] = [];
for (const item of items) {

View File

@ -1,6 +1,6 @@
{
"name": "@joplin/app-desktop",
"version": "3.2.3",
"version": "3.2.4",
"description": "Joplin for Desktop",
"main": "main.js",
"private": true,

View File

@ -79,8 +79,8 @@ android {
applicationId "net.cozic.joplin"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 2097758
versionName "3.2.2"
versionCode 2097759
versionName "3.2.3"
ndk {
abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64"
}

View File

@ -1,7 +1,7 @@
import * as React from 'react';
import { useMemo } from 'react';
import { StyleSheet, View, ViewStyle, useWindowDimensions } from 'react-native';
import { IconButton, Surface } from 'react-native-paper';
import { IconButton, Surface, Text } from 'react-native-paper';
import { themeStyle } from './global-style';
import Modal from './Modal';
import { _ } from '@joplin/lib/locale';
@ -19,6 +19,7 @@ interface Props {
onDismiss: ()=> void;
containerStyle?: ViewStyle;
children: React.ReactNode;
heading?: string;
size: DialogSize;
}
@ -35,7 +36,11 @@ const useStyles = (themeId: number, containerStyle: ViewStyle, size: DialogSize)
return StyleSheet.create({
closeButtonContainer: {
flexDirection: 'row',
justifyContent: 'flex-end',
justifyContent: 'space-between',
alignContent: 'center',
},
heading: {
alignSelf: 'center',
},
dialogContainer: {
maxHeight,
@ -66,8 +71,12 @@ const useStyles = (themeId: number, containerStyle: ViewStyle, size: DialogSize)
const DismissibleDialog: React.FC<Props> = props => {
const styles = useStyles(props.themeId, props.containerStyle, props.size);
const closeButton = (
const heading = props.heading ? (
<Text variant='headlineSmall' role='heading' style={styles.heading}>{props.heading}</Text>
) : null;
const closeButtonRow = (
<View style={styles.closeButtonContainer}>
{heading ?? <View/>}
<IconButton
icon='close'
accessibilityLabel={_('Close')}
@ -87,7 +96,7 @@ const DismissibleDialog: React.FC<Props> = props => {
transparent={true}
>
<Surface style={styles.dialogSurface} elevation={1}>
{closeButton}
{closeButtonRow}
{props.children}
</Surface>
</Modal>

View File

@ -0,0 +1,139 @@
import * as React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react-native';
import '@testing-library/jest-native/extend-expect';
import { Store } from 'redux';
import { AppState } from '../../utils/types';
import TestProviderStack from '../testing/TestProviderStack';
import EditorToolbar from './EditorToolbar';
import { setupDatabase, switchClient } from '@joplin/lib/testing/test-utils';
import createMockReduxStore from '../../utils/testing/createMockReduxStore';
import setupGlobalStore from '../../utils/testing/setupGlobalStore';
import Setting from '@joplin/lib/models/Setting';
import { RegisteredRuntime } from '@joplin/lib/services/CommandService';
import mockCommandRuntimes from './testing/mockCommandRuntimes';
let store: Store<AppState>;
interface WrapperProps { }
const WrappedToolbar: React.FC<WrapperProps> = _props => {
return <TestProviderStack store={store}>
<EditorToolbar editorState={null} />
</TestProviderStack>;
};
const queryToolbarButton = (label: string) => {
return screen.queryByRole('button', { name: label });
};
const openSettings = async () => {
const settingButton = screen.getByRole('button', { name: 'Settings' });
fireEvent.press(settingButton);
// Settings should be open:
const settingsHeader = await screen.findByRole('heading', { name: 'Manage toolbar options' });
expect(settingsHeader).toBeVisible();
};
interface ToggleSettingItemProps {
name: string;
expectedInitialState: boolean;
}
const toggleSettingsItem = async (props: ToggleSettingItemProps) => {
const initialChecked = props.expectedInitialState;
const finalChecked = !props.expectedInitialState;
const itemCheckbox = await screen.findByRole('checkbox', { name: props.name });
expect(itemCheckbox).toBeVisible();
expect(itemCheckbox).toHaveAccessibilityState({ checked: initialChecked });
fireEvent.press(itemCheckbox);
await waitFor(() => {
expect(itemCheckbox).toHaveAccessibilityState({ checked: finalChecked });
});
};
let mockCommands: RegisteredRuntime|null = null;
describe('EditorToolbar', () => {
beforeEach(async () => {
await setupDatabase(0);
await switchClient(0);
store = createMockReduxStore();
setupGlobalStore(store);
mockCommands = mockCommandRuntimes(store);
// Start with the default set of buttons
Setting.setValue('editor.toolbarButtons', []);
});
afterEach(() => {
mockCommands?.deregister();
mockCommands = null;
});
it('unchecking items in settings should remove them from the toolbar', async () => {
const toolbar = render(<WrappedToolbar/>);
// The bold button should be visible by default (if this changes, switch this
// test to a button that is present by default).
const boldLabel = 'Bold';
const boldButton = queryToolbarButton(boldLabel);
expect(boldButton).toBeVisible();
await openSettings();
await toggleSettingsItem({ name: boldLabel, expectedInitialState: true });
// Bold button should be removed from the toolbar
await waitFor(() => {
expect(queryToolbarButton(boldLabel)).toBe(null);
});
toolbar.unmount();
});
it('checking items in settings should add them to the toolbar', async () => {
// Start with a mostly-empty toolbar for testing
Setting.setValue('editor.toolbarButtons', ['textBold', 'textItalic']);
const toolbar = render(<WrappedToolbar/>);
// Initially, the button shouldn't be present in the toolbar.
const commandLabel = 'Code';
expect(queryToolbarButton(commandLabel)).toBeNull();
await openSettings();
await toggleSettingsItem({ name: commandLabel, expectedInitialState: false });
// The button should now be added to the toolbar
await waitFor(() => {
expect(queryToolbarButton(commandLabel)).toBeVisible();
});
toolbar.unmount();
});
it('should only include the math toolbar button if math is enabled in global settings', async () => {
Setting.setValue('editor.toolbarButtons', ['editor.textMath']);
Setting.setValue('markdown.plugin.katex', true);
const toolbar = render(<WrappedToolbar/>);
// Should initially show in the toolbar
expect(queryToolbarButton('Math')).toBeVisible();
// After disabled: Should not show in the toolbar
await waitFor(() => {
Setting.setValue('markdown.plugin.katex', false);
expect(queryToolbarButton('Math')).toBeNull();
});
// Should not show in settings
await openSettings();
expect(screen.queryByRole('checkbox', { name: 'Math' })).toBeNull();
toolbar.unmount();
});
});

View File

@ -0,0 +1,117 @@
import * as React from 'react';
import { AppState } from '../../utils/types';
import { connect } from 'react-redux';
import { ScrollView, StyleSheet, View } from 'react-native';
import { ToolbarButtonInfo, ToolbarItem } from '@joplin/lib/services/commands/ToolbarButtonUtils';
import toolbarButtonsFromState from './utils/toolbarButtonsFromState';
import { useCallback, useMemo, useRef, useState } from 'react';
import { themeStyle } from '../global-style';
import ToggleSpaceButton from '../ToggleSpaceButton';
import ToolbarEditorDialog from './ToolbarEditorDialog';
import { EditorState } from './types';
import ToolbarButton from './ToolbarButton';
import isSelected from './utils/isSelected';
import { _ } from '@joplin/lib/locale';
interface Props {
themeId: number;
toolbarButtonInfos: ToolbarItem[];
editorState: EditorState;
}
const useStyles = (themeId: number) => {
return useMemo(() => {
const theme = themeStyle(themeId);
return StyleSheet.create({
content: {
flexGrow: 0,
backgroundColor: theme.backgroundColor3,
},
contentContainer: {
flexGrow: 1,
paddingVertical: 0,
flexDirection: 'row',
},
spacer: {
flexGrow: 1,
},
});
}, [themeId]);
};
type SetSettingsVisible = React.Dispatch<React.SetStateAction<boolean>>;
const useSettingButtonInfo = (setSettingsVisible: SetSettingsVisible) => {
return useMemo((): ToolbarButtonInfo => ({
type: 'button',
name: 'showToolbarSettings',
tooltip: _('Settings'),
iconName: 'material cogs',
enabled: true,
onClick: () => setSettingsVisible(true),
title: '',
}), [setSettingsVisible]);
};
const EditorToolbar: React.FC<Props> = props => {
const styles = useStyles(props.themeId);
const buttonInfos: ToolbarButtonInfo[] = [];
for (const info of props.toolbarButtonInfos) {
if (info.type !== 'separator') {
buttonInfos.push(info);
}
}
const renderButton = (info: ToolbarButtonInfo) => {
return <ToolbarButton
key={`command-${info.name}`}
buttonInfo={info}
themeId={props.themeId}
selected={isSelected(info.name, props.editorState)}
/>;
};
const [settingsVisible, setSettingsVisible] = useState(false);
const scrollViewRef = useRef<ScrollView|null>(null);
const onDismissSettingsDialog = useCallback(() => {
setSettingsVisible(false);
// On Android, if the ScrollView isn't manually scrolled to the end,
// all items can be invisible in some cases. This causes issues with
// TalkBack on Android.
// In particular, if 1) the toolbar initially has many items on a device
// with a small screen, and 2) the user removes most items, then most/all
// items are scrolled offscreen. Calling .scrollToEnd corrects this:
scrollViewRef.current?.scrollToEnd();
}, []);
const settingsButtonInfo = useSettingButtonInfo(setSettingsVisible);
const settingsButton = <ToolbarButton
buttonInfo={settingsButtonInfo}
themeId={props.themeId}
/>;
return <>
<ToggleSpaceButton themeId={props.themeId}>
<ScrollView
ref={scrollViewRef}
horizontal={true}
style={styles.content}
contentContainerStyle={styles.contentContainer}
>
{buttonInfos.map(renderButton)}
<View style={styles.spacer}/>
{settingsButton}
</ScrollView>
</ToggleSpaceButton>
<ToolbarEditorDialog visible={settingsVisible} onDismiss={onDismissSettingsDialog} />
</>;
};
export default connect((state: AppState) => {
return {
themeId: state.settings.theme,
toolbarButtonInfos: toolbarButtonsFromState(state),
};
})(EditorToolbar);

View File

@ -0,0 +1,58 @@
import * as React from 'react';
import { ToolbarButtonInfo } from '@joplin/lib/services/commands/ToolbarButtonUtils';
import IconButton from '../IconButton';
import { memo, useMemo } from 'react';
import { StyleSheet, useWindowDimensions } from 'react-native';
import { themeStyle } from '../global-style';
interface Props {
themeId: number;
buttonInfo: ToolbarButtonInfo;
selected?: boolean;
}
const useStyles = (themeId: number, selected: boolean, enabled: boolean) => {
const { fontScale } = useWindowDimensions();
return useMemo(() => {
const theme = themeStyle(themeId);
return StyleSheet.create({
icon: {
color: theme.color,
fontSize: 22 * fontScale,
},
button: {
// Scaling the button width/height by the device font scale causes the button to scale
// with the user's device font size.
width: 48 * fontScale,
height: 48 * fontScale,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: selected ? theme.backgroundColorHover3 : theme.backgroundColor3,
opacity: enabled ? 1 : theme.disabledOpacity,
},
});
}, [themeId, selected, enabled, fontScale]);
};
const ToolbarButton: React.FC<Props> = memo(({ themeId, buttonInfo, selected }) => {
const styles = useStyles(themeId, selected, buttonInfo.enabled);
const isToggleButton = selected !== undefined;
return <IconButton
iconName={buttonInfo.iconName}
description={buttonInfo.title || buttonInfo.tooltip}
onPress={buttonInfo.onClick}
disabled={!buttonInfo.enabled}
iconStyle={styles.icon}
containerStyle={styles.button}
accessibilityState={{ selected }}
accessibilityRole={isToggleButton ? 'togglebutton' : 'button'}
role={'button'}
aria-pressed={selected}
preventKeyboardDismiss={true}
themeId={themeId}
/>;
});
export default ToolbarButton;

View File

@ -0,0 +1,191 @@
import * as React from 'react';
import { useCallback, useMemo } from 'react';
import createRootStyle from '../../utils/createRootStyle';
import { View, StyleSheet, ScrollView } from 'react-native';
import { Divider, Text, TouchableRipple } from 'react-native-paper';
import { _ } from '@joplin/lib/locale';
import { themeStyle } from '../global-style';
import { connect } from 'react-redux';
import ToolbarButtonUtils, { ToolbarButtonInfo, ToolbarItem } from '@joplin/lib/services/commands/ToolbarButtonUtils';
import Icon from '../Icon';
import { AppState } from '../../utils/types';
import CommandService from '@joplin/lib/services/CommandService';
import allToolbarCommandNamesFromState from './utils/allToolbarCommandNamesFromState';
import Setting from '@joplin/lib/models/Setting';
import DismissibleDialog, { DialogSize } from '../DismissibleDialog';
import selectedCommandNamesFromState from './utils/selectedCommandNamesFromState';
import stateToWhenClauseContext from '../../services/commands/stateToWhenClauseContext';
import { DeleteButton } from '../buttons';
import shim from '@joplin/lib/shim';
const toolbarButtonUtils = new ToolbarButtonUtils(CommandService.instance());
interface EditorDialogProps {
themeId: number;
defaultToolbarButtonInfos: ToolbarItem[];
selectedCommandNames: string[];
allCommandNames: string[];
hasCustomizedLayout: boolean;
visible: boolean;
onDismiss: ()=> void;
}
const useStyle = (themeId: number) => {
return useMemo(() => {
const theme = themeStyle(themeId);
return StyleSheet.create({
...createRootStyle(themeId),
icon: {
color: theme.color,
fontSize: theme.fontSizeLarge,
},
labelText: {
fontSize: theme.fontSize,
},
listContainer: {
marginTop: theme.marginTop,
flex: 1,
},
resetButton: {
marginTop: theme.marginTop,
},
listItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-start',
gap: theme.margin,
padding: 4,
paddingTop: theme.itemMarginTop,
paddingBottom: theme.itemMarginBottom,
},
});
}, [themeId]);
};
type Styles = ReturnType<typeof useStyle>;
const setCommandIncluded = (
commandName: string,
lastSelectedCommands: string[],
allCommandNames: string[],
include: boolean,
) => {
let newSelectedCommands;
if (include) {
newSelectedCommands = [];
for (const name of allCommandNames) {
const isDivider = name === '-';
if (isDivider || name === commandName || lastSelectedCommands.includes(name)) {
newSelectedCommands.push(name);
}
}
} else {
newSelectedCommands = lastSelectedCommands.filter(name => name !== commandName);
}
Setting.setValue('editor.toolbarButtons', newSelectedCommands);
};
interface ItemToggleProps {
item: ToolbarButtonInfo;
selectedCommandNames: string[];
allCommandNames: string[];
styles: Styles;
}
const ToolbarItemToggle: React.FC<ItemToggleProps> = ({
item, selectedCommandNames, styles, allCommandNames,
}) => {
const title = item.title || item.tooltip;
const checked = selectedCommandNames.includes(item.name);
const onToggle = useCallback(() => {
setCommandIncluded(item.name, selectedCommandNames, allCommandNames, !checked);
}, [item, selectedCommandNames, allCommandNames, checked]);
return (
<TouchableRipple
accessibilityRole='checkbox'
accessibilityState={{ checked }}
aria-checked={checked}
onPress={onToggle}
>
<View style={styles.listItem}>
<Icon name={checked ? 'ionicon checkbox-outline' : 'ionicon square-outline'} style={styles.icon} accessibilityLabel={null}/>
<Icon name={item.iconName} style={styles.icon} accessibilityLabel={null}/>
<Text style={styles.labelText}>
{title}
</Text>
</View>
</TouchableRipple>
);
};
const ToolbarEditorScreen: React.FC<EditorDialogProps> = props => {
const styles = useStyle(props.themeId);
const renderItem = (item: ToolbarItem, index: number) => {
if (item.type === 'separator') {
return <Divider key={`separator-${index}`} />;
}
return <ToolbarItemToggle
key={`command-${item.name}`}
item={item}
styles={styles}
allCommandNames={props.allCommandNames}
selectedCommandNames={props.selectedCommandNames}
/>;
};
const onRestoreDefaultLayout = useCallback(async () => {
// Dismiss before showing the confirm dialog to prevent modal conflicts.
// On some platforms (web and possibly iOS) showing multiple modals
// at the same time can cause issues.
props.onDismiss();
const message = _('Are you sure that you want to restore the default toolbar layout?\nThis cannot be undone.');
if (await shim.showConfirmationDialog(message)) {
Setting.setValue('editor.toolbarButtons', []);
}
}, [props.onDismiss]);
const restoreButton = <DeleteButton
style={styles.resetButton}
onPress={onRestoreDefaultLayout}
>
{_('Restore defaults')}
</DeleteButton>;
return (
<DismissibleDialog
size={DialogSize.Small}
themeId={props.themeId}
visible={props.visible}
onDismiss={props.onDismiss}
heading={_('Manage toolbar options')}
>
<View>
<Text variant='bodyMedium'>{_('Check elements to display in the toolbar')}</Text>
</View>
<ScrollView style={styles.listContainer}>
{props.defaultToolbarButtonInfos.map((item, index) => renderItem(item, index))}
{props.hasCustomizedLayout ? restoreButton : null}
</ScrollView>
</DismissibleDialog>
);
};
export default connect((state: AppState) => {
const whenClauseContext = stateToWhenClauseContext(state);
const allCommandNames = allToolbarCommandNamesFromState(state);
const selectedCommandNames = selectedCommandNamesFromState(state);
return {
themeId: state.settings.theme,
selectedCommandNames,
allCommandNames,
hasCustomizedLayout: state.settings['editor.toolbarButtons'].length > 0,
defaultToolbarButtonInfos: toolbarButtonUtils.commandsToToolbarButtons(allCommandNames, whenClauseContext),
};
})(ToolbarEditorScreen);

View File

@ -0,0 +1,28 @@
import { Store } from 'redux';
import { AppState } from '../../../utils/types';
import CommandService, { CommandRuntime } from '@joplin/lib/services/CommandService';
import allToolbarCommandNamesFromState from '../utils/allToolbarCommandNamesFromState';
// The toolbar expects all toolbar command runtimes to be registered before it can be
// rendered:
const mockCommandRuntimes = (store: Store<AppState>) => {
const makeMockRuntime = (commandName: string) => ({
declaration: { name: commandName },
runtime: (_props: null): CommandRuntime => ({
execute: jest.fn(),
}),
});
const isSeparator = (commandName: string) => commandName === '-';
const mockRuntimes = allToolbarCommandNamesFromState(
store.getState(),
).filter(
name => !isSeparator(name),
).map(makeMockRuntime);
return CommandService.instance().componentRegisterCommands(
null, mockRuntimes,
);
};
export default mockCommandRuntimes;

View File

@ -0,0 +1,6 @@
import SelectionFormatting from '@joplin/editor/SelectionFormatting';
export interface EditorState {
selectionState: SelectionFormatting;
searchVisible: boolean;
}

View File

@ -0,0 +1,54 @@
import { AppState } from '../../../utils/types';
import { utils as pluginUtils } from '@joplin/lib/services/plugins/reducer';
import { EditorCommandType } from '@joplin/editor/types';
const builtInCommandNames = [
'attachFile',
'-',
'editor.textHeading1',
'editor.textHeading2',
'editor.textHeading3',
'editor.textHeading4',
'editor.textHeading5',
EditorCommandType.ToggleBolded,
EditorCommandType.ToggleItalicized,
'-',
EditorCommandType.ToggleCode,
`editor.${EditorCommandType.ToggleMath}`,
'-',
EditorCommandType.ToggleNumberedList,
EditorCommandType.ToggleBulletedList,
EditorCommandType.ToggleCheckList,
'-',
EditorCommandType.IndentLess,
EditorCommandType.IndentMore,
'-',
EditorCommandType.EditLink,
'setTags',
EditorCommandType.ToggleSearch,
'hideKeyboard',
];
const allToolbarCommandNamesFromState = (state: AppState) => {
const pluginCommandNames = pluginUtils.commandNamesFromViews(state.pluginService.plugins, 'editorToolbar');
let allCommandNames = builtInCommandNames;
if (pluginCommandNames.length > 0) {
allCommandNames = allCommandNames.concat(['-'], pluginCommandNames);
}
// If the user disables math markup, the "toggle math" button won't be useful.
// Disabling the math markup button maintains compatibility with the previous
// toolbar.
const mathEnabled = state.settings['markdown.plugin.katex'];
if (!mathEnabled) {
allCommandNames = allCommandNames.filter(
name => name !== `editor.${EditorCommandType.ToggleMath}`,
);
}
return allCommandNames;
};
export default allToolbarCommandNamesFromState;

View File

@ -0,0 +1,40 @@
import SelectionFormatting from '@joplin/editor/SelectionFormatting';
import { EditorCommandType } from '@joplin/editor/types';
import { EditorState } from '../types';
type StateSelector = (selectionState: SelectionFormatting, searchVisible: boolean)=> boolean;
const commandNameToSelectionState: Record<string, StateSelector> = {
[EditorCommandType.ToggleBolded]: state => state.bolded,
[EditorCommandType.ToggleItalicized]: state => state.italicized,
[EditorCommandType.ToggleCode]: state => state.inCode,
[EditorCommandType.ToggleMath]: state => state.inMath,
[EditorCommandType.ToggleHeading1]: state => state.headerLevel === 1,
[EditorCommandType.ToggleHeading2]: state => state.headerLevel === 2,
[EditorCommandType.ToggleHeading3]: state => state.headerLevel === 3,
[EditorCommandType.ToggleHeading4]: state => state.headerLevel === 4,
[EditorCommandType.ToggleHeading5]: state => state.headerLevel === 5,
[EditorCommandType.ToggleBulletedList]: state => state.inUnorderedList,
[EditorCommandType.ToggleNumberedList]: state => state.inOrderedList,
[EditorCommandType.ToggleCheckList]: state => state.inChecklist,
[EditorCommandType.EditLink]: state => state.inLink,
[EditorCommandType.ToggleSearch]: (_selectionState, searchVisible) => searchVisible,
};
// Returns undefined if not a toggle button
const isSelected = (commandName: string, editorState: EditorState) => {
// Newer editor commands are registered with the "editor." prefix. Remove this
// prefix to simplify looking up the selection state:
commandName = commandName.replace(/^editor\./, '');
if (commandName in commandNameToSelectionState) {
if (!editorState) return false;
return commandNameToSelectionState[commandName as EditorCommandType](
editorState.selectionState, editorState.searchVisible,
);
}
return undefined;
};
export default isSelected;

View File

@ -0,0 +1,30 @@
import { AppState } from '../../../utils/types';
import allToolbarCommandNamesFromState from './allToolbarCommandNamesFromState';
import { Platform } from 'react-native';
const omitFromDefault: string[] = [
'editor.textHeading1',
'editor.textHeading3',
'editor.textHeading4',
'editor.textHeading5',
];
// The "hide keyboard" button is only needed on iOS, so only show it there by default.
// (There's no default "dismiss" button on iPhone software keyboards).
if (Platform.OS !== 'ios') {
omitFromDefault.push('hideKeyboard');
}
const selectedCommandNamesFromState = (state: AppState) => {
const allCommandNames = allToolbarCommandNamesFromState(state);
const defaultCommandNames = allCommandNames.filter(commandName => {
return !omitFromDefault.includes(commandName);
});
const commandNameSetting = state.settings['editor.toolbarButtons'] ?? [];
const selectedCommands = commandNameSetting.length > 0 ? commandNameSetting : defaultCommandNames;
return selectedCommands.filter(command => allCommandNames.includes(command));
};
export default selectedCommandNamesFromState;

View File

@ -0,0 +1,16 @@
import { AppState } from '../../../utils/types';
import ToolbarButtonUtils from '@joplin/lib/services/commands/ToolbarButtonUtils';
import CommandService from '@joplin/lib/services/CommandService';
import selectedCommandNamesFromState from './selectedCommandNamesFromState';
import stateToWhenClauseContext from '../../../services/commands/stateToWhenClauseContext';
const toolbarButtonUtils = new ToolbarButtonUtils(CommandService.instance());
const toolbarButtonsFromState = (state: AppState) => {
const whenClauseContext = stateToWhenClauseContext(state);
const commandNames = selectedCommandNamesFromState(state);
return toolbarButtonUtils.commandsToToolbarButtons(commandNames, whenClauseContext);
};
export default toolbarButtonsFromState;

View File

@ -6,7 +6,7 @@ import * as React from 'react';
import { themeStyle } from '@joplin/lib/theme';
import { Theme } from '@joplin/lib/themes/type';
import { useState, useMemo, useCallback, useRef } from 'react';
import { Text, Pressable, ViewStyle, StyleSheet, LayoutChangeEvent, LayoutRectangle, Animated, AccessibilityState, AccessibilityRole, TextStyle, GestureResponderEvent, Platform } from 'react-native';
import { Text, Pressable, ViewStyle, StyleSheet, LayoutChangeEvent, LayoutRectangle, Animated, AccessibilityState, AccessibilityRole, TextStyle, GestureResponderEvent, Platform, Role } from 'react-native';
import { Menu, MenuOptions, MenuTrigger, renderers } from 'react-native-popup-menu';
import Icon from './Icon';
import AccessibleView from './accessibility/AccessibleView';
@ -36,6 +36,8 @@ interface ButtonProps {
// Role of the button. Defaults to 'button'.
accessibilityRole?: AccessibilityRole;
accessibilityState?: AccessibilityState;
'aria-pressed'?: boolean;
role?: Role;
disabled?: boolean;
}
@ -102,7 +104,9 @@ const IconButton = (props: ButtonProps) => {
accessibilityLabel={props.description}
accessibilityHint={props.accessibilityHint}
accessibilityRole={props.accessibilityRole ?? 'button'}
role={props.role}
accessibilityState={props.accessibilityState}
aria-pressed={props['aria-pressed']}
>
<Animated.View style={{
opacity: fadeAnim,

View File

@ -37,10 +37,6 @@ class ModalDialog extends React.Component<Props, State> {
this.styles_ = {};
const styles: Record<string, ViewStyle|TextStyle> = {
modalWrapper: {
flex: 1,
justifyContent: 'center',
},
modalContentWrapper: {
flex: 1,
flexDirection: 'column',
@ -76,20 +72,18 @@ class ModalDialog extends React.Component<Props, State> {
const buttonBarEnabled = this.props.buttonBarEnabled !== false;
return (
<View style={this.styles().modalWrapper}>
<Modal transparent={true} visible={true} onRequestClose={() => {}} containerStyle={this.styles().modalContentWrapper}>
<Text style={this.styles().title}>{this.props.title}</Text>
<View style={this.styles().modalContentWrapper2}>{ContentComponent}</View>
<View style={this.styles().buttonRow}>
<View style={{ flex: 1 }}>
<Button disabled={!buttonBarEnabled} title={_('OK')} onPress={this.props.onOkPress}></Button>
</View>
<View style={{ flex: 1, marginLeft: 5 }}>
<Button disabled={!buttonBarEnabled} title={_('Cancel')} onPress={this.props.onCancelPress}></Button>
</View>
<Modal transparent={true} visible={true} onRequestClose={() => {}} containerStyle={this.styles().modalContentWrapper}>
<Text style={this.styles().title}>{this.props.title}</Text>
<View style={this.styles().modalContentWrapper2}>{ContentComponent}</View>
<View style={this.styles().buttonRow}>
<View style={{ flex: 1 }}>
<Button disabled={!buttonBarEnabled} title={_('OK')} onPress={this.props.onOkPress}></Button>
</View>
</Modal>
</View>
<View style={{ flex: 1, marginLeft: 5 }}>
<Button disabled={!buttonBarEnabled} title={_('Cancel')} onPress={this.props.onCancelPress}></Button>
</View>
</View>
</Modal>
);
}
}

View File

@ -84,6 +84,7 @@ const EditLinkDialog = (props: LinkDialogProps) => {
const onSubmit = useCallback(() => {
props.editorControl.updateLink(linkLabel, linkURL);
props.editorControl.hideLinkDialog();
focus('EditLinkDialog::onSubmit', props.editorControl);
}, [props.editorControl, linkLabel, linkURL]);
// See https://www.hingehealth.com/engineering-blog/accessible-react-native-textinput/

View File

@ -1,140 +0,0 @@
// A toolbar for the markdown editor.
import * as React from 'react';
import { Platform, StyleSheet } from 'react-native';
import { useMemo } from 'react';
import { _ } from '@joplin/lib/locale';
import { MarkdownToolbarProps, StyleSheetData } from './types';
import Toolbar from './Toolbar';
import { buttonSize } from './ToolbarButton';
import { Theme } from '@joplin/lib/themes/type';
import ToggleSpaceButton from './ToggleSpaceButton';
import useHeaderButtons from './buttons/useHeaderButtons';
import useInlineFormattingButtons from './buttons/useInlineFormattingButtons';
import useActionButtons from './buttons/useActionButtons';
import useListButtons from './buttons/useListButtons';
import useKeyboardVisible from '../hooks/useKeyboardVisible';
import usePluginButtons from './buttons/usePluginButtons';
const MarkdownToolbar: React.FC<MarkdownToolbarProps> = (props: MarkdownToolbarProps) => {
const themeData = props.editorSettings.themeData;
const styles = useStyles(props.style, themeData);
const { keyboardVisible, hasSoftwareKeyboard } = useKeyboardVisible();
const buttonProps = {
...props,
iconStyle: styles.text,
keyboardVisible,
hasSoftwareKeyboard,
};
const headerButtons = useHeaderButtons(buttonProps);
const inlineFormattingBtns = useInlineFormattingButtons(buttonProps);
const actionButtons = useActionButtons(buttonProps);
const listButtons = useListButtons(buttonProps);
const pluginButtons = usePluginButtons(buttonProps);
const styleData: StyleSheetData = useMemo(() => ({
styles: styles,
themeId: props.editorSettings.themeId,
}), [styles, props.editorSettings.themeId]);
const toolbarButtons = useMemo(() => {
const buttons = [
{
title: _('Formatting'),
items: inlineFormattingBtns,
},
{
title: _('Headers'),
items: headerButtons,
},
{
title: _('Lists'),
items: listButtons,
},
{
title: _('Actions'),
items: actionButtons,
},
];
if (pluginButtons.length > 0) {
buttons.push({
title: _('Plugins'),
items: pluginButtons,
});
}
return buttons;
}, [headerButtons, inlineFormattingBtns, listButtons, actionButtons, pluginButtons]);
return (
<ToggleSpaceButton
spaceApplicable={ Platform.OS === 'ios' && keyboardVisible }
themeId={props.editorSettings.themeId}
style={styles.container}
>
<Toolbar
styleSheet={styleData}
buttons={toolbarButtons}
/>
</ToggleSpaceButton>
);
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const useStyles = (styleProps: any, theme: Theme) => {
return useMemo(() => {
return StyleSheet.create({
container: {
...styleProps,
},
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,
},
toolbarContainer: {
flexShrink: 1,
},
toolbarContent: {
flexGrow: 1,
justifyContent: 'center',
},
});
}, [styleProps, theme]);
};
export default MarkdownToolbar;

View File

@ -1,31 +0,0 @@
import * as React from 'react';
import { _ } from '@joplin/lib/locale';
import ToolbarButton from './ToolbarButton';
import { ButtonSpec, StyleSheetData } from './types';
type OnToggleOverflowCallback = ()=> void;
interface ToggleOverflowButtonProps {
overflowVisible: boolean;
onToggleOverflowVisible: OnToggleOverflowCallback;
styleSheet: StyleSheetData;
}
// Button that shows/hides the overflow menu.
const ToggleOverflowButton: React.FC<ToggleOverflowButtonProps> = (props: ToggleOverflowButtonProps) => {
const spec: ButtonSpec = {
icon: 'material dots-horizontal',
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

@ -1,124 +0,0 @@
import * as React from 'react';
import { ReactElement, useCallback, useMemo, useState } from 'react';
import { LayoutChangeEvent, ScrollView, View, ViewStyle } 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;
style?: ViewStyle;
}
// Displays a list of buttons with an overflow menu.
const Toolbar: React.FC<ToolbarProps> = (props: ToolbarProps) => {
const [overflowButtonsVisible, setOverflowPopupVisible] = useState(false);
const [maxButtonsEachSide, setMaxButtonsEachSide] = useState(0);
const allButtonSpecs = useMemo(() => {
const buttons = 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
buttons.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
return buttons;
}, [props.buttons]);
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(() => {
setOverflowPopupVisible(!overflowButtonsVisible);
}, [overflowButtonsVisible]);
const toggleOverflowButton = (
<ToggleOverflowButton
key={(++key).toString()}
styleSheet={props.styleSheet}
overflowVisible={overflowButtonsVisible}
onToggleOverflowVisible={onToggleOverflowVisible}
/>
);
const mainButtons: ReactElement[] = [];
if (maxButtonsEachSide >= allButtonComponents.length) {
mainButtons.push(...allButtonComponents);
} else if (maxButtonsEachSide > 0) {
// 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(toggleOverflowButton);
}
const styles = props.styleSheet.styles;
const mainButtonRow = (
<View style={styles.toolbarRow}>
{ mainButtons }
</View>
);
const overflow = (
<ScrollView>
<ToolbarOverflowRows
buttonGroups={props.buttons}
styleSheet={props.styleSheet}
onToggleOverflow={onToggleOverflowVisible}
/>
</ScrollView>
);
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%',
...props.style,
}}
onLayout={onContainerLayout}
>
{ overflowButtonsVisible ? overflow : null }
{ !overflowButtonsVisible ? mainButtonRow : null }
</View>
);
};
export default Toolbar;

View File

@ -1,74 +0,0 @@
import * as React from 'react';
import { useCallback, useMemo } from 'react';
import { TextStyle, StyleSheet } from 'react-native';
import { ButtonSpec, StyleSheetData } from './types';
import IconButton from '../../IconButton';
export const buttonSize = 54;
interface ToolbarButtonProps {
styleSheet: StyleSheetData;
style?: TextStyle;
spec: ButtonSpec;
onActionComplete?: ()=> void;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const useStyles = (baseStyleSheet: any, baseButtonStyle: any, buttonSpec: ButtonSpec, visible: boolean, disabled: boolean) => {
return useMemo(() => {
const activatedStyle = buttonSpec.active ? baseStyleSheet.buttonActive : {};
const disabledStyle = disabled ? baseStyleSheet.buttonDisabled : {};
const activatedTextStyle = buttonSpec.active ? baseStyleSheet.buttonActiveContent : {};
const disabledTextStyle = disabled ? baseStyleSheet.buttonDisabledContent : {};
return StyleSheet.create({
iconStyle: {
...activatedTextStyle,
...disabledTextStyle,
...baseStyleSheet.text,
},
buttonStyle: {
...baseStyleSheet.button,
...activatedStyle,
...disabledStyle,
...baseButtonStyle,
...(!visible ? { opacity: 0 } : null),
},
});
}, [
baseStyleSheet.button, baseStyleSheet.text, baseButtonStyle, baseStyleSheet.buttonActive,
baseStyleSheet.buttonDisabled, baseStyleSheet.buttonActiveContent, baseStyleSheet.buttonDisabledContent,
buttonSpec.active, visible, disabled,
]);
};
const ToolbarButton = ({ styleSheet, spec, onActionComplete, style }: ToolbarButtonProps) => {
const visible = spec.visible ?? true;
const disabled = (spec.disabled ?? false) && visible;
const styles = useStyles(styleSheet.styles, style, spec, visible, disabled);
const sourceOnPress = spec.onPress;
const onPress = useCallback(() => {
if (!disabled) {
sourceOnPress();
onActionComplete?.();
}
}, [disabled, sourceOnPress, onActionComplete]);
return (
<IconButton
containerStyle={styles.buttonStyle}
themeId={styleSheet.themeId}
onPress={onPress}
description={ spec.description }
disabled={ disabled }
preventKeyboardDismiss={true}
iconName={spec.icon}
iconStyle={styles.iconStyle}
/>
);
};
export default ToolbarButton;

View File

@ -1,134 +0,0 @@
import * as React from 'react';
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';
type OnToggleOverflowCallback = ()=> void;
interface OverflowPopupProps {
buttonGroups: ButtonGroup[];
styleSheet: StyleSheetData;
// Should be created using useCallback
onToggleOverflow: OnToggleOverflowCallback;
}
// Specification for a button that acts as padding.
const paddingButtonSpec = { visible: false, icon: '', onPress: ()=>{}, description: '' };
// Contains buttons that overflow the available space.
// Displays all buttons in [props.buttonGroups] if [props.visible].
// Otherwise, displays nothing.
const ToolbarOverflowRows: React.FC<OverflowPopupProps> = (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 || group.items.length === 1)) {
row.push(
<ToggleOverflowButton
key={(++key).toString()}
styleSheet={props.styleSheet}
overflowVisible={true}
onToggleOverflowVisible={props.onToggleOverflow}
/>,
);
}
}
// Pad to an odd number of items to ensure that buttons are centered properly
if (row.length % 2 === 0) {
row.push(
<ToolbarButton
key={`padding-${i}`}
styleSheet={props.styleSheet}
spec={paddingButtonSpec}
/>,
);
}
overflowRows.push(
<View
key={key.toString()}
>
<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: 'text ⨉',
description: _('Close'),
onPress: props.onToggleOverflow,
};
const closeButton = (
<ToolbarButton
styleSheet={props.styleSheet}
spec={closeButtonSpec}
style={{
position: 'absolute',
right: 0,
zIndex: 1,
}}
/>
);
return (
<View
style={{
height: props.buttonGroups.length * buttonSize,
flexDirection: 'column',
flexGrow: 1,
display: 'flex',
}}
onLayout={onContainerLayout}
>
{hasSpaceForCloseBtn ? closeButton : null}
{overflowRows}
</View>
);
};
export default ToolbarOverflowRows;

View File

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

View File

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

View File

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

View File

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

View File

@ -1,38 +0,0 @@
import { useMemo } from 'react';
import { ButtonSpec } from '../types';
import { ButtonRowProps } from '../types';
import { PluginStates, utils as pluginUtils } from '@joplin/lib/services/plugins/reducer';
import CommandService from '@joplin/lib/services/CommandService';
interface PluginButtonsRowProps extends ButtonRowProps {
pluginStates: PluginStates;
}
const usePluginButtons = (props: PluginButtonsRowProps) => {
return useMemo(() => {
const pluginButtons: ButtonSpec[] = [];
const pluginCommands =
pluginUtils
.commandNamesFromViews(props.pluginStates, 'editorToolbar')
// Remove separators
.filter(name => name !== '-');
const commandService = CommandService.instance();
for (const commandName of pluginCommands) {
const command = commandService.commandByName(commandName, { runtimeMustBeRegistered: true });
pluginButtons.push({
description: commandService.description(commandName),
icon: command.declaration.iconName ?? 'fas fa-cog',
onPress: async () => {
void commandService.execute(commandName);
},
});
}
return pluginButtons;
}, [props.pluginStates]);
};
export default usePluginButtons;

View File

@ -1,56 +0,0 @@
import { TextStyle, ViewStyle } from 'react-native';
import { EditorControl, EditorSettings } from '../types';
import SelectionFormatting from '@joplin/editor/SelectionFormatting';
import { SearchState } from '@joplin/editor/types';
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
export type OnPressListener = ()=> void;
export interface ButtonSpec {
// Name of an icon, as accepted by components/Icon.tsx
icon: string;
// 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;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
styles: any;
}
type OnAttachCallback = ()=> void;
export interface MarkdownToolbarProps {
editorControl: EditorControl;
selectionState: SelectionFormatting;
searchState: SearchState;
editorSettings: EditorSettings;
pluginStates: PluginStates;
onAttach: OnAttachCallback;
style?: ViewStyle;
readOnly: boolean;
}
export interface ButtonRowProps extends MarkdownToolbarProps {
iconStyle: TextStyle;
}

View File

@ -7,10 +7,18 @@ import '@testing-library/jest-native';
import NoteEditor from './NoteEditor';
import Setting from '@joplin/lib/models/Setting';
import { _ } from '@joplin/lib/locale';
import { MenuProvider } from 'react-native-popup-menu';
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
import commandDeclarations from './commandDeclarations';
import CommandService from '@joplin/lib/services/CommandService';
import CommandService, { RegisteredRuntime } from '@joplin/lib/services/CommandService';
import TestProviderStack from '../testing/TestProviderStack';
import createMockReduxStore from '../../utils/testing/createMockReduxStore';
import mockCommandRuntimes from '../EditorToolbar/testing/mockCommandRuntimes';
import setupGlobalStore from '../../utils/testing/setupGlobalStore';
import { Store } from 'redux';
import { AppState } from '../../utils/types';
let store: Store<AppState>;
let registeredRuntime: RegisteredRuntime;
describe('NoteEditor', () => {
beforeAll(() => {
@ -24,11 +32,19 @@ describe('NoteEditor', () => {
// Required to use ExtendedWebView
await setupDatabaseAndSynchronizer(0);
await switchClient(0);
store = createMockReduxStore();
setupGlobalStore(store);
registeredRuntime = mockCommandRuntimes(store);
});
afterEach(() => {
registeredRuntime.deregister();
});
it('should hide the markdown toolbar when the window is small', async () => {
const wrappedNoteEditor = render(
<MenuProvider>
<TestProviderStack store={store}>
<NoteEditor
themeId={Setting.THEME_ARITIM_DARK}
initialText='Testing...'
@ -41,7 +57,7 @@ describe('NoteEditor', () => {
onAttach={async ()=>{}}
plugins={{}}
/>
</MenuProvider>,
</TestProviderStack>,
);
// Maps from screen height to whether the markdown toolbar should be visible.
@ -70,11 +86,11 @@ describe('NoteEditor', () => {
setRootHeight(height);
await waitFor(async () => {
const showMoreButton = await screen.queryByLabelText(_('Show more actions'));
const toolbarButton = await screen.queryByLabelText(_('Bold'));
if (visible) {
expect(showMoreButton).not.toBeNull();
expect(toolbarButton).not.toBeNull();
} else {
expect(showMoreButton).toBeNull();
expect(toolbarButton).toBeNull();
}
});
}

View File

@ -16,7 +16,6 @@ import { editorFont } from '../global-style';
import { EditorControl as EditorBodyControl, ContentScriptData } from '@joplin/editor/types';
import { EditorControl, EditorSettings, SelectionRange, WebViewToEditorApi } from './types';
import { _ } from '@joplin/lib/locale';
import MarkdownToolbar from './MarkdownToolbar/MarkdownToolbar';
import { ChangeEvent, EditorEvent, EditorEventType, SelectionRangeChangeEvent, UndoRedoDepthChangeEvent } from '@joplin/editor/events';
import { EditorCommandType, EditorKeymap, EditorLanguageType, SearchState } from '@joplin/editor/types';
import SelectionFormatting, { defaultSelectionFormatting } from '@joplin/editor/SelectionFormatting';
@ -30,6 +29,7 @@ import { OnMessageEvent } from '../ExtendedWebView/types';
import { join, dirname } from 'path';
import * as mimeUtils from '@joplin/lib/mime-utils';
import uuid from '@joplin/lib/uuid';
import EditorToolbar from '../EditorToolbar/EditorToolbar';
type ChangeEventHandler = (event: ChangeEvent)=> void;
type UndoRedoDepthChangeHandler = (event: UndoRedoDepthChangeEvent)=> void;
@ -184,7 +184,7 @@ const useEditorControl = (
setSearchState: OnSearchStateChangeCallback,
): EditorControl => {
return useMemo(() => {
const execCommand = (command: EditorCommandType) => {
const execEditorCommand = (command: EditorCommandType) => {
void bodyControl.execCommand(command);
};
@ -229,25 +229,25 @@ const useEditorControl = (
},
toggleBolded() {
execCommand(EditorCommandType.ToggleBolded);
execEditorCommand(EditorCommandType.ToggleBolded);
},
toggleItalicized() {
execCommand(EditorCommandType.ToggleItalicized);
execEditorCommand(EditorCommandType.ToggleItalicized);
},
toggleOrderedList() {
execCommand(EditorCommandType.ToggleNumberedList);
execEditorCommand(EditorCommandType.ToggleNumberedList);
},
toggleUnorderedList() {
execCommand(EditorCommandType.ToggleBulletedList);
execEditorCommand(EditorCommandType.ToggleBulletedList);
},
toggleTaskList() {
execCommand(EditorCommandType.ToggleCheckList);
execEditorCommand(EditorCommandType.ToggleCheckList);
},
toggleCode() {
execCommand(EditorCommandType.ToggleCode);
execEditorCommand(EditorCommandType.ToggleCode);
},
toggleMath() {
execCommand(EditorCommandType.ToggleMath);
execEditorCommand(EditorCommandType.ToggleMath);
},
toggleHeaderLevel(level: number) {
const levelToCommand = [
@ -264,19 +264,19 @@ const useEditorControl = (
throw new Error(`Unsupported header level ${level}`);
}
execCommand(levelToCommand[index]);
execEditorCommand(levelToCommand[index]);
},
increaseIndent() {
execCommand(EditorCommandType.IndentMore);
execEditorCommand(EditorCommandType.IndentMore);
},
decreaseIndent() {
execCommand(EditorCommandType.IndentLess);
execEditorCommand(EditorCommandType.IndentLess);
},
updateLink(label: string, url: string) {
bodyControl.updateLink(label, url);
},
scrollSelectionIntoView() {
execCommand(EditorCommandType.ScrollSelectionIntoView);
execEditorCommand(EditorCommandType.ScrollSelectionIntoView);
},
showLinkDialog() {
setLinkDialogVisible(true);
@ -296,23 +296,23 @@ const useEditorControl = (
searchControl: {
findNext() {
execCommand(EditorCommandType.FindNext);
execEditorCommand(EditorCommandType.FindNext);
},
findPrevious() {
execCommand(EditorCommandType.FindPrevious);
execEditorCommand(EditorCommandType.FindPrevious);
},
replaceNext() {
execCommand(EditorCommandType.ReplaceNext);
execEditorCommand(EditorCommandType.ReplaceNext);
},
replaceAll() {
execCommand(EditorCommandType.ReplaceAll);
execEditorCommand(EditorCommandType.ReplaceAll);
},
showSearch() {
execCommand(EditorCommandType.ShowSearch);
execEditorCommand(EditorCommandType.ShowSearch);
},
hideSearch() {
execCommand(EditorCommandType.HideSearch);
execEditorCommand(EditorCommandType.HideSearch);
},
setSearchState: setSearchStateCallback,
@ -535,20 +535,12 @@ function NoteEditor(props: Props, ref: any) {
}
}, []);
const toolbar = <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}
pluginStates={props.plugins}
onAttach={props.onAttach}
readOnly={props.readOnly}
/>;
const toolbarEditorState = useMemo(() => ({
selectionState,
searchVisible: searchState.dialogVisible,
}), [selectionState, searchState.dialogVisible]);
const toolbar = <EditorToolbar editorState={toolbarEditorState} />;
return (
<View

View File

@ -1,14 +1,28 @@
import { EditorCommandType } from '@joplin/editor/types';
import { _ } from '@joplin/lib/locale';
import { CommandDeclaration } from '@joplin/lib/services/CommandService';
export const enabledCondition = (_commandName: string) => {
const output = [
'!modalDialogVisible',
'!noteIsReadOnly',
];
return output.filter(c => !!c).join(' && ');
};
const headerDeclarations = () => {
const result: CommandDeclaration[] = [];
for (let level = 1; level <= 5; level++) {
result.push({
name: `editor.textHeading${level}`,
iconName: `material format-header-${level}`,
label: () => _('Header %d', level),
});
}
return result;
};
const declarations: CommandDeclaration[] = [
{
name: 'insertText',
@ -34,6 +48,65 @@ const declarations: CommandDeclaration[] = [
{
name: 'editor.execCommand',
},
{
name: EditorCommandType.ToggleBolded,
label: () => _('Bold'),
iconName: 'material format-bold',
},
{
name: EditorCommandType.ToggleItalicized,
label: () => _('Italic'),
iconName: 'material format-italic',
},
...headerDeclarations(),
{
name: EditorCommandType.ToggleCode,
label: () => _('Code'),
iconName: 'material code-json',
},
{
// The 'editor.' prefix needs to be included because ToggleMath is not a legacy
// editor command. Without this, ToggleMath is not recognised as an editor command.
name: `editor.${EditorCommandType.ToggleMath}`,
label: () => _('Math'),
iconName: 'material sigma',
},
{
name: EditorCommandType.ToggleNumberedList,
label: () => _('Ordered list'),
iconName: 'material format-list-numbered',
},
{
name: EditorCommandType.ToggleBulletedList,
label: () => _('Unordered list'),
iconName: 'material format-list-bulleted',
},
{
name: EditorCommandType.ToggleCheckList,
label: () => _('Task list'),
iconName: 'material format-list-checks',
},
{
name: EditorCommandType.IndentLess,
label: () => _('Decrease indent level'),
iconName: 'ant indent-left',
},
{
name: EditorCommandType.IndentMore,
label: () => _('Increase indent level'),
iconName: 'ant indent-right',
},
{
name: EditorCommandType.ToggleSearch,
label: () => _('Search'),
iconName: 'material magnify',
},
{
name: EditorCommandType.EditLink,
label: () => _('Link'),
iconName: 'material link',
},
];
export default declarations;

View File

@ -1,6 +1,6 @@
import CommandService, { CommandContext, CommandDeclaration } from '@joplin/lib/services/CommandService';
import { EditorControl } from '@joplin/editor/types';
import { useEffect } from 'react';
import useNowEffect from '@joplin/lib/hooks/useNowEffect';
import commandDeclarations, { enabledCondition } from '../commandDeclarations';
import Logger from '@joplin/utils/Logger';
@ -34,7 +34,9 @@ const commandRuntime = (declaration: CommandDeclaration, editor: EditorControl)
};
const useEditorCommandHandler = (editorControl: EditorControl) => {
useEffect(() => {
// useNowEffect: The command runtimes need to be registered before child components
// can render.
useNowEffect(() => {
const commandService = CommandService.instance();
for (const declaration of commandDeclarations) {
commandService.registerRuntime(declaration.name, commandRuntime(declaration, editorControl));
@ -45,7 +47,7 @@ const useEditorCommandHandler = (editorControl: EditorControl) => {
commandService.unregisterRuntime(declaration.name);
}
};
});
}, []);
};
export default useEditorCommandHandler;

View File

@ -42,12 +42,12 @@ const WebBetaButton: React.FC<Props> = props => {
iconStyle={props.iconStyle}
/>
<DismissibleDialog
heading={_('Beta')}
size={DialogSize.Small}
themeId={props.themeId}
visible={dialogVisible}
onDismiss={onHideDialog}
>
<Text variant='headlineMedium'>{_('Beta')}</Text>
<Text>{'At present, the web client is in beta. In the future, it may change significantly, or be removed.'}</Text>
<View style={feedbackContainerStyles}>
<LinkButton onPress={onLeaveFeedback}>{_('Give feedback')}</LinkButton>

View File

@ -9,16 +9,15 @@
import Setting from '@joplin/lib/models/Setting';
import { themeStyle } from '@joplin/lib/theme';
import { Theme } from '@joplin/lib/themes/type';
import * as React from 'react';
import { ReactNode, useCallback, useState, useEffect } from 'react';
import { View, ViewStyle } from 'react-native';
import IconButton from '../../IconButton';
import { Platform, View, ViewStyle } from 'react-native';
import IconButton from './IconButton';
import useKeyboardVisible from '../utils/hooks/useKeyboardVisible';
interface Props {
children: ReactNode;
spaceApplicable: boolean;
themeId: number;
style?: ViewStyle;
}
@ -44,7 +43,7 @@ const ToggleSpaceButton = (props: Props) => {
}
}, [onDecreaseSpace]);
const theme: Theme = themeStyle(props.themeId);
const theme = themeStyle(props.themeId);
const decreaseSpaceButton = (
<>
@ -77,15 +76,18 @@ const ToggleSpaceButton = (props: Props) => {
</>
);
const { keyboardVisible } = useKeyboardVisible();
const spaceApplicable = keyboardVisible && Platform.OS === 'ios';
const style: ViewStyle = {
marginBottom: props.spaceApplicable ? additionalSpace : 0,
marginBottom: spaceApplicable ? additionalSpace : 0,
...props.style,
};
return (
<View style={style}>
{props.children}
{ decreaseSpaceBtnVisible && props.spaceApplicable ? decreaseSpaceButton : null }
{ decreaseSpaceBtnVisible && spaceApplicable ? decreaseSpaceButton : null }
</View>
);
};

View File

@ -12,3 +12,4 @@ const makeTextButtonComponent = (type: ButtonType) => {
export const PrimaryButton = makeTextButtonComponent(ButtonType.Primary);
export const SecondaryButton = makeTextButtonComponent(ButtonType.Secondary);
export const LinkButton = makeTextButtonComponent(ButtonType.Link);
export const DeleteButton = makeTextButtonComponent(ButtonType.Delete);

View File

@ -8,6 +8,7 @@ const Color = require('color');
const baseStyle = {
appearance: 'light',
fontSize: 16,
fontSizeLarge: 20,
noteViewerFontSize: 16,
margin: 15, // No text and no interactive component should be within this margin
itemMarginTop: 10,

View File

@ -8,11 +8,10 @@ import NoteScreen from './Note';
import { setupDatabaseAndSynchronizer, switchClient, simulateReadOnlyShareEnv, supportDir, synchronizerStart, resourceFetcher, runWithFakeTimers } from '@joplin/lib/testing/test-utils';
import { waitFor as waitForWithRealTimers } from '@joplin/lib/testing/test-utils';
import Note from '@joplin/lib/models/Note';
import { AppState } from '../../utils/types';
import { AppState } from '../../../utils/types';
import { Store } from 'redux';
import createMockReduxStore from '../../utils/testing/createMockReduxStore';
import initializeCommandService from '../../utils/initializeCommandService';
import getWebViewDomById from '../../utils/testing/getWebViewDomById';
import createMockReduxStore from '../../../utils/testing/createMockReduxStore';
import getWebViewDomById from '../../../utils/testing/getWebViewDomById';
import { NoteEntity } from '@joplin/lib/services/database/types';
import Folder from '@joplin/lib/models/Folder';
import BaseItem from '@joplin/lib/models/BaseItem';
@ -22,11 +21,12 @@ import { getDisplayParentId } from '@joplin/lib/services/trash';
import { itemIsReadOnlySync, ItemSlice } from '@joplin/lib/models/utils/readOnly';
import { LayoutChangeEvent } from 'react-native';
import shim from '@joplin/lib/shim';
import getWebViewWindowById from '../../utils/testing/getWebViewWindowById';
import getWebViewWindowById from '../../../utils/testing/getWebViewWindowById';
import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl';
import Setting from '@joplin/lib/models/Setting';
import Resource from '@joplin/lib/models/Resource';
import TestProviderStack from '../testing/TestProviderStack';
import TestProviderStack from '../../testing/TestProviderStack';
import setupGlobalStore from '../../../utils/testing/setupGlobalStore';
interface WrapperProps {
}
@ -138,7 +138,7 @@ describe('screens/Note', () => {
await switchClient(0);
store = createMockReduxStore();
initializeCommandService(store);
setupGlobalStore(store);
// In order for note changes to be saved, note-screen-shared requires
// that at least one folder exist.

View File

@ -3,66 +3,65 @@ import uuid from '@joplin/lib/uuid';
import Setting from '@joplin/lib/models/Setting';
import shim from '@joplin/lib/shim';
import UndoRedoService from '@joplin/lib/services/UndoRedoService';
import NoteBodyViewer from '../NoteBodyViewer/NoteBodyViewer';
import checkPermissions from '../../utils/checkPermissions';
import NoteEditor from '../NoteEditor/NoteEditor';
import NoteBodyViewer from '../../NoteBodyViewer/NoteBodyViewer';
import checkPermissions from '../../../utils/checkPermissions';
import NoteEditor from '../../NoteEditor/NoteEditor';
import * as React from 'react';
import { Keyboard, View, TextInput, StyleSheet, Linking, Share, NativeSyntheticEvent } from 'react-native';
import { Platform, PermissionsAndroid } from 'react-native';
import { connect } from 'react-redux';
// const { MarkdownEditor } = require('@joplin/lib/../MarkdownEditor/index.js');
import Note from '@joplin/lib/models/Note';
import BaseItem from '@joplin/lib/models/BaseItem';
import Resource from '@joplin/lib/models/Resource';
import Folder from '@joplin/lib/models/Folder';
const Clipboard = require('@react-native-clipboard/clipboard').default;
const md5 = require('md5');
import BackButtonService from '../../services/BackButtonService';
import BackButtonService from '../../../services/BackButtonService';
import NavService, { OnNavigateCallback as OnNavigateCallback } from '@joplin/lib/services/NavService';
import { ModelType } from '@joplin/lib/BaseModel';
import FloatingActionButton from '../buttons/FloatingActionButton';
import FloatingActionButton from '../../buttons/FloatingActionButton';
const { fileExtension, safeFileExtension } = require('@joplin/lib/path-utils');
import * as mimeUtils from '@joplin/lib/mime-utils';
import ScreenHeader, { MenuOptionType } from '../ScreenHeader';
import NoteTagsDialog from './NoteTagsDialog';
import ScreenHeader, { MenuOptionType } from '../../ScreenHeader';
import NoteTagsDialog from '../NoteTagsDialog';
import time from '@joplin/lib/time';
import Checkbox from '../Checkbox';
import Checkbox from '../../Checkbox';
import { _, currentLocale } from '@joplin/lib/locale';
import { reg } from '@joplin/lib/registry';
import ResourceFetcher from '@joplin/lib/services/ResourceFetcher';
import { BaseScreenComponent } from '../base-screen';
import { themeStyle, editorFont } from '../global-style';
import { BaseScreenComponent } from '../../base-screen';
import { themeStyle, editorFont } from '../../global-style';
import shared, { BaseNoteScreenComponent, Props as BaseProps } from '@joplin/lib/components/shared/note-screen-shared';
import { Asset, ImagePickerResponse, launchImageLibrary } from 'react-native-image-picker';
import SelectDateTimeDialog from '../SelectDateTimeDialog';
import ShareExtension from '../../utils/ShareExtension.js';
import CameraView from '../CameraView/CameraView';
import SelectDateTimeDialog from '../../SelectDateTimeDialog';
import ShareExtension from '../../../utils/ShareExtension.js';
import CameraView from '../../CameraView/CameraView';
import { FolderEntity, NoteEntity, ResourceEntity } from '@joplin/lib/services/database/types';
import Logger from '@joplin/utils/Logger';
import ImageEditor from '../NoteEditor/ImageEditor/ImageEditor';
import promptRestoreAutosave from '../NoteEditor/ImageEditor/promptRestoreAutosave';
import isEditableResource from '../NoteEditor/ImageEditor/isEditableResource';
import VoiceTypingDialog from '../voiceTyping/VoiceTypingDialog';
import { isSupportedLanguage } from '../../services/voiceTyping/vosk';
import ImageEditor from '../../NoteEditor/ImageEditor/ImageEditor';
import promptRestoreAutosave from '../../NoteEditor/ImageEditor/promptRestoreAutosave';
import isEditableResource from '../../NoteEditor/ImageEditor/isEditableResource';
import VoiceTypingDialog from '../../voiceTyping/VoiceTypingDialog';
import { isSupportedLanguage } from '../../../services/voiceTyping/vosk';
import { ChangeEvent as EditorChangeEvent, SelectionRangeChangeEvent, UndoRedoDepthChangeEvent } from '@joplin/editor/events';
import { join } from 'path';
import { Dispatch } from 'redux';
import { RefObject, useContext, useRef } from 'react';
import { SelectionRange } from '../NoteEditor/types';
import { SelectionRange } from '../../NoteEditor/types';
import { getNoteCallbackUrl } from '@joplin/lib/callbackUrlUtils';
import { AppState } from '../../utils/types';
import { AppState } from '../../../utils/types';
import restoreItems from '@joplin/lib/services/trash/restoreItems';
import { getDisplayParentTitle } from '@joplin/lib/services/trash';
import { PluginStates, utils as pluginUtils } from '@joplin/lib/services/plugins/reducer';
import pickDocument from '../../utils/pickDocument';
import debounce from '../../utils/debounce';
import debounce from '../../../utils/debounce';
import { focus } from '@joplin/lib/utils/focusHandler';
import CommandService from '@joplin/lib/services/CommandService';
import { ResourceInfo } from '../NoteBodyViewer/hooks/useRerenderHandler';
import getImageDimensions from '../../utils/image/getImageDimensions';
import resizeImage from '../../utils/image/resizeImage';
import { CameraResult } from '../CameraView/types';
import { DialogContext, DialogControl } from '../DialogManager';
import CommandService, { RegisteredRuntime } from '@joplin/lib/services/CommandService';
import { ResourceInfo } from '../../NoteBodyViewer/hooks/useRerenderHandler';
import getImageDimensions from '../../../utils/image/getImageDimensions';
import resizeImage from '../../../utils/image/resizeImage';
import { CameraResult } from '../../CameraView/types';
import { DialogContext, DialogControl } from '../../DialogManager';
import { CommandRuntimeProps, PickerResponse } from './types';
import commands from './commands';
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const emptyArray: any[] = [];
@ -150,6 +149,7 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
private folderPickerOptions_: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public dialogbox: any;
private commandRegistration_: RegisteredRuntime|null = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public static navigationOptions(): any {
@ -292,7 +292,6 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
}
};
this.takePhoto_onPress = this.takePhoto_onPress.bind(this);
this.cameraView_onPhoto = this.cameraView_onPhoto.bind(this);
this.cameraView_onCancel = this.cameraView_onCancel.bind(this);
this.properties_onPress = this.properties_onPress.bind(this);
@ -315,6 +314,38 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
this.voiceTypingDialog_onDismiss = this.voiceTypingDialog_onDismiss.bind(this);
}
private registerCommands() {
if (this.commandRegistration_) return;
const dialogs = () => this.props.dialogs;
this.commandRegistration_ = CommandService.instance().componentRegisterCommands<CommandRuntimeProps>(
{
attachFile: this.attachFile.bind(this),
hideKeyboard: () => {
if (this.useEditorBeta()) {
this.editorRef?.current?.hideKeyboard();
} else {
Keyboard.dismiss();
}
},
insertText: this.insertText.bind(this),
get dialogs() {
return dialogs();
},
setCameraVisible: (visible) => {
this.setState({ showCamera: visible });
},
setTagDialogVisible: (visible) => {
if (!this.state.note || !this.state.note.id) return;
this.setState({ noteTagDialogShown: visible });
},
},
commands,
true,
);
}
private useEditorBeta(): boolean {
return this.props.useEditorBeta;
}
@ -574,6 +605,9 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
// It cannot theoretically be undefined, since componentDidMount should always be called before
// componentWillUnmount, but with React Native the impossible often becomes possible.
if (this.undoRedoService_) this.undoRedoService_.off('stackChange', this.undoRedoService_stackChange);
this.commandRegistration_?.deregister();
this.commandRegistration_ = null;
}
private title_changeText(text: string) {
@ -636,11 +670,6 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
await shared.saveOneProperty(this, name, value);
}
private async pickDocuments() {
const result = await pickDocument({ multiple: true });
return result;
}
public async resizeImage(localFilePath: string, targetPath: string, mimeType: string) {
const maxSize = Resource.IMAGE_MAX_DIMENSION;
const dimensions = await getImageDimensions(localFilePath);
@ -720,7 +749,10 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
return newNote;
}
public async attachFile(pickerResponse: Asset, fileType: string): Promise<ResourceEntity|null> {
public async attachFile(
pickerResponse: PickerResponse,
fileType: string,
): Promise<ResourceEntity|null> {
if (!pickerResponse) {
// User has cancelled
return null;
@ -802,36 +834,6 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
return resource;
}
private async attachPhoto_onPress() {
// the selection Limit should be specified. I think 200 is enough?
const response: ImagePickerResponse = await launchImageLibrary({ mediaType: 'photo', includeBase64: false, selectionLimit: 200 });
if (response.errorCode) {
reg.logger().warn('Got error from picker', response.errorCode);
return;
}
if (response.didCancel) {
reg.logger().info('User cancelled picker');
return;
}
for (const asset of response.assets) {
await this.attachFile(asset, 'image');
}
}
private async takePhoto_onPress() {
if (Platform.OS === 'web') {
const response = await pickDocument({ multiple: true, preferCamera: true });
for (const asset of response) {
await this.attachFile(asset, 'image');
}
} else {
this.setState({ showCamera: true });
}
}
private cameraView_onPhoto(data: CameraResult) {
void this.attachFile(
data,
@ -935,25 +937,12 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
}
};
private async attachFile_onPress() {
const response = await this.pickDocuments();
for (const asset of response) {
await this.attachFile(asset, 'all');
}
}
private toggleIsTodo_onPress() {
shared.toggleIsTodo_onPress(this);
this.scheduleSave();
}
private tags_onPress() {
if (!this.state.note || !this.state.note.id) return;
this.setState({ noteTagDialogShown: true });
}
private async share_onPress() {
await Share.share({
message: `${this.state.note.title}\n\n${this.state.note.body}`,
@ -1059,37 +1048,8 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
return output;
}
public async showAttachMenu() {
// If the keyboard is editing a WebView, the standard Keyboard.dismiss()
// may not work. As such, we also need to call hideKeyboard on the editorRef
this.editorRef.current?.hideKeyboard();
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 browsing 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 this.props.dialogs.showMenu(_('Choose an option'), buttons);
if (buttonId === 'takePhoto') await this.takePhoto_onPress();
if (buttonId === 'attachFile') await this.attachFile_onPress();
if (buttonId === 'attachPhoto') await this.attachPhoto_onPress();
}
public onAttach = async (filePath?: string) => {
if (filePath) {
await this.attachFile({ uri: filePath }, 'all');
} else {
await this.showAttachMenu();
}
await CommandService.instance().execute('attachFile', filePath);
};
// private vosk_:Vosk;
@ -1183,7 +1143,7 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
if (canAttachPicture) {
output.push({
title: _('Attach...'),
onPress: () => this.showAttachMenu(),
onPress: () => this.onAttach(),
disabled: readOnly,
});
}
@ -1227,13 +1187,24 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
});
}
const commandService = CommandService.instance();
const whenContext = commandService.currentWhenClauseContext();
const addButtonFromCommand = (commandName: string, title?: string) => {
if (commandName === '-') {
output.push({ isDivider: true });
} else {
output.push({
title: title ?? commandService.description(commandName),
onPress: async () => {
void commandService.execute(commandName);
},
disabled: !commandService.isEnabled(commandName, whenContext),
});
}
};
if (isSaved && !isDeleted) {
output.push({
title: _('Tags'),
onPress: () => {
this.tags_onPress();
},
});
addButtonFromCommand('setTags');
}
output.push({
@ -1283,22 +1254,6 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
});
}
const commandService = CommandService.instance();
const whenContext = commandService.currentWhenClauseContext();
const addButtonFromCommand = (commandName: string, title?: string) => {
if (commandName === '-') {
output.push({ isDivider: true });
} else {
output.push({
title: title ?? commandService.description(commandName),
onPress: async () => {
void commandService.execute(commandName);
},
disabled: !commandService.isEnabled(commandName, whenContext),
});
}
};
if (whenContext.inTrash) {
addButtonFromCommand('permanentlyDeleteNote');
} else {
@ -1440,6 +1395,12 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
}
public render() {
// Commands must be registered before child components can render.
// Calling this in the constructor won't work in strict mode, where
// componentWillUnmount (which removes the commands) can be called
// multiple times.
this.registerCommands();
if (this.state.isLoading) {
return (
<View style={this.styles().screen}>

View File

@ -0,0 +1,87 @@
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
import { CommandRuntimeProps } from '../types';
import { Platform } from 'react-native';
import pickDocument from '../../../../utils/pickDocument';
import { ImagePickerResponse, launchImageLibrary } from 'react-native-image-picker';
import Logger from '@joplin/utils/Logger';
const logger = Logger.create('attachFile');
export const declaration: CommandDeclaration = {
name: 'attachFile',
label: () => _('Attach file'),
iconName: 'material attachment',
};
export const runtime = (props: CommandRuntimeProps): CommandRuntime => {
const takePhoto = async () => {
if (Platform.OS === 'web') {
const response = await pickDocument({ multiple: true, preferCamera: true });
for (const asset of response) {
await props.attachFile(asset, 'image');
}
} else {
props.setCameraVisible(true);
}
};
const attachFile = async () => {
const response = await pickDocument({ multiple: true });
for (const asset of response) {
await props.attachFile(asset, 'all');
}
};
const attachPhoto = async () => {
// the selection Limit should be specified. I think 200 is enough?
const response: ImagePickerResponse = await launchImageLibrary({ mediaType: 'photo', includeBase64: false, selectionLimit: 200 });
if (response.errorCode) {
logger.warn('Got error from picker', response.errorCode);
return;
}
if (response.didCancel) {
logger.info('User cancelled picker');
return;
}
for (const asset of response.assets) {
await props.attachFile(asset, 'image');
}
};
const showAttachMenu = async () => {
props.hideKeyboard();
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 browsing 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 props.dialogs.showMenu(_('Choose an option'), buttons);
if (buttonId === 'takePhoto') await takePhoto();
if (buttonId === 'attachFile') await attachFile();
if (buttonId === 'attachPhoto') await attachPhoto();
};
return {
execute: async (_context: CommandContext, filePath?: string) => {
if (filePath) {
await props.attachFile({ uri: filePath }, 'all');
} else {
await showAttachMenu();
}
},
enabledCondition: '!noteIsReadOnly',
};
};

View File

@ -0,0 +1,18 @@
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
import { CommandRuntimeProps } from '../types';
export const declaration: CommandDeclaration = {
name: 'hideKeyboard',
label: () => _('Hide keyboard'),
iconName: 'material keyboard-close',
};
export const runtime = (props: CommandRuntimeProps): CommandRuntime => {
return {
execute: async (_context: CommandContext) => {
props.hideKeyboard();
},
enabledCondition: 'keyboardVisible',
};
};

View File

@ -0,0 +1,13 @@
// AUTO-GENERATED using `gulp buildScriptIndexes`
import * as attachFile from './attachFile';
import * as hideKeyboard from './hideKeyboard';
import * as setTags from './setTags';
const index: any[] = [
attachFile,
hideKeyboard,
setTags,
];
export default index;
// AUTO-GENERATED using `gulp buildScriptIndexes`

View File

@ -0,0 +1,19 @@
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
import { CommandRuntimeProps } from '../types';
export const declaration: CommandDeclaration = {
name: 'setTags',
label: () => _('Tags'),
iconName: 'material tag-multiple',
};
export const runtime = (props: CommandRuntimeProps): CommandRuntime => {
return {
execute: async (_context: CommandContext) => {
props.setTagDialogVisible(true);
},
enabledCondition: '!noteIsReadOnly',
};
};

View File

@ -0,0 +1,17 @@
import { ResourceEntity } from '@joplin/lib/services/database/types';
import { DialogControl } from '../../DialogManager';
export interface PickerResponse {
uri?: string;
type?: string;
fileName?: string;
}
export interface CommandRuntimeProps {
attachFile(pickerResponse: PickerResponse, fileType: string): Promise<ResourceEntity|null>;
hideKeyboard(): void;
insertText(text: string): void;
setCameraVisible(visible: boolean): void;
setTagDialogVisible(visible: boolean): void;
dialogs: DialogControl;
}

View File

@ -32,7 +32,7 @@
"@react-native-community/geolocation": "3.3.0",
"@react-native-community/netinfo": "11.3.2",
"@react-native-community/push-notification-ios": "1.11.0",
"@react-native-community/slider": "4.5.2",
"@react-native-community/slider": "4.5.3",
"assert-browserify": "2.0.0",
"buffer": "6.0.3",
"color": "3.2.1",

View File

@ -12,7 +12,7 @@ import BaseModel from '@joplin/lib/BaseModel';
import BaseService from '@joplin/lib/services/BaseService';
import ResourceService from '@joplin/lib/services/ResourceService';
import KvStore from '@joplin/lib/services/KvStore';
import NoteScreen from './components/screens/Note';
import NoteScreen from './components/screens/Note/Note';
import UpgradeSyncTargetScreen from './components/screens/UpgradeSyncTargetScreen';
import Setting, { AppType, Env } from '@joplin/lib/models/Setting';
import PoorManIntervals from '@joplin/lib/PoorManIntervals';
@ -27,7 +27,7 @@ import SyncTargetJoplinCloud from '@joplin/lib/SyncTargetJoplinCloud';
import SyncTargetOneDrive from '@joplin/lib/SyncTargetOneDrive';
import initProfile from '@joplin/lib/services/profileConfig/initProfile';
const VersionInfo = require('react-native-version-info').default;
const { Keyboard, BackHandler, Animated, StatusBar, Platform, Dimensions } = require('react-native');
import { Keyboard, BackHandler, Animated, StatusBar, Platform, Dimensions } from 'react-native';
import { AppState as RNAppState, EmitterSubscription, View, Text, Linking, NativeEventSubscription, Appearance, ActivityIndicator } from 'react-native';
import getResponsiveValue from './components/getResponsiveValue';
import NetInfo from '@react-native-community/netinfo';
@ -443,6 +443,9 @@ const appReducer = (state = appDefaultState, action: any) => {
newState.isOnMobileData = action.isOnMobileData;
break;
case 'KEYBOARD_VISIBLE_CHANGE':
newState = { ...state, keyboardVisible: action.visible };
break;
}
} catch (error) {
error.message = `In reducer: ${error.message} Action: ${JSON.stringify(action)}`;
@ -853,6 +856,8 @@ class AppComponent extends React.Component {
private urlOpenListener_: EmitterSubscription|null = null;
private appStateChangeListener_: NativeEventSubscription|null = null;
private themeChangeListener_: NativeEventSubscription|null = null;
private keyboardShowListener_: EmitterSubscription|null = null;
private keyboardHideListener_: EmitterSubscription|null = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
private dropdownAlert_ = (_data: any) => new Promise<any>(res => res);
private callbackUrl: string|null = null;
@ -1038,6 +1043,19 @@ class AppComponent extends React.Component {
await setupNotifications(this.props.dispatch);
this.keyboardShowListener_ = Keyboard.addListener('keyboardDidShow', () => {
this.props.dispatch({
type: 'KEYBOARD_VISIBLE_CHANGE',
visible: true,
});
});
this.keyboardHideListener_ = Keyboard.addListener('keyboardDidHide', () => {
this.props.dispatch({
type: 'KEYBOARD_VISIBLE_CHANGE',
visible: false,
});
});
// Setting.setValue('encryption.masterPassword', 'WRONG');
// setTimeout(() => NavService.go('EncryptionConfig'), 2000);
}
@ -1074,6 +1092,15 @@ class AppComponent extends React.Component {
this.quickActionShortcutListener_.remove();
this.quickActionShortcutListener_ = undefined;
}
if (this.keyboardShowListener_) {
this.keyboardShowListener_.remove();
this.keyboardShowListener_ = undefined;
}
if (this.keyboardHideListener_) {
this.keyboardHideListener_.remove();
this.keyboardHideListener_ = undefined;
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied

View File

@ -0,0 +1,16 @@
// This extends the generic stateToWhenClauseContext (potentially shared by
// all apps) with additional properties specific to the desktop app. So in
// general, any desktop component should import this file, and not the lib
// one.
import libStateToWhenClauseContext, { WhenClauseContextOptions } from '@joplin/lib/services/commands/stateToWhenClauseContext';
import { AppState } from '../../utils/types';
const stateToWhenClauseContext = (state: AppState, options: WhenClauseContextOptions = null) => {
return {
...libStateToWhenClauseContext(state, options),
keyboardVisible: state.keyboardVisible,
};
};
export default stateToWhenClauseContext;

View File

@ -11,6 +11,7 @@ export const DEFAULT_ROUTE = {
const appDefaultState: AppState = {
smartFilterId: undefined,
...defaultState,
keyboardVisible: false,
route: DEFAULT_ROUTE,
noteSelectionEnabled: false,
noteSideMenuOptions: null,

View File

@ -3,9 +3,6 @@ import { themeStyle } from '../components/global-style';
export default (themeId: number) => {
const theme = themeStyle(themeId);
return {
root: {
flex: 1,
backgroundColor: theme.backgroundColor,
},
root: theme.rootStyle,
};
};

View File

@ -1,11 +1,12 @@
import Setting from '@joplin/lib/models/Setting';
import CommandService, { CommandDeclaration, CommandRuntime } from '@joplin/lib/services/CommandService';
import stateToWhenClauseContext from '@joplin/lib/services/commands/stateToWhenClauseContext';
import { AppState } from './types';
import { Store } from 'redux';
import editorCommandDeclarations from '../components/NoteEditor/commandDeclarations';
import noteCommands from '../components/screens/Note/commands';
import globalCommands from '../commands';
import libCommands from '@joplin/lib/commands';
import stateToWhenClauseContext from '../services/commands/stateToWhenClauseContext';
interface CommandSpecification {
declaration: CommandDeclaration;
@ -25,6 +26,9 @@ const initializeCommandService = (store: Store<AppState, any>) => {
for (const declaration of editorCommandDeclarations) {
CommandService.instance().registerDeclaration(declaration);
}
for (const command of noteCommands) {
CommandService.instance().registerDeclaration(command.declaration);
}
registerCommands(globalCommands);
registerCommands(libCommands);
};

View File

@ -2,13 +2,10 @@ import { createReduxStore } from '@joplin/lib/testing/test-utils';
import appDefaultState from '../appDefaultState';
import Setting from '@joplin/lib/models/Setting';
const defaultState = {
...appDefaultState,
// Mocking theme in the default state is necessary to prevent "Theme not set!" warnings.
settings: { theme: Setting.THEME_LIGHT },
};
const createMockReduxStore = () => {
return createReduxStore(defaultState);
return createReduxStore({
...appDefaultState,
settings: Setting.toPlainObject(),
});
};
export default createMockReduxStore;

View File

@ -0,0 +1,16 @@
import { Store } from 'redux';
import { AppState } from '../types';
import initializeCommandService from '../initializeCommandService';
import BaseSyncTarget from '@joplin/lib/BaseSyncTarget';
import NavService from '@joplin/lib/services/NavService';
import BaseModel from '@joplin/lib/BaseModel';
// Sets a given Redux store as global
const setupGlobalStore = (store: Store<AppState>) => {
BaseModel.dispatch = store.dispatch;
BaseSyncTarget.dispatch = store.dispatch;
NavService.dispatch = store.dispatch;
initializeCommandService(store);
};
export default setupGlobalStore;

View File

@ -3,6 +3,7 @@ import { State } from '@joplin/lib/reducer';
export interface AppState extends State {
showPanelsDialog: boolean;
isOnMobileData: boolean;
keyboardVisible: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
route: any;
smartFilterId: string;

View File

@ -34,6 +34,7 @@ import biDirectionalTextExtension from './utils/biDirectionalTextExtension';
import searchExtension from './utils/searchExtension';
import isCursorAtBeginning from './utils/isCursorAtBeginning';
import overwriteModeExtension from './utils/overwriteModeExtension';
import handleLinkEditRequests, { showLinkEditor } from './utils/handleLinkEditRequests';
// Newer versions of CodeMirror by default use Chrome's EditContext API.
// While this might be stable enough for desktop use, it causes significant
@ -97,12 +98,6 @@ const createEditor = (
}
};
const notifyLinkEditRequest = () => {
props.onEvent({
kind: EditorEventType.EditLink,
});
};
const globalSpellcheckEnabled = () => {
return editor.contentDOM.spellcheck;
@ -184,10 +179,7 @@ const createEditor = (
keyCommand('Mod-`', toggleCode),
keyCommand('Mod-[', decreaseIndent),
keyCommand('Mod-]', increaseIndent),
keyCommand('Mod-k', (_: EditorView) => {
notifyLinkEditRequest();
return true;
}),
keyCommand('Mod-k', showLinkEditor),
keyCommand('Tab', (view: EditorView) => {
if (settings.autocompleteMarkup) {
return insertOrIncreaseIndent(view);
@ -289,6 +281,11 @@ const createEditor = (
notifySelectionFormattingChange(viewUpdate);
}),
handleLinkEditRequests(() => {
props.onEvent({
kind: EditorEventType.EditLink,
});
}),
],
doc: initialText,
}),

View File

@ -10,8 +10,9 @@ import {
} from '../markdown/markdownCommands';
import duplicateLine from './duplicateLine';
import sortSelectedLines from './sortSelectedLines';
import { closeSearchPanel, findNext, findPrevious, openSearchPanel, replaceAll, replaceNext } from '@codemirror/search';
import { closeSearchPanel, findNext, findPrevious, openSearchPanel, replaceAll, replaceNext, searchPanelOpen } from '@codemirror/search';
import { focus } from '@joplin/lib/utils/focusHandler';
import { showLinkEditor } from '../utils/handleLinkEditRequests';
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
export type EditorCommandFunction = (editor: EditorView, ...args: any[])=> void|any;
@ -71,6 +72,15 @@ const editorCommands: Record<EditorCommandType, EditorCommandFunction> = {
[EditorCommandType.UndoSelection]: undoSelection,
[EditorCommandType.RedoSelection]: redoSelection,
[EditorCommandType.EditLink]: showLinkEditor,
[EditorCommandType.ToggleSearch]: (view) => {
if (searchPanelOpen(view.state)) {
return closeSearchPanel(view);
} else {
return openSearchPanel(view);
}
},
[EditorCommandType.ShowSearch]: openSearchPanel,
[EditorCommandType.HideSearch]: closeSearchPanel,
[EditorCommandType.FindNext]: findNext,

View File

@ -0,0 +1,25 @@
import { EditorState, StateEffect } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
export const showLinkEditorEffect = StateEffect.define<void>();
export const showLinkEditor = (view: EditorView) => {
view.dispatch({
effects: [
showLinkEditorEffect.of(),
],
});
return true;
};
const handleLinkEditRequests = (onShowEditor: ()=> void) => [
EditorState.transactionExtender.of(tr => {
if (tr.effects.some(e => e.is(showLinkEditorEffect))) {
onShowEditor();
}
return null;
}),
];
export default handleLinkEditRequests;

View File

@ -33,6 +33,7 @@ export enum EditorCommandType {
InsertHorizontalRule = 'textHorizontalRule',
// Find commands
ToggleSearch = 'textSearch',
ShowSearch = 'find',
HideSearch = 'hideSearchDialog',
FindNext = 'findNext',
@ -40,6 +41,8 @@ export enum EditorCommandType {
ReplaceNext = 'replace',
ReplaceAll = 'replaceAll',
EditLink = 'textLink',
// Editing and navigation commands
ScrollSelectionIntoView = 'scrollSelectionIntoView',
DeleteLine = 'deleteLine',

View File

@ -1,4 +1,5 @@
import * as fs from 'fs-extra';
import { pathExistsSync } from 'fs-extra';
const Entities = require('html-entities').AllHtmlEntities;
const htmlparser2 = require('@joplin/fork-htmlparser2');
const Datauri = require('datauri/sync');
@ -102,6 +103,7 @@ const processLinkTag = (baseDir: string, _name: string, attrs: any): string => {
const filePath = `${baseDir}/${href}`;
if (!pathExistsSync(filePath)) return null;
const content = fs.readFileSync(filePath, 'utf8');
return `<style>${processCssContent(dirname(filePath), content)}</style>`;
};

View File

@ -668,6 +668,15 @@ const builtInMetadata = (Setting: typeof SettingType) => {
storage: SettingStorage.File,
isGlobal: true,
},
'editor.toolbarButtons': {
value: [] as string[],
public: false,
type: SettingItemType.Array,
storage: SettingStorage.File,
isGlobal: true,
appTypes: [AppType.Mobile],
label: () => 'buttons included in the editor toolbar',
},
'notes.columns': {
value: defaultListColumns(),
public: false,

View File

@ -257,7 +257,7 @@ export default class CommandService extends BaseService {
}
}
public componentRegisterCommands<ComponentType>(component: ComponentType, commands: ComponentCommandSpec<ComponentType>[], allowMultiple?: boolean) {
public componentRegisterCommands<ComponentType>(component: ComponentType, commands: ComponentCommandSpec<ComponentType>[], allowMultiple?: boolean): RegisteredRuntime {
const runtimeHandles: RegisteredRuntime[] = [];
for (const command of commands) {
runtimeHandles.push(

View File

@ -1,10 +1,11 @@
import CommandService from '../CommandService';
import { stateUtils } from '../../reducer';
import focusEditorIfEditorCommand from './focusEditorIfEditorCommand';
import { WhenClauseContext } from './stateToWhenClauseContext';
const separatorItem = { type: 'separator' };
export interface ToolbarButtonInfo {
type: 'button';
name: string;
tooltip: string;
iconName: string;
@ -13,6 +14,16 @@ export interface ToolbarButtonInfo {
title: string;
}
interface SeparatorItem extends Omit<Partial<ToolbarButtonInfo>, 'type'> {
type: 'separator';
}
export const separatorItem: SeparatorItem = {
type: 'separator',
};
export type ToolbarItem = ToolbarButtonInfo|SeparatorItem;
interface ToolbarButtonCacheItem {
info: ToolbarButtonInfo;
}
@ -34,8 +45,7 @@ export default class ToolbarButtonUtils {
return this.service_;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
private commandToToolbarButton(commandName: string, whenClauseContext: any): ToolbarButtonInfo {
private commandToToolbarButton(commandName: string, whenClauseContext: WhenClauseContext): ToolbarButtonInfo {
const newEnabled = this.service.isEnabled(commandName, whenClauseContext);
const newTitle = this.service.title(commandName);
@ -49,13 +59,14 @@ export default class ToolbarButtonUtils {
const command = this.service.commandByName(commandName, { runtimeMustBeRegistered: true });
const output = {
const output: ToolbarButtonInfo = {
type: 'button',
name: commandName,
tooltip: this.service.label(commandName),
iconName: command.declaration.iconName,
enabled: newEnabled,
onClick: async () => {
void this.service.execute(commandName);
await this.service.execute(commandName);
void focusEditorIfEditorCommand(commandName, this.service);
},
title: newTitle,
@ -72,13 +83,12 @@ export default class ToolbarButtonUtils {
// the output also won't change. Invididual toolbarButtonInfo also won't changed
// if the state they use hasn't changed. This is to avoid useless renders of the toolbars.
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public commandsToToolbarButtons(commandNames: string[], whenClauseContext: any): ToolbarButtonInfo[] {
const output: ToolbarButtonInfo[] = [];
public commandsToToolbarButtons(commandNames: string[], whenClauseContext: any): ToolbarItem[] {
const output: ToolbarItem[] = [];
for (const commandName of commandNames) {
if (commandName === '-') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
output.push(separatorItem as any);
output.push(separatorItem);
continue;
}

View File

@ -15,6 +15,10 @@ const { escapeHtml } = require('../../string-utils.js');
import { assetsToHeaders } from '@joplin/renderer';
import getPluginSettingValue from '../plugins/utils/getPluginSettingValue';
import { LinkRenderingType } from '@joplin/renderer/MdToHtml';
import Logger from '@joplin/utils/Logger';
import { parseRenderedNoteMetadata } from './utils';
const logger = Logger.create('InteropService_Exporter_Html');
export default class InteropService_Exporter_Html extends InteropService_Exporter_Base {
@ -125,8 +129,11 @@ export default class InteropService_Exporter_Html extends InteropService_Exporte
},
},
});
const noteContent = [];
if (item.title) noteContent.push(`<div class="exported-note-title">${escapeHtml(item.title)}</div>`);
const metadata = parseRenderedNoteMetadata(result.html ? result.html : '');
if (!metadata.printTitle) logger.info('Not printing title because joplin-metadata-print-title tag is set to false');
if (metadata.printTitle && item.title) noteContent.push(`<div class="exported-note-title">${escapeHtml(item.title)}</div>`);
if (result.html) noteContent.push(result.html);
const libRootPath = dirname(dirname(__dirname));
@ -136,11 +143,15 @@ export default class InteropService_Exporter_Html extends InteropService_Exporte
for (let i = 0; i < result.pluginAssets.length; i++) {
const asset = result.pluginAssets[i];
const filePath = asset.pathIsAbsolute ? asset.path : `${libRootPath}/node_modules/@joplin/renderer/assets/${asset.name}`;
const destPath = `${dirname(noteFilePath)}/pluginAssets/${asset.name}`;
const dir = dirname(destPath);
await shim.fsDriver().mkdir(dir);
this.createdDirs_.push(dir);
await shim.fsDriver().copy(filePath, destPath);
if (!(await shim.fsDriver().exists(filePath))) {
logger.warn(`File does not exist and cannot be exported: ${filePath}`);
} else {
const destPath = `${dirname(noteFilePath)}/pluginAssets/${asset.name}`;
const dir = dirname(destPath);
await shim.fsDriver().mkdir(dir);
this.createdDirs_.push(dir);
await shim.fsDriver().copy(filePath, destPath);
}
}
const fullHtml = `

View File

@ -0,0 +1,35 @@
import { RenderedNoteMetadata, parseRenderedNoteMetadata } from './utils';
describe('interop/utils', () => {
test.each<[string, RenderedNoteMetadata]>([
[
'',
{ printTitle: true },
],
[
'<!-- joplin-metadata-print-title = false -->',
{ printTitle: false },
],
[
'<!-- joplin-metadata-print-title = true -->',
{ printTitle: true },
],
[
'<!-- joplin-metadata-print-title = 0 -->',
{ printTitle: false },
],
[
'<!-- joplin-metadata-print-title = 1 -->',
{ printTitle: true },
],
[
'<!-- joplin-metadata-print-title = 0 -->',
{ printTitle: false },
],
])('should parse metadata from the note HTML body', async (bodyHtml, expected) => {
const actual = parseRenderedNoteMetadata(bodyHtml);
expect(actual).toEqual(expected);
});
});

View File

@ -0,0 +1,25 @@
/* eslint-disable import/prefer-default-export */
export interface RenderedNoteMetadata {
printTitle: boolean;
}
export const parseRenderedNoteMetadata = (noteHtml: string) => {
const output: RenderedNoteMetadata = {
printTitle: true,
};
// <!-- joplin-metadata-print-title = false -->
const match = noteHtml.match(/<!--[\s]+joplin-metadata-(.*?)[\s]+=[\s]+(.*?)[\s]+-->/);
if (match) {
const [, propName, propValue] = match;
if (propName === 'print-title') {
output.printTitle = propValue.toLowerCase() === 'true' || propValue === '1';
} else {
throw new Error(`Unknown view metadata: ${propName}`);
}
}
return output;
};

View File

@ -68,6 +68,7 @@ module.exports = {
await processDirectory(`${rootDir}/packages/app-desktop/gui/NoteListControls/commands`);
await processDirectory(`${rootDir}/packages/app-desktop/gui/Sidebar/commands`);
await processDirectory(`${rootDir}/packages/app-mobile/commands`);
await processDirectory(`${rootDir}/packages/app-mobile/components/screens/Note/commands`);
await processDirectory(`${rootDir}/packages/lib/commands`);
await processDirectory(

View File

@ -1,5 +1,27 @@
# Joplin Android Changelog
## [android-v3.2.3](https://github.com/laurent22/joplin/releases/tag/android-v3.2.3) (Pre-release) - 2024-12-11T13:58:14Z
- New: Accessibility: Add checked/unchecked accessibility information to the "sort notes by" dialog (#11411 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
- New: Translation: Add sk_SK.po (Slovak) (#11433 by [@dodog](https://github.com/dodog))
- Improved: Accessibility: Describe the notebook dropdown for accessibility tools (#11430 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
- Improved: Accessibility: Improve note list accessibility (#11419 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
- Improved: Accessibility: Improve note selection screen reader accessibility (#11424 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
- Improved: Accessibility: Improve screen reader accessibility of the tag list (#11420 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
- Improved: Accessibility: Improve side menu and heading screen reader accessibility (#11427 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
- Improved: Accessibility: Improve tag dialog screen reader accessibility (#11421 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
- Improved: Accessibility: Improve voice typing dialog screen reader accessibility (#11428 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
- Improved: Accessibility: Mark note properties buttons as buttons (#11432 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
- Improved: Accessibility: Search screen: Hide the progress bar from accessibility tools when invisible (#11431 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
- Improved: Close voice typing session when closing the editor (#11466 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
- Improved: Editor: Switch to a scrolling toolbar, allow adding/removing toolbar items (#11472 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
- Improved: Reactivate pCloud synchronisation (23032b9)
- Improved: Removed deprecation notice on OneDrive sync method (ceea0bc)
- Fixed: Accessibility: Fix screen reader is unable to scroll settings tab list (#11429 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
- Fixed: Fix switching notes then unloading app causes blank screen (#11396) (#11384 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
- Fixed: Fix the error caused by undefined isCodeBlock_ (turndown-plugin-gfm) (#11471 by Manabu Nakazawa)
- Fixed: Upgrade CodeMirror packages (#11440) (#11318 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
## [android-v3.2.2](https://github.com/laurent22/joplin/releases/tag/android-v3.2.2) (Pre-release) - 2024-11-19T01:12:43Z
- Improved: Accessibility: Improve dialog accessibility (#11395 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))

View File

@ -0,0 +1,11 @@
# Rendered note metadata
Joplin allows the use of certain metadata tags within the rendered (HTML) version of a note.
At present, the "print-title" metadata tag is the only one supported. By default, the title of a note is displayed at the top when the note is printed or exported. However, this behavior may not always be desired, such as when a plugin needs to produce a document with a specific custom header. In such cases, you can disable the default behavior by including this tag and setting its value to `false`.
You can add this tag either directly in the Markdown document or through a Markdown-it content script in the rendered note.
To prevent the note title from being printed, include the following line in the document:
`<!-- joplin-metadata-print-title = false -->`

View File

@ -8369,7 +8369,7 @@ __metadata:
"@react-native-community/geolocation": 3.3.0
"@react-native-community/netinfo": 11.3.2
"@react-native-community/push-notification-ios": 1.11.0
"@react-native-community/slider": 4.5.2
"@react-native-community/slider": 4.5.3
"@react-native/babel-preset": 0.74.86
"@react-native/metro-config": 0.74.87
"@sqlite.org/sqlite-wasm": 3.46.0-build2