Convert extensions' Dropdown
tool to Flyout
And use the style of a regular context Theatre.js menu.
This commit is contained in:
parent
3d343cc59e
commit
a30bba0708
7 changed files with 209 additions and 167 deletions
|
@ -41,28 +41,32 @@ studio.extend({
|
|||
},
|
||||
},
|
||||
{
|
||||
type: 'Downdown',
|
||||
svgSource: '🫠',
|
||||
onChange: (value: any) => {
|
||||
console.log('Change:', value)
|
||||
},
|
||||
selectable: false,
|
||||
options: [
|
||||
type: 'Flyout',
|
||||
label: '🫠',
|
||||
items: [
|
||||
{
|
||||
label: 'Option 1',
|
||||
value: 0,
|
||||
label: 'Item 1',
|
||||
onClick: () => {
|
||||
console.log('Item 1 clicked')
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Option 2',
|
||||
value: 1,
|
||||
label: 'Item 2',
|
||||
onClick: () => {
|
||||
console.log('Item 2 clicked')
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Option 3',
|
||||
value: 2,
|
||||
label: 'Item 3',
|
||||
onClick: () => {
|
||||
console.log('Item 3 clicked')
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Option 4',
|
||||
value: 3,
|
||||
label: 'Item 4',
|
||||
onClick: () => {
|
||||
console.log('Item 4 clicked')
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
@ -102,21 +102,27 @@ export type ToolConfigSwitch = {
|
|||
options: ToolConfigOption[]
|
||||
}
|
||||
|
||||
export type ToolConfigDowndownOption = {
|
||||
export type ToolconfigFlyoutMenuItem = {
|
||||
label: string
|
||||
value: any
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
export type ToolConfigDowndown = {
|
||||
type: 'Downdown'
|
||||
index?: number
|
||||
svgSource: string
|
||||
selectable: boolean
|
||||
onChange: (option: ToolConfigDowndownOption | null) => void
|
||||
options: ToolConfigDowndownOption[]
|
||||
export type ToolConfigFlyoutMenu = {
|
||||
/**
|
||||
* A flyout menu
|
||||
*/
|
||||
type: 'Flyout'
|
||||
/**
|
||||
* 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>
|
||||
|
||||
|
|
|
@ -5,7 +5,7 @@ import type {ToolConfig, ToolsetConfig} from '@theatre/studio/TheatreStudio'
|
|||
import React from 'react'
|
||||
import IconButton from './tools/IconButton'
|
||||
import Switch from './tools/Switch'
|
||||
import Downdown from './tools/Downdown'
|
||||
import ExtensionFlyoutMenu from './tools/ExtensionFlyoutMenu'
|
||||
|
||||
const Toolset: React.FC<{
|
||||
config: ToolsetConfig
|
||||
|
@ -26,7 +26,7 @@ const toolByType: {
|
|||
} = {
|
||||
Icon: IconButton,
|
||||
Switch: Switch,
|
||||
Downdown: Downdown,
|
||||
Flyout: ExtensionFlyoutMenu,
|
||||
}
|
||||
|
||||
function getToolByType<Type extends ToolConfig['type']>(
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -1,49 +1,20 @@
|
|||
import {pointerEventsAutoInNormalMode} from '@theatre/studio/css'
|
||||
import useBoundingClientRect from '@theatre/studio/uiComponents/useBoundingClientRect'
|
||||
import transparentize from 'polished/lib/color/transparentize'
|
||||
import type {ElementType} from 'react'
|
||||
import {useMemo} from 'react'
|
||||
import {useContext} from 'react'
|
||||
import React, {useLayoutEffect, useState} from 'react'
|
||||
import {createPortal} from 'react-dom'
|
||||
import useWindowSize from 'react-use/esm/useWindowSize'
|
||||
import styled from 'styled-components'
|
||||
import Item, {height as itemHeight} from './Item'
|
||||
import {height as itemHeight} from './Item'
|
||||
import {PortalContext} from 'reakit'
|
||||
import useOnKeyDown from '@theatre/studio/uiComponents/useOnKeyDown'
|
||||
|
||||
const minWidth = 190
|
||||
import BaseMenu from './BaseMenu'
|
||||
|
||||
/**
|
||||
* How far from the menu should the pointer travel to auto close the menu
|
||||
*/
|
||||
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: {
|
||||
closeMenu(): void
|
||||
}) => React.ReactChild
|
||||
|
@ -59,16 +30,21 @@ export type IContextMenuItemsValue =
|
|||
| 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
|
||||
* the other open context menu (if one _is_ open).
|
||||
*/
|
||||
const ContextMenu: React.FC<{
|
||||
items: IContextMenuItemsValue
|
||||
displayName?: string
|
||||
clickPoint: {clientX: number; clientY: number}
|
||||
onRequestClose: () => void
|
||||
}> = (props) => {
|
||||
const ContextMenu: React.FC<ContextMenuProps> = (props) => {
|
||||
const [container, setContainer] = useState<HTMLElement | null>(null)
|
||||
const rect = useBoundingClientRect(container)
|
||||
const windowSize = useWindowSize()
|
||||
|
@ -144,24 +120,12 @@ const ContextMenu: React.FC<{
|
|||
}, [props.items])
|
||||
|
||||
return createPortal(
|
||||
<MenuContainer ref={setContainer}>
|
||||
{SHOW_OPTIONAL_MENU_TITLE && props.displayName ? (
|
||||
<MenuTitle>{props.displayName}</MenuTitle>
|
||||
) : null}
|
||||
{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>,
|
||||
<BaseMenu
|
||||
items={items}
|
||||
onRequestClose={props.onRequestClose}
|
||||
displayName={props.displayName}
|
||||
ref={setContainer}
|
||||
/>,
|
||||
portalLayer!,
|
||||
)
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue