diff --git a/.eslintignore b/.eslintignore index 2949b5fb20a..1f9b8994309 100644 --- a/.eslintignore +++ b/.eslintignore @@ -353,13 +353,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 @@ -421,6 +424,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 diff --git a/.gitignore b/.gitignore index 23569b6e448..c5d9440f5a5 100644 --- a/.gitignore +++ b/.gitignore @@ -328,13 +328,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 @@ -396,6 +399,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 diff --git a/packages/app-desktop/gui/MainScreen.tsx b/packages/app-desktop/gui/MainScreen.tsx index a532772d0dd..90b870f4082 100644 --- a/packages/app-desktop/gui/MainScreen.tsx +++ b/packages/app-desktop/gui/MainScreen.tsx @@ -785,7 +785,7 @@ class MainScreenComponent extends React.Component { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied dispatch={this.props.dispatch as any} /> - + { 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 = { - 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
; }; diff --git a/packages/app-desktop/gui/PopupNotification/NotificationItem.tsx b/packages/app-desktop/gui/PopupNotification/NotificationItem.tsx new file mode 100644 index 00000000000..e1fc85e5eb3 --- /dev/null +++ b/packages/app-desktop/gui/PopupNotification/NotificationItem.tsx @@ -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 => { + 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 = ; + + return
  • + {iconClassName ? icon : null} +
    +
    + {props.children} +
    +
  • ; +}; + +export default NotificationItem; diff --git a/packages/app-desktop/gui/PopupNotification/PopupNotificationList.tsx b/packages/app-desktop/gui/PopupNotification/PopupNotificationList.tsx new file mode 100644 index 00000000000..56e9810b304 --- /dev/null +++ b/packages/app-desktop/gui/PopupNotification/PopupNotificationList.tsx @@ -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 = () => { + const popupSpecs = useContext(VisibleNotificationsContext); + const popups = []; + for (const spec of popupSpecs) { + if (spec.dismissed) continue; + + popups.push( + {spec.content()}, + ); + } + popups.reverse(); + + if (popups.length) { + return
      + {popups} +
    ; + } else { + return null; + } +}; + +export default PopupNotificationList; diff --git a/packages/app-desktop/gui/PopupNotification/PopupNotificationProvider.tsx b/packages/app-desktop/gui/PopupNotification/PopupNotificationProvider.tsx new file mode 100644 index 00000000000..324554045db --- /dev/null +++ b/packages/app-desktop/gui/PopupNotification/PopupNotificationProvider.tsx @@ -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(null); +export const VisibleNotificationsContext = createContext([]); + +interface Props { + children: React.ReactNode; +} + +interface PopupSpec { + key: string; + dismissAt?: number; + dismissed: boolean; + type: NotificationType; + content: ()=> React.ReactNode; +} + +const PopupNotificationProvider: React.FC = props => { + const [popupSpecs, setPopupSpecs] = useState([]); + 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 + + {props.children} + + ; +}; + +export default PopupNotificationProvider; diff --git a/packages/app-desktop/gui/PopupNotification/types.ts b/packages/app-desktop/gui/PopupNotification/types.ts new file mode 100644 index 00000000000..d6ea87b009d --- /dev/null +++ b/packages/app-desktop/gui/PopupNotification/types.ts @@ -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; +} diff --git a/packages/app-desktop/gui/Root.tsx b/packages/app-desktop/gui/Root.tsx index 25f39c90782..9fe3ccd9cb4 100644 --- a/packages/app-desktop/gui/Root.tsx +++ b/packages/app-desktop/gui/Root.tsx @@ -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 { return ( - - - - - - {this.renderSecondaryWindows()} - {this.renderModalMessage(this.modalDialogProps())} + + + + + + + {this.renderSecondaryWindows()} + {this.renderModalMessage(this.modalDialogProps())} + ); diff --git a/packages/app-desktop/gui/TrashNotification/TrashNotification.tsx b/packages/app-desktop/gui/TrashNotification/TrashNotification.tsx index 951e9965a41..dbebfd761f2 100644 --- a/packages/app-desktop/gui/TrashNotification/TrashNotification.tsx +++ b/packages/app-desktop/gui/TrashNotification/TrashNotification.tsx @@ -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; } -export default (props: Props) => { - const notyfContext = useContext(NotyfContext); - const notificationRef = useRef(null); - - const theme = useMemo(() => { - return themeStyle(props.themeId); - }, [props.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 onCancelClick = async (lastDeletion: StateLastDeletion) => { + if (lastDeletion.folderIds.length) { + await restoreItems(ModelType.Folder, lastDeletion.folderIds); + } - // 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.noteIds.length) { + await restoreItems(ModelType.Note, lastDeletion.noteIds); + } +}; - if (lastDeletion.folderIds.length) { - await restoreItems(ModelType.Folder, lastDeletion.folderIds); - } +export default (props: Props) => { + const popupManager = useContext(PopupNotificationContext); - if (lastDeletion.noteIds.length) { - await restoreItems(ModelType.Note, lastDeletion.noteIds); - } - }, [notyf]); + const lastDeletionNotificationTimeRef = useRef(); + lastDeletionNotificationTimeRef.current = props.lastDeletionNotificationTime; - 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} ${cancelLabel}`); - 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(() => ( + + ), { type: NotificationType.Success }); + notification.scheduleDismiss(); + }, [props.lastDeletion, props.dispatch, popupManager]); return
    ; }; diff --git a/packages/app-desktop/gui/TrashNotification/TrashNotificationMessage.tsx b/packages/app-desktop/gui/TrashNotification/TrashNotificationMessage.tsx new file mode 100644 index 00000000000..f60b601af68 --- /dev/null +++ b/packages/app-desktop/gui/TrashNotification/TrashNotificationMessage.tsx @@ -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 => { + const [cancelling, setCancelling] = useState(false); + const onCancel = useCallback(() => { + setCancelling(true); + props.onCancel(); + }, [props.onCancel]); + + return <> + {props.message} + {' '} + + ; +}; + +export default TrashNotificationMessage; diff --git a/packages/app-desktop/gui/TrashNotification/style.scss b/packages/app-desktop/gui/TrashNotification/style.scss deleted file mode 100644 index d6add8b9888..00000000000 --- a/packages/app-desktop/gui/TrashNotification/style.scss +++ /dev/null @@ -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); - } - - } - - } -} diff --git a/packages/app-desktop/gui/UpdateNotification/UpdateNotification.tsx b/packages/app-desktop/gui/UpdateNotification/UpdateNotification.tsx index 05ea6e5025f..63d3a662c95 100644 --- a/packages/app-desktop/gui/UpdateNotification/UpdateNotification.tsx +++ b/packages/app-desktop/gui/UpdateNotification/UpdateNotification.tsx @@ -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(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 = () => { + 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 = ` -
    - ${updateAvailableHtml} ${seeChangelogHtml} -
    - - + const notification = popupManager.createPopup(() => ( +
    + {_('A new update (%s) is available', info.version)} + +
    +
    -
    - `; - - 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 = ` -
    - ${noUpdateMessageHtml} + const notification = popupManager.createPopup(() => ( +
    + {_('No updates available')}
    - `; - - 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 ( diff --git a/packages/app-desktop/gui/UpdateNotification/style.scss b/packages/app-desktop/gui/UpdateNotification/style.scss index 65fbb36d03b..553a67b081e 100644 --- a/packages/app-desktop/gui/UpdateNotification/style.scss +++ b/packages/app-desktop/gui/UpdateNotification/style.scss @@ -1,27 +1,11 @@ .update-notification { - 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; - } + display: flex; + flex-direction: column; + align-items: flex-start; + + > .buttons { + display: flex; + gap: 10px; + margin-top: 8px; + } } \ No newline at end of file diff --git a/packages/app-desktop/gui/WindowCommandsAndDialogs/WindowCommandsAndDialogs.tsx b/packages/app-desktop/gui/WindowCommandsAndDialogs/WindowCommandsAndDialogs.tsx index f8984dfceee..870c6aa0076 100644 --- a/packages/app-desktop/gui/WindowCommandsAndDialogs/WindowCommandsAndDialogs.tsx +++ b/packages/app-desktop/gui/WindowCommandsAndDialogs/WindowCommandsAndDialogs.tsx @@ -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 => { const dialogInfo = PluginManager.instance().pluginDialogToShow(props.pluginsLegacy); const pluginDialog = !dialogInfo ? null : ; - const { noteContentPropertiesDialogOptions, notePropertiesDialogOptions, shareNoteDialogOptions, shareFolderDialogOptions, promptOptions } = dialogState; + const { + noteContentPropertiesDialogOptions, notePropertiesDialogOptions, shareNoteDialogOptions, shareFolderDialogOptions, promptOptions, + } = dialogState; return <> @@ -173,6 +176,8 @@ const WindowCommandsAndDialogs: React.FC = props => { buttons={promptOptions && 'buttons' in promptOptions ? promptOptions.buttons : null} inputType={promptOptions && 'inputType' in promptOptions ? promptOptions.inputType : null} /> + + ; }; diff --git a/packages/app-desktop/gui/styles/index.scss b/packages/app-desktop/gui/styles/index.scss index b87f736e29d..1c061eff6ef 100644 --- a/packages/app-desktop/gui/styles/index.scss +++ b/packages/app-desktop/gui/styles/index.scss @@ -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'; diff --git a/packages/app-desktop/gui/styles/link-button.scss b/packages/app-desktop/gui/styles/link-button.scss new file mode 100644 index 00000000000..e10d040dc3d --- /dev/null +++ b/packages/app-desktop/gui/styles/link-button.scss @@ -0,0 +1,13 @@ + +.link-button { + background: transparent; + border: none; + font-size: inherit; + font-weight: inherit; + color: inherit; + padding: 0; + margin: 0; + + text-decoration: underline; + cursor: pointer; +} diff --git a/packages/app-desktop/gui/styles/popup-notification-item.scss b/packages/app-desktop/gui/styles/popup-notification-item.scss new file mode 100644 index 00000000000..8a985e368fd --- /dev/null +++ b/packages/app-desktop/gui/styles/popup-notification-item.scss @@ -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; + } + } +} diff --git a/packages/app-desktop/gui/styles/popup-notification-list.scss b/packages/app-desktop/gui/styles/popup-notification-list.scss new file mode 100644 index 00000000000..fbf5a2f6486 --- /dev/null +++ b/packages/app-desktop/gui/styles/popup-notification-list.scss @@ -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; + } +} diff --git a/packages/app-desktop/index.html b/packages/app-desktop/index.html index d5c39a52cec..43e07a8c6a3 100644 --- a/packages/app-desktop/index.html +++ b/packages/app-desktop/index.html @@ -10,7 +10,6 @@ Joplin - @@ -19,6 +18,5 @@
    - diff --git a/packages/app-desktop/integration-tests/noteList.spec.ts b/packages/app-desktop/integration-tests/noteList.spec.ts index 2affee7e286..629182ba72f 100644 --- a/packages/app-desktop/integration-tests/noteList.spec.ts +++ b/packages/app-desktop/integration-tests/noteList.spec.ts @@ -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; diff --git a/packages/app-desktop/main.scss b/packages/app-desktop/main.scss index 73ec9ee7371..a1834aaec67 100644 --- a/packages/app-desktop/main.scss +++ b/packages/app-desktop/main.scss @@ -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; -} \ No newline at end of file diff --git a/packages/app-desktop/package.json b/packages/app-desktop/package.json index a93f66dcc28..eee557a5acc 100644 --- a/packages/app-desktop/package.json +++ b/packages/app-desktop/package.json @@ -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", diff --git a/packages/app-desktop/style.scss b/packages/app-desktop/style.scss index 6a0ddc6966f..8638ec29b22 100644 --- a/packages/app-desktop/style.scss +++ b/packages/app-desktop/style.scss @@ -9,7 +9,6 @@ @use 'gui/JoplinCloudLoginScreen.scss' as joplin-cloud-login-screen; @use 'gui/NoteListHeader/style.scss' as note-list-header; @use 'gui/UpdateNotification/style.scss' as update-notification; -@use 'gui/TrashNotification/style.scss' as trash-notification; @use 'gui/Sidebar/style.scss' as sidebar-styles; @use 'gui/NoteEditor/style.scss' as note-editor-styles; @use 'gui/KeymapConfig/style.scss' as keymap-styles; diff --git a/packages/tools/cspell/dictionary4.txt b/packages/tools/cspell/dictionary4.txt index d964d14681e..a452cf2f82e 100644 --- a/packages/tools/cspell/dictionary4.txt +++ b/packages/tools/cspell/dictionary4.txt @@ -90,8 +90,6 @@ signup activatable Prec titlewrapper -notyf -Notyf unresponded activeline Prec diff --git a/yarn.lock b/yarn.lock index b56ea2f4205..e3a895d20d6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8319,7 +8319,6 @@ __metadata: 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 @@ -35835,13 +35834,6 @@ __metadata: languageName: node linkType: hard -"notyf@npm:3.10.0": - version: 3.10.0 - resolution: "notyf@npm:3.10.0" - checksum: 6cc533fccb0d74e544edf10e82d2942975adc4c993a68c966694bbb451dc06056d02e8dced4ecfce2c4586682223759cb1f9f3e3f609c83458e99c2bf5494b00 - languageName: node - linkType: hard - "now-and-later@npm:^2.0.0": version: 2.0.1 resolution: "now-and-later@npm:2.0.1"