diff --git a/Composer/packages/extensions/visual-designer/package.json b/Composer/packages/extensions/visual-designer/package.json index 10909e14cc..acb0e94a99 100644 --- a/Composer/packages/extensions/visual-designer/package.json +++ b/Composer/packages/extensions/visual-designer/package.json @@ -36,6 +36,7 @@ "lodash": "^4.17.15", "office-ui-fabric-react": "7.62.0", "prop-types": "^15.7.2", + "react-measure": "^2.3.0", "source-map-loader": "^0.2.4" }, "peerDependencies": { diff --git a/Composer/packages/extensions/visual-designer/src/components/groups/StepGroup.tsx b/Composer/packages/extensions/visual-designer/src/components/groups/StepGroup.tsx index b852607f9e..83d4b57ebc 100644 --- a/Composer/packages/extensions/visual-designer/src/components/groups/StepGroup.tsx +++ b/Composer/packages/extensions/visual-designer/src/components/groups/StepGroup.tsx @@ -3,10 +3,9 @@ /** @jsx jsx */ import { jsx } from '@emotion/core'; -import { useState, useMemo, useEffect, FunctionComponent } from 'react'; +import { useMemo, FunctionComponent } from 'react'; import { GraphNode } from '../../models/GraphNode'; -import { areBoundariesEqual } from '../../models/Boundary'; import { sequentialLayouter } from '../../layouters/sequentialLayouter'; import { ElementInterval, EdgeAddButtonSize } from '../../constants/ElementSizes'; import { NodeEventTypes } from '../../constants/NodeEventTypes'; @@ -18,46 +17,42 @@ import { GraphLayout } from '../../models/GraphLayout'; import { EdgeMenu } from '../menus/EdgeMenu'; import { SVGContainer } from '../lib/SVGContainer'; import { renderEdge } from '../lib/EdgeUtil'; +import { GraphNodeMap, useSmartLayout } from '../../hooks/useSmartLayout'; +import { designerCache } from '../../store/DesignerCache'; const StepInterval = ElementInterval.y; -const calculateNodes = (groupId: string, data): GraphNode[] => { +type StepNodeKey = string; + +const getStepKey = (stepOrder: number): StepNodeKey => `steps[${stepOrder}]`; + +const calculateNodes = (groupId: string, data): GraphNodeMap => { const steps = transformStepGroup(data, groupId); - return steps.map((x): GraphNode => GraphNode.fromIndexedJson(x)); + const stepNodes = steps.map((x): GraphNode => GraphNode.fromIndexedJson(x)); + return stepNodes.reduce((result, node, index) => { + result[getStepKey(index)] = node; + return result; + }, {} as GraphNodeMap); }; -const calculateLayout = (nodes, boundaryMap): GraphLayout => { - nodes.forEach((x): void => (x.boundary = boundaryMap[x.id] || x.boundary)); +const calculateLayout = (nodeMap: GraphNodeMap): GraphLayout => { + const nodes = Object.keys(nodeMap) + .sort() + .map(stepName => nodeMap[stepName]); return sequentialLayouter(nodes); }; export const StepGroup: FunctionComponent = ({ id, data, onEvent, onResize }: NodeProps): JSX.Element => { - const [boundaryMap, setBoundaryMap] = useState({}); - const initialNodes = useMemo((): GraphNode[] => calculateNodes(id, data), [id, data]); - const layout = useMemo((): GraphLayout => calculateLayout(initialNodes, boundaryMap), [initialNodes, boundaryMap]); - const accumulatedPatches = {}; - - const patchBoundary = (id, boundary): void => { - if (!boundaryMap[id] || !areBoundariesEqual(boundaryMap[id], boundary)) { - accumulatedPatches[id] = boundary; - setBoundaryMap({ - ...boundaryMap, - ...accumulatedPatches, - }); - } - }; + const initialNodes = useMemo(() => calculateNodes(id, data), [id, data]); + const { layout, updateNodeBoundary } = useSmartLayout(initialNodes, calculateLayout, onResize); const { boundary, nodes, edges } = layout; - useEffect(() => { - onResize(layout.boundary); - }, [layout]); - return (
{Array.isArray(edges) ? edges.map(x => renderEdge(x)) : null} {nodes - ? nodes.map(x => ( + ? nodes.map((x, index) => ( = ({ id, data, onEvent, onR data={x.data} onEvent={onEvent} onResize={size => { - patchBoundary(x.id, size); + designerCache.cacheBoundary(x.data, size); + updateNodeBoundary(getStepKey(index), size); }} /> diff --git a/Composer/packages/extensions/visual-designer/src/components/nodes/nodeProps.ts b/Composer/packages/extensions/visual-designer/src/components/nodes/nodeProps.ts index f6a5a9c010..b3471220c2 100644 --- a/Composer/packages/extensions/visual-designer/src/components/nodes/nodeProps.ts +++ b/Composer/packages/extensions/visual-designer/src/components/nodes/nodeProps.ts @@ -9,7 +9,7 @@ export interface NodeProps { data: any; focused?: boolean; onEvent: (action, id, ...rest) => object | void; - onResize: (boundary?: Boundary, id?) => object | void; + onResize: (boundary: Boundary, id?) => object | void; isRoot?: boolean; } diff --git a/Composer/packages/extensions/visual-designer/src/components/nodes/templates/FormCard.tsx b/Composer/packages/extensions/visual-designer/src/components/nodes/templates/FormCard.tsx index 792d708c6d..116595a902 100644 --- a/Composer/packages/extensions/visual-designer/src/components/nodes/templates/FormCard.tsx +++ b/Composer/packages/extensions/visual-designer/src/components/nodes/templates/FormCard.tsx @@ -16,7 +16,7 @@ const contentHeight = boxHeight - headerHeight; const containerStyle = { width: boxWidth, - height: boxHeight, + minHeight: boxHeight, fontSize: '12px', cursor: 'pointer', overflow: 'hidden', @@ -92,17 +92,18 @@ export const FormCard: FunctionComponent = ({ className="card__content" css={{ width: '100%', - height: contentHeight, + minHeight: contentHeight, + display: 'inline-block', }} >
@@ -124,12 +125,13 @@ export const FormCard: FunctionComponent = ({ css={{ height: '100%', width: 'calc(100% - 20px)', - whiteSpace: 'nowrap', - textOverflow: 'ellipsis', - overflow: 'hidden', + whiteSpace: 'initial', fontSize: '12px', lineHeight: '19px', fontFamily: 'Segoe UI', + overflowWrap: 'break-word', + wordBreak: 'break-all', + display: 'inline-block', }} title={typeof label === 'string' ? label : ''} > diff --git a/Composer/packages/extensions/visual-designer/src/components/renderers/ElementMeasurer.tsx b/Composer/packages/extensions/visual-designer/src/components/renderers/ElementMeasurer.tsx new file mode 100644 index 0000000000..17d7a5f346 --- /dev/null +++ b/Composer/packages/extensions/visual-designer/src/components/renderers/ElementMeasurer.tsx @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import React from 'react'; +import Measure from 'react-measure'; + +import { Boundary } from '../../models/Boundary'; + +export interface ElementMeasurerProps { + children: React.ReactNode; + style?: React.CSSProperties; + onResize: (boundary: Boundary) => void; +} + +/** + * Notify a ReactNode's size once its size has been changed. + * Remember to use it inside the focus border component (ElementWrapper). + */ +export const ElementMeasurer: React.FC = ({ children, style, onResize }) => { + return ( + { + onResize(new Boundary(width, height)); + }} + > + {({ measureRef }) => ( +
+ {children} +
+ )} +
+ ); +}; diff --git a/Composer/packages/extensions/visual-designer/src/components/renderers/StepRenderer.tsx b/Composer/packages/extensions/visual-designer/src/components/renderers/StepRenderer.tsx index 0f1c9fcd65..6674904de7 100644 --- a/Composer/packages/extensions/visual-designer/src/components/renderers/StepRenderer.tsx +++ b/Composer/packages/extensions/visual-designer/src/components/renderers/StepRenderer.tsx @@ -12,6 +12,7 @@ import { renderUIWidget } from '../../schema/uischemaRenderer'; import { UISchemaContext } from '../../store/UISchemaContext'; import { ElementWrapper } from './ElementWrapper'; +import { ElementMeasurer } from './ElementMeasurer'; /** TODO: (zeye) integrate this array into UISchema */ const TypesWithoutWrapper = [ @@ -27,19 +28,19 @@ const TypesWithoutWrapper = [ SDKTypes.ChoiceInput, ]; -export const StepRenderer: FC = ({ id, data, onEvent }): JSX.Element => { +export const StepRenderer: FC = ({ id, data, onEvent, onResize }): JSX.Element => { const schemaProvider = useContext(UISchemaContext); const $type = get(data, '$type', ''); const widgetSchema = schemaProvider.get($type); - const content = renderUIWidget(widgetSchema, { id, data, onEvent }); + const content = renderUIWidget(widgetSchema, { id, data, onEvent, onResize }); if (TypesWithoutWrapper.some(x => $type === x)) { return content; } return ( - {content} + onResize(boundary)}>{content} ); }; diff --git a/Composer/packages/extensions/visual-designer/src/hooks/useSmartLayout.ts b/Composer/packages/extensions/visual-designer/src/hooks/useSmartLayout.ts new file mode 100644 index 0000000000..496ec79591 --- /dev/null +++ b/Composer/packages/extensions/visual-designer/src/hooks/useSmartLayout.ts @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { useState, useEffect, useMemo } from 'react'; + +import { Boundary, areBoundariesEqual } from '../models/Boundary'; +import { GraphLayout } from '../models/GraphLayout'; +import { GraphNode } from '../models/GraphNode'; + +// 'T extends string' means an Enum. Reference: https://github.com/microsoft/TypeScript/issues/30611#issuecomment-565384924 +type MapWithEnumKey = { [key in KeyType]: ValueType }; + +type BoundaryMap = MapWithEnumKey; + +export type GraphNodeMap = MapWithEnumKey; + +export function useSmartLayout( + nodeMap: GraphNodeMap, + layouter: (nodeMap: GraphNodeMap) => GraphLayout, + onResize: (boundary: Boundary) => void +): { + layout: GraphLayout; + updateNodeBoundary: (nodeName: T, boundary: Boundary) => void; +} { + const [boundaryMap, setBoundaryMap] = useState>({} as BoundaryMap); + /** + * The object `accumulatedPatches` is used to collect all accumulated + * boundary changes happen in a same JS event cyle. After collecting + * them together, they will be submitted to component states to guide + * next redraw. + * + * We shouldn't use `setState()` here because of `patchBoundary` may be + * fired multiple times (especially at the init render cycle), changes + * will be lost by using `setState()`; + * + * We shouldn't use `useRef` here since `accumulatedPatches` as a local + * cache needs to be cleared after taking effect in one redraw. + */ + const accumulatedPatches = {}; + const patchBoundary = (nodeName: string, boundary: Boundary) => { + if (!boundaryMap[nodeName] || !areBoundariesEqual(boundaryMap[nodeName], boundary)) { + accumulatedPatches[nodeName] = boundary; + setBoundaryMap({ + ...boundaryMap, + ...accumulatedPatches, + }); + } + }; + + const layout = useMemo(() => { + // write updated boundaries to nodes + Object.keys(nodeMap).map(nodeName => { + const node = nodeMap[nodeName]; + if (node) { + node.boundary = boundaryMap[nodeName] || node.boundary; + } + }); + return layouter(nodeMap); + }, [nodeMap, boundaryMap]); + + useEffect(() => { + onResize && onResize(layout.boundary); + }, [layout]); + + return { + layout, + updateNodeBoundary: patchBoundary, + }; +} diff --git a/Composer/packages/extensions/visual-designer/src/layouters/measureJsonBoundary.ts b/Composer/packages/extensions/visual-designer/src/layouters/measureJsonBoundary.ts index 03420424b2..4937873417 100644 --- a/Composer/packages/extensions/visual-designer/src/layouters/measureJsonBoundary.ts +++ b/Composer/packages/extensions/visual-designer/src/layouters/measureJsonBoundary.ts @@ -16,6 +16,7 @@ import { transformIfCondtion } from '../transformers/transformIfCondition'; import { transformSwitchCondition } from '../transformers/transformSwitchCondition'; import { transformForeach } from '../transformers/transformForeach'; import { transformBaseInput } from '../transformers/transformBaseInput'; +import { designerCache } from '../store/DesignerCache'; import { calculateIfElseBoundary, @@ -81,6 +82,11 @@ export function measureJsonBoundary(json): Boundary { let boundary = new Boundary(); if (!json || !json.$type) return boundary; + const cachedBoundary = designerCache.loadBounary(json); + if (cachedBoundary) { + return cachedBoundary; + } + switch (json.$type) { case ObiTypes.ChoiceDiamond: boundary = new Boundary(DiamondSize.width, DiamondSize.height); diff --git a/Composer/packages/extensions/visual-designer/src/schema/uischema.tsx b/Composer/packages/extensions/visual-designer/src/schema/uischema.tsx index 879cce4f58..a8177ac85f 100644 --- a/Composer/packages/extensions/visual-designer/src/schema/uischema.tsx +++ b/Composer/packages/extensions/visual-designer/src/schema/uischema.tsx @@ -16,7 +16,6 @@ import { ForeachWidget } from '../widgets/ForeachWidget'; import { ChoiceInputChoices } from '../widgets/ChoiceInput'; import { ElementIcon } from '../utils/obiPropertyResolver'; import { ObiColors } from '../constants/ElementColors'; -import { measureChoiceInputDetailBoundary } from '../layouters/measureJsonBoundary'; import { UISchema, UIWidget } from './uischema.types'; @@ -41,7 +40,6 @@ const BaseInputSchema: UIWidget = { menu: 'none', content: data => data.property || '', children: data => (data.$type === SDKTypes.ChoiceInput ? : null), - size: data => measureChoiceInputDetailBoundary(data), colors: { theme: ObiColors.LightBlue, icon: ObiColors.AzureBlue, diff --git a/Composer/packages/extensions/visual-designer/src/schema/uischemaRenderer.tsx b/Composer/packages/extensions/visual-designer/src/schema/uischemaRenderer.tsx index 56f9c1594b..8e5841aecc 100644 --- a/Composer/packages/extensions/visual-designer/src/schema/uischemaRenderer.tsx +++ b/Composer/packages/extensions/visual-designer/src/schema/uischemaRenderer.tsx @@ -4,6 +4,8 @@ import React from 'react'; import { BaseSchema } from '@bfc/shared'; +import { Boundary } from '../models/Boundary'; + import { UIWidget, UI_WIDGET_KEY, UIWidgetProp, WidgetEventHandler } from './uischema.types'; export interface UIWidgetContext { @@ -15,6 +17,9 @@ export interface UIWidgetContext { /** Handle UI events */ onEvent: WidgetEventHandler; + + /** Report widget boundary */ + onResize: (boundary: Boundary) => void; } const parseWidgetSchema = (widgetSchema: UIWidget) => { diff --git a/Composer/packages/extensions/visual-designer/src/store/DesignerCache.ts b/Composer/packages/extensions/visual-designer/src/store/DesignerCache.ts new file mode 100644 index 0000000000..39e27f9bb7 --- /dev/null +++ b/Composer/packages/extensions/visual-designer/src/store/DesignerCache.ts @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { BaseSchema } from '@bfc/shared'; +import get from 'lodash/get'; + +import { Boundary } from '../models/Boundary'; + +const MAX_CACHE_SIZE = 99999; + +export class DesignerCache { + private boundaryCache = {}; + private cacheSize = 0; + + private getActionDataHash(actionData: BaseSchema): string | null { + const designerId = get(actionData, '$designer.id', ''); + if (!designerId) return null; + + const $type = get(actionData, '$type'); + return `${$type}-${designerId}`; + } + + cacheBoundary(actionData: BaseSchema, boundary: Boundary): boolean { + const key = this.getActionDataHash(actionData); + if (!key) { + return false; + } + + if (this.cacheSize > MAX_CACHE_SIZE) { + delete this.boundaryCache; + this.boundaryCache = {}; + this.cacheSize = 0; + } + this.boundaryCache[key] = boundary; + this.cacheSize += 1; + return true; + } + + loadBounary(actionData: BaseSchema): Boundary | undefined { + const key = this.getActionDataHash(actionData); + if (key) { + return this.boundaryCache[key]; + } + } +} + +export const designerCache = new DesignerCache(); diff --git a/Composer/packages/extensions/visual-designer/src/widgets/ForeachWidget.tsx b/Composer/packages/extensions/visual-designer/src/widgets/ForeachWidget.tsx index 8aabdba14f..a31786e8fa 100644 --- a/Composer/packages/extensions/visual-designer/src/widgets/ForeachWidget.tsx +++ b/Composer/packages/extensions/visual-designer/src/widgets/ForeachWidget.tsx @@ -3,43 +3,52 @@ /** @jsx jsx */ import { jsx } from '@emotion/core'; -import { useMemo, useEffect, useState, FunctionComponent } from 'react'; +import { useMemo, FunctionComponent } from 'react'; import { transformForeach } from '../transformers/transformForeach'; import { foreachLayouter } from '../layouters/foreachLayouter'; -import { areBoundariesEqual, Boundary } from '../models/Boundary'; import { GraphNode } from '../models/GraphNode'; import { NodeEventTypes } from '../constants/NodeEventTypes'; import { OffsetContainer } from '../components/lib/OffsetContainer'; import { LoopIndicator } from '../components/decorations/LoopIndicator'; import { StepGroup } from '../components/groups'; import { ElementWrapper } from '../components/renderers/ElementWrapper'; -import { NodeMap, BoundaryMap } from '../components/nodes/types'; +import { ElementMeasurer } from '../components/renderers/ElementMeasurer'; import { WidgetContainerProps } from '../schema/uischema.types'; import { renderEdge } from '../components/lib/EdgeUtil'; import { SVGContainer } from '../components/lib/SVGContainer'; +import { useSmartLayout, GraphNodeMap } from '../hooks/useSmartLayout'; +import { designerCache } from '../store/DesignerCache'; -const calculateNodeMap = (jsonpath, data): NodeMap => { +enum ForeachNodes { + Foreach = 'foreachNode', + LoopBegin = 'loopBeginNode', + LoopEnd = 'loopEndNode', + LoopActions = 'loopActionsNode', +} + +const calculateNodeMap = (jsonpath, data): GraphNodeMap => { const result = transformForeach(data, jsonpath); - if (!result) return {}; + if (!result) + return { + [ForeachNodes.Foreach]: new GraphNode(), + [ForeachNodes.LoopActions]: new GraphNode(), + [ForeachNodes.LoopBegin]: new GraphNode(), + [ForeachNodes.LoopEnd]: new GraphNode(), + }; const { foreachDetail, stepGroup, loopBegin, loopEnd } = result; return { - foreachNode: GraphNode.fromIndexedJson(foreachDetail), - stepGroupNode: GraphNode.fromIndexedJson(stepGroup), - loopBeginNode: GraphNode.fromIndexedJson(loopBegin), - loopEndNode: GraphNode.fromIndexedJson(loopEnd), + [ForeachNodes.Foreach]: GraphNode.fromIndexedJson(foreachDetail), + [ForeachNodes.LoopActions]: GraphNode.fromIndexedJson(stepGroup), + [ForeachNodes.LoopBegin]: GraphNode.fromIndexedJson(loopBegin), + [ForeachNodes.LoopEnd]: GraphNode.fromIndexedJson(loopEnd), }; }; -const calculateLayout = (nodeMap: NodeMap, boundaryMap: BoundaryMap) => { - Object.values(nodeMap) - .filter(x => !!x) - .forEach((x: GraphNode) => { - x.boundary = boundaryMap[x.id] || x.boundary; - }); - - return foreachLayouter(nodeMap.foreachNode, nodeMap.stepGroupNode, nodeMap.loopBeginNode, nodeMap.loopEndNode); +const calculateForeachLayout = (nodeMap: GraphNodeMap) => { + const { foreachNode, loopActionsNode, loopBeginNode, loopEndNode } = nodeMap; + return foreachLayouter(foreachNode, loopActionsNode, loopBeginNode, loopEndNode); }; export interface ForeachWidgetProps extends WidgetContainerProps { @@ -47,46 +56,37 @@ export interface ForeachWidgetProps extends WidgetContainerProps { } export const ForeachWidget: FunctionComponent = ({ id, data, onEvent, onResize, loop }) => { - const [boundaryMap, setBoundaryMap] = useState({}); - const initialNodeMap = useMemo(() => calculateNodeMap(id, data), [id, data]); - const layout = useMemo(() => calculateLayout(initialNodeMap, boundaryMap), [initialNodeMap, boundaryMap]); - const accumulatedPatches = {}; - - const patchBoundary = (id, boundary?: Boundary) => { - if (!boundaryMap[id] || !areBoundariesEqual(boundaryMap[id], boundary)) { - accumulatedPatches[id] = boundary; - setBoundaryMap({ - ...boundaryMap, - ...accumulatedPatches, - }); - } - }; - - useEffect(() => { - onResize(layout.boundary); - }, [layout]); + const nodeMap = useMemo(() => calculateNodeMap(id, data), [id, data]); + const { layout, updateNodeBoundary } = useSmartLayout(nodeMap, calculateForeachLayout, onResize); - const { boundary, nodeMap, edges } = layout; + const { boundary, edges } = layout; if (!nodeMap) { return null; } - const { foreachNode, stepsNode, loopBeginNode, loopEndNode } = nodeMap; + const { foreachNode, loopActionsNode, loopBeginNode, loopEndNode } = nodeMap; return (
- {loop} + { + designerCache.cacheBoundary(foreachNode.data, boundary); + updateNodeBoundary(ForeachNodes.Foreach, boundary); + }} + > + {loop} + - + { - patchBoundary(stepsNode.id, size); + updateNodeBoundary(ForeachNodes.LoopActions, size); }} /> diff --git a/Composer/packages/extensions/visual-designer/src/widgets/IfConditionWidget.tsx b/Composer/packages/extensions/visual-designer/src/widgets/IfConditionWidget.tsx index e991d35025..10426593c9 100644 --- a/Composer/packages/extensions/visual-designer/src/widgets/IfConditionWidget.tsx +++ b/Composer/packages/extensions/visual-designer/src/widgets/IfConditionWidget.tsx @@ -3,41 +3,52 @@ /** @jsx jsx */ import { jsx } from '@emotion/core'; -import { FunctionComponent, useEffect, useState, useMemo } from 'react'; +import { FunctionComponent, useMemo } from 'react'; import { transformIfCondtion } from '../transformers/transformIfCondition'; import { ifElseLayouter } from '../layouters/ifelseLayouter'; import { NodeEventTypes } from '../constants/NodeEventTypes'; import { GraphNode } from '../models/GraphNode'; -import { areBoundariesEqual, Boundary } from '../models/Boundary'; import { OffsetContainer } from '../components/lib/OffsetContainer'; import { StepGroup } from '../components/groups'; import { Diamond } from '../components/nodes/templates/Diamond'; import { ElementWrapper } from '../components/renderers/ElementWrapper'; -import { NodeMap, BoundaryMap } from '../components/nodes/types'; +import { ElementMeasurer } from '../components/renderers/ElementMeasurer'; import { WidgetContainerProps } from '../schema/uischema.types'; import { SVGContainer } from '../components/lib/SVGContainer'; import { renderEdge } from '../components/lib/EdgeUtil'; +import { useSmartLayout, GraphNodeMap } from '../hooks/useSmartLayout'; +import { designerCache } from '../store/DesignerCache'; -const calculateNodeMap = (path, data): NodeMap => { +enum IfElseNodes { + Condition = 'conditionNode', + Choice = 'choiceNode', + IfBranch = 'ifBranchNode', + ElseBranch = 'elseBranchNode', +} + +const calculateNodeMap = (path: string, data): GraphNodeMap => { const result = transformIfCondtion(data, path); - if (!result) return {}; + if (!result) + return { + [IfElseNodes.Condition]: new GraphNode(), + [IfElseNodes.Choice]: new GraphNode(), + [IfElseNodes.IfBranch]: new GraphNode(), + [IfElseNodes.ElseBranch]: new GraphNode(), + }; const { condition, choice, ifGroup, elseGroup } = result; return { - conditionNode: GraphNode.fromIndexedJson(condition), - choiceNode: GraphNode.fromIndexedJson(choice), - ifGroupNode: GraphNode.fromIndexedJson(ifGroup), - elseGroupNode: GraphNode.fromIndexedJson(elseGroup), + [IfElseNodes.Condition]: GraphNode.fromIndexedJson(condition), + [IfElseNodes.Choice]: GraphNode.fromIndexedJson(choice), + [IfElseNodes.IfBranch]: GraphNode.fromIndexedJson(ifGroup), + [IfElseNodes.ElseBranch]: GraphNode.fromIndexedJson(elseGroup), }; }; -const calculateLayout = (nodeMap: NodeMap, boundaryMap: BoundaryMap) => { - Object.values(nodeMap) - .filter(x => !!x) - .forEach((x: GraphNode) => (x.boundary = boundaryMap[x.id] || x.boundary)); - - return ifElseLayouter(nodeMap.conditionNode, nodeMap.choiceNode, nodeMap.ifGroupNode, nodeMap.elseGroupNode); +const calculateIfElseLayout = (nodeMap: GraphNodeMap) => { + const { conditionNode, choiceNode, ifBranchNode, elseBranchNode } = nodeMap; + return ifElseLayouter(conditionNode, choiceNode, ifBranchNode, elseBranchNode); }; export interface IfConditionWidgetProps extends WidgetContainerProps { @@ -51,60 +62,49 @@ export const IfConditionWidget: FunctionComponent = ({ onResize, judgement, }) => { - const [boundaryMap, setBoundaryMap] = useState({}); - const initialNodeMap = useMemo(() => calculateNodeMap(id, data), [id, data]); - const layout = useMemo(() => calculateLayout(initialNodeMap, boundaryMap), [initialNodeMap, boundaryMap]); - const accumulatedPatches = {}; - - const patchBoundary = (id, boundary?: Boundary) => { - if (!boundaryMap[id] || !areBoundariesEqual(boundaryMap[id], boundary)) { - accumulatedPatches[id] = boundary; - setBoundaryMap({ - ...boundaryMap, - ...accumulatedPatches, - }); - } - }; - - useEffect(() => { - onResize(layout.boundary); - }, [layout]); + const nodeMap = useMemo(() => calculateNodeMap(id, data), [id, data]); + const { layout, updateNodeBoundary } = useSmartLayout(nodeMap, calculateIfElseLayout, onResize); - const { boundary, nodeMap, edges } = layout; - const condition = nodeMap.condition || new GraphNode(); - const choice = nodeMap.choice || new GraphNode(); + const { boundary, edges } = layout; + const { conditionNode, choiceNode } = nodeMap; return (
- - - {judgement} + + + { + designerCache.cacheBoundary(conditionNode.data, boundary); + updateNodeBoundary(IfElseNodes.Condition, boundary); + }} + > + {judgement} + - + { onEvent(NodeEventTypes.Focus, { id }); }} /> - {nodeMap - ? [nodeMap.if, nodeMap.else] - .filter(x => !!x) - .map(x => ( - - { - patchBoundary(x.id, size); - }} - /> - - )) - : null} + {[IfElseNodes.IfBranch, IfElseNodes.ElseBranch].map(nodeName => { + const node = nodeMap[nodeName]; + return ( + + { + updateNodeBoundary(nodeName, size); + }} + /> + + ); + })} {Array.isArray(edges) ? edges.map(x => renderEdge(x)) : null}
); diff --git a/Composer/packages/extensions/visual-designer/src/widgets/PromptWidget.tsx b/Composer/packages/extensions/visual-designer/src/widgets/PromptWidget.tsx index 1355a2c3f0..248da13dac 100644 --- a/Composer/packages/extensions/visual-designer/src/widgets/PromptWidget.tsx +++ b/Composer/packages/extensions/visual-designer/src/widgets/PromptWidget.tsx @@ -3,7 +3,7 @@ /** @jsx jsx */ import { jsx } from '@emotion/core'; -import { FC } from 'react'; +import { FC, useMemo } from 'react'; import { PromptTab } from '@bfc/shared'; import { baseInputLayouter } from '../layouters/baseInputLayouter'; @@ -16,24 +16,46 @@ import { NodeEventTypes } from '../constants/NodeEventTypes'; import { IconBrick } from '../components/decorations/IconBrick'; import { renderEdge } from '../components/lib/EdgeUtil'; import { SVGContainer } from '../components/lib/SVGContainer'; +import { GraphLayout } from '../models/GraphLayout'; +import { ElementMeasurer } from '../components/renderers/ElementMeasurer'; +import { useSmartLayout, GraphNodeMap } from '../hooks/useSmartLayout'; +import { designerCache } from '../store/DesignerCache'; -const calculateNodes = (data, jsonpath: string) => { +enum PromptNodes { + BotAsks = 'botAsksNode', + UserAnswers = 'userAnswersNode', + InvalidPrompt = 'invalidPromptyNode', +} + +const calculateNodes = (jsonpath: string, data) => { const { botAsks, userAnswers, invalidPrompt } = transformBaseInput(data, jsonpath); return { - botAsksNode: GraphNode.fromIndexedJson(botAsks), - userAnswersNode: GraphNode.fromIndexedJson(userAnswers), - invalidPromptNode: GraphNode.fromIndexedJson(invalidPrompt), + [PromptNodes.BotAsks]: GraphNode.fromIndexedJson(botAsks), + [PromptNodes.UserAnswers]: GraphNode.fromIndexedJson(userAnswers), + [PromptNodes.InvalidPrompt]: GraphNode.fromIndexedJson(invalidPrompt), }; }; +const calculateLayout = (nodeMap: GraphNodeMap): GraphLayout => { + const { botAsksNode, userAnswersNode, invalidPromptyNode } = nodeMap; + return baseInputLayouter(botAsksNode, userAnswersNode, invalidPromptyNode); +}; + export interface PromptWdigetProps extends WidgetContainerProps { botAsks: JSX.Element; userInput: JSX.Element; } -export const PromptWidget: FC = ({ id, data, onEvent, botAsks, userInput }): JSX.Element => { - const nodes = calculateNodes(data, id); - const layout = baseInputLayouter(nodes.botAsksNode, nodes.userAnswersNode, nodes.invalidPromptNode); +export const PromptWidget: FC = ({ + id, + data, + onEvent, + onResize, + botAsks, + userInput, +}): JSX.Element => { + const nodes = useMemo(() => calculateNodes(id, data), [id, data]); + const { layout, updateNodeBoundary } = useSmartLayout(nodes, calculateLayout, onResize); const { boundary, nodeMap, edges } = layout; const { botAsksNode, userAnswersNode, invalidPromptNode: brickNode } = nodeMap; @@ -42,12 +64,26 @@ export const PromptWidget: FC = ({ id, data, onEvent, botAsks
- {botAsks} + { + designerCache.cacheBoundary(botAsksNode.data, boundary); + updateNodeBoundary(PromptNodes.BotAsks, boundary); + }} + > + {botAsks} + - {userInput} + { + designerCache.cacheBoundary(userAnswersNode.data, boundary); + updateNodeBoundary(PromptNodes.UserAnswers, boundary); + }} + > + {userInput} + diff --git a/Composer/packages/extensions/visual-designer/src/widgets/SwitchConditionWidget.tsx b/Composer/packages/extensions/visual-designer/src/widgets/SwitchConditionWidget.tsx index 66a74a2775..6d3c3b6f86 100644 --- a/Composer/packages/extensions/visual-designer/src/widgets/SwitchConditionWidget.tsx +++ b/Composer/packages/extensions/visual-designer/src/widgets/SwitchConditionWidget.tsx @@ -3,39 +3,61 @@ /** @jsx jsx */ import { jsx } from '@emotion/core'; -import { FunctionComponent, useEffect, useState, useMemo } from 'react'; +import { FunctionComponent, useMemo } from 'react'; import { NodeEventTypes } from '../constants/NodeEventTypes'; import { transformSwitchCondition } from '../transformers/transformSwitchCondition'; import { switchCaseLayouter } from '../layouters/switchCaseLayouter'; import { GraphNode } from '../models/GraphNode'; -import { areBoundariesEqual } from '../models/Boundary'; import { OffsetContainer } from '../components/lib/OffsetContainer'; import { StepGroup } from '../components/groups'; import { Diamond } from '../components/nodes/templates/Diamond'; import { ElementWrapper } from '../components/renderers/ElementWrapper'; +import { ElementMeasurer } from '../components/renderers/ElementMeasurer'; import { WidgetContainerProps } from '../schema/uischema.types'; import { renderEdge } from '../components/lib/EdgeUtil'; import { SVGContainer } from '../components/lib/SVGContainer'; +import { GraphNodeMap, useSmartLayout } from '../hooks/useSmartLayout'; +import { designerCache } from '../store/DesignerCache'; -const calculateNodeMap = (path, data) => { +enum SwitchNodes { + Switch = 'switchNode', + Choice = 'choiceNode', +} + +type CaseNodeKey = string; + +const getCaseKey = (caseIndex: number): CaseNodeKey => `cases[${caseIndex}]`; + +const calculateNodeMap = (path: string, data): GraphNodeMap => { const result = transformSwitchCondition(data, path); - if (!result) return {}; + if (!result) + return { + [SwitchNodes.Switch]: new GraphNode(), + [SwitchNodes.Choice]: new GraphNode(), + }; const { condition, choice, branches } = result; - return { - conditionNode: GraphNode.fromIndexedJson(condition), - choiceNode: GraphNode.fromIndexedJson(choice), - branchNodes: branches.map(x => GraphNode.fromIndexedJson(x)), + const nodeMap = { + [SwitchNodes.Switch]: GraphNode.fromIndexedJson(condition), + [SwitchNodes.Choice]: GraphNode.fromIndexedJson(choice), }; -}; -const calculateLayout = (nodeMap, boundaryMap) => { - [nodeMap.conditionNode, nodeMap.choiceNode, ...nodeMap.branchNodes] - .filter(x => !!x) - .forEach(x => (x.boundary = boundaryMap[x.id] || x.boundary)); + branches.forEach((branch, index) => { + const key = getCaseKey(index); + const value = GraphNode.fromIndexedJson(branch); + nodeMap[key] = value; + }); - return switchCaseLayouter(nodeMap.conditionNode, nodeMap.choiceNode, nodeMap.branchNodes); + return nodeMap; +}; + +const calculateLayout = (nodeMap: GraphNodeMap) => { + const { switchNode, choiceNode, ...cases } = nodeMap as GraphNodeMap; + const casesNodes = Object.keys(cases) + .sort() + .map(caseName => nodeMap[caseName]); + return switchCaseLayouter(switchNode, choiceNode, casesNodes); }; export interface SwitchConditionWidgetProps extends WidgetContainerProps { @@ -49,35 +71,25 @@ export const SwitchConditionWidget: FunctionComponent { - const [boundaryMap, setBoundaryMap] = useState({}); - const initialNodeMap = useMemo(() => calculateNodeMap(id, data), [id, data]); - const layout = useMemo(() => calculateLayout(initialNodeMap, boundaryMap), [initialNodeMap, boundaryMap]); - const accumulatedPatches = {}; - - const patchBoundary = (id, boundary) => { - if (!boundaryMap[id] || !areBoundariesEqual(boundaryMap[id], boundary)) { - accumulatedPatches[id] = boundary; - setBoundaryMap({ - ...boundaryMap, - ...accumulatedPatches, - }); - } - }; + const nodeMap = useMemo(() => calculateNodeMap(id, data), [id, data]); + const { layout, updateNodeBoundary } = useSmartLayout(nodeMap, calculateLayout, onResize); - useEffect(() => { - onResize(layout.boundary); - }, [layout]); - - const { boundary, nodeMap, edges } = layout; - const conditionNode = nodeMap.conditionNode; - const choiceNode = nodeMap.choiceNode; - const branchNodes = nodeMap.branchNodes || []; + const { boundary, edges } = layout; + const { switchNode, choiceNode, ...cases } = nodeMap as GraphNodeMap; + const casesNodes = Object.keys(cases).map(x => nodeMap[x]); return (
- - - {judgement} + + + { + designerCache.cacheBoundary(switchNode.data, boundary); + updateNodeBoundary(SwitchNodes.Switch, boundary); + }} + > + {judgement} + @@ -88,7 +100,7 @@ export const SwitchConditionWidget: FunctionComponent - {(branchNodes as any).map(x => ( + {(casesNodes as any).map((x, index) => ( { - patchBoundary(x.id, size); + updateNodeBoundary(getCaseKey(index), size); }} />