diff --git a/.eslintignore b/.eslintignore index a1987205bf..f84c1c3ac3 100644 --- a/.eslintignore +++ b/.eslintignore @@ -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 diff --git a/.gitignore b/.gitignore index ccd136a2e6..5e211bdab6 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/Assets/TinyMCE/JoplinLists/src/main/ts/actions/ToggleList.ts b/Assets/TinyMCE/JoplinLists/src/main/ts/actions/ToggleList.ts index d97845a413..60d97fbff4 100644 --- a/Assets/TinyMCE/JoplinLists/src/main/ts/actions/ToggleList.ts +++ b/Assets/TinyMCE/JoplinLists/src/main/ts/actions/ToggleList.ts @@ -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) { diff --git a/Assets/WebsiteAssets/images/sponsors/EssayWriterPro.png b/Assets/WebsiteAssets/images/sponsors/EssayWriterPro.png new file mode 100644 index 0000000000..3840bf5372 Binary files /dev/null and b/Assets/WebsiteAssets/images/sponsors/EssayWriterPro.png differ diff --git a/README.md b/README.md index 727035bd83..e777e75c68 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Please see the [donation page](https://github.com/laurent22/joplin/blob/dev/read # Sponsors - web design agency RealGambling.ca write an essay online with EssayPro casino without making any upfront cost Tiktok Rise + topagency RealGambling.ca write an essay online with EssayPro casino without making any upfront cost Tiktok Rise write my essay services by EssayWriter * * * diff --git a/crowdin.yml b/crowdin.yml index 9716fe9ec9..d9c685f620 100644 --- a/crowdin.yml +++ b/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 \ No newline at end of file diff --git a/packages/app-cli/tests/ocr_samples/low_confidence_testing.png b/packages/app-cli/tests/ocr_samples/low_confidence_testing.png new file mode 100644 index 0000000000..97eff62e77 Binary files /dev/null and b/packages/app-cli/tests/ocr_samples/low_confidence_testing.png differ diff --git a/packages/app-desktop/ElectronAppWrapper.ts b/packages/app-desktop/ElectronAppWrapper.ts index 53c6aa7bf6..004680dcba 100644 --- a/packages/app-desktop/ElectronAppWrapper.ts +++ b/packages/app-desktop/ElectronAppWrapper.ts @@ -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', () => { diff --git a/packages/app-desktop/app.ts b/packages/app-desktop/app.ts index d43d25fb0e..1b3d75c01d 100644 --- a/packages/app-desktop/app.ts +++ b/packages/app-desktop/app.ts @@ -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')); diff --git a/packages/app-desktop/bridge.ts b/packages/app-desktop/bridge.ts index 9d4302673e..7439a9f688 100644 --- a/packages/app-desktop/bridge.ts +++ b/packages/app-desktop/bridge.ts @@ -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 ; } diff --git a/packages/app-desktop/services/plugins/UserWebviewDialog.tsx b/packages/app-desktop/services/plugins/UserWebviewDialog.tsx index 6c74eb419d..8d7b86b6db 100644 --- a/packages/app-desktop/services/plugins/UserWebviewDialog.tsx +++ b/packages/app-desktop/services/plugins/UserWebviewDialog.tsx @@ -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); }, diff --git a/packages/app-desktop/services/plugins/UserWebviewIndex.js b/packages/app-desktop/services/plugins/UserWebviewIndex.js index b2d22f8492..8ad9681ea7 100644 --- a/packages/app-desktop/services/plugins/UserWebviewIndex.js +++ b/packages/app-desktop/services/plugins/UserWebviewIndex.js @@ -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, + }); }); }); })(); diff --git a/packages/app-desktop/services/plugins/hooks/useContentSize.ts b/packages/app-desktop/services/plugins/hooks/useContentSize.ts index 54382eb6e1..4a5156f6e9 100644 --- a/packages/app-desktop/services/plugins/hooks/useContentSize.ts +++ b/packages/app-desktop/services/plugins/hooks/useContentSize.ts @@ -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, htmlHash: string, minWidth: number, minHeight: number) { const [contentSize, setContentSize] = useState({ 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; } diff --git a/packages/app-desktop/services/plugins/hooks/useFormData.ts b/packages/app-desktop/services/plugins/hooks/useFormData.ts new file mode 100644 index 0000000000..337495b5a7 --- /dev/null +++ b/packages/app-desktop/services/plugins/hooks/useFormData.ts @@ -0,0 +1,39 @@ +import { RefObject, useMemo, useRef } from 'react'; +import { PostMessage } from '../types'; +import useMessageHandler from './useMessageHandler'; + +type FormDataRecord = Record; +type FormDataListener = (formData: FormDataRecord)=> void; + +const useFormData = (viewRef: RefObject, postMessage: PostMessage) => { + const formDataListenersRef = useRef([]); + 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(resolve => { + postMessage('serializeForms', null); + formDataListenersRef.current.push((data) => { + resolve(data); + }); + }); + }, + }; + }, [postMessage]); +}; + +export default useFormData; diff --git a/packages/app-desktop/services/plugins/hooks/useHtmlLoader.ts b/packages/app-desktop/services/plugins/hooks/useHtmlLoader.ts index 8affdf09a2..a5c439a4d2 100644 --- a/packages/app-desktop/services/plugins/hooks/useHtmlLoader.ts +++ b/packages/app-desktop/services/plugins/hooks/useHtmlLoader.ts @@ -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, 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 diff --git a/packages/app-desktop/services/plugins/hooks/useMessageHandler.ts b/packages/app-desktop/services/plugins/hooks/useMessageHandler.ts new file mode 100644 index 0000000000..7b38da0abd --- /dev/null +++ b/packages/app-desktop/services/plugins/hooks/useMessageHandler.ts @@ -0,0 +1,27 @@ +import { RefObject, useEffect, useRef } from 'react'; + +type OnMessage = (event: MessageEvent)=> void; + +const useMessageHandler = (viewRef: RefObject, 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; diff --git a/packages/app-desktop/services/plugins/hooks/useScriptLoader.ts b/packages/app-desktop/services/plugins/hooks/useScriptLoader.ts index 37874827b8..4008fadb3f 100644 --- a/packages/app-desktop/services/plugins/hooks/useScriptLoader.ts +++ b/packages/app-desktop/services/plugins/hooks/useScriptLoader.ts @@ -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]); } diff --git a/packages/app-desktop/services/plugins/hooks/useSubmitHandler.ts b/packages/app-desktop/services/plugins/hooks/useSubmitHandler.ts index 49a5b49198..d25eab8470 100644 --- a/packages/app-desktop/services/plugins/hooks/useSubmitHandler.ts +++ b/packages/app-desktop/services/plugins/hooks/useSubmitHandler.ts @@ -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, 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]); + }); } diff --git a/packages/app-desktop/services/plugins/hooks/useViewIsReady.ts b/packages/app-desktop/services/plugins/hooks/useViewIsReady.ts index f3141d84cc..c28997fef3 100644 --- a/packages/app-desktop/services/plugins/hooks/useViewIsReady.ts +++ b/packages/app-desktop/services/plugins/hooks/useViewIsReady.ts @@ -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) { // 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; } diff --git a/packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.ts b/packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.ts index ca63c174a4..6f362097d4 100644 --- a/packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.ts +++ b/packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.ts @@ -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, 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]); } diff --git a/packages/app-desktop/services/plugins/types.ts b/packages/app-desktop/services/plugins/types.ts new file mode 100644 index 0000000000..f7ed9fc593 --- /dev/null +++ b/packages/app-desktop/services/plugins/types.ts @@ -0,0 +1 @@ +export type PostMessage = (message: string, args: unknown)=> void; diff --git a/packages/app-desktop/style.scss b/packages/app-desktop/style.scss index 6a0ddc6966..8638ec29b2 100644 --- a/packages/app-desktop/style.scss +++ b/packages/app-desktop/style.scss @@ -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; diff --git a/packages/app-desktop/utils/customProtocols/constants.ts b/packages/app-desktop/utils/customProtocols/constants.ts index 64ed9ee3e0..6a5853f54a 100644 --- a/packages/app-desktop/utils/customProtocols/constants.ts +++ b/packages/app-desktop/utils/customProtocols/constants.ts @@ -1,3 +1,2 @@ - // eslint-disable-next-line import/prefer-default-export export const contentProtocolName = 'joplin-content'; diff --git a/packages/app-desktop/utils/customProtocols/handleCustomProtocols.test.ts b/packages/app-desktop/utils/customProtocols/handleCustomProtocols.test.ts index a5a488df42..e066a635b6 100644 --- a/packages/app-desktop/utils/customProtocols/handleCustomProtocols.test.ts +++ b/packages/app-desktop/utils/customProtocols/handleCustomProtocols.test.ts @@ -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) => { diff --git a/packages/app-desktop/utils/customProtocols/handleCustomProtocols.ts b/packages/app-desktop/utils/customProtocols/handleCustomProtocols.ts index 354be211ce..6bd1996a0a 100644 --- a/packages/app-desktop/utils/customProtocols/handleCustomProtocols.ts +++ b/packages/app-desktop/utils/customProtocols/handleCustomProtocols.ts @@ -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; diff --git a/packages/app-mobile/android/app/build.gradle b/packages/app-mobile/android/app/build.gradle index 38d41b4968..42690899f8 100644 --- a/packages/app-mobile/android/app/build.gradle +++ b/packages/app-mobile/android/app/build.gradle @@ -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" } diff --git a/packages/app-mobile/components/BottomDrawer.tsx b/packages/app-mobile/components/BottomDrawer.tsx new file mode 100644 index 0000000000..fd8c2f3cc7 --- /dev/null +++ b/packages/app-mobile/components/BottomDrawer.tsx @@ -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 => { + const theme = themeStyle(props.themeId); + const styles = useStyles(theme); + + const onContainerScroll = useCallback((event: NativeSyntheticEvent) => { + const offsetY = event.nativeEvent.contentOffset.y; + if (offsetY < -50) { + props.onDismiss(); + } + }, [props.onDismiss]); + + return + + {props.children} + + ; +}; + +export default connect((state: AppState) => { + return { + themeId: state.settings.theme, + }; +})(BottomDrawer); diff --git a/packages/app-mobile/components/Checkbox.tsx b/packages/app-mobile/components/Checkbox.tsx index de7cc24628..d34383888b 100644 --- a/packages/app-mobile/components/Checkbox.tsx +++ b/packages/app-mobile/components/Checkbox.tsx @@ -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 => { 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 => { accessibilityRole="checkbox" accessibilityState={accessibilityState} accessibilityLabel={props.accessibilityLabel ?? ''} + accessibilityHint={props.accessibilityHint} // Web requires aria-checked aria-checked={checked} > diff --git a/packages/app-mobile/components/DialogManager/index.tsx b/packages/app-mobile/components/DialogManager/index.tsx index 4b49929194..5ebf14b9e1 100644 --- a/packages/app-mobile/components/DialogManager/index.tsx +++ b/packages/app-mobile/components/DialogManager/index.tsx @@ -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(null); @@ -49,6 +50,7 @@ const DialogManager: React.FC = props => { }; }, []); + const theme = themeStyle(props.themeId); const styles = useStyles(); const dialogComponents: React.ReactNode[] = []; @@ -73,7 +75,7 @@ const DialogManager: React.FC = 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} > diff --git a/packages/app-mobile/components/DismissibleDialog.tsx b/packages/app-mobile/components/DismissibleDialog.tsx index a8c429775a..618f9a8083 100644 --- a/packages/app-mobile/components/DismissibleDialog.tsx +++ b/packages/app-mobile/components/DismissibleDialog.tsx @@ -69,6 +69,7 @@ const useStyles = (themeId: number, containerStyle: ViewStyle, size: DialogSize) }; const DismissibleDialog: React.FC = 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 => { onRequestClose={props.onDismiss} containerStyle={styles.dialogContainer} animationType='fade' - backgroundColor='rgba(0, 0, 0, 0.1)' + backgroundColor={theme.backgroundColorTransparent2} transparent={true} > diff --git a/packages/app-mobile/components/EditorToolbar/utils/allToolbarCommandNamesFromState.ts b/packages/app-mobile/components/EditorToolbar/utils/allToolbarCommandNamesFromState.ts index e6b0d8806f..e0a78c80c3 100644 --- a/packages/app-mobile/components/EditorToolbar/utils/allToolbarCommandNamesFromState.ts +++ b/packages/app-mobile/components/EditorToolbar/utils/allToolbarCommandNamesFromState.ts @@ -22,6 +22,8 @@ const builtInCommandNames = [ '-', EditorCommandType.IndentLess, EditorCommandType.IndentMore, + `editor.${EditorCommandType.SwapLineDown}`, + `editor.${EditorCommandType.SwapLineUp}`, '-', 'insertDateTime', '-', diff --git a/packages/app-mobile/components/EditorToolbar/utils/selectedCommandNamesFromState.ts b/packages/app-mobile/components/EditorToolbar/utils/selectedCommandNamesFromState.ts index c4a3ec67e1..f955f0c345 100644 --- a/packages/app-mobile/components/EditorToolbar/utils/selectedCommandNamesFromState.ts +++ b/packages/app-mobile/components/EditorToolbar/utils/selectedCommandNamesFromState.ts @@ -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. diff --git a/packages/app-mobile/components/Modal.tsx b/packages/app-mobile/components/Modal.tsx index 95f35a2e8b..8d70fbf1f3 100644 --- a/packages/app-mobile/components/Modal.tsx +++ b/packages/app-mobile/components/Modal.tsx @@ -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) => { @@ -114,9 +109,11 @@ const ModalElement: React.FC = ({ 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 = ({ 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 ? : null; + const contentAndBackdrop = @@ -153,6 +150,7 @@ const ModalElement: React.FC = ({ {closeButton} ; + const extraScrollViewProps = (typeof scrollOverflow === 'object' ? scrollOverflow : {}); return ( = ({ > {scrollOverflow ? ( {contentAndBackdrop} ) : contentAndBackdrop} diff --git a/packages/app-mobile/components/NoteEditor/commandDeclarations.ts b/packages/app-mobile/components/NoteEditor/commandDeclarations.ts index 5ea3b46d38..470ce195e4 100644 --- a/packages/app-mobile/components/NoteEditor/commandDeclarations.ts +++ b/packages/app-mobile/components/NoteEditor/commandDeclarations.ts @@ -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'), diff --git a/packages/app-mobile/components/NoteItem.tsx b/packages/app-mobile/components/NoteItem.tsx index d383e7744d..795212c19c 100644 --- a/packages/app-mobile/components/NoteItem.tsx +++ b/packages/app-mobile/components/NoteItem.tsx @@ -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 = 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 = 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 ? : 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 ( - - - - {isTodo ? : null } - {noteTitle} - - - + {noteTitle} + ); }); diff --git a/packages/app-mobile/components/accessibility/AccessibleModalMenu.js b/packages/app-mobile/components/accessibility/AccessibleModalMenu.js new file mode 100644 index 0000000000..bfbb3423fa --- /dev/null +++ b/packages/app-mobile/components/accessibility/AccessibleModalMenu.js @@ -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 diff --git a/packages/app-mobile/components/accessibility/AccessibleModalMenu.tsx b/packages/app-mobile/components/accessibility/AccessibleModalMenu.tsx deleted file mode 100644 index 32dc20eddf..0000000000 --- a/packages/app-mobile/components/accessibility/AccessibleModalMenu.tsx +++ /dev/null @@ -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 => { - 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( - - {action.label} - , - ); - } - - const modal = ( - - {options} - {_('Close menu')} - - ); - - return - {modal} - {props.label} - ; -}; - -export default AccessibleModalMenu; diff --git a/packages/app-mobile/components/accessibility/AccessibleView.tsx b/packages/app-mobile/components/accessibility/AccessibleView.tsx index 050c77ff14..668d16afdc 100644 --- a/packages/app-mobile/components/accessibility/AccessibleView.tsx +++ b/packages/app-mobile/components/accessibility/AccessibleView.tsx @@ -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(); diff --git a/packages/app-mobile/components/app-nav.tsx b/packages/app-mobile/components/app-nav.tsx index 1a44d1eb6f..f56ad87af3 100644 --- a/packages/app-mobile/components/app-nav.tsx +++ b/packages/app-mobile/components/app-nav.tsx @@ -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'; diff --git a/packages/app-mobile/components/buttons/FloatingActionButton.tsx b/packages/app-mobile/components/buttons/FloatingActionButton.tsx index 8e241f97e4..08fa6c7461 100644 --- a/packages/app-mobile/components/buttons/FloatingActionButton.tsx +++ b/packages/app-mobile/components/buttons/FloatingActionButton.tsx @@ -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; - 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; + 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(); 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 ? ( - - ) : null; - - const menuContent = ; - const mainMenu = isWeb ? ( - {menuContent} - ) : menuContent; - return ( - - {mainMenu} - {accessibleMenu} - - ); + return <> + + {menuButton} + + + {props.menuContent} + + ; }; -export default FloatingActionButton; +export default connect()(FloatingActionButton); diff --git a/packages/app-mobile/components/buttons/LabelledIconButton.tsx b/packages/app-mobile/components/buttons/LabelledIconButton.tsx new file mode 100644 index 0000000000..fdbfb116aa --- /dev/null +++ b/packages/app-mobile/components/buttons/LabelledIconButton.tsx @@ -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 = ({ title, icon, style, themeId, ...otherProps }) => { + const styles = useStyles(themeId); + return + + + {title} + + ; +}; + +export default connect((state: AppState) => { + return { themeId: state.settings.theme }; +})(LabelledIconButton); diff --git a/packages/app-mobile/components/buttons/MultiTouchableOpacity.tsx b/packages/app-mobile/components/buttons/MultiTouchableOpacity.tsx new file mode 100644 index 0000000000..6caeac37c2 --- /dev/null +++ b/packages/app-mobile/components/buttons/MultiTouchableOpacity.tsx @@ -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 => { + // 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 = ( + + {props.children} + + ); + + const styles = useMemo(() => { + return StyleSheet.create({ + container: { opacity: fadeAnim }, + }); + }, [fadeAnim]); + + const containerProps = props.containerProps ?? {}; + return ( + + {props.beforePressable} + {button} + + ); +}; + +export default MultiTouchableOpacity; diff --git a/packages/app-mobile/components/buttons/TextButton.tsx b/packages/app-mobile/components/buttons/TextButton.tsx index acc93b574e..dbc7da4072 100644 --- a/packages/app-mobile/components/buttons/TextButton.tsx +++ b/packages/app-mobile/components/buttons/TextButton.tsx @@ -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 { +export enum ButtonSize { + Normal, + Larger, +} + +interface Props extends Omit { 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 => { - 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 => { return exhaustivenessCheck; } + let labelStyle: TextStyle|undefined = undefined; + const containerStyle: StyleProp[] = []; + if (props.size === ButtonSize.Larger) { + labelStyle = styles.largeLabel; + containerStyle.push(styles.largeContainer); + } + + if (props.style) containerStyle.push(props.style); + return