mirror of https://github.com/laurent22/joplin.git
Merge remote-tracking branch 'upstream/dev' into pr/desktop/fix-editor-plugins-with-multi-window-support
commit
ccf342f35f
|
@ -158,9 +158,10 @@ packages/app-desktop/commands/exportFolders.js
|
|||
packages/app-desktop/commands/exportNotes.js
|
||||
packages/app-desktop/commands/focusElement.js
|
||||
packages/app-desktop/commands/index.js
|
||||
packages/app-desktop/commands/newAppInstance.js
|
||||
packages/app-desktop/commands/openNoteInNewWindow.js
|
||||
packages/app-desktop/commands/openPrimaryAppInstance.js
|
||||
packages/app-desktop/commands/openProfileDirectory.js
|
||||
packages/app-desktop/commands/openSecondaryAppInstance.js
|
||||
packages/app-desktop/commands/replaceMisspelling.js
|
||||
packages/app-desktop/commands/restoreNoteRevision.js
|
||||
packages/app-desktop/commands/startExternalEditing.js
|
||||
|
@ -265,6 +266,7 @@ packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useKeyboardRefocusHan
|
|||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useLinkTooltips.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useScroll.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useTabIndenter.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useTextPatternsLookup.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useWebViewApi.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteEditor.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteTitle/NoteTitleBar.js
|
||||
|
@ -356,13 +358,16 @@ packages/app-desktop/gui/NoteSearchBar.js
|
|||
packages/app-desktop/gui/NoteStatusBar.js
|
||||
packages/app-desktop/gui/NoteTextViewer.js
|
||||
packages/app-desktop/gui/NoteToolbar/NoteToolbar.js
|
||||
packages/app-desktop/gui/NotyfContext.js
|
||||
packages/app-desktop/gui/OneDriveLoginScreen.js
|
||||
packages/app-desktop/gui/PasswordInput/LabelledPasswordInput.js
|
||||
packages/app-desktop/gui/PasswordInput/PasswordInput.js
|
||||
packages/app-desktop/gui/PasswordInput/types.js
|
||||
packages/app-desktop/gui/PdfViewer.js
|
||||
packages/app-desktop/gui/PluginNotification/PluginNotification.js
|
||||
packages/app-desktop/gui/PopupNotification/NotificationItem.js
|
||||
packages/app-desktop/gui/PopupNotification/PopupNotificationList.js
|
||||
packages/app-desktop/gui/PopupNotification/PopupNotificationProvider.js
|
||||
packages/app-desktop/gui/PopupNotification/types.js
|
||||
packages/app-desktop/gui/PromptDialog.js
|
||||
packages/app-desktop/gui/ResizableLayout/LayoutItemContainer.js
|
||||
packages/app-desktop/gui/ResizableLayout/MoveButtons.js
|
||||
|
@ -424,6 +429,7 @@ packages/app-desktop/gui/ToolbarBase.js
|
|||
packages/app-desktop/gui/ToolbarButton/ToolbarButton.js
|
||||
packages/app-desktop/gui/ToolbarSpace.js
|
||||
packages/app-desktop/gui/TrashNotification/TrashNotification.js
|
||||
packages/app-desktop/gui/TrashNotification/TrashNotificationMessage.js
|
||||
packages/app-desktop/gui/UpdateNotification/UpdateNotification.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/AppDialogs.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/ModalMessageOverlay.js
|
||||
|
@ -550,13 +556,16 @@ packages/app-desktop/services/plugins/UserWebview.js
|
|||
packages/app-desktop/services/plugins/UserWebviewDialog.js
|
||||
packages/app-desktop/services/plugins/UserWebviewDialogButtonBar.js
|
||||
packages/app-desktop/services/plugins/hooks/useContentSize.js
|
||||
packages/app-desktop/services/plugins/hooks/useFormData.js
|
||||
packages/app-desktop/services/plugins/hooks/useHtmlLoader.js
|
||||
packages/app-desktop/services/plugins/hooks/useMessageHandler.js
|
||||
packages/app-desktop/services/plugins/hooks/useScriptLoader.js
|
||||
packages/app-desktop/services/plugins/hooks/useSubmitHandler.js
|
||||
packages/app-desktop/services/plugins/hooks/useThemeCss.test.js
|
||||
packages/app-desktop/services/plugins/hooks/useThemeCss.js
|
||||
packages/app-desktop/services/plugins/hooks/useViewIsReady.js
|
||||
packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.js
|
||||
packages/app-desktop/services/plugins/types.js
|
||||
packages/app-desktop/services/restart.js
|
||||
packages/app-desktop/services/sortOrder/PerFolderSortOrderService.test.js
|
||||
packages/app-desktop/services/sortOrder/PerFolderSortOrderService.js
|
||||
|
@ -594,6 +603,7 @@ packages/app-mobile/commands/scrollToHash.js
|
|||
packages/app-mobile/commands/util/goToNote.js
|
||||
packages/app-mobile/commands/util/showResource.js
|
||||
packages/app-mobile/components/BetaChip.js
|
||||
packages/app-mobile/components/BottomDrawer.js
|
||||
packages/app-mobile/components/CameraView/ActionButtons.js
|
||||
packages/app-mobile/components/CameraView/Camera/index.jest.js
|
||||
packages/app-mobile/components/CameraView/Camera/index.js
|
||||
|
@ -687,7 +697,6 @@ 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/accessibility/AccessibleModalMenu.js
|
||||
packages/app-mobile/components/accessibility/AccessibleView.test.js
|
||||
packages/app-mobile/components/accessibility/AccessibleView.js
|
||||
packages/app-mobile/components/accessibility/FocusControl/AutoFocusProvider.js
|
||||
|
@ -702,6 +711,8 @@ packages/app-mobile/components/biometrics/BiometricPopup.js
|
|||
packages/app-mobile/components/biometrics/biometricAuthenticate.js
|
||||
packages/app-mobile/components/biometrics/sensorInfo.js
|
||||
packages/app-mobile/components/buttons/FloatingActionButton.js
|
||||
packages/app-mobile/components/buttons/LabelledIconButton.js
|
||||
packages/app-mobile/components/buttons/MultiTouchableOpacity.js
|
||||
packages/app-mobile/components/buttons/TextButton.js
|
||||
packages/app-mobile/components/buttons/index.js
|
||||
packages/app-mobile/components/getResponsiveValue.test.js
|
||||
|
@ -792,7 +803,9 @@ packages/app-mobile/components/screens/Note/commands/setTags.js
|
|||
packages/app-mobile/components/screens/Note/commands/toggleVisiblePanes.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/Notes/NewNoteButton.test.js
|
||||
packages/app-mobile/components/screens/Notes/NewNoteButton.js
|
||||
packages/app-mobile/components/screens/Notes/Notes.js
|
||||
packages/app-mobile/components/screens/SearchScreen/SearchResults.js
|
||||
packages/app-mobile/components/screens/SearchScreen/index.js
|
||||
packages/app-mobile/components/screens/ShareManager/AcceptedShareItem.js
|
||||
|
@ -847,6 +860,7 @@ packages/app-mobile/utils/createRootStyle.js
|
|||
packages/app-mobile/utils/database-driver-react-native.js
|
||||
packages/app-mobile/utils/database-driver-react-native.web.js
|
||||
packages/app-mobile/utils/debounce.js
|
||||
packages/app-mobile/utils/focusView.js
|
||||
packages/app-mobile/utils/fs-driver/constants.js
|
||||
packages/app-mobile/utils/fs-driver/fs-driver-rn.js
|
||||
packages/app-mobile/utils/fs-driver/fs-driver-rn.web.js
|
||||
|
@ -862,6 +876,7 @@ packages/app-mobile/utils/getVersionInfoText.js
|
|||
packages/app-mobile/utils/hooks/useKeyboardState.js
|
||||
packages/app-mobile/utils/hooks/useOnLongPressProps.js
|
||||
packages/app-mobile/utils/hooks/useReduceMotionEnabled.js
|
||||
packages/app-mobile/utils/hooks/useSafeAreaPadding.js
|
||||
packages/app-mobile/utils/image/fileToImage.web.js
|
||||
packages/app-mobile/utils/image/getImageDimensions.js
|
||||
packages/app-mobile/utils/image/resizeImage.js
|
||||
|
|
|
@ -133,9 +133,10 @@ packages/app-desktop/commands/exportFolders.js
|
|||
packages/app-desktop/commands/exportNotes.js
|
||||
packages/app-desktop/commands/focusElement.js
|
||||
packages/app-desktop/commands/index.js
|
||||
packages/app-desktop/commands/newAppInstance.js
|
||||
packages/app-desktop/commands/openNoteInNewWindow.js
|
||||
packages/app-desktop/commands/openPrimaryAppInstance.js
|
||||
packages/app-desktop/commands/openProfileDirectory.js
|
||||
packages/app-desktop/commands/openSecondaryAppInstance.js
|
||||
packages/app-desktop/commands/replaceMisspelling.js
|
||||
packages/app-desktop/commands/restoreNoteRevision.js
|
||||
packages/app-desktop/commands/startExternalEditing.js
|
||||
|
@ -240,6 +241,7 @@ packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useKeyboardRefocusHan
|
|||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useLinkTooltips.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useScroll.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useTabIndenter.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useTextPatternsLookup.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useWebViewApi.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteEditor.js
|
||||
packages/app-desktop/gui/NoteEditor/NoteTitle/NoteTitleBar.js
|
||||
|
@ -331,13 +333,16 @@ packages/app-desktop/gui/NoteSearchBar.js
|
|||
packages/app-desktop/gui/NoteStatusBar.js
|
||||
packages/app-desktop/gui/NoteTextViewer.js
|
||||
packages/app-desktop/gui/NoteToolbar/NoteToolbar.js
|
||||
packages/app-desktop/gui/NotyfContext.js
|
||||
packages/app-desktop/gui/OneDriveLoginScreen.js
|
||||
packages/app-desktop/gui/PasswordInput/LabelledPasswordInput.js
|
||||
packages/app-desktop/gui/PasswordInput/PasswordInput.js
|
||||
packages/app-desktop/gui/PasswordInput/types.js
|
||||
packages/app-desktop/gui/PdfViewer.js
|
||||
packages/app-desktop/gui/PluginNotification/PluginNotification.js
|
||||
packages/app-desktop/gui/PopupNotification/NotificationItem.js
|
||||
packages/app-desktop/gui/PopupNotification/PopupNotificationList.js
|
||||
packages/app-desktop/gui/PopupNotification/PopupNotificationProvider.js
|
||||
packages/app-desktop/gui/PopupNotification/types.js
|
||||
packages/app-desktop/gui/PromptDialog.js
|
||||
packages/app-desktop/gui/ResizableLayout/LayoutItemContainer.js
|
||||
packages/app-desktop/gui/ResizableLayout/MoveButtons.js
|
||||
|
@ -399,6 +404,7 @@ packages/app-desktop/gui/ToolbarBase.js
|
|||
packages/app-desktop/gui/ToolbarButton/ToolbarButton.js
|
||||
packages/app-desktop/gui/ToolbarSpace.js
|
||||
packages/app-desktop/gui/TrashNotification/TrashNotification.js
|
||||
packages/app-desktop/gui/TrashNotification/TrashNotificationMessage.js
|
||||
packages/app-desktop/gui/UpdateNotification/UpdateNotification.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/AppDialogs.js
|
||||
packages/app-desktop/gui/WindowCommandsAndDialogs/ModalMessageOverlay.js
|
||||
|
@ -525,13 +531,16 @@ packages/app-desktop/services/plugins/UserWebview.js
|
|||
packages/app-desktop/services/plugins/UserWebviewDialog.js
|
||||
packages/app-desktop/services/plugins/UserWebviewDialogButtonBar.js
|
||||
packages/app-desktop/services/plugins/hooks/useContentSize.js
|
||||
packages/app-desktop/services/plugins/hooks/useFormData.js
|
||||
packages/app-desktop/services/plugins/hooks/useHtmlLoader.js
|
||||
packages/app-desktop/services/plugins/hooks/useMessageHandler.js
|
||||
packages/app-desktop/services/plugins/hooks/useScriptLoader.js
|
||||
packages/app-desktop/services/plugins/hooks/useSubmitHandler.js
|
||||
packages/app-desktop/services/plugins/hooks/useThemeCss.test.js
|
||||
packages/app-desktop/services/plugins/hooks/useThemeCss.js
|
||||
packages/app-desktop/services/plugins/hooks/useViewIsReady.js
|
||||
packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.js
|
||||
packages/app-desktop/services/plugins/types.js
|
||||
packages/app-desktop/services/restart.js
|
||||
packages/app-desktop/services/sortOrder/PerFolderSortOrderService.test.js
|
||||
packages/app-desktop/services/sortOrder/PerFolderSortOrderService.js
|
||||
|
@ -569,6 +578,7 @@ packages/app-mobile/commands/scrollToHash.js
|
|||
packages/app-mobile/commands/util/goToNote.js
|
||||
packages/app-mobile/commands/util/showResource.js
|
||||
packages/app-mobile/components/BetaChip.js
|
||||
packages/app-mobile/components/BottomDrawer.js
|
||||
packages/app-mobile/components/CameraView/ActionButtons.js
|
||||
packages/app-mobile/components/CameraView/Camera/index.jest.js
|
||||
packages/app-mobile/components/CameraView/Camera/index.js
|
||||
|
@ -662,7 +672,6 @@ 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/accessibility/AccessibleModalMenu.js
|
||||
packages/app-mobile/components/accessibility/AccessibleView.test.js
|
||||
packages/app-mobile/components/accessibility/AccessibleView.js
|
||||
packages/app-mobile/components/accessibility/FocusControl/AutoFocusProvider.js
|
||||
|
@ -677,6 +686,8 @@ packages/app-mobile/components/biometrics/BiometricPopup.js
|
|||
packages/app-mobile/components/biometrics/biometricAuthenticate.js
|
||||
packages/app-mobile/components/biometrics/sensorInfo.js
|
||||
packages/app-mobile/components/buttons/FloatingActionButton.js
|
||||
packages/app-mobile/components/buttons/LabelledIconButton.js
|
||||
packages/app-mobile/components/buttons/MultiTouchableOpacity.js
|
||||
packages/app-mobile/components/buttons/TextButton.js
|
||||
packages/app-mobile/components/buttons/index.js
|
||||
packages/app-mobile/components/getResponsiveValue.test.js
|
||||
|
@ -767,7 +778,9 @@ packages/app-mobile/components/screens/Note/commands/setTags.js
|
|||
packages/app-mobile/components/screens/Note/commands/toggleVisiblePanes.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/Notes/NewNoteButton.test.js
|
||||
packages/app-mobile/components/screens/Notes/NewNoteButton.js
|
||||
packages/app-mobile/components/screens/Notes/Notes.js
|
||||
packages/app-mobile/components/screens/SearchScreen/SearchResults.js
|
||||
packages/app-mobile/components/screens/SearchScreen/index.js
|
||||
packages/app-mobile/components/screens/ShareManager/AcceptedShareItem.js
|
||||
|
@ -822,6 +835,7 @@ packages/app-mobile/utils/createRootStyle.js
|
|||
packages/app-mobile/utils/database-driver-react-native.js
|
||||
packages/app-mobile/utils/database-driver-react-native.web.js
|
||||
packages/app-mobile/utils/debounce.js
|
||||
packages/app-mobile/utils/focusView.js
|
||||
packages/app-mobile/utils/fs-driver/constants.js
|
||||
packages/app-mobile/utils/fs-driver/fs-driver-rn.js
|
||||
packages/app-mobile/utils/fs-driver/fs-driver-rn.web.js
|
||||
|
@ -837,6 +851,7 @@ packages/app-mobile/utils/getVersionInfoText.js
|
|||
packages/app-mobile/utils/hooks/useKeyboardState.js
|
||||
packages/app-mobile/utils/hooks/useOnLongPressProps.js
|
||||
packages/app-mobile/utils/hooks/useReduceMotionEnabled.js
|
||||
packages/app-mobile/utils/hooks/useSafeAreaPadding.js
|
||||
packages/app-mobile/utils/image/fileToImage.web.js
|
||||
packages/app-mobile/utils/image/getImageDimensions.js
|
||||
packages/app-mobile/utils/image/resizeImage.js
|
||||
|
|
|
@ -48,7 +48,7 @@ const updateListWithDetails = function (dom, el, detail) {
|
|||
};
|
||||
|
||||
const removeStyles = (dom, element: HTMLElement, styles: string[]) => {
|
||||
Tools.each(styles, (style) => dom.setStyle(element, { [style]: '' }));
|
||||
Tools.each(styles, (style) => dom.setStyle(element, style, ''));
|
||||
};
|
||||
|
||||
const getEndPointNode = function (editor, rng, start, root) {
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 56 KiB |
|
@ -31,7 +31,7 @@ Please see the [donation page](https://github.com/laurent22/joplin/blob/dev/read
|
|||
# Sponsors
|
||||
|
||||
<!-- SPONSORS-ORG -->
|
||||
<a href="https://seirei.ne.jp"><img title="Serei Network" width="256" src="https://joplinapp.org/images/sponsors/SeireiNetwork.png"/></a> <a href="https://www.hosting.de/nextcloud/?mtm_campaign=managed-nextcloud&mtm_kwd=joplinapp&mtm_source=joplinapp-webseite&mtm_medium=banner"><img title="Hosting.de" width="256" src="https://joplinapp.org/images/sponsors/HostingDe.png"/></a> <a href="https://citricsheep.com"><img title="Citric Sheep" width="256" src="https://joplinapp.org/images/sponsors/CitricSheep.png"/></a> <a href="https://sorted.travel/?utm_source=joplinapp"><img title="Sorted Travel" width="256" src="https://joplinapp.org/images/sponsors/SortedTravel.png"/></a> <a href="https://celebian.com"><img title="Celebian" width="256" src="https://joplinapp.org/images/sponsors/Celebian.png"/></a> <a href="https://bestkru.com"><img title="BestKru" width="256" src="https://joplinapp.org/images/sponsors/BestKru.png"/></a> <a href="https://www.socialfollowers.uk/buy-tiktok-followers/"><img title="Social Followers" width="256" src="https://joplinapp.org/images/sponsors/SocialFollowers.png"/></a> <a href="https://stormlikes.com/"><img title="Stormlikes" width="256" src="https://joplinapp.org/images/sponsors/Stormlikes.png"/></a> <a href="https://route4me.com"><img title="Route4Me" width="256" src="https://joplinapp.org/images/sponsors/Route4Me.png"/></a> <a href="https://casinoreviews.net"><img title="Casino Reviews" width="256" src="https://joplinapp.org/images/sponsors/CasinoReviews.png"/></a> <a href="https://topagency.webflow.io"><img title="WebDesignAgency" width="256" src="https://joplinapp.org/images/sponsors/WebDesignAgency.png" alt="web design agency"/></a> <a href="https://realgambling.ca/"><img title="RealGambling.ca" width="256" src="https://joplinapp.org/images/sponsors/RealGambling.png" alt="RealGambling.ca"/></a> <a href="https://essaypro.com/"><img title="write an essay online with EssayPro" width="256" src="https://joplinapp.org/images/sponsors/EssayPro.png" alt="write an essay online with EssayPro"/></a> <a href="https://www.slotozilla.com/nz/no-deposit-bonus"><img title="casino without making any upfront cost" width="256" src="https://joplinapp.org/images/sponsors/Slotozilla.png" alt="casino without making any upfront cost"/></a> <a href="https://www.reddit.com/r/tiktokRise/"><img title="Tiktok Rise" width="256" src="https://joplinapp.org/images/sponsors/TiktokRise.jpg" alt="Tiktok Rise"/></a>
|
||||
<a href="https://seirei.ne.jp"><img title="Serei Network" width="256" src="https://joplinapp.org/images/sponsors/SeireiNetwork.png"/></a> <a href="https://www.hosting.de/nextcloud/?mtm_campaign=managed-nextcloud&mtm_kwd=joplinapp&mtm_source=joplinapp-webseite&mtm_medium=banner"><img title="Hosting.de" width="256" src="https://joplinapp.org/images/sponsors/HostingDe.png"/></a> <a href="https://citricsheep.com"><img title="Citric Sheep" width="256" src="https://joplinapp.org/images/sponsors/CitricSheep.png"/></a> <a href="https://sorted.travel/?utm_source=joplinapp"><img title="Sorted Travel" width="256" src="https://joplinapp.org/images/sponsors/SortedTravel.png"/></a> <a href="https://celebian.com"><img title="Celebian" width="256" src="https://joplinapp.org/images/sponsors/Celebian.png"/></a> <a href="https://bestkru.com"><img title="BestKru" width="256" src="https://joplinapp.org/images/sponsors/BestKru.png"/></a> <a href="https://www.socialfollowers.uk/buy-tiktok-followers/"><img title="Social Followers" width="256" src="https://joplinapp.org/images/sponsors/SocialFollowers.png"/></a> <a href="https://stormlikes.com/"><img title="Stormlikes" width="256" src="https://joplinapp.org/images/sponsors/Stormlikes.png"/></a> <a href="https://route4me.com"><img title="Route4Me" width="256" src="https://joplinapp.org/images/sponsors/Route4Me.png"/></a> <a href="https://casinoreviews.net"><img title="Casino Reviews" width="256" src="https://joplinapp.org/images/sponsors/CasinoReviews.png"/></a> <a href="https://topagency.webflow.io"><img title="WebDesignAgency" width="256" src="https://joplinapp.org/images/sponsors/WebDesignAgency.png" alt="topagency"/></a> <a href="https://realgambling.ca/"><img title="RealGambling.ca" width="256" src="https://joplinapp.org/images/sponsors/RealGambling.png" alt="RealGambling.ca"/></a> <a href="https://essaypro.com/"><img title="write an essay online with EssayPro" width="256" src="https://joplinapp.org/images/sponsors/EssayPro.png" alt="write an essay online with EssayPro"/></a> <a href="https://www.slotozilla.com/nz/no-deposit-bonus"><img title="casino without making any upfront cost" width="256" src="https://joplinapp.org/images/sponsors/Slotozilla.png" alt="casino without making any upfront cost"/></a> <a href="https://www.reddit.com/r/tiktokRise/"><img title="Tiktok Rise" width="256" src="https://joplinapp.org/images/sponsors/TiktokRise.jpg" alt="Tiktok Rise"/></a> <a href="https://essaywriter.pro"><img title="write my essay services by EssayWriter" width="256" src="https://joplinapp.org/images/sponsors/EssayWriterPro.png" alt="write my essay services by EssayWriter"/></a>
|
||||
<!-- SPONSORS-ORG -->
|
||||
|
||||
* * *
|
||||
|
|
15
crowdin.yml
15
crowdin.yml
|
@ -6,18 +6,19 @@ files:
|
|||
- source: /readme/**/*
|
||||
translation: /readme/i18n/%two_letters_code%/docusaurus-plugin-content-docs/current/**/%original_file_name%
|
||||
ignore:
|
||||
- /**/*.jpg
|
||||
- /**/*.json
|
||||
- /**/*.png
|
||||
- /**/*.yml
|
||||
- /readme/_i18n
|
||||
- /readme/i18n
|
||||
- /readme/about/changelog
|
||||
- /readme/about/stats.md
|
||||
- /readme/api
|
||||
- /readme/dev
|
||||
- /readme/news
|
||||
- /readme/cla.md
|
||||
- /readme/connection_check.md
|
||||
- /readme/dev
|
||||
- /readme/i18n
|
||||
- /readme/licenses.md
|
||||
- /readme/news
|
||||
- /readme/privacy.md
|
||||
- /**/*.yml
|
||||
- /**/*.json
|
||||
- /**/*.png
|
||||
- /**/*.jpg
|
||||
|
Binary file not shown.
After Width: | Height: | Size: 384 KiB |
|
@ -212,7 +212,6 @@ export default class ElectronAppWrapper {
|
|||
spellcheck: true,
|
||||
enableRemoteModule: true,
|
||||
},
|
||||
webviewTag: true,
|
||||
// We start with a hidden window, which is then made visible depending on the showTrayIcon setting
|
||||
// https://github.com/laurent22/joplin/issues/2031
|
||||
//
|
||||
|
@ -655,7 +654,7 @@ export default class ElectronAppWrapper {
|
|||
// might still be there for a short while.
|
||||
await msleep(1000);
|
||||
this.ipcLogger_.warn('restartAltInstance: App is gone - restarting it');
|
||||
void bridge().launchNewAppInstance(this.env());
|
||||
void bridge().launchAltAppInstance(this.env());
|
||||
} else {
|
||||
this.ipcLogger_.warn('restartAltInstance: Could not restart calling app because it was still open');
|
||||
}
|
||||
|
@ -706,10 +705,6 @@ export default class ElectronAppWrapper {
|
|||
return true;
|
||||
}
|
||||
|
||||
public initializeCustomProtocolHandler(logger: LoggerWrapper) {
|
||||
this.customProtocolHandler_ ??= handleCustomProtocols(logger);
|
||||
}
|
||||
|
||||
// Electron's autoUpdater has to be init from the main process
|
||||
public initializeAutoUpdaterService(logger: LoggerWrapper, devMode: boolean, includePreReleases: boolean) {
|
||||
if (shim.isWindows() || shim.isMac()) {
|
||||
|
@ -748,6 +743,7 @@ export default class ElectronAppWrapper {
|
|||
const alreadyRunning = await this.ensureSingleInstance();
|
||||
if (alreadyRunning) return;
|
||||
|
||||
this.customProtocolHandler_ = handleCustomProtocols();
|
||||
this.createWindow();
|
||||
|
||||
this.electronApp_.on('before-quit', () => {
|
||||
|
|
|
@ -456,9 +456,6 @@ class Application extends BaseApplication {
|
|||
bridge().openDevTools();
|
||||
}
|
||||
|
||||
bridge().electronApp().initializeCustomProtocolHandler(
|
||||
Logger.create('handleCustomProtocols'),
|
||||
);
|
||||
this.protocolHandler_ = bridge().electronApp().getCustomProtocolHandler();
|
||||
this.protocolHandler_.allowReadAccessToDirectory(__dirname); // App bundle directory
|
||||
this.protocolHandler_.allowReadAccessToDirectory(Setting.value('cacheDir'));
|
||||
|
|
|
@ -6,7 +6,6 @@ import { dirname, toSystemSlashes } from '@joplin/lib/path-utils';
|
|||
import { fileUriToPath } from '@joplin/utils/url';
|
||||
import { urlDecode } from '@joplin/lib/string-utils';
|
||||
import * as Sentry from '@sentry/electron/main';
|
||||
import { ErrorEvent } from '@sentry/types/types';
|
||||
import { homedir } from 'os';
|
||||
import { msleep } from '@joplin/utils/time';
|
||||
import { pathExists, pathExistsSync, writeFileSync } from 'fs-extra';
|
||||
|
@ -101,9 +100,9 @@ export class Bridge {
|
|||
if (logAttachment) hint.attachments = [logAttachment];
|
||||
const date = (new Date()).toISOString().replace(/[:-]/g, '').split('.')[0];
|
||||
|
||||
interface ErrorEventWithLog extends ErrorEvent {
|
||||
type ErrorEventWithLog = (typeof event) & {
|
||||
log: string[];
|
||||
}
|
||||
};
|
||||
|
||||
const errorEventWithLog: ErrorEventWithLog = {
|
||||
...event,
|
||||
|
@ -123,6 +122,10 @@ export class Bridge {
|
|||
},
|
||||
|
||||
integrations: [Sentry.electronMinidumpIntegration()],
|
||||
|
||||
// Using the default ipcMode value causes <iframe>s that use custom protocols to
|
||||
// have isSecureOrigin: false, limiting which browser APIs are available.
|
||||
ipcMode: Sentry.IPCMode.Classic,
|
||||
};
|
||||
|
||||
if (this.autoUploadCrashDumps_) options.dsn = 'https://cceec550871b1e8a10fee4c7a28d5cf2@o4506576757522432.ingest.sentry.io/4506594281783296';
|
||||
|
@ -523,12 +526,18 @@ export class Bridge {
|
|||
}
|
||||
}
|
||||
|
||||
public async launchNewAppInstance(env: string) {
|
||||
public async launchAltAppInstance(env: string) {
|
||||
const cmd = this.appLaunchCommand(env, 'alt1');
|
||||
|
||||
await execCommand([cmd.execPath].concat(cmd.args), { detached: true });
|
||||
}
|
||||
|
||||
public async launchMainAppInstance(env: string) {
|
||||
const cmd = this.appLaunchCommand(env, '');
|
||||
|
||||
await execCommand([cmd.execPath].concat(cmd.args), { detached: true });
|
||||
}
|
||||
|
||||
public async restart() {
|
||||
// Note that in this case we are not sending the "appClose" event
|
||||
// to notify services and component that the app is about to close
|
||||
|
|
|
@ -6,9 +6,10 @@ import * as exportDeletionLog from './exportDeletionLog';
|
|||
import * as exportFolders from './exportFolders';
|
||||
import * as exportNotes from './exportNotes';
|
||||
import * as focusElement from './focusElement';
|
||||
import * as newAppInstance from './newAppInstance';
|
||||
import * as openNoteInNewWindow from './openNoteInNewWindow';
|
||||
import * as openPrimaryAppInstance from './openPrimaryAppInstance';
|
||||
import * as openProfileDirectory from './openProfileDirectory';
|
||||
import * as openSecondaryAppInstance from './openSecondaryAppInstance';
|
||||
import * as replaceMisspelling from './replaceMisspelling';
|
||||
import * as restoreNoteRevision from './restoreNoteRevision';
|
||||
import * as startExternalEditing from './startExternalEditing';
|
||||
|
@ -29,9 +30,10 @@ const index: any[] = [
|
|||
exportFolders,
|
||||
exportNotes,
|
||||
focusElement,
|
||||
newAppInstance,
|
||||
openNoteInNewWindow,
|
||||
openPrimaryAppInstance,
|
||||
openProfileDirectory,
|
||||
openSecondaryAppInstance,
|
||||
replaceMisspelling,
|
||||
restoreNoteRevision,
|
||||
startExternalEditing,
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import bridge from '../services/bridge';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'openPrimaryAppInstance',
|
||||
label: () => _('Open primary app instance...'),
|
||||
};
|
||||
|
||||
export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
execute: async (_context: CommandContext) => {
|
||||
await bridge().launchMainAppInstance(Setting.value('env'));
|
||||
},
|
||||
|
||||
enabledCondition: 'isAltInstance',
|
||||
};
|
||||
};
|
|
@ -4,14 +4,14 @@ import bridge from '../services/bridge';
|
|||
import Setting from '@joplin/lib/models/Setting';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'newAppInstance',
|
||||
label: () => _('New application instance...'),
|
||||
name: 'openSecondaryAppInstance',
|
||||
label: () => _('Open secondary app instance...'),
|
||||
};
|
||||
|
||||
export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
execute: async (_context: CommandContext) => {
|
||||
await bridge().launchNewAppInstance(Setting.value('env'));
|
||||
await bridge().launchAltAppInstance(Setting.value('env'));
|
||||
},
|
||||
|
||||
enabledCondition: '!isAltInstance',
|
|
@ -804,7 +804,7 @@ class MainScreenComponent extends React.Component<Props, State> {
|
|||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
dispatch={this.props.dispatch as any}
|
||||
/>
|
||||
<UpdateNotification themeId={this.props.themeId} />
|
||||
<UpdateNotification />
|
||||
<PluginNotification
|
||||
themeId={this.props.themeId}
|
||||
toast={this.props.toast}
|
||||
|
|
|
@ -555,7 +555,8 @@ function useMenu(props: Props) {
|
|||
const newFolderItem = menuItemDic.newFolder;
|
||||
const newSubFolderItem = menuItemDic.newSubFolder;
|
||||
const printItem = menuItemDic.print;
|
||||
const newAppInstance = menuItemDic.newAppInstance;
|
||||
const openSecondaryAppInstance = menuItemDic.openSecondaryAppInstance;
|
||||
const openPrimaryAppInstance = menuItemDic.openPrimaryAppInstance;
|
||||
const switchProfileItem = {
|
||||
label: _('Switch profile'),
|
||||
submenu: switchProfileMenuItems,
|
||||
|
@ -723,7 +724,8 @@ function useMenu(props: Props) {
|
|||
type: 'separator',
|
||||
},
|
||||
switchProfileItem,
|
||||
newAppInstance,
|
||||
openSecondaryAppInstance,
|
||||
openPrimaryAppInstance,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
@ -43,6 +43,7 @@ import useKeyboardRefocusHandler from './utils/useKeyboardRefocusHandler';
|
|||
import useDocument from '../../../hooks/useDocument';
|
||||
import useEditDialog from './utils/useEditDialog';
|
||||
import useEditDialogEventListeners from './utils/useEditDialogEventListeners';
|
||||
import useTextPatternsLookup from './utils/useTextPatternsLookup';
|
||||
|
||||
const logger = Logger.create('TinyMCE');
|
||||
|
||||
|
@ -56,7 +57,7 @@ const logger = Logger.create('TinyMCE');
|
|||
//
|
||||
// The problem is that the list plugin was, unknown to me, relying on this <br/>
|
||||
// being present. Without it, trying to add a bullet point or checkbox on an
|
||||
// empty document, does nothing. The exact reason for this is unclear
|
||||
// empty document, adds an empty paragraph. The exact reason for this is unclear
|
||||
// so as a workaround we manually add this <br> for empty documents,
|
||||
// which fixes the issue.
|
||||
//
|
||||
|
@ -69,8 +70,8 @@ const logger = Logger.create('TinyMCE');
|
|||
//
|
||||
// Perhaps upgrading the list plugin (which is a fork of TinyMCE own list plugin)
|
||||
// would help?
|
||||
function awfulInitHack(html: string): string {
|
||||
return html === '<div id="rendered-md"></div>' ? '<div id="rendered-md"><p></p></div>' : html;
|
||||
function preprocessHtml(html: string): string {
|
||||
return html === '' ? '<p></p>' : html;
|
||||
}
|
||||
|
||||
|
||||
|
@ -654,6 +655,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
|
|||
// Create and setup the editor
|
||||
// -----------------------------------------------------------------------------------------
|
||||
|
||||
const textPatternsLookupRef = useTextPatternsLookup({ enabled: props.enableTextPatterns, enableMath: props.mathEnabled });
|
||||
useEffect(() => {
|
||||
if (!scriptLoaded) return;
|
||||
if (!editorContainer) return;
|
||||
|
@ -736,27 +738,42 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
|
|||
joplinSub: { inline: 'sub', remove: 'all' },
|
||||
joplinSup: { inline: 'sup', remove: 'all' },
|
||||
code: { inline: 'code', remove: 'all', attributes: { spellcheck: 'false' } },
|
||||
forecolor: { inline: 'span', styles: { color: '%value' } },
|
||||
// Foreground color: The remove_similar: true is necessary here for the "remove formatting"
|
||||
// button to work. See https://github.com/tinymce/tinymce/issues/5026.
|
||||
forecolor: { inline: 'span', styles: { color: '%value' }, remove_similar: true },
|
||||
},
|
||||
text_patterns: props.enableTextPatterns ? [
|
||||
// See https://www.tiny.cloud/docs/tinymce/latest/content-behavior-options/#text_patterns
|
||||
// for the default value
|
||||
{ start: '==', end: '==', format: 'joplinHighlight' },
|
||||
{ start: '`', end: '`', format: 'code' },
|
||||
{ start: '*', end: '*', format: 'italic' },
|
||||
{ start: '**', end: '**', format: 'bold' },
|
||||
{ start: '#', format: 'h1' },
|
||||
{ start: '##', format: 'h2' },
|
||||
{ start: '###', format: 'h3' },
|
||||
{ start: '####', format: 'h4' },
|
||||
{ start: '#####', format: 'h5' },
|
||||
{ start: '######', format: 'h6' },
|
||||
{ start: '1.', cmd: 'InsertOrderedList' },
|
||||
{ start: '*', cmd: 'InsertUnorderedList' },
|
||||
{ start: '-', cmd: 'InsertUnorderedList' },
|
||||
] : [],
|
||||
text_patterns: [],
|
||||
text_patterns_lookup: () => textPatternsLookupRef.current(),
|
||||
|
||||
setup: (editor: Editor) => {
|
||||
editor.addCommand('joplinMath', async () => {
|
||||
const katex = editor.selection.getContent();
|
||||
const md = `$${katex}$`;
|
||||
|
||||
// Save and clear the selection -- when this command is activated by a text pattern,
|
||||
// TinyMCE:
|
||||
// 1. Adjusts the selection just before calling the command to include the to-be-formatted text.
|
||||
// 2. Calls the command.
|
||||
// 3. Removes the "$" characters and restores the selection.
|
||||
//
|
||||
// As a result, the selection needs to be saved and restored.
|
||||
const mathSelection = editor.selection.getBookmark();
|
||||
|
||||
const result = await markupToHtml.current(MarkupLanguage.Markdown, md, { bodyOnly: true });
|
||||
|
||||
// Replace the math...
|
||||
const finalSelection = editor.selection.getBookmark();
|
||||
editor.selection.moveToBookmark(mathSelection);
|
||||
editor.selection.setContent(result.html);
|
||||
editor.selection.moveToBookmark(finalSelection); // ...then move the selection back.
|
||||
|
||||
// Fire update events
|
||||
editor.fire(TinyMceEditorEvents.JoplinChange);
|
||||
dispatchDidUpdate(editor);
|
||||
// The last replacement seems to need to be manually added to the undo history
|
||||
editor.undoManager.add();
|
||||
});
|
||||
|
||||
editor.addCommand('joplinAttach', () => {
|
||||
insertResourcesIntoContentRef.current();
|
||||
});
|
||||
|
@ -1000,6 +1017,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
|
|||
return true;
|
||||
}
|
||||
|
||||
const lastNoteIdRef = useRef(props.noteId);
|
||||
useEffect(() => {
|
||||
if (!editor) return () => {};
|
||||
|
||||
|
@ -1013,7 +1031,10 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
|
|||
const loadContent = async () => {
|
||||
const resourcesEqual = resourceInfosEqual(lastOnChangeEventInfo.current.resourceInfos, props.resourceInfos);
|
||||
|
||||
if (lastOnChangeEventInfo.current.content !== props.content || !resourcesEqual) {
|
||||
// Use nextOnChangeEventInfo's noteId -- lastOnChangeEventInfo can be slightly out-of-date.
|
||||
const differentNoteId = lastNoteIdRef.current !== props.noteId;
|
||||
const differentContent = lastOnChangeEventInfo.current.content !== props.content;
|
||||
if (differentNoteId || differentContent || !resourcesEqual) {
|
||||
const result = await props.markupToHtml(
|
||||
props.contentMarkupLanguage,
|
||||
props.content,
|
||||
|
@ -1024,6 +1045,11 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
|
|||
// This prevents HTML-style resource URLs (e.g. <a href="file://path/to/resource/.../"></a>)
|
||||
// from being discarded.
|
||||
allowedFilePrefixes: [props.resourceDirectory],
|
||||
|
||||
// Remove the wrapping <div id="rendered-md">...</div>, which can cause
|
||||
// TinyMCE to crash in some cases.
|
||||
// See https://github.com/tinymce/tinymce/issues/10276
|
||||
bodyOnly: true,
|
||||
}),
|
||||
);
|
||||
if (cancelled) return;
|
||||
|
@ -1035,7 +1061,12 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
|
|||
// when the note content is updated externally.
|
||||
const offsetBookmarkId = 2;
|
||||
const bookmark = editor.selection.getBookmark(offsetBookmarkId);
|
||||
editor.setContent(awfulInitHack(result.html));
|
||||
const htmlAndCss = [
|
||||
`<style>${result.cssStrings?.join('\n')}</style>`,
|
||||
preprocessHtml(result.html),
|
||||
].join('\n');
|
||||
editor.setContent(htmlAndCss);
|
||||
lastNoteIdRef.current = props.noteId;
|
||||
|
||||
if (lastOnChangeEventInfo.current.contentKey !== props.contentKey) {
|
||||
// Need to clear UndoManager to avoid this problem:
|
||||
|
@ -1067,6 +1098,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
|
|||
|
||||
const allAssetsOptions: NoteStyleOptions = {
|
||||
contentMaxWidthTarget: '.mce-content-body',
|
||||
contentWrapperSelector: '.mce-content-body',
|
||||
scrollbarSize: props.scrollbarSize,
|
||||
themeId: props.contentMarkupLanguage === MarkupLanguage.Html ? 1 : null,
|
||||
whiteBackgroundNoteRendering: props.whiteBackgroundNoteRendering,
|
||||
|
@ -1084,7 +1116,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
|
|||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [editor, props.themeId, props.scrollbarSize, props.markupToHtml, props.allAssets, props.content, props.resourceInfos, props.contentKey, props.contentMarkupLanguage, props.whiteBackgroundNoteRendering]);
|
||||
}, [editor, props.noteId, props.themeId, props.scrollbarSize, props.markupToHtml, props.allAssets, props.content, props.resourceInfos, props.contentKey, props.contentMarkupLanguage, props.whiteBackgroundNoteRendering]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return () => {};
|
||||
|
|
|
@ -1576,7 +1576,7 @@
|
|||
var removeStyles = function (dom, element, styles) {
|
||||
Tools.each(styles, function (style) {
|
||||
var _a;
|
||||
return dom.setStyle(element, (_a = {}, _a[style] = '', _a));
|
||||
return dom.setStyle(element, style, '');
|
||||
});
|
||||
};
|
||||
var getEndPointNode = function (editor, rng, start, root) {
|
||||
|
|
|
@ -7,8 +7,7 @@ import { ContextMenuOptions, ContextMenuItemType } from '../../../utils/contextM
|
|||
import { menuItems } from '../../../utils/contextMenu';
|
||||
import MenuUtils from '@joplin/lib/services/commands/MenuUtils';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import type { Event as ElectronEvent, MenuItemConstructorOptions } from 'electron';
|
||||
import type { ContextMenuParams, Event as ElectronEvent, MenuItemConstructorOptions } from 'electron';
|
||||
|
||||
import Resource from '@joplin/lib/models/Resource';
|
||||
import { TinyMceEditorEvents } from './types';
|
||||
|
@ -23,33 +22,6 @@ const Menu = bridge().Menu;
|
|||
const MenuItem = bridge().MenuItem;
|
||||
const menuUtils = new MenuUtils(CommandService.instance());
|
||||
|
||||
// x and y are the absolute coordinates, as returned by the context-menu event
|
||||
// handler on the webContent. This function will return null if the point is
|
||||
// not within the TinyMCE editor.
|
||||
function contextMenuElement(editor: Editor, x: number, y: number) {
|
||||
if (!editor || !editor.getDoc()) return null;
|
||||
|
||||
const containerDoc = editor.getContainer().ownerDocument;
|
||||
const iframes = containerDoc.getElementsByClassName('tox-edit-area__iframe');
|
||||
if (!iframes.length) return null;
|
||||
|
||||
const zoom = Setting.value('windowContentZoomFactor') / 100;
|
||||
const xScreen = x / zoom;
|
||||
const yScreen = y / zoom;
|
||||
|
||||
// We use .elementFromPoint to handle the case where a dialog is covering
|
||||
// part of the editor.
|
||||
const targetElement = containerDoc.elementFromPoint(xScreen, yScreen);
|
||||
if (targetElement !== iframes[0]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const iframeRect = iframes[0].getBoundingClientRect();
|
||||
const relativeX = xScreen - iframeRect.left;
|
||||
const relativeY = yScreen - iframeRect.top;
|
||||
return editor.getDoc().elementFromPoint(relativeX, relativeY);
|
||||
}
|
||||
|
||||
interface ContextMenuActionOptions {
|
||||
current: ContextMenuOptions;
|
||||
}
|
||||
|
@ -60,7 +32,7 @@ export default function(editor: Editor, plugins: PluginStates, dispatch: Dispatc
|
|||
useEffect(() => {
|
||||
if (!editor) return () => {};
|
||||
|
||||
const contextMenuItems = menuItems(dispatch, htmlToMd, mdToHtml);
|
||||
const contextMenuItems = menuItems(dispatch);
|
||||
const targetWindow = bridge().activeWindow();
|
||||
|
||||
const makeMainMenuItems = (element: Element) => {
|
||||
|
@ -130,13 +102,7 @@ export default function(editor: Editor, plugins: PluginStates, dispatch: Dispatc
|
|||
return [];
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
function onContextMenu(event: ElectronEvent, params: any) {
|
||||
const element = contextMenuElement(editor, params.x, params.y);
|
||||
if (!element) return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const showContextMenu = (element: HTMLElement, misspelledWord: string|null, dictionarySuggestions: string[]) => {
|
||||
const menu = new Menu();
|
||||
const menuItems: MenuItemType[] = [];
|
||||
const toMenuItems = (specs: MenuItemConstructorOptions[]) => {
|
||||
|
@ -145,7 +111,7 @@ export default function(editor: Editor, plugins: PluginStates, dispatch: Dispatc
|
|||
|
||||
menuItems.push(...makeEditableMenuItems(element));
|
||||
menuItems.push(...makeMainMenuItems(element));
|
||||
const spellCheckerMenuItems = SpellCheckerService.instance().contextMenuItems(params.misspelledWord, params.dictionarySuggestions);
|
||||
const spellCheckerMenuItems = SpellCheckerService.instance().contextMenuItems(misspelledWord, dictionarySuggestions);
|
||||
menuItems.push(
|
||||
...toMenuItems(spellCheckerMenuItems),
|
||||
);
|
||||
|
@ -157,13 +123,49 @@ export default function(editor: Editor, plugins: PluginStates, dispatch: Dispatc
|
|||
menu.append(item);
|
||||
}
|
||||
menu.popup({ window: targetWindow });
|
||||
}
|
||||
};
|
||||
|
||||
targetWindow.webContents.prependListener('context-menu', onContextMenu);
|
||||
let lastTarget: EventTarget|null = null;
|
||||
const onElectronContextMenu = (event: ElectronEvent, params: ContextMenuParams) => {
|
||||
if (!lastTarget) return;
|
||||
const element = lastTarget as HTMLElement;
|
||||
lastTarget = null;
|
||||
|
||||
event.preventDefault();
|
||||
showContextMenu(element, params.misspelledWord, params.dictionarySuggestions);
|
||||
};
|
||||
|
||||
const onBrowserContextMenu = (event: PointerEvent) => {
|
||||
const isKeyboard = event.buttons === 0;
|
||||
if (isKeyboard) {
|
||||
// Context menu events from the keyboard seem to always use <body> as the
|
||||
// event target. Since which context menu is displayed depends on what the
|
||||
// target is, using event.target for keyboard-triggered contextmenu events
|
||||
// would prevent keyboard-only users from accessing certain functionality.
|
||||
// To fix this, use the selection instead.
|
||||
lastTarget = editor.selection.getNode();
|
||||
} else {
|
||||
lastTarget = event.target;
|
||||
}
|
||||
|
||||
// Plugins in the Rich Text Editor (e.g. the mermaid renderer) can sometimes
|
||||
// create custom right-click events. These don't trigger the Electron 'context-menu'
|
||||
// event. As such, the context menu must be shown manually.
|
||||
const isFromPlugin = !event.isTrusted;
|
||||
if (isFromPlugin) {
|
||||
event.preventDefault();
|
||||
showContextMenu(lastTarget as HTMLElement, null, []);
|
||||
lastTarget = null;
|
||||
}
|
||||
};
|
||||
|
||||
targetWindow.webContents.prependListener('context-menu', onElectronContextMenu);
|
||||
editor.on('contextmenu', onBrowserContextMenu);
|
||||
|
||||
return () => {
|
||||
editor.off('contextmenu', onBrowserContextMenu);
|
||||
if (!targetWindow.isDestroyed() && targetWindow?.webContents?.off) {
|
||||
targetWindow.webContents.off('context-menu', onContextMenu);
|
||||
targetWindow.webContents.off('context-menu', onElectronContextMenu);
|
||||
}
|
||||
};
|
||||
}, [editor, plugins, dispatch, htmlToMd, mdToHtml, editDialog]);
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
import { useRef } from 'react';
|
||||
|
||||
interface TextPatternOptions {
|
||||
enabled: boolean;
|
||||
enableMath: boolean;
|
||||
}
|
||||
|
||||
const useTextPatternsLookup = ({ enabled, enableMath }: TextPatternOptions) => {
|
||||
const getTextPatterns = () => {
|
||||
if (!enabled) return [];
|
||||
|
||||
return [
|
||||
// See https://www.tiny.cloud/docs/tinymce/latest/content-behavior-options/#text_patterns
|
||||
// for the default TinyMCE text patterns
|
||||
{ start: '==', end: '==', format: 'joplinHighlight' },
|
||||
// Only replace math if math rendering is enabled.
|
||||
enableMath && { start: '$', end: '$', cmd: 'joplinMath' },
|
||||
{ start: '`', end: '`', format: 'code' },
|
||||
{ start: '*', end: '*', format: 'italic' },
|
||||
{ start: '**', end: '**', format: 'bold' },
|
||||
{ start: '#', format: 'h1' },
|
||||
{ start: '##', format: 'h2' },
|
||||
{ start: '###', format: 'h3' },
|
||||
{ start: '####', format: 'h4' },
|
||||
{ start: '#####', format: 'h5' },
|
||||
{ start: '######', format: 'h6' },
|
||||
{ start: '1.', cmd: 'InsertOrderedList' },
|
||||
{ start: '*', cmd: 'InsertUnorderedList' },
|
||||
{ start: '-', cmd: 'InsertUnorderedList' },
|
||||
].filter(pattern => !!pattern);
|
||||
};
|
||||
|
||||
// Store the lookup callback in a ref so that the editor doesn't need to be reloaded
|
||||
// to use the new patterns:
|
||||
const patternLookupRef = useRef(getTextPatterns);
|
||||
patternLookupRef.current = getTextPatterns;
|
||||
return patternLookupRef;
|
||||
};
|
||||
|
||||
export default useTextPatternsLookup;
|
|
@ -418,6 +418,7 @@ function NoteEditorContent(props: NoteEditorProps) {
|
|||
|
||||
const searchMarkers = useSearchMarkers(showLocalSearch, localSearchMarkerOptions, props.searches, props.selectedSearchId, props.highlightedWords);
|
||||
|
||||
const markupLanguage = formNote.markup_language;
|
||||
const editorProps: NoteBodyEditorProps = {
|
||||
ref: editorRef,
|
||||
contentKey: formNote.id,
|
||||
|
@ -427,7 +428,7 @@ function NoteEditorContent(props: NoteEditorProps) {
|
|||
onWillChange: onBodyWillChange,
|
||||
onMessage: onMessage,
|
||||
content: formNote.body,
|
||||
contentMarkupLanguage: formNote.markup_language,
|
||||
contentMarkupLanguage: markupLanguage,
|
||||
contentOriginalCss: formNote.originalCss,
|
||||
resourceInfos: resourceInfos,
|
||||
resourceDirectory: Setting.value('resourceDir'),
|
||||
|
@ -452,6 +453,8 @@ function NoteEditorContent(props: NoteEditorProps) {
|
|||
onDrop: onDrop,
|
||||
noteToolbarButtonInfos: props.toolbarButtonInfos,
|
||||
plugins: props.plugins,
|
||||
// KaTeX isn't supported in HTML notes
|
||||
mathEnabled: markupLanguage === MarkupLanguage.Markdown && Setting.value('markdown.plugin.katex'),
|
||||
fontSize: Setting.value('style.editor.fontSize'),
|
||||
contentMaxWidth: props.contentMaxWidth,
|
||||
scrollbarSize: props.scrollbarSize,
|
||||
|
|
|
@ -36,7 +36,6 @@ const incompatiblePluginIds = [
|
|||
// cSpell:disable
|
||||
'com.septemberhx.Joplin.Enhancement',
|
||||
'ylc395.noteLinkSystem',
|
||||
'outline',
|
||||
'joplin.plugin.cmoptions',
|
||||
'com.asdibiase.joplin-languagetool',
|
||||
// cSpell:enable
|
||||
|
|
|
@ -8,13 +8,11 @@ const MenuItem = bridge().MenuItem;
|
|||
import Resource, { resourceOcrStatusToString } from '@joplin/lib/models/Resource';
|
||||
import BaseItem from '@joplin/lib/models/BaseItem';
|
||||
import BaseModel, { ModelType } from '@joplin/lib/BaseModel';
|
||||
import { processPastedHtml } from './resourceHandling';
|
||||
import { NoteEntity, ResourceEntity, ResourceOcrStatus } from '@joplin/lib/services/database/types';
|
||||
import { TinyMceEditorEvents } from '../NoteBody/TinyMCE/utils/types';
|
||||
import { itemIsReadOnlySync, ItemSlice } from '@joplin/lib/models/utils/readOnly';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import ItemChange from '@joplin/lib/models/ItemChange';
|
||||
import { HtmlToMarkdownHandler, MarkupToHtmlHandler } from './types';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { openFileWithExternalEditor } from '@joplin/lib/services/ExternalEditWatcher/utils';
|
||||
const fs = require('fs-extra');
|
||||
|
@ -81,7 +79,7 @@ export async function openItemById(itemId: string, dispatch: Function, hash = ''
|
|||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
||||
export function menuItems(dispatch: Function, htmlToMd: HtmlToMarkdownHandler, mdToHtml: MarkupToHtmlHandler): ContextMenuItems {
|
||||
export function menuItems(dispatch: Function): ContextMenuItems {
|
||||
return {
|
||||
open: {
|
||||
label: _('Open...'),
|
||||
|
@ -195,17 +193,10 @@ export function menuItems(dispatch: Function, htmlToMd: HtmlToMarkdownHandler, m
|
|||
},
|
||||
paste: {
|
||||
label: _('Paste'),
|
||||
onAction: async (options: ContextMenuOptions) => {
|
||||
const pastedHtml = clipboard.readHTML();
|
||||
let content = pastedHtml ? pastedHtml : clipboard.readText();
|
||||
|
||||
if (pastedHtml) {
|
||||
content = await processPastedHtml(pastedHtml, htmlToMd, mdToHtml);
|
||||
}
|
||||
|
||||
options.insertContent(content);
|
||||
onAction: async (_options: ContextMenuOptions) => {
|
||||
bridge().activeWindow().webContents.paste();
|
||||
},
|
||||
isActive: (_itemType: ContextMenuItemType, options: ContextMenuOptions) => !options.isReadOnly && (!!clipboard.readText() || !!clipboard.readHTML()),
|
||||
isActive: (_itemType: ContextMenuItemType, options: ContextMenuOptions) => !options.isReadOnly && clipboard.availableFormats().length > 0,
|
||||
},
|
||||
pasteAsText: {
|
||||
label: _('Paste as text'),
|
||||
|
@ -228,7 +219,7 @@ export function menuItems(dispatch: Function, htmlToMd: HtmlToMarkdownHandler, m
|
|||
export default async function contextMenu(options: ContextMenuOptions, dispatch: Function) {
|
||||
const menu = new Menu();
|
||||
|
||||
const items = menuItems(dispatch, options.htmlToMd, options.mdToHtml);
|
||||
const items = menuItems(dispatch);
|
||||
|
||||
if (!('readyOnly' in options)) options.isReadOnly = true;
|
||||
for (const itemKey in items) {
|
||||
|
|
|
@ -131,6 +131,7 @@ export interface NoteBodyEditorProps {
|
|||
onDrop: DropHandler;
|
||||
noteToolbarButtonInfos: ToolbarItem[];
|
||||
plugins: PluginStates;
|
||||
mathEnabled: boolean;
|
||||
fontSize: number;
|
||||
contentMaxWidth: number;
|
||||
isSafeMode: boolean;
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
'use strict';
|
||||
// Based on https://github.com/caroso1222/notyf/blob/master/recipes/react.md
|
||||
Object.defineProperty(exports, '__esModule', { value: true });
|
||||
const React = require('react');
|
||||
const notyf_1 = require('notyf');
|
||||
const types_1 = require('@joplin/lib/services/plugins/api/types');
|
||||
exports.default = React.createContext(new notyf_1.Notyf({
|
||||
// Set your global Notyf configuration here
|
||||
duration: 6000,
|
||||
types: [
|
||||
{
|
||||
type: types_1.ToastType.Info,
|
||||
icon: false,
|
||||
className: 'notyf__toast--info',
|
||||
background: 'blue', // Need to set a background, otherwise Notyf won't create the background element. But the color will be overriden in CSS.
|
||||
},
|
||||
],
|
||||
}));
|
||||
// # sourceMappingURL=NotyfContext.js.map
|
|
@ -1,20 +0,0 @@
|
|||
// Based on https://github.com/caroso1222/notyf/blob/master/recipes/react.md
|
||||
|
||||
import * as React from 'react';
|
||||
import { Notyf } from 'notyf';
|
||||
import { ToastType } from '@joplin/lib/services/plugins/api/types';
|
||||
|
||||
export default React.createContext(
|
||||
new Notyf({
|
||||
// Set your global Notyf configuration here
|
||||
duration: 6000,
|
||||
types: [
|
||||
{
|
||||
type: ToastType.Info,
|
||||
icon: false,
|
||||
className: 'notyf__toast--info',
|
||||
background: 'blue', // Need to set a background, otherwise Notyf won't create the background element. But the color will be overriden in CSS.
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
|
@ -1,8 +1,8 @@
|
|||
import { useContext, useMemo } from 'react';
|
||||
import NotyfContext from '../NotyfContext';
|
||||
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
|
||||
import * as React from 'react';
|
||||
import { useContext, useEffect, useMemo } from 'react';
|
||||
import { Toast, ToastType } from '@joplin/lib/services/plugins/api/types';
|
||||
import { INotyfNotificationOptions } from 'notyf';
|
||||
import { PopupNotificationContext } from '../PopupNotification/PopupNotificationProvider';
|
||||
import { NotificationType } from '../PopupNotification/types';
|
||||
|
||||
const emptyToast = (): Toast => {
|
||||
return {
|
||||
|
@ -19,26 +19,23 @@ interface Props {
|
|||
}
|
||||
|
||||
export default (props: Props) => {
|
||||
const notyfContext = useContext(NotyfContext);
|
||||
const popupManager = useContext(PopupNotificationContext);
|
||||
|
||||
const toast = useMemo(() => {
|
||||
const toast: Toast = props.toast ? props.toast : emptyToast();
|
||||
return toast;
|
||||
}, [props.toast]);
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
useEffect(() => {
|
||||
if (!toast.message) return;
|
||||
|
||||
const options: Partial<INotyfNotificationOptions> = {
|
||||
type: toast.type,
|
||||
message: toast.message,
|
||||
duration: toast.duration,
|
||||
};
|
||||
|
||||
notyfContext.open(options);
|
||||
popupManager.createPopup(() => toast.message, {
|
||||
type: toast.type as string as NotificationType,
|
||||
}).scheduleDismiss(toast.duration);
|
||||
// toast.timestamp needs to be included in the dependency list to allow
|
||||
// showing multiple toasts with the same message, one after another.
|
||||
// See https://github.com/laurent22/joplin/issues/11783
|
||||
}, [toast.message, toast.duration, toast.type, toast.timestamp, notyfContext]);
|
||||
}, [toast.message, toast.duration, toast.type, toast.timestamp, popupManager]);
|
||||
|
||||
return <div style={{ display: 'none' }}/>;
|
||||
};
|
||||
|
|
|
@ -0,0 +1,52 @@
|
|||
import * as React from 'react';
|
||||
import { NotificationType } from './types';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
key: string;
|
||||
type: NotificationType;
|
||||
dismissing: boolean;
|
||||
popup: boolean;
|
||||
}
|
||||
|
||||
const NotificationItem: React.FC<Props> = props => {
|
||||
const [iconClassName, iconLabel] = (() => {
|
||||
if (props.type === NotificationType.Success) {
|
||||
return ['fas fa-check', _('Success')];
|
||||
}
|
||||
if (props.type === NotificationType.Error) {
|
||||
return ['fas fa-times', _('Error')];
|
||||
}
|
||||
if (props.type === NotificationType.Info) {
|
||||
return ['fas fa-info', _('Info')];
|
||||
}
|
||||
return ['', ''];
|
||||
})();
|
||||
|
||||
const containerModifier = (() => {
|
||||
if (props.type === NotificationType.Success) return '-success';
|
||||
if (props.type === NotificationType.Error) return '-error';
|
||||
if (props.type === NotificationType.Info) return '-info';
|
||||
return '';
|
||||
})();
|
||||
|
||||
const icon = <i
|
||||
role='img'
|
||||
aria-label={iconLabel}
|
||||
className={`icon ${iconClassName}`}
|
||||
/>;
|
||||
|
||||
return <li
|
||||
role={props.popup ? 'alert' : undefined}
|
||||
className={`popup-notification-item ${containerModifier} ${props.dismissing ? '-dismissing' : ''}`}
|
||||
>
|
||||
{iconClassName ? icon : null}
|
||||
<div className='ripple'/>
|
||||
<div className='content'>
|
||||
{props.children}
|
||||
</div>
|
||||
</li>;
|
||||
};
|
||||
|
||||
export default NotificationItem;
|
|
@ -0,0 +1,41 @@
|
|||
import * as React from 'react';
|
||||
import { VisibleNotificationsContext } from './PopupNotificationProvider';
|
||||
import NotificationItem from './NotificationItem';
|
||||
import { useContext } from 'react';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
|
||||
interface Props {}
|
||||
|
||||
// This component displays the popups managed by PopupNotificationContext.
|
||||
// This allows popups to be shown in multiple windows at the same time.
|
||||
const PopupNotificationList: React.FC<Props> = () => {
|
||||
const popupSpecs = useContext(VisibleNotificationsContext);
|
||||
const popups = [];
|
||||
for (const spec of popupSpecs) {
|
||||
if (spec.dismissed) continue;
|
||||
|
||||
popups.push(
|
||||
<NotificationItem
|
||||
key={spec.key}
|
||||
type={spec.type}
|
||||
dismissing={!!spec.dismissAt}
|
||||
popup={true}
|
||||
>{spec.content()}</NotificationItem>,
|
||||
);
|
||||
}
|
||||
popups.reverse();
|
||||
|
||||
if (popups.length) {
|
||||
return <ul
|
||||
className='popup-notification-list -overlay'
|
||||
role='group'
|
||||
aria-label={_('Notifications')}
|
||||
>
|
||||
{popups}
|
||||
</ul>;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default PopupNotificationList;
|
|
@ -0,0 +1,122 @@
|
|||
import * as React from 'react';
|
||||
import { createContext, useMemo, useRef, useState } from 'react';
|
||||
import { NotificationType, PopupHandle, PopupControl as PopupManager } from './types';
|
||||
import { Hour, msleep } from '@joplin/utils/time';
|
||||
|
||||
export const PopupNotificationContext = createContext<PopupManager|null>(null);
|
||||
export const VisibleNotificationsContext = createContext<PopupSpec[]>([]);
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface PopupSpec {
|
||||
key: string;
|
||||
dismissAt?: number;
|
||||
dismissed: boolean;
|
||||
type: NotificationType;
|
||||
content: ()=> React.ReactNode;
|
||||
}
|
||||
|
||||
const PopupNotificationProvider: React.FC<Props> = props => {
|
||||
const [popupSpecs, setPopupSpecs] = useState<PopupSpec[]>([]);
|
||||
const nextPopupKey = useRef(0);
|
||||
|
||||
const popupManager = useMemo((): PopupManager => {
|
||||
const removeOldPopups = () => {
|
||||
// The WCAG allows dismissing notifications older than 20 hours.
|
||||
setPopupSpecs(popups => popups.filter(popup => {
|
||||
if (!popup.dismissed) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const dismissedRecently = popup.dismissAt > performance.now() - Hour * 20;
|
||||
return dismissedRecently;
|
||||
}));
|
||||
};
|
||||
|
||||
const removePopupWithKey = (key: string) => {
|
||||
setPopupSpecs(popups => popups.filter(p => p.key !== key));
|
||||
};
|
||||
|
||||
type UpdatePopupCallback = (popup: PopupSpec)=> PopupSpec;
|
||||
const updatePopupWithKey = (key: string, updateCallback: UpdatePopupCallback) => {
|
||||
setPopupSpecs(popups => popups.map(p => {
|
||||
if (p.key === key) {
|
||||
return updateCallback(p);
|
||||
} else {
|
||||
return p;
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const dismissAnimationDelay = 600;
|
||||
const dismissPopup = async (key: string) => {
|
||||
// Start the dismiss animation
|
||||
updatePopupWithKey(key, popup => ({
|
||||
...popup,
|
||||
dismissAt: performance.now() + dismissAnimationDelay,
|
||||
}));
|
||||
|
||||
await msleep(dismissAnimationDelay);
|
||||
|
||||
updatePopupWithKey(key, popup => ({
|
||||
...popup,
|
||||
dismissed: true,
|
||||
}));
|
||||
removeOldPopups();
|
||||
};
|
||||
|
||||
const dismissAndRemovePopup = async (key: string) => {
|
||||
await dismissPopup(key);
|
||||
removePopupWithKey(key);
|
||||
};
|
||||
|
||||
const manager: PopupManager = {
|
||||
createPopup: (content, { type } = {}): PopupHandle => {
|
||||
const key = `popup-${nextPopupKey.current++}`;
|
||||
const newPopup: PopupSpec = {
|
||||
key,
|
||||
content,
|
||||
type,
|
||||
dismissed: false,
|
||||
};
|
||||
|
||||
setPopupSpecs(popups => {
|
||||
const newPopups = [...popups];
|
||||
|
||||
// Replace the existing popup, if it exists
|
||||
const insertIndex = newPopups.findIndex(p => p.key === key);
|
||||
if (insertIndex === -1) {
|
||||
newPopups.push(newPopup);
|
||||
} else {
|
||||
newPopups.splice(insertIndex, 1, newPopup);
|
||||
}
|
||||
|
||||
return newPopups;
|
||||
});
|
||||
|
||||
const handle: PopupHandle = {
|
||||
remove() {
|
||||
void dismissAndRemovePopup(key);
|
||||
},
|
||||
scheduleDismiss(delay = 5_500) {
|
||||
setTimeout(() => {
|
||||
void dismissPopup(key);
|
||||
}, delay);
|
||||
},
|
||||
};
|
||||
return handle;
|
||||
},
|
||||
};
|
||||
return manager;
|
||||
}, []);
|
||||
|
||||
return <PopupNotificationContext.Provider value={popupManager}>
|
||||
<VisibleNotificationsContext.Provider value={popupSpecs}>
|
||||
{props.children}
|
||||
</VisibleNotificationsContext.Provider>
|
||||
</PopupNotificationContext.Provider>;
|
||||
};
|
||||
|
||||
export default PopupNotificationProvider;
|
|
@ -0,0 +1,22 @@
|
|||
import * as React from 'react';
|
||||
|
||||
export type PopupHandle = {
|
||||
remove(): void;
|
||||
scheduleDismiss(delay?: number): void;
|
||||
};
|
||||
|
||||
export enum NotificationType {
|
||||
Info = 'info',
|
||||
Success = 'success',
|
||||
Error = 'error',
|
||||
}
|
||||
|
||||
export type NotificationContentCallback = ()=> React.ReactNode;
|
||||
|
||||
export interface PopupOptions {
|
||||
type?: NotificationType;
|
||||
}
|
||||
|
||||
export interface PopupControl {
|
||||
createPopup(content: NotificationContentCallback, props?: PopupOptions): PopupHandle;
|
||||
}
|
|
@ -30,6 +30,7 @@ import WindowCommandsAndDialogs from './WindowCommandsAndDialogs/WindowCommandsA
|
|||
import { defaultWindowId, stateUtils, WindowState } from '@joplin/lib/reducer';
|
||||
import bridge from '../services/bridge';
|
||||
import EditorWindow from './NoteEditor/EditorWindow';
|
||||
import PopupNotificationProvider from './PopupNotification/PopupNotificationProvider';
|
||||
const { ThemeProvider, StyleSheetManager, createGlobalStyle } = require('styled-components');
|
||||
|
||||
interface Props {
|
||||
|
@ -197,13 +198,15 @@ class RootComponent extends React.Component<Props, any> {
|
|||
return (
|
||||
<StyleSheetManager disableVendorPrefixes>
|
||||
<ThemeProvider theme={theme}>
|
||||
<StyleSheetContainer/>
|
||||
<MenuBar/>
|
||||
<GlobalStyle/>
|
||||
<WindowCommandsAndDialogs windowId={defaultWindowId} />
|
||||
<Navigator style={navigatorStyle} screens={screens} className={`profile-${this.props.profileConfigCurrentProfileId}`} />
|
||||
{this.renderSecondaryWindows()}
|
||||
{this.renderModalMessage(this.modalDialogProps())}
|
||||
<PopupNotificationProvider>
|
||||
<StyleSheetContainer/>
|
||||
<MenuBar/>
|
||||
<GlobalStyle/>
|
||||
<WindowCommandsAndDialogs windowId={defaultWindowId} />
|
||||
<Navigator style={navigatorStyle} screens={screens} className={`profile-${this.props.profileConfigCurrentProfileId}`} />
|
||||
{this.renderSecondaryWindows()}
|
||||
{this.renderModalMessage(this.modalDialogProps())}
|
||||
</PopupNotificationProvider>
|
||||
</ThemeProvider>
|
||||
</StyleSheetManager>
|
||||
);
|
||||
|
|
|
@ -1,15 +1,13 @@
|
|||
import { useContext, useCallback, useMemo, useRef } from 'react';
|
||||
import * as React from 'react';
|
||||
import { useContext, useEffect, useRef } from 'react';
|
||||
import { StateLastDeletion } from '@joplin/lib/reducer';
|
||||
import { _, _n } from '@joplin/lib/locale';
|
||||
import NotyfContext from '../NotyfContext';
|
||||
import { waitForElement } from '@joplin/lib/dom';
|
||||
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
|
||||
import { htmlentities } from '@joplin/utils/html';
|
||||
import restoreItems from '@joplin/lib/services/trash/restoreItems';
|
||||
import { ModelType } from '@joplin/lib/BaseModel';
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
import { Dispatch } from 'redux';
|
||||
import { NotyfNotification } from 'notyf';
|
||||
import { PopupNotificationContext } from '../PopupNotification/PopupNotificationProvider';
|
||||
import { NotificationType } from '../PopupNotification/types';
|
||||
import TrashNotificationMessage from './TrashNotificationMessage';
|
||||
|
||||
interface Props {
|
||||
lastDeletion: StateLastDeletion;
|
||||
|
@ -18,50 +16,29 @@ interface Props {
|
|||
dispatch: Dispatch;
|
||||
}
|
||||
|
||||
const onCancelClick = async (lastDeletion: StateLastDeletion) => {
|
||||
if (lastDeletion.folderIds.length) {
|
||||
await restoreItems(ModelType.Folder, lastDeletion.folderIds);
|
||||
}
|
||||
|
||||
if (lastDeletion.noteIds.length) {
|
||||
await restoreItems(ModelType.Note, lastDeletion.noteIds);
|
||||
}
|
||||
};
|
||||
|
||||
export default (props: Props) => {
|
||||
const notyfContext = useContext(NotyfContext);
|
||||
const notificationRef = useRef<NotyfNotification | null>(null);
|
||||
const popupManager = useContext(PopupNotificationContext);
|
||||
|
||||
const theme = useMemo(() => {
|
||||
return themeStyle(props.themeId);
|
||||
}, [props.themeId]);
|
||||
const lastDeletionNotificationTimeRef = useRef<number>();
|
||||
lastDeletionNotificationTimeRef.current = props.lastDeletionNotificationTime;
|
||||
|
||||
const notyf = useMemo(() => {
|
||||
const output = notyfContext;
|
||||
output.options.types = notyfContext.options.types.map(type => {
|
||||
if (type.type === 'success') {
|
||||
type.background = theme.backgroundColor5;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
(type.icon as any).color = theme.backgroundColor5;
|
||||
}
|
||||
return type;
|
||||
});
|
||||
return output;
|
||||
}, [notyfContext, theme]);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const onCancelClick = useCallback(async (event: any) => {
|
||||
notyf.dismiss(notificationRef.current);
|
||||
notificationRef.current = null;
|
||||
|
||||
const lastDeletion: StateLastDeletion = JSON.parse(event.currentTarget.getAttribute('data-lastDeletion'));
|
||||
|
||||
if (lastDeletion.folderIds.length) {
|
||||
await restoreItems(ModelType.Folder, lastDeletion.folderIds);
|
||||
}
|
||||
|
||||
if (lastDeletion.noteIds.length) {
|
||||
await restoreItems(ModelType.Note, lastDeletion.noteIds);
|
||||
}
|
||||
}, [notyf]);
|
||||
|
||||
useAsyncEffect(async (event) => {
|
||||
if (!props.lastDeletion || props.lastDeletion.timestamp <= props.lastDeletionNotificationTime) return;
|
||||
useEffect(() => {
|
||||
const lastDeletionNotificationTime = lastDeletionNotificationTimeRef.current;
|
||||
if (!props.lastDeletion || props.lastDeletion.timestamp <= lastDeletionNotificationTime) return;
|
||||
|
||||
props.dispatch({ type: 'DELETION_NOTIFICATION_DONE' });
|
||||
|
||||
let msg = '';
|
||||
|
||||
if (props.lastDeletion.folderIds.length) {
|
||||
msg = _('The notebook and its content was successfully moved to the trash.');
|
||||
} else if (props.lastDeletion.noteIds.length) {
|
||||
|
@ -70,16 +47,15 @@ export default (props: Props) => {
|
|||
return;
|
||||
}
|
||||
|
||||
const linkId = `deletion-notification-cancel-${Math.floor(Math.random() * 1000000)}`;
|
||||
const cancelLabel = _('Cancel');
|
||||
|
||||
const notification = notyf.success(`${msg} <a href="#" class="cancel" data-lastDeletion="${htmlentities(JSON.stringify(props.lastDeletion))}" id="${linkId}">${cancelLabel}</a>`);
|
||||
notificationRef.current = notification;
|
||||
|
||||
const element: HTMLAnchorElement = await waitForElement(document, linkId);
|
||||
if (event.cancelled) return;
|
||||
element.addEventListener('click', onCancelClick);
|
||||
}, [props.lastDeletion, notyf, props.dispatch]);
|
||||
const handleCancelClick = () => {
|
||||
notification.remove();
|
||||
void onCancelClick(props.lastDeletion);
|
||||
};
|
||||
const notification = popupManager.createPopup(() => (
|
||||
<TrashNotificationMessage message={msg} onCancel={handleCancelClick}/>
|
||||
), { type: NotificationType.Success });
|
||||
notification.scheduleDismiss();
|
||||
}, [props.lastDeletion, props.dispatch, popupManager]);
|
||||
|
||||
return <div style={{ display: 'none' }}/>;
|
||||
};
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
import * as React from 'react';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
message: string;
|
||||
onCancel: ()=> void;
|
||||
}
|
||||
|
||||
const TrashNotificationMessage: React.FC<Props> = props => {
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const onCancel = useCallback(() => {
|
||||
setCancelling(true);
|
||||
props.onCancel();
|
||||
}, [props.onCancel]);
|
||||
|
||||
return <>
|
||||
{props.message}
|
||||
{' '}
|
||||
<button
|
||||
className="link-button"
|
||||
onClick={onCancel}
|
||||
>{cancelling ? _('Cancelling...') : _('Cancel')}</button>
|
||||
</>;
|
||||
};
|
||||
|
||||
export default TrashNotificationMessage;
|
|
@ -1,27 +0,0 @@
|
|||
body .notyf {
|
||||
color: var(--joplin-color5);
|
||||
}
|
||||
|
||||
.notyf__toast {
|
||||
|
||||
> .notyf__wrapper {
|
||||
|
||||
> .notyf__message {
|
||||
|
||||
> .cancel {
|
||||
color: var(--joplin-color5);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
> .notyf__icon {
|
||||
|
||||
> .notyf__icon--success {
|
||||
background-color: var(--joplin-color5);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
|
@ -1,17 +1,15 @@
|
|||
import * as React from 'react';
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef } from 'react';
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
import NotyfContext from '../NotyfContext';
|
||||
import { useCallback, useContext, useEffect } from 'react';
|
||||
import { UpdateInfo } from 'electron-updater';
|
||||
import { ipcRenderer, IpcRendererEvent } from 'electron';
|
||||
import { AutoUpdaterEvents } from '../../services/autoUpdater/AutoUpdaterService';
|
||||
import { NotyfEvent, NotyfNotification } from 'notyf';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { htmlentities } from '@joplin/utils/html';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { PopupNotificationContext } from '../PopupNotification/PopupNotificationProvider';
|
||||
import Button, { ButtonLevel } from '../Button/Button';
|
||||
import { NotificationType } from '../PopupNotification/types';
|
||||
|
||||
interface UpdateNotificationProps {
|
||||
themeId: number;
|
||||
interface Props {
|
||||
}
|
||||
|
||||
export enum UpdateNotificationEvents {
|
||||
|
@ -22,111 +20,61 @@ export enum UpdateNotificationEvents {
|
|||
|
||||
const changelogLink = 'https://github.com/laurent22/joplin/releases';
|
||||
|
||||
window.openChangelogLink = () => {
|
||||
const openChangelogLink = () => {
|
||||
shim.openUrl(changelogLink);
|
||||
};
|
||||
|
||||
const UpdateNotification = ({ themeId }: UpdateNotificationProps) => {
|
||||
const notyfContext = useContext(NotyfContext);
|
||||
const notificationRef = useRef<NotyfNotification | null>(null); // Use ref to hold the current notification
|
||||
|
||||
const theme = useMemo(() => themeStyle(themeId), [themeId]);
|
||||
|
||||
const notyf = useMemo(() => {
|
||||
const output = notyfContext;
|
||||
output.options.types = notyfContext.options.types.map(type => {
|
||||
if (type.type === 'success') {
|
||||
type.background = theme.backgroundColor5;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
(type.icon as any).color = theme.backgroundColor5;
|
||||
}
|
||||
return type;
|
||||
});
|
||||
return output;
|
||||
}, [notyfContext, theme]);
|
||||
|
||||
const handleDismissNotification = useCallback(() => {
|
||||
notyf.dismiss(notificationRef.current);
|
||||
notificationRef.current = null;
|
||||
}, [notyf]);
|
||||
|
||||
const handleApplyUpdate = useCallback(() => {
|
||||
ipcRenderer.send('apply-update-now');
|
||||
handleDismissNotification();
|
||||
}, [handleDismissNotification]);
|
||||
const handleApplyUpdate = () => {
|
||||
ipcRenderer.send('apply-update-now');
|
||||
};
|
||||
|
||||
const UpdateNotification: React.FC<Props> = () => {
|
||||
const popupManager = useContext(PopupNotificationContext);
|
||||
|
||||
const handleUpdateDownloaded = useCallback((_event: IpcRendererEvent, info: UpdateInfo) => {
|
||||
if (notificationRef.current) return;
|
||||
|
||||
const updateAvailableHtml = htmlentities(_('A new update (%s) is available', info.version));
|
||||
const seeChangelogHtml = htmlentities(_('See changelog'));
|
||||
const restartNowHtml = htmlentities(_('Restart now'));
|
||||
const updateLaterHtml = htmlentities(_('Update later'));
|
||||
|
||||
const messageHtml = `
|
||||
<div class="update-notification" style="color: ${theme.color2};">
|
||||
${updateAvailableHtml} <a href="#" onclick="openChangelogLink()" style="color: ${theme.color2};">${seeChangelogHtml}</a>
|
||||
<div style="display: flex; gap: 10px; margin-top: 8px;">
|
||||
<button onclick="document.dispatchEvent(new CustomEvent('${UpdateNotificationEvents.ApplyUpdate}'))" class="notyf__button notyf__button--confirm" style="color: ${theme.color2};">${restartNowHtml}</button>
|
||||
<button onclick="document.dispatchEvent(new CustomEvent('${UpdateNotificationEvents.Dismiss}'))" class="notyf__button notyf__button--dismiss" style="color: ${theme.color2};">${updateLaterHtml}</button>
|
||||
const notification = popupManager.createPopup(() => (
|
||||
<div className='update-notification'>
|
||||
{_('A new update (%s) is available', info.version)}
|
||||
<button className='link-button' onClick={openChangelogLink}>{
|
||||
_('See changelog')
|
||||
}</button>
|
||||
<div className='buttons'>
|
||||
<Button
|
||||
level={ButtonLevel.Tertiary}
|
||||
onClick={() => {
|
||||
notification.remove();
|
||||
handleApplyUpdate();
|
||||
}}
|
||||
title={_('Restart now')}
|
||||
/>
|
||||
<Button
|
||||
level={ButtonLevel.Tertiary}
|
||||
onClick={() => notification.remove()}
|
||||
title={_('Update later')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const notification: NotyfNotification = notyf.open({
|
||||
type: 'success',
|
||||
message: messageHtml,
|
||||
position: {
|
||||
x: 'right',
|
||||
y: 'bottom',
|
||||
},
|
||||
duration: 0,
|
||||
});
|
||||
|
||||
notificationRef.current = notification;
|
||||
}, [notyf, theme]);
|
||||
));
|
||||
}, [popupManager]);
|
||||
|
||||
const handleUpdateNotAvailable = useCallback(() => {
|
||||
if (notificationRef.current) return;
|
||||
|
||||
const noUpdateMessageHtml = htmlentities(_('No updates available'));
|
||||
|
||||
const messageHtml = `
|
||||
<div class="update-notification" style="color: ${theme.color2};">
|
||||
${noUpdateMessageHtml}
|
||||
const notification = popupManager.createPopup(() => (
|
||||
<div className='update-notification'>
|
||||
{_('No updates available')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
const notification: NotyfNotification = notyf.open({
|
||||
type: 'success',
|
||||
message: messageHtml,
|
||||
position: {
|
||||
x: 'right',
|
||||
y: 'bottom',
|
||||
},
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
notification.on(NotyfEvent.Dismiss, () => {
|
||||
notificationRef.current = null;
|
||||
});
|
||||
|
||||
notificationRef.current = notification;
|
||||
}, [notyf, theme]);
|
||||
), { type: NotificationType.Info });
|
||||
notification.scheduleDismiss();
|
||||
}, [popupManager]);
|
||||
|
||||
useEffect(() => {
|
||||
ipcRenderer.on(AutoUpdaterEvents.UpdateDownloaded, handleUpdateDownloaded);
|
||||
ipcRenderer.on(AutoUpdaterEvents.UpdateNotAvailable, handleUpdateNotAvailable);
|
||||
document.addEventListener(UpdateNotificationEvents.ApplyUpdate, handleApplyUpdate);
|
||||
document.addEventListener(UpdateNotificationEvents.Dismiss, handleDismissNotification);
|
||||
|
||||
return () => {
|
||||
ipcRenderer.removeListener(AutoUpdaterEvents.UpdateDownloaded, handleUpdateDownloaded);
|
||||
ipcRenderer.removeListener(AutoUpdaterEvents.UpdateNotAvailable, handleUpdateNotAvailable);
|
||||
document.removeEventListener(UpdateNotificationEvents.ApplyUpdate, handleApplyUpdate);
|
||||
};
|
||||
}, [handleApplyUpdate, handleDismissNotification, handleUpdateDownloaded, handleUpdateNotAvailable]);
|
||||
}, [handleUpdateDownloaded, handleUpdateNotAvailable]);
|
||||
|
||||
|
||||
return (
|
||||
|
|
|
@ -1,27 +1,11 @@
|
|||
.update-notification {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
.button-container {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.notyf__button {
|
||||
padding: 5px 10px;
|
||||
border: 1px solid;
|
||||
border-radius: 4px;
|
||||
background-color: transparent;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
> .buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
|
@ -18,6 +18,7 @@ import useWindowCommands from './utils/useWindowCommands';
|
|||
import PluginDialogs from './PluginDialogs';
|
||||
import useSyncDialogState from './utils/useSyncDialogState';
|
||||
import AppDialogs from './AppDialogs';
|
||||
import PopupNotificationList from '../PopupNotification/PopupNotificationList';
|
||||
|
||||
const PluginManager = require('@joplin/lib/services/PluginManager');
|
||||
|
||||
|
@ -113,7 +114,9 @@ const WindowCommandsAndDialogs: React.FC<Props> = props => {
|
|||
const dialogInfo = PluginManager.instance().pluginDialogToShow(props.pluginsLegacy);
|
||||
const pluginDialog = !dialogInfo ? null : <dialogInfo.Dialog {...dialogInfo.props} />;
|
||||
|
||||
const { noteContentPropertiesDialogOptions, notePropertiesDialogOptions, shareNoteDialogOptions, shareFolderDialogOptions, promptOptions } = dialogState;
|
||||
const {
|
||||
noteContentPropertiesDialogOptions, notePropertiesDialogOptions, shareNoteDialogOptions, shareFolderDialogOptions, promptOptions,
|
||||
} = dialogState;
|
||||
|
||||
|
||||
return <>
|
||||
|
@ -173,6 +176,8 @@ const WindowCommandsAndDialogs: React.FC<Props> = props => {
|
|||
buttons={promptOptions && 'buttons' in promptOptions ? promptOptions.buttons : null}
|
||||
inputType={promptOptions && 'inputType' in promptOptions ? promptOptions.inputType : null}
|
||||
/>
|
||||
|
||||
<PopupNotificationList/>
|
||||
</>;
|
||||
};
|
||||
|
||||
|
|
|
@ -48,7 +48,8 @@ export default function() {
|
|||
'toggleTabMovesFocus',
|
||||
'editor.deleteLine',
|
||||
'editor.duplicateLine',
|
||||
'newAppInstance',
|
||||
'openSecondaryAppInstance',
|
||||
'openPrimaryAppInstance',
|
||||
// We cannot put the undo/redo commands in the menu because they are
|
||||
// editor-specific commands. If we put them there it will break the
|
||||
// undo/redo in regular text fields.
|
||||
|
|
|
@ -139,11 +139,8 @@
|
|||
const viewerPercent = scrollmap.translateL2V(percent);
|
||||
const newScrollTop = viewerPercent * maxScrollTop();
|
||||
|
||||
// Even if the scroll position hasn't changed (percent is the same),
|
||||
// we still ignore the next scroll event, so that it doesn't create
|
||||
// undesired side effects.
|
||||
// https://github.com/laurent22/joplin/issues/7617
|
||||
ignoreNextScrollEvent();
|
||||
// The next scroll event cannot be skipped in order to correctly
|
||||
// scroll to the target section in a different note when follwing a link
|
||||
|
||||
if (Math.floor(contentElement.scrollTop) !== Math.floor(newScrollTop)) {
|
||||
percentScroll_ = percent;
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
@use './user-webview-dialog.scss';
|
||||
@use './prompt-dialog.scss';
|
||||
@use './flat-button.scss';
|
||||
@use './link-button.scss';
|
||||
@use './help-text.scss';
|
||||
@use './toolbar-button.scss';
|
||||
@use './toolbar-icon.scss';
|
||||
|
@ -14,3 +15,5 @@
|
|||
@use './combobox-wrapper.scss';
|
||||
@use './combobox-suggestion-option.scss';
|
||||
@use './change-app-layout-dialog.scss';
|
||||
@use './popup-notification-list.scss';
|
||||
@use './popup-notification-item.scss';
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
|
||||
.link-button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
|
@ -0,0 +1,126 @@
|
|||
@keyframes slide-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(25%);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(25%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes grow {
|
||||
from {
|
||||
transform: scale(0);
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.popup-notification-item {
|
||||
margin: 12px;
|
||||
padding: 13px 15px;
|
||||
border-radius: 4px;
|
||||
|
||||
overflow: clip;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
box-shadow: 0 3px 7px 0px rgba(0, 0, 0, 0.25);
|
||||
|
||||
--text-color: var(--joplin-color5);
|
||||
--ripple-color: var(--joplin-background-color5);
|
||||
background-color: color-mix(in srgb, var(--ripple-color) 20%, transparent 70%);
|
||||
color: var(--text-color);
|
||||
|
||||
animation: slide-in 0.3s ease-in both;
|
||||
|
||||
> .icon {
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
// Make the line hight slightly larger than the icon size
|
||||
// to vertically center the text
|
||||
line-height: 26px;
|
||||
|
||||
margin-inline-end: 13px;
|
||||
border-radius: 50%;
|
||||
|
||||
color: var(--ripple-color);
|
||||
background-color: var(--text-color);
|
||||
}
|
||||
|
||||
> .content {
|
||||
padding: 10px 0;
|
||||
max-width: min(280px, 70vw);
|
||||
font-size: 1.1em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
> .ripple {
|
||||
--ripple-size: 500px;
|
||||
|
||||
position: absolute;
|
||||
transform-origin: bottom right;
|
||||
top: calc(var(--ripple-size) / -2);
|
||||
right: -40px;
|
||||
z-index: -1;
|
||||
|
||||
background-color: var(--ripple-color);
|
||||
width: var(--ripple-size);
|
||||
height: var(--ripple-size);
|
||||
border-radius: calc(var(--ripple-size) / 2);
|
||||
|
||||
transform: scale(0);
|
||||
animation: grow 0.4s ease-out forwards;
|
||||
}
|
||||
|
||||
&.-dismissing {
|
||||
// Animate the icon and content first
|
||||
animation: slide-out 0.25s ease-out both;
|
||||
animation-delay: 0.25s;
|
||||
|
||||
& > .content, & > .icon {
|
||||
animation: slide-out 0.3s ease-out both;
|
||||
}
|
||||
}
|
||||
|
||||
&.-success {
|
||||
--ripple-color: var(--joplin-color-correct);
|
||||
}
|
||||
|
||||
&.-error {
|
||||
--ripple-color: var(--joplin-color-error);
|
||||
}
|
||||
|
||||
&.-info {
|
||||
--text-color: var(--joplin-color5);
|
||||
--ripple-color: var(--joplin-background-color5);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
&, & > .content, & > .icon {
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
> .ripple {
|
||||
transform: scale(1);
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
|
||||
.popup-notification-list {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
flex-direction: column;
|
||||
list-style-type: none;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
|
||||
&.-overlay {
|
||||
// Focus should jump to the bottom item first
|
||||
flex-direction: column-reverse;
|
||||
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
inset-inline-end: 0; // right: 0 in ltr, left: 0 in rtl
|
||||
z-index: 10;
|
||||
|
||||
max-height: 100vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
|
@ -10,7 +10,6 @@
|
|||
<title>Joplin</title>
|
||||
<!-- Note: Add new dynamic CSS imports to style.scss to allow them to be included in secondary windows. -->
|
||||
<link rel="stylesheet" href="style.min.css">
|
||||
<link rel="stylesheet" href="./node_modules/notyf/notyf.min.css">
|
||||
|
||||
<script src="vendor/lib/smalltalk/dist/smalltalk.min.js"></script>
|
||||
<script src="./node_modules/tesseract.js/dist/tesseract.min.js"></script>
|
||||
|
@ -19,6 +18,5 @@
|
|||
<div id="react-root"></div>
|
||||
<script src="./utils/window/eventHandlerOverrides.js"></script>
|
||||
<script src="main-html.js"></script>
|
||||
<script src="./node_modules/notyf/notyf.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -84,7 +84,11 @@ export default class NoteEditorPage {
|
|||
// We use frameLocator(':scope') to convert the richTextEditor Locator into
|
||||
// a FrameLocator. (:scope selects the locator itself).
|
||||
// https://playwright.dev/docs/api/class-framelocator
|
||||
return this.richTextEditor.frameLocator(':scope');
|
||||
return this.richTextEditor.contentFrame();
|
||||
}
|
||||
|
||||
public getRichTextEditorBody() {
|
||||
return this.richTextEditor.contentFrame().locator('body');
|
||||
}
|
||||
|
||||
public focusCodeMirrorEditor() {
|
||||
|
|
|
@ -75,6 +75,32 @@ test.describe('noteList', () => {
|
|||
await expect(noteList.getNoteItemByTitle('test note 1')).toBeVisible();
|
||||
});
|
||||
|
||||
test('deleting a note to the trash should show a notification', async ({ electronApp, mainWindow }) => {
|
||||
const mainScreen = await new MainScreen(mainWindow).setup();
|
||||
await mainScreen.createNewNote('test note 1');
|
||||
|
||||
const noteList = mainScreen.noteList;
|
||||
await noteList.focusContent(electronApp);
|
||||
const testNoteItem = noteList.getNoteItemByTitle('test note 1');
|
||||
await expect(testNoteItem).toBeVisible();
|
||||
|
||||
// Should be removed after deleting
|
||||
await testNoteItem.press('Delete');
|
||||
await expect(testNoteItem).not.toBeVisible();
|
||||
|
||||
// Should show a deleted notification
|
||||
const notification = mainWindow.locator('[role=alert]', {
|
||||
hasText: /The note was successfully moved to the trash./i,
|
||||
});
|
||||
await expect(notification).toBeVisible();
|
||||
|
||||
// Should be possible to un-delete
|
||||
const undeleteButton = notification.getByRole('button', { name: 'Cancel' });
|
||||
await undeleteButton.click();
|
||||
|
||||
await expect(testNoteItem).toBeVisible();
|
||||
});
|
||||
|
||||
test('arrow keys should navigate the note list', async ({ electronApp, mainWindow }) => {
|
||||
const mainScreen = await new MainScreen(mainWindow).setup();
|
||||
const sidebar = mainScreen.sidebar;
|
||||
|
|
|
@ -24,6 +24,27 @@ test.describe('pluginApi', () => {
|
|||
await editor.expectToHaveText('PASS');
|
||||
});
|
||||
|
||||
test('should return form data from the dialog API', async ({ startAppWithPlugins }) => {
|
||||
const { app, mainWindow } = await startAppWithPlugins(['resources/test-plugins/dialogs.js']);
|
||||
const mainScreen = await new MainScreen(mainWindow).setup();
|
||||
await mainScreen.createNewNote('First note');
|
||||
|
||||
const editor = mainScreen.noteEditor;
|
||||
await editor.expectToHaveText('');
|
||||
|
||||
await mainScreen.goToAnything.runCommand(app, 'showTestDialog');
|
||||
// Wait for the iframe to load
|
||||
const dialogContent = mainScreen.dialog.locator('iframe').contentFrame();
|
||||
await dialogContent.locator('form').waitFor();
|
||||
|
||||
// Submitting the dialog should include form data in the output
|
||||
await mainScreen.dialog.getByRole('button', { name: 'Okay' }).click();
|
||||
await editor.expectToHaveText(JSON.stringify({
|
||||
id: 'ok',
|
||||
hasFormData: true,
|
||||
}));
|
||||
});
|
||||
|
||||
test('should be possible to create multiple toasts with the same text from a plugin', async ({ startAppWithPlugins }) => {
|
||||
const { app, mainWindow } = await startAppWithPlugins(['resources/test-plugins/showToast.js']);
|
||||
const mainScreen = await new MainScreen(mainWindow).setup();
|
||||
|
|
|
@ -0,0 +1,51 @@
|
|||
// Allows referencing the Joplin global:
|
||||
/* eslint-disable no-undef */
|
||||
|
||||
// Allows the `joplin-manifest` block comment:
|
||||
/* eslint-disable multiline-comment-style */
|
||||
|
||||
/* joplin-manifest:
|
||||
{
|
||||
"id": "org.joplinapp.plugins.example.dialogs",
|
||||
"manifest_version": 1,
|
||||
"app_min_version": "3.1",
|
||||
"name": "JS Bundle test",
|
||||
"description": "JS Bundle Test plugin",
|
||||
"version": "1.0.0",
|
||||
"author": "",
|
||||
"homepage_url": "https://joplinapp.org"
|
||||
}
|
||||
*/
|
||||
|
||||
joplin.plugins.register({
|
||||
onStart: async function() {
|
||||
const dialogs = joplin.views.dialogs;
|
||||
const dialogHandle = await dialogs.create('test-dialog');
|
||||
await dialogs.setHtml(
|
||||
dialogHandle,
|
||||
`
|
||||
<form name="main-form">
|
||||
<label>Test: <input type="checkbox" name="test" checked/></label>
|
||||
</form>
|
||||
`,
|
||||
);
|
||||
await dialogs.setButtons(dialogHandle, [
|
||||
{
|
||||
id: 'ok',
|
||||
title: 'Okay',
|
||||
},
|
||||
]);
|
||||
await joplin.commands.register({
|
||||
name: 'showTestDialog',
|
||||
label: 'showTestDialog',
|
||||
iconName: 'fas fa-drum',
|
||||
execute: async () => {
|
||||
const result = await joplin.views.dialogs.open(dialogHandle);
|
||||
await joplin.commands.execute('editor.setText', JSON.stringify({
|
||||
id: result.id,
|
||||
hasFormData: !!result.formData,
|
||||
}));
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
|
@ -62,6 +62,9 @@ test.describe('richTextEditor', () => {
|
|||
await setFilePickerResponse(electronApp, [pathToAttach]);
|
||||
await editor.attachFileButton.click();
|
||||
|
||||
// Wait for it to render
|
||||
await expect(editor.getNoteViewerFrameLocator().getByText('test-file.txt')).toBeVisible();
|
||||
|
||||
// Switch to the RTE
|
||||
await editor.toggleEditorsButton.click();
|
||||
await editor.richTextEditor.waitFor();
|
||||
|
@ -82,6 +85,44 @@ test.describe('richTextEditor', () => {
|
|||
expect(await openPathResult).toContain(basename(pathToAttach));
|
||||
});
|
||||
|
||||
test('should not remove text when pressing [enter] at the end of a line with an image', async ({ mainWindow }) => {
|
||||
const mainScreen = await new MainScreen(mainWindow).setup();
|
||||
await mainScreen.createNewNote('Testing pressing enter!');
|
||||
const editor = mainScreen.noteEditor;
|
||||
|
||||
// Set the initial content
|
||||
await editor.codeMirrorEditor.click();
|
||||
await mainWindow.keyboard.type([
|
||||
'<img',
|
||||
' src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAMAAAADCAYAAABWKLW/AAAAEklEQVQIW2P8z8AARBDAiJMDAIzoBf635fcVAAAAAElFTkSuQmCC"',
|
||||
' width="200"',
|
||||
' height="200"',
|
||||
' alt="test image"',
|
||||
'/>',
|
||||
].join(' '));
|
||||
await mainWindow.keyboard.press('Enter');
|
||||
await mainWindow.keyboard.press('Enter');
|
||||
await mainWindow.keyboard.type('Test secondary paragraph.');
|
||||
|
||||
// Switch to the RTE
|
||||
await editor.toggleEditorsButton.click();
|
||||
await editor.richTextEditor.waitFor();
|
||||
|
||||
const richTextEditorFrame = editor.getRichTextFrameLocator();
|
||||
const testParagraph = richTextEditorFrame.getByText('Test secondary paragraph.');
|
||||
await expect(testParagraph).toBeAttached();
|
||||
|
||||
// Move the cursor just after the image, then press enter.
|
||||
const testImage = richTextEditorFrame.getByRole('img', { name: 'test image' });
|
||||
await testImage.click();
|
||||
await mainWindow.keyboard.press('ArrowRight');
|
||||
await mainWindow.keyboard.press('Enter');
|
||||
|
||||
// Should not have removed the image or the test paragraph.
|
||||
await expect(testImage).toBeAttached();
|
||||
await expect(testParagraph).toBeAttached();
|
||||
});
|
||||
|
||||
test('pressing Tab should indent', async ({ mainWindow }) => {
|
||||
const mainScreen = await new MainScreen(mainWindow).setup();
|
||||
await mainScreen.createNewNote('Testing tabs!');
|
||||
|
@ -212,5 +253,33 @@ test.describe('richTextEditor', () => {
|
|||
await expect(editor.noteTitleInput).not.toBeFocused();
|
||||
await expect(editor.richTextEditor).toBeFocused();
|
||||
});
|
||||
|
||||
test('note should have correct content even if opened quickly after last edit', async ({ mainWindow }) => {
|
||||
const mainScreen = await new MainScreen(mainWindow).setup();
|
||||
await mainScreen.createNewNote('Test 1');
|
||||
await mainScreen.createNewNote('Test 2');
|
||||
const test1Header = mainScreen.noteList.getNoteItemByTitle('Test 1');
|
||||
const test2Header = mainScreen.noteList.getNoteItemByTitle('Test 2');
|
||||
|
||||
const editor = mainScreen.noteEditor;
|
||||
await editor.toggleEditorsButton.click();
|
||||
await editor.richTextEditor.waitFor();
|
||||
|
||||
const editorBody = editor.getRichTextEditorBody();
|
||||
const setEditorText = async (targetText: string) => {
|
||||
await editorBody.pressSequentially(targetText);
|
||||
await expect(editorBody).toHaveText(targetText);
|
||||
};
|
||||
|
||||
await test1Header.click();
|
||||
await expect(editorBody).toHaveText('');
|
||||
await setEditorText('Test 1');
|
||||
|
||||
await test2Header.click();
|
||||
// Previously, after switching to note 2, the "Test 1" text would remain present in the
|
||||
// editor.
|
||||
await expect(editorBody).toHaveText('');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
|
|
@ -345,41 +345,3 @@ mark {
|
|||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Notyf style
|
||||
// ----------------------------------------------------------
|
||||
|
||||
.notyf__toast--info {
|
||||
color: var(--joplin-color5) !important;
|
||||
}
|
||||
|
||||
.notyf__toast--info .notyf__ripple {
|
||||
background-color: var(--joplin-background-color5) !important;
|
||||
}
|
||||
|
||||
.notyf__toast--success {
|
||||
color: var(--joplin-color5) !important;
|
||||
}
|
||||
|
||||
.notyf__toast--success .notyf__ripple {
|
||||
background-color: var(--joplin-color-correct) !important;
|
||||
}
|
||||
|
||||
.notyf__icon--success {
|
||||
color: var(--joplin-color) !important;
|
||||
background-color: var(--joplin-color5) !important;
|
||||
}
|
||||
|
||||
.notyf__toast--error {
|
||||
color: var(--joplin-color2) !important;
|
||||
}
|
||||
|
||||
.notyf__toast--error .notyf__ripple {
|
||||
background-color: var(--joplin-color-error) !important;
|
||||
}
|
||||
|
||||
.notyf__icon--error {
|
||||
color: var(--joplin-color) !important;
|
||||
background-color: var(--joplin-color5) !important;
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@joplin/app-desktop",
|
||||
"version": "3.3.3",
|
||||
"version": "3.3.4",
|
||||
"description": "Joplin for Desktop",
|
||||
"main": "main.js",
|
||||
"private": true,
|
||||
|
@ -144,7 +144,7 @@
|
|||
"@types/styled-components": "5.1.32",
|
||||
"@types/tesseract.js": "2.0.0",
|
||||
"axios": "^1.7.7",
|
||||
"electron": "35.0.1",
|
||||
"electron": "35.1.4",
|
||||
"electron-builder": "24.13.3",
|
||||
"glob": "10.4.5",
|
||||
"gulp": "4.0.2",
|
||||
|
@ -188,7 +188,6 @@
|
|||
"node-fetch": "2.6.7",
|
||||
"node-notifier": "10.0.1",
|
||||
"node-rsa": "1.1.1",
|
||||
"notyf": "3.10.0",
|
||||
"pdfjs-dist": "3.11.174",
|
||||
"pretty-bytes": "5.6.0",
|
||||
"re-resizable": "6.9.17",
|
||||
|
|
|
@ -1,15 +1,16 @@
|
|||
import * as React from 'react';
|
||||
import { useRef, useImperativeHandle, forwardRef, useEffect, useMemo, useContext } from 'react';
|
||||
import { useRef, useImperativeHandle, forwardRef, useEffect, useMemo, useContext, useCallback } from 'react';
|
||||
import useViewIsReady from './hooks/useViewIsReady';
|
||||
import useThemeCss from './hooks/useThemeCss';
|
||||
import useContentSize from './hooks/useContentSize';
|
||||
import useSubmitHandler from './hooks/useSubmitHandler';
|
||||
import useHtmlLoader from './hooks/useHtmlLoader';
|
||||
import useWebviewToPluginMessages from './hooks/useWebviewToPluginMessages';
|
||||
import useScriptLoader from './hooks/useScriptLoader';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { focus } from '@joplin/lib/utils/focusHandler';
|
||||
import { WindowIdContext } from '../../gui/NewWindowOrIFrame';
|
||||
import useSubmitHandler from './hooks/useSubmitHandler';
|
||||
import useFormData from './hooks/useFormData';
|
||||
|
||||
const logger = Logger.create('UserWebview');
|
||||
|
||||
|
@ -33,38 +34,12 @@ export interface Props {
|
|||
onReady?: Function;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
function serializeForm(form: any) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const output: any = {};
|
||||
const formData = new FormData(form);
|
||||
for (const key of formData.keys()) {
|
||||
output[key] = formData.get(key);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
function serializeForms(document: any) {
|
||||
const forms = document.getElementsByTagName('form');
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const output: any = {};
|
||||
let untitledIndex = 0;
|
||||
|
||||
for (const form of forms) {
|
||||
const name = `${form.getAttribute('name')}` || (`form${untitledIndex++}`);
|
||||
output[name] = serializeForm(form);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
function UserWebview(props: Props, ref: any) {
|
||||
const minWidth = props.minWidth ? props.minWidth : 200;
|
||||
const minHeight = props.minHeight ? props.minHeight : 20;
|
||||
|
||||
const viewRef = useRef(null);
|
||||
const viewRef = useRef<HTMLIFrameElement>(null);
|
||||
const isReady = useViewIsReady(viewRef);
|
||||
const cssFilePath = useThemeCss({ pluginId: props.pluginId, themeId: props.themeId });
|
||||
|
||||
|
@ -78,21 +53,22 @@ function UserWebview(props: Props, ref: any) {
|
|||
return viewRef.current.contentWindow;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
function postMessage(name: string, args: any = null) {
|
||||
const postMessage = useCallback((name: string, args: unknown = null) => {
|
||||
const win = frameWindow();
|
||||
if (!win) return;
|
||||
|
||||
logger.debug('Got message', name, args);
|
||||
|
||||
win.postMessage({ target: 'webview', name, args }, '*');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const { getFormData } = useFormData(viewRef, postMessage);
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
return {
|
||||
formData: function() {
|
||||
if (viewRef.current) {
|
||||
return serializeForms(frameWindow().document);
|
||||
return getFormData();
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
@ -101,34 +77,31 @@ function UserWebview(props: Props, ref: any) {
|
|||
if (viewRef.current) focus('UserWebView::focus', viewRef.current);
|
||||
},
|
||||
};
|
||||
});
|
||||
}, [getFormData]);
|
||||
|
||||
const htmlHash = useHtmlLoader(
|
||||
frameWindow(),
|
||||
viewRef,
|
||||
isReady,
|
||||
postMessage,
|
||||
props.html,
|
||||
);
|
||||
|
||||
const contentSize = useContentSize(
|
||||
frameWindow(),
|
||||
viewRef,
|
||||
htmlHash,
|
||||
minWidth,
|
||||
minHeight,
|
||||
props.fitToContent,
|
||||
isReady,
|
||||
);
|
||||
|
||||
useSubmitHandler(
|
||||
frameWindow(),
|
||||
viewRef,
|
||||
props.onSubmit,
|
||||
props.onDismiss,
|
||||
htmlHash,
|
||||
);
|
||||
|
||||
const windowId = useContext(WindowIdContext);
|
||||
useWebviewToPluginMessages(
|
||||
frameWindow(),
|
||||
viewRef,
|
||||
isReady,
|
||||
props.pluginId,
|
||||
props.viewId,
|
||||
|
@ -153,7 +126,7 @@ function UserWebview(props: Props, ref: any) {
|
|||
style={style}
|
||||
className={`plugin-user-webview ${props.fitToContent ? '-fit-to-content' : ''} ${props.borderBottom ? '-border-bottom' : ''}`}
|
||||
ref={viewRef}
|
||||
src="services/plugins/UserWebviewIndex.html"
|
||||
src={`joplin-content://plugin-webview/${__dirname}/UserWebviewIndex.html`}
|
||||
></iframe>;
|
||||
}
|
||||
|
||||
|
|
|
@ -46,9 +46,9 @@ export default function UserWebviewDialog(props: Props) {
|
|||
const buttons: ButtonSpec[] = (props.buttons ? props.buttons : defaultButtons()).map((b: ButtonSpec) => {
|
||||
return {
|
||||
...b,
|
||||
onClick: () => {
|
||||
onClick: async () => {
|
||||
const response: DialogResult = { id: b.id };
|
||||
const formData = webviewRef.current.formData();
|
||||
const formData = await webviewRef.current.formData();
|
||||
if (formData && Object.keys(formData).length) response.formData = formData;
|
||||
viewController().closeWithResponse(response);
|
||||
},
|
||||
|
|
|
@ -1,6 +1,43 @@
|
|||
// This is the API that JS files loaded from the webview can see
|
||||
const webviewApiPromises_ = {};
|
||||
let viewMessageHandler_ = () => {};
|
||||
const postMessage = (message) => {
|
||||
parent.postMessage(message, '*');
|
||||
};
|
||||
|
||||
|
||||
function serializeForm(form) {
|
||||
const output = {};
|
||||
const formData = new FormData(form);
|
||||
for (const key of formData.keys()) {
|
||||
output[key] = formData.get(key);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function serializeForms(document) {
|
||||
const forms = document.getElementsByTagName('form');
|
||||
const output = {};
|
||||
let untitledIndex = 0;
|
||||
|
||||
for (const form of forms) {
|
||||
const name = `${form.getAttribute('name')}` || (`form${untitledIndex++}`);
|
||||
output[name] = serializeForm(form);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function watchElementSize(element, onChange) {
|
||||
const emitSizeChange = () => {
|
||||
onChange(element.getBoundingClientRect());
|
||||
};
|
||||
const observer = new ResizeObserver(emitSizeChange);
|
||||
observer.observe(element);
|
||||
|
||||
// Initial size
|
||||
requestAnimationFrame(emitSizeChange);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
|
||||
const webviewApi = {
|
||||
|
@ -11,7 +48,7 @@ const webviewApi = {
|
|||
webviewApiPromises_[messageId] = { resolve, reject };
|
||||
});
|
||||
|
||||
window.postMessage({
|
||||
postMessage({
|
||||
target: 'postMessageService.message',
|
||||
message: {
|
||||
from: 'userWebview',
|
||||
|
@ -26,7 +63,7 @@ const webviewApi = {
|
|||
|
||||
onMessage: function(viewMessageHandler) {
|
||||
viewMessageHandler_ = viewMessageHandler;
|
||||
window.postMessage({
|
||||
postMessage({
|
||||
target: 'postMessageService.registerViewMessageHandler',
|
||||
});
|
||||
},
|
||||
|
@ -90,14 +127,14 @@ const webviewApi = {
|
|||
window.requestAnimationFrame(() => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug('UserWebviewIndex: setting html callback', args.hash);
|
||||
window.postMessage({ target: 'UserWebview', message: 'htmlIsSet', hash: args.hash }, '*');
|
||||
postMessage({ target: 'UserWebview', message: 'htmlIsSet', hash: args.hash });
|
||||
});
|
||||
},
|
||||
|
||||
setScript: (args) => {
|
||||
const { script, key } = args;
|
||||
|
||||
const scriptPath = `file://${script}`;
|
||||
const scriptPath = `joplin-content://plugin-webview/${script}`;
|
||||
const elementId = `joplin-script-${key}`;
|
||||
|
||||
if (addedScripts[elementId]) {
|
||||
|
@ -114,7 +151,7 @@ const webviewApi = {
|
|||
if (!scripts) return;
|
||||
|
||||
for (let i = 0; i < scripts.length; i++) {
|
||||
const scriptPath = `file://${scripts[i]}`;
|
||||
const scriptPath = `joplin-content://plugin-webview/${scripts[i]}`;
|
||||
|
||||
if (addedScripts[scriptPath]) continue;
|
||||
addedScripts[scriptPath] = true;
|
||||
|
@ -123,6 +160,14 @@ const webviewApi = {
|
|||
}
|
||||
},
|
||||
|
||||
serializeForms: () => {
|
||||
postMessage({
|
||||
target: 'UserWebview',
|
||||
message: 'serializedForms',
|
||||
formData: serializeForms(document),
|
||||
});
|
||||
},
|
||||
|
||||
'postMessageService.response': (event) => {
|
||||
const message = event.message;
|
||||
const promise = webviewApiPromises_[message.responseId];
|
||||
|
@ -171,7 +216,33 @@ const webviewApi = {
|
|||
window.requestAnimationFrame(() => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug('UserWebViewIndex: calling isReady');
|
||||
window.postMessage({ target: 'UserWebview', message: 'ready' }, '*');
|
||||
postMessage({ target: 'UserWebview', message: 'ready' });
|
||||
});
|
||||
|
||||
|
||||
const sendFormSubmit = () => {
|
||||
postMessage({ target: 'UserWebview', message: 'form-submit' });
|
||||
};
|
||||
const sendDismiss = () => {
|
||||
postMessage({ target: 'UserWebview', message: 'dismiss' });
|
||||
};
|
||||
document.addEventListener('submit', () => {
|
||||
sendFormSubmit();
|
||||
});
|
||||
document.addEventListener('keydown', event => {
|
||||
if (event.key === 'Enter' && event.target.tagName === 'INPUT' && event.target.type === 'text') {
|
||||
sendFormSubmit();
|
||||
} else if (event.key === 'Escape') {
|
||||
sendDismiss();
|
||||
}
|
||||
});
|
||||
|
||||
watchElementSize(document.getElementById('joplin-plugin-content'), size => {
|
||||
postMessage({
|
||||
target: 'UserWebview',
|
||||
message: 'updateContentSize',
|
||||
size,
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { RefObject, useState } from 'react';
|
||||
import useMessageHandler from './useMessageHandler';
|
||||
|
||||
interface Size {
|
||||
width: number;
|
||||
|
@ -6,59 +7,29 @@ interface Size {
|
|||
hash: string;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
export default function(frameWindow: any, htmlHash: string, minWidth: number, minHeight: number, fitToContent: boolean, isReady: boolean) {
|
||||
export default function(viewRef: RefObject<HTMLIFrameElement>, htmlHash: string, minWidth: number, minHeight: number) {
|
||||
const [contentSize, setContentSize] = useState<Size>({
|
||||
width: minWidth,
|
||||
height: minHeight,
|
||||
hash: '',
|
||||
});
|
||||
|
||||
function updateContentSize(hash: string) {
|
||||
if (!frameWindow) return;
|
||||
|
||||
const rect = frameWindow.document.getElementById('joplin-plugin-content').getBoundingClientRect();
|
||||
useMessageHandler(viewRef, event => {
|
||||
if (event.data.message !== 'updateContentSize') return;
|
||||
const rect = event.data.size;
|
||||
|
||||
let w = rect.width;
|
||||
let h = rect.height;
|
||||
if (w < minWidth) w = minWidth;
|
||||
if (h < minHeight) h = minHeight;
|
||||
|
||||
const newSize = { width: w, height: h, hash: hash };
|
||||
const newSize = { width: w, height: h, hash: htmlHash };
|
||||
|
||||
setContentSize((current: Size) => {
|
||||
if (current.width === newSize.width && current.height === newSize.height && current.hash === hash) return current;
|
||||
if (current.width === newSize.width && current.height === newSize.height && current.hash === htmlHash) return current;
|
||||
return newSize;
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
updateContentSize(htmlHash);
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [htmlHash]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fitToContent || !isReady) return () => {};
|
||||
|
||||
function onTick() {
|
||||
updateContentSize(htmlHash);
|
||||
}
|
||||
|
||||
// The only reliable way to make sure that the iframe has the same dimensions
|
||||
// as its content is to poll the dimensions at regular intervals. Other methods
|
||||
// work most of the time but will fail in various edge cases. Most reliable way
|
||||
// is probably iframe-resizer package, but still with 40 unfixed bugs.
|
||||
//
|
||||
// Polling in our case is fine since this is only used when displaying plugin
|
||||
// dialogs, which should be short lived. updateContentSize() is also optimised
|
||||
// to do nothing when size hasn't changed.
|
||||
const updateFrameSizeIID = setInterval(onTick, 100);
|
||||
|
||||
return () => {
|
||||
clearInterval(updateFrameSizeIID);
|
||||
};
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [fitToContent, isReady, minWidth, minHeight, htmlHash]);
|
||||
});
|
||||
|
||||
return contentSize;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
import { RefObject, useMemo, useRef } from 'react';
|
||||
import { PostMessage } from '../types';
|
||||
import useMessageHandler from './useMessageHandler';
|
||||
|
||||
type FormDataRecord = Record<string, unknown>;
|
||||
type FormDataListener = (formData: FormDataRecord)=> void;
|
||||
|
||||
const useFormData = (viewRef: RefObject<HTMLIFrameElement>, postMessage: PostMessage) => {
|
||||
const formDataListenersRef = useRef<FormDataListener[]>([]);
|
||||
useMessageHandler(viewRef, (event) => {
|
||||
if (event.data.message === 'serializedForms') {
|
||||
const formData = event.data.formData;
|
||||
if (typeof formData !== 'object') {
|
||||
throw new Error('Invalid formData result.');
|
||||
}
|
||||
|
||||
const listeners = [...formDataListenersRef.current];
|
||||
formDataListenersRef.current = [];
|
||||
for (const listener of listeners) {
|
||||
listener(formData);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return useMemo(() => {
|
||||
return {
|
||||
getFormData: () => {
|
||||
return new Promise<FormDataRecord>(resolve => {
|
||||
postMessage('serializeForms', null);
|
||||
formDataListenersRef.current.push((data) => {
|
||||
resolve(data);
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
}, [postMessage]);
|
||||
};
|
||||
|
||||
export default useFormData;
|
|
@ -1,41 +1,31 @@
|
|||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { useEffect, useState, useMemo, RefObject } from 'react';
|
||||
import useMessageHandler from './useMessageHandler';
|
||||
const md5 = require('md5');
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types, @typescript-eslint/no-explicit-any -- Old code before rule was applied, Old code before rule was applied
|
||||
export default function(frameWindow: any, isReady: boolean, postMessage: Function, html: string) {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied, Old code before rule was applied
|
||||
export default function(viewRef: RefObject<HTMLIFrameElement>, isReady: boolean, postMessage: Function, html: string) {
|
||||
const [loadedHtmlHash, setLoadedHtmlHash] = useState('');
|
||||
|
||||
const htmlHash = useMemo(() => {
|
||||
return md5(html);
|
||||
}, [html]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!frameWindow) return () => {};
|
||||
useMessageHandler(viewRef, event => {
|
||||
const data = event.data;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
function onMessage(event: any) {
|
||||
const data = event.data;
|
||||
if (!data || data.target !== 'UserWebview') return;
|
||||
|
||||
if (!data || data.target !== 'UserWebview') return;
|
||||
// eslint-disable-next-line no-console
|
||||
console.info('useHtmlLoader: message', data);
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.info('useHtmlLoader: message', data);
|
||||
|
||||
// We only update if the HTML that was loaded is the same as
|
||||
// the active one. Otherwise it means the content has been
|
||||
// changed between the moment it was set by the user and the
|
||||
// moment it was loaded in the view.
|
||||
if (data.message === 'htmlIsSet' && data.hash === htmlHash) {
|
||||
setLoadedHtmlHash(data.hash);
|
||||
}
|
||||
// We only update if the HTML that was loaded is the same as
|
||||
// the active one. Otherwise it means the content has been
|
||||
// changed between the moment it was set by the user and the
|
||||
// moment it was loaded in the view.
|
||||
if (data.message === 'htmlIsSet' && data.hash === htmlHash) {
|
||||
setLoadedHtmlHash(data.hash);
|
||||
}
|
||||
|
||||
frameWindow.addEventListener('message', onMessage);
|
||||
|
||||
return () => {
|
||||
if (frameWindow.removeEventListener) frameWindow.removeEventListener('message', onMessage);
|
||||
};
|
||||
}, [frameWindow, htmlHash]);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line no-console
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
import { RefObject, useEffect, useRef } from 'react';
|
||||
|
||||
type OnMessage = (event: MessageEvent)=> void;
|
||||
|
||||
const useMessageHandler = (viewRef: RefObject<HTMLIFrameElement>, onMessage: OnMessage) => {
|
||||
const onMessageRef = useRef(onMessage);
|
||||
onMessageRef.current = onMessage;
|
||||
|
||||
useEffect(() => {
|
||||
function onMessage_(event: MessageEvent) {
|
||||
if (event.source !== viewRef.current.contentWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
onMessageRef.current(event);
|
||||
}
|
||||
|
||||
const containerWindow = (viewRef.current.getRootNode() as Document).defaultView;
|
||||
containerWindow.addEventListener('message', onMessage_);
|
||||
|
||||
return () => {
|
||||
containerWindow.removeEventListener('message', onMessage_);
|
||||
};
|
||||
}, [viewRef]);
|
||||
};
|
||||
|
||||
export default useMessageHandler;
|
|
@ -1,16 +1,23 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import bridge from '../../bridge';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
|
||||
export default function(postMessage: Function, isReady: boolean, scripts: string[], cssFilePath: string) {
|
||||
useEffect(() => {
|
||||
if (!isReady) return;
|
||||
postMessage('setScripts', { scripts: scripts });
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [scripts, isReady]);
|
||||
const protocolHandler = useMemo(() => {
|
||||
return bridge().electronApp().getCustomProtocolHandler();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isReady || !cssFilePath) return;
|
||||
if (!isReady) return () => {};
|
||||
postMessage('setScripts', { scripts: scripts });
|
||||
const { remove } = protocolHandler.allowReadAccessToFiles(scripts);
|
||||
return remove;
|
||||
}, [scripts, isReady, postMessage, protocolHandler]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isReady || !cssFilePath) return () => {};
|
||||
postMessage('setScript', { script: cssFilePath, key: 'themeCss' });
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [isReady, cssFilePath]);
|
||||
const { remove } = protocolHandler.allowReadAccessToFile(cssFilePath);
|
||||
return remove;
|
||||
}, [isReady, cssFilePath, postMessage, protocolHandler]);
|
||||
}
|
||||
|
|
|
@ -1,41 +1,14 @@
|
|||
import { useEffect } from 'react';
|
||||
import { RefObject } from 'react';
|
||||
import useMessageHandler from './useMessageHandler';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types, @typescript-eslint/no-explicit-any -- Old code before rule was applied, Old code before rule was applied
|
||||
export default function(frameWindow: any, onSubmit: Function, onDismiss: Function, loadedHtmlHash: string) {
|
||||
const document = frameWindow && frameWindow.document ? frameWindow.document : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!document) return () => {};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
function onFormSubmit(event: any) {
|
||||
event.preventDefault();
|
||||
if (onSubmit) onSubmit();
|
||||
export default function(viewRef: RefObject<HTMLIFrameElement>, onSubmit: Function, onDismiss: Function) {
|
||||
useMessageHandler(viewRef, event => {
|
||||
const message = event.data?.message;
|
||||
if (message === 'form-submit') {
|
||||
onSubmit();
|
||||
} else if (message === 'dismiss') {
|
||||
onDismiss();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
function onKeyDown(event: any) {
|
||||
if (event.key === 'Escape') {
|
||||
if (onDismiss) onDismiss();
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
//
|
||||
// Disable enter key from submitting when a text area is in focus!
|
||||
// https://github.com/laurent22/joplin/issues/4766
|
||||
//
|
||||
if (document.activeElement.tagName !== 'TEXTAREA') {
|
||||
if (onSubmit) onSubmit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('submit', onFormSubmit);
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
|
||||
return () => {
|
||||
if (document) document.removeEventListener('submit', onFormSubmit);
|
||||
if (document) document.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [document, loadedHtmlHash, onSubmit, onDismiss]);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { RefObject, useEffect, useState } from 'react';
|
||||
import useMessageHandler from './useMessageHandler';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
export default function useViewIsReady(viewRef: any) {
|
||||
export default function useViewIsReady(viewRef: RefObject<HTMLIFrameElement>) {
|
||||
// Just checking if the iframe is ready is not sufficient because its content
|
||||
// might not be ready (for example, IPC listeners might not be initialised).
|
||||
// So we also listen to a custom "ready" message coming from the webview content
|
||||
|
@ -9,6 +9,18 @@ export default function useViewIsReady(viewRef: any) {
|
|||
const [iframeReady, setIFrameReady] = useState(false);
|
||||
const [iframeContentReady, setIFrameContentReady] = useState(false);
|
||||
|
||||
useMessageHandler(viewRef, event => {
|
||||
const data = event.data;
|
||||
if (!data || data.target !== 'UserWebview') return;
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug('useViewIsReady: message', data);
|
||||
|
||||
if (data.message === 'ready') {
|
||||
setIFrameContentReady(true);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug('useViewIsReady ============== Setup Listeners');
|
||||
|
@ -19,20 +31,6 @@ export default function useViewIsReady(viewRef: any) {
|
|||
setIFrameReady(true);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
function onMessage(event: any) {
|
||||
const data = event.data;
|
||||
|
||||
if (!data || data.target !== 'UserWebview') return;
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug('useViewIsReady: message', data);
|
||||
|
||||
if (data.message === 'ready') {
|
||||
setIFrameContentReady(true);
|
||||
}
|
||||
}
|
||||
|
||||
const iframeDocument = viewRef.current.contentWindow.document;
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
|
@ -42,20 +40,15 @@ export default function useViewIsReady(viewRef: any) {
|
|||
onIFrameReady();
|
||||
}
|
||||
|
||||
viewRef.current.addEventListener('dom-ready', onIFrameReady);
|
||||
viewRef.current.addEventListener('load', onIFrameReady);
|
||||
viewRef.current.contentWindow.addEventListener('message', onMessage);
|
||||
const view = viewRef.current;
|
||||
view.addEventListener('dom-ready', onIFrameReady);
|
||||
view.addEventListener('load', onIFrameReady);
|
||||
|
||||
return () => {
|
||||
if (viewRef.current) {
|
||||
viewRef.current.removeEventListener('dom-ready', onIFrameReady);
|
||||
viewRef.current.removeEventListener('load', onIFrameReady);
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
viewRef.current.contentWindow.removeEventListener('message', onMessage);
|
||||
}
|
||||
view.removeEventListener('dom-ready', onIFrameReady);
|
||||
view.removeEventListener('load', onIFrameReady);
|
||||
};
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, []);
|
||||
}, [viewRef]);
|
||||
|
||||
return iframeReady && iframeContentReady;
|
||||
}
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import PostMessageService, { MessageResponse, ResponderComponentType } from '@joplin/lib/services/PostMessageService';
|
||||
import { useEffect } from 'react';
|
||||
import { RefObject, useEffect } from 'react';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types, @typescript-eslint/no-explicit-any -- Old code before rule was applied, Old code before rule was applied
|
||||
export default function(frameWindow: any, isReady: boolean, pluginId: string, viewId: string, windowId: string, postMessage: Function) {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied, Old code before rule was applied
|
||||
export default function(webviewRef: RefObject<HTMLIFrameElement>, isReady: boolean, pluginId: string, viewId: string, windowId: string, postMessage: Function) {
|
||||
useEffect(() => {
|
||||
PostMessageService.instance().registerResponder(ResponderComponentType.UserWebview, viewId, windowId, (message: MessageResponse) => {
|
||||
postMessage('postMessageService.response', { message });
|
||||
|
@ -15,12 +16,8 @@ export default function(frameWindow: any, isReady: boolean, pluginId: string, vi
|
|||
}, [viewId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!frameWindow) return () => {};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
function onMessage_(event: any) {
|
||||
|
||||
if (!event.data || !event.data.target) {
|
||||
function onMessage_(event: MessageEvent) {
|
||||
if (!event.data || event.source !== webviewRef.current.contentWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -42,11 +39,11 @@ export default function(frameWindow: any, isReady: boolean, pluginId: string, vi
|
|||
}
|
||||
}
|
||||
|
||||
frameWindow.addEventListener('message', onMessage_);
|
||||
const containerWindow = (webviewRef.current.getRootNode() as Document).defaultView;
|
||||
containerWindow.addEventListener('message', onMessage_);
|
||||
|
||||
return () => {
|
||||
if (frameWindow?.removeEventListener) frameWindow.removeEventListener('message', onMessage_);
|
||||
containerWindow.removeEventListener('message', onMessage_);
|
||||
};
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, [frameWindow, isReady, pluginId, windowId, viewId]);
|
||||
}, [webviewRef, isReady, pluginId, windowId, viewId, postMessage]);
|
||||
}
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
export type PostMessage = (message: string, args: unknown)=> void;
|
|
@ -9,7 +9,6 @@
|
|||
@use 'gui/JoplinCloudLoginScreen.scss' as joplin-cloud-login-screen;
|
||||
@use 'gui/NoteListHeader/style.scss' as note-list-header;
|
||||
@use 'gui/UpdateNotification/style.scss' as update-notification;
|
||||
@use 'gui/TrashNotification/style.scss' as trash-notification;
|
||||
@use 'gui/Sidebar/style.scss' as sidebar-styles;
|
||||
@use 'gui/NoteEditor/style.scss' as note-editor-styles;
|
||||
@use 'gui/KeymapConfig/style.scss' as keymap-styles;
|
||||
|
|
|
@ -1,3 +1,2 @@
|
|||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const contentProtocolName = 'joplin-content';
|
||||
|
|
|
@ -19,7 +19,6 @@ jest.doMock('electron', () => {
|
|||
};
|
||||
});
|
||||
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import handleCustomProtocols from './handleCustomProtocols';
|
||||
import { supportDir } from '@joplin/lib/testing/test-utils';
|
||||
import { join } from 'path';
|
||||
|
@ -27,8 +26,7 @@ import { stat } from 'fs-extra';
|
|||
import { toForwardSlashes } from '@joplin/utils/path';
|
||||
|
||||
const setUpProtocolHandler = () => {
|
||||
const logger = Logger.create('test-logger');
|
||||
const protocolHandler = handleCustomProtocols(logger);
|
||||
const protocolHandler = handleCustomProtocols();
|
||||
|
||||
expect(handleProtocolMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
|
@ -56,9 +54,8 @@ const toAccessUrl = (path: string, { host = 'note-viewer' }: ExpectBlockedOption
|
|||
|
||||
const expectPathToBeBlocked = async (onRequestListener: ProtocolHandler, filePath: string, options?: ExpectBlockedOptions) => {
|
||||
const url = toAccessUrl(filePath, options);
|
||||
await expect(
|
||||
async () => await onRequestListener(new Request(url)),
|
||||
).rejects.toThrow(/Read access not granted for URL|Invalid or missing media access key|Media access denied/);
|
||||
const response = await onRequestListener(new Request(url));
|
||||
expect(response.status).toBe(403); // Forbidden
|
||||
};
|
||||
|
||||
const expectPathToBeUnblocked = async (onRequestListener: ProtocolHandler, filePath: string, options?: ExpectBlockedOptions) => {
|
||||
|
|
|
@ -3,7 +3,6 @@ import { dirname, resolve, normalize } from 'path';
|
|||
import { fileURLToPath, pathToFileURL } from 'url';
|
||||
import { contentProtocolName } from './constants';
|
||||
import resolvePathWithinDir from '@joplin/lib/utils/resolvePathWithinDir';
|
||||
import { LoggerWrapper } from '@joplin/utils/Logger';
|
||||
import * as fs from 'fs-extra';
|
||||
import { createReadStream } from 'fs';
|
||||
import { fromFilename } from '@joplin/lib/mime-utils';
|
||||
|
@ -17,6 +16,7 @@ export interface CustomProtocolHandler {
|
|||
// note-viewer/ URLs
|
||||
allowReadAccessToDirectory(path: string): void;
|
||||
allowReadAccessToFile(path: string): AccessController;
|
||||
allowReadAccessToFiles(paths: string[]): AccessController;
|
||||
|
||||
// file-media/ URLs
|
||||
setMediaAccessEnabled(enabled: boolean): void;
|
||||
|
@ -124,6 +124,12 @@ const handleRangeRequest = async (request: Request, targetPath: string) => {
|
|||
);
|
||||
};
|
||||
|
||||
const makeAccessDeniedResponse = (message: string) => {
|
||||
return new Response(message, {
|
||||
status: 403, // Forbidden
|
||||
});
|
||||
};
|
||||
|
||||
// Creating a custom protocol allows us to isolate iframes by giving them
|
||||
// different domain names from the main Joplin app.
|
||||
//
|
||||
|
@ -134,10 +140,10 @@ const handleRangeRequest = async (request: Request, targetPath: string) => {
|
|||
//
|
||||
// TODO: Use Logger.create (doesn't work for now because Logger is only initialized
|
||||
// in the main process.)
|
||||
const handleCustomProtocols = (logger: LoggerWrapper): CustomProtocolHandler => {
|
||||
logger = {
|
||||
...logger,
|
||||
debug: () => {},
|
||||
const handleCustomProtocols = (): CustomProtocolHandler => {
|
||||
const logger = {
|
||||
// Disabled for now
|
||||
debug: (..._message: unknown[]) => {},
|
||||
};
|
||||
|
||||
// Allow-listed files/directories for joplin-content://note-viewer/
|
||||
|
@ -155,14 +161,16 @@ const handleCustomProtocols = (logger: LoggerWrapper): CustomProtocolHandler =>
|
|||
|
||||
// See https://security.stackexchange.com/a/123723
|
||||
if (pathname.startsWith('..')) {
|
||||
throw new Error(`Invalid URL (not absolute), ${request.url}`);
|
||||
return new Response('Invalid URL (not absolute)', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
pathname = resolve(appBundleDirectory, pathname);
|
||||
|
||||
let canRead = false;
|
||||
let mediaOnly = true;
|
||||
if (host === 'note-viewer') {
|
||||
if (host === 'note-viewer' || host === 'plugin-webview') {
|
||||
if (readableFiles.has(pathname)) {
|
||||
canRead = true;
|
||||
} else {
|
||||
|
@ -177,7 +185,7 @@ const handleCustomProtocols = (logger: LoggerWrapper): CustomProtocolHandler =>
|
|||
mediaOnly = false;
|
||||
} else if (host === 'file-media') {
|
||||
if (!mediaAccessKey) {
|
||||
throw new Error('Media access denied. This must be enabled with .setMediaAccessEnabled');
|
||||
return makeAccessDeniedResponse('Media access denied. This must be enabled with .setMediaAccessEnabled');
|
||||
}
|
||||
|
||||
canRead = true;
|
||||
|
@ -185,14 +193,16 @@ const handleCustomProtocols = (logger: LoggerWrapper): CustomProtocolHandler =>
|
|||
|
||||
const accessKey = url.searchParams.get('access-key');
|
||||
if (accessKey !== mediaAccessKey) {
|
||||
throw new Error(`Invalid or missing media access key (was ${accessKey}). An allow-listed ?access-key= parameter must be provided.`);
|
||||
return makeAccessDeniedResponse('Invalid or missing media access key. An allow-listed ?access-key= parameter must be provided.');
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Invalid URL ${request.url}`);
|
||||
return new Response(`Invalid request URL (${request.url})`, {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (!canRead) {
|
||||
throw new Error(`Read access not granted for URL ${request.url}`);
|
||||
return makeAccessDeniedResponse(`Read access not granted for URL (${request.url})`);
|
||||
}
|
||||
|
||||
const asFileUrl = pathToFileURL(pathname).toString();
|
||||
|
@ -214,7 +224,7 @@ const handleCustomProtocols = (logger: LoggerWrapper): CustomProtocolHandler =>
|
|||
// This is an extra check to prevent loading text/html and arbitrary non-media content from the URL.
|
||||
const contentType = response.headers.get('Content-Type');
|
||||
if (!contentType || !contentType.match(/^(image|video|audio)\//)) {
|
||||
throw new Error(`Attempted to access non-media file from ${request.url}, which is media-only. Content type was ${contentType}.`);
|
||||
return makeAccessDeniedResponse(`Attempted to access non-media file from ${request.url}, which is media-only. Content type was ${contentType}.`);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -222,7 +232,7 @@ const handleCustomProtocols = (logger: LoggerWrapper): CustomProtocolHandler =>
|
|||
});
|
||||
|
||||
const appBundleDirectory = dirname(dirname(__dirname));
|
||||
return {
|
||||
const result: CustomProtocolHandler = {
|
||||
allowReadAccessToDirectory: (path: string) => {
|
||||
path = resolve(appBundleDirectory, path);
|
||||
logger.debug('protocol handler: Allow read access to directory', path);
|
||||
|
@ -250,6 +260,18 @@ const handleCustomProtocols = (logger: LoggerWrapper): CustomProtocolHandler =>
|
|||
},
|
||||
};
|
||||
},
|
||||
allowReadAccessToFiles: (paths: string[]) => {
|
||||
const handles = paths.map(path => {
|
||||
return result.allowReadAccessToFile(path);
|
||||
});
|
||||
return {
|
||||
remove: () => {
|
||||
for (const handle of handles) {
|
||||
handle.remove();
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
setMediaAccessEnabled: (enabled: boolean) => {
|
||||
if (enabled) {
|
||||
mediaAccessKey ||= createSecureRandom();
|
||||
|
@ -263,6 +285,7 @@ const handleCustomProtocols = (logger: LoggerWrapper): CustomProtocolHandler =>
|
|||
return mediaAccessKey || null;
|
||||
},
|
||||
};
|
||||
return result;
|
||||
};
|
||||
|
||||
export default handleCustomProtocols;
|
||||
|
|
|
@ -86,8 +86,8 @@ android {
|
|||
applicationId "net.cozic.joplin"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 2097767
|
||||
versionName "3.3.4"
|
||||
versionCode 2097768
|
||||
versionName "3.3.5"
|
||||
ndk {
|
||||
abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64"
|
||||
}
|
||||
|
|
|
@ -0,0 +1,106 @@
|
|||
import * as React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { NativeScrollEvent, NativeSyntheticEvent, StyleSheet, useWindowDimensions, View } from 'react-native';
|
||||
import useSafeAreaPadding from '../utils/hooks/useSafeAreaPadding';
|
||||
import { themeStyle, ThemeStyle } from './global-style';
|
||||
import Modal from './Modal';
|
||||
import { AppState } from '../utils/types';
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
children: React.ReactNode;
|
||||
visible: boolean;
|
||||
onDismiss: ()=> void;
|
||||
onShow: ()=> void;
|
||||
}
|
||||
|
||||
const useStyles = (theme: ThemeStyle) => {
|
||||
const { width: windowWidth } = useWindowDimensions();
|
||||
const safeAreaPadding = useSafeAreaPadding();
|
||||
|
||||
return useMemo(() => {
|
||||
const isSmallWidthScreen = windowWidth < 500;
|
||||
const menuGapLeft = safeAreaPadding.paddingLeft + 6;
|
||||
const menuGapRight = safeAreaPadding.paddingRight + 6;
|
||||
|
||||
return StyleSheet.create({
|
||||
menuStyle: {
|
||||
alignSelf: 'flex-end',
|
||||
...(isSmallWidthScreen ? {
|
||||
// Center on small screens, rather than float right.
|
||||
alignSelf: 'center',
|
||||
} : {}),
|
||||
flexDirection: 'row',
|
||||
marginRight: menuGapRight,
|
||||
marginLeft: menuGapLeft,
|
||||
paddingBottom: 0,
|
||||
|
||||
backgroundColor: theme.backgroundColor,
|
||||
borderRadius: 16,
|
||||
borderBottomRightRadius: 0,
|
||||
borderBottomLeftRadius: 0,
|
||||
maxWidth: Math.min(400, windowWidth - menuGapRight - menuGapLeft),
|
||||
},
|
||||
contentContainer: {
|
||||
padding: 20,
|
||||
paddingBottom: 14,
|
||||
gap: 8,
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
modalBackground: {
|
||||
paddingTop: 0,
|
||||
paddingLeft: 0,
|
||||
paddingRight: 0,
|
||||
paddingBottom: 0,
|
||||
justifyContent: 'flex-end',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
dismissButton: {
|
||||
top: 0,
|
||||
bottom: undefined,
|
||||
height: 12,
|
||||
},
|
||||
});
|
||||
}, [theme, safeAreaPadding, windowWidth]);
|
||||
};
|
||||
|
||||
const BottomDrawer: React.FC<Props> = props => {
|
||||
const theme = themeStyle(props.themeId);
|
||||
const styles = useStyles(theme);
|
||||
|
||||
const onContainerScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const offsetY = event.nativeEvent.contentOffset.y;
|
||||
if (offsetY < -50) {
|
||||
props.onDismiss();
|
||||
}
|
||||
}, [props.onDismiss]);
|
||||
|
||||
return <Modal
|
||||
visible={props.visible}
|
||||
onDismiss={props.onDismiss}
|
||||
onRequestClose={props.onDismiss}
|
||||
onShow={props.onShow}
|
||||
animationType='fade'
|
||||
backgroundColor={theme.backgroundColorTransparent2}
|
||||
transparent
|
||||
modalBackgroundStyle={styles.modalBackground}
|
||||
dismissButtonStyle={styles.dismissButton}
|
||||
containerStyle={styles.menuStyle}
|
||||
scrollOverflow={{
|
||||
overScrollMode: 'always',
|
||||
onScroll: onContainerScroll,
|
||||
}}
|
||||
>
|
||||
<View style={styles.contentContainer}>
|
||||
{props.children}
|
||||
</View>
|
||||
</Modal>;
|
||||
};
|
||||
|
||||
export default connect((state: AppState) => {
|
||||
return {
|
||||
themeId: state.settings.theme,
|
||||
};
|
||||
})(BottomDrawer);
|
|
@ -1,11 +1,13 @@
|
|||
import * as React from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { TouchableHighlight, StyleSheet, TextStyle } from 'react-native';
|
||||
import { TouchableHighlight, StyleSheet, TextStyle, AccessibilityInfo } from 'react-native';
|
||||
import Icon from './Icon';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
|
||||
interface Props {
|
||||
checked: boolean;
|
||||
accessibilityLabel?: string;
|
||||
accessibilityHint?: string;
|
||||
onChange?: (checked: boolean)=> void;
|
||||
style?: TextStyle;
|
||||
iconStyle?: TextStyle;
|
||||
|
@ -40,6 +42,15 @@ const Checkbox: React.FC<Props> = props => {
|
|||
setChecked(checked => {
|
||||
const newChecked = !checked;
|
||||
props.onChange?.(newChecked);
|
||||
|
||||
// An .announceForAccessibility is necessary here because VoiceOver doesn't announce this
|
||||
// change on its own, even though we change [accessibilityState].
|
||||
if (newChecked) {
|
||||
AccessibilityInfo.announceForAccessibility(_('Checked'));
|
||||
} else {
|
||||
AccessibilityInfo.announceForAccessibility(_('Unchecked'));
|
||||
}
|
||||
|
||||
return newChecked;
|
||||
});
|
||||
}, [props.onChange]);
|
||||
|
@ -58,6 +69,7 @@ const Checkbox: React.FC<Props> = props => {
|
|||
accessibilityRole="checkbox"
|
||||
accessibilityState={accessibilityState}
|
||||
accessibilityLabel={props.accessibilityLabel ?? ''}
|
||||
accessibilityHint={props.accessibilityHint}
|
||||
// Web requires aria-checked
|
||||
aria-checked={checked}
|
||||
>
|
||||
|
|
|
@ -8,6 +8,7 @@ import makeShowMessageBox from '../../utils/makeShowMessageBox';
|
|||
import { DialogControl, PromptDialogData } from './types';
|
||||
import useDialogControl from './hooks/useDialogControl';
|
||||
import PromptDialog from './PromptDialog';
|
||||
import { themeStyle } from '../global-style';
|
||||
|
||||
export type { DialogControl } from './types';
|
||||
export const DialogContext = createContext<DialogControl>(null);
|
||||
|
@ -49,6 +50,7 @@ const DialogManager: React.FC<Props> = props => {
|
|||
};
|
||||
}, []);
|
||||
|
||||
const theme = themeStyle(props.themeId);
|
||||
const styles = useStyles();
|
||||
|
||||
const dialogComponents: React.ReactNode[] = [];
|
||||
|
@ -73,7 +75,7 @@ const DialogManager: React.FC<Props> = props => {
|
|||
scrollOverflow={true}
|
||||
containerStyle={styles.modalContainer}
|
||||
animationType='fade'
|
||||
backgroundColor='rgba(0, 0, 0, 0.1)'
|
||||
backgroundColor={theme.backgroundColorTransparent2}
|
||||
transparent={true}
|
||||
onRequestClose={dialogModels[dialogComponents.length - 1]?.onDismiss}
|
||||
>
|
||||
|
|
|
@ -69,6 +69,7 @@ const useStyles = (themeId: number, containerStyle: ViewStyle, size: DialogSize)
|
|||
};
|
||||
|
||||
const DismissibleDialog: React.FC<Props> = props => {
|
||||
const theme = themeStyle(props.themeId);
|
||||
const styles = useStyles(props.themeId, props.containerStyle, props.size);
|
||||
|
||||
const heading = props.heading ? (
|
||||
|
@ -92,7 +93,7 @@ const DismissibleDialog: React.FC<Props> = props => {
|
|||
onRequestClose={props.onDismiss}
|
||||
containerStyle={styles.dialogContainer}
|
||||
animationType='fade'
|
||||
backgroundColor='rgba(0, 0, 0, 0.1)'
|
||||
backgroundColor={theme.backgroundColorTransparent2}
|
||||
transparent={true}
|
||||
>
|
||||
<Surface style={styles.dialogSurface} elevation={1}>
|
||||
|
|
|
@ -22,6 +22,8 @@ const builtInCommandNames = [
|
|||
'-',
|
||||
EditorCommandType.IndentLess,
|
||||
EditorCommandType.IndentMore,
|
||||
`editor.${EditorCommandType.SwapLineDown}`,
|
||||
`editor.${EditorCommandType.SwapLineUp}`,
|
||||
'-',
|
||||
'insertDateTime',
|
||||
'-',
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { EditorCommandType } from '@joplin/editor/types';
|
||||
import { AppState } from '../../../utils/types';
|
||||
import allToolbarCommandNamesFromState from './allToolbarCommandNamesFromState';
|
||||
import { Platform } from 'react-native';
|
||||
|
@ -7,6 +8,8 @@ const omitFromDefault: string[] = [
|
|||
'editor.textHeading3',
|
||||
'editor.textHeading4',
|
||||
'editor.textHeading5',
|
||||
`editor.${EditorCommandType.SwapLineDown}`,
|
||||
`editor.${EditorCommandType.SwapLineUp}`,
|
||||
];
|
||||
|
||||
// The "hide keyboard" button is only needed on iOS, so only show it there by default.
|
||||
|
|
|
@ -1,40 +1,35 @@
|
|||
import * as React from 'react';
|
||||
import { RefObject, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { GestureResponderEvent, Modal, ModalProps, Platform, Pressable, ScrollView, StyleSheet, View, ViewStyle, useWindowDimensions } from 'react-native';
|
||||
import { hasNotch } from 'react-native-device-info';
|
||||
import { GestureResponderEvent, Modal, ModalProps, Platform, Pressable, ScrollView, ScrollViewProps, StyleSheet, View, ViewStyle } from 'react-native';
|
||||
import FocusControl from './accessibility/FocusControl/FocusControl';
|
||||
import { msleep, Second } from '@joplin/utils/time';
|
||||
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
|
||||
import { ModalState } from './accessibility/FocusControl/types';
|
||||
import useSafeAreaPadding from '../utils/hooks/useSafeAreaPadding';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
|
||||
interface ModalElementProps extends ModalProps {
|
||||
children: React.ReactNode;
|
||||
containerStyle?: ViewStyle;
|
||||
backgroundColor?: string;
|
||||
modalBackgroundStyle?: ViewStyle;
|
||||
// Extra styles for the accessibility tools dismiss button. For example,
|
||||
// this might be used to display the dismiss button near the top of the
|
||||
// screen (rather than the bottom).
|
||||
dismissButtonStyle?: ViewStyle;
|
||||
|
||||
// If scrollOverflow is provided, the modal is wrapped in a vertical
|
||||
// ScrollView. This allows the user to scroll parts of dialogs into
|
||||
// view that would otherwise be clipped by the screen edge.
|
||||
scrollOverflow?: boolean;
|
||||
scrollOverflow?: boolean|ScrollViewProps;
|
||||
}
|
||||
|
||||
const useStyles = (hasScrollView: boolean, backgroundColor: string|undefined) => {
|
||||
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
||||
const isLandscape = windowWidth > windowHeight;
|
||||
const safeAreaPadding = useSafeAreaPadding();
|
||||
return useMemo(() => {
|
||||
const backgroundPadding: ViewStyle = isLandscape ? {
|
||||
paddingRight: hasNotch() ? 60 : 0,
|
||||
paddingLeft: hasNotch() ? 60 : 0,
|
||||
paddingTop: 15,
|
||||
paddingBottom: 15,
|
||||
} : {
|
||||
paddingTop: hasNotch() ? 65 : 15,
|
||||
paddingBottom: hasNotch() ? 35 : 15,
|
||||
};
|
||||
return StyleSheet.create({
|
||||
modalBackground: {
|
||||
...backgroundPadding,
|
||||
...safeAreaPadding,
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
|
||||
|
@ -62,7 +57,7 @@ const useStyles = (hasScrollView: boolean, backgroundColor: string|undefined) =>
|
|||
zIndex: -1,
|
||||
},
|
||||
});
|
||||
}, [hasScrollView, isLandscape, backgroundColor]);
|
||||
}, [hasScrollView, safeAreaPadding, backgroundColor]);
|
||||
};
|
||||
|
||||
const useBackgroundTouchListeners = (onRequestClose: (event: GestureResponderEvent)=> void, backdropRef: RefObject<View>) => {
|
||||
|
@ -114,9 +109,11 @@ const ModalElement: React.FC<ModalElementProps> = ({
|
|||
containerStyle,
|
||||
backgroundColor,
|
||||
scrollOverflow,
|
||||
modalBackgroundStyle: extraModalBackgroundStyles,
|
||||
dismissButtonStyle,
|
||||
...modalProps
|
||||
}) => {
|
||||
const styles = useStyles(scrollOverflow, backgroundColor);
|
||||
const styles = useStyles(!!scrollOverflow, backgroundColor);
|
||||
|
||||
// contentWrapper adds padding. To allow styling the region outside of the modal
|
||||
// (e.g. to add a background), the content is wrapped twice.
|
||||
|
@ -134,18 +131,18 @@ const ModalElement: React.FC<ModalElementProps> = ({
|
|||
containerRef.current = containerComponent;
|
||||
const { onShouldBackgroundCaptureTouch, onBackgroundTouchFinished } = useBackgroundTouchListeners(modalProps.onRequestClose, containerRef);
|
||||
|
||||
|
||||
// A close button for accessibility tools. Since iOS accessibility focus order is based on the position
|
||||
// of the element on the screen, the close button is placed after the modal content, rather than behind.
|
||||
const closeButton = modalProps.onRequestClose ? <Pressable
|
||||
style={styles.dismissButton}
|
||||
style={[styles.dismissButton, dismissButtonStyle]}
|
||||
onPress={modalProps.onRequestClose}
|
||||
accessibilityLabel={_('Close dialog')}
|
||||
accessibilityRole='button'
|
||||
/> : null;
|
||||
|
||||
const contentAndBackdrop = <View
|
||||
ref={setContainerComponent}
|
||||
style={styles.modalBackground}
|
||||
style={[styles.modalBackground, extraModalBackgroundStyles]}
|
||||
onStartShouldSetResponder={onShouldBackgroundCaptureTouch}
|
||||
onResponderRelease={onBackgroundTouchFinished}
|
||||
>
|
||||
|
@ -153,6 +150,7 @@ const ModalElement: React.FC<ModalElementProps> = ({
|
|||
{closeButton}
|
||||
</View>;
|
||||
|
||||
const extraScrollViewProps = (typeof scrollOverflow === 'object' ? scrollOverflow : {});
|
||||
return (
|
||||
<FocusControl.ModalWrapper state={modalStatus}>
|
||||
<Modal
|
||||
|
@ -162,8 +160,9 @@ const ModalElement: React.FC<ModalElementProps> = ({
|
|||
>
|
||||
{scrollOverflow ? (
|
||||
<ScrollView
|
||||
style={styles.modalScrollView}
|
||||
contentContainerStyle={styles.modalScrollViewContent}
|
||||
{...extraScrollViewProps}
|
||||
style={[styles.modalScrollView, extraScrollViewProps.style]}
|
||||
contentContainerStyle={[styles.modalScrollViewContent, extraScrollViewProps.contentContainerStyle]}
|
||||
>{contentAndBackdrop}</ScrollView>
|
||||
) : contentAndBackdrop}
|
||||
</Modal>
|
||||
|
|
|
@ -97,6 +97,16 @@ const declarations: CommandDeclaration[] = [
|
|||
label: () => _('Increase indent level'),
|
||||
iconName: 'ant indent-right',
|
||||
},
|
||||
{
|
||||
name: `editor.${EditorCommandType.SwapLineDown}`,
|
||||
label: () => _('Swap line down'),
|
||||
iconName: 'material chevron-double-down',
|
||||
},
|
||||
{
|
||||
name: `editor.${EditorCommandType.SwapLineUp}`,
|
||||
label: () => _('Swap line up'),
|
||||
iconName: 'material chevron-double-up',
|
||||
},
|
||||
{
|
||||
name: EditorCommandType.ToggleSearch,
|
||||
label: () => _('Search'),
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import * as React from 'react';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Text, View, StyleSheet, TextStyle, ViewStyle, AccessibilityInfo, TouchableOpacity } from 'react-native';
|
||||
import { Text, StyleSheet, TextStyle, ViewStyle, AccessibilityInfo } from 'react-native';
|
||||
import Checkbox from './Checkbox';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import time from '@joplin/lib/time';
|
||||
|
@ -11,6 +11,7 @@ import { AppState } from '../utils/types';
|
|||
import { Dispatch } from 'redux';
|
||||
import { NoteEntity } from '@joplin/lib/services/database/types';
|
||||
import useOnLongPressProps from '../utils/hooks/useOnLongPressProps';
|
||||
import MultiTouchableOpacity from './buttons/MultiTouchableOpacity';
|
||||
|
||||
interface Props {
|
||||
dispatch: Dispatch;
|
||||
|
@ -31,18 +32,25 @@ const useStyles = (themeId: number) => {
|
|||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.dividerColor,
|
||||
alignItems: 'flex-start',
|
||||
// backgroundColor: theme.backgroundColor,
|
||||
};
|
||||
|
||||
const listItemPressable: ViewStyle = {
|
||||
flexGrow: 1,
|
||||
alignSelf: 'stretch',
|
||||
};
|
||||
const listItemPressableWithCheckbox: ViewStyle = {
|
||||
...listItemPressable,
|
||||
paddingRight: theme.marginRight,
|
||||
};
|
||||
const listItemPressableWithoutCheckbox: ViewStyle = {
|
||||
...listItemPressable,
|
||||
paddingLeft: theme.marginLeft,
|
||||
paddingRight: theme.marginRight,
|
||||
paddingTop: theme.itemMarginTop,
|
||||
paddingBottom: theme.itemMarginBottom,
|
||||
// backgroundColor: theme.backgroundColor,
|
||||
};
|
||||
|
||||
const listItemWithCheckbox = { ...listItem };
|
||||
delete listItemWithCheckbox.paddingTop;
|
||||
delete listItemWithCheckbox.paddingBottom;
|
||||
delete listItemWithCheckbox.paddingLeft;
|
||||
|
||||
const listItemText: TextStyle = {
|
||||
flex: 1,
|
||||
color: theme.color,
|
||||
|
@ -62,7 +70,8 @@ const useStyles = (themeId: number) => {
|
|||
listItem,
|
||||
listItemText,
|
||||
selectionWrapper,
|
||||
listItemWithCheckbox,
|
||||
listItemPressableWithoutCheckbox,
|
||||
listItemPressableWithCheckbox,
|
||||
listItemTextWithCheckbox,
|
||||
selectionWrapperSelected,
|
||||
checkboxStyle: {
|
||||
|
@ -132,7 +141,6 @@ const NoteItemComponent: React.FC<Props> = memo(props => {
|
|||
const checkboxChecked = !!Number(note.todo_completed);
|
||||
|
||||
const checkboxStyle = styles.checkboxStyle;
|
||||
const listItemStyle = isTodo ? styles.listItemWithCheckbox : styles.listItem;
|
||||
const listItemTextStyle = isTodo ? styles.listItemTextWithCheckbox : styles.listItemText;
|
||||
const opacityStyle = isTodo && checkboxChecked ? styles.checkedOpacityStyle : styles.uncheckedOpacityStyle;
|
||||
const isSelected = props.noteSelectionEnabled && props.selectedNoteIds.includes(note.id);
|
||||
|
@ -140,41 +148,34 @@ const NoteItemComponent: React.FC<Props> = memo(props => {
|
|||
const selectionWrapperStyle = isSelected ? styles.selectionWrapperSelected : styles.selectionWrapper;
|
||||
|
||||
const noteTitle = Note.displayTitle(note);
|
||||
|
||||
const selectDeselectLabel = isSelected ? _('Deselect') : _('Select');
|
||||
const onLongPressProps = useOnLongPressProps({ onLongPress, actionDescription: selectDeselectLabel });
|
||||
|
||||
const contextMenuProps = {
|
||||
// Web only.
|
||||
onContextMenu: onLongPressProps.onContextMenu,
|
||||
const todoCheckbox = isTodo ? <Checkbox
|
||||
style={checkboxStyle}
|
||||
checked={checkboxChecked}
|
||||
onChange={todoCheckbox_change}
|
||||
accessibilityLabel={_('to-do: %s', noteTitle)}
|
||||
/> : null;
|
||||
|
||||
const pressableProps = {
|
||||
style: isTodo ? styles.listItemPressableWithCheckbox : styles.listItemPressableWithoutCheckbox,
|
||||
accessibilityHint: props.noteSelectionEnabled ? '' : _('Opens note'),
|
||||
'aria-pressed': props.noteSelectionEnabled ? isSelected : undefined,
|
||||
accessibilityState: { selected: isSelected },
|
||||
...onLongPressProps,
|
||||
};
|
||||
return (
|
||||
<View
|
||||
// context menu listeners need to be added to a parent view of the
|
||||
// TouchableOpacity -- on web, TouchableOpacity registers a custom
|
||||
// onContextMenu handler that can't be overridden.
|
||||
{...contextMenuProps}
|
||||
<MultiTouchableOpacity
|
||||
containerProps={{
|
||||
style: [selectionWrapperStyle, opacityStyle, styles.listItem],
|
||||
}}
|
||||
pressableProps={pressableProps}
|
||||
onPress={onPress}
|
||||
beforePressable={todoCheckbox}
|
||||
>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.5}
|
||||
onPress={onPress}
|
||||
accessibilityRole='button'
|
||||
accessibilityHint={props.noteSelectionEnabled ? '' : _('Opens note')}
|
||||
aria-pressed={props.noteSelectionEnabled ? isSelected : undefined}
|
||||
accessibilityState={{ selected: isSelected }}
|
||||
{...onLongPressProps}
|
||||
>
|
||||
<View style={[selectionWrapperStyle, opacityStyle, listItemStyle]}>
|
||||
{isTodo ? <Checkbox
|
||||
style={checkboxStyle}
|
||||
checked={checkboxChecked}
|
||||
onChange={todoCheckbox_change}
|
||||
accessibilityLabel={_('to-do: %s', noteTitle)}
|
||||
/> : null }
|
||||
<Text style={listItemTextStyle}>{noteTitle}</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<Text style={listItemTextStyle}>{noteTitle}</Text>
|
||||
</MultiTouchableOpacity>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
'use strict';
|
||||
Object.defineProperty(exports, '__esModule', { value: true });
|
||||
const React = require('react');
|
||||
const react_native_1 = require('react-native');
|
||||
const Modal_1 = require('../Modal');
|
||||
const react_1 = require('react');
|
||||
const locale_1 = require('@joplin/lib/locale');
|
||||
const buttons_1 = require('../buttons');
|
||||
// react-native-paper's floating action button menu is inaccessible on web
|
||||
// (can't be activated by a screen reader, and, in some cases, by the tab key).
|
||||
// This component provides an alternative.
|
||||
const AccessibleModalMenu = props => {
|
||||
let _a;
|
||||
const [open, setOpen] = (0, react_1.useState)(false);
|
||||
const onClick = (0, react_1.useCallback)(() => {
|
||||
if (props.onPress) {
|
||||
props.onPress();
|
||||
} else {
|
||||
setOpen(!open);
|
||||
}
|
||||
}, [open, props.onPress]);
|
||||
const options = [];
|
||||
for (const action of ((_a = props.actions) !== null && _a !== void 0 ? _a : [])) {
|
||||
options.push(React.createElement(buttons_1.PrimaryButton, { key: action.label, onPress: action.onPress }, action.label));
|
||||
}
|
||||
const modal = (React.createElement(Modal_1.default, { visible: open },
|
||||
options,
|
||||
React.createElement(buttons_1.SecondaryButton, { onPress: onClick }, (0, locale_1._)('Close menu'))));
|
||||
return React.createElement(react_native_1.View, { style: { height: 0, overflow: 'visible' } },
|
||||
modal,
|
||||
React.createElement(buttons_1.SecondaryButton, { onPress: onClick }, props.label));
|
||||
};
|
||||
exports.default = AccessibleModalMenu;
|
||||
// # sourceMappingURL=AccessibleModalMenu.js.map
|
|
@ -1,56 +0,0 @@
|
|||
import * as React from 'react';
|
||||
import { View } from 'react-native';
|
||||
import Modal from '../Modal';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { PrimaryButton, SecondaryButton } from '../buttons';
|
||||
|
||||
interface MenuItem {
|
||||
label: string;
|
||||
onPress?: ()=> void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
onPress: ()=> void;
|
||||
actions: MenuItem[]|null;
|
||||
}
|
||||
|
||||
// react-native-paper's floating action button menu is inaccessible on web
|
||||
// (can't be activated by a screen reader, and, in some cases, by the tab key).
|
||||
// This component provides an alternative.
|
||||
|
||||
const AccessibleModalMenu: React.FC<Props> = props => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
if (props.onPress) {
|
||||
props.onPress();
|
||||
} else {
|
||||
setOpen(!open);
|
||||
}
|
||||
}, [open, props.onPress]);
|
||||
|
||||
const options: React.ReactElement[] = [];
|
||||
for (const action of (props.actions ?? [])) {
|
||||
options.push(
|
||||
<PrimaryButton key={action.label} onPress={action.onPress}>
|
||||
{action.label}
|
||||
</PrimaryButton>,
|
||||
);
|
||||
}
|
||||
|
||||
const modal = (
|
||||
<Modal visible={open}>
|
||||
{options}
|
||||
<SecondaryButton onPress={onClick}>{_('Close menu')}</SecondaryButton>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
return <View style={{ height: 0, overflow: 'visible' }}>
|
||||
{modal}
|
||||
<SecondaryButton onPress={onClick}>{props.label}</SecondaryButton>
|
||||
</View>;
|
||||
};
|
||||
|
||||
export default AccessibleModalMenu;
|
|
@ -1,9 +1,9 @@
|
|||
import { focus } from '@joplin/lib/utils/focusHandler';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import * as React from 'react';
|
||||
import { useContext, useEffect, useRef, useState } from 'react';
|
||||
import { AccessibilityInfo, findNodeHandle, Platform, UIManager, View, ViewProps } from 'react-native';
|
||||
import { Platform, View, ViewProps } from 'react-native';
|
||||
import { AutoFocusContext } from './FocusControl/AutoFocusProvider';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import focusView from '../../utils/focusView';
|
||||
|
||||
const logger = Logger.create('AccessibleView');
|
||||
|
||||
|
@ -29,33 +29,7 @@ const useAutoFocus = (refocusCounter: number|null, containerNode: View|HTMLEleme
|
|||
if (!containerNode) return () => {};
|
||||
|
||||
const focusContainer = () => {
|
||||
const doFocus = () => {
|
||||
if (Platform.OS === 'web') {
|
||||
// react-native-web defines UIManager.focus for setting the keyboard focus. However,
|
||||
// this property is not available in standard react-native. As such, access it using type
|
||||
// narrowing:
|
||||
// eslint-disable-next-line no-restricted-properties
|
||||
if (!('focus' in UIManager) || typeof UIManager.focus !== 'function') {
|
||||
throw new Error('Failed to focus sidebar. UIManager.focus is not a function.');
|
||||
}
|
||||
|
||||
// Disable the "use focusHandler for all focus calls" rule -- UIManager.focus requires
|
||||
// an argument, which is not supported by focusHandler.
|
||||
// eslint-disable-next-line no-restricted-properties
|
||||
UIManager.focus(containerNode);
|
||||
} else {
|
||||
const handle = findNodeHandle(containerNode as View);
|
||||
if (handle !== null) {
|
||||
AccessibilityInfo.setAccessibilityFocus(handle);
|
||||
} else {
|
||||
logger.warn('Couldn\'t find a view to focus.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
focus(`AccessibleView::${debugLabelRef.current}`, {
|
||||
focus: doFocus,
|
||||
});
|
||||
focusView(`AccessibleView::${debugLabelRef.current}`, containerNode);
|
||||
};
|
||||
|
||||
const canFocusNow = !autoFocusControlRef.current || autoFocusControlRef.current.canAutoFocus();
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import * as React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import NotesScreen from './screens/Notes';
|
||||
import NotesScreen from './screens/Notes/Notes';
|
||||
import SearchScreen from './screens/SearchScreen';
|
||||
import { KeyboardAvoidingView, Platform, View } from 'react-native';
|
||||
import { AppState } from '../utils/types';
|
||||
|
|
|
@ -1,16 +1,13 @@
|
|||
const React = require('react');
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { FAB, Portal } from 'react-native-paper';
|
||||
import * as React from 'react';
|
||||
import { useState, useCallback, useMemo, useRef } from 'react';
|
||||
import { FAB } from 'react-native-paper';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { Dispatch } from 'redux';
|
||||
import { Platform, View, ViewStyle } from 'react-native';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import AccessibleWebMenu from '../accessibility/AccessibleModalMenu';
|
||||
import { AccessibilityActionEvent, AccessibilityActionInfo, View } from 'react-native';
|
||||
import { connect } from 'react-redux';
|
||||
import BottomDrawer from '../BottomDrawer';
|
||||
const Icon = require('react-native-vector-icons/Ionicons').default;
|
||||
|
||||
// eslint-disable-next-line no-undef -- Don't know why it says React is undefined when it's defined above
|
||||
type FABGroupProps = React.ComponentProps<typeof FAB.Group>;
|
||||
|
||||
type OnButtonPress = ()=> void;
|
||||
interface ButtonSpec {
|
||||
icon: string;
|
||||
|
@ -20,14 +17,18 @@ interface ButtonSpec {
|
|||
}
|
||||
|
||||
interface ActionButtonProps {
|
||||
buttons?: ButtonSpec[];
|
||||
|
||||
// If not given, an "add" button will be used.
|
||||
mainButton?: ButtonSpec;
|
||||
mainButton: ButtonSpec;
|
||||
dispatch: Dispatch;
|
||||
}
|
||||
|
||||
const defaultOnPress = () => {};
|
||||
menuContent?: React.ReactNode;
|
||||
onMenuShow?: ()=> void;
|
||||
|
||||
accessibilityActions?: readonly AccessibilityActionInfo[];
|
||||
// Can return a Promise to simplify unit testing
|
||||
onAccessibilityAction?: (event: AccessibilityActionEvent)=> void|Promise<void>;
|
||||
accessibilityHint?: string;
|
||||
}
|
||||
|
||||
// Returns a render function compatible with React Native Paper.
|
||||
const getIconRenderFunction = (iconName: string) => {
|
||||
|
@ -43,95 +44,55 @@ const useIcon = (iconName: string) => {
|
|||
|
||||
const FloatingActionButton = (props: ActionButtonProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const onMenuToggled: FABGroupProps['onStateChange'] = useCallback(state => {
|
||||
const onMenuToggled = useCallback(() => {
|
||||
props.dispatch({
|
||||
type: 'SIDE_MENU_CLOSE',
|
||||
});
|
||||
setOpen(state.open);
|
||||
}, [setOpen, props.dispatch]);
|
||||
const newOpen = !open;
|
||||
setOpen(newOpen);
|
||||
}, [setOpen, open, props.dispatch]);
|
||||
|
||||
const actions = useMemo(() => (props.buttons ?? []).map(button => {
|
||||
return {
|
||||
...button,
|
||||
icon: getIconRenderFunction(button.icon),
|
||||
onPress: button.onPress ?? defaultOnPress,
|
||||
};
|
||||
}), [props.buttons]);
|
||||
const onDismiss = useCallback(() => {
|
||||
if (open) onMenuToggled();
|
||||
}, [open, onMenuToggled]);
|
||||
|
||||
const mainButtonRef = useRef<View>();
|
||||
|
||||
const closedIcon = useIcon(props.mainButton?.icon ?? 'add');
|
||||
const openIcon = useIcon('close');
|
||||
|
||||
// To work around an Android accessibility bug, we decrease the
|
||||
// size of the container for the FAB. According to the documentation for
|
||||
// RN Paper, a large action button has size 96x96. As such, we allocate
|
||||
// a larger than this space for the button.
|
||||
//
|
||||
// To prevent the accessibility issue from regressing (which makes it
|
||||
// very hard to access some UI features), we also enable this when Talkback
|
||||
// is disabled.
|
||||
//
|
||||
// See https://github.com/callstack/react-native-paper/issues/4064
|
||||
// May be possible to remove if https://github.com/callstack/react-native-paper/pull/4514
|
||||
// is merged.
|
||||
const adjustMargins = !open && shim.mobilePlatform() === 'android';
|
||||
const marginStyles = useMemo((): ViewStyle => {
|
||||
if (!adjustMargins) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Internally, React Native Paper uses absolute positioning to make its
|
||||
// (usually invisible) view fill the screen. Setting top and left to
|
||||
// undefined causes the view to take up only part of the screen.
|
||||
return {
|
||||
top: undefined,
|
||||
left: undefined,
|
||||
};
|
||||
}, [adjustMargins]);
|
||||
|
||||
const label = props.mainButton?.label ?? _('Add new');
|
||||
|
||||
// On Web, FAB.Group can't be used at all with accessibility tools. Work around this
|
||||
// by hiding the FAB for accessibility, and providing a screen-reader-only custom menu.
|
||||
const isWeb = Platform.OS === 'web';
|
||||
const accessibleMenu = isWeb ? (
|
||||
<AccessibleWebMenu
|
||||
label={label}
|
||||
onPress={props.mainButton?.onPress}
|
||||
actions={props.buttons}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const menuContent = <FAB.Group
|
||||
open={open}
|
||||
const menuButton = <FAB
|
||||
ref={mainButtonRef}
|
||||
icon={open ? openIcon : closedIcon}
|
||||
accessibilityLabel={label}
|
||||
style={marginStyles}
|
||||
icon={ open ? openIcon : closedIcon }
|
||||
fabStyle={{
|
||||
backgroundColor: props.mainButton?.color ?? 'rgba(231,76,60,1)',
|
||||
onPress={props.mainButton?.onPress ?? onMenuToggled}
|
||||
style={{
|
||||
alignSelf: 'flex-end',
|
||||
}}
|
||||
onStateChange={onMenuToggled}
|
||||
actions={actions}
|
||||
onPress={props.mainButton?.onPress ?? defaultOnPress}
|
||||
// The long press delay is too short by default (and we don't use the long press event). See https://github.com/laurent22/joplin/issues/11183.
|
||||
// Increase to a large value:
|
||||
delayLongPress={10_000}
|
||||
visible={true}
|
||||
accessibilityActions={props.accessibilityActions}
|
||||
onAccessibilityAction={props.onAccessibilityAction}
|
||||
/>;
|
||||
const mainMenu = isWeb ? (
|
||||
<View
|
||||
aria-hidden={true}
|
||||
pointerEvents='box-none'
|
||||
tabIndex={-1}
|
||||
style={{ flex: 1 }}
|
||||
>{menuContent}</View>
|
||||
) : menuContent;
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
{mainMenu}
|
||||
{accessibleMenu}
|
||||
</Portal>
|
||||
);
|
||||
return <>
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 10,
|
||||
right: 10,
|
||||
}}
|
||||
>
|
||||
{menuButton}
|
||||
</View>
|
||||
<BottomDrawer
|
||||
visible={open}
|
||||
onDismiss={onDismiss}
|
||||
onShow={props.onMenuShow}
|
||||
>
|
||||
{props.menuContent}
|
||||
</BottomDrawer>
|
||||
</>;
|
||||
};
|
||||
|
||||
export default FloatingActionButton;
|
||||
export default connect()(FloatingActionButton);
|
||||
|
|
|
@ -0,0 +1,67 @@
|
|||
import * as React from 'react';
|
||||
import { Text, TouchableRipple } from 'react-native-paper';
|
||||
import Icon from '../Icon';
|
||||
import { themeStyle } from '../global-style';
|
||||
import { connect } from 'react-redux';
|
||||
import { AppState } from '../../utils/types';
|
||||
import { StyleSheet, View, ViewProps } from 'react-native';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
interface Props extends ViewProps {
|
||||
themeId: number;
|
||||
title: string;
|
||||
icon: string;
|
||||
onPress: ()=> void;
|
||||
}
|
||||
|
||||
const useStyles = (themeId: number) => {
|
||||
return useMemo(() => {
|
||||
const theme = themeStyle(themeId);
|
||||
|
||||
return StyleSheet.create({
|
||||
icon: {
|
||||
fontSize: 27,
|
||||
width: 44,
|
||||
height: 44,
|
||||
textAlign: 'center',
|
||||
overflow: 'hidden',
|
||||
|
||||
color: theme.color3,
|
||||
borderColor: theme.codeBorderColor, // TODO: Use a different theme variable
|
||||
borderRadius: 22,
|
||||
padding: 6,
|
||||
borderWidth: 2,
|
||||
backgroundColor: theme.backgroundColor3,
|
||||
},
|
||||
buttonContent: {
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
},
|
||||
button: {
|
||||
borderRadius: 8,
|
||||
padding: 8,
|
||||
},
|
||||
});
|
||||
}, [themeId]);
|
||||
};
|
||||
|
||||
const LabelledIconButton: React.FC<Props> = ({ title, icon, style, themeId, ...otherProps }) => {
|
||||
const styles = useStyles(themeId);
|
||||
return <TouchableRipple
|
||||
borderless={true}
|
||||
role='button'
|
||||
accessibilityRole='button'
|
||||
{...otherProps}
|
||||
style={[styles.button, style]}
|
||||
>
|
||||
<View style={styles.buttonContent}>
|
||||
<Icon style={styles.icon} accessibilityLabel={null} name={icon}/>
|
||||
<Text variant='labelMedium'>{title}</Text>
|
||||
</View>
|
||||
</TouchableRipple>;
|
||||
};
|
||||
|
||||
export default connect((state: AppState) => {
|
||||
return { themeId: state.settings.theme };
|
||||
})(LabelledIconButton);
|
|
@ -0,0 +1,68 @@
|
|||
import * as React from 'react';
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { Animated, StyleSheet, Pressable, ViewProps, PressableProps } from 'react-native';
|
||||
|
||||
interface Props {
|
||||
// Nodes that need to change opacity but shouldn't be included in the main touchable
|
||||
beforePressable: React.ReactNode;
|
||||
// Children of the main pressable
|
||||
children: React.ReactNode;
|
||||
onPress: ()=> void;
|
||||
|
||||
pressableProps?: PressableProps;
|
||||
containerProps?: ViewProps;
|
||||
}
|
||||
|
||||
// A TouchableOpacity that can contain multiple pressable items still within the region that
|
||||
// changes opacity
|
||||
const MultiTouchableOpacity: React.FC<Props> = props => {
|
||||
// See https://blog.logrocket.com/react-native-touchable-vs-pressable-components/
|
||||
// for more about animating Pressable buttons.
|
||||
const fadeAnim = useRef(new Animated.Value(1)).current;
|
||||
|
||||
const animationDuration = 100; // ms
|
||||
const onPressIn = useCallback(() => {
|
||||
// Fade out.
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 0.5,
|
||||
duration: animationDuration,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}, [fadeAnim]);
|
||||
const onPressOut = useCallback(() => {
|
||||
// Fade in.
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration: animationDuration,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}, [fadeAnim]);
|
||||
|
||||
const button = (
|
||||
<Pressable
|
||||
accessibilityRole='button'
|
||||
{...props.pressableProps}
|
||||
onPress={props.onPress}
|
||||
onPressIn={onPressIn}
|
||||
onPressOut={onPressOut}
|
||||
>
|
||||
{props.children}
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
const styles = useMemo(() => {
|
||||
return StyleSheet.create({
|
||||
container: { opacity: fadeAnim },
|
||||
});
|
||||
}, [fadeAnim]);
|
||||
|
||||
const containerProps = props.containerProps ?? {};
|
||||
return (
|
||||
<Animated.View {...containerProps} style={[styles.container, props.containerProps.style]}>
|
||||
{props.beforePressable}
|
||||
{button}
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
export default MultiTouchableOpacity;
|
|
@ -4,6 +4,7 @@ import { themeStyle } from '../global-style';
|
|||
import { Button, ButtonProps } from 'react-native-paper';
|
||||
import { connect } from 'react-redux';
|
||||
import { AppState } from '../../utils/types';
|
||||
import { TextStyle, StyleSheet, ViewStyle, StyleProp } from 'react-native';
|
||||
|
||||
export enum ButtonType {
|
||||
Primary,
|
||||
|
@ -12,9 +13,16 @@ export enum ButtonType {
|
|||
Link,
|
||||
}
|
||||
|
||||
interface Props extends Omit<ButtonProps, 'item'|'onPress'|'children'> {
|
||||
export enum ButtonSize {
|
||||
Normal,
|
||||
Larger,
|
||||
}
|
||||
|
||||
interface Props extends Omit<ButtonProps, 'item'|'onPress'|'children'|'style'> {
|
||||
themeId: number;
|
||||
type: ButtonType;
|
||||
size?: ButtonSize;
|
||||
style?: TextStyle;
|
||||
onPress: ()=> void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
@ -41,12 +49,25 @@ const useStyles = ({ themeId }: Props) => {
|
|||
primaryButton: { },
|
||||
};
|
||||
|
||||
return { themeOverride };
|
||||
return {
|
||||
themeOverride,
|
||||
styles: StyleSheet.create({
|
||||
largeContainer: {
|
||||
paddingVertical: 2,
|
||||
borderWidth: 2,
|
||||
borderRadius: 10,
|
||||
},
|
||||
largeLabel: {
|
||||
fontSize: theme.fontSize,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
}),
|
||||
};
|
||||
}, [themeId]);
|
||||
};
|
||||
|
||||
const TextButton: React.FC<Props> = props => {
|
||||
const { themeOverride } = useStyles(props);
|
||||
const { themeOverride, styles } = useStyles(props);
|
||||
|
||||
let mode: ButtonProps['mode'];
|
||||
let theme: ButtonProps['theme'];
|
||||
|
@ -68,8 +89,19 @@ const TextButton: React.FC<Props> = props => {
|
|||
return exhaustivenessCheck;
|
||||
}
|
||||
|
||||
let labelStyle: TextStyle|undefined = undefined;
|
||||
const containerStyle: StyleProp<ViewStyle>[] = [];
|
||||
if (props.size === ButtonSize.Larger) {
|
||||
labelStyle = styles.largeLabel;
|
||||
containerStyle.push(styles.largeContainer);
|
||||
}
|
||||
|
||||
if (props.style) containerStyle.push(props.style);
|
||||
|
||||
return <Button
|
||||
labelStyle={labelStyle}
|
||||
{...props}
|
||||
style={containerStyle}
|
||||
theme={theme}
|
||||
mode={mode}
|
||||
onPress={props.onPress}
|
||||
|
|
|
@ -8,6 +8,7 @@ const Color = require('color');
|
|||
const baseStyle = {
|
||||
appearance: 'light',
|
||||
fontSize: 16,
|
||||
fontSizeLarger: 18,
|
||||
fontSizeLarge: 20,
|
||||
margin: 15, // No text and no interactive component should be within this margin
|
||||
itemMarginTop: 10,
|
||||
|
|
|
@ -1646,7 +1646,7 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
|||
|
||||
if (this.state.mode === 'edit') return null;
|
||||
|
||||
return <FloatingActionButton mainButton={editButton} dispatch={this.props.dispatch} />;
|
||||
return <FloatingActionButton mainButton={editButton} />;
|
||||
};
|
||||
|
||||
// Save button is not really needed anymore with the improved save logic
|
||||
|
|
|
@ -0,0 +1,233 @@
|
|||
'use strict';
|
||||
Object.defineProperty(exports, '__esModule', { value: true });
|
||||
const React = require('react');
|
||||
const react_native_1 = require('react-native');
|
||||
const reducer_1 = require('@joplin/lib/reducer');
|
||||
const react_redux_1 = require('react-redux');
|
||||
const NoteList_1 = require('../NoteList');
|
||||
const Folder_1 = require('@joplin/lib/models/Folder');
|
||||
const Tag_1 = require('@joplin/lib/models/Tag');
|
||||
const Note_1 = require('@joplin/lib/models/Note');
|
||||
const Setting_1 = require('@joplin/lib/models/Setting');
|
||||
const global_style_1 = require('../global-style');
|
||||
const ScreenHeader_1 = require('../ScreenHeader');
|
||||
const locale_1 = require('@joplin/lib/locale');
|
||||
const FloatingActionButton_1 = require('../buttons/FloatingActionButton');
|
||||
const base_screen_1 = require('../base-screen');
|
||||
const trash_1 = require('@joplin/lib/services/trash');
|
||||
const AccessibleView_1 = require('../accessibility/AccessibleView');
|
||||
const DialogManager_1 = require('../DialogManager');
|
||||
const react_1 = require('react');
|
||||
class NotesScreenComponent extends base_screen_1.BaseScreenComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.onAppStateChangeSub_ = null;
|
||||
this.styles_ = {};
|
||||
this.onAppStateChange_ = async () => {
|
||||
// Force an update to the notes list when app state changes
|
||||
const newProps = { ...this.props };
|
||||
newProps.notesSource = '';
|
||||
await this.refreshNotes(newProps);
|
||||
};
|
||||
this.sortButton_press = async () => {
|
||||
const buttons = [];
|
||||
const sortNoteOptions = Setting_1.default.enumOptions('notes.sortOrder.field');
|
||||
for (const field in sortNoteOptions) {
|
||||
if (!sortNoteOptions.hasOwnProperty(field)) { continue; }
|
||||
buttons.push({
|
||||
text: sortNoteOptions[field],
|
||||
iconChecked: 'fas fa-circle',
|
||||
checked: Setting_1.default.value('notes.sortOrder.field') === field,
|
||||
id: { name: 'notes.sortOrder.field', value: field },
|
||||
});
|
||||
}
|
||||
buttons.push({
|
||||
text: `[ ${Setting_1.default.settingMetadata('notes.sortOrder.reverse').label()} ]`,
|
||||
checked: Setting_1.default.value('notes.sortOrder.reverse'),
|
||||
id: { name: 'notes.sortOrder.reverse', value: !Setting_1.default.value('notes.sortOrder.reverse') },
|
||||
});
|
||||
buttons.push({
|
||||
text: `[ ${Setting_1.default.settingMetadata('uncompletedTodosOnTop').label()} ]`,
|
||||
checked: Setting_1.default.value('uncompletedTodosOnTop'),
|
||||
id: { name: 'uncompletedTodosOnTop', value: !Setting_1.default.value('uncompletedTodosOnTop') },
|
||||
});
|
||||
buttons.push({
|
||||
text: `[ ${Setting_1.default.settingMetadata('showCompletedTodos').label()} ]`,
|
||||
checked: Setting_1.default.value('showCompletedTodos'),
|
||||
id: { name: 'showCompletedTodos', value: !Setting_1.default.value('showCompletedTodos') },
|
||||
});
|
||||
const r = await this.props.dialogManager.showMenu(Setting_1.default.settingMetadata('notes.sortOrder.field').label(), buttons);
|
||||
if (!r) { return; }
|
||||
Setting_1.default.setValue(r.name, r.value);
|
||||
};
|
||||
this.newNoteNavigate = async (folderId, isTodo) => {
|
||||
try {
|
||||
const newNote = await Note_1.default.save({
|
||||
parent_id: folderId,
|
||||
is_todo: isTodo ? 1 : 0,
|
||||
}, { provisional: true });
|
||||
this.props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'Note',
|
||||
noteId: newNote.id,
|
||||
});
|
||||
} catch (error) {
|
||||
alert((0, locale_1._)('Cannot create a new note: %s', error.message));
|
||||
}
|
||||
};
|
||||
}
|
||||
styles() {
|
||||
if (!this.styles_) { this.styles_ = {}; }
|
||||
const themeId = this.props.themeId;
|
||||
const cacheKey = themeId;
|
||||
if (this.styles_[cacheKey]) { return this.styles_[cacheKey]; }
|
||||
this.styles_ = {};
|
||||
const styles = {
|
||||
noteList: {
|
||||
flex: 1,
|
||||
},
|
||||
};
|
||||
this.styles_[cacheKey] = react_native_1.StyleSheet.create(styles);
|
||||
return this.styles_[cacheKey];
|
||||
}
|
||||
async componentDidMount() {
|
||||
await this.refreshNotes();
|
||||
this.onAppStateChangeSub_ = react_native_1.AppState.addEventListener('change', this.onAppStateChange_);
|
||||
}
|
||||
async componentWillUnmount() {
|
||||
if (this.onAppStateChangeSub_) { this.onAppStateChangeSub_.remove(); }
|
||||
}
|
||||
async componentDidUpdate(prevProps) {
|
||||
if (prevProps.notesOrder !== this.props.notesOrder || prevProps.selectedFolderId !== this.props.selectedFolderId || prevProps.selectedTagId !== this.props.selectedTagId || prevProps.selectedSmartFilterId !== this.props.selectedSmartFilterId || prevProps.notesParentType !== this.props.notesParentType || prevProps.uncompletedTodosOnTop !== this.props.uncompletedTodosOnTop || prevProps.showCompletedTodos !== this.props.showCompletedTodos) {
|
||||
await this.refreshNotes(this.props);
|
||||
}
|
||||
}
|
||||
async refreshNotes(props = null) {
|
||||
if (props === null) { props = this.props; }
|
||||
const options = {
|
||||
order: props.notesOrder,
|
||||
uncompletedTodosOnTop: props.uncompletedTodosOnTop,
|
||||
showCompletedTodos: props.showCompletedTodos,
|
||||
caseInsensitive: true,
|
||||
};
|
||||
const parent = this.parentItem(props);
|
||||
if (!parent) { return; }
|
||||
const source = JSON.stringify({
|
||||
options: options,
|
||||
parentId: parent.id,
|
||||
});
|
||||
if (source === props.notesSource) { return; }
|
||||
// For now, search refresh is handled by the search screen.
|
||||
if (props.notesParentType === 'Search') { return; }
|
||||
let notes = [];
|
||||
if (props.notesParentType === 'Folder') {
|
||||
notes = await Note_1.default.previews(props.selectedFolderId, options);
|
||||
} else if (props.notesParentType === 'Tag') {
|
||||
notes = await Tag_1.default.notes(props.selectedTagId, options);
|
||||
} else if (props.notesParentType === 'SmartFilter') {
|
||||
notes = await Note_1.default.previews(null, options);
|
||||
}
|
||||
this.props.dispatch({
|
||||
type: 'NOTE_UPDATE_ALL',
|
||||
notes: notes,
|
||||
notesSource: source,
|
||||
});
|
||||
}
|
||||
parentItem(props = null) {
|
||||
if (!props) { props = this.props; }
|
||||
let output = null;
|
||||
if (props.notesParentType === 'Folder') {
|
||||
output = Folder_1.default.byId(props.folders, props.selectedFolderId);
|
||||
} else if (props.notesParentType === 'Tag') {
|
||||
output = Tag_1.default.byId(props.tags, props.selectedTagId);
|
||||
} else if (props.notesParentType === 'SmartFilter') {
|
||||
output = { id: this.props.selectedSmartFilterId, title: (0, locale_1._)('All notes') };
|
||||
} else {
|
||||
return null;
|
||||
// throw new Error('Invalid parent type: ' + props.notesParentType);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
folderPickerOptions() {
|
||||
const options = {
|
||||
visible: this.props.noteSelectionEnabled,
|
||||
mustSelect: true,
|
||||
};
|
||||
if (this.folderPickerOptions_ && options.visible === this.folderPickerOptions_.visible) { return this.folderPickerOptions_; }
|
||||
this.folderPickerOptions_ = options;
|
||||
return this.folderPickerOptions_;
|
||||
}
|
||||
render() {
|
||||
const parent = this.parentItem();
|
||||
const theme = (0, global_style_1.themeStyle)(this.props.themeId);
|
||||
const rootStyle = this.props.visible ? theme.rootStyle : theme.hiddenRootStyle;
|
||||
const title = parent ? parent.title : null;
|
||||
if (!parent) {
|
||||
return (React.createElement(react_native_1.View, { style: rootStyle },
|
||||
React.createElement(ScreenHeader_1.ScreenHeader, { title: title, showSideMenuButton: true, showBackButton: false })));
|
||||
}
|
||||
const icon = Folder_1.default.unserializeIcon(parent.icon);
|
||||
const iconString = icon ? `${icon.emoji} ` : '';
|
||||
let buttonFolderId = this.props.selectedFolderId !== Folder_1.default.conflictFolderId() ? this.props.selectedFolderId : null;
|
||||
if (!buttonFolderId) { buttonFolderId = this.props.activeFolderId; }
|
||||
const addFolderNoteButtons = !!buttonFolderId;
|
||||
const makeActionButtonComp = () => {
|
||||
if ((this.props.notesParentType === 'Folder' && (0, trash_1.itemIsInTrash)(parent)) || !Folder_1.default.atLeastOneRealFolderExists(this.props.folders)) { return null; }
|
||||
if (addFolderNoteButtons && this.props.folders.length > 0) {
|
||||
const buttons = [];
|
||||
buttons.push({
|
||||
label: (0, locale_1._)('New to-do'),
|
||||
onPress: async () => {
|
||||
const isTodo = true;
|
||||
void this.newNoteNavigate(buttonFolderId, isTodo);
|
||||
},
|
||||
color: '#9b59b6',
|
||||
icon: 'checkbox-outline',
|
||||
});
|
||||
buttons.push({
|
||||
label: (0, locale_1._)('New note'),
|
||||
onPress: async () => {
|
||||
const isTodo = false;
|
||||
void this.newNoteNavigate(buttonFolderId, isTodo);
|
||||
},
|
||||
color: '#9b59b6',
|
||||
icon: 'document',
|
||||
});
|
||||
return React.createElement(FloatingActionButton_1.default, { buttons: buttons, dispatch: this.props.dispatch });
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const actionButtonComp = this.props.noteSelectionEnabled || !this.props.visible ? null : makeActionButtonComp();
|
||||
// Ensure that screen readers can't focus the notes list when it isn't visible.
|
||||
const accessibilityHidden = !this.props.visible;
|
||||
return (React.createElement(AccessibleView_1.default, { style: rootStyle, inert: accessibilityHidden },
|
||||
React.createElement(ScreenHeader_1.ScreenHeader, { title: iconString + title, showBackButton: false, sortButton_press: this.sortButton_press, folderPickerOptions: this.folderPickerOptions(), showSearchButton: true, showSideMenuButton: true }),
|
||||
React.createElement(NoteList_1.default, null),
|
||||
actionButtonComp));
|
||||
}
|
||||
}
|
||||
const NotesScreenWrapper = props => {
|
||||
const dialogManager = (0, react_1.useContext)(DialogManager_1.DialogContext);
|
||||
return React.createElement(NotesScreenComponent, { ...props, dialogManager: dialogManager });
|
||||
};
|
||||
const NotesScreen = (0, react_redux_1.connect)((state) => {
|
||||
return {
|
||||
folders: state.folders,
|
||||
tags: state.tags,
|
||||
activeFolderId: state.settings.activeFolderId,
|
||||
selectedFolderId: state.selectedFolderId,
|
||||
selectedNoteIds: state.selectedNoteIds,
|
||||
selectedTagId: state.selectedTagId,
|
||||
selectedSmartFilterId: state.selectedSmartFilterId,
|
||||
notesParentType: state.notesParentType,
|
||||
notes: state.notes,
|
||||
notesSource: state.notesSource,
|
||||
uncompletedTodosOnTop: state.settings.uncompletedTodosOnTop,
|
||||
showCompletedTodos: state.settings.showCompletedTodos,
|
||||
themeId: state.settings.theme,
|
||||
noteSelectionEnabled: state.noteSelectionEnabled,
|
||||
notesOrder: reducer_1.stateUtils.notesOrder(state.settings),
|
||||
};
|
||||
})(NotesScreenWrapper);
|
||||
exports.default = NotesScreen;
|
||||
// # sourceMappingURL=Notes.js.map
|
|
@ -0,0 +1,60 @@
|
|||
import * as React from 'react';
|
||||
import TestProviderStack from '../../testing/TestProviderStack';
|
||||
import NewNoteButton from './NewNoteButton';
|
||||
import { AppState } from '../../../utils/types';
|
||||
import { Store } from 'redux';
|
||||
import createMockReduxStore from '../../../utils/testing/createMockReduxStore';
|
||||
import setupGlobalStore from '../../../utils/testing/setupGlobalStore';
|
||||
import { act, render, screen, waitFor } from '@testing-library/react-native';
|
||||
import '@testing-library/jest-native/extend-expect';
|
||||
import { AccessibilityActionInfo } from 'react-native';
|
||||
import { setupDatabaseAndSynchronizer } from '@joplin/lib/testing/test-utils';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import NavService from '@joplin/lib/services/NavService';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
|
||||
let testStore: Store<AppState>;
|
||||
|
||||
interface WrappedNewNoteButtonProps {}
|
||||
|
||||
const WrappedNewNoteButton: React.FC<WrappedNewNoteButtonProps> = () => {
|
||||
return <TestProviderStack store={testStore}>
|
||||
<NewNoteButton/>
|
||||
</TestProviderStack>;
|
||||
};
|
||||
|
||||
describe('NewNoteButton', () => {
|
||||
beforeEach(async () => {
|
||||
await setupDatabaseAndSynchronizer(0);
|
||||
testStore = createMockReduxStore();
|
||||
setupGlobalStore(testStore);
|
||||
|
||||
// Set an initial folder
|
||||
const folder = await Folder.save({ title: 'Test folder' });
|
||||
Setting.setValue('activeFolderId', folder.id);
|
||||
await NavService.go('Notes', { folderId: folder.id });
|
||||
});
|
||||
|
||||
test('should be possible to create a note using accessibility actions', async () => {
|
||||
const wrapper = render(<WrappedNewNoteButton/>);
|
||||
|
||||
const toggleButton = screen.getByRole('button', { name: 'Add new' });
|
||||
expect(toggleButton).toBeVisible();
|
||||
|
||||
const actions: AccessibilityActionInfo[] = toggleButton.props.accessibilityActions;
|
||||
const newNoteAction = actions.find(action => action.label === 'New note');
|
||||
expect(newNoteAction).toBeTruthy();
|
||||
|
||||
const onAction = toggleButton.props.onAccessibilityAction;
|
||||
await act(() => {
|
||||
return onAction({ nativeEvent: { actionName: newNoteAction.name } });
|
||||
});
|
||||
|
||||
await waitFor(async () => {
|
||||
expect(await Note.allIds()).toHaveLength(1);
|
||||
});
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,140 @@
|
|||
import * as React from 'react';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import { Divider } from 'react-native-paper';
|
||||
import FloatingActionButton from '../../buttons/FloatingActionButton';
|
||||
import { AccessibilityActionEvent, AccessibilityActionInfo, StyleSheet, View } from 'react-native';
|
||||
import { AttachFileAction } from '../Note/commands/attachFile';
|
||||
import LabelledIconButton from '../../buttons/LabelledIconButton';
|
||||
import TextButton, { ButtonSize, ButtonType } from '../../buttons/TextButton';
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import focusView from '../../../utils/focusView';
|
||||
|
||||
const logger = Logger.create('NewNoteButton');
|
||||
|
||||
interface Props {
|
||||
|
||||
}
|
||||
|
||||
const makeNewNote = (isTodo: boolean, action?: AttachFileAction) => {
|
||||
logger.debug(`New ${isTodo ? 'to-do' : 'note'} with action`, action);
|
||||
const body = '';
|
||||
return CommandService.instance().execute('newNote', body, isTodo, { attachFileAction: action });
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
buttonRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 2,
|
||||
},
|
||||
mainButtonRow: {
|
||||
flexWrap: 'nowrap',
|
||||
},
|
||||
spacer: {
|
||||
flexShrink: 1,
|
||||
flexGrow: 0,
|
||||
width: 12,
|
||||
},
|
||||
shortcutButton: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
mainButton: {
|
||||
flexShrink: 1,
|
||||
},
|
||||
mainButtonLabel: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
menuContent: {
|
||||
gap: 12,
|
||||
flexShrink: 1,
|
||||
flexDirection: 'column',
|
||||
},
|
||||
});
|
||||
|
||||
const NewNoteButton: React.FC<Props> = _props => {
|
||||
const newNoteRef = useRef<View|null>(null);
|
||||
|
||||
const renderShortcutButton = (action: AttachFileAction, icon: string, title: string) => {
|
||||
return <LabelledIconButton
|
||||
onPress={() => makeNewNote(false, action)}
|
||||
style={styles.shortcutButton}
|
||||
title={title}
|
||||
accessibilityHint={_('Creates a new note with an attachment of type %s', title)}
|
||||
icon={icon}
|
||||
/>;
|
||||
};
|
||||
|
||||
const menuContent = <View style={styles.menuContent}>
|
||||
<View style={styles.buttonRow}>
|
||||
{renderShortcutButton(AttachFileAction.AttachFile, 'material attachment', _('Attachment'))}
|
||||
{renderShortcutButton(AttachFileAction.RecordAudio, 'material microphone', _('Recording'))}
|
||||
{renderShortcutButton(AttachFileAction.TakePhoto, 'material camera', _('Camera'))}
|
||||
{renderShortcutButton(AttachFileAction.AttachDrawing, 'material draw', _('Drawing'))}
|
||||
</View>
|
||||
<Divider/>
|
||||
<View style={[styles.buttonRow, styles.mainButtonRow]}>
|
||||
<TextButton
|
||||
icon='checkbox-outline'
|
||||
style={styles.mainButton}
|
||||
onPress={() => {
|
||||
void makeNewNote(true);
|
||||
}}
|
||||
type={ButtonType.Secondary}
|
||||
size={ButtonSize.Larger}
|
||||
>{_('New to-do')}</TextButton>
|
||||
<View style={styles.spacer}/>
|
||||
<TextButton
|
||||
touchableRef={newNoteRef}
|
||||
icon='file-document-outline'
|
||||
style={styles.mainButton}
|
||||
onPress={() => {
|
||||
void makeNewNote(false);
|
||||
}}
|
||||
type={ButtonType.Primary}
|
||||
size={ButtonSize.Larger}
|
||||
>{_('New note')}</TextButton>
|
||||
</View>
|
||||
</View>;
|
||||
|
||||
// Android and iOS: Accessibility actions simplify creating new notes and to-dos. These
|
||||
// are extra important because the "note with attachment" items are annoyingly first in
|
||||
// the focus order (and it doesn't seem possible to change this without adding a new
|
||||
// dependency).
|
||||
const accessibilityActions = useMemo((): AccessibilityActionInfo[] => {
|
||||
return [{
|
||||
name: 'new-note',
|
||||
label: _('New note'),
|
||||
}, {
|
||||
name: 'new-to-do',
|
||||
label: _('New to-do'),
|
||||
}];
|
||||
}, []);
|
||||
const onAccessibilityAction = useCallback((event: AccessibilityActionEvent) => {
|
||||
if (event.nativeEvent.actionName === 'new-note') {
|
||||
return makeNewNote(false);
|
||||
} else if (event.nativeEvent.actionName === 'new-to-do') {
|
||||
return makeNewNote(true);
|
||||
}
|
||||
return Promise.resolve();
|
||||
}, []);
|
||||
const onMenuShown = useCallback(() => {
|
||||
// Note: May apply only to web:
|
||||
focusView('NewNoteButton', newNoteRef.current);
|
||||
}, []);
|
||||
|
||||
return <FloatingActionButton
|
||||
mainButton={{
|
||||
icon: 'add',
|
||||
label: _('Add new'),
|
||||
}}
|
||||
menuContent={menuContent}
|
||||
onMenuShow={onMenuShown}
|
||||
accessibilityActions={accessibilityActions}
|
||||
onAccessibilityAction={onAccessibilityAction}
|
||||
/>;
|
||||
};
|
||||
|
||||
export default NewNoteButton;
|
|
@ -2,24 +2,24 @@ import * as React from 'react';
|
|||
import { AppState as RNAppState, View, StyleSheet, NativeEventSubscription, ViewStyle, TextStyle } from 'react-native';
|
||||
import { stateUtils } from '@joplin/lib/reducer';
|
||||
import { connect } from 'react-redux';
|
||||
import NoteList from '../NoteList';
|
||||
import NoteList from '../../NoteList';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import Tag from '@joplin/lib/models/Tag';
|
||||
import Note, { PreviewsOrder } from '@joplin/lib/models/Note';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { themeStyle } from '../global-style';
|
||||
import { FolderPickerOptions, ScreenHeader } from '../ScreenHeader';
|
||||
import { themeStyle } from '../../global-style';
|
||||
import { FolderPickerOptions, ScreenHeader } from '../../ScreenHeader';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import ActionButton from '../buttons/FloatingActionButton';
|
||||
import { BaseScreenComponent } from '../base-screen';
|
||||
import { AppState } from '../../utils/types';
|
||||
import { BaseScreenComponent } from '../../base-screen';
|
||||
import { AppState } from '../../../utils/types';
|
||||
import { FolderEntity, NoteEntity, TagEntity } from '@joplin/lib/services/database/types';
|
||||
import { itemIsInTrash } from '@joplin/lib/services/trash';
|
||||
import AccessibleView from '../accessibility/AccessibleView';
|
||||
import AccessibleView from '../../accessibility/AccessibleView';
|
||||
import { Dispatch } from 'redux';
|
||||
import { DialogContext, DialogControl } from '../DialogManager';
|
||||
import { DialogContext, DialogControl } from '../../DialogManager';
|
||||
import { useContext } from 'react';
|
||||
import { MenuChoice } from '../DialogManager/types';
|
||||
import { MenuChoice } from '../../DialogManager/types';
|
||||
import NewNoteButton from './NewNoteButton';
|
||||
|
||||
interface Props {
|
||||
dispatch: Dispatch;
|
||||
|
@ -252,27 +252,7 @@ class NotesScreenComponent extends BaseScreenComponent<ComponentProps, State> {
|
|||
if ((this.props.notesParentType === 'Folder' && itemIsInTrash(parent)) || !Folder.atLeastOneRealFolderExists(this.props.folders)) return null;
|
||||
|
||||
if (addFolderNoteButtons && this.props.folders.length > 0) {
|
||||
const buttons = [];
|
||||
buttons.push({
|
||||
label: _('New to-do'),
|
||||
onPress: async () => {
|
||||
const isTodo = true;
|
||||
void this.newNoteNavigate(buttonFolderId, isTodo);
|
||||
},
|
||||
color: '#9b59b6',
|
||||
icon: 'checkbox-outline',
|
||||
});
|
||||
|
||||
buttons.push({
|
||||
label: _('New note'),
|
||||
onPress: async () => {
|
||||
const isTodo = false;
|
||||
void this.newNoteNavigate(buttonFolderId, isTodo);
|
||||
},
|
||||
color: '#9b59b6',
|
||||
icon: 'document',
|
||||
});
|
||||
return <ActionButton buttons={buttons} dispatch={this.props.dispatch}/>;
|
||||
return <NewNoteButton />;
|
||||
}
|
||||
return null;
|
||||
};
|
|
@ -1,16 +1,17 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import { View, Text, Button, FlatList, TextStyle, StyleSheet } from 'react-native';
|
||||
import { View, Text, Button, FlatList, TextStyle, StyleSheet, Role } from 'react-native';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { connect } from 'react-redux';
|
||||
import { ScreenHeader } from '../ScreenHeader';
|
||||
import ReportService, { ReportSection } from '@joplin/lib/services/ReportService';
|
||||
import ReportService, { ReportItemType, ReportSection } from '@joplin/lib/services/ReportService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { BaseScreenComponent } from '../base-screen';
|
||||
import { themeStyle } from '../global-style';
|
||||
import { AppState } from '../../utils/types';
|
||||
import checkDisabledSyncItemsNotification from '@joplin/lib/services/synchronizer/utils/checkDisabledSyncItemsNotification';
|
||||
import { Dispatch } from 'redux';
|
||||
import Icon from '../Icon';
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
|
@ -21,6 +22,86 @@ interface State {
|
|||
report: ReportSection[];
|
||||
}
|
||||
|
||||
interface ProcessedLine {
|
||||
key: string;
|
||||
text?: string;
|
||||
isSection?: boolean;
|
||||
isDivider?: boolean;
|
||||
retryAllHandler?: ()=> void;
|
||||
retryHandler?: ()=> void;
|
||||
ignoreHandler?: ()=> void;
|
||||
listItems?: ProcessedLine[];
|
||||
}
|
||||
|
||||
type OnRefreshScreen = ()=> Promise<void>;
|
||||
|
||||
const processReport = (report: ReportSection[], refreshScreen: OnRefreshScreen, dispatch: Dispatch, baseStyle: TextStyle) => {
|
||||
const lines: ProcessedLine[] = [];
|
||||
let currentList: ProcessedLine[]|null = null;
|
||||
|
||||
for (let i = 0; i < report.length; i++) {
|
||||
const section = report[i];
|
||||
|
||||
let style: TextStyle = { ...baseStyle };
|
||||
style.fontWeight = 'bold';
|
||||
if (i > 0) style.paddingTop = 20;
|
||||
lines.push({ key: `section_${i}`, isSection: true, text: section.title });
|
||||
if (section.canRetryAll) {
|
||||
lines.push({ key: `retry_all_${i}`, text: '', retryAllHandler: section.retryAllHandler });
|
||||
}
|
||||
|
||||
for (const n in section.body) {
|
||||
if (!section.body.hasOwnProperty(n)) continue;
|
||||
style = { ...baseStyle };
|
||||
const item = section.body[n];
|
||||
|
||||
let text = '';
|
||||
|
||||
let retryHandler = null;
|
||||
let ignoreHandler = null;
|
||||
if (typeof item === 'object') {
|
||||
if (item.canRetry) {
|
||||
retryHandler = async () => {
|
||||
await item.retryHandler();
|
||||
await refreshScreen();
|
||||
};
|
||||
}
|
||||
if (item.canIgnore) {
|
||||
ignoreHandler = async () => {
|
||||
await item.ignoreHandler();
|
||||
await refreshScreen();
|
||||
await checkDisabledSyncItemsNotification((action) => dispatch(action));
|
||||
};
|
||||
}
|
||||
if (item.type === ReportItemType.OpenList) {
|
||||
currentList = [];
|
||||
} else if (item.type === ReportItemType.CloseList) {
|
||||
lines.push({ key: `list_${i}_${n}`, listItems: currentList });
|
||||
currentList = null;
|
||||
}
|
||||
text = item.text;
|
||||
} else {
|
||||
text = item;
|
||||
}
|
||||
|
||||
const line = { key: `item_${i}_${n}`, text: text, retryHandler, ignoreHandler };
|
||||
if (currentList) {
|
||||
// The OpenList item, for example, might be empty and should be skipped:
|
||||
const hasContent = line.text || retryHandler || ignoreHandler;
|
||||
if (hasContent) {
|
||||
currentList.push(line);
|
||||
}
|
||||
} else {
|
||||
lines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push({ key: `divider2_${i}`, isDivider: true });
|
||||
}
|
||||
|
||||
return lines;
|
||||
};
|
||||
|
||||
class StatusScreenComponent extends BaseScreenComponent<Props, State> {
|
||||
public constructor(props: Props) {
|
||||
super(props);
|
||||
|
@ -52,15 +133,11 @@ class StatusScreenComponent extends BaseScreenComponent<Props, State> {
|
|||
marginLeft: 2,
|
||||
marginRight: 2,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public override render() {
|
||||
const theme = themeStyle(this.props.themeId);
|
||||
const styles = this.styles();
|
||||
|
||||
const renderBody = (report: ReportSection[]) => {
|
||||
const baseStyle = {
|
||||
retryAllButton: {
|
||||
flexGrow: 0,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
baseStyle: {
|
||||
paddingLeft: 6,
|
||||
paddingRight: 6,
|
||||
paddingTop: 2,
|
||||
|
@ -68,98 +145,95 @@ class StatusScreenComponent extends BaseScreenComponent<Props, State> {
|
|||
flex: 0,
|
||||
color: theme.color,
|
||||
fontSize: theme.fontSize,
|
||||
};
|
||||
alignSelf: 'center',
|
||||
},
|
||||
listWrapper: {
|
||||
paddingBottom: 5,
|
||||
},
|
||||
listBullet: {
|
||||
fontSize: theme.fontSize / 3,
|
||||
color: theme.color,
|
||||
alignSelf: 'center',
|
||||
justifyContent: 'center',
|
||||
flexGrow: 0,
|
||||
marginStart: 12,
|
||||
marginEnd: 2,
|
||||
},
|
||||
divider: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.dividerColor,
|
||||
marginTop: 20,
|
||||
marginBottom: 20,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const lines = [];
|
||||
public override render() {
|
||||
const styles = this.styles();
|
||||
|
||||
for (let i = 0; i < report.length; i++) {
|
||||
const section = report[i];
|
||||
const renderItem = (item: ProcessedLine, inList: boolean) => {
|
||||
const style: TextStyle = { ...styles.baseStyle };
|
||||
|
||||
let style: TextStyle = { ...baseStyle };
|
||||
let textRole: Role|null = undefined;
|
||||
const text = item.text;
|
||||
if (item.isSection === true) {
|
||||
style.fontWeight = 'bold';
|
||||
if (i > 0) style.paddingTop = 20;
|
||||
lines.push({ key: `section_${i}`, isSection: true, text: section.title });
|
||||
if (section.canRetryAll) {
|
||||
lines.push({ key: `retry_all_${i}`, text: '', retryAllHandler: section.retryAllHandler });
|
||||
}
|
||||
|
||||
for (const n in section.body) {
|
||||
if (!section.body.hasOwnProperty(n)) continue;
|
||||
style = { ...baseStyle };
|
||||
const item = section.body[n];
|
||||
|
||||
let text = '';
|
||||
|
||||
let retryHandler = null;
|
||||
let ignoreHandler = null;
|
||||
if (typeof item === 'object') {
|
||||
if (item.canRetry) {
|
||||
retryHandler = async () => {
|
||||
await item.retryHandler();
|
||||
await this.refreshScreen();
|
||||
};
|
||||
}
|
||||
if (item.canIgnore) {
|
||||
ignoreHandler = async () => {
|
||||
await item.ignoreHandler();
|
||||
await this.refreshScreen();
|
||||
await checkDisabledSyncItemsNotification((action) => this.props.dispatch(action));
|
||||
};
|
||||
}
|
||||
text = item.text;
|
||||
} else {
|
||||
text = item;
|
||||
}
|
||||
|
||||
lines.push({ key: `item_${i}_${n}`, text: text, retryHandler, ignoreHandler });
|
||||
}
|
||||
|
||||
lines.push({ key: `divider2_${i}`, isDivider: true });
|
||||
style.marginBottom = 5;
|
||||
textRole = 'heading';
|
||||
} else if (inList) {
|
||||
textRole = 'listitem';
|
||||
}
|
||||
|
||||
style.flex = 1;
|
||||
|
||||
const retryAllButton = item.retryAllHandler ? (
|
||||
<View style={styles.retryAllButton}>
|
||||
<Button title={_('Retry All')} onPress={item.retryAllHandler} />
|
||||
</View>
|
||||
) : null;
|
||||
|
||||
const retryButton = item.retryHandler ? (
|
||||
<View style={styles.actionButton}>
|
||||
<Button title={_('Retry')} onPress={item.retryHandler} />
|
||||
</View>
|
||||
) : null;
|
||||
|
||||
const ignoreButton = item.ignoreHandler ? (
|
||||
<View style={styles.actionButton}>
|
||||
<Button title={_('Ignore')} onPress={item.ignoreHandler} />
|
||||
</View>
|
||||
) : null;
|
||||
|
||||
const textComponent = text ? <Text style={style} role={textRole}>{text}</Text> : null;
|
||||
if (item.isDivider) {
|
||||
return <View style={styles.divider} role='separator' key={item.key} />;
|
||||
} else if (item.listItems) {
|
||||
return <View role='list' style={styles.listWrapper} key={item.key}>
|
||||
{textComponent}
|
||||
{item.listItems.map(item => renderItem(item, true))}
|
||||
</View>;
|
||||
} else {
|
||||
return (
|
||||
<View style={{ flex: 1, flexDirection: 'row' }} key={item.key}>
|
||||
{inList ? <Icon style={styles.listBullet} name='fas fa-circle' accessibilityLabel={null} /> : null}
|
||||
{textComponent}
|
||||
{ignoreButton}
|
||||
{retryAllButton}
|
||||
{retryButton}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const renderBody = (report: ReportSection[]) => {
|
||||
const baseStyle = styles.baseStyle;
|
||||
const lines = processReport(report, () => this.refreshScreen(), this.props.dispatch, baseStyle);
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={lines}
|
||||
renderItem={({ item }) => {
|
||||
const style: TextStyle = { ...baseStyle };
|
||||
|
||||
if (item.isSection === true) {
|
||||
style.fontWeight = 'bold';
|
||||
style.marginBottom = 5;
|
||||
}
|
||||
|
||||
style.flex = 1;
|
||||
|
||||
const retryAllButton = item.retryAllHandler ? (
|
||||
<View style={{ flex: 0 }}>
|
||||
<Button title={_('Retry All')} onPress={item.retryAllHandler} />
|
||||
</View>
|
||||
) : null;
|
||||
|
||||
const retryButton = item.retryHandler ? (
|
||||
<View style={styles.actionButton}>
|
||||
<Button title={_('Retry')} onPress={item.retryHandler} />
|
||||
</View>
|
||||
) : null;
|
||||
|
||||
const ignoreButton = item.ignoreHandler ? (
|
||||
<View style={styles.actionButton}>
|
||||
<Button title={_('Ignore')} onPress={item.ignoreHandler} />
|
||||
</View>
|
||||
) : null;
|
||||
|
||||
if (item.isDivider) {
|
||||
return <View style={{ borderBottomWidth: 1, borderBottomColor: theme.dividerColor, marginTop: 20, marginBottom: 20 }} />;
|
||||
} else {
|
||||
return (
|
||||
<View style={{ flex: 1, flexDirection: 'row' }}>
|
||||
<Text style={style}>{item.text}</Text>
|
||||
{ignoreButton}
|
||||
{retryAllButton}
|
||||
{retryButton}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return renderItem(item, false);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -535,13 +535,13 @@
|
|||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 135;
|
||||
CURRENT_PROJECT_VERSION = 136;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Joplin/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.4;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 13.3.2;
|
||||
MARKETING_VERSION = 13.3.3;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
|
@ -567,12 +567,12 @@
|
|||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 135;
|
||||
CURRENT_PROJECT_VERSION = 136;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
INFOPLIST_FILE = Joplin/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.4;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 13.3.2;
|
||||
MARKETING_VERSION = 13.3.3;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
|
@ -758,14 +758,14 @@
|
|||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 135;
|
||||
CURRENT_PROJECT_VERSION = 136;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.4;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||
MARKETING_VERSION = 13.3.2;
|
||||
MARKETING_VERSION = 13.3.3;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_LDFLAGS = (
|
||||
|
@ -797,14 +797,14 @@
|
|||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 135;
|
||||
CURRENT_PROJECT_VERSION = 136;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.4;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||
MARKETING_VERSION = 13.3.2;
|
||||
MARKETING_VERSION = 13.3.3;
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
|
|
|
@ -1034,7 +1034,7 @@ PODS:
|
|||
- React-Core
|
||||
- react-native-netinfo (11.3.3):
|
||||
- React-Core
|
||||
- react-native-quick-crypto (0.7.5):
|
||||
- react-native-quick-crypto (0.7.12):
|
||||
- DoubleConversion
|
||||
- glog
|
||||
- hermes-engine
|
||||
|
@ -1709,7 +1709,7 @@ SPEC CHECKSUMS:
|
|||
react-native-image-picker: 10f58d521d8da62b9747cd107045bce399cf5a1e
|
||||
react-native-image-resizer: 24c5d06fae2176dc0caed4b6396e02befb44064a
|
||||
react-native-netinfo: 28c2462c85067fe653615f6f595673bdca93a287
|
||||
react-native-quick-crypto: 08ecf18a70a8a49dbd0b5d983afbd56bdd809f86
|
||||
react-native-quick-crypto: 38fde7e5ddfb667b54536c25e6a6ac6d404633d3
|
||||
react-native-rsa-native: a7931cdda1f73a8576a46d7f431378c5550f0c38
|
||||
react-native-saf-x: 318d0cdb38f4618bd7ef5840d4f5c12e097dfbe7
|
||||
react-native-safe-area-context: b72c4611af2e86d80a59ac76279043d8f75f454c
|
||||
|
|
|
@ -64,7 +64,7 @@
|
|||
"react-native-paper": "5.13.1",
|
||||
"react-native-popup-menu": "0.16.1",
|
||||
"react-native-quick-actions": "0.3.13",
|
||||
"react-native-quick-crypto": "0.7.5",
|
||||
"react-native-quick-crypto": "0.7.12",
|
||||
"react-native-rsa-native": "2.0.5",
|
||||
"react-native-safe-area-context": "4.10.8",
|
||||
"react-native-securerandom": "1.0.1",
|
||||
|
@ -92,7 +92,7 @@
|
|||
"@babel/preset-env": "7.24.7",
|
||||
"@babel/runtime": "7.24.7",
|
||||
"@joplin/tools": "~3.3",
|
||||
"@js-draw/material-icons": "1.27.2",
|
||||
"@js-draw/material-icons": "1.29.1",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
|
||||
"@react-native/babel-preset": "0.74.86",
|
||||
"@react-native/metro-config": "0.74.87",
|
||||
|
@ -118,7 +118,7 @@
|
|||
"jest": "29.7.0",
|
||||
"jest-environment-jsdom": "29.7.0",
|
||||
"jetifier": "2.0.0",
|
||||
"js-draw": "1.27.2",
|
||||
"js-draw": "1.29.2",
|
||||
"jsdom": "24.1.1",
|
||||
"nodemon": "3.1.7",
|
||||
"punycode": "2.3.1",
|
||||
|
|
|
@ -55,7 +55,7 @@ import Revision from '@joplin/lib/models/Revision';
|
|||
import RevisionService from '@joplin/lib/services/RevisionService';
|
||||
import JoplinDatabase from '@joplin/lib/JoplinDatabase';
|
||||
import Database from '@joplin/lib/database';
|
||||
import NotesScreen from './components/screens/Notes';
|
||||
import NotesScreen from './components/screens/Notes/Notes';
|
||||
import TagsScreen from './components/screens/tags';
|
||||
import ConfigScreen from './components/screens/ConfigScreen/ConfigScreen';
|
||||
const { FolderScreen } = require('./components/screens/folder.js');
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
import { focus } from '@joplin/lib/utils/focusHandler';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { AccessibilityInfo, findNodeHandle, Platform, UIManager, View } from 'react-native';
|
||||
|
||||
const logger = Logger.create('focusView');
|
||||
|
||||
const focusView = (source: string, view: View|HTMLElement) => {
|
||||
const autoFocus = () => {
|
||||
if (Platform.OS === 'web') {
|
||||
// react-native-web defines UIManager.focus for setting the keyboard focus. However,
|
||||
// this property is not available in standard react-native. As such, access it using type
|
||||
// narrowing:
|
||||
// eslint-disable-next-line no-restricted-properties
|
||||
if (!('focus' in UIManager) || typeof UIManager.focus !== 'function') {
|
||||
throw new Error('Failed to focus sidebar. UIManager.focus is not a function.');
|
||||
}
|
||||
|
||||
// Disable the "use focusHandler for all focus calls" rule -- UIManager.focus requires
|
||||
// an argument, which is not supported by focusHandler.
|
||||
// eslint-disable-next-line no-restricted-properties
|
||||
UIManager.focus(view);
|
||||
} else {
|
||||
const handle = findNodeHandle(view as View);
|
||||
if (handle !== null) {
|
||||
AccessibilityInfo.setAccessibilityFocus(handle);
|
||||
} else {
|
||||
logger.warn('Couldn\'t find a view to focus.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
focus(`focusView:${source}`, {
|
||||
focus: autoFocus,
|
||||
});
|
||||
};
|
||||
|
||||
export default focusView;
|
|
@ -0,0 +1,24 @@
|
|||
import { useMemo } from 'react';
|
||||
import { useWindowDimensions } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
const useSafeAreaPadding = () => {
|
||||
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
||||
const safeAreaInsets = useSafeAreaInsets();
|
||||
const isLandscape = windowWidth > windowHeight;
|
||||
return useMemo(() => {
|
||||
return isLandscape ? {
|
||||
paddingRight: safeAreaInsets.right,
|
||||
paddingLeft: safeAreaInsets.left,
|
||||
paddingTop: 15,
|
||||
paddingBottom: 15,
|
||||
} : {
|
||||
paddingTop: safeAreaInsets.top,
|
||||
paddingBottom: safeAreaInsets.bottom,
|
||||
paddingLeft: 0,
|
||||
paddingRight: 0,
|
||||
};
|
||||
}, [isLandscape, safeAreaInsets]);
|
||||
};
|
||||
|
||||
export default useSafeAreaPadding;
|
|
@ -7,6 +7,6 @@
|
|||
"io.github.personalizedrefrigerator.js-draw": {
|
||||
"cloneUrl": "https://github.com/personalizedrefrigerator/joplin-plugin-freehand-drawing.git",
|
||||
"branch": "main",
|
||||
"commit": "94a78496fac0b3bc7b0f6a896a982480adad3994"
|
||||
"commit": "49650407c1d56a2a0123d2d56d2387e48eb58415"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,12 +8,6 @@ This website is built using [Docusaurus 2](https://docusaurus.io/), a modern sta
|
|||
|
||||
From `packages/tools`, run `node website/processDocs.js --env dev`
|
||||
|
||||
### Getting the translations
|
||||
|
||||
```shell
|
||||
CROWDIN_PERSONAL_TOKEN=..... yarn crowdinDownload
|
||||
```
|
||||
|
||||
### Building the doc
|
||||
|
||||
From `packages/doc-builder`, run:
|
||||
|
@ -40,6 +34,23 @@ Alternatively, to test the doc website after it has been built, build it using o
|
|||
|
||||
Translation is done using https://crowdin.com/
|
||||
|
||||
### Uploading the string
|
||||
|
||||
```shell
|
||||
CROWDIN_PERSONAL_TOKEN=..... yarn crowdinUpload
|
||||
```
|
||||
|
||||
### Getting the translations
|
||||
|
||||
```shell
|
||||
CROWDIN_PERSONAL_TOKEN=..... yarn crowdinDownload
|
||||
```
|
||||
|
||||
### Adding a translation
|
||||
|
||||
- Make sure the translation is available in Crowdin
|
||||
- In `packages/doc-builder/docusaurus.config.js`, add the language code to `i18n.locales`
|
||||
|
||||
## Building for production
|
||||
|
||||
This is done in `release-website.sh` from the repository https://github.com/joplin/website/
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue