diff --git a/theatre/studio/src/UIRoot/UIRoot.tsx b/theatre/studio/src/UIRoot/UIRoot.tsx
index b8c4a99..8bad5c9 100644
--- a/theatre/studio/src/UIRoot/UIRoot.tsx
+++ b/theatre/studio/src/UIRoot/UIRoot.tsx
@@ -12,6 +12,7 @@ import {PortalContext} from 'reakit'
import type {$IntentionalAny} from '@theatre/shared/utils/types'
import useKeyboardShortcuts from './useKeyboardShortcuts'
import PointerEventsHandler from '@theatre/studio/uiComponents/PointerEventsHandler'
+import TooltipContext from '@theatre/studio/uiComponents/Popover/TooltipContext'
const GlobalStyle = createGlobalStyle`
:host {
@@ -74,12 +75,14 @@ export default function UIRoot() {
-
-
- {shouldShowGlobalToolbar && }
- {shouldShowTrigger && }
- {shouldShowPanels && }
-
+
+
+
+ {shouldShowGlobalToolbar && }
+ {shouldShowTrigger && }
+ {shouldShowPanels && }
+
+
>
diff --git a/theatre/studio/src/panels/OutlinePanel/OutlinePanel.tsx b/theatre/studio/src/panels/OutlinePanel/OutlinePanel.tsx
index 30ec913..0487e8b 100644
--- a/theatre/studio/src/panels/OutlinePanel/OutlinePanel.tsx
+++ b/theatre/studio/src/panels/OutlinePanel/OutlinePanel.tsx
@@ -8,6 +8,9 @@ import {VscListTree} from 'react-icons/all'
import {usePrism} from '@theatre/dataverse-react'
import getStudio from '@theatre/studio/getStudio'
import {val} from '@theatre/dataverse'
+import useTooltip from '@theatre/studio/uiComponents/Popover/useTooltip'
+import type {$IntentionalAny} from '@theatre/shared/utils/types'
+import BasicTooltip from '@theatre/studio/uiComponents/Popover/BasicTooltip'
const Container = styled.div`
background-color: transparent;
@@ -29,8 +32,7 @@ const Container = styled.div`
width: 20px;
${pointerEventsAutoInNormalMode};
}
-
- &:hover:before {
+ ب &:hover:before {
top: -12px;
width: 300px;
}
@@ -59,6 +61,8 @@ const Content = styled.div`
}
`
+const ErrorTooltip = styled(BasicTooltip)``
+
const headerHeight = `32px`
const TriggerButton = styled(ToolbarIconButton)`
@@ -128,16 +132,30 @@ const OutlinePanel: React.FC<{}> = (props) => {
const ephemeralStateOfAllProjects = val(
getStudio().atomP.ephemeral.coreByProject,
)
- return Object.entries(ephemeralStateOfAllProjects).filter(
- ([a, state]) =>
- state.loadingState.type === 'browserStateIsNotBasedOnDiskState',
- )
+ return Object.entries(ephemeralStateOfAllProjects)
+ .map(([projectId, state]) => ({projectId, state}))
+ .filter(
+ ({state}) =>
+ state.loadingState.type === 'browserStateIsNotBasedOnDiskState',
+ )
}, [])
+ const [errorTooltip, triggerButtonRef] = useTooltip(
+ {enabled: conflicts.length > 0, delay: 0},
+ () => (
+
+ {conflicts.length === 1
+ ? `There is a state conflict in project "${conflicts[0].projectId}". Select the project in the outline below in order to fix it.`
+ : `There are ${conflicts.length} projects that have state conflicts. They are highlighted in the outline below. `}
+
+ ),
+ )
+
return (
-
+ {errorTooltip}
+
{conflicts.length > 0 ? (
@@ -148,7 +166,6 @@ const OutlinePanel: React.FC<{}> = (props) => {
Outline
- {/* */}
diff --git a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/LengthIndicator/LengthIndicator.tsx b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/LengthIndicator/LengthIndicator.tsx
index 1438c67..bf6278e 100644
--- a/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/LengthIndicator/LengthIndicator.tsx
+++ b/theatre/studio/src/panels/SequenceEditorPanel/DopeSheet/Right/LengthIndicator/LengthIndicator.tsx
@@ -133,6 +133,7 @@ const LengthIndicator: React.FC = ({layoutP}) => {
const [nodeRef, node] = useRefAndState(null)
const [isDraggingD] = useDragBulge(node, {layoutP})
const [popoverNode, openPopover, closePopover, isPopoverOpen] = usePopover(
+ {},
() => {
return (
diff --git a/theatre/studio/src/uiComponents/Popover/ArrowContext.tsx b/theatre/studio/src/uiComponents/Popover/ArrowContext.tsx
new file mode 100644
index 0000000..9a8539c
--- /dev/null
+++ b/theatre/studio/src/uiComponents/Popover/ArrowContext.tsx
@@ -0,0 +1,4 @@
+import {createContext} from 'react'
+
+const ArrowContext = createContext>({})
+export default ArrowContext
diff --git a/theatre/studio/src/uiComponents/Popover/BasicPopover.tsx b/theatre/studio/src/uiComponents/Popover/BasicPopover.tsx
new file mode 100644
index 0000000..a9bbc88
--- /dev/null
+++ b/theatre/studio/src/uiComponents/Popover/BasicPopover.tsx
@@ -0,0 +1,35 @@
+import type {$IntentionalAny} from '@theatre/shared/utils/types'
+import {pointerEventsAutoInNormalMode} from '@theatre/studio/css'
+import React from 'react'
+import styled from 'styled-components'
+import {popoverBackgroundColor} from './Popover'
+import PopoverArrow, {popoverArrowColor} from './PopoverArrow'
+
+const Container = styled.div`
+ position: absolute;
+ background: ${popoverBackgroundColor};
+ ${popoverArrowColor(popoverBackgroundColor)};
+ color: white;
+ padding: 0;
+ margin: 0;
+ cursor: default;
+ ${pointerEventsAutoInNormalMode};
+ border-radius: 3px;
+ z-index: 10000;
+ border: 1px solid #505159;
+ box-shadow: 0 6px 8px -4px black, 0 0 0 1px black;
+ backdrop-filter: blur(8px);
+`
+
+const BasicPopover: React.FC<{className?: string}> = React.forwardRef(
+ ({children, className}, ref) => {
+ return (
+
+
+ {children}
+
+ )
+ },
+)
+
+export default BasicPopover
diff --git a/theatre/studio/src/uiComponents/Popover/BasicTooltip.tsx b/theatre/studio/src/uiComponents/Popover/BasicTooltip.tsx
new file mode 100644
index 0000000..32c7466
--- /dev/null
+++ b/theatre/studio/src/uiComponents/Popover/BasicTooltip.tsx
@@ -0,0 +1,9 @@
+import styled from 'styled-components'
+import BasicPopover from './BasicPopover'
+
+const BasicTooltip = styled(BasicPopover)`
+ padding: 1em;
+ max-width: 240px;
+`
+
+export default BasicTooltip
diff --git a/theatre/studio/src/uiComponents/Popover/MinimalTooltip.tsx b/theatre/studio/src/uiComponents/Popover/MinimalTooltip.tsx
new file mode 100644
index 0000000..d1d797e
--- /dev/null
+++ b/theatre/studio/src/uiComponents/Popover/MinimalTooltip.tsx
@@ -0,0 +1,7 @@
+import styled from 'styled-components'
+import BasicTooltip from './BasicTooltip'
+
+const MinimalTooltip = styled(BasicTooltip)`
+ padding: 6px;
+`
+export default MinimalTooltip
diff --git a/theatre/studio/src/uiComponents/Popover/Popover.tsx b/theatre/studio/src/uiComponents/Popover/Popover.tsx
index aeee2e6..d7ce10f 100644
--- a/theatre/studio/src/uiComponents/Popover/Popover.tsx
+++ b/theatre/studio/src/uiComponents/Popover/Popover.tsx
@@ -6,14 +6,11 @@ import React, {useLayoutEffect, useRef, useState} from 'react'
import {createPortal} from 'react-dom'
import useWindowSize from 'react-use/esm/useWindowSize'
import styled from 'styled-components'
+import useOnClickOutside from '@theatre/studio/uiComponents/useOnClickOutside'
import PopoverArrow from './PopoverArrow'
+import {usePopoverContext} from './PopoverContext'
-/**
- * How far from the menu should the pointer travel to auto close the menu
- */
-const defaultPointerDistanceThreshold = 200
-
-export const popoverBackgroundColor = transparentize(0.2, '#111')
+export const popoverBackgroundColor = transparentize(0.05, `#2a2a31`)
const minimumDistanceOfArrowToEdgeOfPopover = 8
const Container = styled.ul`
@@ -29,15 +26,12 @@ const Container = styled.ul`
`
const Popover: React.FC<{
- clickPoint?: {clientX: number; clientY: number}
- target: HTMLElement
- onPointerOutOfThreshold: () => void
+ target: Element
children: () => React.ReactNode
- pointerDistanceThreshold?: number
className?: string
}> = (props) => {
- const pointerDistanceThreshold =
- props.pointerDistanceThreshold ?? defaultPointerDistanceThreshold
+ const {pointerDistanceThreshold, onPointerOutOfThreshold} =
+ usePopoverContext()
const [container, setContainer] = useState(null)
const arrowRef = useRef(null)
@@ -102,33 +96,26 @@ const Popover: React.FC<{
e.clientY < pos.top - pointerDistanceThreshold ||
e.clientY > pos.top + containerRect.height + pointerDistanceThreshold
) {
- props.onPointerOutOfThreshold()
- }
- }
-
- const onMouseDown = (e: MouseEvent) => {
- if (!e.composedPath().includes(container)) {
- props.onPointerOutOfThreshold()
+ onPointerOutOfThreshold()
}
}
window.addEventListener('mousemove', onMouseMove)
- window.addEventListener('mousedown', onMouseDown, {capture: true})
return () => {
window.removeEventListener('mousemove', onMouseMove)
- window.removeEventListener('mousedown', onMouseDown, {capture: true})
}
}, [
containerRect,
container,
- props.clickPoint,
props.target,
targetRect,
windowSize,
- props.onPointerOutOfThreshold,
+ onPointerOutOfThreshold,
])
+ useOnClickOutside(container, onPointerOutOfThreshold)
+
return createPortal(
diff --git a/theatre/studio/src/uiComponents/Popover/PopoverArrow.tsx b/theatre/studio/src/uiComponents/Popover/PopoverArrow.tsx
index 9fa10f5..5ab6fef 100644
--- a/theatre/studio/src/uiComponents/Popover/PopoverArrow.tsx
+++ b/theatre/studio/src/uiComponents/Popover/PopoverArrow.tsx
@@ -1,15 +1,18 @@
-import React, {forwardRef} from 'react'
+import React, {forwardRef, useContext} from 'react'
import {GoTriangleUp} from 'react-icons/all'
-import styled from 'styled-components'
-import {popoverBackgroundColor} from './Popover'
+import styled, {css} from 'styled-components'
+import ArrowContext from './ArrowContext'
+
+export const popoverArrowColor = (color: string) => css`
+ --popover-arrow-color: ${color};
+`
const Container = styled.div`
font-size: 18px;
position: absolute;
width: 0;
height: 0;
-
- color: ${() => popoverBackgroundColor};
+ color: var(--popover-arrow-color);
`
const Adjust = styled.div`
@@ -23,11 +26,13 @@ const Adjust = styled.div`
type Props = {
className?: string
+ color?: string
}
const PopoverArrow = forwardRef(({className}, ref) => {
+ const arrowStyle = useContext(ArrowContext)
return (
-
+
diff --git a/theatre/studio/src/uiComponents/Popover/PopoverContext.tsx b/theatre/studio/src/uiComponents/Popover/PopoverContext.tsx
new file mode 100644
index 0000000..3b87c93
--- /dev/null
+++ b/theatre/studio/src/uiComponents/Popover/PopoverContext.tsx
@@ -0,0 +1,36 @@
+import type {$IntentionalAny} from '@theatre/shared/utils/types'
+import {createContext, useContext, default as React} from 'react'
+
+export type PopoverContext = {
+ triggerPoint?: {clientX: number; clientY: number}
+ onPointerOutOfThreshold: () => void
+ /**
+ * How far from the menu should the pointer travel to auto close the menu
+ */
+ pointerDistanceThreshold: number
+}
+
+const defaultPointerDistanceThreshold = 200
+
+const ctx = createContext(null as $IntentionalAny)
+
+export const usePopoverContext = () => useContext(ctx)
+
+export const PopoverContextProvider: React.FC<{
+ triggerPoint: PopoverContext['triggerPoint']
+ onPointerOutOfThreshold: PopoverContext['onPointerOutOfThreshold']
+ pointerDistanceThreshold?: number
+}> = ({
+ children,
+ triggerPoint,
+ pointerDistanceThreshold = defaultPointerDistanceThreshold,
+ onPointerOutOfThreshold,
+}) => {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/theatre/studio/src/uiComponents/Popover/TooltipContext.tsx b/theatre/studio/src/uiComponents/Popover/TooltipContext.tsx
new file mode 100644
index 0000000..01cab78
--- /dev/null
+++ b/theatre/studio/src/uiComponents/Popover/TooltipContext.tsx
@@ -0,0 +1,63 @@
+import {Box} from '@theatre/dataverse'
+import useRefAndState from '@theatre/studio/utils/useRefAndState'
+import React, {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+} from 'react'
+
+const ctx = createContext<{
+ currentTooltipId: Box
+ portalTarget: HTMLElement
+}>(null!)
+
+let lastTooltipId = 0
+
+export const useTooltipOpenState = (): [
+ isOpen: boolean,
+ setIsOpen: (isOpen: boolean, delay: number) => void,
+] => {
+ const id = useMemo(() => lastTooltipId++, [])
+ const {currentTooltipId} = useContext(ctx)
+ const [isOpenRef, isOpen] = useRefAndState(false)
+
+ const setIsOpen = useCallback((shouldOpen: boolean, delay: number) => {
+ if (shouldOpen) {
+ if (currentTooltipId.get() !== id) {
+ currentTooltipId.set(id)
+ }
+ } else {
+ if (currentTooltipId.get() === id) {
+ currentTooltipId.set(-1)
+ }
+ }
+ }, [])
+
+ useEffect(() => {
+ const {derivation} = currentTooltipId
+ return derivation.changesWithoutValues().tap(() => {
+ const flag = derivation.getValue() === id
+
+ if (isOpenRef.current !== flag) isOpenRef.current = flag
+ })
+ }, [currentTooltipId, id])
+
+ return [isOpen, setIsOpen]
+}
+
+const TooltipContext: React.FC<{portalTarget: HTMLElement}> = ({
+ children,
+ portalTarget,
+}) => {
+ const currentTooltipId = useMemo(() => new Box(-1), [])
+
+ return (
+
+ {children}
+
+ )
+}
+
+export default TooltipContext
diff --git a/theatre/studio/src/uiComponents/Popover/TooltipWrapper.tsx b/theatre/studio/src/uiComponents/Popover/TooltipWrapper.tsx
new file mode 100644
index 0000000..662d960
--- /dev/null
+++ b/theatre/studio/src/uiComponents/Popover/TooltipWrapper.tsx
@@ -0,0 +1,88 @@
+import React from 'react'
+import {cloneElement, useLayoutEffect, useState} from 'react'
+import useWindowSize from 'react-use/esm/useWindowSize'
+import useBoundingClientRect from '@theatre/studio/uiComponents/useBoundingClientRect'
+import ArrowContext from './ArrowContext'
+import useRefAndState from '@theatre/studio/utils/useRefAndState'
+
+const minimumDistanceOfArrowToEdgeOfPopover = 8
+
+const TooltipWrapper: React.FC<{
+ target: HTMLElement | SVGElement
+ children: () => React.ReactElement
+}> = (props) => {
+ const originalElement = props.children()
+ const [ref, container] = useRefAndState(null)
+ const style: Record = originalElement.props.style
+ ? {...originalElement.props.style}
+ : {}
+ style.position = 'absolute'
+
+ const containerRect = useBoundingClientRect(container)
+ const targetRect = useBoundingClientRect(props.target)
+ const windowSize = useWindowSize()
+ const [arrowContextValue, setArrowContextValue] = useState<
+ Record
+ >({})
+
+ useLayoutEffect(() => {
+ if (!containerRect || !container || !targetRect) return
+
+ const gap = 8
+ const arrowStyle: Record = {}
+
+ let verticalPlacement: 'bottom' | 'top' | 'overlay' = 'bottom'
+ let top = 0
+ let left = 0
+ if (targetRect.bottom + containerRect.height + gap < windowSize.height) {
+ verticalPlacement = 'bottom'
+ top = targetRect.bottom + gap
+ arrowStyle.top = '0px'
+ } else if (targetRect.top > containerRect.height + gap) {
+ verticalPlacement = 'top'
+ top = targetRect.top - (containerRect.height + gap)
+ arrowStyle.bottom = '0px'
+ arrowStyle.transform = 'rotateZ(180deg)'
+ } else {
+ verticalPlacement = 'overlay'
+ }
+
+ let arrowLeft = 0
+ if (verticalPlacement !== 'overlay') {
+ const anchorLeft = targetRect.left + targetRect.width / 2
+ if (anchorLeft < containerRect.width / 2) {
+ left = gap
+ arrowLeft = Math.max(
+ anchorLeft - gap,
+ minimumDistanceOfArrowToEdgeOfPopover,
+ )
+ } else if (anchorLeft + containerRect.width / 2 > windowSize.width) {
+ left = windowSize.width - (gap + containerRect.width)
+ arrowLeft = Math.min(
+ anchorLeft - left,
+ containerRect.width - minimumDistanceOfArrowToEdgeOfPopover,
+ )
+ } else {
+ left = anchorLeft - containerRect.width / 2
+ arrowLeft = containerRect.width / 2
+ }
+ arrowStyle.left = arrowLeft + 'px'
+ }
+
+ const pos = {left, top}
+
+ container.style.left = pos.left + 'px'
+ container.style.top = pos.top + 'px'
+ setArrowContextValue(arrowStyle)
+
+ return () => {}
+ }, [containerRect, container, props.target, targetRect, windowSize])
+
+ return (
+
+ {cloneElement(originalElement, {ref, style})}
+
+ )
+}
+
+export default TooltipWrapper
diff --git a/theatre/studio/src/uiComponents/Popover/usePopover.tsx b/theatre/studio/src/uiComponents/Popover/usePopover.tsx
index 8bd35e6..ec7f4e3 100644
--- a/theatre/studio/src/uiComponents/Popover/usePopover.tsx
+++ b/theatre/studio/src/uiComponents/Popover/usePopover.tsx
@@ -1,5 +1,6 @@
+import noop from '@theatre/shared/utils/noop'
import React, {useCallback, useState} from 'react'
-import Popover from './Popover'
+import {PopoverContextProvider} from './PopoverContext'
type OpenFn = (e: React.MouseEvent, target: HTMLElement) => void
type CloseFn = () => void
@@ -15,6 +16,10 @@ type State =
}
export default function usePopover(
+ opts: {
+ closeWhenPointerIsDistant?: boolean
+ pointerDistanceThreshold?: number
+ },
render: () => React.ReactNode,
): [node: React.ReactNode, open: OpenFn, close: CloseFn, isOpen: boolean] {
const [state, setState] = useState({
@@ -34,13 +39,22 @@ export default function usePopover(
}, [])
const node = state.isOpen ? (
-
) : (
+ //
<>>
)
diff --git a/theatre/studio/src/uiComponents/Popover/useTooltip.tsx b/theatre/studio/src/uiComponents/Popover/useTooltip.tsx
new file mode 100644
index 0000000..59df2b0
--- /dev/null
+++ b/theatre/studio/src/uiComponents/Popover/useTooltip.tsx
@@ -0,0 +1,56 @@
+import useRefAndState from '@theatre/studio/utils/useRefAndState'
+import type {MutableRefObject} from 'react'
+import {useEffect} from 'react'
+import React from 'react'
+import TooltipWrapper from './TooltipWrapper'
+import getStudio from '@theatre/studio/getStudio'
+import {createPortal} from 'react-dom'
+import {useTooltipOpenState} from './TooltipContext'
+
+export default function useTooltip(
+ opts: {enabled?: boolean; delay?: number},
+ render: () => React.ReactElement,
+): [
+ node: React.ReactNode,
+ targetRef: MutableRefObject,
+ isOpen: boolean,
+] {
+ const enabled = opts.enabled !== false
+ const [isOpen, setIsOpen] = useTooltipOpenState()
+
+ const [targetRef, targetNode] = useRefAndState<
+ HTMLElement | SVGElement | null
+ >(null)
+
+ useEffect(() => {
+ if (!enabled) {
+ return
+ }
+
+ const target = targetRef.current
+ if (!target) return
+
+ const onMouseEnter = () => setIsOpen(true, opts.delay ?? 200)
+ const onMouseLeave = () => setIsOpen(false, opts.delay ?? 200)
+
+ target.addEventListener('mouseenter', onMouseEnter)
+ target.addEventListener('mouseleave', onMouseLeave)
+
+ return () => {
+ target.removeEventListener('mouseenter', onMouseEnter)
+ target.removeEventListener('mouseleave', onMouseLeave)
+ }
+ }, [targetRef, enabled, opts.delay])
+
+ const node =
+ enabled && isOpen && targetNode ? (
+ createPortal(
+ ,
+ getStudio()!.ui.containerShadow,
+ )
+ ) : (
+ <>>
+ )
+
+ return [node, targetRef, isOpen]
+}
diff --git a/theatre/studio/src/uiComponents/onPointerOutside.ts b/theatre/studio/src/uiComponents/onPointerOutside.ts
new file mode 100644
index 0000000..3855701
--- /dev/null
+++ b/theatre/studio/src/uiComponents/onPointerOutside.ts
@@ -0,0 +1,31 @@
+import {useEffect} from 'react'
+import useBoundingClientRect from './useBoundingClientRect'
+
+export default function onPointerOutside(
+ container: Element | null,
+ threshold: number,
+ onPointerOutside: () => void,
+) {
+ const containerRect = useBoundingClientRect(container)
+
+ useEffect(() => {
+ if (!containerRect) return
+
+ const onMouseMove = (e: MouseEvent) => {
+ if (
+ e.clientX < containerRect.left - threshold ||
+ e.clientX > containerRect.left + containerRect.width + threshold ||
+ e.clientY < containerRect.top - threshold ||
+ e.clientY > containerRect.top + containerRect.height + threshold
+ ) {
+ onPointerOutside()
+ }
+ }
+
+ window.addEventListener('mousemove', onMouseMove)
+
+ return () => {
+ window.removeEventListener('mousemove', onMouseMove)
+ }
+ }, [containerRect, threshold])
+}
diff --git a/theatre/studio/src/uiComponents/toolbar/ToolbarIconButton.tsx b/theatre/studio/src/uiComponents/toolbar/ToolbarIconButton.tsx
index 5950655..62bb422 100644
--- a/theatre/studio/src/uiComponents/toolbar/ToolbarIconButton.tsx
+++ b/theatre/studio/src/uiComponents/toolbar/ToolbarIconButton.tsx
@@ -2,10 +2,15 @@ import styled from 'styled-components'
import {outlinePanelTheme} from '@theatre/studio/panels/OutlinePanel/BaseItem'
import {darken, opacify} from 'polished'
import {pointerEventsAutoInNormalMode} from '@theatre/studio/css'
+import React from 'react'
+import type {$IntentionalAny} from '@theatre/shared/utils/types'
+import useTooltip from '@theatre/studio/uiComponents/Popover/useTooltip'
+import mergeRefs from 'react-merge-refs'
+import MinimalTooltip from '@theatre/studio/uiComponents/Popover/MinimalTooltip'
const {baseBg, baseBorderColor, baseFontColor} = outlinePanelTheme
-const ToolbarIconButton = styled.button`
+const Container = styled.button`
${pointerEventsAutoInNormalMode};
position: relative;
display: flex;
@@ -52,4 +57,20 @@ const ToolbarIconButton = styled.button`
border: 0;
`
+const ToolbarIconButton: typeof Container = React.forwardRef(
+ ({title, ...props}, ref: $IntentionalAny) => {
+ const [tooltip, localRef] = useTooltip(
+ {enabled: typeof title === 'string'},
+ () => {title},
+ )
+
+ return (
+ <>
+ {tooltip}
+ {' '}
+ >
+ )
+ },
+) as $IntentionalAny
+
export default ToolbarIconButton
diff --git a/theatre/studio/src/uiComponents/useOnClickOutside.ts b/theatre/studio/src/uiComponents/useOnClickOutside.ts
new file mode 100644
index 0000000..562a865
--- /dev/null
+++ b/theatre/studio/src/uiComponents/useOnClickOutside.ts
@@ -0,0 +1,21 @@
+import {useEffect} from 'react'
+
+export default function useOnClickOutside(
+ container: Element | null,
+ onOutside: (e: MouseEvent) => void,
+) {
+ useEffect(() => {
+ if (!container) return
+
+ const onMouseDown = (e: MouseEvent) => {
+ if (!e.composedPath().includes(container)) {
+ onOutside(e)
+ }
+ }
+
+ window.addEventListener('mousedown', onMouseDown, {capture: true})
+ return () => {
+ window.removeEventListener('mousedown', onMouseDown, {capture: true})
+ }
+ }, [container, onOutside])
+}