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 AudioPlaybackController from './playbackControllers/AudioPlaybackController'
|
||||||
import coreTicker from '@theatre/core/coreTicker'
|
import coreTicker from '@theatre/core/coreTicker'
|
||||||
import type {Pointer} from '@theatre/dataverse'
|
import type {Pointer} from '@theatre/dataverse'
|
||||||
|
import {notify} from '@theatre/shared/notify'
|
||||||
|
|
||||||
interface IAttachAudioArgs {
|
interface IAttachAudioArgs {
|
||||||
/**
|
/**
|
||||||
|
@ -239,16 +240,25 @@ export default class TheatreSequence implements ISequence {
|
||||||
return priv.play(conf)
|
return priv.play(conf)
|
||||||
} else {
|
} else {
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
console.warn(
|
notify.warning(
|
||||||
`You seem to have called sequence.play() before the project has finished loading.\n` +
|
"Sequence can't be played",
|
||||||
`This would **not** a problem in production when using '@theatre/core', since Theatre.js loads instantly in core mode. ` +
|
'You seem to have called `sequence.play()` before the project has finished loading.\n\n' +
|
||||||
`However, when using '@theatre/studio', it takes a few milliseconds for it to load your project's state, ` +
|
'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` +
|
`before which your sequences cannot start playing.\n` +
|
||||||
`\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` +
|
`project.ready.then(() => {\n` +
|
||||||
` sequence.play()\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>()
|
const d = defer<boolean>()
|
||||||
|
|
|
@ -72,6 +72,7 @@
|
||||||
"react-colorful": "^5.5.1",
|
"react-colorful": "^5.5.1",
|
||||||
"react-dom": "^17.0.2",
|
"react-dom": "^17.0.2",
|
||||||
"react-error-boundary": "^3.1.3",
|
"react-error-boundary": "^3.1.3",
|
||||||
|
"react-hot-toast": "^2.4.0",
|
||||||
"react-icons": "^4.2.0",
|
"react-icons": "^4.2.0",
|
||||||
"react-is": "^17.0.2",
|
"react-is": "^17.0.2",
|
||||||
"react-merge-refs": "^1.1.0",
|
"react-merge-refs": "^1.1.0",
|
||||||
|
@ -85,6 +86,7 @@
|
||||||
"rollup": "^2.56.3",
|
"rollup": "^2.56.3",
|
||||||
"rollup-plugin-dts": "^4.0.0",
|
"rollup-plugin-dts": "^4.0.0",
|
||||||
"shallowequal": "^1.1.0",
|
"shallowequal": "^1.1.0",
|
||||||
|
"snarkdown": "^2.0.0",
|
||||||
"styled-components": "^5.3.5",
|
"styled-components": "^5.3.5",
|
||||||
"svg-inline-loader": "^0.8.2",
|
"svg-inline-loader": "^0.8.2",
|
||||||
"timing-function": "^0.2.3",
|
"timing-function": "^0.2.3",
|
||||||
|
|
|
@ -4,3 +4,4 @@
|
||||||
*/
|
*/
|
||||||
export const studioBundle = '__TheatreJS_StudioBundle'
|
export const studioBundle = '__TheatreJS_StudioBundle'
|
||||||
export const coreBundle = '__TheatreJS_CoreBundle'
|
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 {InvalidArgumentError} from './errors'
|
||||||
|
import {notify} from '@theatre/shared/notify'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Make the given string's "path" slashes normalized with preceding and trailing spaces.
|
* Make the given string's "path" slashes normalized with preceding and trailing spaces.
|
||||||
|
@ -46,9 +46,20 @@ export function validateAndSanitiseSlashedPathOrThrow(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (unsanitisedPath !== sanitisedPath) {
|
if (unsanitisedPath !== sanitisedPath) {
|
||||||
logger.warn(
|
notify.warning(
|
||||||
// @todo better error message needed. What's the call to action?
|
'Invalid path provided to object',
|
||||||
`The path in ${fnName}("${unsanitisedPath}") was sanitised to "${sanitisedPath}".`,
|
`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
|
return sanitisedPath
|
||||||
|
|
|
@ -19,6 +19,7 @@ import {
|
||||||
TheatreLoggerLevel,
|
TheatreLoggerLevel,
|
||||||
} from '@theatre/shared/logger'
|
} from '@theatre/shared/logger'
|
||||||
import {ProvideLogger} from '@theatre/studio/uiComponents/useLogger'
|
import {ProvideLogger} from '@theatre/studio/uiComponents/useLogger'
|
||||||
|
import {Notifier} from '@theatre/studio/notify'
|
||||||
|
|
||||||
const MakeRootHostContainStatic =
|
const MakeRootHostContainStatic =
|
||||||
typeof window !== 'undefined'
|
typeof window !== 'undefined'
|
||||||
|
@ -32,7 +33,7 @@ const MakeRootHostContainStatic =
|
||||||
const Container = styled(PointerEventsHandler)`
|
const Container = styled(PointerEventsHandler)`
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0px;
|
inset: 0;
|
||||||
|
|
||||||
&.invisible {
|
&.invisible {
|
||||||
pointer-events: none !important;
|
pointer-events: none !important;
|
||||||
|
@ -99,6 +100,7 @@ export default function UIRoot() {
|
||||||
<PortalLayer ref={portalLayerRef} />
|
<PortalLayer ref={portalLayerRef} />
|
||||||
<GlobalToolbar />
|
<GlobalToolbar />
|
||||||
<PanelsRoot />
|
<PanelsRoot />
|
||||||
|
<Notifier />
|
||||||
</Container>
|
</Container>
|
||||||
</>
|
</>
|
||||||
</ProvideStyles>
|
</ProvideStyles>
|
||||||
|
|
|
@ -73,6 +73,13 @@ function registerStudioBundle() {
|
||||||
// export {default as ToolbarIconButton} from './uiComponents/toolbar/ToolbarIconButton'
|
// export {default as ToolbarIconButton} from './uiComponents/toolbar/ToolbarIconButton'
|
||||||
export {default as ToolbarDropdownSelect} from './uiComponents/toolbar/ToolbarDropdownSelect'
|
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 {IScrub} from '@theatre/studio/Scrub'
|
||||||
export type {
|
export type {
|
||||||
IStudio,
|
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
|
drafts().ahistoric.pinDetails = pinDetails
|
||||||
}
|
}
|
||||||
|
export function setPinNotifications(
|
||||||
|
pinNotifications: StudioAhistoricState['pinNotifications'],
|
||||||
|
) {
|
||||||
|
drafts().ahistoric.pinNotifications = pinNotifications
|
||||||
|
}
|
||||||
export function setVisibilityState(
|
export function setVisibilityState(
|
||||||
visibilityState: StudioAhistoricState['visibilityState'],
|
visibilityState: StudioAhistoricState['visibilityState'],
|
||||||
) {
|
) {
|
||||||
|
|
|
@ -23,6 +23,7 @@ export type StudioAhistoricState = {
|
||||||
* undefined means the detail panel is pinned
|
* undefined means the detail panel is pinned
|
||||||
*/
|
*/
|
||||||
pinDetails?: boolean
|
pinDetails?: boolean
|
||||||
|
pinNotifications?: boolean
|
||||||
visibilityState: 'everythingIsHidden' | 'everythingIsVisible'
|
visibilityState: 'everythingIsHidden' | 'everythingIsVisible'
|
||||||
clipboard?: {
|
clipboard?: {
|
||||||
keyframesWithRelativePaths?: KeyframeWithPathToPropFromCommonRoot[]
|
keyframesWithRelativePaths?: KeyframeWithPathToPropFromCommonRoot[]
|
||||||
|
|
30
yarn.lock
30
yarn.lock
|
@ -17217,6 +17217,15 @@ fsevents@^1.2.7:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"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
|
version: 4.2.6
|
||||||
resolution: "graceful-fs@npm:4.2.6"
|
resolution: "graceful-fs@npm:4.2.6"
|
||||||
|
@ -26134,6 +26143,18 @@ fsevents@^1.2.7:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"react-icons@npm:*, react-icons@npm:^4.2.0":
|
||||||
version: 4.2.0
|
version: 4.2.0
|
||||||
resolution: "react-icons@npm:4.2.0"
|
resolution: "react-icons@npm:4.2.0"
|
||||||
|
@ -28211,6 +28232,13 @@ fsevents@^1.2.7:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"sockjs-client@npm:^1.5.0":
|
||||||
version: 1.5.1
|
version: 1.5.1
|
||||||
resolution: "sockjs-client@npm:1.5.1"
|
resolution: "sockjs-client@npm:1.5.1"
|
||||||
|
@ -29684,6 +29712,7 @@ fsevents@^1.2.7:
|
||||||
react-colorful: ^5.5.1
|
react-colorful: ^5.5.1
|
||||||
react-dom: ^17.0.2
|
react-dom: ^17.0.2
|
||||||
react-error-boundary: ^3.1.3
|
react-error-boundary: ^3.1.3
|
||||||
|
react-hot-toast: ^2.4.0
|
||||||
react-icons: ^4.2.0
|
react-icons: ^4.2.0
|
||||||
react-is: ^17.0.2
|
react-is: ^17.0.2
|
||||||
react-merge-refs: ^1.1.0
|
react-merge-refs: ^1.1.0
|
||||||
|
@ -29697,6 +29726,7 @@ fsevents@^1.2.7:
|
||||||
rollup: ^2.56.3
|
rollup: ^2.56.3
|
||||||
rollup-plugin-dts: ^4.0.0
|
rollup-plugin-dts: ^4.0.0
|
||||||
shallowequal: ^1.1.0
|
shallowequal: ^1.1.0
|
||||||
|
snarkdown: ^2.0.0
|
||||||
styled-components: ^5.3.5
|
styled-components: ^5.3.5
|
||||||
svg-inline-loader: ^0.8.2
|
svg-inline-loader: ^0.8.2
|
||||||
timing-function: ^0.2.3
|
timing-function: ^0.2.3
|
||||||
|
|
Loading…
Reference in a new issue