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 - {/*
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]) +}