WIP: Popovers and tooltips

This commit is contained in:
Aria Minaei 2021-08-19 20:50:26 +02:00
parent c20b065bae
commit e63d273830
17 changed files with 447 additions and 49 deletions

View file

@ -12,6 +12,7 @@ import {PortalContext} from 'reakit'
import type {$IntentionalAny} from '@theatre/shared/utils/types' import type {$IntentionalAny} from '@theatre/shared/utils/types'
import useKeyboardShortcuts from './useKeyboardShortcuts' import useKeyboardShortcuts from './useKeyboardShortcuts'
import PointerEventsHandler from '@theatre/studio/uiComponents/PointerEventsHandler' import PointerEventsHandler from '@theatre/studio/uiComponents/PointerEventsHandler'
import TooltipContext from '@theatre/studio/uiComponents/Popover/TooltipContext'
const GlobalStyle = createGlobalStyle` const GlobalStyle = createGlobalStyle`
:host { :host {
@ -74,12 +75,14 @@ export default function UIRoot() {
<GlobalStyle /> <GlobalStyle />
<ProvideTheme> <ProvideTheme>
<PortalContext.Provider value={portalLayer}> <PortalContext.Provider value={portalLayer}>
<TooltipContext portalTarget={portalLayer}>
<Container> <Container>
<PortalLayer ref={portalLayerRef} /> <PortalLayer ref={portalLayerRef} />
{shouldShowGlobalToolbar && <GlobalToolbar />} {shouldShowGlobalToolbar && <GlobalToolbar />}
{shouldShowTrigger && <TheTrigger />} {shouldShowTrigger && <TheTrigger />}
{shouldShowPanels && <PanelsRoot />} {shouldShowPanels && <PanelsRoot />}
</Container> </Container>
</TooltipContext>
</PortalContext.Provider> </PortalContext.Provider>
</ProvideTheme> </ProvideTheme>
</> </>

View file

@ -8,6 +8,9 @@ import {VscListTree} from 'react-icons/all'
import {usePrism} from '@theatre/dataverse-react' import {usePrism} from '@theatre/dataverse-react'
import getStudio from '@theatre/studio/getStudio' import getStudio from '@theatre/studio/getStudio'
import {val} from '@theatre/dataverse' 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` const Container = styled.div`
background-color: transparent; background-color: transparent;
@ -29,8 +32,7 @@ const Container = styled.div`
width: 20px; width: 20px;
${pointerEventsAutoInNormalMode}; ${pointerEventsAutoInNormalMode};
} }
ب &:hover:before {
&:hover:before {
top: -12px; top: -12px;
width: 300px; width: 300px;
} }
@ -59,6 +61,8 @@ const Content = styled.div`
} }
` `
const ErrorTooltip = styled(BasicTooltip)``
const headerHeight = `32px` const headerHeight = `32px`
const TriggerButton = styled(ToolbarIconButton)` const TriggerButton = styled(ToolbarIconButton)`
@ -128,16 +132,30 @@ const OutlinePanel: React.FC<{}> = (props) => {
const ephemeralStateOfAllProjects = val( const ephemeralStateOfAllProjects = val(
getStudio().atomP.ephemeral.coreByProject, getStudio().atomP.ephemeral.coreByProject,
) )
return Object.entries(ephemeralStateOfAllProjects).filter( return Object.entries(ephemeralStateOfAllProjects)
([a, state]) => .map(([projectId, state]) => ({projectId, state}))
.filter(
({state}) =>
state.loadingState.type === 'browserStateIsNotBasedOnDiskState', state.loadingState.type === 'browserStateIsNotBasedOnDiskState',
) )
}, []) }, [])
const [errorTooltip, triggerButtonRef] = useTooltip(
{enabled: conflicts.length > 0, delay: 0},
() => (
<ErrorTooltip>
{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. `}
</ErrorTooltip>
),
)
return ( return (
<Container> <Container>
<TriggerContainer> <TriggerContainer>
<TriggerButton title="Outline"> {errorTooltip}
<TriggerButton ref={triggerButtonRef as $IntentionalAny}>
<VscListTree /> <VscListTree />
</TriggerButton> </TriggerButton>
{conflicts.length > 0 ? ( {conflicts.length > 0 ? (
@ -148,7 +166,6 @@ const OutlinePanel: React.FC<{}> = (props) => {
<Title>Outline</Title> <Title>Outline</Title>
</TriggerContainer> </TriggerContainer>
<Content> <Content>
{/* <Header><Title>Outline</Title></Header> */}
<Body> <Body>
<ProjectsList /> <ProjectsList />
</Body> </Body>

View file

@ -133,6 +133,7 @@ const LengthIndicator: React.FC<IProps> = ({layoutP}) => {
const [nodeRef, node] = useRefAndState<HTMLDivElement | null>(null) const [nodeRef, node] = useRefAndState<HTMLDivElement | null>(null)
const [isDraggingD] = useDragBulge(node, {layoutP}) const [isDraggingD] = useDragBulge(node, {layoutP})
const [popoverNode, openPopover, closePopover, isPopoverOpen] = usePopover( const [popoverNode, openPopover, closePopover, isPopoverOpen] = usePopover(
{},
() => { () => {
return ( return (
<LengthEditorPopover layoutP={layoutP} onRequestClose={closePopover} /> <LengthEditorPopover layoutP={layoutP} onRequestClose={closePopover} />

View file

@ -0,0 +1,4 @@
import {createContext} from 'react'
const ArrowContext = createContext<Record<string, string>>({})
export default ArrowContext

View file

@ -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 (
<Container className={className} ref={ref as $IntentionalAny}>
<PopoverArrow />
{children}
</Container>
)
},
)
export default BasicPopover

View file

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

View file

@ -0,0 +1,7 @@
import styled from 'styled-components'
import BasicTooltip from './BasicTooltip'
const MinimalTooltip = styled(BasicTooltip)`
padding: 6px;
`
export default MinimalTooltip

View file

@ -6,14 +6,11 @@ import React, {useLayoutEffect, useRef, useState} from 'react'
import {createPortal} from 'react-dom' import {createPortal} from 'react-dom'
import useWindowSize from 'react-use/esm/useWindowSize' import useWindowSize from 'react-use/esm/useWindowSize'
import styled from 'styled-components' import styled from 'styled-components'
import useOnClickOutside from '@theatre/studio/uiComponents/useOnClickOutside'
import PopoverArrow from './PopoverArrow' import PopoverArrow from './PopoverArrow'
import {usePopoverContext} from './PopoverContext'
/** export const popoverBackgroundColor = transparentize(0.05, `#2a2a31`)
* How far from the menu should the pointer travel to auto close the menu
*/
const defaultPointerDistanceThreshold = 200
export const popoverBackgroundColor = transparentize(0.2, '#111')
const minimumDistanceOfArrowToEdgeOfPopover = 8 const minimumDistanceOfArrowToEdgeOfPopover = 8
const Container = styled.ul` const Container = styled.ul`
@ -29,15 +26,12 @@ const Container = styled.ul`
` `
const Popover: React.FC<{ const Popover: React.FC<{
clickPoint?: {clientX: number; clientY: number} target: Element
target: HTMLElement
onPointerOutOfThreshold: () => void
children: () => React.ReactNode children: () => React.ReactNode
pointerDistanceThreshold?: number
className?: string className?: string
}> = (props) => { }> = (props) => {
const pointerDistanceThreshold = const {pointerDistanceThreshold, onPointerOutOfThreshold} =
props.pointerDistanceThreshold ?? defaultPointerDistanceThreshold usePopoverContext()
const [container, setContainer] = useState<HTMLElement | null>(null) const [container, setContainer] = useState<HTMLElement | null>(null)
const arrowRef = useRef<HTMLDivElement>(null) const arrowRef = useRef<HTMLDivElement>(null)
@ -102,33 +96,26 @@ const Popover: React.FC<{
e.clientY < pos.top - pointerDistanceThreshold || e.clientY < pos.top - pointerDistanceThreshold ||
e.clientY > pos.top + containerRect.height + pointerDistanceThreshold e.clientY > pos.top + containerRect.height + pointerDistanceThreshold
) { ) {
props.onPointerOutOfThreshold() onPointerOutOfThreshold()
}
}
const onMouseDown = (e: MouseEvent) => {
if (!e.composedPath().includes(container)) {
props.onPointerOutOfThreshold()
} }
} }
window.addEventListener('mousemove', onMouseMove) window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mousedown', onMouseDown, {capture: true})
return () => { return () => {
window.removeEventListener('mousemove', onMouseMove) window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mousedown', onMouseDown, {capture: true})
} }
}, [ }, [
containerRect, containerRect,
container, container,
props.clickPoint,
props.target, props.target,
targetRect, targetRect,
windowSize, windowSize,
props.onPointerOutOfThreshold, onPointerOutOfThreshold,
]) ])
useOnClickOutside(container, onPointerOutOfThreshold)
return createPortal( return createPortal(
<Container ref={setContainer} className={props.className}> <Container ref={setContainer} className={props.className}>
<PopoverArrow ref={arrowRef} /> <PopoverArrow ref={arrowRef} />

View file

@ -1,15 +1,18 @@
import React, {forwardRef} from 'react' import React, {forwardRef, useContext} from 'react'
import {GoTriangleUp} from 'react-icons/all' import {GoTriangleUp} from 'react-icons/all'
import styled from 'styled-components' import styled, {css} from 'styled-components'
import {popoverBackgroundColor} from './Popover' import ArrowContext from './ArrowContext'
export const popoverArrowColor = (color: string) => css`
--popover-arrow-color: ${color};
`
const Container = styled.div` const Container = styled.div`
font-size: 18px; font-size: 18px;
position: absolute; position: absolute;
width: 0; width: 0;
height: 0; height: 0;
color: var(--popover-arrow-color);
color: ${() => popoverBackgroundColor};
` `
const Adjust = styled.div` const Adjust = styled.div`
@ -23,11 +26,13 @@ const Adjust = styled.div`
type Props = { type Props = {
className?: string className?: string
color?: string
} }
const PopoverArrow = forwardRef<HTMLDivElement, Props>(({className}, ref) => { const PopoverArrow = forwardRef<HTMLDivElement, Props>(({className}, ref) => {
const arrowStyle = useContext(ArrowContext)
return ( return (
<Container className={className} ref={ref}> <Container className={className} ref={ref} style={{...arrowStyle}}>
<Adjust> <Adjust>
<GoTriangleUp size={'18px'} /> <GoTriangleUp size={'18px'} />
</Adjust> </Adjust>

View file

@ -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<PopoverContext>(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 (
<ctx.Provider
value={{triggerPoint, pointerDistanceThreshold, onPointerOutOfThreshold}}
>
{children}
</ctx.Provider>
)
}

View file

@ -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<number>
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<boolean>(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 (
<ctx.Provider value={{currentTooltipId, portalTarget}}>
{children}
</ctx.Provider>
)
}
export default TooltipContext

View file

@ -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<HTMLElement | SVGElement | null>(null)
const style: Record<string, string> = 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<string, string>
>({})
useLayoutEffect(() => {
if (!containerRect || !container || !targetRect) return
const gap = 8
const arrowStyle: Record<string, string> = {}
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 (
<ArrowContext.Provider value={arrowContextValue}>
{cloneElement(originalElement, {ref, style})}
</ArrowContext.Provider>
)
}
export default TooltipWrapper

View file

@ -1,5 +1,6 @@
import noop from '@theatre/shared/utils/noop'
import React, {useCallback, useState} from 'react' import React, {useCallback, useState} from 'react'
import Popover from './Popover' import {PopoverContextProvider} from './PopoverContext'
type OpenFn = (e: React.MouseEvent, target: HTMLElement) => void type OpenFn = (e: React.MouseEvent, target: HTMLElement) => void
type CloseFn = () => void type CloseFn = () => void
@ -15,6 +16,10 @@ type State =
} }
export default function usePopover( export default function usePopover(
opts: {
closeWhenPointerIsDistant?: boolean
pointerDistanceThreshold?: number
},
render: () => React.ReactNode, render: () => React.ReactNode,
): [node: React.ReactNode, open: OpenFn, close: CloseFn, isOpen: boolean] { ): [node: React.ReactNode, open: OpenFn, close: CloseFn, isOpen: boolean] {
const [state, setState] = useState<State>({ const [state, setState] = useState<State>({
@ -34,13 +39,22 @@ export default function usePopover(
}, []) }, [])
const node = state.isOpen ? ( const node = state.isOpen ? (
<Popover <PopoverContextProvider
children={render} children={render}
clickPoint={state.clickPoint} triggerPoint={state.clickPoint}
target={state.target} pointerDistanceThreshold={opts.pointerDistanceThreshold}
onPointerOutOfThreshold={close} onPointerOutOfThreshold={
opts.closeWhenPointerIsDistant === false ? noop : close
}
/> />
) : ( ) : (
// <Popover
// children={render}
// triggerPoint={state.clickPoint}
// target={state.target}
// onPointerOutOfThreshold={
// }
// />
<></> <></>
) )

View file

@ -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<HTMLElement | SVGElement | null>,
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(
<TooltipWrapper children={render} target={targetNode} />,
getStudio()!.ui.containerShadow,
)
) : (
<></>
)
return [node, targetRef, isOpen]
}

View file

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

View file

@ -2,10 +2,15 @@ import styled from 'styled-components'
import {outlinePanelTheme} from '@theatre/studio/panels/OutlinePanel/BaseItem' import {outlinePanelTheme} from '@theatre/studio/panels/OutlinePanel/BaseItem'
import {darken, opacify} from 'polished' import {darken, opacify} from 'polished'
import {pointerEventsAutoInNormalMode} from '@theatre/studio/css' 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 {baseBg, baseBorderColor, baseFontColor} = outlinePanelTheme
const ToolbarIconButton = styled.button` const Container = styled.button`
${pointerEventsAutoInNormalMode}; ${pointerEventsAutoInNormalMode};
position: relative; position: relative;
display: flex; display: flex;
@ -52,4 +57,20 @@ const ToolbarIconButton = styled.button`
border: 0; border: 0;
` `
const ToolbarIconButton: typeof Container = React.forwardRef(
({title, ...props}, ref: $IntentionalAny) => {
const [tooltip, localRef] = useTooltip(
{enabled: typeof title === 'string'},
() => <MinimalTooltip>{title}</MinimalTooltip>,
)
return (
<>
{tooltip}
<Container ref={mergeRefs([localRef, ref])} {...props} />{' '}
</>
)
},
) as $IntentionalAny
export default ToolbarIconButton export default ToolbarIconButton

View file

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