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:
Andrew Prifer 2022-10-21 15:51:13 +02:00 committed by GitHub
parent ef5752cbd3
commit 62bc12ab51
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 818 additions and 11 deletions

View 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>
)
}

View 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'),
)

View 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])
}

View file

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

View file

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

View file

@ -4,3 +4,4 @@
*/
export const studioBundle = '__TheatreJS_StudioBundle'
export const coreBundle = '__TheatreJS_CoreBundle'
export const notifications = '__TheatreJS_Notifications'

View 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'),
}

View file

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

View file

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

View file

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

View 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>
)
}

View file

@ -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'],
) {

View file

@ -23,6 +23,7 @@ export type StudioAhistoricState = {
* undefined means the detail panel is pinned
*/
pinDetails?: boolean
pinNotifications?: boolean
visibilityState: 'everythingIsHidden' | 'everythingIsVisible'
clipboard?: {
keyframesWithRelativePaths?: KeyframeWithPathToPropFromCommonRoot[]

View file

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