Skip to content

Commit

Permalink
feat: support multi-line node block in Visual Editor (#2005)
Browse files Browse the repository at this point in the history
* draft: DynamicBlock

* don't setState in effect hook

* set default demo to editor

* give decorators unique id

* implement ElementMeasurer for dynamic size

* pass onResize handler to enable boundary propagation

* update FormCard css to not truncate text

* apply ElementMeasurer to necessary widgets

* css: word break in FormCard

* support smart layout in all input types

* revert exp changes in uischema

* add new hook useSmartLayout  for layout changes

* apply useSmartLayouter to PromptWidgets

* apply useSmartLayout hook to IfConditionWidget

* include boundary merge logic in useSmartLayout

* IfCondition, Prompt adapt to new smart layout hook

* apply useSmartLayout to ForeachWidget

* apply useSmartLayout to SwitchConditionWidget

* add comments to ElementMeasurer

* add comments in useSmartLayout

* revert ObiEditor changes

* update FormCard style

* revert unnecessary changes

* revert unnecessary transformer changes

* fix IfConditionWidget layout

* fix a potential undefined func call

* improve ts type in Switch

* sort Switch case keys

* StepGroup apply useSmartLayouter

* feat: boundary cache mechanism to fix flickering

* remove size limit in uischema (deprecated by smart layouter)

* fix FormCard css compatibility

Co-authored-by: Andy Brown <asbrown002@gmail.com>
  • Loading branch information
yeze322 and a-b-r-o-w-n committed Feb 25, 2020
1 parent 9eeca92 commit 7b452d3
Show file tree
Hide file tree
Showing 15 changed files with 397 additions and 190 deletions.
1 change: 1 addition & 0 deletions Composer/packages/extensions/visual-designer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -18,54 +17,51 @@ 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<StepNodeKey> => {
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<StepNodeKey>);
};

const calculateLayout = (nodes, boundaryMap): GraphLayout => {
nodes.forEach((x): void => (x.boundary = boundaryMap[x.id] || x.boundary));
const calculateLayout = (nodeMap: GraphNodeMap<StepNodeKey>): GraphLayout => {
const nodes = Object.keys(nodeMap)
.sort()
.map(stepName => nodeMap[stepName]);
return sequentialLayouter(nodes);
};

export const StepGroup: FunctionComponent<NodeProps> = ({ 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 (
<div css={{ width: boundary.width, height: boundary.height, position: 'relative' }}>
<SVGContainer>{Array.isArray(edges) ? edges.map(x => renderEdge(x)) : null}</SVGContainer>
{nodes
? nodes.map(x => (
? nodes.map((x, index) => (
<OffsetContainer key={`stepGroup/${x.id}/offset`} offset={x.offset}>
<StepRenderer
key={`stepGroup/${x.id}`}
id={x.id}
data={x.data}
onEvent={onEvent}
onResize={size => {
patchBoundary(x.id, size);
designerCache.cacheBoundary(x.data, size);
updateNodeBoundary(getStepKey(index), size);
}}
/>
</OffsetContainer>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const contentHeight = boxHeight - headerHeight;

const containerStyle = {
width: boxWidth,
height: boxHeight,
minHeight: boxHeight,
fontSize: '12px',
cursor: 'pointer',
overflow: 'hidden',
Expand Down Expand Up @@ -92,17 +92,18 @@ export const FormCard: FunctionComponent<NodeProps> = ({
className="card__content"
css={{
width: '100%',
height: contentHeight,
minHeight: contentHeight,
display: 'inline-block',
}}
>
<div
css={{
fontWeight: 400,
paddingLeft: '5px',
margin: '3px 5px',
margin: '2px 5px',
fontSize: '14px',
lineHeight: '19px',
display: 'flex',
display: 'inline-flex',
alignItems: 'center',
}}
>
Expand All @@ -124,12 +125,13 @@ export const FormCard: FunctionComponent<NodeProps> = ({
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 : ''}
>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ElementMeasurerProps> = ({ children, style, onResize }) => {
return (
<Measure
bounds
onResize={({ bounds: { width, height } }) => {
onResize(new Boundary(width, height));
}}
>
{({ measureRef }) => (
<div ref={measureRef} style={style}>
{children}
</div>
)}
</Measure>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -27,19 +28,19 @@ const TypesWithoutWrapper = [
SDKTypes.ChoiceInput,
];

export const StepRenderer: FC<NodeProps> = ({ id, data, onEvent }): JSX.Element => {
export const StepRenderer: FC<NodeProps> = ({ 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 (
<ElementWrapper id={id} onEvent={onEvent}>
{content}
<ElementMeasurer onResize={boundary => onResize(boundary)}>{content}</ElementMeasurer>
</ElementWrapper>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -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<KeyType extends string, ValueType> = { [key in KeyType]: ValueType };

type BoundaryMap<T extends string> = MapWithEnumKey<T, Boundary>;

export type GraphNodeMap<T extends string> = MapWithEnumKey<T, GraphNode>;

export function useSmartLayout<T extends string>(
nodeMap: GraphNodeMap<T>,
layouter: (nodeMap: GraphNodeMap<T>) => GraphLayout,
onResize: (boundary: Boundary) => void
): {
layout: GraphLayout;
updateNodeBoundary: (nodeName: T, boundary: Boundary) => void;
} {
const [boundaryMap, setBoundaryMap] = useState<BoundaryMap<T>>({} as BoundaryMap<T>);
/**
* 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,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -41,7 +40,6 @@ const BaseInputSchema: UIWidget = {
menu: 'none',
content: data => data.property || '<property>',
children: data => (data.$type === SDKTypes.ChoiceInput ? <ChoiceInputChoices choices={data.choices} /> : null),
size: data => measureChoiceInputDetailBoundary(data),
colors: {
theme: ObiColors.LightBlue,
icon: ObiColors.AzureBlue,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -15,6 +17,9 @@ export interface UIWidgetContext {

/** Handle UI events */
onEvent: WidgetEventHandler;

/** Report widget boundary */
onResize: (boundary: Boundary) => void;
}

const parseWidgetSchema = (widgetSchema: UIWidget) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
Loading

0 comments on commit 7b452d3

Please sign in to comment.