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',
|
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')
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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']>(
|
||||||
|
|
|
@ -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 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!,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue