Merge remote-tracking branch 'upstream/dev' into pr/desktop/fix-editor-plugins-with-multi-window-support

pull/12041/head
Henry Heino 2025-04-16 16:13:48 -07:00
commit ccf342f35f
143 changed files with 4236 additions and 2606 deletions

View File

@ -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

23
.gitignore vendored
View File

@ -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

View File

@ -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

View File

@ -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&amp;mtm_kwd=joplinapp&amp;mtm_source=joplinapp-webseite&amp;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&amp;mtm_kwd=joplinapp&amp;mtm_source=joplinapp-webseite&amp;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 -->
* * *

View File

@ -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

View File

@ -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', () => {

View File

@ -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'));

View File

@ -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

View File

@ -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,

View File

@ -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',
};
};

View File

@ -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',

View File

@ -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}

View File

@ -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,
],
};

View File

@ -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 () => {};

View File

@ -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) {

View File

@ -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]);

View File

@ -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;

View File

@ -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,

View File

@ -36,7 +36,6 @@ const incompatiblePluginIds = [
// cSpell:disable
'com.septemberhx.Joplin.Enhancement',
'ylc395.noteLinkSystem',
'outline',
'joplin.plugin.cmoptions',
'com.asdibiase.joplin-languagetool',
// cSpell:enable

View File

@ -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) {

View File

@ -131,6 +131,7 @@ export interface NoteBodyEditorProps {
onDrop: DropHandler;
noteToolbarButtonInfos: ToolbarItem[];
plugins: PluginStates;
mathEnabled: boolean;
fontSize: number;
contentMaxWidth: number;
isSafeMode: boolean;

View File

@ -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

View File

@ -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.
},
],
}),
);

View File

@ -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' }}/>;
};

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;
}

View File

@ -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>
);

View File

@ -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' }}/>;
};

View File

@ -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;

View File

@ -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);
}
}
}
}

View File

@ -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 (

View File

@ -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;
}
}

View File

@ -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/>
</>;
};

View File

@ -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.

View File

@ -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;

View File

@ -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';

View File

@ -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;
}

View File

@ -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;
}
}
}

View File

@ -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;
}
}

View File

@ -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>

View File

@ -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() {

View File

@ -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;

View File

@ -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();

View File

@ -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,
}));
},
});
},
});

View File

@ -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('');
});
});

View File

@ -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;
}

View File

@ -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",

View File

@ -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>;
}

View File

@ -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);
},

View File

@ -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,
});
});
});
})();

View File

@ -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;
}

View File

@ -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;

View File

@ -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

View File

@ -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;

View File

@ -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]);
}

View File

@ -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]);
});
}

View File

@ -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;
}

View File

@ -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]);
}

View File

@ -0,0 +1 @@
export type PostMessage = (message: string, args: unknown)=> void;

View File

@ -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;

View File

@ -1,3 +1,2 @@
// eslint-disable-next-line import/prefer-default-export
export const contentProtocolName = 'joplin-content';

View File

@ -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) => {

View File

@ -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;

View File

@ -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"
}

View File

@ -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);

View File

@ -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}
>

View File

@ -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}
>

View File

@ -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}>

View File

@ -22,6 +22,8 @@ const builtInCommandNames = [
'-',
EditorCommandType.IndentLess,
EditorCommandType.IndentMore,
`editor.${EditorCommandType.SwapLineDown}`,
`editor.${EditorCommandType.SwapLineUp}`,
'-',
'insertDateTime',
'-',

View File

@ -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.

View File

@ -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>

View File

@ -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'),

View File

@ -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>
);
});

View File

@ -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

View File

@ -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;

View File

@ -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();

View File

@ -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';

View File

@ -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);

View File

@ -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);

View File

@ -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;

View File

@ -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}

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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();
});
});

View File

@ -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;

View File

@ -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;
};

View File

@ -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);
}}
/>
);

View File

@ -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)",

View File

@ -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

View File

@ -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",

View File

@ -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');

View File

@ -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;

View File

@ -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;

View File

@ -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"
}
}

View File

@ -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