Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com>tags/0.6.13
| import { memo } from 'react' | |||||
| import { MenuOption } from '@lexical/react/LexicalTypeaheadMenuPlugin' | |||||
| export class VariableOption extends MenuOption { | |||||
| title: string | |||||
| icon?: JSX.Element | |||||
| extraElement?: JSX.Element | |||||
| keywords: Array<string> | |||||
| keyboardShortcut?: string | |||||
| onSelect: (queryString: string) => void | |||||
| constructor( | |||||
| title: string, | |||||
| options: { | |||||
| icon?: JSX.Element | |||||
| extraElement?: JSX.Element | |||||
| keywords?: Array<string> | |||||
| keyboardShortcut?: string | |||||
| onSelect: (queryString: string) => void | |||||
| }, | |||||
| ) { | |||||
| super(title) | |||||
| this.title = title | |||||
| this.keywords = options.keywords || [] | |||||
| this.icon = options.icon | |||||
| this.extraElement = options.extraElement | |||||
| this.keyboardShortcut = options.keyboardShortcut | |||||
| this.onSelect = options.onSelect.bind(this) | |||||
| } | |||||
| } | |||||
| type VariableMenuItemProps = { | |||||
| isSelected: boolean | |||||
| onClick: () => void | |||||
| onMouseEnter: () => void | |||||
| option: VariableOption | |||||
| queryString: string | null | |||||
| } | |||||
| export const VariableMenuItem = memo(({ | |||||
| isSelected, | |||||
| onClick, | |||||
| onMouseEnter, | |||||
| option, | |||||
| queryString, | |||||
| }: VariableMenuItemProps) => { | |||||
| const title = option.title | |||||
| let before = title | |||||
| let middle = '' | |||||
| let after = '' | |||||
| if (queryString) { | |||||
| const regex = new RegExp(queryString, 'i') | |||||
| const match = regex.exec(option.title) | |||||
| if (match) { | |||||
| before = title.substring(0, match.index) | |||||
| middle = match[0] | |||||
| after = title.substring(match.index + match[0].length) | |||||
| } | |||||
| } | |||||
| return ( | |||||
| <div | |||||
| key={option.key} | |||||
| className={` | |||||
| flex items-center px-3 h-6 rounded-md hover:bg-primary-50 cursor-pointer | |||||
| ${isSelected && 'bg-primary-50'} | |||||
| `} | |||||
| tabIndex={-1} | |||||
| ref={option.setRefElement} | |||||
| onMouseEnter={onMouseEnter} | |||||
| onClick={onClick}> | |||||
| <div className='mr-2'> | |||||
| {option.icon} | |||||
| </div> | |||||
| <div className='grow text-[13px] text-gray-900 truncate' title={option.title}> | |||||
| {before} | |||||
| <span className='text-[#2970FF]'>{middle}</span> | |||||
| {after} | |||||
| </div> | |||||
| {option.extraElement} | |||||
| </div> | |||||
| ) | |||||
| }) | |||||
| VariableMenuItem.displayName = 'VariableMenuItem' |
| import { INSERT_QUERY_BLOCK_COMMAND } from '../query-block' | import { INSERT_QUERY_BLOCK_COMMAND } from '../query-block' | ||||
| import { INSERT_VARIABLE_VALUE_BLOCK_COMMAND } from '../variable-block' | import { INSERT_VARIABLE_VALUE_BLOCK_COMMAND } from '../variable-block' | ||||
| import { $createCustomTextNode } from '../custom-text/node' | import { $createCustomTextNode } from '../custom-text/node' | ||||
| import { PromptOption } from './prompt-option' | |||||
| import { VariableOption } from './variable-option' | |||||
| import { PromptMenuItem } from './prompt-option' | |||||
| import { VariableMenuItem } from './variable-option' | |||||
| import { PickerBlockMenuOption } from './menu' | |||||
| import { File05 } from '@/app/components/base/icons/src/vender/solid/files' | import { File05 } from '@/app/components/base/icons/src/vender/solid/files' | ||||
| import { | import { | ||||
| MessageClockCircle, | MessageClockCircle, | ||||
| const { t } = useTranslation() | const { t } = useTranslation() | ||||
| const [editor] = useLexicalComposerContext() | const [editor] = useLexicalComposerContext() | ||||
| return useMemo(() => { | |||||
| return [ | |||||
| ...contextBlock?.show | |||||
| ? [ | |||||
| new PromptOption(t('common.promptEditor.context.item.title'), { | |||||
| icon: <File05 className='w-4 h-4 text-[#6938EF]' />, | |||||
| onSelect: () => { | |||||
| if (!contextBlock?.selectable) | |||||
| return | |||||
| editor.dispatchCommand(INSERT_CONTEXT_BLOCK_COMMAND, undefined) | |||||
| }, | |||||
| disabled: !contextBlock?.selectable, | |||||
| }), | |||||
| ] | |||||
| : [], | |||||
| ...queryBlock?.show | |||||
| ? [ | |||||
| new PromptOption(t('common.promptEditor.query.item.title'), { | |||||
| icon: <UserEdit02 className='w-4 h-4 text-[#FD853A]' />, | |||||
| onSelect: () => { | |||||
| if (!queryBlock?.selectable) | |||||
| return | |||||
| editor.dispatchCommand(INSERT_QUERY_BLOCK_COMMAND, undefined) | |||||
| }, | |||||
| disabled: !queryBlock?.selectable, | |||||
| }), | |||||
| ] | |||||
| : [], | |||||
| ...historyBlock?.show | |||||
| ? [ | |||||
| new PromptOption(t('common.promptEditor.history.item.title'), { | |||||
| icon: <MessageClockCircle className='w-4 h-4 text-[#DD2590]' />, | |||||
| onSelect: () => { | |||||
| if (!historyBlock?.selectable) | |||||
| return | |||||
| editor.dispatchCommand(INSERT_HISTORY_BLOCK_COMMAND, undefined) | |||||
| }, | |||||
| disabled: !historyBlock?.selectable, | |||||
| }), | |||||
| ] | |||||
| : [], | |||||
| ] | |||||
| }, [contextBlock, editor, historyBlock, queryBlock, t]) | |||||
| const promptOptions: PickerBlockMenuOption[] = [] | |||||
| if (contextBlock?.show) { | |||||
| promptOptions.push(new PickerBlockMenuOption({ | |||||
| key: t('common.promptEditor.context.item.title'), | |||||
| group: 'prompt context', | |||||
| render: ({ isSelected, onSelect, onSetHighlight }) => { | |||||
| return <PromptMenuItem | |||||
| title={t('common.promptEditor.context.item.title')} | |||||
| icon={<File05 className='w-4 h-4 text-[#6938EF]' />} | |||||
| disabled={!contextBlock.selectable} | |||||
| isSelected={isSelected} | |||||
| onClick={onSelect} | |||||
| onMouseEnter={onSetHighlight} | |||||
| /> | |||||
| }, | |||||
| onSelect: () => { | |||||
| if (!contextBlock?.selectable) | |||||
| return | |||||
| editor.dispatchCommand(INSERT_CONTEXT_BLOCK_COMMAND, undefined) | |||||
| }, | |||||
| })) | |||||
| } | |||||
| if (queryBlock?.show) { | |||||
| promptOptions.push( | |||||
| new PickerBlockMenuOption({ | |||||
| key: t('common.promptEditor.query.item.title'), | |||||
| group: 'prompt query', | |||||
| render: ({ isSelected, onSelect, onSetHighlight }) => { | |||||
| return ( | |||||
| <PromptMenuItem | |||||
| title={t('common.promptEditor.query.item.title')} | |||||
| icon={<UserEdit02 className='w-4 h-4 text-[#FD853A]' />} | |||||
| disabled={!queryBlock.selectable} | |||||
| isSelected={isSelected} | |||||
| onClick={onSelect} | |||||
| onMouseEnter={onSetHighlight} | |||||
| /> | |||||
| ) | |||||
| }, | |||||
| onSelect: () => { | |||||
| if (!queryBlock?.selectable) | |||||
| return | |||||
| editor.dispatchCommand(INSERT_QUERY_BLOCK_COMMAND, undefined) | |||||
| }, | |||||
| }), | |||||
| ) | |||||
| } | |||||
| if (historyBlock?.show) { | |||||
| promptOptions.push( | |||||
| new PickerBlockMenuOption({ | |||||
| key: t('common.promptEditor.history.item.title'), | |||||
| group: 'prompt history', | |||||
| render: ({ isSelected, onSelect, onSetHighlight }) => { | |||||
| return ( | |||||
| <PromptMenuItem | |||||
| title={t('common.promptEditor.history.item.title')} | |||||
| icon={<MessageClockCircle className='w-4 h-4 text-[#DD2590]' />} | |||||
| disabled={!historyBlock.selectable | |||||
| } | |||||
| isSelected={isSelected} | |||||
| onClick={onSelect} | |||||
| onMouseEnter={onSetHighlight} | |||||
| /> | |||||
| ) | |||||
| }, | |||||
| onSelect: () => { | |||||
| if (!historyBlock?.selectable) | |||||
| return | |||||
| editor.dispatchCommand(INSERT_HISTORY_BLOCK_COMMAND, undefined) | |||||
| }, | |||||
| }), | |||||
| ) | |||||
| } | |||||
| return promptOptions | |||||
| } | } | ||||
| export const useVariableOptions = ( | export const useVariableOptions = ( | ||||
| variableBlock?: VariableBlockType, | variableBlock?: VariableBlockType, | ||||
| queryString?: string, | queryString?: string, | ||||
| ) => { | |||||
| ): PickerBlockMenuOption[] => { | |||||
| const { t } = useTranslation() | const { t } = useTranslation() | ||||
| const [editor] = useLexicalComposerContext() | const [editor] = useLexicalComposerContext() | ||||
| const options = useMemo(() => { | const options = useMemo(() => { | ||||
| const baseOptions = (variableBlock?.variables || []).map((item) => { | |||||
| return new VariableOption(item.value, { | |||||
| icon: <BracketsX className='w-[14px] h-[14px] text-[#2970FF]' />, | |||||
| if (!variableBlock?.variables) | |||||
| return [] | |||||
| const baseOptions = (variableBlock.variables).map((item) => { | |||||
| return new PickerBlockMenuOption({ | |||||
| key: item.value, | |||||
| group: 'prompt variable', | |||||
| render: ({ queryString, isSelected, onSelect, onSetHighlight }) => { | |||||
| return ( | |||||
| <VariableMenuItem | |||||
| title={item.value} | |||||
| icon={<BracketsX className='w-[14px] h-[14px] text-[#2970FF]' />} | |||||
| queryString={queryString} | |||||
| isSelected={isSelected} | |||||
| onClick={onSelect} | |||||
| onMouseEnter={onSetHighlight} | |||||
| /> | |||||
| ) | |||||
| }, | |||||
| onSelect: () => { | onSelect: () => { | ||||
| editor.dispatchCommand(INSERT_VARIABLE_VALUE_BLOCK_COMMAND, `{{${item.value}}}`) | editor.dispatchCommand(INSERT_VARIABLE_VALUE_BLOCK_COMMAND, `{{${item.value}}}`) | ||||
| }, | }, | ||||
| const regex = new RegExp(queryString, 'i') | const regex = new RegExp(queryString, 'i') | ||||
| return baseOptions.filter(option => regex.test(option.title) || option.keywords.some(keyword => regex.test(keyword))) | |||||
| return baseOptions.filter(option => regex.test(option.key)) | |||||
| }, [editor, queryString, variableBlock]) | }, [editor, queryString, variableBlock]) | ||||
| const addOption = useMemo(() => { | const addOption = useMemo(() => { | ||||
| return new VariableOption(t('common.promptEditor.variable.modal.add'), { | |||||
| icon: <BracketsX className='mr-2 w-[14px] h-[14px] text-[#2970FF]' />, | |||||
| return new PickerBlockMenuOption({ | |||||
| key: t('common.promptEditor.variable.modal.add'), | |||||
| group: 'prompt variable', | |||||
| render: ({ queryString, isSelected, onSelect, onSetHighlight }) => { | |||||
| return ( | |||||
| <VariableMenuItem | |||||
| title={t('common.promptEditor.variable.modal.add')} | |||||
| icon={<BracketsX className='mr-2 w-[14px] h-[14px] text-[#2970FF]' />} | |||||
| queryString={queryString} | |||||
| isSelected={isSelected} | |||||
| onClick={onSelect} | |||||
| onMouseEnter={onSetHighlight} | |||||
| /> | |||||
| ) | |||||
| }, | |||||
| onSelect: () => { | onSelect: () => { | ||||
| editor.update(() => { | editor.update(() => { | ||||
| const prefixNode = $createCustomTextNode('{{') | const prefixNode = $createCustomTextNode('{{') | ||||
| const [editor] = useLexicalComposerContext() | const [editor] = useLexicalComposerContext() | ||||
| const options = useMemo(() => { | const options = useMemo(() => { | ||||
| const baseToolOptions = (externalToolBlockType?.externalTools || []).map((item) => { | |||||
| return new VariableOption(item.name, { | |||||
| icon: ( | |||||
| <AppIcon | |||||
| className='!w-[14px] !h-[14px]' | |||||
| icon={item.icon} | |||||
| background={item.icon_background} | |||||
| /> | |||||
| ), | |||||
| extraElement: <div className='text-xs text-gray-400'>{item.variableName}</div>, | |||||
| if (!externalToolBlockType?.externalTools) | |||||
| return [] | |||||
| const baseToolOptions = (externalToolBlockType.externalTools).map((item) => { | |||||
| return new PickerBlockMenuOption({ | |||||
| key: item.name, | |||||
| group: 'external tool', | |||||
| render: ({ queryString, isSelected, onSelect, onSetHighlight }) => { | |||||
| return ( | |||||
| <VariableMenuItem | |||||
| title={item.name} | |||||
| icon={ | |||||
| <AppIcon | |||||
| className='!w-[14px] !h-[14px]' | |||||
| icon={item.icon} | |||||
| background={item.icon_background} | |||||
| /> | |||||
| } | |||||
| extraElement={<div className='text-xs text-gray-400'>{item.variableName}</div>} | |||||
| queryString={queryString} | |||||
| isSelected={isSelected} | |||||
| onClick={onSelect} | |||||
| onMouseEnter={onSetHighlight} | |||||
| /> | |||||
| ) | |||||
| }, | |||||
| onSelect: () => { | onSelect: () => { | ||||
| editor.dispatchCommand(INSERT_VARIABLE_VALUE_BLOCK_COMMAND, `{{${item.variableName}}}`) | editor.dispatchCommand(INSERT_VARIABLE_VALUE_BLOCK_COMMAND, `{{${item.variableName}}}`) | ||||
| }, | }, | ||||
| const regex = new RegExp(queryString, 'i') | const regex = new RegExp(queryString, 'i') | ||||
| return baseToolOptions.filter(option => regex.test(option.title) || option.keywords.some(keyword => regex.test(keyword))) | |||||
| return baseToolOptions.filter(option => regex.test(option.key)) | |||||
| }, [editor, queryString, externalToolBlockType]) | }, [editor, queryString, externalToolBlockType]) | ||||
| const addOption = useMemo(() => { | const addOption = useMemo(() => { | ||||
| return new VariableOption(t('common.promptEditor.variable.modal.addTool'), { | |||||
| icon: <Tool03 className='mr-2 w-[14px] h-[14px] text-[#444CE7]' />, | |||||
| extraElement: <ArrowUpRight className='w-3 h-3 text-gray-400' />, | |||||
| return new PickerBlockMenuOption({ | |||||
| key: t('common.promptEditor.variable.modal.addTool'), | |||||
| group: 'external tool', | |||||
| render: ({ queryString, isSelected, onSelect, onSetHighlight }) => { | |||||
| return ( | |||||
| <VariableMenuItem | |||||
| title={t('common.promptEditor.variable.modal.addTool')} | |||||
| icon={<Tool03 className='mr-2 w-[14px] h-[14px] text-[#444CE7]' />} | |||||
| extraElement={< ArrowUpRight className='w-3 h-3 text-gray-400' />} | |||||
| queryString={queryString} | |||||
| isSelected={isSelected} | |||||
| onClick={onSelect} | |||||
| onMouseEnter={onSetHighlight} | |||||
| /> | |||||
| ) | |||||
| }, | |||||
| onSelect: () => { | onSelect: () => { | ||||
| if (externalToolBlockType?.onAddExternalTool) | |||||
| externalToolBlockType.onAddExternalTool() | |||||
| externalToolBlockType?.onAddExternalTool?.() | |||||
| }, | }, | ||||
| }) | }) | ||||
| }, [externalToolBlockType, t]) | }, [externalToolBlockType, t]) | ||||
| return useMemo(() => { | return useMemo(() => { | ||||
| return { | return { | ||||
| promptOptions, | |||||
| variableOptions, | |||||
| externalToolOptions, | |||||
| workflowVariableOptions, | workflowVariableOptions, | ||||
| allOptions: [...promptOptions, ...variableOptions, ...externalToolOptions], | |||||
| allFlattenOptions: [...promptOptions, ...variableOptions, ...externalToolOptions], | |||||
| } | } | ||||
| }, [promptOptions, variableOptions, externalToolOptions, workflowVariableOptions]) | }, [promptOptions, variableOptions, externalToolOptions, workflowVariableOptions]) | ||||
| } | } |
| import { | import { | ||||
| Fragment, | |||||
| memo, | memo, | ||||
| useCallback, | useCallback, | ||||
| useState, | useState, | ||||
| } from 'react' | } from 'react' | ||||
| import ReactDOM from 'react-dom' | import ReactDOM from 'react-dom' | ||||
| import { | import { | ||||
| FloatingPortal, | |||||
| flip, | flip, | ||||
| offset, | offset, | ||||
| shift, | shift, | ||||
| import { INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND } from '../workflow-variable-block' | import { INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND } from '../workflow-variable-block' | ||||
| import { INSERT_VARIABLE_VALUE_BLOCK_COMMAND } from '../variable-block' | import { INSERT_VARIABLE_VALUE_BLOCK_COMMAND } from '../variable-block' | ||||
| import { $splitNodeContainingQuery } from '../../utils' | import { $splitNodeContainingQuery } from '../../utils' | ||||
| import type { PromptOption } from './prompt-option' | |||||
| import PromptMenu from './prompt-menu' | |||||
| import VariableMenu from './variable-menu' | |||||
| import type { VariableOption } from './variable-option' | |||||
| import { useOptions } from './hooks' | import { useOptions } from './hooks' | ||||
| import type { PickerBlockMenuOption } from './menu' | |||||
| import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars' | import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars' | ||||
| import { useEventEmitterContextContext } from '@/context/event-emitter' | import { useEventEmitterContextContext } from '@/context/event-emitter' | ||||
| workflowVariableBlock, | workflowVariableBlock, | ||||
| }: ComponentPickerProps) => { | }: ComponentPickerProps) => { | ||||
| const { eventEmitter } = useEventEmitterContextContext() | const { eventEmitter } = useEventEmitterContextContext() | ||||
| const { refs, floatingStyles, elements } = useFloating({ | |||||
| const { refs, floatingStyles, isPositioned } = useFloating({ | |||||
| placement: 'bottom-start', | placement: 'bottom-start', | ||||
| middleware: [ | middleware: [ | ||||
| offset(0), // fix hide cursor | offset(0), // fix hide cursor | ||||
| shift(), | |||||
| shift({ | |||||
| padding: 8, | |||||
| }), | |||||
| flip(), | flip(), | ||||
| ], | ], | ||||
| }) | }) | ||||
| }) | }) | ||||
| const { | const { | ||||
| allOptions, | |||||
| promptOptions, | |||||
| variableOptions, | |||||
| externalToolOptions, | |||||
| allFlattenOptions, | |||||
| workflowVariableOptions, | workflowVariableOptions, | ||||
| } = useOptions( | } = useOptions( | ||||
| contextBlock, | contextBlock, | ||||
| const onSelectOption = useCallback( | const onSelectOption = useCallback( | ||||
| ( | ( | ||||
| selectedOption: PromptOption | VariableOption, | |||||
| selectedOption: PickerBlockMenuOption, | |||||
| nodeToRemove: TextNode | null, | nodeToRemove: TextNode | null, | ||||
| closeMenu: () => void, | closeMenu: () => void, | ||||
| matchingString: string, | |||||
| ) => { | ) => { | ||||
| editor.update(() => { | editor.update(() => { | ||||
| if (nodeToRemove && selectedOption?.key) | if (nodeToRemove && selectedOption?.key) | ||||
| nodeToRemove.remove() | nodeToRemove.remove() | ||||
| if (selectedOption?.onSelect) | |||||
| selectedOption.onSelect(matchingString) | |||||
| selectedOption.onSelectMenuOption() | |||||
| closeMenu() | closeMenu() | ||||
| }) | }) | ||||
| }, | }, | ||||
| editor.dispatchCommand(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, variables) | editor.dispatchCommand(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, variables) | ||||
| }, [editor, checkForTriggerMatch, triggerString]) | }, [editor, checkForTriggerMatch, triggerString]) | ||||
| const renderMenu = useCallback<MenuRenderFn<PromptOption | VariableOption>>(( | |||||
| const renderMenu = useCallback<MenuRenderFn<PickerBlockMenuOption>>(( | |||||
| anchorElementRef, | anchorElementRef, | ||||
| { selectedIndex, selectOptionAndCleanUp, setHighlightedIndex }, | |||||
| { options, selectedIndex, selectOptionAndCleanUp, setHighlightedIndex }, | |||||
| ) => { | ) => { | ||||
| if (anchorElementRef.current && (allOptions.length || workflowVariableBlock?.show)) { | |||||
| return ( | |||||
| <> | |||||
| { | |||||
| ReactDOM.createPortal( | |||||
| <div ref={refs.setReference}></div>, | |||||
| anchorElementRef.current, | |||||
| ) | |||||
| } | |||||
| { | |||||
| elements.reference && ( | |||||
| <FloatingPortal id='typeahead-menu'> | |||||
| <div | |||||
| className='w-[260px] bg-white rounded-lg border-[0.5px] border-gray-200 shadow-lg overflow-y-auto' | |||||
| style={{ | |||||
| ...floatingStyles, | |||||
| maxHeight: 'calc(1 / 3 * 100vh)', | |||||
| }} | |||||
| ref={refs.setFloating} | |||||
| > | |||||
| { | |||||
| !!promptOptions.length && ( | |||||
| <> | |||||
| <PromptMenu | |||||
| startIndex={0} | |||||
| selectedIndex={selectedIndex} | |||||
| options={promptOptions} | |||||
| onClick={(index, option) => { | |||||
| if (option.disabled) | |||||
| return | |||||
| setHighlightedIndex(index) | |||||
| selectOptionAndCleanUp(option) | |||||
| }} | |||||
| onMouseEnter={(index, option) => { | |||||
| if (option.disabled) | |||||
| return | |||||
| setHighlightedIndex(index) | |||||
| }} | |||||
| /> | |||||
| </> | |||||
| ) | |||||
| } | |||||
| { | |||||
| !!variableOptions.length && ( | |||||
| <> | |||||
| { | |||||
| !!promptOptions.length && ( | |||||
| <div className='h-[1px] bg-gray-100'></div> | |||||
| ) | |||||
| } | |||||
| <VariableMenu | |||||
| startIndex={promptOptions.length} | |||||
| selectedIndex={selectedIndex} | |||||
| options={variableOptions} | |||||
| onClick={(index, option) => { | |||||
| if (option.disabled) | |||||
| return | |||||
| setHighlightedIndex(index) | |||||
| selectOptionAndCleanUp(option) | |||||
| }} | |||||
| onMouseEnter={(index, option) => { | |||||
| if (option.disabled) | |||||
| return | |||||
| setHighlightedIndex(index) | |||||
| }} | |||||
| queryString={queryString} | |||||
| /> | |||||
| </> | |||||
| ) | |||||
| } | |||||
| { | |||||
| !!externalToolOptions.length && ( | |||||
| <> | |||||
| { | |||||
| (!!promptOptions.length || !!variableOptions.length) && ( | |||||
| <div className='h-[1px] bg-gray-100'></div> | |||||
| ) | |||||
| } | |||||
| <VariableMenu | |||||
| startIndex={promptOptions.length + variableOptions.length} | |||||
| selectedIndex={selectedIndex} | |||||
| options={externalToolOptions} | |||||
| onClick={(index, option) => { | |||||
| if (option.disabled) | |||||
| return | |||||
| setHighlightedIndex(index) | |||||
| selectOptionAndCleanUp(option) | |||||
| }} | |||||
| onMouseEnter={(index, option) => { | |||||
| if (option.disabled) | |||||
| return | |||||
| setHighlightedIndex(index) | |||||
| if (!(anchorElementRef.current && (allFlattenOptions.length || workflowVariableBlock?.show))) | |||||
| return null | |||||
| refs.setReference(anchorElementRef.current) | |||||
| return ( | |||||
| <> | |||||
| { | |||||
| ReactDOM.createPortal( | |||||
| // The `LexicalMenu` will try to calculate the position of the floating menu based on the first child. | |||||
| // Since we use floating ui, we need to wrap it with a div to prevent the position calculation being affected. | |||||
| // See https://github.com/facebook/lexical/blob/ac97dfa9e14a73ea2d6934ff566282d7f758e8bb/packages/lexical-react/src/shared/LexicalMenu.ts#L493 | |||||
| <div className='w-0 h-0'> | |||||
| <div | |||||
| className='p-1 w-[260px] bg-white rounded-lg border-[0.5px] border-gray-200 shadow-lg overflow-y-auto overflow-x-hidden' | |||||
| style={{ | |||||
| ...floatingStyles, | |||||
| visibility: isPositioned ? 'visible' : 'hidden', | |||||
| maxHeight: 'calc(1 / 3 * 100vh)', | |||||
| }} | |||||
| ref={refs.setFloating} | |||||
| > | |||||
| { | |||||
| options.map((option, index) => ( | |||||
| <Fragment key={option.key}> | |||||
| { | |||||
| // Divider | |||||
| index !== 0 && options.at(index - 1)?.group !== option.group && ( | |||||
| <div className='h-px bg-gray-100 my-1 w-screen -translate-x-1'></div> | |||||
| ) | |||||
| } | |||||
| {option.renderMenuOption({ | |||||
| queryString, | |||||
| isSelected: selectedIndex === index, | |||||
| onSelect: () => { | |||||
| selectOptionAndCleanUp(option) | |||||
| }, | |||||
| onSetHighlight: () => { | |||||
| setHighlightedIndex(index) | |||||
| }, | |||||
| })} | |||||
| </Fragment> | |||||
| )) | |||||
| } | |||||
| { | |||||
| workflowVariableBlock?.show && ( | |||||
| <> | |||||
| { | |||||
| (!!options.length) && ( | |||||
| <div className='h-px bg-gray-100 my-1 w-screen -translate-x-1'></div> | |||||
| ) | |||||
| } | |||||
| <div className='p-1'> | |||||
| <VarReferenceVars | |||||
| hideSearch | |||||
| vars={workflowVariableOptions} | |||||
| onChange={(variables: string[]) => { | |||||
| handleSelectWorkflowVariable(variables) | |||||
| }} | }} | ||||
| queryString={queryString} | |||||
| /> | /> | ||||
| </> | |||||
| ) | |||||
| } | |||||
| { | |||||
| workflowVariableBlock?.show && ( | |||||
| <> | |||||
| { | |||||
| (!!promptOptions.length || !!variableOptions.length || !!externalToolOptions.length) && ( | |||||
| <div className='h-[1px] bg-gray-100'></div> | |||||
| ) | |||||
| } | |||||
| <div className='p-1'> | |||||
| <VarReferenceVars | |||||
| hideSearch | |||||
| vars={workflowVariableOptions} | |||||
| onChange={(variables: string[]) => { | |||||
| handleSelectWorkflowVariable(variables) | |||||
| }} | |||||
| /> | |||||
| </div> | |||||
| </> | |||||
| ) | |||||
| } | |||||
| </div> | |||||
| </FloatingPortal> | |||||
| ) | |||||
| } | |||||
| </> | |||||
| ) | |||||
| } | |||||
| return null | |||||
| }, [ | |||||
| allOptions, | |||||
| promptOptions, | |||||
| variableOptions, | |||||
| externalToolOptions, | |||||
| queryString, | |||||
| workflowVariableBlock?.show, | |||||
| workflowVariableOptions, | |||||
| handleSelectWorkflowVariable, | |||||
| elements, | |||||
| floatingStyles, | |||||
| refs, | |||||
| ]) | |||||
| </div> | |||||
| </> | |||||
| ) | |||||
| } | |||||
| </div> | |||||
| </div>, | |||||
| anchorElementRef.current, | |||||
| ) | |||||
| } | |||||
| </> | |||||
| ) | |||||
| }, [allFlattenOptions.length, workflowVariableBlock?.show, refs, isPositioned, floatingStyles, queryString, workflowVariableOptions, handleSelectWorkflowVariable]) | |||||
| return ( | return ( | ||||
| <LexicalTypeaheadMenuPlugin | <LexicalTypeaheadMenuPlugin | ||||
| options={allOptions as any} | |||||
| options={allFlattenOptions} | |||||
| onQueryChange={setQueryString} | onQueryChange={setQueryString} | ||||
| onSelectOption={onSelectOption} | onSelectOption={onSelectOption} | ||||
| anchorClassName='z-[999999]' | |||||
| // The `translate` class is used to workaround the issue that the `typeahead-menu` menu is not positioned as expected. | |||||
| // See also https://github.com/facebook/lexical/blob/772520509308e8ba7e4a82b6cd1996a78b3298d0/packages/lexical-react/src/shared/LexicalMenu.ts#L498 | |||||
| // | |||||
| // We no need the position function of the `LexicalTypeaheadMenuPlugin`, | |||||
| // so the reference anchor should be positioned based on the range of the trigger string, and the menu will be positioned by the floating ui. | |||||
| anchorClassName='z-[999999] translate-y-[calc(-100%-3px)]' | |||||
| menuRenderFn={renderMenu} | menuRenderFn={renderMenu} | ||||
| triggerFn={checkForTriggerMatch} | triggerFn={checkForTriggerMatch} | ||||
| /> | /> |
| import { MenuOption } from '@lexical/react/LexicalTypeaheadMenuPlugin' | |||||
| import { Fragment } from 'react' | |||||
| /** | |||||
| * Corresponds to the `MenuRenderFn` type from `@lexical/react/LexicalTypeaheadMenuPlugin`. | |||||
| */ | |||||
| type MenuOptionRenderProps = { | |||||
| isSelected: boolean | |||||
| onSelect: () => void | |||||
| onSetHighlight: () => void | |||||
| queryString: string | null | |||||
| } | |||||
| export class PickerBlockMenuOption extends MenuOption { | |||||
| public group?: string | |||||
| constructor( | |||||
| private data: { | |||||
| key: string | |||||
| group?: string | |||||
| onSelect?: () => void | |||||
| render: (menuRenderProps: MenuOptionRenderProps) => JSX.Element | |||||
| }, | |||||
| ) { | |||||
| super(data.key) | |||||
| this.group = data.group | |||||
| } | |||||
| public onSelectMenuOption = () => this.data.onSelect?.() | |||||
| public renderMenuOption = (menuRenderProps: MenuOptionRenderProps) => <Fragment key={this.data.key}>{this.data.render(menuRenderProps)}</Fragment> | |||||
| } |
| import { memo } from 'react' | |||||
| import { PromptMenuItem } from './prompt-option' | |||||
| type PromptMenuProps = { | |||||
| startIndex: number | |||||
| selectedIndex: number | null | |||||
| options: any[] | |||||
| onClick: (index: number, option: any) => void | |||||
| onMouseEnter: (index: number, option: any) => void | |||||
| } | |||||
| const PromptMenu = ({ | |||||
| startIndex, | |||||
| selectedIndex, | |||||
| options, | |||||
| onClick, | |||||
| onMouseEnter, | |||||
| }: PromptMenuProps) => { | |||||
| return ( | |||||
| <div className='p-1'> | |||||
| { | |||||
| options.map((option, index: number) => ( | |||||
| <PromptMenuItem | |||||
| startIndex={startIndex} | |||||
| index={index} | |||||
| isSelected={selectedIndex === index + startIndex} | |||||
| onClick={onClick} | |||||
| onMouseEnter={onMouseEnter} | |||||
| key={option.key} | |||||
| option={option} | |||||
| /> | |||||
| )) | |||||
| } | |||||
| </div> | |||||
| ) | |||||
| } | |||||
| export default memo(PromptMenu) |
| import { memo } from 'react' | import { memo } from 'react' | ||||
| import { MenuOption } from '@lexical/react/LexicalTypeaheadMenuPlugin' | |||||
| export class PromptOption extends MenuOption { | |||||
| type PromptMenuItemMenuItemProps = { | |||||
| icon: JSX.Element | |||||
| title: string | title: string | ||||
| icon?: JSX.Element | |||||
| keywords: Array<string> | |||||
| keyboardShortcut?: string | |||||
| onSelect: (queryString: string) => void | |||||
| disabled?: boolean | disabled?: boolean | ||||
| constructor( | |||||
| title: string, | |||||
| options: { | |||||
| icon?: JSX.Element | |||||
| keywords?: Array<string> | |||||
| keyboardShortcut?: string | |||||
| onSelect: (queryString: string) => void | |||||
| disabled?: boolean | |||||
| }, | |||||
| ) { | |||||
| super(title) | |||||
| this.title = title | |||||
| this.keywords = options.keywords || [] | |||||
| this.icon = options.icon | |||||
| this.keyboardShortcut = options.keyboardShortcut | |||||
| this.onSelect = options.onSelect.bind(this) | |||||
| this.disabled = options.disabled | |||||
| } | |||||
| } | |||||
| type PromptMenuItemMenuItemProps = { | |||||
| startIndex: number | |||||
| index: number | |||||
| isSelected: boolean | isSelected: boolean | ||||
| onClick: (index: number, option: PromptOption) => void | |||||
| onMouseEnter: (index: number, option: PromptOption) => void | |||||
| option: PromptOption | |||||
| onClick: () => void | |||||
| onMouseEnter: () => void | |||||
| setRefElement?: (element: HTMLDivElement) => void | |||||
| } | } | ||||
| export const PromptMenuItem = memo(({ | export const PromptMenuItem = memo(({ | ||||
| startIndex, | |||||
| index, | |||||
| icon, | |||||
| title, | |||||
| disabled, | |||||
| isSelected, | isSelected, | ||||
| onClick, | onClick, | ||||
| onMouseEnter, | onMouseEnter, | ||||
| option, | |||||
| setRefElement, | |||||
| }: PromptMenuItemMenuItemProps) => { | }: PromptMenuItemMenuItemProps) => { | ||||
| return ( | return ( | ||||
| <div | <div | ||||
| key={option.key} | |||||
| className={` | className={` | ||||
| flex items-center px-3 h-6 cursor-pointer hover:bg-gray-50 rounded-md | flex items-center px-3 h-6 cursor-pointer hover:bg-gray-50 rounded-md | ||||
| ${isSelected && !option.disabled && '!bg-gray-50'} | |||||
| ${option.disabled ? 'cursor-not-allowed opacity-30' : 'hover:bg-gray-50 cursor-pointer'} | |||||
| ${isSelected && !disabled && '!bg-gray-50'} | |||||
| ${disabled ? 'cursor-not-allowed opacity-30' : 'hover:bg-gray-50 cursor-pointer'} | |||||
| `} | `} | ||||
| tabIndex={-1} | tabIndex={-1} | ||||
| ref={option.setRefElement} | |||||
| onMouseEnter={() => onMouseEnter(index + startIndex, option)} | |||||
| onClick={() => onClick(index + startIndex, option)}> | |||||
| {option.icon} | |||||
| <div className='ml-1 text-[13px] text-gray-900'>{option.title}</div> | |||||
| ref={setRefElement} | |||||
| onMouseEnter={() => { | |||||
| if (disabled) | |||||
| return | |||||
| onMouseEnter() | |||||
| }} | |||||
| onClick={() => { | |||||
| if (disabled) | |||||
| return | |||||
| onClick() | |||||
| }}> | |||||
| {icon} | |||||
| <div className='ml-1 text-[13px] text-gray-900'>{title}</div> | |||||
| </div> | </div> | ||||
| ) | ) | ||||
| }) | }) |
| import { memo } from 'react' | |||||
| import { VariableMenuItem } from './variable-option' | |||||
| type VariableMenuProps = { | |||||
| startIndex: number | |||||
| selectedIndex: number | null | |||||
| options: any[] | |||||
| onClick: (index: number, option: any) => void | |||||
| onMouseEnter: (index: number, option: any) => void | |||||
| queryString: string | null | |||||
| } | |||||
| const VariableMenu = ({ | |||||
| startIndex, | |||||
| selectedIndex, | |||||
| options, | |||||
| onClick, | |||||
| onMouseEnter, | |||||
| queryString, | |||||
| }: VariableMenuProps) => { | |||||
| return ( | |||||
| <div className='p-1'> | |||||
| { | |||||
| options.map((option, index: number) => ( | |||||
| <VariableMenuItem | |||||
| startIndex={startIndex} | |||||
| index={index} | |||||
| isSelected={selectedIndex === index + startIndex} | |||||
| onClick={onClick} | |||||
| onMouseEnter={onMouseEnter} | |||||
| key={option.key} | |||||
| option={option} | |||||
| queryString={queryString} | |||||
| /> | |||||
| )) | |||||
| } | |||||
| </div> | |||||
| ) | |||||
| } | |||||
| export default memo(VariableMenu) |
| import { memo } from 'react' | import { memo } from 'react' | ||||
| import { MenuOption } from '@lexical/react/LexicalTypeaheadMenuPlugin' | |||||
| export class VariableOption extends MenuOption { | |||||
| type VariableMenuItemProps = { | |||||
| title: string | title: string | ||||
| icon?: JSX.Element | icon?: JSX.Element | ||||
| extraElement?: JSX.Element | extraElement?: JSX.Element | ||||
| keywords: Array<string> | |||||
| keyboardShortcut?: string | |||||
| onSelect: (queryString: string) => void | |||||
| constructor( | |||||
| title: string, | |||||
| options: { | |||||
| icon?: JSX.Element | |||||
| extraElement?: JSX.Element | |||||
| keywords?: Array<string> | |||||
| keyboardShortcut?: string | |||||
| onSelect: (queryString: string) => void | |||||
| }, | |||||
| ) { | |||||
| super(title) | |||||
| this.title = title | |||||
| this.keywords = options.keywords || [] | |||||
| this.icon = options.icon | |||||
| this.extraElement = options.extraElement | |||||
| this.keyboardShortcut = options.keyboardShortcut | |||||
| this.onSelect = options.onSelect.bind(this) | |||||
| } | |||||
| } | |||||
| type VariableMenuItemProps = { | |||||
| startIndex: number | |||||
| index: number | |||||
| isSelected: boolean | isSelected: boolean | ||||
| onClick: (index: number, option: VariableOption) => void | |||||
| onMouseEnter: (index: number, option: VariableOption) => void | |||||
| option: VariableOption | |||||
| queryString: string | null | queryString: string | null | ||||
| onClick: () => void | |||||
| onMouseEnter: () => void | |||||
| setRefElement?: (element: HTMLDivElement) => void | |||||
| } | } | ||||
| export const VariableMenuItem = memo(({ | export const VariableMenuItem = memo(({ | ||||
| startIndex, | |||||
| index, | |||||
| title, | |||||
| icon, | |||||
| extraElement, | |||||
| isSelected, | isSelected, | ||||
| queryString, | |||||
| onClick, | onClick, | ||||
| onMouseEnter, | onMouseEnter, | ||||
| option, | |||||
| queryString, | |||||
| setRefElement, | |||||
| }: VariableMenuItemProps) => { | }: VariableMenuItemProps) => { | ||||
| const title = option.title | |||||
| let before = title | let before = title | ||||
| let middle = '' | let middle = '' | ||||
| let after = '' | let after = '' | ||||
| if (queryString) { | if (queryString) { | ||||
| const regex = new RegExp(queryString, 'i') | const regex = new RegExp(queryString, 'i') | ||||
| const match = regex.exec(option.title) | |||||
| const match = regex.exec(title) | |||||
| if (match) { | if (match) { | ||||
| before = title.substring(0, match.index) | before = title.substring(0, match.index) | ||||
| return ( | return ( | ||||
| <div | <div | ||||
| key={option.key} | |||||
| className={` | className={` | ||||
| flex items-center px-3 h-6 rounded-md hover:bg-primary-50 cursor-pointer | flex items-center px-3 h-6 rounded-md hover:bg-primary-50 cursor-pointer | ||||
| ${isSelected && 'bg-primary-50'} | ${isSelected && 'bg-primary-50'} | ||||
| `} | `} | ||||
| tabIndex={-1} | tabIndex={-1} | ||||
| ref={option.setRefElement} | |||||
| onMouseEnter={() => onMouseEnter(index + startIndex, option)} | |||||
| onClick={() => onClick(index + startIndex, option)}> | |||||
| ref={setRefElement} | |||||
| onMouseEnter={onMouseEnter} | |||||
| onClick={onClick}> | |||||
| <div className='mr-2'> | <div className='mr-2'> | ||||
| {option.icon} | |||||
| {icon} | |||||
| </div> | </div> | ||||
| <div className='grow text-[13px] text-gray-900 truncate' title={option.title}> | |||||
| <div className='grow text-[13px] text-gray-900 truncate' title={title}> | |||||
| {before} | {before} | ||||
| <span className='text-[#2970FF]'>{middle}</span> | <span className='text-[#2970FF]'>{middle}</span> | ||||
| {after} | {after} | ||||
| </div> | </div> | ||||
| {option.extraElement} | |||||
| {extraElement} | |||||
| </div> | </div> | ||||
| ) | ) | ||||
| }) | }) |