WIP: Popovers and tooltips
This commit is contained in:
parent
c20b065bae
commit
e63d273830
17 changed files with 447 additions and 49 deletions
|
@ -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}>
|
||||||
<Container>
|
<TooltipContext portalTarget={portalLayer}>
|
||||||
<PortalLayer ref={portalLayerRef} />
|
<Container>
|
||||||
{shouldShowGlobalToolbar && <GlobalToolbar />}
|
<PortalLayer ref={portalLayerRef} />
|
||||||
{shouldShowTrigger && <TheTrigger />}
|
{shouldShowGlobalToolbar && <GlobalToolbar />}
|
||||||
{shouldShowPanels && <PanelsRoot />}
|
{shouldShowTrigger && <TheTrigger />}
|
||||||
</Container>
|
{shouldShowPanels && <PanelsRoot />}
|
||||||
|
</Container>
|
||||||
|
</TooltipContext>
|
||||||
</PortalContext.Provider>
|
</PortalContext.Provider>
|
||||||
</ProvideTheme>
|
</ProvideTheme>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -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}))
|
||||||
state.loadingState.type === 'browserStateIsNotBasedOnDiskState',
|
.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 (
|
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>
|
||||||
|
|
|
@ -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} />
|
||||||
|
|
4
theatre/studio/src/uiComponents/Popover/ArrowContext.tsx
Normal file
4
theatre/studio/src/uiComponents/Popover/ArrowContext.tsx
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
import {createContext} from 'react'
|
||||||
|
|
||||||
|
const ArrowContext = createContext<Record<string, string>>({})
|
||||||
|
export default ArrowContext
|
35
theatre/studio/src/uiComponents/Popover/BasicPopover.tsx
Normal file
35
theatre/studio/src/uiComponents/Popover/BasicPopover.tsx
Normal 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
|
9
theatre/studio/src/uiComponents/Popover/BasicTooltip.tsx
Normal file
9
theatre/studio/src/uiComponents/Popover/BasicTooltip.tsx
Normal 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
|
|
@ -0,0 +1,7 @@
|
||||||
|
import styled from 'styled-components'
|
||||||
|
import BasicTooltip from './BasicTooltip'
|
||||||
|
|
||||||
|
const MinimalTooltip = styled(BasicTooltip)`
|
||||||
|
padding: 6px;
|
||||||
|
`
|
||||||
|
export default MinimalTooltip
|
|
@ -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} />
|
||||||
|
|
|
@ -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>
|
||||||
|
|
36
theatre/studio/src/uiComponents/Popover/PopoverContext.tsx
Normal file
36
theatre/studio/src/uiComponents/Popover/PopoverContext.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
63
theatre/studio/src/uiComponents/Popover/TooltipContext.tsx
Normal file
63
theatre/studio/src/uiComponents/Popover/TooltipContext.tsx
Normal 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
|
88
theatre/studio/src/uiComponents/Popover/TooltipWrapper.tsx
Normal file
88
theatre/studio/src/uiComponents/Popover/TooltipWrapper.tsx
Normal 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
|
|
@ -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={
|
||||||
|
// }
|
||||||
|
// />
|
||||||
<></>
|
<></>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
56
theatre/studio/src/uiComponents/Popover/useTooltip.tsx
Normal file
56
theatre/studio/src/uiComponents/Popover/useTooltip.tsx
Normal 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]
|
||||||
|
}
|
31
theatre/studio/src/uiComponents/onPointerOutside.ts
Normal file
31
theatre/studio/src/uiComponents/onPointerOutside.ts
Normal 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])
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
21
theatre/studio/src/uiComponents/useOnClickOutside.ts
Normal file
21
theatre/studio/src/uiComponents/useOnClickOutside.ts
Normal 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])
|
||||||
|
}
|
Loading…
Reference in a new issue