diff --git a/Composer/packages/adaptive-flow/src/adaptive-flow-editor/AdaptiveFlowEditor.tsx b/Composer/packages/adaptive-flow/src/adaptive-flow-editor/AdaptiveFlowEditor.tsx index 5377920e93..e4b7af0d51 100644 --- a/Composer/packages/adaptive-flow/src/adaptive-flow-editor/AdaptiveFlowEditor.tsx +++ b/Composer/packages/adaptive-flow/src/adaptive-flow-editor/AdaptiveFlowEditor.tsx @@ -29,6 +29,7 @@ import { VisualEditorElementWrapper, } from './renderers'; import { useFlowUIOptions } from './hooks/useFlowUIOptions'; +import { ZoomZone } from './components/ZoomZone'; formatMessage.setup({ missingTranslation: 'ignore', @@ -46,8 +47,6 @@ const styles = css` left: 0; right: 0; - overflow: scroll; - border: 1px solid transparent; &:focus { @@ -73,8 +72,11 @@ const VisualDesigner: React.FC = ({ onFocus, onBlur, schema data: inputData, hosted, schemas, + flowZoomRate, } = shellData; + const { updateFlowZoomRate } = shellApi; + const dataCache = useRef({}); /** @@ -116,7 +118,6 @@ const VisualDesigner: React.FC = ({ onFocus, onBlur, schema }, {} as FlowUISchema); const divRef = useRef(null); - // send focus to the keyboard area when navigating to a new trigger useEffect(() => { divRef.current?.focus(); @@ -143,42 +144,44 @@ const VisualDesigner: React.FC = ({ onFocus, onBlur, schema {...enableKeyboardCommandAttributes(handleCommand)} data-testid="visualdesigner-container" > - - -
{ - e.stopPropagation(); - handleEditorEvent(NodeEventTypes.Focus, { id: '' }); - }} - > - + + +
{ - divRef.current?.focus({ preventScroll: true }); - handleEditorEvent(eventName, eventData); + data-testid="flow-editor-container" + onClick={(e) => { + e.stopPropagation(); + handleEditorEvent(NodeEventTypes.Focus, { id: '' }); }} - /> -
-
-
+ > + { + divRef.current?.focus({ preventScroll: true }); + handleEditorEvent(eventName, eventData); + }} + /> +
+
+
+ diff --git a/Composer/packages/adaptive-flow/src/adaptive-flow-editor/components/ZoomZone.tsx b/Composer/packages/adaptive-flow/src/adaptive-flow-editor/components/ZoomZone.tsx new file mode 100644 index 0000000000..0f3788a39f --- /dev/null +++ b/Composer/packages/adaptive-flow/src/adaptive-flow-editor/components/ZoomZone.tsx @@ -0,0 +1,150 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx, css } from '@emotion/core'; +import { useRef, useEffect, ReactNode } from 'react'; +import { ZoomInfo } from '@bfc/shared'; +import { IconButton, IButtonStyles } from 'office-ui-fabric-react/lib/Button'; +import { IIconProps } from 'office-ui-fabric-react/lib/Icon'; + +import { scrollNodeIntoView } from '../utils/scrollNodeIntoView'; +import { AttrNames } from '../constants/ElementAttributes'; + +function scrollZoom(delta: number, rateList: number[], maxRate: number, minRate: number, currentRate: number): number { + let rate: number = currentRate; + + if (delta < 0) { + // Zoom in + rate = rateList[rateList.indexOf(currentRate) + 1] || rate; + rate = Math.min(maxRate, rate); + } else if (delta > 0) { + // Zoom out + rate = rateList[rateList.indexOf(currentRate) - 1] || rate; + rate = Math.max(minRate, rate); + } else { + rate = 1; + } + + return rate; +} + +interface ZoomZoneProps { + flowZoomRate: ZoomInfo; + focusedId: string; + updateFlowZoomRate: (currentRate: number) => void; + children?: ReactNode; +} + +export const ZoomZone: React.FC = ({ flowZoomRate, focusedId, updateFlowZoomRate, children }) => { + const divRef = useRef(null); + const { rateList, maxRate, minRate, currentRate } = flowZoomRate || { + rateList: [0.5, 1, 3], + maxRate: 3, + minRate: 0.5, + currentRate: 1, + }; + const onWheel = (event: WheelEvent) => { + if (event.ctrlKey) { + event.preventDefault(); + event.stopPropagation(); + handleZoom(event.deltaY); + } + }; + + const handleZoom = (delta: number) => { + const rate = scrollZoom(delta, rateList, maxRate, minRate, currentRate); + + updateFlowZoomRate(rate); + }; + + const container = divRef.current as HTMLElement; + useEffect(() => { + if (!container) return; + const target = container.children[0] as HTMLElement; + target.style.transform = `scale(${currentRate})`; + target.style.transformOrigin = 'top left'; + container.scroll({ + top: (container.scrollWidth - container.clientWidth) / 2, + left: (container.scrollHeight - container.clientHeight) / 2, + }); + + if (currentRate === 1) { + scrollNodeIntoView(`[${AttrNames.SelectedId}="${focusedId}"]`); + } + }, [currentRate]); + + const buttonRender = () => { + const buttonBoxStyle = css({ position: 'absolute', left: '25px', bottom: '25px', width: '35px' }); + const iconStyle = (zoom: string): IIconProps => { + return zoom === 'in' + ? { iconName: 'ZoomIn', styles: { root: { color: '#fff' } } } + : { iconName: 'ZoomOut', styles: { root: { color: '#fff' } } }; + }; + const buttonStyle: IButtonStyles = { + root: { + width: '35px', + height: '35px', + background: 'rgba(44, 41, 41, 0.8)', + borderRadius: '2px', + margin: '2.5px 0', + selectors: { + ':disabled': { + backgroundColor: '#BDBDBD', + }, + }, + }, + rootHovered: { + backgroundColor: 'rgba(44, 41, 41, 0.8)', + }, + rootPressed: { + backgroundColor: 'rgba(44, 41, 41, 0.8)', + }, + }; + return ( +
+ handleZoom(-100)} + > + handleZoom(100)} + > + { + handleZoom(0); + container.scrollTo({ top: 0 }); + }} + > + + + + +
+ ); + }; + + // Using ref and eventListener instead of
because passive property can not be set in
+ useEffect(() => { + if (flowZoomRate) { + divRef.current?.addEventListener('wheel', onWheel, { passive: false }); + } + return () => divRef.current?.removeEventListener('wheel', onWheel); + }, [flowZoomRate]); + + return ( +
+ {children} + {buttonRender()} +
+ ); +}; diff --git a/Composer/packages/client/src/pages/design/DesignPage.tsx b/Composer/packages/client/src/pages/design/DesignPage.tsx index 3969cc7eb1..82bdef201f 100644 --- a/Composer/packages/client/src/pages/design/DesignPage.tsx +++ b/Composer/packages/client/src/pages/design/DesignPage.tsx @@ -140,6 +140,7 @@ const DesignPage: React.FC { + return `Zoom_${value}_State`; +}; + +export const rateInfoState = atom({ + key: getFullyQualifiedKey('rateInfo'), + default: { + rateList: [0.25, 0.33, 0.5, 0.75, 0.8, 0.9, 1, 1.1, 1.25, 1.75, 2, 2.5, 3, 4, 5], + maxRate: 3, + minRate: 0.5, + currentRate: 1, + }, +}); diff --git a/Composer/packages/client/src/recoilModel/dispatchers/index.ts b/Composer/packages/client/src/recoilModel/dispatchers/index.ts index ad35085575..737dd69856 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/index.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/index.ts @@ -21,6 +21,7 @@ import { multilangDispatcher } from './multilang'; import { notificationDispatcher } from './notification'; import { extensionsDispatcher } from './extensions'; import { botProjectFileDispatcher } from './botProjectFile'; +import { zoomDispatcher } from './zoom'; const createDispatchers = () => { return { @@ -44,6 +45,7 @@ const createDispatchers = () => { ...notificationDispatcher(), ...extensionsDispatcher(), ...botProjectFileDispatcher(), + ...zoomDispatcher(), }; }; diff --git a/Composer/packages/client/src/recoilModel/dispatchers/zoom.ts b/Composer/packages/client/src/recoilModel/dispatchers/zoom.ts new file mode 100644 index 0000000000..cc1def19d6 --- /dev/null +++ b/Composer/packages/client/src/recoilModel/dispatchers/zoom.ts @@ -0,0 +1,18 @@ +/* eslint-disable react-hooks/rules-of-hooks */ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { CallbackInterface, useRecoilCallback } from 'recoil'; + +import { rateInfoState } from '../atoms/zoomState'; + +export const zoomDispatcher = () => { + const updateZoomRate = useRecoilCallback(({ set }: CallbackInterface) => async ({ currentRate }) => { + set(rateInfoState, (rateInfo) => { + return { ...rateInfo, currentRate }; + }); + }); + return { + updateZoomRate, + }; +}; diff --git a/Composer/packages/client/src/shell/useShell.ts b/Composer/packages/client/src/shell/useShell.ts index 4ad88eb1f6..e536a10baa 100644 --- a/Composer/packages/client/src/shell/useShell.ts +++ b/Composer/packages/client/src/shell/useShell.ts @@ -26,6 +26,7 @@ import { dialogSchemasState, lgFilesState, luFilesState, + rateInfoState, } from '../recoilModel'; import { undoFunctionState } from '../recoilModel/undo/history'; @@ -56,6 +57,7 @@ export function useShell(source: EventSource, projectId: string): Shell { const dialogSchemas = useRecoilValue(dialogSchemasState(projectId)); const botName = useRecoilValue(botDisplayNameState(projectId)); const settings = useRecoilValue(settingsState(projectId)); + const flowZoomRate = useRecoilValue(rateInfoState); const userSettings = useRecoilValue(userSettingsState); const clipboardActions = useRecoilValue(clipboardActionsState); @@ -74,6 +76,7 @@ export function useShell(source: EventSource, projectId: string): Shell { setMessage, displayManifestModal, updateSkill, + updateZoomRate, } = useRecoilValue(dispatcherState); const lgApi = useLgApi(projectId); @@ -133,6 +136,10 @@ export function useShell(source: EventSource, projectId: string): Shell { focusTo(projectId, dataPath, fragment ?? ''); } + function updateFlowZoomRate(currentRate) { + updateZoomRate({ currentRate }); + } + dialogMapRef.current = dialogsMap; const api: ShellApi = { @@ -204,6 +211,7 @@ export function useShell(source: EventSource, projectId: string): Shell { updateDialogSchema(dialogSchema, projectId); }, updateSkillSetting: (...params) => updateSkill(projectId, ...params), + updateFlowZoomRate, }; const currentDialog = useMemo(() => dialogs.find((d) => d.id === dialogId), [dialogs, dialogId]); @@ -239,6 +247,7 @@ export function useShell(source: EventSource, projectId: string): Shell { luFeatures: settings.luFeatures, skills, skillsSettings: settings.skill || {}, + flowZoomRate, } : ({ projectId, diff --git a/Composer/packages/types/src/shell.ts b/Composer/packages/types/src/shell.ts index 9ede477bbe..c395cd0228 100644 --- a/Composer/packages/types/src/shell.ts +++ b/Composer/packages/types/src/shell.ts @@ -11,6 +11,13 @@ type AllPartial = { [P in keyof T]?: T[P] extends (infer U)[] ? AllPartial[] : T[P] extends object ? AllPartial : T[P]; }; +export type ZoomInfo = { + rateList: number[]; + maxRate: number; + minRate: number; + currentRate: number; +}; + export type EditorSchema = { content?: { fieldTemplateOverrides: any; @@ -62,6 +69,7 @@ export type ShellData = { skillsSettings: Record; // TODO: remove schemas: BotSchemas; + flowZoomRate: ZoomInfo; }; export type ShellApi = { @@ -103,6 +111,7 @@ export type ShellApi = { updateDialogSchema: (_: DialogSchemaFile) => Promise; createTrigger: (id: string, formData, url?: string) => void; updateSkillSetting: (skillId: string, skillsData: SkillSetting) => Promise; + updateFlowZoomRate: (currentRate: number) => void; }; export type Shell = {