From d1fc69ffbef8bcd876f0d60b3cfeec215c8baee0 Mon Sep 17 00:00:00 2001 From: Laurent Cozic Date: Tue, 10 Dec 2024 14:11:38 +0100 Subject: [PATCH 1/6] Desktop, Cli: Prevent PDF and HTML export from failing when a plugin references a non-existent file --- packages/htmlpack/src/index.ts | 2 ++ .../interop/InteropService_Exporter_Html.ts | 17 ++++++++++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/htmlpack/src/index.ts b/packages/htmlpack/src/index.ts index 6dae9d4fe..5659ac8be 100644 --- a/packages/htmlpack/src/index.ts +++ b/packages/htmlpack/src/index.ts @@ -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 ``; }; diff --git a/packages/lib/services/interop/InteropService_Exporter_Html.ts b/packages/lib/services/interop/InteropService_Exporter_Html.ts index e8aa08e62..8142cd275 100644 --- a/packages/lib/services/interop/InteropService_Exporter_Html.ts +++ b/packages/lib/services/interop/InteropService_Exporter_Html.ts @@ -15,6 +15,9 @@ 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'; + +const logger = Logger.create('InteropService_Exporter_Html'); export default class InteropService_Exporter_Html extends InteropService_Exporter_Base { @@ -136,11 +139,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 = ` From 5d84f80ad11b94f08f088c6543a7a83be6dc84b9 Mon Sep 17 00:00:00 2001 From: Laurent Cozic Date: Tue, 10 Dec 2024 17:13:08 +0100 Subject: [PATCH 2/6] Desktop: Added support for rendered note metadata, in particular the joplin-metadata-print-title tag --- .eslintignore | 2 ++ .gitignore | 2 ++ .../interop/InteropService_Exporter_Html.ts | 6 +++- packages/lib/services/interop/utils.test.ts | 35 +++++++++++++++++++ packages/lib/services/interop/utils.ts | 25 +++++++++++++ .../plugin_rendered_note_metadata.md | 11 ++++++ 6 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 packages/lib/services/interop/utils.test.ts create mode 100644 packages/lib/services/interop/utils.ts create mode 100644 readme/api/references/plugin_rendered_note_metadata.md diff --git a/.eslintignore b/.eslintignore index fa88ee67e..94485166b 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1195,6 +1195,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 diff --git a/.gitignore b/.gitignore index daf97879a..5db4a4268 100644 --- a/.gitignore +++ b/.gitignore @@ -1171,6 +1171,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 diff --git a/packages/lib/services/interop/InteropService_Exporter_Html.ts b/packages/lib/services/interop/InteropService_Exporter_Html.ts index 8142cd275..2a2e424ef 100644 --- a/packages/lib/services/interop/InteropService_Exporter_Html.ts +++ b/packages/lib/services/interop/InteropService_Exporter_Html.ts @@ -16,6 +16,7 @@ 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'); @@ -128,8 +129,11 @@ export default class InteropService_Exporter_Html extends InteropService_Exporte }, }, }); + const noteContent = []; - if (item.title) noteContent.push(`
${escapeHtml(item.title)}
`); + 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(`
${escapeHtml(item.title)}
`); if (result.html) noteContent.push(result.html); const libRootPath = dirname(dirname(__dirname)); diff --git a/packages/lib/services/interop/utils.test.ts b/packages/lib/services/interop/utils.test.ts new file mode 100644 index 000000000..dcd3a3c78 --- /dev/null +++ b/packages/lib/services/interop/utils.test.ts @@ -0,0 +1,35 @@ +import { RenderedNoteMetadata, parseRenderedNoteMetadata } from './utils'; + +describe('interop/utils', () => { + + test.each<[string, RenderedNoteMetadata]>([ + [ + '', + { printTitle: true }, + ], + [ + '', + { printTitle: false }, + ], + [ + '', + { printTitle: true }, + ], + [ + '', + { printTitle: false }, + ], + [ + '', + { printTitle: true }, + ], + [ + '', + { printTitle: false }, + ], + ])('should parse metadata from the note HTML body', async (bodyHtml, expected) => { + const actual = parseRenderedNoteMetadata(bodyHtml); + expect(actual).toEqual(expected); + }); + +}); diff --git a/packages/lib/services/interop/utils.ts b/packages/lib/services/interop/utils.ts new file mode 100644 index 000000000..06ae85074 --- /dev/null +++ b/packages/lib/services/interop/utils.ts @@ -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, + }; + + // + const match = noteHtml.match(//); + 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; +}; diff --git a/readme/api/references/plugin_rendered_note_metadata.md b/readme/api/references/plugin_rendered_note_metadata.md new file mode 100644 index 000000000..7ec403930 --- /dev/null +++ b/readme/api/references/plugin_rendered_note_metadata.md @@ -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: + +`` \ No newline at end of file From d935a491ba741457ef6864a08de5e593c5b0a542 Mon Sep 17 00:00:00 2001 From: Henry Heino <46334387+personalizedrefrigerator@users.noreply.github.com> Date: Wed, 11 Dec 2024 04:31:05 -0800 Subject: [PATCH 3/6] Mobile: Editor: Switch to a scrolling toolbar, allow adding/removing toolbar items (#11472) --- .eslintignore | 37 +-- .gitignore | 37 +-- .../NoteBody/CodeMirror/Toolbar.tsx | 4 +- .../NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx | 8 +- .../app-desktop/gui/NoteEditor/NoteEditor.tsx | 4 +- .../app-desktop/gui/NoteEditor/utils/types.ts | 6 +- .../gui/NoteToolbar/NoteToolbar.tsx | 4 +- packages/app-desktop/gui/ToolbarBase.tsx | 33 ++- .../components/DismissibleDialog.tsx | 17 +- .../EditorToolbar/EditorToolbar.test.tsx | 139 +++++++++++ .../EditorToolbar/EditorToolbar.tsx | 117 +++++++++ .../EditorToolbar/ToolbarButton.tsx | 58 +++++ .../EditorToolbar/ToolbarEditorDialog.tsx | 191 +++++++++++++++ .../testing/mockCommandRuntimes.ts | 28 +++ .../components/EditorToolbar/types.ts | 6 + .../utils/allToolbarCommandNamesFromState.ts | 54 +++++ .../EditorToolbar/utils/isSelected.ts | 40 +++ .../utils/selectedCommandNamesFromState.ts | 30 +++ .../utils/toolbarButtonsFromState.ts | 16 ++ packages/app-mobile/components/IconButton.tsx | 6 +- .../app-mobile/components/ModalDialog.tsx | 28 +-- .../components/NoteEditor/EditLinkDialog.tsx | 1 + .../MarkdownToolbar/MarkdownToolbar.tsx | 140 ----------- .../MarkdownToolbar/ToggleOverflowButton.tsx | 31 --- .../NoteEditor/MarkdownToolbar/Toolbar.tsx | 124 ---------- .../MarkdownToolbar/ToolbarButton.tsx | 74 ------ .../MarkdownToolbar/ToolbarOverflowRows.tsx | 134 ----------- .../buttons/useActionButtons.ts | 83 ------- .../buttons/useHeaderButtons.ts | 34 --- .../buttons/useInlineFormattingButtons.ts | 67 ------ .../MarkdownToolbar/buttons/useListButtons.ts | 63 ----- .../buttons/usePluginButtons.ts | 38 --- .../NoteEditor/MarkdownToolbar/types.ts | 56 ----- .../components/NoteEditor/NoteEditor.test.tsx | 30 ++- .../components/NoteEditor/NoteEditor.tsx | 58 ++--- .../NoteEditor/commandDeclarations.ts | 75 +++++- .../hooks/useEditorCommandHandler.ts | 8 +- .../components/ScreenHeader/WebBetaButton.tsx | 2 +- .../ToggleSpaceButton.tsx | 16 +- .../app-mobile/components/buttons/index.tsx | 1 + .../app-mobile/components/global-style.ts | 1 + .../screens/{ => Note}/Note.test.tsx | 14 +- .../components/screens/{ => Note}/Note.tsx | 227 ++++++++---------- .../screens/Note/commands/attachFile.ts | 87 +++++++ .../screens/Note/commands/hideKeyboard.ts | 18 ++ .../components/screens/Note/commands/index.ts | 13 + .../screens/Note/commands/setTags.ts | 19 ++ .../components/screens/Note/types.ts | 17 ++ packages/app-mobile/root.tsx | 31 ++- .../commands/stateToWhenClauseContext.ts | 16 ++ packages/app-mobile/utils/appDefaultState.ts | 1 + packages/app-mobile/utils/createRootStyle.ts | 5 +- .../hooks/useKeyboardVisible.ts | 0 .../utils/initializeCommandService.ts | 6 +- .../utils/testing/createMockReduxStore.ts | 13 +- .../utils/testing/setupGlobalStore.ts | 16 ++ packages/app-mobile/utils/types.ts | 1 + packages/editor/CodeMirror/createEditor.ts | 17 +- .../editorCommands/editorCommands.ts | 12 +- .../utils/handleLinkEditRequests.ts | 25 ++ packages/editor/types.ts | 3 + .../lib/models/settings/builtInMetadata.ts | 9 + packages/lib/services/CommandService.ts | 2 +- .../services/commands/ToolbarButtonUtils.ts | 28 ++- .../tools/gulp/tasks/buildScriptIndexes.js | 1 + 65 files changed, 1326 insertions(+), 1154 deletions(-) create mode 100644 packages/app-mobile/components/EditorToolbar/EditorToolbar.test.tsx create mode 100644 packages/app-mobile/components/EditorToolbar/EditorToolbar.tsx create mode 100644 packages/app-mobile/components/EditorToolbar/ToolbarButton.tsx create mode 100644 packages/app-mobile/components/EditorToolbar/ToolbarEditorDialog.tsx create mode 100644 packages/app-mobile/components/EditorToolbar/testing/mockCommandRuntimes.ts create mode 100644 packages/app-mobile/components/EditorToolbar/types.ts create mode 100644 packages/app-mobile/components/EditorToolbar/utils/allToolbarCommandNamesFromState.ts create mode 100644 packages/app-mobile/components/EditorToolbar/utils/isSelected.ts create mode 100644 packages/app-mobile/components/EditorToolbar/utils/selectedCommandNamesFromState.ts create mode 100644 packages/app-mobile/components/EditorToolbar/utils/toolbarButtonsFromState.ts delete mode 100644 packages/app-mobile/components/NoteEditor/MarkdownToolbar/MarkdownToolbar.tsx delete mode 100644 packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleOverflowButton.tsx delete mode 100644 packages/app-mobile/components/NoteEditor/MarkdownToolbar/Toolbar.tsx delete mode 100644 packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarButton.tsx delete mode 100644 packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarOverflowRows.tsx delete mode 100644 packages/app-mobile/components/NoteEditor/MarkdownToolbar/buttons/useActionButtons.ts delete mode 100644 packages/app-mobile/components/NoteEditor/MarkdownToolbar/buttons/useHeaderButtons.ts delete mode 100644 packages/app-mobile/components/NoteEditor/MarkdownToolbar/buttons/useInlineFormattingButtons.ts delete mode 100644 packages/app-mobile/components/NoteEditor/MarkdownToolbar/buttons/useListButtons.ts delete mode 100644 packages/app-mobile/components/NoteEditor/MarkdownToolbar/buttons/usePluginButtons.ts delete mode 100644 packages/app-mobile/components/NoteEditor/MarkdownToolbar/types.ts rename packages/app-mobile/components/{NoteEditor/MarkdownToolbar => }/ToggleSpaceButton.tsx (83%) rename packages/app-mobile/components/screens/{ => Note}/Note.test.tsx (96%) rename packages/app-mobile/components/screens/{ => Note}/Note.tsx (91%) create mode 100644 packages/app-mobile/components/screens/Note/commands/attachFile.ts create mode 100644 packages/app-mobile/components/screens/Note/commands/hideKeyboard.ts create mode 100644 packages/app-mobile/components/screens/Note/commands/index.ts create mode 100644 packages/app-mobile/components/screens/Note/commands/setTags.ts create mode 100644 packages/app-mobile/components/screens/Note/types.ts create mode 100644 packages/app-mobile/services/commands/stateToWhenClauseContext.ts rename packages/app-mobile/{components/NoteEditor => utils}/hooks/useKeyboardVisible.ts (100%) create mode 100644 packages/app-mobile/utils/testing/setupGlobalStore.ts create mode 100644 packages/editor/CodeMirror/utils/handleLinkEditRequests.ts diff --git a/.eslintignore b/.eslintignore index 94485166b..b99d01f32 100644 --- a/.eslintignore +++ b/.eslintignore @@ -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 diff --git a/.gitignore b/.gitignore index 5db4a4268..dcd9c3f10 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/Toolbar.tsx b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/Toolbar.tsx index cd5b286b7..e07fc4bb0 100644 --- a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/Toolbar.tsx +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/Toolbar.tsx @@ -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; } diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx b/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx index 260e129dd..9fd1e6867 100644 --- a/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx @@ -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 { 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( { ], whenClauseContext), setTagsToolbarButtonInfo: toolbarButtonUtils.commandsToToolbarButtons([ 'setTags', - ], whenClauseContext)[0], + ], whenClauseContext)[0] as ToolbarButtonInfo, contentMaxWidth: state.settings['style.editor.contentMaxWidth'], isSafeMode: state.settings.isSafeMode, useCustomPdfViewer: false, diff --git a/packages/app-desktop/gui/NoteEditor/utils/types.ts b/packages/app-desktop/gui/NoteEditor/utils/types.ts index 3d0be500b..6d53f0c76 100644 --- a/packages/app-desktop/gui/NoteEditor/utils/types.ts +++ b/packages/app-desktop/gui/NoteEditor/utils/types.ts @@ -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; diff --git a/packages/app-desktop/gui/NoteToolbar/NoteToolbar.tsx b/packages/app-desktop/gui/NoteToolbar/NoteToolbar.tsx index dc24442d6..90dc11d96 100644 --- a/packages/app-desktop/gui/NoteToolbar/NoteToolbar.tsx +++ b/packages/app-desktop/gui/NoteToolbar/NoteToolbar.tsx @@ -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; } diff --git a/packages/app-desktop/gui/ToolbarBase.tsx b/packages/app-desktop/gui/ToolbarBase.tsx index 83a5f3f01..eed25d5ed 100644 --- a/packages/app-desktop/gui/ToolbarBase.tsx +++ b/packages/app-desktop/gui/ToolbarBase.tsx @@ -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>, - focusableItems: ToolbarItemInfo[], + focusableItems: ToolbarItem[], ) => { const onKeyDown: React.KeyboardEventHandler = useCallback(event => { let direction = 0; @@ -110,11 +106,10 @@ const ToolbarBaseComponent: React.FC = 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 => { } }; - if (o.name === 'toggleEditors') { + if (o.type === 'button' && o.name === 'toggleEditors') { return = props => { toolbarButtonInfo={o} tabIndex={tabIndex} />; - } else if (itemType === 'button') { + } else if (o.type === 'button') { return ( = props => { {...buttonProps} /> ); - } else if (itemType === 'separator') { + } else if (o.type === 'separator') { return ; } @@ -157,7 +152,7 @@ const ToolbarBaseComponent: React.FC = props => { }; let focusableIndex = 0; - const renderList = (items: ToolbarItemInfo[]) => { + const renderList = (items: ToolbarItem[]) => { const result: React.ReactNode[] = []; for (const item of items) { diff --git a/packages/app-mobile/components/DismissibleDialog.tsx b/packages/app-mobile/components/DismissibleDialog.tsx index 36075e62c..a8c429775 100644 --- a/packages/app-mobile/components/DismissibleDialog.tsx +++ b/packages/app-mobile/components/DismissibleDialog.tsx @@ -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 => { const styles = useStyles(props.themeId, props.containerStyle, props.size); - const closeButton = ( + const heading = props.heading ? ( + {props.heading} + ) : null; + const closeButtonRow = ( + {heading ?? } = props => { transparent={true} > - {closeButton} + {closeButtonRow} {props.children} diff --git a/packages/app-mobile/components/EditorToolbar/EditorToolbar.test.tsx b/packages/app-mobile/components/EditorToolbar/EditorToolbar.test.tsx new file mode 100644 index 000000000..437c26df3 --- /dev/null +++ b/packages/app-mobile/components/EditorToolbar/EditorToolbar.test.tsx @@ -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; + +interface WrapperProps { } + +const WrappedToolbar: React.FC = _props => { + return + + ; +}; + +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(); + + // 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(); + + // 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(); + + // 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(); + }); +}); diff --git a/packages/app-mobile/components/EditorToolbar/EditorToolbar.tsx b/packages/app-mobile/components/EditorToolbar/EditorToolbar.tsx new file mode 100644 index 000000000..e2f057f3e --- /dev/null +++ b/packages/app-mobile/components/EditorToolbar/EditorToolbar.tsx @@ -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>; +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 => { + 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 ; + }; + + const [settingsVisible, setSettingsVisible] = useState(false); + const scrollViewRef = useRef(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 = ; + + return <> + + + {buttonInfos.map(renderButton)} + + {settingsButton} + + + + ; +}; + +export default connect((state: AppState) => { + return { + themeId: state.settings.theme, + toolbarButtonInfos: toolbarButtonsFromState(state), + }; +})(EditorToolbar); diff --git a/packages/app-mobile/components/EditorToolbar/ToolbarButton.tsx b/packages/app-mobile/components/EditorToolbar/ToolbarButton.tsx new file mode 100644 index 000000000..d5c93a971 --- /dev/null +++ b/packages/app-mobile/components/EditorToolbar/ToolbarButton.tsx @@ -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 = memo(({ themeId, buttonInfo, selected }) => { + const styles = useStyles(themeId, selected, buttonInfo.enabled); + const isToggleButton = selected !== undefined; + + return ; +}); + +export default ToolbarButton; diff --git a/packages/app-mobile/components/EditorToolbar/ToolbarEditorDialog.tsx b/packages/app-mobile/components/EditorToolbar/ToolbarEditorDialog.tsx new file mode 100644 index 000000000..00f97e510 --- /dev/null +++ b/packages/app-mobile/components/EditorToolbar/ToolbarEditorDialog.tsx @@ -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; + +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 = ({ + 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 ( + + + + + + {title} + + + + ); +}; + +const ToolbarEditorScreen: React.FC = props => { + const styles = useStyle(props.themeId); + + const renderItem = (item: ToolbarItem, index: number) => { + if (item.type === 'separator') { + return ; + } + + return ; + }; + + 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 = + {_('Restore defaults')} + ; + + return ( + + + {_('Check elements to display in the toolbar')} + + + {props.defaultToolbarButtonInfos.map((item, index) => renderItem(item, index))} + {props.hasCustomizedLayout ? restoreButton : null} + + + ); +}; + +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); diff --git a/packages/app-mobile/components/EditorToolbar/testing/mockCommandRuntimes.ts b/packages/app-mobile/components/EditorToolbar/testing/mockCommandRuntimes.ts new file mode 100644 index 000000000..a66d76a28 --- /dev/null +++ b/packages/app-mobile/components/EditorToolbar/testing/mockCommandRuntimes.ts @@ -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) => { + 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; diff --git a/packages/app-mobile/components/EditorToolbar/types.ts b/packages/app-mobile/components/EditorToolbar/types.ts new file mode 100644 index 000000000..d91639093 --- /dev/null +++ b/packages/app-mobile/components/EditorToolbar/types.ts @@ -0,0 +1,6 @@ +import SelectionFormatting from '@joplin/editor/SelectionFormatting'; + +export interface EditorState { + selectionState: SelectionFormatting; + searchVisible: boolean; +} diff --git a/packages/app-mobile/components/EditorToolbar/utils/allToolbarCommandNamesFromState.ts b/packages/app-mobile/components/EditorToolbar/utils/allToolbarCommandNamesFromState.ts new file mode 100644 index 000000000..dd0478895 --- /dev/null +++ b/packages/app-mobile/components/EditorToolbar/utils/allToolbarCommandNamesFromState.ts @@ -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; diff --git a/packages/app-mobile/components/EditorToolbar/utils/isSelected.ts b/packages/app-mobile/components/EditorToolbar/utils/isSelected.ts new file mode 100644 index 000000000..29058102b --- /dev/null +++ b/packages/app-mobile/components/EditorToolbar/utils/isSelected.ts @@ -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 = { + [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; diff --git a/packages/app-mobile/components/EditorToolbar/utils/selectedCommandNamesFromState.ts b/packages/app-mobile/components/EditorToolbar/utils/selectedCommandNamesFromState.ts new file mode 100644 index 000000000..c4a3ec67e --- /dev/null +++ b/packages/app-mobile/components/EditorToolbar/utils/selectedCommandNamesFromState.ts @@ -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; diff --git a/packages/app-mobile/components/EditorToolbar/utils/toolbarButtonsFromState.ts b/packages/app-mobile/components/EditorToolbar/utils/toolbarButtonsFromState.ts new file mode 100644 index 000000000..24b4265ee --- /dev/null +++ b/packages/app-mobile/components/EditorToolbar/utils/toolbarButtonsFromState.ts @@ -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; diff --git a/packages/app-mobile/components/IconButton.tsx b/packages/app-mobile/components/IconButton.tsx index fd67f632b..c6dc9eed6 100644 --- a/packages/app-mobile/components/IconButton.tsx +++ b/packages/app-mobile/components/IconButton.tsx @@ -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']} > { this.styles_ = {}; const styles: Record = { - modalWrapper: { - flex: 1, - justifyContent: 'center', - }, modalContentWrapper: { flex: 1, flexDirection: 'column', @@ -76,20 +72,18 @@ class ModalDialog extends React.Component { const buttonBarEnabled = this.props.buttonBarEnabled !== false; return ( - - {}} containerStyle={this.styles().modalContentWrapper}> - {this.props.title} - {ContentComponent} - - - - - - - + {}} containerStyle={this.styles().modalContentWrapper}> + {this.props.title} + {ContentComponent} + + + - - + + + + + ); } } diff --git a/packages/app-mobile/components/NoteEditor/EditLinkDialog.tsx b/packages/app-mobile/components/NoteEditor/EditLinkDialog.tsx index 6520bdd00..9721dba2d 100644 --- a/packages/app-mobile/components/NoteEditor/EditLinkDialog.tsx +++ b/packages/app-mobile/components/NoteEditor/EditLinkDialog.tsx @@ -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/ diff --git a/packages/app-mobile/components/NoteEditor/MarkdownToolbar/MarkdownToolbar.tsx b/packages/app-mobile/components/NoteEditor/MarkdownToolbar/MarkdownToolbar.tsx deleted file mode 100644 index 2dced3dc2..000000000 --- a/packages/app-mobile/components/NoteEditor/MarkdownToolbar/MarkdownToolbar.tsx +++ /dev/null @@ -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 = (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 ( - - - - ); -}; - -// 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; diff --git a/packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleOverflowButton.tsx b/packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleOverflowButton.tsx deleted file mode 100644 index 4f7b6afba..000000000 --- a/packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleOverflowButton.tsx +++ /dev/null @@ -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 = (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 ( - - ); -}; -export default ToggleOverflowButton; diff --git a/packages/app-mobile/components/NoteEditor/MarkdownToolbar/Toolbar.tsx b/packages/app-mobile/components/NoteEditor/MarkdownToolbar/Toolbar.tsx deleted file mode 100644 index 6e910e560..000000000 --- a/packages/app-mobile/components/NoteEditor/MarkdownToolbar/Toolbar.tsx +++ /dev/null @@ -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 = (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( - , - ); - } - - 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 = ( - - ); - - 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 = ( - - { mainButtons } - - ); - - const overflow = ( - - - - ); - - return ( - - { overflowButtonsVisible ? overflow : null } - { !overflowButtonsVisible ? mainButtonRow : null } - - ); -}; -export default Toolbar; diff --git a/packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarButton.tsx b/packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarButton.tsx deleted file mode 100644 index 5afe83f37..000000000 --- a/packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarButton.tsx +++ /dev/null @@ -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 ( - - ); -}; - -export default ToolbarButton; diff --git a/packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarOverflowRows.tsx b/packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarOverflowRows.tsx deleted file mode 100644 index 5f3901d5c..000000000 --- a/packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToolbarOverflowRows.tsx +++ /dev/null @@ -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 = (props: OverflowPopupProps) => { - const overflowRows: ReactElement[] = []; - - let key = 0; - for (let i = 0; i < props.buttonGroups.length; i++) { - key++; - const row: ReactElement[] = []; - - const group = props.buttonGroups[i]; - for (let j = 0; j < group.items.length; j++) { - key++; - - const buttonSpec = group.items[j]; - row.push( - , - ); - - // Show the "hide overflow" button if in the center of the last row - const isLastRow = i === props.buttonGroups.length - 1; - const isCenterOfRow = j + 1 === Math.floor(group.items.length / 2); - if (isLastRow && (isCenterOfRow || group.items.length === 1)) { - row.push( - , - ); - } - } - - // Pad to an odd number of items to ensure that buttons are centered properly - if (row.length % 2 === 0) { - row.push( - , - ); - } - - overflowRows.push( - - - {row} - - , - ); - } - - const [hasSpaceForCloseBtn, setHasSpaceForCloseBtn] = useState(true); - const onContainerLayout = useCallback((event: LayoutChangeEvent) => { - if (props.buttonGroups.length === 0) { - return; - } - - // Add 1 to account for the close button - const totalButtonCount = props.buttonGroups[0].items.length + 1; - - const newWidth = event.nativeEvent.layout.width; - setHasSpaceForCloseBtn(newWidth > totalButtonCount * buttonSize); - }, [setHasSpaceForCloseBtn, props.buttonGroups]); - - const closeButtonSpec: ButtonSpec = { - icon: 'text ⨉', - description: _('Close'), - onPress: props.onToggleOverflow, - }; - const closeButton = ( - - ); - - return ( - - {hasSpaceForCloseBtn ? closeButton : null} - {overflowRows} - - ); -}; -export default ToolbarOverflowRows; diff --git a/packages/app-mobile/components/NoteEditor/MarkdownToolbar/buttons/useActionButtons.ts b/packages/app-mobile/components/NoteEditor/MarkdownToolbar/buttons/useActionButtons.ts deleted file mode 100644 index 330c62434..000000000 --- a/packages/app-mobile/components/NoteEditor/MarkdownToolbar/buttons/useActionButtons.ts +++ /dev/null @@ -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; diff --git a/packages/app-mobile/components/NoteEditor/MarkdownToolbar/buttons/useHeaderButtons.ts b/packages/app-mobile/components/NoteEditor/MarkdownToolbar/buttons/useHeaderButtons.ts deleted file mode 100644 index 87ba65007..000000000 --- a/packages/app-mobile/components/NoteEditor/MarkdownToolbar/buttons/useHeaderButtons.ts +++ /dev/null @@ -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; diff --git a/packages/app-mobile/components/NoteEditor/MarkdownToolbar/buttons/useInlineFormattingButtons.ts b/packages/app-mobile/components/NoteEditor/MarkdownToolbar/buttons/useInlineFormattingButtons.ts deleted file mode 100644 index 129955aac..000000000 --- a/packages/app-mobile/components/NoteEditor/MarkdownToolbar/buttons/useInlineFormattingButtons.ts +++ /dev/null @@ -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; diff --git a/packages/app-mobile/components/NoteEditor/MarkdownToolbar/buttons/useListButtons.ts b/packages/app-mobile/components/NoteEditor/MarkdownToolbar/buttons/useListButtons.ts deleted file mode 100644 index bd83a964e..000000000 --- a/packages/app-mobile/components/NoteEditor/MarkdownToolbar/buttons/useListButtons.ts +++ /dev/null @@ -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; diff --git a/packages/app-mobile/components/NoteEditor/MarkdownToolbar/buttons/usePluginButtons.ts b/packages/app-mobile/components/NoteEditor/MarkdownToolbar/buttons/usePluginButtons.ts deleted file mode 100644 index 315c120e2..000000000 --- a/packages/app-mobile/components/NoteEditor/MarkdownToolbar/buttons/usePluginButtons.ts +++ /dev/null @@ -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; diff --git a/packages/app-mobile/components/NoteEditor/MarkdownToolbar/types.ts b/packages/app-mobile/components/NoteEditor/MarkdownToolbar/types.ts deleted file mode 100644 index 80b613353..000000000 --- a/packages/app-mobile/components/NoteEditor/MarkdownToolbar/types.ts +++ /dev/null @@ -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; -} diff --git a/packages/app-mobile/components/NoteEditor/NoteEditor.test.tsx b/packages/app-mobile/components/NoteEditor/NoteEditor.test.tsx index 392be13ee..32ef1b0f7 100644 --- a/packages/app-mobile/components/NoteEditor/NoteEditor.test.tsx +++ b/packages/app-mobile/components/NoteEditor/NoteEditor.test.tsx @@ -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; +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( - + { onAttach={async ()=>{}} plugins={{}} /> - , + , ); // 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(); } }); } diff --git a/packages/app-mobile/components/NoteEditor/NoteEditor.tsx b/packages/app-mobile/components/NoteEditor/NoteEditor.tsx index 6f987348f..82b07afb6 100644 --- a/packages/app-mobile/components/NoteEditor/NoteEditor.tsx +++ b/packages/app-mobile/components/NoteEditor/NoteEditor.tsx @@ -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 = ; + const toolbarEditorState = useMemo(() => ({ + selectionState, + searchVisible: searchState.dialogVisible, + }), [selectionState, searchState.dialogVisible]); + + const toolbar = ; return ( { 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; diff --git a/packages/app-mobile/components/NoteEditor/hooks/useEditorCommandHandler.ts b/packages/app-mobile/components/NoteEditor/hooks/useEditorCommandHandler.ts index bd6a1b2e4..16807573c 100644 --- a/packages/app-mobile/components/NoteEditor/hooks/useEditorCommandHandler.ts +++ b/packages/app-mobile/components/NoteEditor/hooks/useEditorCommandHandler.ts @@ -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; diff --git a/packages/app-mobile/components/ScreenHeader/WebBetaButton.tsx b/packages/app-mobile/components/ScreenHeader/WebBetaButton.tsx index 671a79dcd..20f7cd959 100644 --- a/packages/app-mobile/components/ScreenHeader/WebBetaButton.tsx +++ b/packages/app-mobile/components/ScreenHeader/WebBetaButton.tsx @@ -42,12 +42,12 @@ const WebBetaButton: React.FC = props => { iconStyle={props.iconStyle} /> - {_('Beta')} {'At present, the web client is in beta. In the future, it may change significantly, or be removed.'} {_('Give feedback')} diff --git a/packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleSpaceButton.tsx b/packages/app-mobile/components/ToggleSpaceButton.tsx similarity index 83% rename from packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleSpaceButton.tsx rename to packages/app-mobile/components/ToggleSpaceButton.tsx index 7a486a241..6671034cb 100644 --- a/packages/app-mobile/components/NoteEditor/MarkdownToolbar/ToggleSpaceButton.tsx +++ b/packages/app-mobile/components/ToggleSpaceButton.tsx @@ -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 ( {props.children} - { decreaseSpaceBtnVisible && props.spaceApplicable ? decreaseSpaceButton : null } + { decreaseSpaceBtnVisible && spaceApplicable ? decreaseSpaceButton : null } ); }; diff --git a/packages/app-mobile/components/buttons/index.tsx b/packages/app-mobile/components/buttons/index.tsx index 577f721ba..119607c80 100644 --- a/packages/app-mobile/components/buttons/index.tsx +++ b/packages/app-mobile/components/buttons/index.tsx @@ -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); diff --git a/packages/app-mobile/components/global-style.ts b/packages/app-mobile/components/global-style.ts index 1f0d5f0d5..ef6720c5e 100644 --- a/packages/app-mobile/components/global-style.ts +++ b/packages/app-mobile/components/global-style.ts @@ -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, diff --git a/packages/app-mobile/components/screens/Note.test.tsx b/packages/app-mobile/components/screens/Note/Note.test.tsx similarity index 96% rename from packages/app-mobile/components/screens/Note.test.tsx rename to packages/app-mobile/components/screens/Note/Note.test.tsx index 59b0b4601..19b93be95 100644 --- a/packages/app-mobile/components/screens/Note.test.tsx +++ b/packages/app-mobile/components/screens/Note/Note.test.tsx @@ -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. diff --git a/packages/app-mobile/components/screens/Note.tsx b/packages/app-mobile/components/screens/Note/Note.tsx similarity index 91% rename from packages/app-mobile/components/screens/Note.tsx rename to packages/app-mobile/components/screens/Note/Note.tsx index 3ca4dbc9f..d89dc6a0e 100644 --- a/packages/app-mobile/components/screens/Note.tsx +++ b/packages/app-mobile/components/screens/Note/Note.tsx @@ -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 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 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 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( + { + 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 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 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 imp return newNote; } - public async attachFile(pickerResponse: Asset, fileType: string): Promise { + public async attachFile( + pickerResponse: PickerResponse, + fileType: string, + ): Promise { if (!pickerResponse) { // User has cancelled return null; @@ -802,36 +834,6 @@ class NoteScreenComponent extends BaseScreenComponent 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 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 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 imp if (canAttachPicture) { output.push({ title: _('Attach...'), - onPress: () => this.showAttachMenu(), + onPress: () => this.onAttach(), disabled: readOnly, }); } @@ -1227,13 +1187,24 @@ class NoteScreenComponent extends BaseScreenComponent 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 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 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 ( diff --git a/packages/app-mobile/components/screens/Note/commands/attachFile.ts b/packages/app-mobile/components/screens/Note/commands/attachFile.ts new file mode 100644 index 000000000..3e131a849 --- /dev/null +++ b/packages/app-mobile/components/screens/Note/commands/attachFile.ts @@ -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', + }; +}; diff --git a/packages/app-mobile/components/screens/Note/commands/hideKeyboard.ts b/packages/app-mobile/components/screens/Note/commands/hideKeyboard.ts new file mode 100644 index 000000000..52b791168 --- /dev/null +++ b/packages/app-mobile/components/screens/Note/commands/hideKeyboard.ts @@ -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', + }; +}; diff --git a/packages/app-mobile/components/screens/Note/commands/index.ts b/packages/app-mobile/components/screens/Note/commands/index.ts new file mode 100644 index 000000000..b9b0d33db --- /dev/null +++ b/packages/app-mobile/components/screens/Note/commands/index.ts @@ -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` \ No newline at end of file diff --git a/packages/app-mobile/components/screens/Note/commands/setTags.ts b/packages/app-mobile/components/screens/Note/commands/setTags.ts new file mode 100644 index 000000000..1c07575a2 --- /dev/null +++ b/packages/app-mobile/components/screens/Note/commands/setTags.ts @@ -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', + }; +}; diff --git a/packages/app-mobile/components/screens/Note/types.ts b/packages/app-mobile/components/screens/Note/types.ts new file mode 100644 index 000000000..becba7fba --- /dev/null +++ b/packages/app-mobile/components/screens/Note/types.ts @@ -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; + hideKeyboard(): void; + insertText(text: string): void; + setCameraVisible(visible: boolean): void; + setTagDialogVisible(visible: boolean): void; + dialogs: DialogControl; +} diff --git a/packages/app-mobile/root.tsx b/packages/app-mobile/root.tsx index 09435fa43..e4e6593a7 100644 --- a/packages/app-mobile/root.tsx +++ b/packages/app-mobile/root.tsx @@ -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(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 diff --git a/packages/app-mobile/services/commands/stateToWhenClauseContext.ts b/packages/app-mobile/services/commands/stateToWhenClauseContext.ts new file mode 100644 index 000000000..adee6a287 --- /dev/null +++ b/packages/app-mobile/services/commands/stateToWhenClauseContext.ts @@ -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; diff --git a/packages/app-mobile/utils/appDefaultState.ts b/packages/app-mobile/utils/appDefaultState.ts index 0eaf1c44e..583bdb48b 100644 --- a/packages/app-mobile/utils/appDefaultState.ts +++ b/packages/app-mobile/utils/appDefaultState.ts @@ -11,6 +11,7 @@ export const DEFAULT_ROUTE = { const appDefaultState: AppState = { smartFilterId: undefined, ...defaultState, + keyboardVisible: false, route: DEFAULT_ROUTE, noteSelectionEnabled: false, noteSideMenuOptions: null, diff --git a/packages/app-mobile/utils/createRootStyle.ts b/packages/app-mobile/utils/createRootStyle.ts index adbc51229..35e2e0b7a 100644 --- a/packages/app-mobile/utils/createRootStyle.ts +++ b/packages/app-mobile/utils/createRootStyle.ts @@ -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, }; }; diff --git a/packages/app-mobile/components/NoteEditor/hooks/useKeyboardVisible.ts b/packages/app-mobile/utils/hooks/useKeyboardVisible.ts similarity index 100% rename from packages/app-mobile/components/NoteEditor/hooks/useKeyboardVisible.ts rename to packages/app-mobile/utils/hooks/useKeyboardVisible.ts diff --git a/packages/app-mobile/utils/initializeCommandService.ts b/packages/app-mobile/utils/initializeCommandService.ts index 53e685221..01d0ae294 100644 --- a/packages/app-mobile/utils/initializeCommandService.ts +++ b/packages/app-mobile/utils/initializeCommandService.ts @@ -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) => { for (const declaration of editorCommandDeclarations) { CommandService.instance().registerDeclaration(declaration); } + for (const command of noteCommands) { + CommandService.instance().registerDeclaration(command.declaration); + } registerCommands(globalCommands); registerCommands(libCommands); }; diff --git a/packages/app-mobile/utils/testing/createMockReduxStore.ts b/packages/app-mobile/utils/testing/createMockReduxStore.ts index a4c41d45d..5f102a467 100644 --- a/packages/app-mobile/utils/testing/createMockReduxStore.ts +++ b/packages/app-mobile/utils/testing/createMockReduxStore.ts @@ -2,14 +2,13 @@ import reducer from '@joplin/lib/reducer'; import { createStore } from 'redux'; import appDefaultState from '../appDefaultState'; import Setting from '@joplin/lib/models/Setting'; +import { AppState } from '../types'; -const defaultState = { - ...appDefaultState, - // Mocking theme in the default state is necessary to prevent "Theme not set!" warnings. - settings: { theme: Setting.THEME_LIGHT }, -}; - -const testReducer = (state = defaultState, action: unknown) => { +const testReducer = (state: AppState|undefined, action: unknown) => { + state ??= { + ...appDefaultState, + settings: Setting.toPlainObject(), + }; return reducer(state, action); }; diff --git a/packages/app-mobile/utils/testing/setupGlobalStore.ts b/packages/app-mobile/utils/testing/setupGlobalStore.ts new file mode 100644 index 000000000..edb86238f --- /dev/null +++ b/packages/app-mobile/utils/testing/setupGlobalStore.ts @@ -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) => { + BaseModel.dispatch = store.dispatch; + BaseSyncTarget.dispatch = store.dispatch; + NavService.dispatch = store.dispatch; + initializeCommandService(store); +}; + +export default setupGlobalStore; diff --git a/packages/app-mobile/utils/types.ts b/packages/app-mobile/utils/types.ts index 33b2b711a..f844f1edd 100644 --- a/packages/app-mobile/utils/types.ts +++ b/packages/app-mobile/utils/types.ts @@ -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; diff --git a/packages/editor/CodeMirror/createEditor.ts b/packages/editor/CodeMirror/createEditor.ts index cd06f8089..9be00d56f 100644 --- a/packages/editor/CodeMirror/createEditor.ts +++ b/packages/editor/CodeMirror/createEditor.ts @@ -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, }), diff --git a/packages/editor/CodeMirror/editorCommands/editorCommands.ts b/packages/editor/CodeMirror/editorCommands/editorCommands.ts index fdac28b15..0aca1bf71 100644 --- a/packages/editor/CodeMirror/editorCommands/editorCommands.ts +++ b/packages/editor/CodeMirror/editorCommands/editorCommands.ts @@ -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.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, diff --git a/packages/editor/CodeMirror/utils/handleLinkEditRequests.ts b/packages/editor/CodeMirror/utils/handleLinkEditRequests.ts new file mode 100644 index 000000000..287f73499 --- /dev/null +++ b/packages/editor/CodeMirror/utils/handleLinkEditRequests.ts @@ -0,0 +1,25 @@ +import { EditorState, StateEffect } from '@codemirror/state'; +import { EditorView } from '@codemirror/view'; + +export const showLinkEditorEffect = StateEffect.define(); + +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; diff --git a/packages/editor/types.ts b/packages/editor/types.ts index 12a15f02c..2eb360a7d 100644 --- a/packages/editor/types.ts +++ b/packages/editor/types.ts @@ -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', diff --git a/packages/lib/models/settings/builtInMetadata.ts b/packages/lib/models/settings/builtInMetadata.ts index 943fbb23d..7f7f463b1 100644 --- a/packages/lib/models/settings/builtInMetadata.ts +++ b/packages/lib/models/settings/builtInMetadata.ts @@ -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, diff --git a/packages/lib/services/CommandService.ts b/packages/lib/services/CommandService.ts index 8f7506c61..5a1fba54e 100644 --- a/packages/lib/services/CommandService.ts +++ b/packages/lib/services/CommandService.ts @@ -257,7 +257,7 @@ export default class CommandService extends BaseService { } } - public componentRegisterCommands(component: ComponentType, commands: ComponentCommandSpec[], allowMultiple?: boolean) { + public componentRegisterCommands(component: ComponentType, commands: ComponentCommandSpec[], allowMultiple?: boolean): RegisteredRuntime { const runtimeHandles: RegisteredRuntime[] = []; for (const command of commands) { runtimeHandles.push( diff --git a/packages/lib/services/commands/ToolbarButtonUtils.ts b/packages/lib/services/commands/ToolbarButtonUtils.ts index 5097554d3..554bb8b88 100644 --- a/packages/lib/services/commands/ToolbarButtonUtils.ts +++ b/packages/lib/services/commands/ToolbarButtonUtils.ts @@ -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, '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; } diff --git a/packages/tools/gulp/tasks/buildScriptIndexes.js b/packages/tools/gulp/tasks/buildScriptIndexes.js index f746d931e..2549c714e 100644 --- a/packages/tools/gulp/tasks/buildScriptIndexes.js +++ b/packages/tools/gulp/tasks/buildScriptIndexes.js @@ -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( From 1aefcb9da511dffc7e0496f1c2d4ab244fba6711 Mon Sep 17 00:00:00 2001 From: Laurent Cozic Date: Wed, 11 Dec 2024 14:01:52 +0100 Subject: [PATCH 4/6] Desktop release v3.2.4 --- packages/app-desktop/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app-desktop/package.json b/packages/app-desktop/package.json index 2410c3a35..7571636de 100644 --- a/packages/app-desktop/package.json +++ b/packages/app-desktop/package.json @@ -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, From d2caaeb4ba1e56697cf03f3b1be792077fa19dc4 Mon Sep 17 00:00:00 2001 From: Laurent Cozic Date: Wed, 11 Dec 2024 14:59:11 +0100 Subject: [PATCH 5/6] Android 3.2.3 --- packages/app-mobile/android/app/build.gradle | 4 ++-- readme/about/changelog/android.md | 22 ++++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/app-mobile/android/app/build.gradle b/packages/app-mobile/android/app/build.gradle index f487f6627..969e0ca4c 100644 --- a/packages/app-mobile/android/app/build.gradle +++ b/packages/app-mobile/android/app/build.gradle @@ -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" } diff --git a/readme/about/changelog/android.md b/readme/about/changelog/android.md index f789a3f15..7231f39ef 100644 --- a/readme/about/changelog/android.md +++ b/readme/about/changelog/android.md @@ -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)) From 3801b60fae561026f39046ece9135b6d9d1340a7 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 11 Dec 2024 14:01:18 +0000 Subject: [PATCH 6/6] Update dependency @react-native-community/slider to v4.5.3 (#11490) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- packages/app-mobile/package.json | 2 +- yarn.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app-mobile/package.json b/packages/app-mobile/package.json index 5fb741124..76e83f0fe 100644 --- a/packages/app-mobile/package.json +++ b/packages/app-mobile/package.json @@ -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", diff --git a/yarn.lock b/yarn.lock index aacc12fb1..55e6182e4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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