1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-08-10 22:11:50 +02:00

Desktop: Improve notification accessibility (#11752)

This commit is contained in:
Henry Heino
2025-04-07 12:12:40 -07:00
committed by GitHub
parent a29e30e442
commit 5280ec12cd
27 changed files with 571 additions and 295 deletions

View File

@@ -355,13 +355,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
@@ -423,6 +426,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

6
.gitignore vendored
View File

@@ -330,13 +330,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
@@ -398,6 +401,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

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

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

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

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

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

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

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

@@ -90,8 +90,6 @@ signup
activatable
Prec
titlewrapper
notyf
Notyf
unresponded
activeline
Prec

View File

@@ -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
@@ -35814,13 +35813,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"