Convert extensions' Dropdown tool to Flyout

And use the style of a regular context Theatre.js menu.
This commit is contained in:
Aria Minaei 2023-01-23 22:23:06 +01:00
parent 3d343cc59e
commit a30bba0708
7 changed files with 209 additions and 167 deletions

View file

@ -41,28 +41,32 @@ studio.extend({
}, },
}, },
{ {
type: 'Downdown', type: 'Flyout',
svgSource: '🫠', label: '🫠',
onChange: (value: any) => { items: [
console.log('Change:', value)
},
selectable: false,
options: [
{ {
label: 'Option 1', label: 'Item 1',
value: 0, onClick: () => {
console.log('Item 1 clicked')
},
}, },
{ {
label: 'Option 2', label: 'Item 2',
value: 1, onClick: () => {
console.log('Item 2 clicked')
},
}, },
{ {
label: 'Option 3', label: 'Item 3',
value: 2, onClick: () => {
console.log('Item 3 clicked')
},
}, },
{ {
label: 'Option 4', label: 'Item 4',
value: 3, onClick: () => {
console.log('Item 4 clicked')
},
}, },
], ],
}, },

View file

@ -102,21 +102,27 @@ export type ToolConfigSwitch = {
options: ToolConfigOption[] options: ToolConfigOption[]
} }
export type ToolConfigDowndownOption = { export type ToolconfigFlyoutMenuItem = {
label: string label: string
value: any onClick?: () => void
} }
export type ToolConfigDowndown = { export type ToolConfigFlyoutMenu = {
type: 'Downdown' /**
index?: number * A flyout menu
svgSource: string */
selectable: boolean type: 'Flyout'
onChange: (option: ToolConfigDowndownOption | null) => void /**
options: ToolConfigDowndownOption[] * The label of the trigger button
*/
label: string
items: ToolconfigFlyoutMenuItem[]
} }
export type ToolConfig = ToolConfigIcon | ToolConfigSwitch | ToolConfigDowndown export type ToolConfig =
| ToolConfigIcon
| ToolConfigSwitch
| ToolConfigFlyoutMenu
export type ToolsetConfig = Array<ToolConfig> export type ToolsetConfig = Array<ToolConfig>

View file

