You've already forked joplin
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:
@@ -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
6
.gitignore
vendored
@@ -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
|
||||
|
@@ -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}
|
||||
|
@@ -1,20 +0,0 @@
|
||||
// Based on https://github.com/caroso1222/notyf/blob/master/recipes/react.md
|
||||
|
||||
import * as React from 'react';
|
||||
import { Notyf } from 'notyf';
|
||||
import { ToastType } from '@joplin/lib/services/plugins/api/types';
|
||||
|
||||
export default React.createContext(
|
||||
new Notyf({
|
||||
// Set your global Notyf configuration here
|
||||
duration: 6000,
|
||||
types: [
|
||||
{
|
||||
type: ToastType.Info,
|
||||
icon: false,
|
||||
className: 'notyf__toast--info',
|
||||
background: 'blue', // Need to set a background, otherwise Notyf won't create the background element. But the color will be overriden in CSS.
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
@@ -1,8 +1,8 @@
|
||||
import { useContext, useMemo } from 'react';
|
||||
import NotyfContext from '../NotyfContext';
|
||||
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
|
||||
import * as React from 'react';
|
||||
import { useContext, useEffect, useMemo } from 'react';
|
||||
import { Toast, ToastType } from '@joplin/lib/services/plugins/api/types';
|
||||
import { INotyfNotificationOptions } from 'notyf';
|
||||
import { PopupNotificationContext } from '../PopupNotification/PopupNotificationProvider';
|
||||
import { NotificationType } from '../PopupNotification/types';
|
||||
|
||||
const emptyToast = (): Toast => {
|
||||
return {
|
||||
@@ -19,26 +19,23 @@ interface Props {
|
||||
}
|
||||
|
||||
export default (props: Props) => {
|
||||
const notyfContext = useContext(NotyfContext);
|
||||
const popupManager = useContext(PopupNotificationContext);
|
||||
|
||||
const toast = useMemo(() => {
|
||||
const toast: Toast = props.toast ? props.toast : emptyToast();
|
||||
return toast;
|
||||
}, [props.toast]);
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
useEffect(() => {
|
||||
if (!toast.message) return;
|
||||
|
||||
const options: Partial<INotyfNotificationOptions> = {
|
||||
type: toast.type,
|
||||
message: toast.message,
|
||||
duration: toast.duration,
|
||||
};
|
||||
|
||||
notyfContext.open(options);
|
||||
popupManager.createPopup(() => toast.message, {
|
||||
type: toast.type as string as NotificationType,
|
||||
}).scheduleDismiss(toast.duration);
|
||||
// toast.timestamp needs to be included in the dependency list to allow
|
||||
// showing multiple toasts with the same message, one after another.
|
||||
// See https://github.com/laurent22/joplin/issues/11783
|
||||
}, [toast.message, toast.duration, toast.type, toast.timestamp, notyfContext]);
|
||||
}, [toast.message, toast.duration, toast.type, toast.timestamp, popupManager]);
|
||||
|
||||
return <div style={{ display: 'none' }}/>;
|
||||
};
|
||||
|
@@ -0,0 +1,52 @@
|
||||
import * as React from 'react';
|
||||
import { NotificationType } from './types';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
key: string;
|
||||
type: NotificationType;
|
||||
dismissing: boolean;
|
||||
popup: boolean;
|
||||
}
|
||||
|
||||
const NotificationItem: React.FC<Props> = props => {
|
||||
const [iconClassName, iconLabel] = (() => {
|
||||
if (props.type === NotificationType.Success) {
|
||||
return ['fas fa-check', _('Success')];
|
||||
}
|
||||
if (props.type === NotificationType.Error) {
|
||||
return ['fas fa-times', _('Error')];
|
||||
}
|
||||
if (props.type === NotificationType.Info) {
|
||||
return ['fas fa-info', _('Info')];
|
||||
}
|
||||
return ['', ''];
|
||||
})();
|
||||
|
||||
const containerModifier = (() => {
|
||||
if (props.type === NotificationType.Success) return '-success';
|
||||
if (props.type === NotificationType.Error) return '-error';
|
||||
if (props.type === NotificationType.Info) return '-info';
|
||||
return '';
|
||||
})();
|
||||
|
||||
const icon = <i
|
||||
role='img'
|
||||
aria-label={iconLabel}
|
||||
className={`icon ${iconClassName}`}
|
||||
/>;
|
||||
|
||||
return <li
|
||||
role={props.popup ? 'alert' : undefined}
|
||||
className={`popup-notification-item ${containerModifier} ${props.dismissing ? '-dismissing' : ''}`}
|
||||
>
|
||||
{iconClassName ? icon : null}
|
||||
<div className='ripple'/>
|
||||
<div className='content'>
|
||||
{props.children}
|
||||
</div>
|
||||
</li>;
|
||||
};
|
||||
|
||||
export default NotificationItem;
|
@@ -0,0 +1,41 @@
|
||||
import * as React from 'react';
|
||||
import { VisibleNotificationsContext } from './PopupNotificationProvider';
|
||||
import NotificationItem from './NotificationItem';
|
||||
import { useContext } from 'react';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
|
||||
interface Props {}
|
||||
|
||||
// This component displays the popups managed by PopupNotificationContext.
|
||||
// This allows popups to be shown in multiple windows at the same time.
|
||||
const PopupNotificationList: React.FC<Props> = () => {
|
||||
const popupSpecs = useContext(VisibleNotificationsContext);
|
||||
const popups = [];
|
||||
for (const spec of popupSpecs) {
|
||||
if (spec.dismissed) continue;
|
||||
|
||||
popups.push(
|
||||
<NotificationItem
|
||||
key={spec.key}
|
||||
type={spec.type}
|
||||
dismissing={!!spec.dismissAt}
|
||||
popup={true}
|
||||
>{spec.content()}</NotificationItem>,
|
||||
);
|
||||
}
|
||||
popups.reverse();
|
||||
|
||||
if (popups.length) {
|
||||
return <ul
|
||||
className='popup-notification-list -overlay'
|
||||
role='group'
|
||||
aria-label={_('Notifications')}
|
||||
>
|
||||
{popups}
|
||||
</ul>;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default PopupNotificationList;
|
@@ -0,0 +1,122 @@
|
||||
import * as React from 'react';
|
||||
import { createContext, useMemo, useRef, useState } from 'react';
|
||||
import { NotificationType, PopupHandle, PopupControl as PopupManager } from './types';
|
||||
import { Hour, msleep } from '@joplin/utils/time';
|
||||
|
||||
export const PopupNotificationContext = createContext<PopupManager|null>(null);
|
||||
export const VisibleNotificationsContext = createContext<PopupSpec[]>([]);
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface PopupSpec {
|
||||
key: string;
|
||||
dismissAt?: number;
|
||||
dismissed: boolean;
|
||||
type: NotificationType;
|
||||
content: ()=> React.ReactNode;
|
||||
}
|
||||
|
||||
const PopupNotificationProvider: React.FC<Props> = props => {
|
||||
const [popupSpecs, setPopupSpecs] = useState<PopupSpec[]>([]);
|
||||
const nextPopupKey = useRef(0);
|
||||
|
||||
const popupManager = useMemo((): PopupManager => {
|
||||
const removeOldPopups = () => {
|
||||
// The WCAG allows dismissing notifications older than 20 hours.
|
||||
setPopupSpecs(popups => popups.filter(popup => {
|
||||
if (!popup.dismissed) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const dismissedRecently = popup.dismissAt > performance.now() - Hour * 20;
|
||||
return dismissedRecently;
|
||||
}));
|
||||
};
|
||||
|
||||
const removePopupWithKey = (key: string) => {
|
||||
setPopupSpecs(popups => popups.filter(p => p.key !== key));
|
||||
};
|
||||
|
||||
type UpdatePopupCallback = (popup: PopupSpec)=> PopupSpec;
|
||||
const updatePopupWithKey = (key: string, updateCallback: UpdatePopupCallback) => {
|
||||
setPopupSpecs(popups => popups.map(p => {
|
||||
if (p.key === key) {
|
||||
return updateCallback(p);
|
||||
} else {
|
||||
return p;
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const dismissAnimationDelay = 600;
|
||||
const dismissPopup = async (key: string) => {
|
||||
// Start the dismiss animation
|
||||
updatePopupWithKey(key, popup => ({
|
||||
...popup,
|
||||
dismissAt: performance.now() + dismissAnimationDelay,
|
||||
}));
|
||||
|
||||
await msleep(dismissAnimationDelay);
|
||||
|
||||
updatePopupWithKey(key, popup => ({
|
||||
...popup,
|
||||
dismissed: true,
|
||||
}));
|
||||
removeOldPopups();
|
||||
};
|
||||
|
||||
const dismissAndRemovePopup = async (key: string) => {
|
||||
await dismissPopup(key);
|
||||
removePopupWithKey(key);
|
||||
};
|
||||
|
||||
const manager: PopupManager = {
|
||||
createPopup: (content, { type } = {}): PopupHandle => {
|
||||
const key = `popup-${nextPopupKey.current++}`;
|
||||
const newPopup: PopupSpec = {
|
||||
key,
|
||||
content,
|
||||
type,
|
||||
dismissed: false,
|
||||
};
|
||||
|
||||
setPopupSpecs(popups => {
|
||||
const newPopups = [...popups];
|
||||
|
||||
// Replace the existing popup, if it exists
|
||||
const insertIndex = newPopups.findIndex(p => p.key === key);
|
||||
if (insertIndex === -1) {
|
||||
newPopups.push(newPopup);
|
||||
} else {
|
||||
newPopups.splice(insertIndex, 1, newPopup);
|
||||
}
|
||||
|
||||
return newPopups;
|
||||
});
|
||||
|
||||
const handle: PopupHandle = {
|
||||
remove() {
|
||||
void dismissAndRemovePopup(key);
|
||||
},
|
||||
scheduleDismiss(delay = 5_500) {
|
||||
setTimeout(() => {
|
||||
void dismissPopup(key);
|
||||
}, delay);
|
||||
},
|
||||
};
|
||||
return handle;
|
||||
},
|
||||
};
|
||||
return manager;
|
||||
}, []);
|
||||
|
||||
return <PopupNotificationContext.Provider value={popupManager}>
|
||||
<VisibleNotificationsContext.Provider value={popupSpecs}>
|
||||
{props.children}
|
||||
</VisibleNotificationsContext.Provider>
|
||||
</PopupNotificationContext.Provider>;
|
||||
};
|
||||
|
||||
export default PopupNotificationProvider;
|
22
packages/app-desktop/gui/PopupNotification/types.ts
Normal file
22
packages/app-desktop/gui/PopupNotification/types.ts
Normal 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;
|
||||
}
|
@@ -30,6 +30,7 @@ import WindowCommandsAndDialogs from './WindowCommandsAndDialogs/WindowCommandsA
|
||||
import { defaultWindowId, stateUtils, WindowState } from '@joplin/lib/reducer';
|
||||
import bridge from '../services/bridge';
|
||||
import EditorWindow from './NoteEditor/EditorWindow';
|
||||
import PopupNotificationProvider from './PopupNotification/PopupNotificationProvider';
|
||||
const { ThemeProvider, StyleSheetManager, createGlobalStyle } = require('styled-components');
|
||||
|
||||
interface Props {
|
||||
@@ -197,13 +198,15 @@ class RootComponent extends React.Component<Props, any> {
|
||||
return (
|
||||
<StyleSheetManager disableVendorPrefixes>
|
||||
<ThemeProvider theme={theme}>
|
||||
<StyleSheetContainer/>
|
||||
<MenuBar/>
|
||||
<GlobalStyle/>
|
||||
<WindowCommandsAndDialogs windowId={defaultWindowId} />
|
||||
<Navigator style={navigatorStyle} screens={screens} className={`profile-${this.props.profileConfigCurrentProfileId}`} />
|
||||
{this.renderSecondaryWindows()}
|
||||
{this.renderModalMessage(this.modalDialogProps())}
|
||||
<PopupNotificationProvider>
|
||||
<StyleSheetContainer/>
|
||||
<MenuBar/>
|
||||
<GlobalStyle/>
|
||||
<WindowCommandsAndDialogs windowId={defaultWindowId} />
|
||||
<Navigator style={navigatorStyle} screens={screens} className={`profile-${this.props.profileConfigCurrentProfileId}`} />
|
||||
{this.renderSecondaryWindows()}
|
||||
{this.renderModalMessage(this.modalDialogProps())}
|
||||
</PopupNotificationProvider>
|
||||
</ThemeProvider>
|
||||
</StyleSheetManager>
|
||||
);
|
||||
|
@@ -1,15 +1,13 @@
|
||||
import { useContext, useCallback, useMemo, useRef } from 'react';
|
||||
import * as React from 'react';
|
||||
import { useContext, useEffect, useRef } from 'react';
|
||||
import { StateLastDeletion } from '@joplin/lib/reducer';
|
||||
import { _, _n } from '@joplin/lib/locale';
|
||||
import NotyfContext from '../NotyfContext';
|
||||
import { waitForElement } from '@joplin/lib/dom';
|
||||
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
|
||||
import { htmlentities } from '@joplin/utils/html';
|
||||
import restoreItems from '@joplin/lib/services/trash/restoreItems';
|
||||
import { ModelType } from '@joplin/lib/BaseModel';
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
import { Dispatch } from 'redux';
|
||||
import { NotyfNotification } from 'notyf';
|
||||
import { PopupNotificationContext } from '../PopupNotification/PopupNotificationProvider';
|
||||
import { NotificationType } from '../PopupNotification/types';
|
||||
import TrashNotificationMessage from './TrashNotificationMessage';
|
||||
|
||||
interface Props {
|
||||
lastDeletion: StateLastDeletion;
|
||||
@@ -18,50 +16,29 @@ interface Props {
|
||||
dispatch: Dispatch;
|
||||
}
|
||||
|
||||
const onCancelClick = async (lastDeletion: StateLastDeletion) => {
|
||||
if (lastDeletion.folderIds.length) {
|
||||
await restoreItems(ModelType.Folder, lastDeletion.folderIds);
|
||||
}
|
||||
|
||||
if (lastDeletion.noteIds.length) {
|
||||
await restoreItems(ModelType.Note, lastDeletion.noteIds);
|
||||
}
|
||||
};
|
||||
|
||||
export default (props: Props) => {
|
||||
const notyfContext = useContext(NotyfContext);
|
||||
const notificationRef = useRef<NotyfNotification | null>(null);
|
||||
const popupManager = useContext(PopupNotificationContext);
|
||||
|
||||
const theme = useMemo(() => {
|
||||
return themeStyle(props.themeId);
|
||||
}, [props.themeId]);
|
||||
const lastDeletionNotificationTimeRef = useRef<number>();
|
||||
lastDeletionNotificationTimeRef.current = props.lastDeletionNotificationTime;
|
||||
|
||||
const notyf = useMemo(() => {
|
||||
const output = notyfContext;
|
||||
output.options.types = notyfContext.options.types.map(type => {
|
||||
if (type.type === 'success') {
|
||||
type.background = theme.backgroundColor5;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
(type.icon as any).color = theme.backgroundColor5;
|
||||
}
|
||||
return type;
|
||||
});
|
||||
return output;
|
||||
}, [notyfContext, theme]);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const onCancelClick = useCallback(async (event: any) => {
|
||||
notyf.dismiss(notificationRef.current);
|
||||
notificationRef.current = null;
|
||||
|
||||
const lastDeletion: StateLastDeletion = JSON.parse(event.currentTarget.getAttribute('data-lastDeletion'));
|
||||
|
||||
if (lastDeletion.folderIds.length) {
|
||||
await restoreItems(ModelType.Folder, lastDeletion.folderIds);
|
||||
}
|
||||
|
||||
if (lastDeletion.noteIds.length) {
|
||||
await restoreItems(ModelType.Note, lastDeletion.noteIds);
|
||||
}
|
||||
}, [notyf]);
|
||||
|
||||
useAsyncEffect(async (event) => {
|
||||
if (!props.lastDeletion || props.lastDeletion.timestamp <= props.lastDeletionNotificationTime) return;
|
||||
useEffect(() => {
|
||||
const lastDeletionNotificationTime = lastDeletionNotificationTimeRef.current;
|
||||
if (!props.lastDeletion || props.lastDeletion.timestamp <= lastDeletionNotificationTime) return;
|
||||
|
||||
props.dispatch({ type: 'DELETION_NOTIFICATION_DONE' });
|
||||
|
||||
let msg = '';
|
||||
|
||||
if (props.lastDeletion.folderIds.length) {
|
||||
msg = _('The notebook and its content was successfully moved to the trash.');
|
||||
} else if (props.lastDeletion.noteIds.length) {
|
||||
@@ -70,16 +47,15 @@ export default (props: Props) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const linkId = `deletion-notification-cancel-${Math.floor(Math.random() * 1000000)}`;
|
||||
const cancelLabel = _('Cancel');
|
||||
|
||||
const notification = notyf.success(`${msg} <a href="#" class="cancel" data-lastDeletion="${htmlentities(JSON.stringify(props.lastDeletion))}" id="${linkId}">${cancelLabel}</a>`);
|
||||
notificationRef.current = notification;
|
||||
|
||||
const element: HTMLAnchorElement = await waitForElement(document, linkId);
|
||||
if (event.cancelled) return;
|
||||
element.addEventListener('click', onCancelClick);
|
||||
}, [props.lastDeletion, notyf, props.dispatch]);
|
||||
const handleCancelClick = () => {
|
||||
notification.remove();
|
||||
void onCancelClick(props.lastDeletion);
|
||||
};
|
||||
const notification = popupManager.createPopup(() => (
|
||||
<TrashNotificationMessage message={msg} onCancel={handleCancelClick}/>
|
||||
), { type: NotificationType.Success });
|
||||
notification.scheduleDismiss();
|
||||
}, [props.lastDeletion, props.dispatch, popupManager]);
|
||||
|
||||
return <div style={{ display: 'none' }}/>;
|
||||
};
|
||||
|
@@ -0,0 +1,27 @@
|
||||
import * as React from 'react';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
message: string;
|
||||
onCancel: ()=> void;
|
||||
}
|
||||
|
||||
const TrashNotificationMessage: React.FC<Props> = props => {
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const onCancel = useCallback(() => {
|
||||
setCancelling(true);
|
||||
props.onCancel();
|
||||
}, [props.onCancel]);
|
||||
|
||||
return <>
|
||||
{props.message}
|
||||
{' '}
|
||||
<button
|
||||
className="link-button"
|
||||
onClick={onCancel}
|
||||
>{cancelling ? _('Cancelling...') : _('Cancel')}</button>
|
||||
</>;
|
||||
};
|
||||
|
||||
export default TrashNotificationMessage;
|
@@ -1,27 +0,0 @@
|
||||
body .notyf {
|
||||
color: var(--joplin-color5);
|
||||
}
|
||||
|
||||
.notyf__toast {
|
||||
|
||||
> .notyf__wrapper {
|
||||
|
||||
> .notyf__message {
|
||||
|
||||
> .cancel {
|
||||
color: var(--joplin-color5);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
> .notyf__icon {
|
||||
|
||||
> .notyf__icon--success {
|
||||
background-color: var(--joplin-color5);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
@@ -1,17 +1,15 @@
|
||||
import * as React from 'react';
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef } from 'react';
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
import NotyfContext from '../NotyfContext';
|
||||
import { useCallback, useContext, useEffect } from 'react';
|
||||
import { UpdateInfo } from 'electron-updater';
|
||||
import { ipcRenderer, IpcRendererEvent } from 'electron';
|
||||
import { AutoUpdaterEvents } from '../../services/autoUpdater/AutoUpdaterService';
|
||||
import { NotyfEvent, NotyfNotification } from 'notyf';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { htmlentities } from '@joplin/utils/html';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { PopupNotificationContext } from '../PopupNotification/PopupNotificationProvider';
|
||||
import Button, { ButtonLevel } from '../Button/Button';
|
||||
import { NotificationType } from '../PopupNotification/types';
|
||||
|
||||
interface UpdateNotificationProps {
|
||||
themeId: number;
|
||||
interface Props {
|
||||
}
|
||||
|
||||
export enum UpdateNotificationEvents {
|
||||
@@ -22,111 +20,61 @@ export enum UpdateNotificationEvents {
|
||||
|
||||
const changelogLink = 'https://github.com/laurent22/joplin/releases';
|
||||
|
||||
window.openChangelogLink = () => {
|
||||
const openChangelogLink = () => {
|
||||
shim.openUrl(changelogLink);
|
||||
};
|
||||
|
||||
const UpdateNotification = ({ themeId }: UpdateNotificationProps) => {
|
||||
const notyfContext = useContext(NotyfContext);
|
||||
const notificationRef = useRef<NotyfNotification | null>(null); // Use ref to hold the current notification
|
||||
|
||||
const theme = useMemo(() => themeStyle(themeId), [themeId]);
|
||||
|
||||
const notyf = useMemo(() => {
|
||||
const output = notyfContext;
|
||||
output.options.types = notyfContext.options.types.map(type => {
|
||||
if (type.type === 'success') {
|
||||
type.background = theme.backgroundColor5;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
(type.icon as any).color = theme.backgroundColor5;
|
||||
}
|
||||
return type;
|
||||
});
|
||||
return output;
|
||||
}, [notyfContext, theme]);
|
||||
|
||||
const handleDismissNotification = useCallback(() => {
|
||||
notyf.dismiss(notificationRef.current);
|
||||
notificationRef.current = null;
|
||||
}, [notyf]);
|
||||
|
||||
const handleApplyUpdate = useCallback(() => {
|
||||
ipcRenderer.send('apply-update-now');
|
||||
handleDismissNotification();
|
||||
}, [handleDismissNotification]);
|
||||
const handleApplyUpdate = () => {
|
||||
ipcRenderer.send('apply-update-now');
|
||||
};
|
||||
|
||||
const UpdateNotification: React.FC<Props> = () => {
|
||||
const popupManager = useContext(PopupNotificationContext);
|
||||
|
||||
const handleUpdateDownloaded = useCallback((_event: IpcRendererEvent, info: UpdateInfo) => {
|
||||
if (notificationRef.current) return;
|
||||
|
||||
const updateAvailableHtml = htmlentities(_('A new update (%s) is available', info.version));
|
||||
const seeChangelogHtml = htmlentities(_('See changelog'));
|
||||
const restartNowHtml = htmlentities(_('Restart now'));
|
||||
const updateLaterHtml = htmlentities(_('Update later'));
|
||||
|
||||
const messageHtml = `
|
||||
<div class="update-notification" style="color: ${theme.color2};">
|
||||
${updateAvailableHtml} <a href="#" onclick="openChangelogLink()" style="color: ${theme.color2};">${seeChangelogHtml}</a>
|
||||
<div style="display: flex; gap: 10px; margin-top: 8px;">
|
||||
<button onclick="document.dispatchEvent(new CustomEvent('${UpdateNotificationEvents.ApplyUpdate}'))" class="notyf__button notyf__button--confirm" style="color: ${theme.color2};">${restartNowHtml}</button>
|
||||
<button onclick="document.dispatchEvent(new CustomEvent('${UpdateNotificationEvents.Dismiss}'))" class="notyf__button notyf__button--dismiss" style="color: ${theme.color2};">${updateLaterHtml}</button>
|
||||
const notification = popupManager.createPopup(() => (
|
||||
<div className='update-notification'>
|
||||
{_('A new update (%s) is available', info.version)}
|
||||
<button className='link-button' onClick={openChangelogLink}>{
|
||||
_('See changelog')
|
||||
}</button>
|
||||
<div className='buttons'>
|
||||
<Button
|
||||
level={ButtonLevel.Tertiary}
|
||||
onClick={() => {
|
||||
notification.remove();
|
||||
handleApplyUpdate();
|
||||
}}
|
||||
title={_('Restart now')}
|
||||
/>
|
||||
<Button
|
||||
level={ButtonLevel.Tertiary}
|
||||
onClick={() => notification.remove()}
|
||||
title={_('Update later')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const notification: NotyfNotification = notyf.open({
|
||||
type: 'success',
|
||||
message: messageHtml,
|
||||
position: {
|
||||
x: 'right',
|
||||
y: 'bottom',
|
||||
},
|
||||
duration: 0,
|
||||
});
|
||||
|
||||
notificationRef.current = notification;
|
||||
}, [notyf, theme]);
|
||||
));
|
||||
}, [popupManager]);
|
||||
|
||||
const handleUpdateNotAvailable = useCallback(() => {
|
||||
if (notificationRef.current) return;
|
||||
|
||||
const noUpdateMessageHtml = htmlentities(_('No updates available'));
|
||||
|
||||
const messageHtml = `
|
||||
<div class="update-notification" style="color: ${theme.color2};">
|
||||
${noUpdateMessageHtml}
|
||||
const notification = popupManager.createPopup(() => (
|
||||
<div className='update-notification'>
|
||||
{_('No updates available')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
const notification: NotyfNotification = notyf.open({
|
||||
type: 'success',
|
||||
message: messageHtml,
|
||||
position: {
|
||||
x: 'right',
|
||||
y: 'bottom',
|
||||
},
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
notification.on(NotyfEvent.Dismiss, () => {
|
||||
notificationRef.current = null;
|
||||
});
|
||||
|
||||
notificationRef.current = notification;
|
||||
}, [notyf, theme]);
|
||||
), { type: NotificationType.Info });
|
||||
notification.scheduleDismiss();
|
||||
}, [popupManager]);
|
||||
|
||||
useEffect(() => {
|
||||
ipcRenderer.on(AutoUpdaterEvents.UpdateDownloaded, handleUpdateDownloaded);
|
||||
ipcRenderer.on(AutoUpdaterEvents.UpdateNotAvailable, handleUpdateNotAvailable);
|
||||
document.addEventListener(UpdateNotificationEvents.ApplyUpdate, handleApplyUpdate);
|
||||
document.addEventListener(UpdateNotificationEvents.Dismiss, handleDismissNotification);
|
||||
|
||||
return () => {
|
||||
ipcRenderer.removeListener(AutoUpdaterEvents.UpdateDownloaded, handleUpdateDownloaded);
|
||||
ipcRenderer.removeListener(AutoUpdaterEvents.UpdateNotAvailable, handleUpdateNotAvailable);
|
||||
document.removeEventListener(UpdateNotificationEvents.ApplyUpdate, handleApplyUpdate);
|
||||
};
|
||||
}, [handleApplyUpdate, handleDismissNotification, handleUpdateDownloaded, handleUpdateNotAvailable]);
|
||||
}, [handleUpdateDownloaded, handleUpdateNotAvailable]);
|
||||
|
||||
|
||||
return (
|
||||
|
@@ -1,27 +1,11 @@
|
||||
.update-notification {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
.button-container {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.notyf__button {
|
||||
padding: 5px 10px;
|
||||
border: 1px solid;
|
||||
border-radius: 4px;
|
||||
background-color: transparent;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
> .buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
@@ -18,6 +18,7 @@ import useWindowCommands from './utils/useWindowCommands';
|
||||
import PluginDialogs from './PluginDialogs';
|
||||
import useSyncDialogState from './utils/useSyncDialogState';
|
||||
import AppDialogs from './AppDialogs';
|
||||
import PopupNotificationList from '../PopupNotification/PopupNotificationList';
|
||||
|
||||
const PluginManager = require('@joplin/lib/services/PluginManager');
|
||||
|
||||
@@ -113,7 +114,9 @@ const WindowCommandsAndDialogs: React.FC<Props> = props => {
|
||||
const dialogInfo = PluginManager.instance().pluginDialogToShow(props.pluginsLegacy);
|
||||
const pluginDialog = !dialogInfo ? null : <dialogInfo.Dialog {...dialogInfo.props} />;
|
||||
|
||||
const { noteContentPropertiesDialogOptions, notePropertiesDialogOptions, shareNoteDialogOptions, shareFolderDialogOptions, promptOptions } = dialogState;
|
||||
const {
|
||||
noteContentPropertiesDialogOptions, notePropertiesDialogOptions, shareNoteDialogOptions, shareFolderDialogOptions, promptOptions,
|
||||
} = dialogState;
|
||||
|
||||
|
||||
return <>
|
||||
@@ -173,6 +176,8 @@ const WindowCommandsAndDialogs: React.FC<Props> = props => {
|
||||
buttons={promptOptions && 'buttons' in promptOptions ? promptOptions.buttons : null}
|
||||
inputType={promptOptions && 'inputType' in promptOptions ? promptOptions.inputType : null}
|
||||
/>
|
||||
|
||||
<PopupNotificationList/>
|
||||
</>;
|
||||
};
|
||||
|
||||
|
@@ -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';
|
||||
|
13
packages/app-desktop/gui/styles/link-button.scss
Normal file
13
packages/app-desktop/gui/styles/link-button.scss
Normal 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;
|
||||
}
|
126
packages/app-desktop/gui/styles/popup-notification-item.scss
Normal file
126
packages/app-desktop/gui/styles/popup-notification-item.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
22
packages/app-desktop/gui/styles/popup-notification-list.scss
Normal file
22
packages/app-desktop/gui/styles/popup-notification-list.scss
Normal 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;
|
||||
}
|
||||
}
|
@@ -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>
|
||||
|
@@ -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;
|
||||
|
@@ -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;
|
||||
}
|
@@ -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",
|
||||
|
@@ -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;
|
||||
|
@@ -90,8 +90,6 @@ signup
|
||||
activatable
|
||||
Prec
|
||||
titlewrapper
|
||||
notyf
|
||||
Notyf
|
||||
unresponded
|
||||
activeline
|
||||
Prec
|
||||
|
@@ -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"
|
||||
|
Reference in New Issue
Block a user