diff --git a/Composer/packages/extensions/visual-designer/.eslintrc.js b/Composer/packages/extensions/visual-designer/.eslintrc.js index 8234d9c966..ea421ff1e3 100644 --- a/Composer/packages/extensions/visual-designer/.eslintrc.js +++ b/Composer/packages/extensions/visual-designer/.eslintrc.js @@ -6,6 +6,7 @@ module.exports = { }, rules: { '@typescript-eslint/explicit-member-accessibility': 'off', - '@typescript-eslint/no-use-before-define': ['warn', { functions: false, classes: true }], + '@typescript-eslint/no-use-before-define': 'off', + '@typescript-eslint/no-explicit-any': 'off', }, }; diff --git a/Composer/packages/extensions/visual-designer/demo/src/stories/VisualEditorDemo.js b/Composer/packages/extensions/visual-designer/demo/src/stories/VisualEditorDemo.js index 232b5ab896..17c7c5c6ec 100644 --- a/Composer/packages/extensions/visual-designer/demo/src/stories/VisualEditorDemo.js +++ b/Composer/packages/extensions/visual-designer/demo/src/stories/VisualEditorDemo.js @@ -83,7 +83,7 @@ export class VisualEditorDemo extends Component { data={obiJson} dialogId={selectedFile} focusedEvent={focusedEvent} - focusedSteps={focusedSteps} + focusedActions={focusedSteps} focusedTab={focusedTab} clipboardActions={clipboardActions} shellApi={{ diff --git a/Composer/packages/extensions/visual-designer/src/editors/ObiEditor.tsx b/Composer/packages/extensions/visual-designer/src/editors/ObiEditor.tsx index 19e40120b7..02cf4b943b 100644 --- a/Composer/packages/extensions/visual-designer/src/editors/ObiEditor.tsx +++ b/Composer/packages/extensions/visual-designer/src/editors/ObiEditor.tsx @@ -22,10 +22,11 @@ import { pasteNodes, deleteNodes, } from '../utils/jsonTracker'; -import { moveCursor } from '../utils/cursorTracker'; +import { moveCursor, querySelectableElements, SelectorElement } from '../utils/cursorTracker'; import { NodeIndexGenerator } from '../utils/NodeIndexGetter'; import { normalizeSelection } from '../utils/normalizeSelection'; import { KeyboardZone } from '../components/lib/KeyboardZone'; +import { scrollNodeIntoView } from '../utils/nodeOperation'; import { AdaptiveDialogEditor } from './AdaptiveDialogEditor'; @@ -228,10 +229,7 @@ export const ObiEditor: FC = ({ }, }); - const querySelectableElements = (): NodeListOf => { - return document.querySelectorAll(`[${AttrNames.SelectableElement}]`); - }; - const [selectableElements, setSelectableElements] = useState>(querySelectableElements()); + const [selectableElements, setSelectableElements] = useState(querySelectableElements()); const getClipboardTargetsFromContext = (): string[] => { const selectedActionIds = normalizeSelection(selectionContext.selectedIds); @@ -283,6 +281,7 @@ export const ObiEditor: FC = ({ selectedIds: [selected as string], }); focused && onFocusSteps([focused], tab); + scrollNodeIntoView(`[${AttrNames.SelectedId}="${selected}"]`); break; } case KeyboardPrimaryTypes.Operation: { diff --git a/Composer/packages/extensions/visual-designer/src/utils/cursorTracker.ts b/Composer/packages/extensions/visual-designer/src/utils/cursorTracker.ts deleted file mode 100644 index 7aa275bf3b..0000000000 --- a/Composer/packages/extensions/visual-designer/src/utils/cursorTracker.ts +++ /dev/null @@ -1,194 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { KeyboardCommandTypes } from '../constants/KeyboardCommandTypes'; -import { InitNodeSize } from '../constants/ElementSizes'; -import { AttrNames } from '../constants/ElementAttributes'; - -enum BoundRect { - Top = 'top', - Bottom = 'bottom', - Left = 'left', - Right = 'right', -} - -enum Axle { - X, - Y, -} - -/** - * - * @param currentElement current element - * @param elements all elements have AttrNames.SelectableElements attribute - * @param boundRectKey key to calculate shortest distance - * @param assistAxle assist axle for calculating. - * @param filterAttrs filtering elements - */ -function localeNearestElement( - currentElement: HTMLElement, - elements: NodeListOf, - boundRectKey: BoundRect, - assistAxle: Axle, - filterAttrs?: AttrNames[] -): HTMLElement { - let neareastElement: HTMLElement = currentElement; - let minDistance = 10000; - let distance = minDistance; - const elementArr = Array.from(elements).filter( - element => !filterAttrs || (filterAttrs && filterAttrs.find(key => !!element.getAttribute(key))) - ); - const currentElementBounds = currentElement.getBoundingClientRect(); - let bounds: ClientRect; - let assistMinDistance = 10000; - let assistDistance; - - elementArr.forEach(element => { - bounds = element.getBoundingClientRect(); - if (boundRectKey === BoundRect.Top || boundRectKey === BoundRect.Left) { - distance = bounds[boundRectKey] - currentElementBounds[boundRectKey]; - } else { - distance = currentElementBounds[boundRectKey] - bounds[boundRectKey]; - } - - if (assistAxle === Axle.X) { - assistDistance = Math.abs( - currentElementBounds.left + currentElementBounds.width / 2 - (bounds.left + bounds.width / 2) - ); - if (assistDistance < InitNodeSize.width / 2 && distance > 0 && distance < minDistance) { - neareastElement = element; - minDistance = distance; - } - } else { - assistDistance = Math.abs( - currentElementBounds.top + currentElementBounds.height / 2 - (bounds.top + bounds.height / 2) - ); - if (distance > 0 && distance <= minDistance && assistMinDistance >= assistDistance) { - neareastElement = element; - minDistance = distance; - assistMinDistance = assistDistance; - } - } - }); - return neareastElement; -} - -function localeElementByTab(currentElement: HTMLElement, elements: NodeListOf, command: string) { - const elementArr = Array.from(elements); - const currentElementBounds = currentElement.getBoundingClientRect(); - let bounds: ClientRect; - let selectedElement: HTMLElement = currentElement; - let selectedElementBounds: ClientRect; - let isInvolved = false; - const judgeElementRelation = (parentBounds, childBounds) => { - return ( - parentBounds.left < childBounds.left && - parentBounds.right >= childBounds.right && - parentBounds.top < childBounds.top && - parentBounds.bottom > childBounds.bottom - ); - }; - if (command === KeyboardCommandTypes.Cursor.MoveNext) { - elementArr.forEach(element => { - bounds = element.getBoundingClientRect(); - if (judgeElementRelation(currentElementBounds, bounds)) { - isInvolved = true; - selectedElement = element; - } - }); - if (!isInvolved) { - selectedElement = localeNearestElement(currentElement, elements, BoundRect.Top, Axle.X, [ - AttrNames.NodeElement, - AttrNames.EdgeMenuElement, - ]); - } - } else { - elementArr.forEach(element => { - bounds = element.getBoundingClientRect(); - if (judgeElementRelation(bounds, currentElementBounds)) { - isInvolved = true; - selectedElement = element; - } - }); - if (!isInvolved) { - selectedElement = localeNearestElement(currentElement, elements, BoundRect.Bottom, Axle.X, [ - AttrNames.NodeElement, - AttrNames.EdgeMenuElement, - ]); - selectedElementBounds = selectedElement.getBoundingClientRect(); - elementArr.forEach(element => { - bounds = element.getBoundingClientRect(); - if (judgeElementRelation(selectedElementBounds, bounds)) { - selectedElement = element; - } - }); - } - } - return selectedElement; -} -export function moveCursor( - selectedElements: NodeListOf, - id: string, - command: string -): { [key: string]: string | undefined } { - const currentElement = Array.from(selectedElements).find( - element => element.dataset.selectedId === id || element.dataset.focusedId === id - ); - if (!currentElement) return { selected: id, focused: undefined }; - let element: HTMLElement = currentElement; - switch (command) { - case KeyboardCommandTypes.Cursor.MoveDown: - element = localeNearestElement(currentElement, selectedElements, BoundRect.Top, Axle.X, [AttrNames.NodeElement]); - break; - case KeyboardCommandTypes.Cursor.MoveUp: - element = localeNearestElement(currentElement, selectedElements, BoundRect.Bottom, Axle.X, [ - AttrNames.NodeElement, - ]); - break; - case KeyboardCommandTypes.Cursor.MoveLeft: - element = localeNearestElement(currentElement, selectedElements, BoundRect.Right, Axle.Y, [ - AttrNames.NodeElement, - ]); - break; - case KeyboardCommandTypes.Cursor.MoveRight: - element = localeNearestElement(currentElement, selectedElements, BoundRect.Left, Axle.Y, [AttrNames.NodeElement]); - break; - case KeyboardCommandTypes.Cursor.ShortMoveDown: - element = localeNearestElement(currentElement, selectedElements, BoundRect.Top, Axle.X, [ - AttrNames.NodeElement, - AttrNames.EdgeMenuElement, - ]); - break; - case KeyboardCommandTypes.Cursor.ShortMoveUp: - element = localeNearestElement(currentElement, selectedElements, BoundRect.Bottom, Axle.X, [ - AttrNames.NodeElement, - AttrNames.EdgeMenuElement, - ]); - break; - case KeyboardCommandTypes.Cursor.ShortMoveLeft: - element = localeNearestElement(currentElement, selectedElements, BoundRect.Right, Axle.Y, [ - AttrNames.NodeElement, - AttrNames.EdgeMenuElement, - ]); - break; - case KeyboardCommandTypes.Cursor.ShortMoveRight: - element = localeNearestElement(currentElement, selectedElements, BoundRect.Left, Axle.Y, [ - AttrNames.NodeElement, - AttrNames.EdgeMenuElement, - ]); - break; - case KeyboardCommandTypes.Cursor.MovePrevious: - element = localeElementByTab(currentElement, selectedElements, KeyboardCommandTypes.Cursor.MovePrevious); - break; - case KeyboardCommandTypes.Cursor.MoveNext: - element = localeElementByTab(currentElement, selectedElements, KeyboardCommandTypes.Cursor.MoveNext); - break; - } - element.scrollIntoView(true); - - return { - selected: element.getAttribute(AttrNames.SelectedId) || id, - focused: element.getAttribute(AttrNames.FocusedId) || undefined, - tab: element.getAttribute(AttrNames.Tab) || '', - }; -} diff --git a/Composer/packages/extensions/visual-designer/src/utils/cursorTracker/arrowMove.ts b/Composer/packages/extensions/visual-designer/src/utils/cursorTracker/arrowMove.ts new file mode 100644 index 0000000000..64edb49f25 --- /dev/null +++ b/Composer/packages/extensions/visual-designer/src/utils/cursorTracker/arrowMove.ts @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { KeyboardCommandTypes } from '../../constants/KeyboardCommandTypes'; + +import { locateNearestElement } from './locateElement'; +import { SelectorElement, Direction } from './type'; + +export function handleArrowkeyMove( + currentElement: SelectorElement, + selectableElements: SelectorElement[], + command: string +) { + let element: SelectorElement = currentElement; + let direction: Direction; + let filterAttrs: string[] = []; + + switch (command) { + case KeyboardCommandTypes.Cursor.MoveDown: + direction = Direction.Down; + filterAttrs = ['isNode']; + break; + case KeyboardCommandTypes.Cursor.MoveUp: + direction = Direction.Up; + filterAttrs = ['isNode']; + break; + case KeyboardCommandTypes.Cursor.MoveLeft: + direction = Direction.Left; + filterAttrs = ['isNode']; + break; + case KeyboardCommandTypes.Cursor.MoveRight: + direction = Direction.Right; + filterAttrs = ['isNode']; + break; + case KeyboardCommandTypes.Cursor.ShortMoveDown: + direction = Direction.Down; + filterAttrs = ['isNode', 'isEdgeMenu']; + break; + case KeyboardCommandTypes.Cursor.ShortMoveUp: + direction = Direction.Up; + filterAttrs = ['isNode', 'isEdgeMenu']; + break; + case KeyboardCommandTypes.Cursor.ShortMoveLeft: + direction = Direction.Left; + filterAttrs = ['isNode', 'isEdgeMenu']; + break; + case KeyboardCommandTypes.Cursor.ShortMoveRight: + direction = Direction.Right; + filterAttrs = ['isNode', 'isEdgeMenu']; + break; + default: + return element; + } + element = locateNearestElement(currentElement, selectableElements, direction, filterAttrs); + + return element; +} diff --git a/Composer/packages/extensions/visual-designer/src/utils/cursorTracker/calculate/calculateBySchema.ts b/Composer/packages/extensions/visual-designer/src/utils/cursorTracker/calculate/calculateBySchema.ts new file mode 100644 index 0000000000..09943b1427 --- /dev/null +++ b/Composer/packages/extensions/visual-designer/src/utils/cursorTracker/calculate/calculateBySchema.ts @@ -0,0 +1,219 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +import { PromptTab } from '@bfc/shared'; + +import { SelectorElement, Direction } from '../type'; +function parseSelector(path: string): null | string[] { + if (!path) return null; + + const selectors = path.split('.'); + if (selectors.length === 0) { + return null; + } + + if (selectors[0] === '$' || selectors[0] === '') { + selectors.shift(); + } + + const normalizedSelectors = selectors.reduce( + (result, selector) => { + // e.g. actions[0] + const parseResult = /(\w+)\[(-\d+|\d+)\]/.exec(selector); + + if (parseResult) { + const [, objSelector, arraySelector] = parseResult; + const arrayIndex = parseInt(arraySelector); + result.push(objSelector, arrayIndex); + } else { + result.push(selector); + } + + return result; + }, + [] as any[] + ); + + return normalizedSelectors; +} +function transformDefaultBranch(path) { + return path.replace(new RegExp('default', 'g'), 'default[-1].actions'); +} +export function filterPromptElementsBySchema( + currentElement: SelectorElement, + elements: SelectorElement[], + direction: Direction +): SelectorElement[] { + let candidateElements: SelectorElement[] = elements; + + switch (direction) { + case Direction.Up: + if (currentElement.tab === PromptTab.OTHER || currentElement.tab === PromptTab.USER_INPUT) { + candidateElements = elements.filter( + ele => ele.selectedId === `${currentElement.focusedId}${PromptTab.BOT_ASKS}` + ); + } + break; + case Direction.Down: + if (currentElement.tab === PromptTab.USER_INPUT) { + candidateElements = elements.filter(ele => ele.tab !== PromptTab.OTHER); + } + break; + case Direction.Left: + if (currentElement.tab === PromptTab.OTHER) { + candidateElements = elements.filter(ele => ele.tab === PromptTab.USER_INPUT); + } + break; + case Direction.Right: + if (!currentElement.tab) { + candidateElements = elements.filter(ele => ele.tab !== PromptTab.OTHER); + } else if (currentElement.tab === PromptTab.BOT_ASKS || currentElement.tab === PromptTab.USER_INPUT) { + candidateElements = elements.filter(ele => ele.selectedId === `${currentElement.focusedId}${PromptTab.OTHER}`); + } + break; + default: + candidateElements = elements; + } + return candidateElements; +} + +function handleNextMoveFilter(currentElement: SelectorElement, elements: SelectorElement[]): SelectorElement[] { + const currentElementSelectors = parseSelector(transformDefaultBranch(currentElement.selectedId)) as string[]; + return elements.filter(ele => { + const eleSelectors = parseSelector(transformDefaultBranch(ele.selectedId)) as string[]; + const condition1 = + eleSelectors.length === currentElementSelectors.length && + eleSelectors.slice(0, eleSelectors.length - 2).join('.') === + currentElementSelectors.slice(0, currentElementSelectors.length - 2).join('.') && + Number(eleSelectors[eleSelectors.length - 1]) - 1 <= + Number(currentElementSelectors[currentElementSelectors.length - 1]); + const condition2 = + eleSelectors.length > currentElementSelectors.length && + eleSelectors.join('.').includes(currentElementSelectors.join('.')) && + Number(eleSelectors[eleSelectors.length - 1]) === 0; + const condition3 = + eleSelectors.length < currentElementSelectors.length && + Number(eleSelectors[eleSelectors.length - 1]) - 1 === Number(currentElementSelectors[eleSelectors.length - 1]); + return condition1 || condition2 || condition3; + }); +} + +function handlePrevMoveFilter(currentElement: SelectorElement, elements: SelectorElement[]): SelectorElement[] { + const currentElementSelectors = parseSelector(transformDefaultBranch(currentElement.selectedId)) as string[]; + return elements.filter(ele => { + const eleSelectors = parseSelector(transformDefaultBranch(ele.selectedId)) as string[]; + const condition1 = + eleSelectors.length === currentElementSelectors.length && + eleSelectors.slice(0, eleSelectors.length - 2).join('.') === + currentElementSelectors.slice(0, currentElementSelectors.length - 2).join('.') && + Number(eleSelectors[eleSelectors.length - 1]) + 1 >= + Number(currentElementSelectors[currentElementSelectors.length - 1]); + const condition2 = + eleSelectors.length > currentElementSelectors.length && + Number(eleSelectors[currentElementSelectors.length - 1]) - 1 === + Number(currentElementSelectors[currentElementSelectors.length - 1]); + const condition3 = + eleSelectors.length < currentElementSelectors.length && + currentElementSelectors.join('.').includes(eleSelectors.join('.')) && + Number(currentElementSelectors[currentElementSelectors.length - 1]) === 0; + return condition1 || condition2 || condition3; + }); +} + +function handleSwitchCasePrevMoveFilter( + currentElement: SelectorElement, + elements: SelectorElement[] +): SelectorElement[] { + const currentElementSelectors = parseSelector(transformDefaultBranch(currentElement.selectedId)) as string[]; + let candidateElements = elements; + let swicthPosition = -1; + swicthPosition = currentElementSelectors.lastIndexOf('cases'); + const samePath = currentElementSelectors.slice(0, swicthPosition).join('.'); + const sortedElement = elements + .filter(ele => { + const eleSelectors = parseSelector(transformDefaultBranch(ele.selectedId)) as string[]; + return ( + eleSelectors.slice(0, swicthPosition).join('.') === samePath && + Number(eleSelectors[swicthPosition + 1]) <= Number(currentElementSelectors[swicthPosition + 1]) + ); + }) + .sort((ele1, ele2) => { + const eleSelectors1 = parseSelector(transformDefaultBranch(ele1.selectedId)) as string[]; + const eleSelectors2 = parseSelector(transformDefaultBranch(ele2.selectedId)) as string[]; + return Number(eleSelectors2[swicthPosition + 1]) - Number(eleSelectors1[swicthPosition + 1]); + }); + const minSwitchCasesElement = parseSelector(transformDefaultBranch(sortedElement[0].selectedId)) as string[]; + const minSwitchCasesIndex = Number(minSwitchCasesElement[swicthPosition + 1]); + candidateElements = elements.filter(ele => { + const eleSelectors = parseSelector(transformDefaultBranch(ele.selectedId)) as string[]; + return Number(eleSelectors[swicthPosition + 1]) === minSwitchCasesIndex; + }); + return candidateElements; +} + +function handleSwitchCaseNextMoveFilter(currentElement: SelectorElement, elements: SelectorElement[]) { + const currentElementSelectors = parseSelector(transformDefaultBranch(currentElement.selectedId)) as string[]; + let candidateElements = elements; + let swicthPosition = -1; + if (currentElementSelectors.lastIndexOf('default') > currentElementSelectors.lastIndexOf('cases')) { + swicthPosition = currentElementSelectors.lastIndexOf('default'); + } else { + swicthPosition = currentElementSelectors.lastIndexOf('cases'); + } + const samePath = currentElementSelectors.slice(0, swicthPosition).join('.'); + const sortedElement = elements + .filter(ele => { + const eleSelectors = parseSelector(transformDefaultBranch(ele.selectedId)) as string[]; + return ( + eleSelectors.slice(0, swicthPosition).join('.') === samePath && + Number(eleSelectors[swicthPosition + 1]) >= Number(currentElementSelectors[swicthPosition + 1]) + ); + }) + .sort((ele1, ele2) => { + const eleSelectors1 = parseSelector(transformDefaultBranch(ele1.selectedId)) as string[]; + const eleSelectors2 = parseSelector(transformDefaultBranch(ele2.selectedId)) as string[]; + return Number(eleSelectors1[swicthPosition + 1]) - Number(eleSelectors2[swicthPosition + 1]); + }); + const minSwitchCasesElement = parseSelector(transformDefaultBranch(sortedElement[0].selectedId)) as string[]; + const minSwitchCasesIndex = Number(minSwitchCasesElement[swicthPosition + 1]); + candidateElements = elements.filter(ele => { + const eleSelectors = parseSelector(transformDefaultBranch(ele.selectedId)) as string[]; + return Number(eleSelectors[swicthPosition + 1]) === minSwitchCasesIndex; + }); + return candidateElements; +} + +export function filterElementBySchema( + currentElement: SelectorElement, + elements: SelectorElement[], + direction: Direction +) { + const currentElementSelectors = parseSelector(transformDefaultBranch(currentElement.selectedId)) as string[]; + let candidateElements = elements; + switch (direction) { + case Direction.Up: { + candidateElements = handlePrevMoveFilter(currentElement, elements); + break; + } + case Direction.Down: { + candidateElements = handleNextMoveFilter(currentElement, elements); + break; + } + case Direction.Left: { + if (currentElementSelectors.lastIndexOf('cases') > -1) { + candidateElements = handleSwitchCasePrevMoveFilter(currentElement, elements); + } else { + candidateElements = handlePrevMoveFilter(currentElement, elements); + } + break; + } + case Direction.Right: { + if (currentElementSelectors.lastIndexOf('default') > -1 || currentElementSelectors.lastIndexOf('cases') > -1) { + candidateElements = handleSwitchCaseNextMoveFilter(currentElement, elements); + } else { + candidateElements = handleNextMoveFilter(currentElement, elements); + } + break; + } + } + return candidateElements; +} diff --git a/Composer/packages/extensions/visual-designer/src/utils/cursorTracker/calculate/calculateByVector.ts b/Composer/packages/extensions/visual-designer/src/utils/cursorTracker/calculate/calculateByVector.ts new file mode 100644 index 0000000000..d09209fbd9 --- /dev/null +++ b/Composer/packages/extensions/visual-designer/src/utils/cursorTracker/calculate/calculateByVector.ts @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +import { SelectorElement, Axle, BoundRect, Direction } from '../type'; +interface ElementVector { + distance: number; + assistDistance: number; + selectedId: string; +} + +function transformDirectionToVectorAttrs(direction: Direction): Record { + switch (direction) { + case Direction.Up: + return { assistAxle: Axle.X, boundRectKey: BoundRect.Bottom }; + case Direction.Down: + return { assistAxle: Axle.X, boundRectKey: BoundRect.Top }; + case Direction.Left: + return { assistAxle: Axle.Y, boundRectKey: BoundRect.Right }; + case Direction.Right: + return { assistAxle: Axle.Y, boundRectKey: BoundRect.Left }; + } +} + +function transformVectorToElement(vectors: ElementVector[], elements: SelectorElement[]): SelectorElement[] { + const results: SelectorElement[] = []; + vectors.forEach(vector => + results.push(elements.find(element => vector.selectedId === element.selectedId) as SelectorElement) + ); + return results; +} +// calculate vector between element and currentElement +function calculateElementVector( + currentElement: SelectorElement, + elements: SelectorElement[], + boundRectKey: BoundRect, + assistAxle: Axle +): ElementVector[] { + const currentElementBounds = currentElement.bounds; + const elementVectors: ElementVector[] = []; + elements.forEach(element => { + const bounds = element.bounds; + let distance: number; + let assistDistance: number; + + if (assistAxle === Axle.X) { + if (boundRectKey === BoundRect.Top) { + distance = bounds[boundRectKey] - currentElementBounds[boundRectKey]; + } else { + distance = currentElementBounds[boundRectKey] - bounds[boundRectKey]; + } + assistDistance = Math.abs( + currentElementBounds.left + currentElementBounds.width / 2 - (bounds.left + bounds.width / 2) + ); + } else { + if (boundRectKey === BoundRect.Left) { + distance = + bounds[boundRectKey] + + bounds.width / 2 - + (currentElementBounds[boundRectKey] + currentElementBounds.width / 2); + } else { + distance = + currentElementBounds[boundRectKey] - + currentElementBounds.width / 2 - + (bounds[boundRectKey] - bounds.width / 2); + } + assistDistance = Math.abs( + currentElementBounds.top + currentElementBounds.height / 2 - (bounds.top + bounds.height / 2) + ); + } + elementVectors.push({ + distance, + assistDistance, + selectedId: element.selectedId as string, + }); + }); + return elementVectors; +} + +export function sortElementsByVector( + currentElement: SelectorElement, + elements: SelectorElement[], + direction: Direction +): SelectorElement[] { + const { assistAxle, boundRectKey } = transformDirectionToVectorAttrs(direction); + const elementVectors = calculateElementVector(currentElement, elements, boundRectKey, assistAxle); + const candidates = elementVectors.sort((ele1, ele2) => ele1.distance - ele2.distance); + + return transformVectorToElement(candidates, elements); +} + +export function filterElementsByVector( + currentElement: SelectorElement, + elements: SelectorElement[], + direction: Direction +): SelectorElement[] { + const { assistAxle, boundRectKey } = transformDirectionToVectorAttrs(direction); + const elementVectors = calculateElementVector(currentElement, elements, boundRectKey, assistAxle); + const candidates: ElementVector[] = elementVectors.filter(ele => ele.distance > 0); + + return transformVectorToElement(candidates, elements); +} diff --git a/Composer/packages/extensions/visual-designer/src/utils/cursorTracker/index.ts b/Composer/packages/extensions/visual-designer/src/utils/cursorTracker/index.ts new file mode 100644 index 0000000000..f213bb5a44 --- /dev/null +++ b/Composer/packages/extensions/visual-designer/src/utils/cursorTracker/index.ts @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { AttrNames } from '../../constants/ElementAttributes'; +import { KeyboardCommandTypes } from '../../constants/KeyboardCommandTypes'; + +import { SelectorElement } from './type'; +import { handleArrowkeyMove } from './arrowMove'; +import { handleTabMove } from './tabMove'; + +export function moveCursor( + selectableElements: SelectorElement[], + id: string, + command: string +): { [key: string]: string | undefined } { + const currentElement = selectableElements.find(element => element.selectedId === id || element.focusedId === id); + if (!currentElement) return { selected: id, focused: undefined }; + let element: SelectorElement = currentElement; + + // tab move or arrow move + switch (command) { + case KeyboardCommandTypes.Cursor.MovePrevious: + case KeyboardCommandTypes.Cursor.MoveNext: + element = handleTabMove(currentElement, selectableElements, command); + break; + default: + element = handleArrowkeyMove(currentElement, selectableElements, command); + break; + } + + return { + selected: element.selectedId || id, + focused: element.focusedId || undefined, + tab: element.tab || '', + }; +} + +export function querySelectableElements(): SelectorElement[] { + const items: SelectorElement[] = []; + Array.from(document.querySelectorAll(`[${AttrNames.SelectableElement}]`)).forEach(ele => { + items.push(new SelectorElement(ele as HTMLElement)); + }); + return items; +} + +export { SelectorElement }; diff --git a/Composer/packages/extensions/visual-designer/src/utils/cursorTracker/locateElement.ts b/Composer/packages/extensions/visual-designer/src/utils/cursorTracker/locateElement.ts new file mode 100644 index 0000000000..d857ef9831 --- /dev/null +++ b/Composer/packages/extensions/visual-designer/src/utils/cursorTracker/locateElement.ts @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { SelectorElement, Direction } from './type'; +import { filterElementsByVector, sortElementsByVector } from './calculate/calculateByVector'; +import { filterPromptElementsBySchema, filterElementBySchema } from './calculate/calculateBySchema'; +/** + * + * @param currentElement current element + * @param elements all elements have AttrNames.SelectableElements attribute + * @param boundRectKey key to calculate shortest distance + * @param assistAxle assist axle for calculating. + * @param filterAttrs filtering elements + */ +export function locateNearestElement( + currentElement: SelectorElement, + elements: SelectorElement[], + direction: Direction, + filterAttrs?: string[] +): SelectorElement { + // Get elements that meet the filter criteria + let elementArr = elements.filter(element => !filterAttrs || (filterAttrs && filterAttrs.find(key => !!element[key]))); + + elementArr = filterPromptElementsBySchema(currentElement, elementArr, direction); + elementArr = filterElementsByVector(currentElement, elementArr, direction); + elementArr = sortElementsByVector(currentElement, elementArr, direction); + elementArr = filterElementBySchema(currentElement, elementArr, direction); + + return elementArr.length > 0 ? elementArr[0] : currentElement; +} diff --git a/Composer/packages/extensions/visual-designer/src/utils/cursorTracker/tabMove.ts b/Composer/packages/extensions/visual-designer/src/utils/cursorTracker/tabMove.ts new file mode 100644 index 0000000000..8db10f5a74 --- /dev/null +++ b/Composer/packages/extensions/visual-designer/src/utils/cursorTracker/tabMove.ts @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +import { KeyboardCommandTypes } from '../../constants/KeyboardCommandTypes'; + +import { SelectorElement, Direction } from './type'; +import { locateNearestElement } from './locateElement'; + +function isParentRect(parentRect, childRect) { + return ( + parentRect.left < childRect.left && + parentRect.right >= childRect.right && + parentRect.top < childRect.top && + parentRect.bottom > childRect.bottom + ); +} + +function findSelectableChildren(element: SelectorElement, elementList: SelectorElement[]) { + const rect = element.bounds; + return elementList.filter(el => { + const candidateRect = el.bounds; + return isParentRect(rect, candidateRect); + }); +} + +function findSelectableParent(element: SelectorElement, elementList: SelectorElement[]) { + const rect = element.bounds; + return elementList.find(el => { + const candidateRect = el.bounds; + return isParentRect(candidateRect, rect); + }); +} + +export function handleTabMove(currentElement: SelectorElement, selectableElements: SelectorElement[], command: string) { + let nextElement: SelectorElement; + if (command === KeyboardCommandTypes.Cursor.MoveNext) { + const selectableChildren = findSelectableChildren(currentElement, selectableElements); + if (selectableChildren.length > 0) { + // Tab to inner selectable element. + nextElement = selectableChildren[0]; + } else { + // Perform like presssing down arrow key. + nextElement = locateNearestElement(currentElement, selectableElements, Direction.Down, ['isNode', 'isEdgeMenu']); + } + } else if (command === KeyboardCommandTypes.Cursor.MovePrevious) { + const selectableParent = findSelectableParent(currentElement, selectableElements); + if (selectableParent) { + // Tab to parent. + nextElement = selectableParent; + } else { + // Perform like pressing up arrow key. + nextElement = locateNearestElement(currentElement, selectableElements, Direction.Up, ['isNode', 'isEdgeMenu']); + // If prev element has children, tab to the last child before the element itself. + const selectableChildInNext = findSelectableChildren(nextElement, selectableElements); + if (selectableChildInNext.length > 0) { + nextElement = selectableChildInNext[selectableChildInNext.length - 1]; + } + } + } else { + // By default, stay focus on the origin element. + nextElement = currentElement; + } + + return nextElement; +} diff --git a/Composer/packages/extensions/visual-designer/src/utils/cursorTracker/type.ts b/Composer/packages/extensions/visual-designer/src/utils/cursorTracker/type.ts new file mode 100644 index 0000000000..8f45f690e4 --- /dev/null +++ b/Composer/packages/extensions/visual-designer/src/utils/cursorTracker/type.ts @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +import { AttrNames } from '../../constants/ElementAttributes'; + +export class SelectorElement { + bounds: Record; + isSelectable: string | null; + isNode: string | null; + isEdgeMenu: string | null; + focusedId: string | null; + selectedId: string; + tab: string | null; + + constructor(element: HTMLElement) { + this.isSelectable = element.getAttribute(AttrNames.SelectableElement); + this.isNode = element.getAttribute(AttrNames.NodeElement); + this.isEdgeMenu = element.getAttribute(AttrNames.EdgeMenuElement); + this.focusedId = element.getAttribute(AttrNames.FocusedId); + this.selectedId = element.getAttribute(AttrNames.SelectedId) as string; + this.tab = element.getAttribute(AttrNames.Tab); + + const elementBounds = element.getBoundingClientRect(); + this.bounds = { + width: elementBounds.width, + height: elementBounds.height, + left: elementBounds.left, + right: elementBounds.right, + top: elementBounds.top, + bottom: elementBounds.bottom, + }; + } + + hasAttribute(attrName) { + return this[attrName]; + } +} + +export enum Direction { + Up, + Down, + Left, + Right, +} + +export enum BoundRect { + Top = 'top', + Bottom = 'bottom', + Left = 'left', + Right = 'right', +} + +export enum Axle { + X, + Y, +} diff --git a/Composer/packages/extensions/visual-designer/src/utils/nodeOperation.ts b/Composer/packages/extensions/visual-designer/src/utils/nodeOperation.ts new file mode 100644 index 0000000000..8eb376092a --- /dev/null +++ b/Composer/packages/extensions/visual-designer/src/utils/nodeOperation.ts @@ -0,0 +1,7 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export function scrollNodeIntoView(selector: string) { + const node: Element = document.querySelector(selector) as Element; + node.scrollIntoView(true); +}