@ -5,7 +5,7 @@ import type {ToolConfig, ToolsetConfig} from '@theatre/studio/TheatreStudio'
import React from 'react' import React from 'react'
import IconButton from './tools/IconButton' import IconButton from './tools/IconButton'
import Switch from './tools/Switch' import Switch from './tools/Switch'
import Downdown from './tools/Downdown' import ExtensionFlyoutMenu from './tools/ExtensionFlyoutMenu'
const Toolset: React.FC<{ const Toolset: React.FC<{
config: ToolsetConfig config: ToolsetConfig
@ -26,7 +26,7 @@ const toolByType: {
} = { } = {
Icon: IconButton, Icon: IconButton,
Switch: Switch, Switch: Switch,
Downdown: Downdown, Flyout: ExtensionFlyoutMenu,
} }
function getToolByType<Type extends ToolConfig['type']>( function getToolByType<Type extends ToolConfig['type']>(

View file

@ -1,85 +0,0 @@
import React, {useState} from 'react'
import styled from 'styled-components'
import {pointerEventsAutoInNormalMode} from '@theatre/studio/css'
import type {
ToolConfigDowndown,
ToolConfigDowndownOption,
} from '@theatre/studio/TheatreStudio'
import ToolbarIconButton from '@theatre/studio/uiComponents/toolbar/ToolbarIconButton'
const Container = styled.div`
${pointerEventsAutoInNormalMode};
& > svg {
width: 1em;
height: 1em;
pointer-events: none;
}
`
const DropdownItem = styled.li`
width: max-content;
& > button {
color: #fff;
font-size: 12px;
font-weight: normal;
padding: 0 9px;
width: fit-content;
}
& > .selected {
border-color: white;
}
`
const Downdown: React.FC<{
config: ToolConfigDowndown
}> = ({config}) => {
const [currentIndex, setCurrentIndex] = useState(
config.index !== undefined ? config.index : -1,
)
const [showOptions, setShowOptions] = useState(false)
const toggleOptions = () => {
setShowOptions(!showOptions)
}
const selectOption = (index: number, option: ToolConfigDowndownOption) => {
if (config.selectable) {
if (index !== currentIndex) {
config.onChange(option.value)
setCurrentIndex(index)
} else {
config.onChange(null)
setCurrentIndex(-1)
}
} else {
config.onChange(option.value)
}
setShowOptions(false)
}
return (
<Container>
<ToolbarIconButton onClick={toggleOptions}>
{config.svgSource}
</ToolbarIconButton>
{showOptions && (
<ul>
{config.options.map(
(option: ToolConfigDowndownOption, index: number) => (
<DropdownItem key={index}>
<ToolbarIconButton
onClick={() => selectOption(index, option)}
className={index === currentIndex ? 'selected' : ''}
>
{option.label}
</ToolbarIconButton>
</DropdownItem>
),
)}
</ul>
)}
</Container>
)
}
export default Downdown

View file

@ -0,0 +1,81 @@
import React, {useRef} from 'react'
import styled from 'styled-components'
import {pointerEventsAutoInNormalMode} from '@theatre/studio/css'
import type {
ToolConfigFlyoutMenu,
ToolconfigFlyoutMenuItem,
} from '@theatre/studio/TheatreStudio'
import ToolbarIconButton from '@theatre/studio/uiComponents/toolbar/ToolbarIconButton'
import BaseMenu from '@theatre/studio/uiComponents/simpleContextMenu/ContextMenu/BaseMenu'
import usePopover from '@theatre/studio/uiComponents/Popover/usePopover'
import type {$IntentionalAny} from '@theatre/shared/utils/types'
const Container = styled.div`
${pointerEventsAutoInNormalMode};
& > svg {
width: 1em;
height: 1em;
pointer-events: none;
}
`
const ExtensionFlyoutMenu: React.FC<{
config: ToolConfigFlyoutMenu
}> = ({config}) => {
const triggerRef = useRef<null | HTMLElement>(null)
const popover = usePopover(
() => {
const triggerBounds = triggerRef.current!.getBoundingClientRect()
return {
debugName: 'ExtensionFlyoutMenu:' + config.label,
constraints: {
maxX: triggerBounds.right,
maxY: 8,
minX: triggerBounds.left,
minY: 8,
},
verticalGap: 2,
}
},
() => {
return (
<BaseMenu
items={config.items.map(
(option: ToolconfigFlyoutMenuItem, index: number) => ({
label: option.label,
callback: () => {
// this is a user-defined function, so we need to wrap it in a try/catch
try {
option.onClick?.()
} catch (e) {
console.error(e)
}
},
}),
)}
onRequestClose={() => {
popover.close('clicked')
}}
/>
)
},
)
return (
<Container>
{popover.node}
<ToolbarIconButton
ref={triggerRef as $IntentionalAny}
onClick={(e) => {
popover.open(e, triggerRef.current!)
}}
>
{config.label}
</ToolbarIconButton>
</Container>
)
}
export default ExtensionFlyoutMenu

View file

@ -0,0 +1,72 @@
import type {ElementType} from 'react'
import React from 'react'
import Item from './Item'
import type {$FixMe} from '@theatre/shared/utils/types'
import styled from 'styled-components'
import {transparentize} from 'polished'
import {pointerEventsAutoInNormalMode} from '@theatre/studio/css'
const minWidth = 190
const SHOW_OPTIONAL_MENU_TITLE = true
const MenuContainer = styled.ul`
position: absolute;
min-width: ${minWidth}px;
z-index: 10000;
background: ${transparentize(0.2, '#111')};
backdrop-filter: blur(2px);
color: white;
list-style-type: none;
padding: 2px 0;
margin: 0;
border-radius: 1px;
cursor: default;
${pointerEventsAutoInNormalMode};
border-radius: 3px;
`
const MenuTitle = styled.div`
padding: 4px 10px;
border-bottom: 1px solid #6262626d;
color: #adadadb3;
font-size: 11px;
font-weight: 500;
`
type MenuItem = {
label: string | ElementType
callback?: (e: React.MouseEvent) => void
enabled?: boolean
// subs?: Item[]
}
const BaseMenu: React.FC<{
items: MenuItem[]
ref?: $FixMe
displayName?: string
onRequestClose: () => void
}> = React.forwardRef((props, ref: $FixMe) => {
return (
<MenuContainer ref={ref}>
{SHOW_OPTIONAL_MENU_TITLE && props.displayName ? (
<MenuTitle>{props.displayName}</MenuTitle>
) : null}
{props.items.map((item, i) => (
<Item
key={`item-${i}`}
label={item.label}
enabled={item.enabled === false ? false : true}
onClick={(e) => {
if (item.callback) {
item.callback(e)
}
props.onRequestClose()
}}
/>
))}
</MenuContainer>
)
})
export default BaseMenu

View file

@ -1,49 +1,20 @@
import {pointerEventsAutoInNormalMode} from '@theatre/studio/css'
import useBoundingClientRect from '@theatre/studio/uiComponents/useBoundingClientRect' import useBoundingClientRect from '@theatre/studio/uiComponents/useBoundingClientRect'
import transparentize from 'polished/lib/color/transparentize'
import type {ElementType} from 'react' import type {ElementType} from 'react'
import {useMemo} from 'react' import {useMemo} from 'react'
import {useContext} from 'react' import {useContext} from 'react'
import React, {useLayoutEffect, useState} from 'react' import React, {useLayoutEffect, 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 {height as itemHeight} from './Item'
import Item, {height as itemHeight} from './Item'
import {PortalContext} from 'reakit' import {PortalContext} from 'reakit'
import useOnKeyDown from '@theatre/studio/uiComponents/useOnKeyDown' import useOnKeyDown from '@theatre/studio/uiComponents/useOnKeyDown'
import BaseMenu from './BaseMenu'
const minWidth = 190
/** /**
* How far from the menu should the pointer travel to auto close the menu * How far from the menu should the pointer travel to auto close the menu
*/ */
const pointerDistanceThreshold = 20 const pointerDistanceThreshold = 20
const SHOW_OPTIONAL_MENU_TITLE = true
const MenuContainer = styled.ul`
position: absolute;
min-width: ${minWidth}px;
z-index: 10000;
background: ${transparentize(0.2, '#111')};
backdrop-filter: blur(2px);
color: white;
list-style-type: none;
padding: 2px 0;
margin: 0;
border-radius: 1px;
cursor: default;
${pointerEventsAutoInNormalMode};
border-radius: 3px;
`
const MenuTitle = styled.div`
padding: 4px 10px;
border-bottom: 1px solid #6262626d;
color: #adadadb3;
font-size: 11px;
font-weight: 500;
`
export type IContextMenuItemCustomNodeRenderFn = (controls: { export type IContextMenuItemCustomNodeRenderFn = (controls: {
closeMenu(): void closeMenu(): void
}) => React.ReactChild }) => React.ReactChild
@ -59,16 +30,21 @@ export type IContextMenuItemsValue =
| IContextMenuItem[] | IContextMenuItem[]
| (() => IContextMenuItem[]) | (() => IContextMenuItem[])
export type ContextMenuProps = {
items: IContextMenuItemsValue
displayName?: string
clickPoint: {
clientX: number
clientY: number
}
onRequestClose: () => void
}
/** /**
* TODO let's make sure that triggering a context menu would close * TODO let's make sure that triggering a context menu would close
* the other open context menu (if one _is_ open). * the other open context menu (if one _is_ open).
*/ */
const ContextMenu: React.FC<{ const ContextMenu: React.FC<ContextMenuProps> = (props) => {
items: IContextMenuItemsValue
displayName?: string
clickPoint: {clientX: number; clientY: number}
onRequestClose: () => void
}> = (props) => {
const [container, setContainer] = useState<HTMLElement | null>(null) const [container, setContainer] = useState<HTMLElement | null>(null)
const rect = useBoundingClientRect(container) const rect = useBoundingClientRect(container)
const windowSize = useWindowSize() const windowSize = useWindowSize()
@ -144,24 +120,12 @@ const ContextMenu: React.FC<{
}, [props.items]) }, [props.items])
return createPortal( return createPortal(
<MenuContainer ref={setContainer}> <BaseMenu
{SHOW_OPTIONAL_MENU_TITLE && props.displayName ? ( items={items}
<MenuTitle>{props.displayName}</MenuTitle> onRequestClose={props.onRequestClose}
) : null} displayName={props.displayName}
{items.map((item, i) => ( ref={setContainer}
<Item />,
key={`item-${i}`}
label={item.label}
enabled={item.enabled === false ? false : true}
onClick={(e) => {
if (item.callback) {
item.callback(e)
}
props.onRequestClose()
}}
/>
))}
</MenuContainer>,
portalLayer!, portalLayer!,
) )
} }