diff --git a/packages/playground/src/shared/hello-world-extension/index.tsx b/packages/playground/src/shared/hello-world-extension/index.tsx index b9ffc48..2cebd3b 100644 --- a/packages/playground/src/shared/hello-world-extension/index.tsx +++ b/packages/playground/src/shared/hello-world-extension/index.tsx @@ -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') + }, }, ], }, diff --git a/theatre/studio/src/TheatreStudio.ts b/theatre/studio/src/TheatreStudio.ts index b7f5f25..3f7fada 100644 --- a/theatre/studio/src/TheatreStudio.ts +++ b/theatre/studio/src/TheatreStudio.ts @@ -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 diff --git a/theatre/studio/src/toolbars/ExtensionToolbar/Toolset.tsx b/theatre/studio/src/toolbars/ExtensionToolbar/Toolset.tsx index 755cc70..0980154 100644 --- a/theatre/studio/src/toolbars/ExtensionToolbar/Toolset.tsx +++ b/theatre/studio/src/toolbars/ExtensionToolbar/Toolset.tsx @@ -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( diff --git a/theatre/studio/src/toolbars/ExtensionToolbar/tools/Downdown.tsx b/theatre/studio/src/toolbars/ExtensionToolbar/tools/Downdown.tsx deleted file mode 100644 index d47117e..0000000 --- a/theatre/studio/src/toolbars/ExtensionToolbar/tools/Downdown.tsx +++ /dev/null @@ -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 ( - - - {config.svgSource} - - {showOptions && ( -
    - {config.options.map( - (option: ToolConfigDowndownOption, index: number) => ( - - selectOption(index, option)} - className={index === currentIndex ? 'selected' : ''} - > - {option.label} - - - ), - )} -
- )} -
- ) -} - -export default Downdown diff --git a/theatre/studio/src/toolbars/ExtensionToolbar/tools/ExtensionFlyoutMenu.tsx b/theatre/studio/src/toolbars/ExtensionToolbar/tools/ExtensionFlyoutMenu.tsx new file mode 100644 index 0000000..85d3e6e --- /dev/null +++ b/theatre/studio/src/toolbars/ExtensionToolbar/tools/ExtensionFlyoutMenu.tsx @@ -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) + + 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 ( + ({ + 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 ( + + {popover.node} + { + popover.open(e, triggerRef.current!) + }} + > + {config.label} + + + ) +} + +export default ExtensionFlyoutMenu diff --git a/theatre/studio/src/uiComponents/simpleContextMenu/ContextMenu/BaseMenu.tsx b/theatre/studio/src/uiComponents/simpleContextMenu/ContextMenu/BaseMenu.tsx new file mode 100644 index 0000000..bb8bd68 --- /dev/null +++ b/theatre/studio/src/uiComponents/simpleContextMenu/ContextMenu/BaseMenu.tsx @@ -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 ( + + {SHOW_OPTIONAL_MENU_TITLE && props.displayName ? ( + {props.displayName} + ) : null} + {props.items.map((item, i) => ( + { + if (item.callback) { + item.callback(e) + } + props.onRequestClose() + }} + /> + ))} + + ) +}) + +export default BaseMenu diff --git a/theatre/studio/src/uiComponents/simpleContextMenu/ContextMenu/ContextMenu.tsx b/theatre/studio/src/uiComponents/simpleContextMenu/ContextMenu/ContextMenu.tsx index 3918fa3..7c5b960 100644 --- a/theatre/studio/src/uiComponents/simpleContextMenu/ContextMenu/ContextMenu.tsx +++ b/theatre/studio/src/uiComponents/simpleContextMenu/ContextMenu/ContextMenu.tsx @@ -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 = (props) => { const [container, setContainer] = useState(null) const rect = useBoundingClientRect(container) const windowSize = useWindowSize() @@ -144,24 +120,12 @@ const ContextMenu: React.FC<{ }, [props.items]) return createPortal( - - {SHOW_OPTIONAL_MENU_TITLE && props.displayName ? ( - {props.displayName} - ) : null} - {items.map((item, i) => ( - { - if (item.callback) { - item.callback(e) - } - props.onRequestClose() - }} - /> - ))} - , + , portalLayer!, ) }