import * as React from 'react' import * as ReactDOM from 'react-dom' import * as Popper from '@popperjs/core' import { usePopper } from 'react-popper' import { forwardAndUseRef, LogicalDomContext, useLogicalDom } from './util' /** Tooltip contents should call `redrawTooltip` whenever their layout changes. */ export type MkTooltipContentFn = (redrawTooltip: () => void) => React.ReactNode const TooltipPlacementContext = React.createContext<Popper.Placement>('top') export const Tooltip = forwardAndUseRef<HTMLDivElement, React.HTMLProps<HTMLDivElement> & { reference: HTMLElement | null, mkTooltipContent: MkTooltipContentFn, placement?: Popper.Placement, onFirstUpdate?: (_: Partial<Popper.State>) => void }>((props_, divRef, setDivRef) => { const {reference, mkTooltipContent, placement: preferPlacement, onFirstUpdate, ...props} = props_ // We remember the global trend in placement (as `globalPlacement`) so tooltip chains can bounce // off the top and continue downwards or vice versa and initialize to that, but then update // the trend (as `ourPlacement`). const globalPlacement = React.useContext(TooltipPlacementContext) const placement = preferPlacement ? preferPlacement : globalPlacement const [ourPlacement, setOurPlacement] = React.useState<Popper.Placement>(placement) // https://popper.js.org/react-popper/v2/faq/#why-i-get-render-loop-whenever-i-put-a-function-inside-the-popper-configuration const onFirstUpdate_ = React.useCallback((state: Partial<Popper.State>) => { if (state.placement) setOurPlacement(state.placement) if (onFirstUpdate) onFirstUpdate(state) }, [onFirstUpdate]) const [arrowElement, setArrowElement] = React.useState<HTMLDivElement | null>(null) const { styles, attributes, update } = usePopper(reference, divRef.current, { modifiers: [ { name: 'arrow', options: { element: arrowElement } }, { name: 'offset', options: { offset: [0, 8] } }, ], placement, onFirstUpdate: onFirstUpdate_ }) const update_ = React.useCallback(() => update?.(), [update]) const logicalDom = React.useContext(LogicalDomContext) const popper = <div ref={node => { setDivRef(node) logicalDom.registerDescendant(node) }} style={styles.popper} className='tooltip' {...props} {...attributes.popper} > <TooltipPlacementContext.Provider value={ourPlacement}> {mkTooltipContent(update_)} </TooltipPlacementContext.Provider> <div ref={setArrowElement} style={styles.arrow} className='tooltip-arrow' /> </div> // Append the tooltip to the end of document body to avoid layout issues. // (https://github.com/leanprover/vscode-lean4/issues/51) return ReactDOM.createPortal(popper, document.body) }) export type HoverState = 'off' | 'over' | 'ctrlOver' /** An element which calls `setHoverState` when the hover state of its DOM children changes. * * It is implemented with JS rather than CSS in order to allow nesting of these elements. When nested, * only the smallest (deepest in the DOM tree) {@link DetectHoverSpan} has an enabled hover state. */ export const DetectHoverSpan = forwardAndUseRef<HTMLSpanElement, React.HTMLProps<HTMLSpanElement> & {setHoverState: React.Dispatch<React.SetStateAction<HoverState>>}>((props_, ref, setRef) => { const {setHoverState, ...props} = props_; const onPointerEvent = (b: boolean) => (e: React.PointerEvent<HTMLSpanElement>) => { // It's more composable to let pointer events bubble up rather than to `stopPropagating`, // but we only want to handle hovers in the innermost component. So we record that the // event was handled with a property. // The `contains` check ensures that the node hovered over is a child in the DOM // tree and not just a logical React child (see useLogicalDom and // https://reactjs.org/docs/portals.html#event-bubbling-through-portals). if (ref.current && e.target instanceof Node && ref.current.contains(e.target)) { if ('_DetectHoverSpanSeen' in e) return (e as any)._DetectHoverSpanSeen = {} if (!b) setHoverState('off') else if (e.ctrlKey) setHoverState('ctrlOver') else setHoverState('over') } } React.useEffect(() => { const onKeyDown = (e : KeyboardEvent) => { if (e.key === 'Control') setHoverState(st => st === 'over' ? 'ctrlOver' : st) } const onKeyUp = (e : KeyboardEvent) => { if (e.key === 'Control') setHoverState(st => st === 'ctrlOver' ? 'over' : st) } document.addEventListener('keydown', onKeyDown) document.addEventListener('keyup', onKeyUp) return () => { document.removeEventListener('keydown', onKeyDown) document.removeEventListener('keyup', onKeyUp) } }, []) return <span {...props} ref={setRef} onPointerOver={onPointerEvent(true)} onPointerOut={onPointerEvent(false)} onPointerMove={e => { if (e.ctrlKey) setHoverState(st => st === 'over' ? 'ctrlOver' : st) else setHoverState(st => st === 'ctrlOver' ? 'over' : st) }} > {props.children} </span> }) interface TipChainContext { pinParent(): void } const TipChainContext = React.createContext<TipChainContext>({pinParent: () => {}}) /** Shows a tooltip when the children are hovered over or clicked. * * An `onClick` middleware can optionally be given in order to control what happens when the * hoverable area is clicked. The middleware can invoke `next` to execute the default action * (show the tooltip). */ export const WithTooltipOnHover = forwardAndUseRef<HTMLSpanElement, Omit<React.HTMLProps<HTMLSpanElement>, 'onClick'> & { mkTooltipContent: MkTooltipContentFn, onClick?: (event: React.MouseEvent<HTMLSpanElement>, next: React.MouseEventHandler<HTMLSpanElement>) => void }>((props_, ref, setRef) => { const {mkTooltipContent, ...props} = props_ // We are pinned when clicked, shown when hovered over, and otherwise hidden. type TooltipState = 'pin' | 'show' | 'hide' const [state, setState] = React.useState<TooltipState>('hide') const shouldShow = state !== 'hide' const tipChainCtx = React.useContext(TipChainContext) React.useEffect(() => { if (state === 'pin') tipChainCtx.pinParent() }, [state, tipChainCtx]) const newTipChainCtx = React.useMemo(() => ({ pinParent: () => { setState('pin'); tipChainCtx.pinParent() } }), [tipChainCtx]) // Note: because tooltips are attached to `document.body`, they are not descendants of the // hoverable area in the DOM tree, and the `contains` check fails for elements within tooltip // contents. We can use this to distinguish these elements. const isWithinHoverable = (el: EventTarget) => ref.current && el instanceof Node && ref.current.contains(el) const [logicalDom, logicalDomStorage] = useLogicalDom(ref) // We use timeouts for debouncing hover events. const timeout = React.useRef<number>() const clearTimeout = () => { if (timeout.current) { window.clearTimeout(timeout.current) timeout.current = undefined } } const showDelay = 500 const hideDelay = 300 const onClick = (e: React.MouseEvent<HTMLSpanElement>) => { clearTimeout() setState(state => state === 'pin' ? 'hide' : 'pin') } React.useEffect(() => { const onClickAnywhere = (e: Event) => { if (e.target instanceof Node && !logicalDom.contains(e.target)) { clearTimeout() setState('hide') } } document.addEventListener('pointerdown', onClickAnywhere) return () => document.removeEventListener('pointerdown', onClickAnywhere) }, [ref, logicalDom]) const isPointerOverTooltip = React.useRef<boolean>(false) const startShowTimeout = () => { clearTimeout() timeout.current = window.setTimeout(() => { setState(state => state === 'hide' ? 'show' : state) timeout.current = undefined }, showDelay) } const startHideTimeout = () => { clearTimeout() timeout.current = window.setTimeout(() => { if (!isPointerOverTooltip.current) setState(state => state === 'show' ? 'hide' : state) timeout.current = undefined }, hideDelay) } const onPointerEnter = (e: React.PointerEvent<HTMLSpanElement>) => { isPointerOverTooltip.current = true clearTimeout() } const onPointerLeave = (e: React.PointerEvent<HTMLSpanElement>) => { isPointerOverTooltip.current = false startHideTimeout() } const onPointerEvent = (act: () => void, e: React.PointerEvent<HTMLSpanElement>) => { if ('_WithTooltipOnHoverSeen' in e) return if (!isWithinHoverable(e.target)) return (e as any)._WithTooltipOnHoverSeen = {} act() } return <LogicalDomContext.Provider value={logicalDomStorage}> <span {...props} ref={setRef} onClick={e => { if (!isWithinHoverable(e.target)) return e.stopPropagation() if (props.onClick !== undefined) props.onClick(e, onClick) else onClick(e) }} onPointerOver={e => { onPointerEvent(startShowTimeout, e) if (props.onPointerOver !== undefined) props.onPointerOver(e) }} onPointerOut={e => { onPointerEvent(startHideTimeout, e) if (props.onPointerOut !== undefined) props.onPointerOut(e) }} > {shouldShow && <TipChainContext.Provider value={newTipChainCtx}> <Tooltip reference={ref.current} onPointerEnter={onPointerEnter} onPointerLeave={onPointerLeave} mkTooltipContent={mkTooltipContent} /> </TipChainContext.Provider>} {props.children} </span> </LogicalDomContext.Provider> })