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 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() {
<GlobalStyle />
<ProvideTheme>
<PortalContext.Provider value={portalLayer}>
<Container>
<PortalLayer ref={portalLayerRef} />
{shouldShowGlobalToolbar && <GlobalToolbar />}
{shouldShowTrigger && <TheTrigger />}
{shouldShowPanels && <PanelsRoot />}
</Container>
<TooltipContext portalTarget={portalLayer}>
<Container>
<PortalLayer ref={portalLayerRef} />
{shouldShowGlobalToolbar && <GlobalToolbar />}
{shouldShowTrigger && <TheTrigger />}
{shouldShowPanels && <PanelsRoot />}
</Container>
</TooltipContext>
</PortalContext.Provider>
</ProvideTheme>
</>

View file

@ -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},
() => (
<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 (
<Container>
<TriggerContainer>
<TriggerButton title="Outline">
{errorTooltip}
<TriggerButton ref={triggerButtonRef as $IntentionalAny}>
<VscListTree />
</TriggerButton>
{conflicts.length > 0 ? (
@ -148,7 +166,6 @@ const OutlinePanel: React.FC<{}> = (props) => {
<Title>Outline</Title>
</TriggerContainer>
<Content>
{/* <Header><Title>Outline</Title></Header> */}
<Body>
<ProjectsList />
</Body>

View file

@ -133,6 +133,7 @@ const LengthIndicator: React.FC<IProps> = ({layoutP}) => {
const [nodeRef, node] = useRefAndState<HTMLDivElement | null>(null)
const [isDraggingD] = useDragBulge(node, {layoutP})
const [popoverNode, openPopover, closePopover, isPopoverOpen] = usePopover(
{},
() => {
return (
<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 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<HTMLElement | null>(null)
const arrowRef = useRef<HTMLDivElement>(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(
<Container ref={setContainer} className={props.className}>
<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 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<HTMLDivElement, Props>(({className}, ref) => {
const arrowStyle = useContext(ArrowContext)
return (
<Container className={className} ref={ref}>
<Container className={className} ref={ref} style={{...arrowStyle}}>
<Adjust>
<GoTriangleUp size={'18px'} />
</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 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<State>({
@ -34,13 +39,22 @@ export default function usePopover(
}, [])
const node = state.isOpen ? (
<Popover
<PopoverContextProvider
children={render}
clickPoint={state.clickPoint}
target={state.target}
onPointerOutOfThreshold={close}
triggerPoint={state.clickPoint}
pointerDistanceThreshold={opts.pointerDistanceThreshold}
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 {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'},
() => <MinimalTooltip>{title}</MinimalTooltip>,
)
return (
<>
{tooltip}
<Container ref={mergeRefs([localRef, ref])} {...props} />{' '}
</>
)
},
) as $IntentionalAny
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])
}