mirror of
https://github.com/overleaf/overleaf.git
synced 2024-12-12 08:07:37 -05:00
[web] Add toast notifications to editor (#21567)
GitOrigin-RevId: 7f7ab83d4615f564c0e79bc2a05ca6cb9e5239fd
This commit is contained in:
parent
a50d76f4ea
commit
cb72799fff
14 changed files with 363 additions and 1 deletions
|
@ -963,6 +963,7 @@ module.exports = {
|
||||||
settingsEntries: [],
|
settingsEntries: [],
|
||||||
autoCompleteExtensions: [],
|
autoCompleteExtensions: [],
|
||||||
sectionTitleGenerators: [],
|
sectionTitleGenerators: [],
|
||||||
|
toastGenerators: [],
|
||||||
},
|
},
|
||||||
|
|
||||||
moduleImportSequence: [
|
moduleImportSequence: [
|
||||||
|
|
|
@ -14,6 +14,10 @@
|
||||||
"a_fatal_compile_error_that_completely_blocks_compilation": "",
|
"a_fatal_compile_error_that_completely_blocks_compilation": "",
|
||||||
"a_file_with_that_name_already_exists_and_will_be_overriden": "",
|
"a_file_with_that_name_already_exists_and_will_be_overriden": "",
|
||||||
"a_more_comprehensive_list_of_keyboard_shortcuts": "",
|
"a_more_comprehensive_list_of_keyboard_shortcuts": "",
|
||||||
|
"a_new_reference_was_added": "",
|
||||||
|
"a_new_reference_was_added_from_provider": "",
|
||||||
|
"a_new_reference_was_added_to_file": "",
|
||||||
|
"a_new_reference_was_added_to_file_from_provider": "",
|
||||||
"about_to_archive_projects": "",
|
"about_to_archive_projects": "",
|
||||||
"about_to_delete_cert": "",
|
"about_to_delete_cert": "",
|
||||||
"about_to_delete_projects": "",
|
"about_to_delete_projects": "",
|
||||||
|
|
|
@ -0,0 +1,99 @@
|
||||||
|
import { OLToast, OLToastProps } from '@/features/ui/components/ol/ol-toast'
|
||||||
|
import useEventListener from '@/shared/hooks/use-event-listener'
|
||||||
|
import { Fragment, ReactElement, useCallback, useState } from 'react'
|
||||||
|
|
||||||
|
import { debugConsole } from '@/utils/debugging'
|
||||||
|
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
|
||||||
|
import { OLToastContainer } from '@/features/ui/components/ol/ol-toast-container'
|
||||||
|
|
||||||
|
const moduleGeneratorsImport = importOverleafModules('toastGenerators') as {
|
||||||
|
import: { default: GlobalToastGeneratorEntry[] }
|
||||||
|
}[]
|
||||||
|
|
||||||
|
const moduleGenerators = moduleGeneratorsImport.map(
|
||||||
|
({ import: { default: listEntry } }) => listEntry
|
||||||
|
)
|
||||||
|
|
||||||
|
export type GlobalToastGeneratorEntry = {
|
||||||
|
key: string
|
||||||
|
generator: GlobalToastGenerator
|
||||||
|
}
|
||||||
|
|
||||||
|
type GlobalToastGenerator = (
|
||||||
|
args: Record<string, any>
|
||||||
|
) => Omit<OLToastProps, 'onDismiss'>
|
||||||
|
|
||||||
|
const GENERATOR_LIST: GlobalToastGeneratorEntry[] = moduleGenerators.flat()
|
||||||
|
const GENERATOR_MAP: Map<string, GlobalToastGenerator> = new Map(
|
||||||
|
GENERATOR_LIST.map(({ key, generator }) => [key, generator])
|
||||||
|
)
|
||||||
|
|
||||||
|
let toastCounter = 1
|
||||||
|
|
||||||
|
export const GlobalToasts = () => {
|
||||||
|
const [toasts, setToasts] = useState<
|
||||||
|
{ component: ReactElement; id: string }[]
|
||||||
|
>([])
|
||||||
|
|
||||||
|
const removeToast = useCallback((id: string) => {
|
||||||
|
setToasts(current => current.filter(toast => toast.id !== id))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const createToast = useCallback(
|
||||||
|
(id: string, key: string, data: any): ReactElement | null => {
|
||||||
|
const generator = GENERATOR_MAP.get(key)
|
||||||
|
if (!generator) {
|
||||||
|
debugConsole.error('No toast generator found for key:', key)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = generator(data)
|
||||||
|
|
||||||
|
if (!props.autoHide && !props.isDismissible) {
|
||||||
|
// We don't want any toasts that are not dismissible and don't auto-hide
|
||||||
|
props.isDismissible = true
|
||||||
|
}
|
||||||
|
if (props.autoHide && !props.isDismissible && props.delay !== undefined) {
|
||||||
|
// If the toast is auto-hiding but not dismissible, we need to make sure the delay is not too long
|
||||||
|
props.delay = Math.min(props.delay, 60_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <OLToast {...props} onDismiss={() => removeToast(id)} />
|
||||||
|
},
|
||||||
|
[removeToast]
|
||||||
|
)
|
||||||
|
|
||||||
|
const addToast = useCallback(
|
||||||
|
(key: string, data?: any) => {
|
||||||
|
const id = `toast-${toastCounter++}`
|
||||||
|
const component = createToast(id, key, data)
|
||||||
|
if (!component) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setToasts(current => [...current, { id, component }])
|
||||||
|
},
|
||||||
|
[createToast]
|
||||||
|
)
|
||||||
|
|
||||||
|
const showToastListener = useCallback(
|
||||||
|
(event: CustomEvent) => {
|
||||||
|
if (!event.detail?.key) {
|
||||||
|
debugConsole.error('No key provided for toast')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const { key, ...rest } = event.detail
|
||||||
|
addToast(key, rest)
|
||||||
|
},
|
||||||
|
[addToast]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEventListener('ide:show-toast', showToastListener)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OLToastContainer className="global-toasts">
|
||||||
|
{toasts.map(({ component, id }) => (
|
||||||
|
<Fragment key={id}>{component}</Fragment>
|
||||||
|
))}
|
||||||
|
</OLToastContainer>
|
||||||
|
)
|
||||||
|
}
|
|
@ -9,6 +9,7 @@ import { useRegisterUserActivity } from '@/features/ide-react/hooks/use-register
|
||||||
import { useHasLintingError } from '@/features/ide-react/hooks/use-has-linting-error'
|
import { useHasLintingError } from '@/features/ide-react/hooks/use-has-linting-error'
|
||||||
import { Modals } from '@/features/ide-react/components/modals/modals'
|
import { Modals } from '@/features/ide-react/components/modals/modals'
|
||||||
import { GlobalAlertsProvider } from '@/features/ide-react/context/global-alerts-context'
|
import { GlobalAlertsProvider } from '@/features/ide-react/context/global-alerts-context'
|
||||||
|
import { GlobalToasts } from '../global-toasts'
|
||||||
|
|
||||||
export default function IdePage() {
|
export default function IdePage() {
|
||||||
useLayoutEventTracking() // sent event when the layout changes
|
useLayoutEventTracking() // sent event when the layout changes
|
||||||
|
@ -24,6 +25,7 @@ export default function IdePage() {
|
||||||
<Modals />
|
<Modals />
|
||||||
<EditorLeftMenu />
|
<EditorLeftMenu />
|
||||||
<MainLayout />
|
<MainLayout />
|
||||||
|
<GlobalToasts />
|
||||||
</GlobalAlertsProvider>
|
</GlobalAlertsProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,14 @@
|
||||||
|
import classNames from 'classnames'
|
||||||
|
import { FC, HTMLProps } from 'react'
|
||||||
|
|
||||||
|
export const ToastContainer: FC<HTMLProps<HTMLDivElement>> = ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className={classNames('toast-container', className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,51 @@
|
||||||
|
import classNames from 'classnames'
|
||||||
|
import { FC, useCallback, useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
type ToastProps = {
|
||||||
|
onClose?: () => void
|
||||||
|
onExited?: () => void
|
||||||
|
autohide?: boolean
|
||||||
|
delay?: number
|
||||||
|
show: boolean
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
export const Toast: FC<ToastProps> = ({
|
||||||
|
children,
|
||||||
|
delay = 5000,
|
||||||
|
onClose,
|
||||||
|
onExited,
|
||||||
|
autohide,
|
||||||
|
show,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
const delayRef = useRef(delay)
|
||||||
|
const onCloseRef = useRef(onClose)
|
||||||
|
const onExitedRef = useRef(onExited)
|
||||||
|
const shouldAutoHide = Boolean(autohide && show)
|
||||||
|
|
||||||
|
const handleTimeout = useCallback(() => {
|
||||||
|
if (shouldAutoHide) {
|
||||||
|
onCloseRef.current?.()
|
||||||
|
onExitedRef.current?.()
|
||||||
|
}
|
||||||
|
}, [shouldAutoHide])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeout = window.setTimeout(handleTimeout, delayRef.current)
|
||||||
|
return () => window.clearTimeout(timeout)
|
||||||
|
}, [handleTimeout])
|
||||||
|
|
||||||
|
if (!show) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames('toast', show ? 'show' : 'hide', className)}
|
||||||
|
aria-live="assertive"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,31 @@
|
||||||
|
import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
|
||||||
|
|
||||||
|
import { CSSProperties, FC } from 'react'
|
||||||
|
import { ToastContainer as BS5ToastContainer } from 'react-bootstrap-5'
|
||||||
|
import { ToastContainer as BS3ToastContainer } from '../bootstrap-3/toast-container'
|
||||||
|
|
||||||
|
type OLToastContainerProps = {
|
||||||
|
style?: CSSProperties
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OLToastContainer: FC<OLToastContainerProps> = ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<BootstrapVersionSwitcher
|
||||||
|
bs5={
|
||||||
|
<BS5ToastContainer className={className} style={style}>
|
||||||
|
{children}
|
||||||
|
</BS5ToastContainer>
|
||||||
|
}
|
||||||
|
bs3={
|
||||||
|
<BS3ToastContainer className={className} style={style}>
|
||||||
|
{children}
|
||||||
|
</BS3ToastContainer>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
106
services/web/frontend/js/features/ui/components/ol/ol-toast.tsx
Normal file
106
services/web/frontend/js/features/ui/components/ol/ol-toast.tsx
Normal file
|
@ -0,0 +1,106 @@
|
||||||
|
import classNames from 'classnames'
|
||||||
|
import { Toast as BS5Toast } from 'react-bootstrap-5'
|
||||||
|
import {
|
||||||
|
NotificationIcon,
|
||||||
|
NotificationType,
|
||||||
|
} from '../../../../shared/components/notification'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import MaterialIcon from '../../../../shared/components/material-icon'
|
||||||
|
import { ReactNode, useCallback, useState } from 'react'
|
||||||
|
import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
|
||||||
|
import { Toast as BS3Toast } from '../bootstrap-3/toast'
|
||||||
|
|
||||||
|
export type OLToastProps = {
|
||||||
|
type: NotificationType
|
||||||
|
className?: string
|
||||||
|
title?: string
|
||||||
|
content: string | ReactNode
|
||||||
|
isDismissible?: boolean
|
||||||
|
onDismiss?: () => void
|
||||||
|
autoHide?: boolean
|
||||||
|
delay?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OLToast = ({
|
||||||
|
type = 'info',
|
||||||
|
className = '',
|
||||||
|
content,
|
||||||
|
title,
|
||||||
|
isDismissible,
|
||||||
|
onDismiss,
|
||||||
|
autoHide,
|
||||||
|
delay,
|
||||||
|
}: OLToastProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [show, setShow] = useState(true)
|
||||||
|
|
||||||
|
const toastClassName = classNames(
|
||||||
|
'notification',
|
||||||
|
`notification-type-${type}`,
|
||||||
|
className,
|
||||||
|
'toast-content'
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setShow(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleOnHidden = useCallback(() => {
|
||||||
|
if (onDismiss) onDismiss()
|
||||||
|
}, [onDismiss])
|
||||||
|
|
||||||
|
const toastElement = (
|
||||||
|
<div className={toastClassName}>
|
||||||
|
<NotificationIcon notificationType={type} />
|
||||||
|
|
||||||
|
<div className="notification-content-and-cta">
|
||||||
|
<div className="notification-content">
|
||||||
|
{title && (
|
||||||
|
<p>
|
||||||
|
<b>{title}</b>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isDismissible && (
|
||||||
|
<div className="notification-close-btn">
|
||||||
|
<button
|
||||||
|
aria-label={t('close')}
|
||||||
|
data-bs-dismiss="toast"
|
||||||
|
onClick={handleClose}
|
||||||
|
>
|
||||||
|
<MaterialIcon type="close" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<BootstrapVersionSwitcher
|
||||||
|
bs5={
|
||||||
|
<BS5Toast
|
||||||
|
onClose={handleClose}
|
||||||
|
autohide={autoHide}
|
||||||
|
onExited={handleOnHidden}
|
||||||
|
delay={delay}
|
||||||
|
show={show}
|
||||||
|
>
|
||||||
|
{toastElement}
|
||||||
|
</BS5Toast>
|
||||||
|
}
|
||||||
|
bs3={
|
||||||
|
<BS3Toast
|
||||||
|
onClose={handleClose}
|
||||||
|
autohide={autoHide}
|
||||||
|
delay={delay}
|
||||||
|
onExited={handleOnHidden}
|
||||||
|
show={show}
|
||||||
|
>
|
||||||
|
{toastElement}
|
||||||
|
</BS3Toast>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
|
@ -27,7 +27,7 @@ export type NotificationProps = {
|
||||||
id?: string
|
id?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function NotificationIcon({
|
export function NotificationIcon({
|
||||||
notificationType,
|
notificationType,
|
||||||
customIcon,
|
customIcon,
|
||||||
}: {
|
}: {
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
@import './editor/error-boundary.less';
|
@import './editor/error-boundary.less';
|
||||||
@import './editor/share.less';
|
@import './editor/share.less';
|
||||||
@import './editor/chat.less';
|
@import './editor/chat.less';
|
||||||
|
@import './editor/toast.less';
|
||||||
@import './editor/file-view.less';
|
@import './editor/file-view.less';
|
||||||
@import './editor/search.less';
|
@import './editor/search.less';
|
||||||
@import './editor/publish-template.less';
|
@import './editor/publish-template.less';
|
||||||
|
|
22
services/web/frontend/stylesheets/app/editor/toast.less
Normal file
22
services/web/frontend/stylesheets/app/editor/toast.less
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
.toast-container {
|
||||||
|
pointer-events: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-content {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-toasts {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 12px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
|
||||||
|
[role='alert'] {
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
}
|
|
@ -16,6 +16,7 @@
|
||||||
@import 'editor/figure-modal';
|
@import 'editor/figure-modal';
|
||||||
@import 'editor/review-panel';
|
@import 'editor/review-panel';
|
||||||
@import 'editor/chat';
|
@import 'editor/chat';
|
||||||
|
@import 'editor/toast';
|
||||||
@import 'editor/history';
|
@import 'editor/history';
|
||||||
@import 'subscription';
|
@import 'subscription';
|
||||||
@import 'editor/pdf';
|
@import 'editor/pdf';
|
||||||
|
|
|
@ -0,0 +1,26 @@
|
||||||
|
.toast-container {
|
||||||
|
pointer-events: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-content {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.showing {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-toasts {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 12px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
|
||||||
|
[role='alert'] {
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
}
|
|
@ -16,6 +16,10 @@
|
||||||
"a_fatal_compile_error_that_completely_blocks_compilation": "A <0>fatal compile error</0> that completely blocks the compilation.",
|
"a_fatal_compile_error_that_completely_blocks_compilation": "A <0>fatal compile error</0> that completely blocks the compilation.",
|
||||||
"a_file_with_that_name_already_exists_and_will_be_overriden": "A file with that name already exists. That file will be overwritten.",
|
"a_file_with_that_name_already_exists_and_will_be_overriden": "A file with that name already exists. That file will be overwritten.",
|
||||||
"a_more_comprehensive_list_of_keyboard_shortcuts": "A more comprehensive list of keyboard shortcuts can be found in <0>this __appName__ project template</0>",
|
"a_more_comprehensive_list_of_keyboard_shortcuts": "A more comprehensive list of keyboard shortcuts can be found in <0>this __appName__ project template</0>",
|
||||||
|
"a_new_reference_was_added": "A new reference was added",
|
||||||
|
"a_new_reference_was_added_from_provider": "A new reference was added from __provider__",
|
||||||
|
"a_new_reference_was_added_to_file": "A new reference was added to <0>__filePath__</0>",
|
||||||
|
"a_new_reference_was_added_to_file_from_provider": "A new reference was added to <0>__filePath__</0> from __provider__",
|
||||||
"about": "About",
|
"about": "About",
|
||||||
"about_to_archive_projects": "You are about to archive the following projects:",
|
"about_to_archive_projects": "You are about to archive the following projects:",
|
||||||
"about_to_delete_cert": "You are about to delete the following certificate:",
|
"about_to_delete_cert": "You are about to delete the following certificate:",
|
||||||
|
|
Loading…
Reference in a new issue