diff --git a/packages/playground/src/shared/notifications/Scene.tsx b/packages/playground/src/shared/notifications/Scene.tsx new file mode 100644 index 0000000..f5c6dd5 --- /dev/null +++ b/packages/playground/src/shared/notifications/Scene.tsx @@ -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(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 ( +
+ ) +} diff --git a/packages/playground/src/shared/notifications/index.tsx b/packages/playground/src/shared/notifications/index.tsx new file mode 100644 index 0000000..40f65f4 --- /dev/null +++ b/packages/playground/src/shared/notifications/index.tsx @@ -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( + , + document.getElementById('root'), +) diff --git a/packages/playground/src/shared/notifications/useDrag.ts b/packages/playground/src/shared/notifications/useDrag.ts new file mode 100644 index 0000000..7f21101 --- /dev/null +++ b/packages/playground/src/shared/notifications/useDrag.ts @@ -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(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]) +} diff --git a/theatre/core/src/sequences/TheatreSequence.ts b/theatre/core/src/sequences/TheatreSequence.ts index 46ed91f..186c87d 100644 --- a/theatre/core/src/sequences/TheatreSequence.ts +++ b/theatre/core/src/sequences/TheatreSequence.ts @@ -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() diff --git a/theatre/package.json b/theatre/package.json index 36c34af..6303202 100644 --- a/theatre/package.json +++ b/theatre/package.json @@ -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", diff --git a/theatre/shared/src/globalVariableNames.ts b/theatre/shared/src/globalVariableNames.ts index 984d41f..086880c 100644 --- a/theatre/shared/src/globalVariableNames.ts +++ b/theatre/shared/src/globalVariableNames.ts @@ -4,3 +4,4 @@ */ export const studioBundle = '__TheatreJS_StudioBundle' export const coreBundle = '__TheatreJS_CoreBundle' +export const notifications = '__TheatreJS_Notifications' diff --git a/theatre/shared/src/notify.ts b/theatre/shared/src/notify.ts new file mode 100644 index 0000000..58f5ae4 --- /dev/null +++ b/theatre/shared/src/notify.ts @@ -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'), +} diff --git a/theatre/shared/src/utils/slashedPaths.ts b/theatre/shared/src/utils/slashedPaths.ts index 0e40675..3c3c9de 100644 --- a/theatre/shared/src/utils/slashedPaths.ts +++ b/theatre/shared/src/utils/slashedPaths.ts @@ -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 diff --git a/theatre/studio/src/UIRoot/UIRoot.tsx b/theatre/studio/src/UIRoot/UIRoot.tsx index 70ab205..2168fbb 100644 --- a/theatre/studio/src/UIRoot/UIRoot.tsx +++ b/theatre/studio/src/UIRoot/UIRoot.tsx @@ -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() { + diff --git a/theatre/studio/src/index.ts b/theatre/studio/src/index.ts index 5d18d79..b54a554 100644 --- a/theatre/studio/src/index.ts +++ b/theatre/studio/src/index.ts @@ -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, diff --git a/theatre/studio/src/notify.tsx b/theatre/studio/src/notify.tsx new file mode 100644 index 0000000..502497d --- /dev/null +++ b/theatre/studio/src/notify.tsx @@ -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() + 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() + 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
tags with
tags. We do this because snarkdown outputs
+ * between paragraphs, which are not styleable. + * + * A better solution would be to use a markdown parser that outputs

tags instead of
. + */ +const replaceBrWithHr = (text: string) => { + return text.replace(/
/g, '


') +} + +/** + * 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) => ( + + + + {title} + + {docs.length > 0 && ( + + + Docs:{' '} + {docs.map((doc, i) => ( + + {i > 0 && ', '} + + {doc.title} + + + ))} + + + )} + + { + toast.remove(t.id) + notificationUniquenessChecker.delete({title, message}) + notificationTypeChecker.delete(type) + }} + > + Close + + + ), + {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 ( + + + <> + {pinNotifications && toasts.length > 0 && ( + + )} + + + + {!pinNotifications ? null : toasts.length > 0 ? ( + +
+ {toasts.map((toast) => { + return ( +
+ {/* message is always a function in our case */} + {/* @ts-ignore */} + {toast.message(toast)} +
+ ) + })} +
+
+ ) : ( + + No notifications + Notifications will appear here when you get them. + + )} +
+ ) +} diff --git a/theatre/studio/src/store/stateEditors.ts b/theatre/studio/src/store/stateEditors.ts index fbdd789..f688fba 100644 --- a/theatre/studio/src/store/stateEditors.ts +++ b/theatre/studio/src/store/stateEditors.ts @@ -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'], ) { diff --git a/theatre/studio/src/store/types/ahistoric.ts b/theatre/studio/src/store/types/ahistoric.ts index 7aabb91..a21ee37 100644 --- a/theatre/studio/src/store/types/ahistoric.ts +++ b/theatre/studio/src/store/types/ahistoric.ts @@ -23,6 +23,7 @@ export type StudioAhistoricState = { * undefined means the detail panel is pinned */ pinDetails?: boolean + pinNotifications?: boolean visibilityState: 'everythingIsHidden' | 'everythingIsVisible' clipboard?: { keyframesWithRelativePaths?: KeyframeWithPathToPropFromCommonRoot[] diff --git a/yarn.lock b/yarn.lock index 0bbea0e..05240d3 100644 --- a/yarn.lock +++ b/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