diff --git a/Composer/packages/adaptive-form/src/components/fields/IntentField.tsx b/Composer/packages/adaptive-form/src/components/fields/IntentField.tsx index e9bb3a877c..96e2dcd8d6 100644 --- a/Composer/packages/adaptive-form/src/components/fields/IntentField.tsx +++ b/Composer/packages/adaptive-form/src/components/fields/IntentField.tsx @@ -12,7 +12,7 @@ const IntentField: React.FC = (props) => { const { currentRecognizer } = useRecognizerConfig(); const Editor = currentRecognizer?.intentEditor; - const label = formatMessage('Trigger phrases (intent: #{intentName})', { intentName: value }); + const label = formatMessage('Trigger phrases'); const handleChange = () => { onChange(value); diff --git a/Composer/packages/adaptive-form/src/components/fields/__tests__/IntentField.test.tsx b/Composer/packages/adaptive-form/src/components/fields/__tests__/IntentField.test.tsx index a33641b904..d4f08f9072 100644 --- a/Composer/packages/adaptive-form/src/components/fields/__tests__/IntentField.test.tsx +++ b/Composer/packages/adaptive-form/src/components/fields/__tests__/IntentField.test.tsx @@ -47,7 +47,7 @@ describe('', () => { }); const { getByLabelText } = renderSubject({ value: 'MyIntent' }); - expect(getByLabelText('Trigger phrases (intent: #MyIntent)')).toBeInTheDocument(); + expect(getByLabelText('Trigger phrases')).toBeInTheDocument(); }); it('invokes change handler with intent name', () => { diff --git a/Composer/packages/client/__tests__/pages/language-understanding/LUPage.test.tsx b/Composer/packages/client/__tests__/pages/language-understanding/LUPage.test.tsx index 269416c1d9..3e1bf307c3 100644 --- a/Composer/packages/client/__tests__/pages/language-understanding/LUPage.test.tsx +++ b/Composer/packages/client/__tests__/pages/language-understanding/LUPage.test.tsx @@ -37,8 +37,8 @@ const state = { ], locale: 'en-us', luFiles: [ - { id: 'a.en-us', content: initialContent, templates: initialIntents, diagnostics: [] }, - { id: 'a.fr-fr', content: initialContent, templates: initialIntents, diagnostics: [] }, + { id: 'a.en-us', content: initialContent, templates: initialIntents, diagnostics: [], intents: [] }, + { id: 'a.fr-fr', content: initialContent, templates: initialIntents, diagnostics: [], intents: [] }, ], settings: { defaultLanguage: 'en-us', diff --git a/Composer/packages/client/src/pages/language-understanding/code-editor.tsx b/Composer/packages/client/src/pages/language-understanding/code-editor.tsx index 3f0cbd86c7..d4dd476e62 100644 --- a/Composer/packages/client/src/pages/language-understanding/code-editor.tsx +++ b/Composer/packages/client/src/pages/language-understanding/code-editor.tsx @@ -153,18 +153,20 @@ const CodeEditor: React.FC = (props) => { languageServer={{ path: lspServerPath, }} + luFile={file} luOption={luOption} value={content} onChange={onChange} onChangeSettings={handleSettingsChange} /> ); - }, [luOption]); + }, [luOption, file]); const defaultLanguageFileEditor = useMemo(() => { return ( = (props) => { onChange={() => {}} /> ); - }, [dialogId]); + }, [defaultLangFile, dialogId]); return ( diff --git a/Composer/packages/lib/code-editor/src/BaseEditor.tsx b/Composer/packages/lib/code-editor/src/BaseEditor.tsx index ada5374195..fa8af5762e 100644 --- a/Composer/packages/lib/code-editor/src/BaseEditor.tsx +++ b/Composer/packages/lib/code-editor/src/BaseEditor.tsx @@ -110,7 +110,7 @@ export interface BaseEditorProps extends EditorProps { helpURL?: string; hidePlaceholder?: boolean; id?: string; - onChange: (newValue: string) => void; + onChange: (newValue: string, isFlush?: boolean) => void; onInit?: OnInit; placeholder?: string; value?: string; @@ -192,10 +192,10 @@ const BaseEditor: React.FC = (props) => { useEffect(() => { if (editorRef.current) { - const disposable = editorRef.current.onDidChangeModelContent(() => { + const disposable = editorRef.current.onDidChangeModelContent((e) => { if (editorRef.current) { const newValue = editorRef.current.getValue(); - setTimeout(() => onChange(newValue), 0); + setTimeout(() => onChange(newValue, e.isFlush), 0); } }); diff --git a/Composer/packages/lib/code-editor/src/LuEditor.tsx b/Composer/packages/lib/code-editor/src/LuEditor.tsx index 25f1da5e67..3b327d6f48 100644 --- a/Composer/packages/lib/code-editor/src/LuEditor.tsx +++ b/Composer/packages/lib/code-editor/src/LuEditor.tsx @@ -1,22 +1,72 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import React, { useRef, useState, useEffect } from 'react'; -import { listen, MessageConnection } from 'vscode-ws-jsonrpc'; -import get from 'lodash/get'; -import { MonacoServices, MonacoLanguageClient } from 'monaco-languageclient'; +import { LuFile } from '@botframework-composer/types'; +import styled from '@emotion/styled'; import { EditorDidMount, Monaco } from '@monaco-editor/react'; +import { FluentTheme, NeutralColors } from '@uifabric/fluent-theme'; import formatMessage from 'format-message'; +import get from 'lodash/get'; +import { MonacoLanguageClient, MonacoServices } from 'monaco-languageclient'; +import { Icon } from 'office-ui-fabric-react/lib/Icon'; +import { Link } from 'office-ui-fabric-react/lib/Link'; +import { Stack } from 'office-ui-fabric-react/lib/Stack'; +import { Text } from 'office-ui-fabric-react/lib/Text'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { listen, MessageConnection } from 'vscode-ws-jsonrpc'; -import { registerLULanguage } from './languages'; -import { createUrl, createWebSocket, createLanguageClient, sendRequestWithRetry } from './utils/lspUtil'; import { BaseEditor, BaseEditorProps, OnInit } from './BaseEditor'; import { defaultPlaceholder, LU_HELP } from './constants'; +import { registerLULanguage } from './languages'; +import { getDefaultMlEntityName } from './lu/constants'; +import { useLuEntities } from './lu/hooks/useLuEntities'; +import { LuEditorToolbar as DefaultLuEditorToolbar } from './lu/LuEditorToolbar'; +import { LuLabelingMenu } from './lu/LuLabelingMenu'; +import { ToolbarLuEntityType } from './lu/types'; import { LUOption } from './utils'; +import { createLanguageClient, createUrl, createWebSocket, sendRequestWithRetry } from './utils/lspUtil'; +import { computeDefineLuEntityEdits, computeInsertLuEntityEdits } from './utils/luUtils'; +import { withTooltip } from './utils/withTooltip'; +const LuEditorToolbar = styled(DefaultLuEditorToolbar)({ + border: `1px solid ${NeutralColors.gray120}`, + borderBottom: 'none', +}); + +const linkStyles = { + root: { + fontSize: FluentTheme.fonts.small.fontSize, + ':hover': { textDecoration: 'none' }, + ':active': { textDecoration: 'none' }, + }, +}; + +const botIconStyles = { root: { padding: '0 4px', fontSize: FluentTheme.fonts.small.fontSize } }; +const grayTextStyle = { root: { color: NeutralColors.gray80, fontSize: FluentTheme.fonts.small.fontSize } }; + +const LuSectionLink = withTooltip( + { + content: ( + + {formatMessage.rich('Edit this intent inUser Input view', { + a: ({ children }) => ( + + + {children} + + ), + })} + + ), + }, + Link +); + +const sectionLinkTokens = { childrenGap: 4 }; export interface LULSPEditorProps extends BaseEditorProps { luOption?: LUOption; helpURL?: string; + luFile?: LuFile; languageServer?: | { host?: string; @@ -25,11 +75,14 @@ export interface LULSPEditorProps extends BaseEditorProps { path: string; } | string; + toolbarHidden?: boolean; + onNavigateToLuPage?: (luFileId: string, luSectionId?: string) => void; } const defaultLUServer = { path: '/lu-language-server', }; + declare global { interface Window { monacoServiceInstance: MonacoServices; @@ -43,7 +96,7 @@ type ServerEdit = { }; /* -convert the edits results from the server to an exectable object in manoco editor +convert the edits results from the server to an executable object in monaco editor */ function convertEdit(serverEdit: ServerEdit) { return { @@ -69,11 +122,15 @@ const LuEditor: React.FC = (props) => { lightbulb: { enabled: true, }, + contextmenu: false, ...props.options, }; const { + toolbarHidden, + onNavigateToLuPage, luOption, + luFile, languageServer, onInit: onInitProp, placeholder = defaultPlaceholder, @@ -89,6 +146,10 @@ const LuEditor: React.FC = (props) => { } const [editor, setEditor] = useState(); + const entities = useLuEntities(luFile); + + const [labelingMenuVisible, setLabelingMenuVisible] = useState(false); + const editorDomRef = useRef(null); useEffect(() => { if (!editor) return; @@ -109,7 +170,7 @@ const LuEditor: React.FC = (props) => { const m = monacoRef.current; if (m) { - // this is the correct way to combine keycodes in Monaco + // this is the correct way to combine key codes in Monaco // eslint-disable-next-line no-bitwise editor.addCommand(m.KeyMod.Shift | m.KeyCode.Enter, () => { const position = editor.getPosition(); @@ -146,6 +207,7 @@ const LuEditor: React.FC = (props) => { sendRequestWithRetry(languageClient, 'initializeDocuments', { luOption, uri }); } }, [editor]); + const onInit: OnInit = (monaco) => { registerLULanguage(monaco); monacoRef.current = monaco; @@ -157,23 +219,93 @@ const LuEditor: React.FC = (props) => { const editorDidMount: EditorDidMount = (_getValue, editor) => { setEditor(editor); + editorDomRef.current = editor.getDomNode(); if (typeof props.editorDidMount === 'function') { return props.editorDidMount(_getValue, editor); } }; + const defineEntity = useCallback( + (entityType: ToolbarLuEntityType, entityName?: string) => { + entityName = entityName || getDefaultMlEntityName(entityType); + if (editor) { + const luEdits = computeDefineLuEntityEdits(entityType, entityName, editor, entities); + if (luEdits?.edits?.length) { + editor.executeEdits('toolbarMenu', luEdits.edits); + if (luEdits.selection) { + editor.setSelection(luEdits.selection); + } + + if (luEdits?.scrollLine) { + editor.revealLineInCenter(luEdits?.scrollLine); + } + + editor.focus(); + } + } + }, + [editor, entities] + ); + + const insertEntity = useCallback( + (entityName: string) => { + if (editor) { + const edits = computeInsertLuEntityEdits(entityName, editor); + if (edits) { + editor.executeEdits('toolbarMenu', edits); + editor.focus(); + } + } + }, + [editor] + ); + + const navigateToLuPage = React.useCallback(() => { + onNavigateToLuPage?.(luOption?.fileId ?? 'common', luOption?.sectionId); + }, [onNavigateToLuPage, luOption]); + + const onLabelingMenuToggled = React.useCallback((visible: boolean) => setLabelingMenuVisible(visible), []); + return ( - + <> + + {!toolbarHidden && ( + + )} + + + {onNavigateToLuPage && luOption && ( + + {formatMessage('Intent name: ')} + + #{luOption.sectionId} + + + )} + + + ); }; diff --git a/Composer/packages/lib/code-editor/src/lg/hooks/__tests__/useDebounce.test.tsx b/Composer/packages/lib/code-editor/src/hooks/__tests__/useDebounce.test.tsx similarity index 100% rename from Composer/packages/lib/code-editor/src/lg/hooks/__tests__/useDebounce.test.tsx rename to Composer/packages/lib/code-editor/src/hooks/__tests__/useDebounce.test.tsx diff --git a/Composer/packages/lib/code-editor/src/lg/hooks/useDebounce.ts b/Composer/packages/lib/code-editor/src/hooks/useDebounce.ts similarity index 100% rename from Composer/packages/lib/code-editor/src/lg/hooks/useDebounce.ts rename to Composer/packages/lib/code-editor/src/hooks/useDebounce.ts diff --git a/Composer/packages/lib/code-editor/src/hooks/useDebouncedSearchCallbacks.ts b/Composer/packages/lib/code-editor/src/hooks/useDebouncedSearchCallbacks.ts new file mode 100644 index 0000000000..c6654b1e6f --- /dev/null +++ b/Composer/packages/lib/code-editor/src/hooks/useDebouncedSearchCallbacks.ts @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { useState, useCallback } from 'react'; + +import { useDebounce } from './useDebounce'; + +/** + * Debounced query and callbacks for FluentUI SearchBox. + */ +export const useDebouncedSearchCallbacks = () => { + const [query, setQuery] = useState(); + const debouncedQuery = useDebounce(query, 300); + + const onSearchAbort = useCallback(() => { + setQuery(''); + }, []); + + const onSearchQueryChange = useCallback((_?: React.ChangeEvent, newValue?: string) => { + setQuery(newValue); + }, []); + + return { onSearchAbort, onSearchQueryChange, query: debouncedQuery, setQuery }; +}; diff --git a/Composer/packages/lib/code-editor/src/hooks/useNoSearchResultMenuItem.tsx b/Composer/packages/lib/code-editor/src/hooks/useNoSearchResultMenuItem.tsx new file mode 100644 index 0000000000..37ad7741b4 --- /dev/null +++ b/Composer/packages/lib/code-editor/src/hooks/useNoSearchResultMenuItem.tsx @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import formatMessage from 'format-message'; +import { IContextualMenuItem } from 'office-ui-fabric-react/lib/ContextualMenu'; +import { Icon } from 'office-ui-fabric-react/lib/Icon'; +import { Stack } from 'office-ui-fabric-react/lib/Stack'; +import { Text } from 'office-ui-fabric-react/lib/Text'; +import * as React from 'react'; + +const searchEmptyMessageStyles = { root: { height: 32 } }; +const searchEmptyMessageTokens = { childrenGap: 8 }; + +/** + * Search empty view for contextual menu with search capability. + */ +export const useNoSearchResultMenuItem = (message?: string): IContextualMenuItem => { + message = message ?? formatMessage('no items found'); + return React.useMemo( + () => ({ + key: 'no_results', + onRender: () => ( + + + {message} + + ), + }), + [message] + ); +}; diff --git a/Composer/packages/lib/code-editor/src/hooks/useSearchableMenuListCallback.tsx b/Composer/packages/lib/code-editor/src/hooks/useSearchableMenuListCallback.tsx new file mode 100644 index 0000000000..8ee19771e5 --- /dev/null +++ b/Composer/packages/lib/code-editor/src/hooks/useSearchableMenuListCallback.tsx @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { FluentTheme } from '@uifabric/fluent-theme'; +import formatMessage from 'format-message'; +import { IContextualMenuListProps } from 'office-ui-fabric-react/lib/ContextualMenu'; +import { SearchBox } from 'office-ui-fabric-react/lib/SearchBox'; +import { Stack } from 'office-ui-fabric-react/lib/Stack'; +import { IRenderFunction } from 'office-ui-fabric-react/lib/Utilities'; +import * as React from 'react'; + +import { useDebouncedSearchCallbacks } from './useDebouncedSearchCallbacks'; + +const fontSizeStyle = { + fontSize: FluentTheme.fonts.small.fontSize, +}; + +const itemsContainerStyles = { root: { overflowY: 'auto', maxHeight: 216, width: 200, overflowX: 'hidden' } }; +const searchFieldStyles = { root: { borderRadius: 0, ...fontSizeStyle }, iconContainer: { display: 'none' } }; + +/** + * Callback for FluentUI contextual menu list renderer with search box on top. + */ +export const useSearchableMenuListCallback = ( + searchFiledPlaceHolder?: string, + headerRenderer?: () => React.ReactNode +) => { + const { onSearchAbort, onSearchQueryChange, query, setQuery } = useDebouncedSearchCallbacks(); + const callback = React.useCallback( + (menuListProps?: IContextualMenuListProps, defaultRender?: IRenderFunction) => { + return ( + + {headerRenderer?.()} + + {defaultRender?.(menuListProps)} + + ); + }, + [searchFiledPlaceHolder, headerRenderer, onSearchAbort, onSearchQueryChange] + ); + + return { onRenderMenuList: callback, query, setQuery }; +}; diff --git a/Composer/packages/lib/code-editor/src/lg/LgCodeEditor.tsx b/Composer/packages/lib/code-editor/src/lg/LgCodeEditor.tsx index 9e2c075064..319eea43dd 100644 --- a/Composer/packages/lib/code-editor/src/lg/LgCodeEditor.tsx +++ b/Composer/packages/lib/code-editor/src/lg/LgCodeEditor.tsx @@ -10,9 +10,11 @@ import { MonacoLanguageClient, MonacoServices } from 'monaco-languageclient'; import { Icon } from 'office-ui-fabric-react/lib/Icon'; import { Link } from 'office-ui-fabric-react/lib/Link'; import { Stack } from 'office-ui-fabric-react/lib/Stack'; +import { Dialog } from 'office-ui-fabric-react/lib/Dialog'; import { Text } from 'office-ui-fabric-react/lib/Text'; import React, { useEffect, useState } from 'react'; import { listen, MessageConnection } from 'vscode-ws-jsonrpc'; +import omit from 'lodash/omit'; import { BaseEditor, OnInit } from '../BaseEditor'; import { LG_HELP } from '../constants'; @@ -60,6 +62,8 @@ const LgTemplateLink = withTooltip( Link ); +const templateLinkTokens = { childrenGap: 4 }; + const LgEditorToolbar = styled(DefaultLgEditorToolbar)({ border: `1px solid ${NeutralColors.gray120}`, borderBottom: 'none', @@ -93,6 +97,8 @@ export const LgCodeEditor = (props: LgCodeEditorProps) => { lgTemplates, telemetryClient, onNavigateToLgPage, + popExpandOptions, + onChange, ...restProps } = props; @@ -105,6 +111,7 @@ export const LgCodeEditor = (props: LgCodeEditorProps) => { } const [editor, setEditor] = useState(); + const [expanded, setExpanded] = useState(false); useEffect(() => { if (!editor) return; @@ -170,37 +177,86 @@ export const LgCodeEditor = (props: LgCodeEditorProps) => { ); const navigateToLgPage = React.useCallback(() => { - onNavigateToLgPage?.(lgOption?.fileId ?? 'common'); - }, [onNavigateToLgPage, lgOption?.fileId]); + onNavigateToLgPage?.(lgOption?.fileId ?? 'common', lgOption?.templateId); + }, [onNavigateToLgPage, lgOption]); + + const onExpandedEditorChange = React.useCallback( + (newValue: string) => { + editor?.getModel()?.setValue(newValue); + onChange(newValue); + }, + [editor, onChange] + ); + + const change = React.useCallback( + (newValue: string, isFlush?: boolean) => { + // Only invoke callback if it's user edits and not setValue call + if (!isFlush) { + onChange(newValue); + } + }, + [onChange] + ); return ( - - {!toolbarHidden && ( - + + {!toolbarHidden && ( + { + setExpanded(true); + popExpandOptions.onEditorPopToggle?.(true); + } + : undefined + } + onSelectToolbarMenuItem={selectToolbarMenuItem} + /> + )} + + {onNavigateToLgPage && lgOption && ( + + {formatMessage('Template name: ')} + + #{lgOption.templateId}() + + + )} + + {expanded && ( + )} - - {onNavigateToLgPage && lgOption && ( - - {formatMessage('Template name: ')} - - #{lgOption.templateId}() - - - )} - + ); }; diff --git a/Composer/packages/lib/code-editor/src/lg/LgEditorToolbar.tsx b/Composer/packages/lib/code-editor/src/lg/LgEditorToolbar.tsx index 9d91209d3d..8c2f3dfca6 100644 --- a/Composer/packages/lib/code-editor/src/lg/LgEditorToolbar.tsx +++ b/Composer/packages/lib/code-editor/src/lg/LgEditorToolbar.tsx @@ -8,6 +8,7 @@ import { CommandBar, ICommandBarItemProps } from 'office-ui-fabric-react/lib/Com import { VerticalDivider } from 'office-ui-fabric-react/lib/Divider'; import { IContextualMenuProps } from 'office-ui-fabric-react/lib/ContextualMenu'; import * as React from 'react'; +import { createSvgIcon } from '@fluentui/react-icons'; import { withTooltip } from '../utils/withTooltip'; @@ -16,6 +17,17 @@ import { useLgEditorToolbarItems } from './hooks/useLgEditorToolbarItems'; import { ToolbarButtonMenu } from './ToolbarButtonMenu'; import { ToolbarButtonPayload } from './types'; +const svgIconStyle = { fill: NeutralColors.black, margin: '0 4px', width: 16, height: 16 }; + +const popExpandSvgIcon = ( + + + +); + const menuHeight = 32; const dividerStyles = { @@ -68,10 +80,11 @@ export type LgEditorToolbarProps = { onSelectToolbarMenuItem: (itemText: string, itemType: ToolbarButtonPayload['kind']) => void; moreToolbarItems?: readonly ICommandBarItemProps[]; className?: string; + onPopExpand?: () => void; }; export const LgEditorToolbar = React.memo((props: LgEditorToolbarProps) => { - const { className, properties, lgTemplates, moreToolbarItems, onSelectToolbarMenuItem } = props; + const { className, properties, lgTemplates, moreToolbarItems, onSelectToolbarMenuItem, onPopExpand } = props; const { functionRefPayload, propertyRefPayload, templateRefPayload } = useLgEditorToolbarItems( lgTemplates ?? [], @@ -154,5 +167,37 @@ export const LgEditorToolbar = React.memo((props: LgEditorToolbarProps) => { [fixedItems, moreItems] ); - return undefined} />; + const popExpand = React.useCallback(() => { + onPopExpand?.(); + }, [onPopExpand]); + + const farItems = React.useMemo( + () => + onPopExpand + ? [ + { + key: 'popExpandButton', + buttonStyles: moreButtonStyles, + className: jsLgToolbarMenuClassName, + onRenderIcon: () => { + let PopExpandIcon = createSvgIcon({ svg: () => popExpandSvgIcon, displayName: 'PopExpandIcon' }); + PopExpandIcon = withTooltip({ content: formatMessage('Pop out editor') }, PopExpandIcon); + return ; + }, + onClick: popExpand, + }, + ] + : [], + [popExpand] + ); + + return ( + undefined} + /> + ); }); diff --git a/Composer/packages/lib/code-editor/src/lg/ToolbarButtonMenu.tsx b/Composer/packages/lib/code-editor/src/lg/ToolbarButtonMenu.tsx index b588d3c272..2b9cee1fa6 100644 --- a/Composer/packages/lib/code-editor/src/lg/ToolbarButtonMenu.tsx +++ b/Composer/packages/lib/code-editor/src/lg/ToolbarButtonMenu.tsx @@ -11,23 +11,21 @@ import { ContextualMenuItem, IContextualMenuItem, IContextualMenuItemProps, - IContextualMenuListProps, IContextualMenuProps, } from 'office-ui-fabric-react/lib/ContextualMenu'; import { Icon } from 'office-ui-fabric-react/lib/Icon'; import { Label } from 'office-ui-fabric-react/lib/Label'; -import { SearchBox } from 'office-ui-fabric-react/lib/SearchBox'; import { IStackStyles, Stack } from 'office-ui-fabric-react/lib/Stack'; import { Text } from 'office-ui-fabric-react/lib/Text'; import { DirectionalHint } from 'office-ui-fabric-react/lib/Tooltip'; -import { IRenderFunction } from 'office-ui-fabric-react/lib/Utilities'; import * as React from 'react'; +import { useNoSearchResultMenuItem } from '../hooks/useNoSearchResultMenuItem'; +import { useSearchableMenuListCallback } from '../hooks/useSearchableMenuListCallback'; import { computePropertyItemTree, getAllNodes } from '../utils/lgUtils'; import { withTooltip } from '../utils/withTooltip'; import { jsLgToolbarMenuClassName } from './constants'; -import { useDebounce } from './hooks/useDebounce'; import { PropertyTreeItem } from './PropertyTreeItem'; import { FunctionRefPayload, @@ -83,19 +81,15 @@ const OneLiner = styled.div({ padding: '0 8px', }); -const searchEmptyMessageStyles = { root: { height: 32 } }; -const searchEmptyMessageTokens = { childrenGap: 8 }; - const svgIconStyle = { fill: NeutralColors.black, margin: '0 4px', width: 16, height: 16 }; const iconStyles = { root: { color: NeutralColors.black, margin: '0 4px', width: 16, height: 16 } }; -const searchFieldStyles = { root: { borderRadius: 0, ...fontSizeStyle }, iconContainer: { display: 'none' } }; + const calloutProps = { styles: { calloutMain: { overflowY: 'hidden' }, }, layerProps: { className: jsLgToolbarMenuClassName }, }; -const itemsContainerStyles = { root: { overflowY: 'auto', maxHeight: 216, width: 200, overflowX: 'hidden' } }; type ToolbarButtonMenuProps = { payload: ToolbarButtonPayload; @@ -149,8 +143,6 @@ export const ToolbarButtonMenu = React.memo((props: ToolbarButtonMenuProps) => { const { payload, disabled = false } = props; const [propertyTreeExpanded, setPropertyTreeExpanded] = React.useState>({}); - const [query, setQuery] = React.useState(); - const debouncedQuery = useDebounce(query, 300); const uiStrings = React.useMemo(() => getStrings(payload.kind), [payload.kind]); const propertyTreeConfig = React.useMemo(() => { @@ -290,15 +282,7 @@ export const ToolbarButtonMenu = React.memo((props: ToolbarButtonMenuProps) => { React.useEffect(() => setItems(menuItems), [menuItems]); - const onSearchAbort = React.useCallback(() => { - setQuery(''); - }, []); - - const onSearchQueryChange = React.useCallback((_?: React.ChangeEvent, newValue?: string) => { - setQuery(newValue); - }, []); - - const getFilterPredictable = React.useCallback((kind: ToolbarButtonPayload['kind'], q: string) => { + const getFilterPredicate = React.useCallback((kind: ToolbarButtonPayload['kind'], q: string) => { switch (kind) { case 'function': case 'template': @@ -309,8 +293,24 @@ export const ToolbarButtonMenu = React.memo((props: ToolbarButtonMenuProps) => { } }, []); + const noSearchResultMenuItem = useNoSearchResultMenuItem(uiStrings.emptyMessage); + + const menuHeaderRenderer = React.useCallback( + () => ( + +
{uiStrings.header}
+
+ ), + [] + ); + + const { onRenderMenuList, query, setQuery } = useSearchableMenuListCallback( + uiStrings.searchPlaceholder, + menuHeaderRenderer + ); + React.useEffect(() => { - if (debouncedQuery) { + if (query) { const searchableItems = payload.kind === 'function' ? flatFunctionListItems @@ -318,55 +318,19 @@ export const ToolbarButtonMenu = React.memo((props: ToolbarButtonMenuProps) => { ? flatPropertyListItems : menuItems; - const predictable = getFilterPredictable(payload.kind, debouncedQuery); + const predicate = getFilterPredicate(payload.kind, query); - const filteredItems = searchableItems.filter(predictable); + const filteredItems = searchableItems.filter(predicate); if (!filteredItems || !filteredItems.length) { - filteredItems.push({ - key: 'no_results', - onRender: () => ( - - - {uiStrings.emptyMessage} - - ), - }); + filteredItems.push(noSearchResultMenuItem); } setItems(filteredItems); } else { setItems(menuItems); } - }, [menuItems, flatPropertyListItems, flatFunctionListItems, debouncedQuery, payload.kind]); - - const onRenderMenuList = React.useCallback( - (menuListProps?: IContextualMenuListProps, defaultRender?: IRenderFunction) => { - return ( - - -
{uiStrings.header}
-
- - {defaultRender?.(menuListProps)} -
- ); - }, - [onSearchAbort, onSearchQueryChange, payload.kind, debouncedQuery] - ); + }, [menuItems, flatPropertyListItems, flatFunctionListItems, noSearchResultMenuItem, query, payload.kind]); const onDismiss = React.useCallback(() => { setQuery(''); @@ -452,7 +416,7 @@ export const ToolbarButtonMenu = React.memo((props: ToolbarButtonMenuProps) => { expanded={propertyTreeExpanded[node.id]} item={node} level={level} - onRenderLabel={debouncedQuery ? renderSearchResultLabel : renderLabel} + onRenderLabel={query ? renderSearchResultLabel : renderLabel} onToggleExpand={onToggleExpand} /> ); @@ -460,7 +424,7 @@ export const ToolbarButtonMenu = React.memo((props: ToolbarButtonMenuProps) => { } as IContextualMenuProps; } } - }, [items, onRenderMenuList, onDismiss, propertyTreeExpanded, debouncedQuery]); + }, [items, onRenderMenuList, onDismiss, propertyTreeExpanded, query]); const renderIcon = React.useCallback(() => getIcon(payload.kind), [payload.kind]); diff --git a/Composer/packages/lib/code-editor/src/lg/modalityEditors/AttachmentArrayEditor.tsx b/Composer/packages/lib/code-editor/src/lg/modalityEditors/AttachmentArrayEditor.tsx index c76db86a65..88c43b895c 100644 --- a/Composer/packages/lib/code-editor/src/lg/modalityEditors/AttachmentArrayEditor.tsx +++ b/Composer/packages/lib/code-editor/src/lg/modalityEditors/AttachmentArrayEditor.tsx @@ -21,6 +21,8 @@ import { getUniqueTemplateName } from '../../utils/lgUtils'; import { StringArrayItem } from './StringArrayItem'; +const inputs = ['input', 'textarea']; + const getLgCardTemplateDisplayName = (attachmentType: LgCardTemplateType) => { switch (attachmentType) { case 'hero': @@ -87,6 +89,9 @@ export const AttachmentArrayEditor = React.memo( }: AttachmentArrayEditorProps) => { const containerRef = React.useRef(null); const [currentIndex, setCurrentIndex] = React.useState(null); + const [editorExpanded, setEditorExpanded] = React.useState(false); + + const editorPopToggle = React.useCallback((expanded: boolean) => setEditorExpanded(expanded), []); const debouncedChange = React.useCallback( debounce((id: string, content: string | undefined, callback: (templateId: string, body?: string) => void) => { @@ -167,7 +172,10 @@ export const AttachmentArrayEditor = React.memo( React.useEffect(() => { const keydownHandler = (e: KeyboardEvent) => { - if (e.key === 'Escape') { + if ( + e.key === 'Escape' && + (!document.activeElement || inputs.includes(document.activeElement.tagName.toLowerCase())) + ) { setCurrentIndex(null); // Remove empty variations only if necessary if (items.some((item) => !item)) { @@ -181,6 +189,10 @@ export const AttachmentArrayEditor = React.memo( return; } + if (editorExpanded) { + return; + } + if ( !e .composedPath() @@ -203,7 +215,7 @@ export const AttachmentArrayEditor = React.memo( document.removeEventListener('keydown', keydownHandler); document.removeEventListener('focusin', focusHandler); }; - }, [items, onChange]); + }, [items, editorExpanded, onChange]); const templates: LgTemplate[] = React.useMemo(() => { return items.map((name) => { @@ -245,6 +257,7 @@ export const AttachmentArrayEditor = React.memo( removeTooltipTextContent={formatMessage('Remove attachment')} telemetryClient={telemetryClient} value={body} + onEditorPopToggle={editorPopToggle} onFocus={onFocus(idx)} onLgChange={onLgCodeChange(name)} onRemove={onRemove(name)} diff --git a/Composer/packages/lib/code-editor/src/lg/modalityEditors/StringArrayEditor.tsx b/Composer/packages/lib/code-editor/src/lg/modalityEditors/StringArrayEditor.tsx index 7841aee920..d81c55a287 100644 --- a/Composer/packages/lib/code-editor/src/lg/modalityEditors/StringArrayEditor.tsx +++ b/Composer/packages/lib/code-editor/src/lg/modalityEditors/StringArrayEditor.tsx @@ -17,7 +17,7 @@ import { ToolbarButtonPayload } from '../types'; import { StringArrayItem } from './StringArrayItem'; -const submitKeys = ['Enter', 'Escape']; +const inputs = ['input', 'textarea']; const styles: { link: ILinkStyles } = { link: { @@ -118,7 +118,11 @@ export const StringArrayEditor = React.memo( useEffect(() => { const keydownHandler = (e: KeyboardEvent) => { - if (submitKeys.includes(e.key)) { + if ( + e.key === 'Enter' || + (e.key === 'Escape' && + (!document.activeElement || inputs.includes(document.activeElement.tagName.toLowerCase()))) + ) { // Allow multiline via shift+Enter if (e.key === 'Enter' && e.shiftKey) { return; diff --git a/Composer/packages/lib/code-editor/src/lg/modalityEditors/StringArrayItem.tsx b/Composer/packages/lib/code-editor/src/lg/modalityEditors/StringArrayItem.tsx index f57da6c9f1..36bfb92c9c 100644 --- a/Composer/packages/lib/code-editor/src/lg/modalityEditors/StringArrayItem.tsx +++ b/Composer/packages/lib/code-editor/src/lg/modalityEditors/StringArrayItem.tsx @@ -38,6 +38,9 @@ const Input = styled(TextField)({ padding: '8px 0 8px 4px', width: '100%', position: 'relative', + '& input::placeholder, & textarea::placeholder': { + fontSize: FluentTheme.fonts.small.fontSize, + }, '& input, & textarea': { fontSize: FluentTheme.fonts.small.fontSize, maxHeight: '97px', @@ -108,6 +111,7 @@ type Props = { onChange?: (event: React.FormEvent, value?: string) => void; onLgChange?: (value: string) => void; onShowCallout?: (target: HTMLTextAreaElement) => void; + onEditorPopToggle?: (expanded: boolean) => void; }; type TextViewItemProps = Pick< @@ -204,6 +208,7 @@ const TextFieldItem = React.memo(({ value, onShowCallout, onChange }: TextFieldI multiline componentRef={(ref) => (itemRef.current = ref)} defaultValue={value} + placeholder={formatMessage('Press Shift+Enter to insert a new line')} resizable={false} styles={textFieldStyles} value={value} @@ -228,6 +233,7 @@ export const StringArrayItem = (props: Props) => { onShowCallout, onRemove, onFocus, + onEditorPopToggle, value, telemetryClient, codeEditorSettings, @@ -243,6 +249,10 @@ export const StringArrayItem = (props: Props) => { [editorMode] ); + const popExpandOptions = React.useMemo(() => ({ onEditorPopToggle, popExpandTitle: formatMessage('Attachment') }), [ + onEditorPopToggle, + ]); + return ( {mode === 'edit' ? ( @@ -258,6 +268,7 @@ export const StringArrayItem = (props: Props) => { lgTemplates={lgTemplates} memoryVariables={memoryVariables} options={{ folding: false }} + popExpandOptions={popExpandOptions} telemetryClient={telemetryClient} value={value} onChange={onLgChange} diff --git a/Composer/packages/lib/code-editor/src/lu/DefineEntityButton.tsx b/Composer/packages/lib/code-editor/src/lu/DefineEntityButton.tsx new file mode 100644 index 0000000000..89499ef88a --- /dev/null +++ b/Composer/packages/lib/code-editor/src/lu/DefineEntityButton.tsx @@ -0,0 +1,168 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { FluentTheme } from '@uifabric/fluent-theme'; +import formatMessage from 'format-message'; +import { CommandBarButton as DefaultCommandBarButton } from 'office-ui-fabric-react/lib/Button'; +import { Link } from 'office-ui-fabric-react/lib/Link'; +import { + ContextualMenuItemType, + IContextualMenuItem, + IContextualMenuProps, + IContextualMenuItemProps, + IContextualMenuItemRenderFunctions, +} from 'office-ui-fabric-react/lib/ContextualMenu'; +import * as React from 'react'; + +import { ItemWithTooltip } from '../components/ItemWithTooltip'; +import { useNoSearchResultMenuItem } from '../hooks/useNoSearchResultMenuItem'; +import { useSearchableMenuListCallback } from '../hooks/useSearchableMenuListCallback'; +import { withTooltip } from '../utils/withTooltip'; +import { getEntityTypeDisplayName } from '../utils/luUtils'; + +import { jsLuToolbarMenuClassName, prebuiltEntities } from './constants'; +import { getLuToolbarItemTextAndIcon } from './iconUtils'; +import { ToolbarLuEntityType, toolbarSupportedLuEntityTypes } from './types'; + +const allowedLuEntityTypes = ['prebuilt', 'ml']; +const entityDefinitionLinkId = 'define-entity-menu-header-link'; +const entityDefinitionHelpUrl = + 'https://docs.microsoft.com/en-us/azure/bot-service/file-format/bot-builder-lu-file-format?view=azure-bot-service-4.0#entity'; + +const fontSizeStyle = { + fontSize: FluentTheme.fonts.small.fontSize, +}; +const buttonStyles = { + root: { + height: 32, + '&:hover .ms-Button-flexContainer i, &:active .ms-Button-flexContainer i, &.is-expanded .ms-Button-flexContainer i': { + color: FluentTheme.palette.black, + }, + }, + menuIcon: { fontSize: 8, color: FluentTheme.palette.black }, + label: { ...fontSizeStyle }, + icon: { color: FluentTheme.palette.black, fontSize: 12 }, +}; + +const CommandBarButton = React.memo( + withTooltip({ content: formatMessage('Define new entity') }, DefaultCommandBarButton) +); + +type Props = { + onDefineEntity: (entityType: ToolbarLuEntityType, entityName?: string) => void; +}; + +export const DefineEntityButton = React.memo((props: Props) => { + const { onDefineEntity } = props; + + const { iconName, text } = React.useMemo(() => getLuToolbarItemTextAndIcon('defineEntity'), []); + const { onRenderMenuList, query, setQuery } = useSearchableMenuListCallback( + formatMessage('Search prebuilt entities') + ); + const noSearchResultsMenuItem = useNoSearchResultMenuItem(formatMessage('no prebuilt entities found')); + + const filteredPrebuiltEntities = React.useMemo(() => { + const filteredItems = query + ? prebuiltEntities.filter((e) => e.toLowerCase().indexOf(query.toLowerCase()) !== -1) + : prebuiltEntities; + + if (!filteredItems.length) { + return [noSearchResultsMenuItem]; + } + + return filteredItems.map((prebuiltEntity) => ({ + key: prebuiltEntity, + text: prebuiltEntity, + style: fontSizeStyle, + onClick: () => onDefineEntity('prebuilt', prebuiltEntity), + })); + }, [onDefineEntity, noSearchResultsMenuItem, query]); + + const onDismiss = React.useCallback(() => { + setQuery(''); + }, []); + + const prebuiltSubMenuProps = React.useMemo( + () => ({ + calloutProps: { calloutMaxHeight: 216 }, + items: filteredPrebuiltEntities, + onRenderMenuList, + onDismiss, + }), + [filteredPrebuiltEntities, onDismiss, onRenderMenuList] + ); + + const renderMenuItemHeader = React.useCallback( + (itemProps: IContextualMenuItemProps, defaultRenders: IContextualMenuItemRenderFunctions) => ( + this page to learn more about entity definition.', { + a: ({ children }) => ( + + {children} + + ), + })} + /> + ), + [] + ); + + const menuItems = React.useMemo(() => { + return [ + { + key: 'defineEntity_header', + itemType: ContextualMenuItemType.Header, + text: formatMessage('Define new entity'), + onRenderContent: renderMenuItemHeader, + }, + ...toolbarSupportedLuEntityTypes + .filter((t) => allowedLuEntityTypes.includes(t)) + .map((t) => ({ + key: `${t}Entity`, + text: getEntityTypeDisplayName(t), + style: fontSizeStyle, + subMenuProps: t === 'prebuilt' ? prebuiltSubMenuProps : undefined, + onClick: t !== 'prebuilt' ? () => onDefineEntity(t) : undefined, + })), + ]; + }, [onDefineEntity, renderMenuItemHeader, prebuiltSubMenuProps]); + + const menuProps = React.useMemo(() => { + return { + items: menuItems, + calloutProps: { + preventDismissOnEvent: ( + e: Event | React.FocusEvent | React.KeyboardEvent | React.MouseEvent + ) => { + /** + * Due to a bug in Fluent, tooltip in a button menu header dismisses when user clicks a link inside it + * Here, it manually intercepts the event and opens the link to documentation + */ + if ( + e.target instanceof HTMLElement && + (e.target as HTMLElement).tagName.toLowerCase() === 'a' && + (e.target as HTMLElement).id === entityDefinitionLinkId + ) { + window?.open((e.target as HTMLAnchorElement).href, '_blank'); + return true; + } + return false; + }, + }, + }; + }, [menuItems]); + + return ( + + {text} + + ); +}); diff --git a/Composer/packages/lib/code-editor/src/lu/InsertEntityButton.tsx b/Composer/packages/lib/code-editor/src/lu/InsertEntityButton.tsx new file mode 100644 index 0000000000..714c33175f --- /dev/null +++ b/Composer/packages/lib/code-editor/src/lu/InsertEntityButton.tsx @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { LuEntity, LuFile } from '@botframework-composer/types'; +import { FluentTheme } from '@uifabric/fluent-theme'; +import formatMessage from 'format-message'; +import { CommandBarButton as DefaultCommandBarButton } from 'office-ui-fabric-react/lib/Button'; +import { IContextualMenuItem } from 'office-ui-fabric-react/lib/ContextualMenu'; +import * as React from 'react'; + +import { withTooltip } from '../utils/withTooltip'; + +import { jsLuToolbarMenuClassName } from './constants'; +import { useLabelingMenuProps } from './hooks/useLabelingMenuItems'; +import { getLuToolbarItemTextAndIcon } from './iconUtils'; + +const fontSizeStyle = { + fontSize: FluentTheme.fonts.small.fontSize, +}; +const buttonStyles = { + root: { + height: 32, + '&:hover .ms-Button-flexContainer i, &:active .ms-Button-flexContainer i, &.is-expanded .ms-Button-flexContainer i': { + color: FluentTheme.palette.black, + }, + }, + menuIcon: { fontSize: 8, color: FluentTheme.palette.black }, + label: { ...fontSizeStyle }, + icon: { color: FluentTheme.palette.black, fontSize: 12 }, +}; + +type Props = { + onInsertEntity: (entityName: string) => void; + labelingMenuVisible: boolean; + insertEntityDisabled: boolean; + tagEntityDisabled: boolean; + luFile?: LuFile; +}; + +const getCommandBarButton = (tooltipContent: string) => + withTooltip({ content: tooltipContent }, DefaultCommandBarButton); + +export const InsertEntityButton = React.memo((props: Props) => { + const { luFile, labelingMenuVisible, tagEntityDisabled, insertEntityDisabled, onInsertEntity } = props; + + const itemClick = React.useCallback( + (_, item?: IContextualMenuItem) => { + const entity = item?.data as LuEntity; + if (entity) { + onInsertEntity(entity.Name); + } + }, + [onInsertEntity] + ); + + const { menuProps, noEntities } = useLabelingMenuProps(labelingMenuVisible ? 'disable' : 'none', luFile, itemClick, { + menuHeaderText: labelingMenuVisible ? formatMessage('Tag entity') : undefined, + }); + + const mode = React.useMemo(() => (labelingMenuVisible ? 'tag' : 'insert'), [labelingMenuVisible]); + const disabled = React.useMemo( + () => noEntities || (mode === 'tag' && tagEntityDisabled) || (mode === 'insert' && insertEntityDisabled), + [mode, noEntities, insertEntityDisabled, tagEntityDisabled] + ); + + const { iconName, text } = React.useMemo( + () => getLuToolbarItemTextAndIcon(labelingMenuVisible ? 'tagEntity' : 'useEntity'), + [labelingMenuVisible] + ); + + const CommandBarButton = React.useMemo( + () => + getCommandBarButton(labelingMenuVisible ? formatMessage('Tag entity') : formatMessage('Insert defined entity')), + [labelingMenuVisible] + ); + + return ( + + {text} + + ); +}); diff --git a/Composer/packages/lib/code-editor/src/lu/LuEditorToolbar.tsx b/Composer/packages/lib/code-editor/src/lu/LuEditorToolbar.tsx new file mode 100644 index 0000000000..3b80248d92 --- /dev/null +++ b/Composer/packages/lib/code-editor/src/lu/LuEditorToolbar.tsx @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { LuFile } from '@botframework-composer/types'; +import { FluentTheme } from '@uifabric/fluent-theme'; +import { CommandBar, ICommandBarItemProps } from 'office-ui-fabric-react/lib/CommandBar'; +import * as React from 'react'; + +import { canInsertEntityBySelection, canTagEntityBySelection } from '../utils/luUtils'; +import { MonacoRange } from '../utils/monacoTypes'; + +import { DefineEntityButton } from './DefineEntityButton'; +import { InsertEntityButton } from './InsertEntityButton'; +import { ToolbarLuEntityType } from './types'; + +const menuHeight = 32; + +const commandBarStyles = { + root: { + height: menuHeight, + padding: 0, + fontSize: FluentTheme.fonts.small.fontSize, + }, +}; + +type Props = { + editor?: any; + className?: string; + luFile?: LuFile; + labelingMenuVisible: boolean; + onDefineEntity: (entityType: ToolbarLuEntityType, entityName?: string) => void; + onInsertEntity: (entityName: string) => void; +}; + +export const LuEditorToolbar = React.memo((props: Props) => { + const { editor, luFile, labelingMenuVisible, className, onDefineEntity, onInsertEntity } = props; + + const [insertEntityDisabled, setInsertEntityDisabled] = React.useState(true); + const [tagEntityDisabled, setTagEntityDisabled] = React.useState(true); + + React.useEffect(() => { + const listeners: { dispose: () => void }[] = []; + if (editor) { + listeners.push( + editor.onDidChangeCursorSelection((e) => { + setInsertEntityDisabled(!canInsertEntityBySelection(editor, e.selection as MonacoRange)); + setTagEntityDisabled(!canTagEntityBySelection(editor, e.selection as MonacoRange)); + }) + ); + } + + return () => listeners.forEach((l) => l.dispose()); + }, [editor]); + + const defineLuEntityItem: ICommandBarItemProps = React.useMemo(() => { + return { + key: 'defineLuEntityItem', + commandBarButtonAs: () => , + }; + }, [onDefineEntity]); + + const useLuEntityItem: ICommandBarItemProps = React.useMemo(() => { + return { + key: 'useLuEntityItem', + commandBarButtonAs: () => ( + + ), + }; + }, [labelingMenuVisible, insertEntityDisabled, tagEntityDisabled, luFile, onInsertEntity]); + + const items = React.useMemo(() => [defineLuEntityItem, useLuEntityItem], [useLuEntityItem, defineLuEntityItem]); + + return ; +}); diff --git a/Composer/packages/lib/code-editor/src/lu/LuLabelingMenu.tsx b/Composer/packages/lib/code-editor/src/lu/LuLabelingMenu.tsx new file mode 100644 index 0000000000..830fa5fb78 --- /dev/null +++ b/Composer/packages/lib/code-editor/src/lu/LuLabelingMenu.tsx @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { LuEntity, LuFile } from '@botframework-composer/types'; +import formatMessage from 'format-message'; +import { ContextualMenu, DirectionalHint, IContextualMenuItem } from 'office-ui-fabric-react/lib/ContextualMenu'; +import React, { useCallback, useEffect, useState } from 'react'; + +import { isSelectionWithinBrackets } from '../utils/luUtils'; + +import { useLabelingMenuProps } from './hooks/useLabelingMenuItems'; +import { useMonacoSelectedTextDom } from './hooks/useMonacoSelectedTextDom'; + +type Props = { + editor: any; + luFile?: LuFile; + onMenuToggled?: (visible: boolean) => void; + onInsertEntity: (entityName: string) => void; +}; + +export const LuLabelingMenu = ({ editor, luFile, onMenuToggled, onInsertEntity }: Props) => { + const [menuTargetElm, setMenuTargetElm] = useState(null); + + React.useEffect(() => { + onMenuToggled?.(!!menuTargetElm); + }, [menuTargetElm]); + + const callback = React.useCallback( + (data?: { selectedDomElement: HTMLElement; selectedText: string; lineContent: string; selection: any }) => { + if (!data) { + setMenuTargetElm(null); + return; + } + + const { selectedDomElement, selectedText, lineContent, selection } = data; + if ( + selectedText.trim() && + !isSelectionWithinBrackets(lineContent, selection, selectedText) && + selectedDomElement + ) { + setMenuTargetElm(selectedDomElement); + } else { + setMenuTargetElm(null); + } + }, + [] + ); + + useMonacoSelectedTextDom(editor, callback); + + useEffect(() => { + let scrollDisposable: { dispose: () => void }; + + if (editor) { + scrollDisposable = editor.onDidScrollChange(() => { + setMenuTargetElm(null); + }); + } + + return () => { + scrollDisposable?.dispose(); + }; + }, [editor]); + + useEffect(() => { + const keydownHandler = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + setMenuTargetElm(null); + } + }; + document.addEventListener('keydown', keydownHandler); + + return () => { + document.removeEventListener('keydown', keydownHandler); + }; + }, []); + + const itemClick = useCallback( + (_, item?: IContextualMenuItem) => { + const entity = item?.data as LuEntity; + if (entity) { + onInsertEntity(entity.Name); + } + setMenuTargetElm(null); + }, + [onInsertEntity] + ); + + const { menuProps } = useLabelingMenuProps('filter', luFile, itemClick, { + menuHeaderText: formatMessage('Tag entity'), + }); + + return menuTargetElm ? ( +