Create a notification system that can display notifications in Theatre.js' Studio (#320)
* Implement an internal library for studio notifications * Improve design a little * Document code * Change relative import to absolute one * Fix tiny styling issue * Add notifications playground * Add notifications empty state and keep notifications buttons always visible Also fix a bug related to not clearing the type and uniqueness checkers. * Simplify notifications playground * Treat window as optional in case it runs in server code
This commit is contained in:
parent
ef5752cbd3
commit
62bc12ab51
14 changed files with 818 additions and 11 deletions
45
packages/playground/src/shared/notifications/Scene.tsx
Normal file
45
packages/playground/src/shared/notifications/Scene.tsx
Normal file
|
@ -0,0 +1,45 @@
|
|||
import React, {useLayoutEffect, useRef} from 'react'
|
||||
import type {IProject} from '@theatre/core'
|
||||
import {onChange, types} from '@theatre/core'
|
||||
|
||||
const globalConfig = {
|
||||
background: {
|
||||
type: types.stringLiteral('black', {
|
||||
black: 'black',
|
||||
white: 'white',
|
||||
dynamic: 'dynamic',
|
||||
}),
|
||||
dynamic: types.rgba(),
|
||||
},
|
||||
}
|
||||
|
||||
export const Scene: React.FC<{project: IProject}> = ({project}) => {
|
||||
// This is cheap to call and always returns the same value, so no need for useMemo()
|
||||
const sheet = project.sheet('Scene', 'default')
|
||||
const containerRef = useRef<HTMLDivElement>(null!)
|
||||
const globalObj = sheet.object('global', globalConfig)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const unsubscribeFromChanges = onChange(globalObj.props, (newValues) => {
|
||||
containerRef.current.style.background =
|
||||
newValues.background.type !== 'dynamic'
|
||||
? newValues.background.type
|
||||
: newValues.background.dynamic.toString()
|
||||
})
|
||||
return unsubscribeFromChanges
|
||||
}, [globalObj])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '0',
|
||||
right: '0',
|
||||
top: 0,
|
||||
bottom: '0',
|
||||
background: '#333',
|
||||
}}
|
||||
></div>
|
||||
)
|
||||
}
|
40
packages/playground/src/shared/notifications/index.tsx
Normal file
40
packages/playground/src/shared/notifications/index.tsx
Normal file
|
@ -0,0 +1,40 @@
|
|||
import React from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import studio, {notify} from '@theatre/studio'
|
||||
import {getProject} from '@theatre/core'
|
||||
import {Scene} from './Scene'
|
||||
|
||||
studio.initialize()
|
||||
|
||||
// trigger warning notification
|
||||
getProject('Sample project').sheet('Scene').sequence.play()
|
||||
|
||||
// fire an info notification
|
||||
notify.info(
|
||||
'Welcome to the notifications playground!',
|
||||
'This is a basic example of a notification! You can see the code for this notification ' +
|
||||
'(and all others) at the start of index.tsx. You can also see examples of success and warnign notifications.',
|
||||
)
|
||||
|
||||
getProject('Sample project').ready.then(() => {
|
||||
// fire a success notification on project load
|
||||
notify.success(
|
||||
'Project loaded!',
|
||||
'Now you can start calling `sequence.play()` to trigger animations. ;)',
|
||||
)
|
||||
})
|
||||
|
||||
ReactDOM.render(
|
||||
<Scene
|
||||
project={getProject('Sample project', {
|
||||
// experiments: {
|
||||
// logging: {
|
||||
// internal: true,
|
||||
// dev: true,
|
||||
// min: TheatreLoggerLevel.TRACE,
|
||||
// },
|
||||
// },
|
||||
})}
|
||||
/>,
|
||||
document.getElementById('root'),
|
||||
)
|
150
packages/playground/src/shared/notifications/useDrag.ts
Normal file
150
packages/playground/src/shared/notifications/useDrag.ts
Normal file
|
@ -0,0 +1,150 @@
|
|||
import {useLayoutEffect, useRef} from 'react'
|
||||
|
||||
const noop = () => {}
|
||||
|
||||
function createCursorLock(cursor: string) {
|
||||
const el = document.createElement('div')
|
||||
el.style.cssText = `
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 9999999;`
|
||||
|
||||
el.style.cursor = cursor
|
||||
document.body.appendChild(el)
|
||||
const relinquish = () => {
|
||||
document.body.removeChild(el)
|
||||
}
|
||||
|
||||
return relinquish
|
||||
}
|
||||
|
||||
export type UseDragOpts = {
|
||||
disabled?: boolean
|
||||
dontBlockMouseDown?: boolean
|
||||
lockCursorTo?: string
|
||||
onDragStart?: (event: MouseEvent) => void | false
|
||||
onDragEnd?: (dragHappened: boolean) => void
|
||||
onDrag: (dx: number, dy: number, event: MouseEvent) => void
|
||||
}
|
||||
|
||||
export default function useDrag(
|
||||
target: HTMLElement | undefined | null,
|
||||
opts: UseDragOpts,
|
||||
) {
|
||||
const optsRef = useRef<typeof opts>(opts)
|
||||
optsRef.current = opts
|
||||
|
||||
const modeRef = useRef<'dragStartCalled' | 'dragging' | 'notDragging'>(
|
||||
'notDragging',
|
||||
)
|
||||
|
||||
const stateRef = useRef<{
|
||||
dragHappened: boolean
|
||||
startPos: {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
}>({dragHappened: false, startPos: {x: 0, y: 0}})
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!target) return
|
||||
|
||||
const getDistances = (event: MouseEvent): [number, number] => {
|
||||
const {startPos} = stateRef.current
|
||||
return [event.screenX - startPos.x, event.screenY - startPos.y]
|
||||
}
|
||||
|
||||
let relinquishCursorLock = noop
|
||||
|
||||
const dragHandler = (event: MouseEvent) => {
|
||||
if (!stateRef.current.dragHappened && optsRef.current.lockCursorTo) {
|
||||
relinquishCursorLock = createCursorLock(optsRef.current.lockCursorTo)
|
||||
}
|
||||
if (!stateRef.current.dragHappened) stateRef.current.dragHappened = true
|
||||
modeRef.current = 'dragging'
|
||||
|
||||
const deltas = getDistances(event)
|
||||
optsRef.current.onDrag(deltas[0], deltas[1], event)
|
||||
}
|
||||
|
||||
const dragEndHandler = () => {
|
||||
removeDragListeners()
|
||||
modeRef.current = 'notDragging'
|
||||
|
||||
optsRef.current.onDragEnd &&
|
||||
optsRef.current.onDragEnd(stateRef.current.dragHappened)
|
||||
relinquishCursorLock()
|
||||
relinquishCursorLock = noop
|
||||
}
|
||||
|
||||
const addDragListeners = () => {
|
||||
document.addEventListener('mousemove', dragHandler)
|
||||
document.addEventListener('mouseup', dragEndHandler)
|
||||
}
|
||||
|
||||
const removeDragListeners = () => {
|
||||
document.removeEventListener('mousemove', dragHandler)
|
||||
document.removeEventListener('mouseup', dragEndHandler)
|
||||
}
|
||||
|
||||
const preventUnwantedClick = (event: MouseEvent) => {
|
||||
if (optsRef.current.disabled) return
|
||||
if (stateRef.current.dragHappened) {
|
||||
if (
|
||||
!optsRef.current.dontBlockMouseDown &&
|
||||
modeRef.current !== 'notDragging'
|
||||
) {
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
}
|
||||
stateRef.current.dragHappened = false
|
||||
}
|
||||
}
|
||||
|
||||
const dragStartHandler = (event: MouseEvent) => {
|
||||
const opts = optsRef.current
|
||||
if (opts.disabled === true) return
|
||||
|
||||
if (event.button !== 0) return
|
||||
const resultOfStart = opts.onDragStart && opts.onDragStart(event)
|
||||
|
||||
if (resultOfStart === false) return
|
||||
|
||||
if (!opts.dontBlockMouseDown) {
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
modeRef.current = 'dragStartCalled'
|
||||
|
||||
const {screenX, screenY} = event
|
||||
stateRef.current.startPos = {x: screenX, y: screenY}
|
||||
stateRef.current.dragHappened = false
|
||||
|
||||
addDragListeners()
|
||||
}
|
||||
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
dragStartHandler(e)
|
||||
}
|
||||
|
||||
target.addEventListener('mousedown', onMouseDown)
|
||||
target.addEventListener('click', preventUnwantedClick)
|
||||
|
||||
return () => {
|
||||
removeDragListeners()
|
||||
target.removeEventListener('mousedown', onMouseDown)
|
||||
target.removeEventListener('click', preventUnwantedClick)
|
||||
relinquishCursorLock()
|
||||
|
||||
if (modeRef.current !== 'notDragging') {
|
||||
optsRef.current.onDragEnd &&
|
||||
optsRef.current.onDragEnd(modeRef.current === 'dragging')
|
||||
}
|
||||
modeRef.current = 'notDragging'
|
||||
}
|
||||
}, [target])
|
||||
}
|
|
@ -5,6 +5,7 @@ import type {IPlaybackDirection, IPlaybackRange} from './Sequence'
|
|||
import AudioPlaybackController from './playbackControllers/AudioPlaybackController'
|
||||
import coreTicker from '@theatre/core/coreTicker'
|
||||
import type {Pointer} from '@theatre/dataverse'
|
||||
import {notify} from '@theatre/shared/notify'
|
||||
|
||||
interface IAttachAudioArgs {
|
||||
/**
|
||||
|
@ -239,16 +240,25 @@ export default class TheatreSequence implements ISequence {
|
|||
return priv.play(conf)
|
||||
} else {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.warn(
|
||||
`You seem to have called sequence.play() before the project has finished loading.\n` +
|
||||
`This would **not** a problem in production when using '@theatre/core', since Theatre.js loads instantly in core mode. ` +
|
||||
`However, when using '@theatre/studio', it takes a few milliseconds for it to load your project's state, ` +
|
||||
notify.warning(
|
||||
"Sequence can't be played",
|
||||
'You seem to have called `sequence.play()` before the project has finished loading.\n\n' +
|
||||
'This would **not** a problem in production when using `@theatre/core`, since Theatre.js loads instantly in core mode. ' +
|
||||
"However, when using `@theatre/studio`, it takes a few milliseconds for it to load your project's state, " +
|
||||
`before which your sequences cannot start playing.\n` +
|
||||
`\n` +
|
||||
`To fix this, simply defer calling sequence.play() until after the project is loaded, like this:\n` +
|
||||
'To fix this, simply defer calling `sequence.play()` until after the project is loaded, like this:\n' +
|
||||
'```\n' +
|
||||
`project.ready.then(() => {\n` +
|
||||
` sequence.play()\n` +
|
||||
`})`,
|
||||
`})\n` +
|
||||
'```\n',
|
||||
[
|
||||
{
|
||||
url: 'https://www.theatrejs.com/docs/0.5/api/core#project.ready',
|
||||
title: 'Project.ready',
|
||||
},
|
||||
],
|
||||
)
|
||||
}
|
||||
const d = defer<boolean>()
|
||||
|
|
|
@ -72,6 +72,7 @@
|
|||
"react-colorful": "^5.5.1",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-error-boundary": "^3.1.3",
|
||||
"react-hot-toast": "^2.4.0",
|
||||
"react-icons": "^4.2.0",
|
||||
"react-is": "^17.0.2",
|
||||
"react-merge-refs": "^1.1.0",
|
||||
|
@ -85,6 +86,7 @@
|
|||
"rollup": "^2.56.3",
|
||||
"rollup-plugin-dts": "^4.0.0",
|
||||
"shallowequal": "^1.1.0",
|
||||
"snarkdown": "^2.0.0",
|
||||
"styled-components": "^5.3.5",
|
||||
"svg-inline-loader": "^0.8.2",
|
||||
"timing-function": "^0.2.3",
|
||||
|
|
|
@ -4,3 +4,4 @@
|
|||
*/
|
||||
export const studioBundle = '__TheatreJS_StudioBundle'
|
||||
export const coreBundle = '__TheatreJS_CoreBundle'
|
||||
export const notifications = '__TheatreJS_Notifications'
|
||||
|
|
57
theatre/shared/src/notify.ts
Normal file
57
theatre/shared/src/notify.ts
Normal file
|
@ -0,0 +1,57 @@
|
|||
import logger from './logger'
|
||||
import * as globalVariableNames from './globalVariableNames'
|
||||
|
||||
export type Notification = {title: string; message: string}
|
||||
export type NotificationType = 'info' | 'success' | 'warning'
|
||||
export type Notify = (
|
||||
/**
|
||||
* The title of the notification.
|
||||
*/
|
||||
title: string,
|
||||
/**
|
||||
* The message of the notification.
|
||||
*/
|
||||
message: string,
|
||||
/**
|
||||
* An array of doc pages to link to.
|
||||
*/
|
||||
docs?: {url: string; title: string}[],
|
||||
/**
|
||||
* Whether duplicate notifications should be allowed.
|
||||
*/
|
||||
allowDuplicates?: boolean,
|
||||
) => void
|
||||
export type Notifiers = {
|
||||
/**
|
||||
* Show a success notification.
|
||||
*/
|
||||
success: Notify
|
||||
/**
|
||||
* Show a warning notification.
|
||||
*
|
||||
* Say what happened in the title.
|
||||
* In the message, start with 1) a reassurance, then 2) explain why it happened, and 3) what the user can do about it.
|
||||
*/
|
||||
warning: Notify
|
||||
/**
|
||||
* Show an info notification.
|
||||
*/
|
||||
info: Notify
|
||||
}
|
||||
|
||||
const createHandler =
|
||||
(type: NotificationType): Notify =>
|
||||
(...args) => {
|
||||
if (type === 'warning') {
|
||||
logger.warn(args[1])
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
return window?.[globalVariableNames.notifications]?.notify[type](...args)
|
||||
}
|
||||
|
||||
export const notify: Notifiers = {
|
||||
warning: createHandler('warning'),
|
||||
success: createHandler('success'),
|
||||
info: createHandler('info'),
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
import logger from '@theatre/shared/logger'
|
||||
import {InvalidArgumentError} from './errors'
|
||||
import {notify} from '@theatre/shared/notify'
|
||||
|
||||
/**
|
||||
* Make the given string's "path" slashes normalized with preceding and trailing spaces.
|
||||
|
@ -46,9 +46,20 @@ export function validateAndSanitiseSlashedPathOrThrow(
|
|||
)
|
||||
}
|
||||
if (unsanitisedPath !== sanitisedPath) {
|
||||
logger.warn(
|
||||
// @todo better error message needed. What's the call to action?
|
||||
`The path in ${fnName}("${unsanitisedPath}") was sanitised to "${sanitisedPath}".`,
|
||||
notify.warning(
|
||||
'Invalid path provided to object',
|
||||
`The path in \`${fnName}("${unsanitisedPath}")\` was sanitized to \`"${sanitisedPath}"\`.\n\n` +
|
||||
'Please replace the path with the sanitized one, otherwise it will likely break in the future.',
|
||||
[
|
||||
{
|
||||
url: 'https://www.theatrejs.com/docs/latest/manual/objects#creating-sheet-objects',
|
||||
title: 'Sheet Objects',
|
||||
},
|
||||
{
|
||||
url: 'https://www.theatrejs.com/docs/latest/api/core#sheet.object',
|
||||
title: 'API',
|
||||
},
|
||||
],
|
||||
)
|
||||
}
|
||||
return sanitisedPath
|
||||
|
|
|
@ -19,6 +19,7 @@ import {
|
|||
TheatreLoggerLevel,
|
||||
} from '@theatre/shared/logger'
|
||||
import {ProvideLogger} from '@theatre/studio/uiComponents/useLogger'
|
||||
import {Notifier} from '@theatre/studio/notify'
|
||||
|
||||
const MakeRootHostContainStatic =
|
||||
typeof window !== 'undefined'
|
||||
|
@ -32,7 +33,7 @@ const MakeRootHostContainStatic =
|
|||
const Container = styled(PointerEventsHandler)`
|
||||
z-index: 50;
|
||||
position: fixed;
|
||||
inset: 0px;
|
||||
inset: 0;
|
||||
|
||||
&.invisible {
|
||||
pointer-events: none !important;
|
||||
|
@ -99,6 +100,7 @@ export default function UIRoot() {
|
|||
<PortalLayer ref={portalLayerRef} />
|
||||
<GlobalToolbar />
|
||||
<PanelsRoot />
|
||||
<Notifier />
|
||||
</Container>
|
||||
</>
|
||||
</ProvideStyles>
|
||||
|
|
|
@ -73,6 +73,13 @@ function registerStudioBundle() {
|
|||
// export {default as ToolbarIconButton} from './uiComponents/toolbar/ToolbarIconButton'
|
||||
export {default as ToolbarDropdownSelect} from './uiComponents/toolbar/ToolbarDropdownSelect'
|
||||
|
||||
import {notify} from '@theatre/studio/notify'
|
||||
// @ts-ignore
|
||||
window[globalVariableNames.notifications] = {
|
||||
notify,
|
||||
}
|
||||
export {notify}
|
||||
|
||||
export type {IScrub} from '@theatre/studio/Scrub'
|
||||
export type {
|
||||
IStudio,
|
||||
|
|
446
theatre/studio/src/notify.tsx
Normal file
446
theatre/studio/src/notify.tsx
Normal file
|
@ -0,0 +1,446 @@
|
|||
import React, {Fragment} from 'react'
|
||||
import toast, {useToaster} from 'react-hot-toast/headless'
|
||||
import styled from 'styled-components'
|
||||
import snarkdown from 'snarkdown'
|
||||
import {pointerEventsAutoInNormalMode} from './css'
|
||||
import type {
|
||||
Notification,
|
||||
NotificationType,
|
||||
Notify,
|
||||
Notifiers,
|
||||
} from '@theatre/shared/notify'
|
||||
import {useVal} from '@theatre/react'
|
||||
import getStudio from './getStudio'
|
||||
|
||||
/**
|
||||
* Creates a string key unique to a notification with a certain title and message.
|
||||
*/
|
||||
const hashNotification = ({title, message}: Notification) =>
|
||||
`${title} ${message}`
|
||||
|
||||
/**
|
||||
* Used to check if a notification with a certain title and message is already displayed.
|
||||
*/
|
||||
const notificationUniquenessChecker = (() => {
|
||||
const map = new Map<string, number>()
|
||||
return {
|
||||
add: (notification: Notification) => {
|
||||
const key = hashNotification(notification)
|
||||
if (map.has(key)) {
|
||||
map.set(key, map.get(key)! + 1)
|
||||
} else {
|
||||
map.set(key, 1)
|
||||
}
|
||||
},
|
||||
delete: (notification: Notification) => {
|
||||
const key = hashNotification(notification)
|
||||
if (map.has(key) && map.get(key)! > 1) {
|
||||
map.set(key, map.get(key)! - 1)
|
||||
} else {
|
||||
map.delete(key)
|
||||
}
|
||||
},
|
||||
clear: () => {
|
||||
map.clear()
|
||||
},
|
||||
check: (notification: Notification) =>
|
||||
map.has(hashNotification(notification)),
|
||||
}
|
||||
})()
|
||||
|
||||
/**
|
||||
* Used to check if a notification with a certain type is already displayed.
|
||||
*
|
||||
* Massive hack, we should be able to attach this info to toasts.
|
||||
*/
|
||||
const notificationTypeChecker = (() => {
|
||||
const map = new Map<NotificationType, number>()
|
||||
return {
|
||||
add: (type: NotificationType) => {
|
||||
if (map.has(type)) {
|
||||
map.set(type, map.get(type)! + 1)
|
||||
} else {
|
||||
map.set(type, 1)
|
||||
}
|
||||
},
|
||||
delete: (type: NotificationType) => {
|
||||
if (map.has(type) && map.get(type)! > 1) {
|
||||
map.set(type, map.get(type)! - 1)
|
||||
} else {
|
||||
map.delete(type)
|
||||
}
|
||||
},
|
||||
clear: () => {
|
||||
map.clear()
|
||||
},
|
||||
check: (type: NotificationType) => map.has(type),
|
||||
get types() {
|
||||
return Array.of(...map.keys())
|
||||
},
|
||||
}
|
||||
})()
|
||||
|
||||
//region Styles
|
||||
const NotificationContainer = styled.div`
|
||||
width: 100%;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
${pointerEventsAutoInNormalMode};
|
||||
background-color: rgba(40, 43, 47, 0.8);
|
||||
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.25), 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||
backdrop-filter: blur(14px);
|
||||
|
||||
@supports not (backdrop-filter: blur()) {
|
||||
background: rgba(40, 43, 47, 0.95);
|
||||
}
|
||||
`
|
||||
|
||||
const NotificationTitle = styled.div`
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
`
|
||||
|
||||
const NotificationMain = styled.div`
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
width: 0;
|
||||
display: flex;
|
||||
padding: 16px 0;
|
||||
gap: 12px;
|
||||
`
|
||||
|
||||
const NotificationMessage = styled.div`
|
||||
color: #b4b4b4;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
|
||||
a {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
hr {
|
||||
visibility: hidden;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
em {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: bold;
|
||||
color: #d5d5d5;
|
||||
}
|
||||
|
||||
.code {
|
||||
font-family: monospace;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 4px;
|
||||
margin-bottom: 8px;
|
||||
margin-top: 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
:not(.code) > code {
|
||||
font-family: monospace;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 1px 1px 2px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
`
|
||||
|
||||
const DismissButton = styled.button`
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.05);
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
`
|
||||
|
||||
const COLORS = {
|
||||
info: '#3b82f6',
|
||||
success: '#10b981',
|
||||
warning: '#f59e0b',
|
||||
}
|
||||
|
||||
const IndicatorDot = styled.div<{type: NotificationType}>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: 12px;
|
||||
|
||||
::before {
|
||||
content: '';
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999999px;
|
||||
background-color: ${({type}) => COLORS[type]};
|
||||
}
|
||||
`
|
||||
//endregion
|
||||
|
||||
/**
|
||||
* Replaces <br /> tags with <hr /> tags. We do this because snarkdown outputs <br />
|
||||
* between paragraphs, which are not styleable.
|
||||
*
|
||||
* A better solution would be to use a markdown parser that outputs <p> tags instead of <br />.
|
||||
*/
|
||||
const replaceBrWithHr = (text: string) => {
|
||||
return text.replace(/<br \/>/g, '<hr />')
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms the provided notification message into HTML.
|
||||
*/
|
||||
const massageMessage = (message: string) => {
|
||||
return replaceBrWithHr(snarkdown(message))
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates handlers for different types of notifications.
|
||||
*/
|
||||
const createHandler =
|
||||
(type: 'warning' | 'success' | 'info'): Notify =>
|
||||
(title, message, docs = [], allowDuplicates = false) => {
|
||||
// We can disallow duplicates. We do this through checking the notification contents
|
||||
// against a registry of already displayed notifications.
|
||||
if (
|
||||
allowDuplicates ||
|
||||
!notificationUniquenessChecker.check({title, message})
|
||||
) {
|
||||
notificationUniquenessChecker.add({title, message})
|
||||
// We have not way sadly to attach custom notification types to react-hot-toast toasts,
|
||||
// so we use our own data structure for it.
|
||||
notificationTypeChecker.add(type)
|
||||
toast.custom(
|
||||
(t) => (
|
||||
<NotificationContainer>
|
||||
<IndicatorDot type={type} />
|
||||
<NotificationMain>
|
||||
<NotificationTitle>{title}</NotificationTitle>
|
||||
<NotificationMessage
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: massageMessage(message),
|
||||
}}
|
||||
/>
|
||||
{docs.length > 0 && (
|
||||
<NotificationMessage>
|
||||
<span>
|
||||
Docs:{' '}
|
||||
{docs.map((doc, i) => (
|
||||
<Fragment key={i}>
|
||||
{i > 0 && ', '}
|
||||
<a target="_blank" href={doc.url}>
|
||||
{doc.title}
|
||||
</a>
|
||||
</Fragment>
|
||||
))}
|
||||
</span>
|
||||
</NotificationMessage>
|
||||
)}
|
||||
</NotificationMain>
|
||||
<DismissButton
|
||||
onClick={() => {
|
||||
toast.remove(t.id)
|
||||
notificationUniquenessChecker.delete({title, message})
|
||||
notificationTypeChecker.delete(type)
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</DismissButton>
|
||||
</NotificationContainer>
|
||||
),
|
||||
{duration: Infinity},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const notify: Notifiers = {
|
||||
warning: createHandler('warning'),
|
||||
success: createHandler('success'),
|
||||
info: createHandler('info'),
|
||||
}
|
||||
|
||||
//region Styles
|
||||
const ButtonContainer = styled.div<{
|
||||
align: 'center' | 'side'
|
||||
danger?: boolean
|
||||
}>`
|
||||
display: flex;
|
||||
justify-content: ${({align}) => (align === 'center' ? 'center' : 'flex-end')};
|
||||
gap: 12px;
|
||||
`
|
||||
|
||||
const Button = styled.button<{danger?: boolean}>`
|
||||
position: relative;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
${pointerEventsAutoInNormalMode};
|
||||
background-color: rgba(40, 43, 47, 0.8);
|
||||
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.25), 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||
backdrop-filter: blur(14px);
|
||||
border: none;
|
||||
padding: 12px;
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
|
||||
::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
:hover::before {
|
||||
background: ${({danger}) =>
|
||||
danger ? 'rgba(255, 0, 0, 0.1)' : 'rgba(255, 255, 255, 0.1)'};
|
||||
}
|
||||
|
||||
@supports not (backdrop-filter: blur()) {
|
||||
background: rgba(40, 43, 47, 0.95);
|
||||
}
|
||||
`
|
||||
|
||||
const Dots = styled.span`
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
`
|
||||
|
||||
const NotificationsDot = styled.div<{type: NotificationType}>`
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999999px;
|
||||
background-color: ${({type}) => COLORS[type]};
|
||||
`
|
||||
|
||||
const NotifierContainer = styled.div`
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
gap: 8px;
|
||||
position: fixed;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
width: 500px;
|
||||
height: 50vh;
|
||||
min-height: 400px;
|
||||
`
|
||||
|
||||
const NotificationScroller = styled.div`
|
||||
overflow: hidden;
|
||||
pointer-events: auto;
|
||||
border-radius: 4px;
|
||||
|
||||
& > div {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
gap: 8px;
|
||||
overflow: scroll;
|
||||
height: 100%;
|
||||
}
|
||||
`
|
||||
|
||||
const EmptyState = styled.div`
|
||||
align-self: flex-end;
|
||||
width: fit-content;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
${pointerEventsAutoInNormalMode};
|
||||
background-color: rgba(40, 43, 47, 0.8);
|
||||
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.25), 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||
backdrop-filter: blur(14px);
|
||||
color: #b4b4b4;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
|
||||
@supports not (backdrop-filter: blur()) {
|
||||
background: rgba(40, 43, 47, 0.95);
|
||||
}
|
||||
`
|
||||
//endregion
|
||||
|
||||
/**
|
||||
* The component responsible for rendering the notifications.
|
||||
*/
|
||||
export const Notifier = () => {
|
||||
const {toasts, handlers} = useToaster()
|
||||
const {startPause, endPause} = handlers
|
||||
|
||||
const pinNotifications =
|
||||
useVal(getStudio().atomP.ahistoric.pinNotifications) ?? true
|
||||
const togglePinNotifications = () =>
|
||||
getStudio().transaction(({stateEditors, drafts}) => {
|
||||
stateEditors.studio.ahistoric.setPinNotifications(
|
||||
!(drafts.ahistoric.pinNotifications ?? true),
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<NotifierContainer>
|
||||
<ButtonContainer align="side">
|
||||
<>
|
||||
{pinNotifications && toasts.length > 0 && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
notificationTypeChecker.clear()
|
||||
notificationUniquenessChecker.clear()
|
||||
toast.remove()
|
||||
}}
|
||||
danger
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => togglePinNotifications()}>
|
||||
<span>Notifications</span>
|
||||
{notificationTypeChecker.types.length > 0 && (
|
||||
<Dots>
|
||||
{notificationTypeChecker.types.map((type) => (
|
||||
<NotificationsDot type={type} key={type} />
|
||||
))}
|
||||
</Dots>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
</ButtonContainer>
|
||||
{!pinNotifications ? null : toasts.length > 0 ? (
|
||||
<NotificationScroller onMouseEnter={startPause} onMouseLeave={endPause}>
|
||||
<div>
|
||||
{toasts.map((toast) => {
|
||||
return (
|
||||
<div key={toast.id}>
|
||||
{/* message is always a function in our case */}
|
||||
{/* @ts-ignore */}
|
||||
{toast.message(toast)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</NotificationScroller>
|
||||
) : (
|
||||
<EmptyState>
|
||||
<NotificationTitle>No notifications</NotificationTitle>
|
||||
Notifications will appear here when you get them.
|
||||
</EmptyState>
|
||||
)}
|
||||
</NotifierContainer>
|
||||
)
|
||||
}
|
|
@ -419,6 +419,11 @@ namespace stateEditors {
|
|||
) {
|
||||
drafts().ahistoric.pinDetails = pinDetails
|
||||
}
|
||||
export function setPinNotifications(
|
||||
pinNotifications: StudioAhistoricState['pinNotifications'],
|
||||
) {
|
||||
drafts().ahistoric.pinNotifications = pinNotifications
|
||||
}
|
||||
export function setVisibilityState(
|
||||
visibilityState: StudioAhistoricState['visibilityState'],
|
||||
) {
|
||||
|
|
|
@ -23,6 +23,7 @@ export type StudioAhistoricState = {
|
|||
* undefined means the detail panel is pinned
|
||||
*/
|
||||
pinDetails?: boolean
|
||||
pinNotifications?: boolean
|
||||
visibilityState: 'everythingIsHidden' | 'everythingIsVisible'
|
||||
clipboard?: {
|
||||
keyframesWithRelativePaths?: KeyframeWithPathToPropFromCommonRoot[]
|
||||
|
|
30
yarn.lock
30
yarn.lock
|
@ -17217,6 +17217,15 @@ fsevents@^1.2.7:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"goober@npm:^2.1.10":
|
||||
version: 2.1.11
|
||||
resolution: "goober@npm:2.1.11"
|
||||
peerDependencies:
|
||||
csstype: ^3.0.10
|
||||
checksum: c37c14f476f65f7c66d94ac1a1686a130b4fa42fc512175444e3c708fa1815f6ea579e1b23567e57d41de2e62ff8b5913915ec829f05a3b995d1a7a0016c0bdf
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"graceful-fs@npm:^4.1.11, graceful-fs@npm:^4.1.15, graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6":
|
||||
version: 4.2.6
|
||||
resolution: "graceful-fs@npm:4.2.6"
|
||||
|
@ -26134,6 +26143,18 @@ fsevents@^1.2.7:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-hot-toast@npm:^2.4.0":
|
||||
version: 2.4.0
|
||||
resolution: "react-hot-toast@npm:2.4.0"
|
||||
dependencies:
|
||||
goober: ^2.1.10
|
||||
peerDependencies:
|
||||
react: ">=16"
|
||||
react-dom: ">=16"
|
||||
checksum: 910214496d6821af8e643c5f7087eff6c76f1395756f3d6cf1fc07a599c582c443cbb5a4cadfc58205acf0845b77a2a10a4ca49b385e772b72edc57dfd38d016
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-icons@npm:*, react-icons@npm:^4.2.0":
|
||||
version: 4.2.0
|
||||
resolution: "react-icons@npm:4.2.0"
|
||||
|
@ -28211,6 +28232,13 @@ fsevents@^1.2.7:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"snarkdown@npm:^2.0.0":
|
||||
version: 2.0.0
|
||||
resolution: "snarkdown@npm:2.0.0"
|
||||
checksum: e92bb254aad8c50da610e2f5f770360542b8412cb1dd6512aaf1f213322f474acd71c882d557cfadb54c34dd1041ee8677bc44061568d60df7cee54134e047e5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"sockjs-client@npm:^1.5.0":
|
||||
version: 1.5.1
|
||||
resolution: "sockjs-client@npm:1.5.1"
|
||||
|
@ -29684,6 +29712,7 @@ fsevents@^1.2.7:
|
|||
react-colorful: ^5.5.1
|
||||
react-dom: ^17.0.2
|
||||
react-error-boundary: ^3.1.3
|
||||
react-hot-toast: ^2.4.0
|
||||
react-icons: ^4.2.0
|
||||
react-is: ^17.0.2
|
||||
react-merge-refs: ^1.1.0
|
||||
|
@ -29697,6 +29726,7 @@ fsevents@^1.2.7:
|
|||
rollup: ^2.56.3
|
||||
rollup-plugin-dts: ^4.0.0
|
||||
shallowequal: ^1.1.0
|
||||
snarkdown: ^2.0.0
|
||||
styled-components: ^5.3.5
|
||||
svg-inline-loader: ^0.8.2
|
||||
timing-function: ^0.2.3
|
||||
|
|
Loading…
Reference in a new issue