feat: Reflow and Resize while Dragging and Resizing widgets. (#9054)

* resize n reflow rough cut

* removing warnings

* relatively stable changes

* minor bug fix

* reflow relative collision

* working dp cut

* fix for reflow of widgets closer next to each other

* disabling scroll

* Drag with reflow

* reflow fix

* overlap and retracing fix

* On Drop updates.

* bug when no displacement but resize update.

* temp fix for new widget addition.

* reflow bug fixes

* new widget addition bug.

* stop reflow on leave.

* fix corner case overlap

* update bottom row when reflowed widgets go beyond bottom boundary.

* capture mouse positions on enter

* enable container jumps with faster mouse movements.

* reflow only for snap changes.

* restructured reflow Algorithm

* collision check and bug fixes

* undo redo fix for new widget drop

* resizable fix snapRows fix

* directional stability

* self collision fix

* first round of perf fixes

* update bottom row while resizing and resize-reflowing

* performance fix and overlapping fix

* Remove eslint warning

* remove eslint warning

* eslint warning

* can reflowed Drop Indication Stability

* container jumps and force direction on entering canvas

* fixing scroll on resize jitters.

* reflow when jumping into container.

* reflow ux fixes while leaving container

* resizing fixes.

* fixes for edge move.

* restrict container jumps into reflowed containers.

* container jump direction reflow

* checkbox dimensions fix.

* Excess bottom rows not lost post dragging or resizing widgets.

* fixing the after drop css glitch.

* double first move trigger bug fix.

* stop reflow only if reflowing

* stabilize container exit directions

* using acceleration and speed instead of movement covered to restrict reflow.

* fixing modal drops.

* remove warnings.

* reflow resize styles

* moving acceleration and movement logic to a monitoring effect.

* adding beta flag for reflow.

* fixing jest tests

* Adding analytics to beta flag toggle.

* Adding placeholder for reflow beta screens.

* fixing initial load's screen

* few more crashes.

* force close onboarding for the session.

* fixing bugs in reset canvas.

* Beta flag bug fixes.

* fixing bugs.

* restrict reflow screens during onboarding.

* disabling reflow screens in tests.

* code review comments.

* fixing store based specs.

* fixing cypress failures.

* fixing specs.

* code cleanup

* reverting yarn lock changes

* removing onboarding screens.

* more cleanup and function descriptors

* keeping reflow under the hood.

Co-authored-by: rahulramesha <rahul@appsmith.com>
Co-authored-by: Arpit Mohan <arpit@appsmith.com>
This commit is contained in:
Ashok Kumar M 2022-01-13 18:51:57 +05:30 committed by GitHub
parent cf59d4d10e
commit 0149085bf8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
45 changed files with 4342 additions and 252 deletions

View File

@ -32,7 +32,6 @@ const jsEditorLocators = require("../locators/JSEditor.json");
const queryLocators = require("../locators/QueryEditor.json");
const welcomePage = require("../locators/welcomePage.json");
const publishWidgetspage = require("../locators/publishWidgetspage.json");
let pageidcopy = " ";
const chainStart = Symbol();
@ -833,7 +832,7 @@ Cypress.Commands.add("SearchEntityandDblClick", (apiname1) => {
return cy
.get(commonlocators.entitySearchResult.concat(apiname1).concat("')"))
.dblclick()
.get("input")
.get("input[type=text]")
.last();
});

View File

@ -253,6 +253,8 @@ export type WidgetResize = {
rightColumn: number;
topRow: number;
bottomRow: number;
snapColumnSpace: number;
snapRowSpace: number;
};
export type ModalWidgetResize = {

View File

@ -0,0 +1,27 @@
import {
ReduxAction,
ReflowReduxActionTypes,
} from "constants/ReduxActionConstants";
import { ReflowedSpaceMap } from "reflow/reflowTypes";
export const reflowMoveAction = (
payload: ReflowedSpaceMap,
): ReduxAction<ReflowedSpaceMap> => {
return {
type: ReflowReduxActionTypes.REFLOW_MOVE,
payload: payload,
};
};
export const stopReflowAction = () => {
return {
type: ReflowReduxActionTypes.STOP_REFLOW,
};
};
export const setEnableReflowAction = (payload: boolean) => {
return {
type: ReflowReduxActionTypes.ENABLE_REFLOW,
payload,
};
};

View File

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="36" height="20" fill="none" viewBox="0 0 36 20"><path fill="#716E6E" d="M8.38184 14C9.88574 14 10.8037 13.2334 10.8037 11.998C10.8037 11.0801 10.1494 10.3965 9.20215 10.3086V10.2207C9.91016 10.1084 10.457 9.4541 10.457 8.71191C10.457 7.62793 9.65137 6.9541 8.31348 6.9541H5.36914V14H8.38184ZM6.62891 7.95508H8.00586C8.76758 7.95508 9.21191 8.31641 9.21191 8.93652C9.21191 9.57129 8.73828 9.91797 7.84473 9.91797H6.62891V7.95508ZM6.62891 12.999V10.8262H8.04492C9.00684 10.8262 9.51465 11.1973 9.51465 11.9004C9.51465 12.6182 9.02148 12.999 8.09375 12.999H6.62891ZM17.1363 12.9111H13.8355V10.9385H16.9557V9.91309H13.8355V8.04297H17.1363V6.9541H12.5758V14H17.1363V12.9111ZM22.1115 14V8.04297H24.2844V6.9541H18.6838V8.04297H20.8518V14H22.1115ZM29.7674 14H31.1199L28.615 6.9541H27.199L24.699 14H25.9734L26.5789 12.1982H29.1717L29.7674 14ZM27.8387 8.29688H27.9217L28.8787 11.2021H26.8719L27.8387 8.29688Z"/><rect width="34" height="19" x="1" y=".5" stroke="#716E6E" rx="1.5"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="36" height="20" fill="none" viewBox="0 0 36 20"><rect width="34" height="19" x="1" y=".5" stroke="#716E6E" rx="1.5"/><path fill="#716E6E" d="M8.38184 14C9.88574 14 10.8037 13.2334 10.8037 11.998C10.8037 11.0801 10.1494 10.3965 9.20215 10.3086V10.2207C9.91016 10.1084 10.457 9.4541 10.457 8.71191C10.457 7.62793 9.65137 6.9541 8.31348 6.9541H5.36914V14H8.38184ZM6.62891 7.95508H8.00586C8.76758 7.95508 9.21191 8.31641 9.21191 8.93652C9.21191 9.57129 8.73828 9.91797 7.84473 9.91797H6.62891V7.95508ZM6.62891 12.999V10.8262H8.04492C9.00684 10.8262 9.51465 11.1973 9.51465 11.9004C9.51465 12.6182 9.02148 12.999 8.09375 12.999H6.62891ZM17.1363 12.9111H13.8355V10.9385H16.9557V9.91309H13.8355V8.04297H17.1363V6.9541H12.5758V14H17.1363V12.9111ZM22.1115 14V8.04297H24.2844V6.9541H18.6838V8.04297H20.8518V14H22.1115ZM29.7674 14H31.1199L28.615 6.9541H27.199L24.699 14H25.9734L26.5789 12.1982H29.1717L29.7674 14ZM27.8387 8.29688H27.9217L28.8787 11.2021H26.8719L27.8387 8.29688Z"/></svg>

Before

Width:  |  Height:  |  Size: 1023 B

After

Width:  |  Height:  |  Size: 1023 B

View File

@ -21,8 +21,8 @@ function ShowcaseCarouselModal({ children }: { children: React.ReactNode }) {
dispatch(hideCommentsIntroCarousel());
AnalyticsUtil.logEvent("COMMENTS_ONBOARDING_MODAL_DISMISSED");
}}
overlayClassName="comments-onboarding-carousel"
portalClassName="comments-onboarding-carousel-portal"
overlayClassName="onboarding-carousel"
portalClassName="onboarding-carousel-portal"
scrollContents
width={325}
zIndex={Layers.appComments}

View File

@ -8,7 +8,9 @@ import { usePositionedContainerZIndex } from "utils/hooks/usePositionedContainer
import { useSelector } from "react-redux";
import { snipingModeSelector } from "selectors/editorSelectors";
import WidgetFactory from "utils/WidgetFactory";
import { memoize } from "lodash";
import { isEqual, memoize } from "lodash";
import { getReflowSelector } from "selectors/widgetReflowSelectors";
import { AppState } from "reducers";
const PositionedWidget = styled.div<{ zIndexOnHover: number }>`
&:hover {
@ -18,6 +20,7 @@ const PositionedWidget = styled.div<{ zIndexOnHover: number }>`
export type PositionedContainerProps = {
style: BaseStyle;
children: ReactNode;
parentId?: string;
widgetId: string;
widgetType: WidgetType;
selected?: boolean;
@ -54,18 +57,70 @@ export function PositionedContainer(props: PositionedContainerProps) {
isDropTarget,
);
const reflowSelector = getReflowSelector(props.widgetId);
const reflowedPosition = useSelector(reflowSelector, isEqual);
const dragDetails = useSelector(
(state: AppState) => state.ui.widgetDragResize.dragDetails,
);
const isResizing = useSelector(
(state: AppState) => state.ui.widgetDragResize.isResizing,
);
const isCurrentCanvasReflowing =
(dragDetails && dragDetails.draggedOn === props.parentId) || isResizing;
const containerStyle: CSSProperties = useMemo(() => {
return {
const reflowX = reflowedPosition?.X || 0;
const reflowY = reflowedPosition?.Y || 0;
const reflowWidth = reflowedPosition?.width;
const reflowHeight = reflowedPosition?.height;
const reflowEffected = isCurrentCanvasReflowing && reflowedPosition;
const hasReflowedPosition = reflowEffected && reflowX + reflowY !== 0;
const hasReflowedDimensions =
reflowEffected &&
((reflowHeight && reflowHeight !== props.style.componentHeight) ||
(reflowWidth && reflowWidth !== props.style.componentWidth));
const effectedByReflow = hasReflowedPosition || hasReflowedDimensions;
const dropTargetStyles: CSSProperties =
isDropTarget && effectedByReflow ? { pointerEvents: "none" } : {};
const reflowedPositionStyles: CSSProperties = hasReflowedPosition
? {
transform: `translate(${reflowX}px,${reflowY}px)`,
transition: `transform 100ms linear`,
boxShadow: `0 0 0 1px rgba(104,113,239,0.5)`,
}
: {};
const reflowDimensionsStyles = hasReflowedDimensions
? {
transition: `width 0.1s, height 0.1s`,
boxShadow: `0 0 0 1px rgba(104,113,239,0.5)`,
}
: {};
const styles: CSSProperties = {
position: "absolute",
left: x,
top: y,
height: props.style.componentHeight + (props.style.heightUnit || "px"),
width: props.style.componentWidth + (props.style.widthUnit || "px"),
height:
reflowHeight ||
props.style.componentHeight + (props.style.heightUnit || "px"),
width:
reflowWidth ||
props.style.componentWidth + (props.style.widthUnit || "px"),
padding: padding + "px",
zIndex,
backgroundColor: "inherit",
...reflowedPositionStyles,
...reflowDimensionsStyles,
...dropTargetStyles,
};
}, [props.style, onHoverZIndex, zIndex]);
return styles;
}, [
props.style,
isCurrentCanvasReflowing,
onHoverZIndex,
zIndex,
reflowSelector,
reflowedPosition,
]);
const onClickFn = useCallback(
(e) => {

View File

@ -100,6 +100,8 @@ export function DropTargetComponent(props: DropTargetComponentProps) {
const showPropertyPane = useShowPropertyPane();
const { deselectAll, focusWidget } = useWidgetSelection();
const updateCanvasSnapRows = useCanvasSnapRowsUpdateHook();
const showDragLayer =
(isDragging && draggedOn === props.widgetId) || isResizing;
useEffect(() => {
const snapRows = getCanvasSnapRows(props.bottomRow, props.canExtend);
@ -111,6 +113,16 @@ export function DropTargetComponent(props: DropTargetComponentProps) {
}
}
}, [props.bottomRow, props.canExtend]);
useEffect(() => {
if (!isDragging || !isResizing) {
// bottom row of canvas can increase by any number as user moves/resizes any widget towards the bottom of the canvas
// but canvas height is not lost when user moves/resizes back top.
// it is done that way to have a pleasant building experience.
// post drop the bottom most row is used to appropriately calculate the canvas height and lose unwanted height.
rowRef.current = snapRows;
updateHeight();
}
}, [isDragging, isResizing]);
const updateHeight = () => {
if (dropTargetRef.current) {
@ -165,7 +177,6 @@ export function DropTargetComponent(props: DropTargetComponentProps) {
updateDropTargetRows,
};
}, [updateDropTargetRows, occupiedSpacesByChildren]);
return (
<DropTargetContext.Provider value={contextValue}>
<StyledDropTarget
@ -181,7 +192,7 @@ export function DropTargetComponent(props: DropTargetComponentProps) {
{!(childWidgets && childWidgets.length) &&
!isDragging &&
!props.parentId && <Onboarding />}
{((isDragging && draggedOn === props.widgetId) || isResizing) && (
{showDragLayer && (
<DragLayerComponent
noPad={props.noPad || false}
parentColumnWidth={props.snapColumnSpace}

View File

@ -1,4 +1,4 @@
import React, { useContext, useRef, memo, useMemo } from "react";
import React, { useContext, memo, useMemo } from "react";
import { XYCord } from "utils/hooks/useCanvasDragging";
import {
@ -7,8 +7,6 @@ import {
WidgetProps,
} from "widgets/BaseWidget";
import { EditorContext } from "components/editorComponents/EditorContextProvider";
import { generateClassName } from "utils/generators";
import { DropTargetContext } from "./DropTargetComponent";
import {
UIElementSize,
computeFinalRowCols,
@ -21,9 +19,9 @@ import {
} from "utils/hooks/dragResizeHooks";
import { useSelector } from "react-redux";
import { AppState } from "reducers";
import Resizable from "resizable";
import { omit, get, ceil } from "lodash";
import { getSnapColumns, isDropZoneOccupied } from "utils/WidgetPropsUtils";
import Resizable from "resizable/resizenreflow";
import { omit, get } from "lodash";
import { getSnapColumns } from "utils/WidgetPropsUtils";
import {
VisibilityContainer,
LeftHandleStyles,
@ -36,18 +34,17 @@ import {
BottomRightHandleStyles,
} from "./ResizeStyledComponents";
import AnalyticsUtil from "utils/AnalyticsUtil";
import { scrollElementIntoParentCanvasView } from "utils/helpers";
import { getNearestParentCanvas } from "utils/generators";
import {
getOccupiedSpaces,
previewModeSelector,
} from "selectors/editorSelectors";
import { commentModeSelector } from "selectors/commentsSelectors";
import { snipingModeSelector } from "selectors/editorSelectors";
import {
previewModeSelector,
snipingModeSelector,
} from "selectors/editorSelectors";
import { useWidgetSelection } from "utils/hooks/useWidgetSelection";
import { getCanvasWidgets } from "selectors/entitiesSelector";
import { focusWidget } from "actions/widgetActions";
import { getParentToOpenIfAny } from "utils/hooks/useClickToSelectWidget";
import { GridDefaults } from "constants/WidgetConstants";
import { DropTargetContext } from "./DropTargetComponent";
export type ResizableComponentProps = WidgetProps & {
paddingOffset: number;
@ -56,14 +53,10 @@ export type ResizableComponentProps = WidgetProps & {
export const ResizableComponent = memo(function ResizableComponent(
props: ResizableComponentProps,
) {
const resizableRef = useRef<HTMLDivElement>(null);
// Fetch information from the context
const { updateWidget } = useContext(EditorContext);
const occupiedSpaces = useSelector(getOccupiedSpaces);
const canvasWidgets = useSelector(getCanvasWidgets);
const { updateDropTargetRows } = useContext(DropTargetContext);
const isCommentMode = useSelector(commentModeSelector);
const isSnipingMode = useSelector(snipingModeSelector);
const isPreviewMode = useSelector(previewModeSelector);
@ -93,13 +86,6 @@ export const ResizableComponent = memo(function ResizableComponent(
canvasWidgets,
);
const occupiedSpacesBySiblingWidgets = useMemo(() => {
return occupiedSpaces && props.parentId && occupiedSpaces[props.parentId]
? occupiedSpaces[props.parentId]
: undefined;
}, [occupiedSpaces, props.parentId]);
// isFocused (string | boolean) -> isWidgetFocused (boolean)
const isWidgetFocused =
focusedWidget === props.widgetId ||
selectedWidget === props.widgetId ||
@ -116,116 +102,56 @@ export const ResizableComponent = memo(function ResizableComponent(
2 * props.paddingOffset,
};
// Resize bound's className - defaults to body
// ResizableContainer accepts the className of the element,
// whose clientRect will act as the bounds for resizing.
// Note, if there are many containers with the same className
// the bounding container becomes the nearest parent with the className
const boundingElementClassName = generateClassName(props.parentId);
const possibleBoundingElements = document.getElementsByClassName(
boundingElementClassName,
);
const boundingElement =
possibleBoundingElements.length > 0
? possibleBoundingElements[0]
: undefined;
// onResize handler
// Checks if the current resize position has any collisions
// If yes, set isColliding flag to true.
// If no, set isColliding flag to false.
const isColliding = (newDimensions: UIElementSize, position: XYCord) => {
// Moving the bounding element calculations inside
// to make this expensive operation only whne
const boundingElementClientRect = boundingElement
? boundingElement.getBoundingClientRect()
: undefined;
const bottom =
props.topRow +
position.y / props.parentRowSpace +
newDimensions.height / props.parentRowSpace;
// Make sure to calculate collision IF we don't update the main container's rows
let updated = false;
if (updateDropTargetRows) {
updated = !!updateDropTargetRows(props.widgetId, bottom);
const el = resizableRef.current;
if (el) {
const { height } = el?.getBoundingClientRect();
const scrollParent = getNearestParentCanvas(resizableRef.current);
scrollElementIntoParentCanvasView(
{
top: 40,
height,
},
scrollParent,
el,
);
}
}
const getResizedPositions = (
newDimensions: UIElementSize,
position: XYCord,
) => {
const delta: UIElementSize = {
height: newDimensions.height - dimensions.height,
width: newDimensions.width - dimensions.width,
};
const newRowCols: WidgetRowCols | false = computeRowCols(
delta,
position,
props,
);
if (newRowCols.rightColumn > getSnapColumns()) {
return true;
}
// Minimum row and columns to be set to a widget.
if (
newRowCols.rightColumn - newRowCols.leftColumn < 2 ||
newRowCols.bottomRow - newRowCols.topRow < 4
) {
return true;
}
if (
boundingElementClientRect &&
newRowCols.rightColumn * props.parentColumnSpace >
ceil(boundingElementClientRect.width)
) {
return true;
}
if (newRowCols && newRowCols.leftColumn < 0) {
return true;
}
if (!updated) {
if (
boundingElementClientRect &&
newRowCols.bottomRow * props.parentRowSpace >
ceil(boundingElementClientRect.height)
) {
return true;
}
if (newRowCols && newRowCols.topRow < 0) {
return true;
}
}
const newRowCols: WidgetRowCols = computeRowCols(delta, position, props);
let canResizeHorizontally = true,
canResizeVertically = true;
// this is required for list widget so that template have no collision
if (props.ignoreCollision) return false;
if (props.ignoreCollision)
return {
canResizeHorizontally,
canResizeVertically,
};
if (
newRowCols &&
(newRowCols.rightColumn > getSnapColumns() ||
newRowCols.leftColumn < 0 ||
newRowCols.rightColumn - newRowCols.leftColumn < 2)
) {
canResizeHorizontally = false;
}
if (
newRowCols &&
(newRowCols.topRow < 0 || newRowCols.bottomRow - newRowCols.topRow < 4)
) {
canResizeVertically = false;
}
const resizedPositions = {
id: props.widgetId,
left: newRowCols.leftColumn,
top: newRowCols.topRow,
bottom: newRowCols.bottomRow,
right: newRowCols.rightColumn,
};
// Check if new row cols are occupied by sibling widgets
return isDropZoneOccupied(
{
left: newRowCols.leftColumn,
top: newRowCols.topRow,
bottom: newRowCols.bottomRow,
right: newRowCols.rightColumn,
},
props.widgetId,
occupiedSpacesBySiblingWidgets,
);
return {
canResizeHorizontally,
canResizeVertically,
resizedPositions,
};
};
// onResizeStop handler
@ -254,6 +180,8 @@ export const ResizableComponent = memo(function ResizableComponent(
updateWidget(WidgetOperations.RESIZE, props.widgetId, {
...newRowCols,
parentId: props.parentId,
snapColumnSpace: props.parentColumnSpace,
snapRowSpace: props.parentRowSpace,
});
}
// Tell the Canvas that we've stopped resizing
@ -328,6 +256,27 @@ export const ResizableComponent = memo(function ResizableComponent(
selectedWidgets &&
selectedWidgets.length > 1 &&
selectedWidgets.includes(props.widgetId);
const { updateDropTargetRows } = useContext(DropTargetContext);
const gridProps = {
parentColumnSpace: props.parentColumnSpace,
parentRowSpace: props.parentRowSpace,
paddingOffset: props.paddingOffset,
maxGridColumns: GridDefaults.DEFAULT_GRID_COLUMNS,
};
const originalPositions = {
id: props.widgetId,
left: props.leftColumn,
top: props.topRow,
bottom: props.bottomRow,
right: props.rightColumn,
};
const updateBottomRow = (bottom: number) => {
if (props.parentId) {
updateDropTargetRows && updateDropTargetRows(props.parentId, bottom);
}
};
return (
<Resizable
@ -335,12 +284,16 @@ export const ResizableComponent = memo(function ResizableComponent(
componentHeight={dimensions.height}
componentWidth={dimensions.width}
enable={isEnabled}
getResizedPositions={getResizedPositions}
gridProps={gridProps}
handles={handles}
isColliding={isColliding}
onStart={handleResizeStart}
onStop={updateSize}
ref={resizableRef}
originalPositions={originalPositions}
parentId={props.parentId}
snapGrid={{ x: props.parentColumnSpace, y: props.parentRowSpace }}
updateBottomRow={updateBottomRow}
widgetId={props.widgetId}
// Used only for performance tracking, can be removed after optimization.
zWidgetId={props.widgetId}
zWidgetType={props.type}

View File

@ -6,10 +6,23 @@ export type UIElementSize = { height: number; width: number };
export const RESIZABLE_CONTAINER_BORDER_THEME_INDEX = 1;
export type WidgetPosition = {
rightColumn: number;
leftColumn: number;
bottomRow: number;
topRow: number;
parentRowSpace: number;
parentColumnSpace: number;
};
export type WidgetExtendedPosition = WidgetPosition & {
paddingOffset: number;
};
export const computeRowCols = (
delta: UIElementSize,
position: XYCord,
props: WidgetProps,
props: WidgetPosition,
) => {
return {
leftColumn: Math.round(

View File

@ -7,6 +7,16 @@ export type OccupiedSpace = {
parentId?: string;
};
export type WidgetSpace = {
left: number;
right: number;
top: number;
bottom: number;
id: string;
type: string;
parentId?: string;
};
export const zIndexLayers = {
PROPERTY_PANE: "z-3",
ENTITY_EXPLORER: "z-3",

View File

@ -231,6 +231,7 @@ export const ReduxActionTypes = {
WIDGET_CHILD_ADDED: "WIDGET_CHILD_ADDED",
WIDGET_REMOVE_CHILD: "WIDGET_REMOVE_CHILD",
WIDGETS_MOVE: "WIDGETS_MOVE",
WIDGETS_ADD_CHILD_AND_MOVE: "WIDGETS_ADD_CHILD_AND_MOVE",
WIDGET_RESIZE: "WIDGET_RESIZE",
WIDGET_DELETE: "WIDGET_DELETE",
WIDGET_BULK_DELETE: "WIDGET_BULK_DELETE",
@ -810,6 +811,7 @@ export const ReduxActionErrorTypes = {
RESTART_SERVER_ERROR: "RESTART_SERVER_ERROR",
UPDATE_JS_ACTION_BODY_ERROR: "UPDATE_JS_ACTION_BODY_ERROR",
DELETE_ORG_ERROR: "DELETE_ORG_ERROR",
REFLOW_BETA_FLAGS_INIT_ERROR: "REFLOW_BETA_FLAGS_INIT_ERROR",
};
export const ReduxFormActionTypes = {
@ -824,6 +826,14 @@ export enum ReplayReduxActionTypes {
REDO = "redo",
}
export const ReflowReduxActionTypes = {
STOP_REFLOW: "STOP_REFLOW",
REFLOW_MOVE: "REFLOW_MOVE",
ENABLE_REFLOW: "ENABLE_REFLOW",
ONBOARDING_UPDATE: "ONBOARDING_UPDATE",
FORCE_STOP_ON_BOARDING: "FORCE_STOP_ON_BOARDING",
};
export const WidgetReduxActionTypes: { [key: string]: string } = {
WIDGET_ADD_CHILD: "WIDGET_ADD_CHILD",
WIDGET_CHILD_ADDED: "WIDGET_CHILD_ADDED",

View File

@ -19,10 +19,10 @@ export const PopoverStyles = createGlobalStyle`
min-width: 233px !important ;
}
}
.comments-onboarding-carousel .${Classes.OVERLAY_CONTENT} {
.onboarding-carousel .${Classes.OVERLAY_CONTENT} {
filter: drop-shadow(0px 6px 20px rgba(0, 0, 0, 0.15));
}
.bp3-modal-widget.comments-onboarding-carousel-portal {
.bp3-modal-widget.onboarding-carousel-portal {
z-index: ${Layers.help} !important;
}

View File

@ -19,6 +19,7 @@ import lodash from "lodash";
import { getAbsolutePixels } from "utils/helpers";
import { UpdatedMainContainer } from "test/testMockedWidgets";
import { AppState } from "reducers";
import { generateReactKey } from "utils/generators";
const renderNestedComponent = () => {
const initialState = (store.getState() as unknown) as Partial<AppState>;
@ -626,16 +627,29 @@ describe("Drag and Drop widgets into Main container", () => {
it("Disallow drag if widget not focused", () => {
const initialState = (store.getState() as unknown) as Partial<AppState>;
const containerId = generateReactKey();
const canvasId = generateReactKey();
const children: any = buildChildren([
const canvasWidget = buildChildren([
{
type: "CANVAS_WIDGET",
parentId: containerId,
children: [],
widgetId: canvasId,
dropDisabled: true,
},
]);
const containerChildren: any = buildChildren([
{
type: "CONTAINER_WIDGET",
children: canvasWidget,
widgetId: containerId,
parentId: "0",
},
]);
const dsl: any = widgetCanvasFactory.build({
children,
children: containerChildren,
});
spyGetCanvasWidgetDsl.mockImplementation(mockGetCanvasWidgetDsl);

View File

@ -16,6 +16,16 @@ import TooltipComponent from "components/ads/Tooltip";
import Icon, { IconName, IconSize } from "components/ads/Icon";
import { updateApplicationLayout } from "actions/applicationActions";
import { setEnableReflowAction } from "actions/reflowActions";
import Checkbox from "components/ads/Checkbox";
import { ReactComponent as BetaIcon } from "assets/icons/menu/beta.svg";
import styled from "styled-components";
import { isReflowEnabled } from "selectors/widgetReflowSelectors";
import { setReflowBetaFlag } from "utils/storage";
import AnalyticsUtil from "utils/AnalyticsUtil";
import { getCurrentUser } from "selectors/usersSelectors";
import { User } from "constants/userConstants";
interface AppsmithLayoutConfigOption {
name: string;
type: SupportedLayouts;
@ -53,12 +63,26 @@ const AppsmithLayouts: AppsmithLayoutConfigOption[] = [
icon: "mobile",
},
];
const ReflowBetaWrapper = styled.div`
display: inline-flex;
flex-direction: row;
.beta-icon {
fill: #feb811;
rect {
stroke: #feb811;
}
path {
fill: #fff;
}
}
`;
export function MainContainerLayoutControl() {
const dispatch = useDispatch();
const appId = useSelector(getCurrentApplicationId);
const appLayout = useSelector(getCurrentApplicationLayout);
const shouldResize = useSelector(isReflowEnabled);
const user: User | undefined = useSelector(getCurrentUser);
/**
* return selected layout. if there is no app
* layout, use the default one ( fluid )
@ -89,6 +113,18 @@ export function MainContainerLayoutControl() {
[dispatch, appLayout],
);
const reflowBetaToggle = (isChecked: boolean) => {
if (user?.email) {
setReflowBetaFlag(user.email, isChecked);
}
dispatch(setEnableReflowAction(isChecked));
AnalyticsUtil.logEvent("REFLOW_BETA_FLAG", {
enabled: isChecked,
});
};
const appsmithEmailRegex = /@appsmith.com/g;
const canReflow = user && user.email && appsmithEmailRegex.test(user.email);
return (
<div className="px-3 space-y-2 t--layout-control-wrapper">
<p className="text-sm text-gray-700">Canvas Size</p>
@ -123,6 +159,16 @@ export function MainContainerLayoutControl() {
);
})}
</div>
{canReflow && (
<ReflowBetaWrapper>
<Checkbox
isDefaultChecked={shouldResize}
label="New Reflow & Resize"
onCheckChange={reflowBetaToggle}
/>
<BetaIcon className="beta-icon" />
</ReflowBetaWrapper>
)}
</div>
);
}

View File

@ -32,6 +32,7 @@ export interface CanvasDraggingArenaProps {
snapColumnSpace: number;
snapRows: number;
snapRowSpace: number;
parentId?: string;
widgetId: string;
}
@ -39,6 +40,7 @@ export function CanvasDraggingArena({
canExtend,
dropDisabled = false,
noPad,
parentId = "",
snapColumnSpace,
snapRows,
snapRowSpace,
@ -54,6 +56,7 @@ export function CanvasDraggingArena({
canExtend,
dropDisabled,
noPad,
parentId,
snapColumnSpace,
snapRows,
snapRowSpace,

View File

@ -150,8 +150,8 @@ export function CanvasSelectionArena({
useCanvasDragToScroll(
canvasRef,
isCurrentWidgetDrawing,
isDraggingForSelection,
isCurrentWidgetDrawing || isResizing,
isDraggingForSelection || isResizing,
snapRows,
canExtend,
);
@ -446,15 +446,11 @@ export function CanvasSelectionArena({
snapRowSpace,
]);
// Resizing state still shows selection arena to aid with scroll behavior
const shouldShow =
appMode === APP_MODE.EDIT &&
!(
isDragging ||
isResizing ||
isCommentMode ||
isPreviewMode ||
dropDisabled
);
!(isDragging || isCommentMode || isPreviewMode || dropDisabled);
return shouldShow ? (
<StyledSelectionCanvas

View File

@ -54,6 +54,7 @@ import { GitSyncReducerState } from "./uiReducers/gitSyncReducer";
import { AppCollabReducerState } from "./uiReducers/appCollabReducer";
import { CrudInfoModalReduxState } from "./uiReducers/crudInfoModalReducer";
import { FormEvaluationState } from "./evaluationReducers/formEvaluationReducer";
import { widgetReflowState } from "./uiReducers/reflowReducer";
import SettingsReducer, { SettingsReduxState } from "./settingsReducer";
const appReducer = combineReducers({
@ -106,6 +107,7 @@ export interface AppState {
gitSync: GitSyncReducerState;
appCollab: AppCollabReducerState;
crudInfoModal: CrudInfoModalReduxState;
widgetReflow: widgetReflowState;
};
entities: {
canvasWidgets: CanvasWidgetsReduxState;

View File

@ -36,6 +36,7 @@ import appCollabReducer from "./appCollabReducer";
import canvasSelectionReducer from "./canvasSelectionReducer";
import gitSyncReducer from "./gitSyncReducer";
import crudInfoModalReducer from "./crudInfoModalReducer";
import { widgetReflowReducer } from "./reflowReducer";
import jsObjectNameReducer from "./jsObjectNameReducer";
const uiReducer = combineReducers({
@ -77,6 +78,7 @@ const uiReducer = combineReducers({
gitSync: gitSyncReducer,
appCollab: appCollabReducer,
crudInfoModal: crudInfoModalReducer,
widgetReflow: widgetReflowReducer,
});
export default uiReducer;

View File

@ -0,0 +1,52 @@
import { createReducer } from "utils/AppsmithUtils";
import {
ReduxAction,
ReflowReduxActionTypes,
} from "constants/ReduxActionConstants";
import { ReflowedSpaceMap } from "reflow/reflowTypes";
const initialState: widgetReflowState = {
isReflowing: false,
reflowingWidgets: {},
enableReflow: true,
};
export const widgetReflowReducer = createReducer(initialState, {
[ReflowReduxActionTypes.STOP_REFLOW]: ({
enableReflow,
}: widgetReflowState) => {
return {
isReflowing: false,
enableReflow,
};
},
[ReflowReduxActionTypes.REFLOW_MOVE]: (
{ enableReflow }: widgetReflowState,
action: ReduxAction<{ reflowingWidgets: ReflowedSpaceMap }>,
) => {
return {
isReflowing: true,
reflowingWidgets: { ...action.payload },
enableReflow,
};
},
[ReflowReduxActionTypes.ENABLE_REFLOW]: (
state: widgetReflowState,
action: ReduxAction<boolean>,
) => {
return { ...state, enableReflow: action.payload };
},
});
export type widgetReflow = {
isReflowing: boolean;
reflowingWidgets: ReflowedSpaceMap;
};
export type widgetReflowState = widgetReflow & {
enableReflow: boolean;
};
export type Reflow = {
reflowingWidgets?: ReflowedSpaceMap;
};

View File

@ -0,0 +1,90 @@
import { OccupiedSpace } from "constants/CanvasEditorConstants";
import { getMovementMap } from "./reflowHelpers";
import { CollidingSpaceMap, GridProps, ReflowDirection } from "./reflowTypes";
import {
changeExitContainerDirection,
filterSpaceById,
getCollidingSpaces,
getDelta,
getIsHorizontalMove,
getShouldReflow,
} from "./reflowUtils";
/**
* Reflow method that returns the displacement metrics of all other colliding spaces
*
* @param newPositions new/current positions of the space/block
* @param OGPositions original positions of the space before movement
* @param occupiedSpaces array of all the occupied spaces on the canvas
* @param direction direction of movement of the moving space
* @param gridProps properties of the canvas's grid
* @param forceDirection boolean to force the direction on certain scenarioes
* @param shouldResize boolean to indicate if colliding spaces should resize
* @param immediateExitContainer boolean to indicate if the space exitted a nested canvas
* @param prevPositions last known position of the space
* @param prevCollidingSpaces last known colliding spaces of the dragging/resising space
* @returns movement information of the dragging/resizing space and other colliding spaces
*/
export function reflow(
newPositions: OccupiedSpace,
OGPositions: OccupiedSpace,
occupiedSpaces: OccupiedSpace[],
direction: ReflowDirection,
gridProps: GridProps,
forceDirection = false,
shouldResize = true,
immediateExitContainer?: string,
prevPositions?: OccupiedSpace,
prevCollidingSpaces?: CollidingSpaceMap,
) {
const isHorizontalMove = getIsHorizontalMove(newPositions, prevPositions);
const filteredOccupiedSpace = filterSpaceById(
newPositions.id,
occupiedSpaces,
);
const { collidingSpaceMap, isColliding } = getCollidingSpaces(
newPositions,
direction,
filteredOccupiedSpace,
isHorizontalMove,
prevPositions,
prevCollidingSpaces,
forceDirection,
);
if (!isColliding || !OGPositions || direction === ReflowDirection.UNSET) {
return {
movementLimit: {
canHorizontalMove: true,
canVerticalMove: true,
},
};
}
changeExitContainerDirection(
collidingSpaceMap,
immediateExitContainer,
direction,
);
const delta = getDelta(OGPositions, newPositions, direction);
const { movementMap, newPositionsMovement } = getMovementMap(
filteredOccupiedSpace,
newPositions,
collidingSpaceMap,
gridProps,
delta,
shouldResize,
);
const movementLimit = getShouldReflow(newPositionsMovement, delta);
return {
movementLimit,
movementMap,
newPositionsMovement,
collidingSpaceMap,
};
}

View File

@ -0,0 +1,466 @@
import { OccupiedSpace } from "constants/CanvasEditorConstants";
import { GridDefaults } from "constants/WidgetConstants";
import { isEmpty } from "lodash";
import {
CollidingSpace,
CollidingSpaceMap,
CollisionAccessors,
CollisionTree,
Delta,
DirectionalMovement,
GridProps,
HORIZONTAL_RESIZE_LIMIT,
ReflowDirection,
ReflowedSpaceMap,
VERTICAL_RESIZE_LIMIT,
} from "./reflowTypes";
import {
getAccessor,
getCollidingSpacesInDirection,
getMaxX,
getMaxY,
getReflowDistance,
getResizedDimension,
getResizedDimensions,
shouldReplaceOldMovement,
sortCollidingSpacesByDistance,
} from "./reflowUtils";
/**
* returns movement map of all the cascading colliding spaces
* @param occupiedSpaces array of all the occupied spaces on the canvas
* @param newPositions new/current positions of the space/block
* @param collidingSpaceMap Map of Colliding spaces of the dragging/resizing space
* @param gridProps properties of the canvas's grid
* @param delta X and Y coordinate displacement of the newPosition from the original position
* @param shouldResize boolean to indicate if colliding spaces should resize
* @returns movement map of all the cascading colliding spaces
*/
export function getMovementMap(
occupiedSpaces: OccupiedSpace[],
newPositions: OccupiedSpace,
collidingSpaceMap: CollidingSpaceMap,
gridProps: GridProps,
delta = { X: 0, Y: 0 },
shouldResize = true,
) {
const movementMap: ReflowedSpaceMap = {};
const collisionTree = getCollisionTree(
occupiedSpaces,
newPositions,
collidingSpaceMap,
);
if (
!collisionTree ||
!collisionTree.children ||
Object.keys(collisionTree.children).length <= 0
) {
return {};
}
const childrenKeys = Object.keys(collisionTree.children);
const directionalVariables: {
[direction: string]: [number, number, CollisionAccessors, ReflowDirection];
} = {};
for (const childKey of childrenKeys) {
const childNode = collisionTree.children[childKey];
const childDirection = collidingSpaceMap[childNode.id].direction;
const directionalAccessors = getAccessor(childDirection);
const distanceBeforeCollision =
childNode[directionalAccessors.oppositeDirection] -
newPositions[directionalAccessors.direction];
const { depth, occupiedSpace } = getMovementMapHelper(
childNode,
movementMap,
delta,
gridProps,
directionalAccessors,
childDirection,
0,
childNode[directionalAccessors.direction],
distanceBeforeCollision,
shouldResize,
);
let staticDepth = 0,
maxOccupiedSpace = 0;
if (directionalVariables[childDirection]) {
[staticDepth, maxOccupiedSpace] = directionalVariables[childDirection];
}
staticDepth = Math.max(staticDepth, depth);
maxOccupiedSpace = Math.max(maxOccupiedSpace, occupiedSpace);
directionalVariables[childDirection] = [
staticDepth,
maxOccupiedSpace,
directionalAccessors,
childDirection,
];
}
const directionalKeys = Object.keys(directionalVariables);
const directionalMovements: DirectionalMovement[] = [];
for (const directionKey of directionalKeys) {
const [
staticDepth,
maxOccupiedSpace,
accessors,
reflowDirection,
] = directionalVariables[directionKey];
const maxMethod = accessors.isHorizontal ? getMaxX : getMaxY;
const gridDistance = accessors.isHorizontal
? gridProps.parentColumnSpace
: gridProps.parentRowSpace;
const coordinateKey = accessors.isHorizontal ? "X" : "Y";
const maxMovement =
maxMethod(
collisionTree,
gridProps,
reflowDirection,
staticDepth,
maxOccupiedSpace,
shouldResize,
) +
delta[coordinateKey] +
accessors.directionIndicator * gridDistance;
directionalMovements.push({
maxMovement,
directionalIndicator: accessors.directionIndicator,
coordinateKey,
isHorizontal: accessors.isHorizontal,
});
}
const newPositionsMovement = {
id: collisionTree.id,
directionalMovements,
};
return {
newPositionsMovement,
movementMap,
};
}
/**
* Recursively create a tree of Space collisions
* @param occupiedSpaces array of all the occupied spaces on the canvas
* @param newPositions new/current positions of the space/block
* @param collidingSpaceMap Map of Colliding spaces of the dragging/resizing space
* @returns Collisions in a tree structure
*/
function getCollisionTree(
occupiedSpaces: OccupiedSpace[],
newPositions: OccupiedSpace,
collidingSpaceMap: CollidingSpaceMap,
) {
const collisionTree: CollisionTree = {
...newPositions,
children: {},
};
const collidingSpaces = Object.values(collidingSpaceMap);
sortCollidingSpacesByDistance(collidingSpaces, newPositions, false);
const globalCompletedTree: { [key: string]: boolean } = {
[newPositions.id]: true,
};
for (const collidingSpace of collidingSpaces) {
const directionalAccessors = getAccessor(collidingSpace.direction);
if (!globalCompletedTree[collidingSpace.id]) {
const currentCollisionTree = getCollisionTreeHelper(
occupiedSpaces,
collidingSpace,
directionalAccessors,
collidingSpace[directionalAccessors.oppositeDirection] -
collisionTree[directionalAccessors.direction],
collidingSpace.direction,
globalCompletedTree,
);
if (currentCollisionTree && collisionTree.children) {
collisionTree.children[collidingSpace.id] = { ...currentCollisionTree };
}
}
}
return collisionTree;
}
function getCollisionTreeHelper(
occupiedSpaces: OccupiedSpace[],
collidingSpace: CollidingSpace,
accessors: CollisionAccessors,
distanceBeforeCollision: number,
direction: ReflowDirection,
globalProcessedNodes: { [key: string]: boolean },
emptySpaces = 0,
processedNodes: { [key: string]: boolean } = {},
) {
if (!collidingSpace) return;
const collisionTree: CollisionTree = { ...collidingSpace, children: {} };
const resizedDimensions = getResizedDimensions(
collisionTree,
distanceBeforeCollision,
emptySpaces,
accessors,
);
const {
collidingSpaces,
occupiedSpacesInDirection,
} = getCollidingSpacesInDirection(
resizedDimensions,
direction,
occupiedSpaces,
);
sortCollidingSpacesByDistance(collidingSpaces, collisionTree);
const currentProcessedNodes: { [key: string]: boolean } = {};
for (const currentCollidingSpace of collidingSpaces) {
const nextEmptySpaces =
emptySpaces +
currentCollidingSpace[accessors.oppositeDirection] -
collisionTree[accessors.direction];
if (!currentProcessedNodes[currentCollidingSpace.id]) {
const currentCollisionTree = getCollisionTreeHelper(
occupiedSpacesInDirection,
currentCollidingSpace,
accessors,
distanceBeforeCollision,
direction,
globalProcessedNodes,
nextEmptySpaces,
currentProcessedNodes,
);
currentProcessedNodes[currentCollidingSpace.id] = true;
if (currentCollisionTree && collisionTree.children) {
collisionTree.children[currentCollidingSpace.id] = {
...currentCollisionTree,
};
}
}
}
const currentProcessedNodesKeys = Object.keys(currentProcessedNodes);
for (const key of currentProcessedNodesKeys) processedNodes[key] = true;
globalProcessedNodes[collisionTree.id] = true;
return collisionTree;
}
function getMovementMapHelper(
collisionTree: CollisionTree,
movementMap: ReflowedSpaceMap,
dimensions = { X: 0, Y: 0 },
gridProps: GridProps,
accessors: CollisionAccessors,
direction: ReflowDirection,
emptySpaces = 0,
prevWidgetdistance: number,
distanceBeforeCollision = 0,
shouldResize: boolean,
) {
let maxOccupiedSpace = 0,
depth = 0;
let currentEmptySpaces = emptySpaces;
if (collisionTree.children && !isEmpty(collisionTree.children)) {
const childNodes = Object.values(collisionTree.children);
for (const childNode of childNodes) {
const nextEmptySpaces =
emptySpaces +
Math.abs(prevWidgetdistance - childNode[accessors.oppositeDirection]);
const {
currentEmptySpaces: childEmptySpaces,
depth: currentDepth,
occupiedSpace,
} = getMovementMapHelper(
childNode,
movementMap,
dimensions,
gridProps,
accessors,
direction,
nextEmptySpaces,
childNode[accessors.direction],
distanceBeforeCollision,
shouldResize,
);
if (maxOccupiedSpace < occupiedSpace) {
currentEmptySpaces = childEmptySpaces;
}
maxOccupiedSpace = Math.max(maxOccupiedSpace, occupiedSpace || 0);
depth = Math.max(depth, currentDepth);
}
} else {
if (direction === ReflowDirection.RIGHT) {
currentEmptySpaces +=
GridDefaults.DEFAULT_GRID_COLUMNS - collisionTree.right;
} else if (direction !== ReflowDirection.BOTTOM) {
currentEmptySpaces += collisionTree[accessors.direction];
}
}
const getSpaceMovement = accessors.isHorizontal
? getHorizontalSpaceMovement
: getVerticalSpaceMovement;
const movementObj = getSpaceMovement(
collisionTree,
gridProps,
direction,
maxOccupiedSpace,
depth,
distanceBeforeCollision,
emptySpaces,
currentEmptySpaces,
dimensions,
shouldResize,
);
if (
!shouldReplaceOldMovement(
movementMap[collisionTree.id],
movementObj,
direction,
)
) {
return {
occupiedSpace:
(movementMap[collisionTree.id].maxOccupiedSpace || 0) +
collisionTree[accessors.parallelMax] -
collisionTree[accessors.parallelMin],
depth: (movementMap[collisionTree.id].depth || 0) + 1,
currentEmptySpaces: movementMap[collisionTree.id].emptySpaces || 0,
};
}
movementMap[collisionTree.id] = { ...movementObj };
return {
occupiedSpace:
maxOccupiedSpace +
collisionTree[accessors.parallelMax] -
collisionTree[accessors.parallelMin],
depth: depth + 1,
currentEmptySpaces,
};
}
function getHorizontalSpaceMovement(
collisionTree: CollisionTree,
gridProps: GridProps,
direction: ReflowDirection,
maxOccupiedSpace: number,
depth: number,
distanceBeforeCollision: number,
emptySpaces: number,
currentEmptySpaces: number,
{ X }: Delta,
shouldResize: boolean,
) {
const maxX = getMaxX(
collisionTree,
gridProps,
direction,
depth,
maxOccupiedSpace,
shouldResize,
);
const width = getResizedDimension(
collisionTree,
direction,
X,
maxX,
distanceBeforeCollision,
gridProps.parentColumnSpace,
emptySpaces,
HORIZONTAL_RESIZE_LIMIT,
shouldResize,
);
const spaceMovement = {
X: getReflowDistance(
collisionTree,
direction,
maxX,
distanceBeforeCollision,
width,
emptySpaces,
gridProps.parentColumnSpace,
),
distanceBeforeCollision,
maxX,
width,
emptySpaces: currentEmptySpaces,
depth,
maxOccupiedSpace,
};
return spaceMovement;
}
function getVerticalSpaceMovement(
collisionTree: CollisionTree,
gridProps: GridProps,
direction: ReflowDirection,
maxOccupiedSpace: number,
depth: number,
distanceBeforeCollision: number,
emptySpaces: number,
currentEmptySpaces: number,
{ Y }: Delta,
shouldResize: boolean,
) {
const maxY = getMaxY(
collisionTree,
gridProps,
direction,
depth,
maxOccupiedSpace,
shouldResize,
);
const height = getResizedDimension(
collisionTree,
direction,
Y,
maxY,
distanceBeforeCollision,
gridProps.parentRowSpace,
emptySpaces,
VERTICAL_RESIZE_LIMIT,
shouldResize,
);
const spaceMovement = {
Y: getReflowDistance(
collisionTree,
direction,
maxY,
distanceBeforeCollision,
height,
emptySpaces,
gridProps.parentRowSpace,
true,
),
distanceBeforeCollision,
maxY,
height,
emptySpaces: currentEmptySpaces,
depth,
maxOccupiedSpace,
};
return spaceMovement;
}

View File

@ -0,0 +1,98 @@
import { OccupiedSpace } from "constants/CanvasEditorConstants";
export const HORIZONTAL_RESIZE_LIMIT = 2;
export const VERTICAL_RESIZE_LIMIT = 4;
export enum ReflowDirection {
LEFT = "LEFT",
RIGHT = "RIGHT",
TOP = "TOP",
BOTTOM = "BOTTOM",
TOPLEFT = "TOP|LEFT",
TOPRIGHT = "TOP|RIGHT",
BOTTOMLEFT = "BOTTOM|LEFT",
BOTTOMRIGHT = "BOTTOM|RIGHT",
UNSET = "UNSET",
}
export enum SpaceAttributes {
top = "top",
bottom = "bottom",
left = "left",
right = "right",
}
export enum MathComparators {
min = "min",
max = "max",
}
export type GridProps = {
parentColumnSpace: number;
parentRowSpace: number;
maxGridColumns: number;
paddingOffset?: number;
};
export type CollisionAccessors = {
direction: SpaceAttributes;
oppositeDirection: SpaceAttributes;
perpendicularMax: SpaceAttributes;
perpendicularMin: SpaceAttributes;
parallelMax: SpaceAttributes;
parallelMin: SpaceAttributes;
mathComparator: MathComparators;
directionIndicator: 1 | -1;
isHorizontal: boolean;
};
export type Delta = {
X: number;
Y: number;
};
export type CollidingSpace = OccupiedSpace & {
direction: ReflowDirection;
};
export type CollidingSpaceMap = {
[key: string]: CollidingSpace;
};
export type CollisionTree = OccupiedSpace & {
children?: {
[key: string]: CollisionTree;
};
};
export type SpaceMovement = {
id?: string;
directionalMovements: DirectionalMovement[];
};
export type DirectionalMovement = {
maxMovement: number;
directionalIndicator: 1 | -1;
coordinateKey: "X" | "Y";
isHorizontal: boolean;
};
export type ReflowedSpace = {
X?: number;
Y?: number;
width?: number;
height?: number;
depth?: number;
x?: number;
y?: number;
maxX?: number;
maxY?: number;
dimensionXBeforeCollision?: number;
dimensionYBeforeCollision?: number;
maxOccupiedSpace?: number;
emptySpaces?: number;
};
export type ReflowedSpaceMap = {
[key: string]: ReflowedSpace;
};

View File

@ -0,0 +1,836 @@
import { OccupiedSpace } from "constants/CanvasEditorConstants";
import { Rect } from "utils/WidgetPropsUtils";
import {
CollidingSpace,
CollidingSpaceMap,
CollisionAccessors,
CollisionTree,
GridProps,
HORIZONTAL_RESIZE_LIMIT,
MathComparators,
ReflowDirection,
ReflowedSpace,
ReflowedSpaceMap,
SpaceAttributes,
SpaceMovement,
VERTICAL_RESIZE_LIMIT,
} from "./reflowTypes";
/**
* Get if the space moved horizontally
*
* @param newPositions
* @param prevPositions
* @returns boolean
*/
export function getIsHorizontalMove(
newPositions: OccupiedSpace,
prevPositions?: OccupiedSpace,
) {
if (
prevPositions?.left !== newPositions.left ||
prevPositions?.right !== newPositions.right
) {
return true;
}
return false;
}
/**
* method to determine if the newly calculated MovementValue should replace an old value of the same Space Id
*
* @param oldMovement
* @param newMovement
* @param direction
* @returns boolean
*/
export function shouldReplaceOldMovement(
oldMovement: ReflowedSpace,
newMovement: ReflowedSpace,
direction: ReflowDirection,
) {
if (!oldMovement) return true;
const { directionIndicator, isHorizontal } = getAccessor(direction);
const distanceKey = isHorizontal ? "X" : "Y";
const oldDistance = oldMovement[distanceKey],
newDistance = newMovement[distanceKey];
if (oldDistance === undefined || newDistance === undefined) {
return false;
}
return compareNumbers(oldDistance, newDistance, directionIndicator < 0);
}
/**
* method to get resized dimensions of the Space to determine the Spaces colliding with this Space
*
* @param collisionTree
* @param distanceBeforeCollision
* @param emptySpaces
* @param accessors
* @returns resized Direction
*/
export function getResizedDimensions(
collisionTree: CollisionTree,
distanceBeforeCollision: number,
emptySpaces: number,
{ direction }: CollisionAccessors,
) {
const reflowedPosition = { ...collisionTree, children: [] };
const newDimension = distanceBeforeCollision + emptySpaces;
reflowedPosition[direction] -= newDimension;
return reflowedPosition;
}
/**
* sort the collidingSpaces with respect to the distance from the staticPosition
*
* @param collidingSpaces
* @param staticPosition
* @param isAscending
*/
export function sortCollidingSpacesByDistance(
collidingSpaces: CollidingSpace[],
staticPosition: OccupiedSpace,
isAscending = true,
) {
const distanceComparator = getDistanceComparator(staticPosition, isAscending);
collidingSpaces.sort(distanceComparator);
}
/**
* Returns a comparator bound to the
*
* @param staticPosition
* @param isAscending
* @returns negative or positive indicator
*/
function getDistanceComparator(
staticPosition: OccupiedSpace,
isAscending = true,
) {
return function(spaceA: CollidingSpace, spaceB: CollidingSpace) {
const accessorA = getAccessor(spaceA.direction);
const accessorB = getAccessor(spaceB.direction);
const distanceA = Math.abs(
staticPosition[accessorA.direction] - spaceA[accessorA.oppositeDirection],
);
const distanceB = Math.abs(
staticPosition[accessorB.direction] - spaceB[accessorB.oppositeDirection],
);
return isAscending ? distanceA - distanceB : distanceB - distanceA;
};
}
/**
* To Get Indicators if the static widget can continue to reflow without Overlapping
*
* @param staticPosition
* @param delta
* @param beforeLimit
* @returns object with a boolean each for vertical and horizontal direction
*/
export function getShouldReflow(
staticPosition: SpaceMovement | undefined,
delta = { X: 0, Y: 0 },
beforeLimit = false,
): { canVerticalMove: boolean; canHorizontalMove: boolean } {
if (!staticPosition) {
return {
canHorizontalMove: false,
canVerticalMove: false,
};
}
let canHorizontalMove = true,
canVerticalMove = true;
const { directionalMovements } = staticPosition;
for (const movementLimit of directionalMovements) {
const {
coordinateKey,
directionalIndicator,
isHorizontal,
maxMovement,
} = movementLimit;
const canMove = compareNumbers(
delta[coordinateKey],
maxMovement,
directionalIndicator < 0,
beforeLimit,
);
if (!canMove) {
if (isHorizontal) canHorizontalMove = false;
else canVerticalMove = false;
}
}
return {
canHorizontalMove,
canVerticalMove,
};
}
/**
* Should return X and Y coordinates of movement from OGPositions to newPositions
* in a particular direction
*
* @param OGPositions
* @param newPositions
* @param direction
* @returns
*/
export function getDelta(
OGPositions: OccupiedSpace,
newPositions: OccupiedSpace,
direction: ReflowDirection,
) {
let X = OGPositions.left - newPositions.left,
Y = OGPositions.top - newPositions.top;
if (direction.indexOf("|") > 0) {
const [verticalDirection, horizontalDirection] = direction.split("|");
const { direction: xDirection } = getAccessor(
horizontalDirection as ReflowDirection,
);
const { direction: yDirection } = getAccessor(
verticalDirection as ReflowDirection,
);
X = OGPositions[xDirection] - newPositions[xDirection];
Y = OGPositions[yDirection] - newPositions[yDirection];
return { X, Y };
}
const { direction: directionalAccessor, isHorizontal } = getAccessor(
direction,
);
const diff =
OGPositions[directionalAccessor] - newPositions[directionalAccessor];
if (isHorizontal) X = diff;
else Y = diff;
return { X, Y };
}
/**
* returns Collising Spaces map with the direction of collision
*
* @param staticPosition
* @param direction
* @param occupiedSpaces
* @param isHorizontalMove
* @param prevPositions
* @param prevCollidingSpaces
* @param forceDirection
* @returns collision spaces Map
*/
export function getCollidingSpaces(
staticPosition: OccupiedSpace,
direction: ReflowDirection,
occupiedSpaces?: OccupiedSpace[],
isHorizontalMove?: boolean,
prevPositions?: OccupiedSpace,
prevCollidingSpaces?: CollidingSpaceMap,
forceDirection = false,
) {
let isColliding = false;
const collidingSpaceMap: CollidingSpaceMap = {};
const filteredOccupiedSpaces = filterSpaceById(
staticPosition.id,
occupiedSpaces,
);
for (const occupiedSpace of filteredOccupiedSpaces) {
if (areIntersecting(occupiedSpace, staticPosition)) {
isColliding = true;
const currentSpaceId = occupiedSpace.id;
const movementDirection = getCorrectedDirection(
occupiedSpace,
prevPositions,
direction,
forceDirection,
isHorizontalMove,
prevCollidingSpaces,
);
collidingSpaceMap[currentSpaceId] = {
...occupiedSpace,
direction: movementDirection,
};
}
}
return {
isColliding,
collidingSpaceMap,
};
}
/**
* returns Collising Spaces map in a particular direction
* @param staticPosition
* @param direction
* @param occupiedSpaces
* @returns Colliding Spaces Array
*/
export function getCollidingSpacesInDirection(
staticPosition: OccupiedSpace,
direction: ReflowDirection,
occupiedSpaces?: OccupiedSpace[],
) {
const collidingSpaces: CollidingSpace[] = [];
const occupiedSpacesInDirection = filterSpaceByDirection(
staticPosition,
occupiedSpaces,
direction,
);
for (const occupiedSpace of occupiedSpacesInDirection) {
if (areIntersecting(occupiedSpace, staticPosition)) {
collidingSpaces.push({
...occupiedSpace,
direction,
});
}
}
return { collidingSpaces, occupiedSpacesInDirection };
}
function filterSpaceByDirection(
staticPosition: OccupiedSpace,
occupiedSpaces: OccupiedSpace[] | undefined,
direction: ReflowDirection,
): OccupiedSpace[] {
let filteredSpaces: OccupiedSpace[] = [];
const { directionIndicator, oppositeDirection } = getAccessor(direction);
if (occupiedSpaces) {
filteredSpaces = occupiedSpaces.filter((occupiedSpace) => {
if (
occupiedSpace.id === staticPosition.id ||
occupiedSpace.parentId === staticPosition.id
) {
return false;
}
return compareNumbers(
occupiedSpace[oppositeDirection],
staticPosition[oppositeDirection],
directionIndicator > 0,
);
});
}
return filteredSpaces;
}
/**
* filters out a space with an id and returns the filtered Spaces
* @param id
* @param occupiedSpaces
* @returns filtered occupied spaces
*/
export function filterSpaceById(
id: string,
occupiedSpaces: OccupiedSpace[] | undefined,
): OccupiedSpace[] {
let filteredSpaces: OccupiedSpace[] = [];
if (occupiedSpaces) {
filteredSpaces = occupiedSpaces.filter((occupiedSpace) => {
return occupiedSpace.id !== id && occupiedSpace.parentId !== id;
});
}
return filteredSpaces;
}
function areIntersecting(r1: Rect, r2: Rect) {
return !(
r2.left >= r1.right ||
r2.right <= r1.left ||
r2.top >= r1.bottom ||
r2.bottom <= r1.top
);
}
function getCorrectedDirection(
collidingSpace: OccupiedSpace,
prevPositions: OccupiedSpace | undefined,
direction: ReflowDirection,
forceDirection = false,
isHorizontalMove?: boolean,
prevCollidingSpaces?: CollidingSpaceMap,
): ReflowDirection {
if (forceDirection) return direction;
if (prevCollidingSpaces && prevCollidingSpaces[collidingSpace.id]) {
return prevCollidingSpaces[collidingSpace.id].direction;
}
let primaryDirection: ReflowDirection = direction,
secondaryDirection: ReflowDirection | undefined = undefined;
if (direction.indexOf("|") >= 0) {
const directions = direction.split("|");
if (isHorizontalMove) {
primaryDirection = directions[1] as ReflowDirection;
secondaryDirection = directions[0] as ReflowDirection;
} else {
primaryDirection = directions[0] as ReflowDirection;
secondaryDirection = directions[1] as ReflowDirection;
}
}
if (!prevPositions) return primaryDirection;
const primaryAccessors = getAccessor(primaryDirection);
const isCorrectDirection = compareNumbers(
collidingSpace[primaryAccessors.oppositeDirection],
prevPositions[primaryAccessors.direction],
primaryAccessors.directionIndicator > 0,
true,
);
if (isCorrectDirection) {
return primaryDirection;
} else if (secondaryDirection) {
return secondaryDirection;
}
return getVerifiedDirection(
collidingSpace,
prevPositions,
primaryDirection,
primaryAccessors.isHorizontal,
);
}
function getVerifiedDirection(
collidingSpace: OccupiedSpace,
prevPositions: OccupiedSpace,
direction: ReflowDirection,
isHorizontalMove: boolean,
) {
if (isHorizontalMove) {
if (collidingSpace.bottom <= prevPositions.top) {
return ReflowDirection.TOP;
} else if (collidingSpace.top >= prevPositions.bottom) {
return ReflowDirection.BOTTOM;
} else if (
direction !== ReflowDirection.RIGHT &&
collidingSpace.left >= prevPositions.right
) {
return ReflowDirection.RIGHT;
} else if (
direction !== ReflowDirection.LEFT &&
collidingSpace.right <= prevPositions.left
) {
return ReflowDirection.LEFT;
}
} else {
if (collidingSpace.right <= prevPositions.left) {
return ReflowDirection.LEFT;
} else if (collidingSpace.left >= prevPositions.right) {
return ReflowDirection.RIGHT;
} else if (
direction !== ReflowDirection.TOP &&
collidingSpace.bottom <= prevPositions.top
) {
return ReflowDirection.TOP;
} else if (
direction !== ReflowDirection.BOTTOM &&
collidingSpace.top >= prevPositions.bottom
) {
return ReflowDirection.BOTTOM;
}
}
return direction;
}
/**
* compares numbers and returns boolean
* @param numberA
* @param numberB
* @param isGreaterThan
* @param isEqual
* @returns boolean
*/
export function compareNumbers(
numberA: number,
numberB: number,
isGreaterThan: boolean,
isEqual = false,
): boolean {
if (isGreaterThan) {
if (isEqual) {
return numberA >= numberB;
}
return numberA > numberB;
}
if (isEqual) {
return numberA <= numberB;
}
return numberA < numberB;
}
/**
* gets opposite direction
* @param direction
* @returns ReflowDirection
*/
export function getOppositeDirection(
direction: ReflowDirection,
): ReflowDirection {
const directionalAccessors = getAccessor(direction);
return directionalAccessors.oppositeDirection.toUpperCase() as ReflowDirection;
}
/**
*
* @param direction
* @returns accessors
*/
export function getAccessor(direction: ReflowDirection): CollisionAccessors {
switch (direction) {
case ReflowDirection.LEFT:
return {
direction: SpaceAttributes.left,
oppositeDirection: SpaceAttributes.right,
perpendicularMax: SpaceAttributes.bottom,
perpendicularMin: SpaceAttributes.top,
parallelMax: SpaceAttributes.right,
parallelMin: SpaceAttributes.left,
mathComparator: MathComparators.max,
directionIndicator: -1,
isHorizontal: true,
};
case ReflowDirection.RIGHT:
return {
direction: SpaceAttributes.right,
oppositeDirection: SpaceAttributes.left,
perpendicularMax: SpaceAttributes.bottom,
perpendicularMin: SpaceAttributes.top,
parallelMax: SpaceAttributes.right,
parallelMin: SpaceAttributes.left,
mathComparator: MathComparators.min,
directionIndicator: 1,
isHorizontal: true,
};
case ReflowDirection.TOP:
return {
direction: SpaceAttributes.top,
oppositeDirection: SpaceAttributes.bottom,
perpendicularMax: SpaceAttributes.right,
perpendicularMin: SpaceAttributes.left,
parallelMax: SpaceAttributes.bottom,
parallelMin: SpaceAttributes.top,
mathComparator: MathComparators.max,
directionIndicator: -1,
isHorizontal: false,
};
case ReflowDirection.BOTTOM:
return {
direction: SpaceAttributes.bottom,
oppositeDirection: SpaceAttributes.top,
perpendicularMax: SpaceAttributes.right,
perpendicularMin: SpaceAttributes.left,
parallelMax: SpaceAttributes.bottom,
parallelMin: SpaceAttributes.top,
mathComparator: MathComparators.min,
directionIndicator: 1,
isHorizontal: false,
};
}
return {
direction: SpaceAttributes.bottom,
oppositeDirection: SpaceAttributes.top,
perpendicularMax: SpaceAttributes.right,
perpendicularMin: SpaceAttributes.left,
parallelMax: SpaceAttributes.bottom,
parallelMin: SpaceAttributes.top,
mathComparator: MathComparators.min,
directionIndicator: 1,
isHorizontal: false,
};
}
/**
* get Max X coordinate of the the space
*
* @param collisionTree
* @param gridProps
* @param direction
* @param depth
* @param maxOccupiedSpace
* @param shouldResize
* @returns number
*/
export function getMaxX(
collisionTree: CollisionTree,
gridProps: GridProps,
direction: ReflowDirection,
depth: number,
maxOccupiedSpace: number,
shouldResize: boolean,
) {
const accessors = getAccessor(direction);
const movementLimit = shouldResize
? depth * HORIZONTAL_RESIZE_LIMIT
: maxOccupiedSpace;
let maxX = collisionTree[accessors.direction] - movementLimit;
if (direction === ReflowDirection.RIGHT) {
maxX =
gridProps.maxGridColumns -
collisionTree[accessors.direction] -
movementLimit;
}
return accessors.directionIndicator * maxX * gridProps.parentColumnSpace;
}
/**
* get Max Y coordinate of the the space
*
* @param collisionTree
* @param gridProps
* @param direction
* @param depth
* @param maxOccupiedSpace
* @param shouldResize
* @returns number
*/
export function getMaxY(
collisionTree: CollisionTree,
gridProps: GridProps,
direction: ReflowDirection,
depth: number,
maxOccupiedSpace: number,
shouldResize: boolean,
) {
const accessors = getAccessor(direction);
const movementLimit = shouldResize
? depth * VERTICAL_RESIZE_LIMIT
: maxOccupiedSpace;
let maxY =
(collisionTree[accessors.direction] - movementLimit) *
gridProps.parentRowSpace;
if (direction === ReflowDirection.BOTTOM) {
maxY = Infinity;
}
return accessors.directionIndicator * maxY;
}
/**
* get X or Y coordinate distance for space
*
* @param collisionTree
* @param direction
* @param maxDistance
* @param distanceBeforeCollision
* @param actualDimension
* @param emptySpaces
* @param snapGridSpace
* @param expandableCanvas
* @returns distance in number
*/
export function getReflowDistance(
collisionTree: CollisionTree,
direction: ReflowDirection,
maxDistance: number,
distanceBeforeCollision: number,
actualDimension: number,
emptySpaces: number,
snapGridSpace: number,
expandableCanvas = false,
) {
const accessors = getAccessor(direction);
const originalDimension =
(collisionTree[accessors.parallelMax] -
collisionTree[accessors.parallelMin]) *
snapGridSpace;
const value =
(distanceBeforeCollision + emptySpaces * accessors.directionIndicator) *
snapGridSpace *
-1;
const maxValue = Math[accessors.mathComparator](value, maxDistance);
if (expandableCanvas) {
return maxValue;
}
return accessors.directionIndicator < 0
? maxValue
: maxValue + originalDimension - actualDimension;
}
/**
* gets the resized dimension of the space along a direction
*
* @param collisionTree
* @param direction
* @param travelDistance
* @param maxDistance
* @param distanceBeforeCollision
* @param snapGridSpace
* @param emptySpaces
* @param minDimension
* @param shouldResize
* @returns resized width or height of space
*/
export function getResizedDimension(
collisionTree: CollisionTree,
direction: ReflowDirection,
travelDistance: number,
maxDistance: number,
distanceBeforeCollision: number,
snapGridSpace: number,
emptySpaces: number,
minDimension: number,
shouldResize: boolean,
) {
const accessors = getAccessor(direction);
const currentDistanceBeforeCollision =
travelDistance +
(distanceBeforeCollision + emptySpaces * accessors.directionIndicator) *
snapGridSpace;
const originalDimension =
collisionTree[accessors.parallelMax] - collisionTree[accessors.parallelMin];
if (!shouldResize) {
return originalDimension * snapGridSpace;
}
const resizeTreshold = maxDistance + currentDistanceBeforeCollision;
const resizeLimit =
resizeTreshold +
(originalDimension - minDimension) *
snapGridSpace *
accessors.directionIndicator;
let shrink = 0;
const canResize = compareNumbers(
travelDistance,
resizeTreshold,
accessors.directionIndicator > 0,
true,
);
if (canResize) {
shrink = Math[accessors.mathComparator](travelDistance, resizeLimit);
shrink = shrink - resizeTreshold;
}
return originalDimension * snapGridSpace - Math.abs(shrink);
}
/**
*
* @param movementMap
* @param prevMovementMap
* @param movementLimit
* @returns
*/
export function getLimitedMovementMap(
movementMap: ReflowedSpaceMap | undefined,
prevMovementMap: ReflowedSpaceMap,
movementLimit: { canVerticalMove: boolean; canHorizontalMove: boolean },
): ReflowedSpaceMap {
if (!movementMap) return {};
const { canHorizontalMove, canVerticalMove } = movementLimit;
if (!canVerticalMove && !canHorizontalMove) {
return prevMovementMap;
}
if (!canVerticalMove) {
return replaceMovementMapByDirection(movementMap, prevMovementMap, false);
}
if (!canHorizontalMove) {
return replaceMovementMapByDirection(movementMap, prevMovementMap, true);
}
return movementMap;
}
function replaceMovementMapByDirection(
movementMap: ReflowedSpaceMap,
prevMovementMap: ReflowedSpaceMap,
replaceHorizontal: boolean,
): ReflowedSpaceMap {
const checkKey = replaceHorizontal ? "X" : "Y";
const currentMovementMap = { ...movementMap };
const movementMapIds = Object.keys(movementMap);
for (const spaceId of movementMapIds) {
if (currentMovementMap[spaceId][checkKey] !== undefined) {
delete currentMovementMap[spaceId];
if (prevMovementMap[spaceId]) {
currentMovementMap[spaceId] = { ...prevMovementMap[spaceId] };
}
}
}
return currentMovementMap;
}
/**
* on Container exit, the exitted container and the widgets behind it should reflow in opposite direction
* @param collidingSpaceMap
* @param immediateExitContainer
* @param direction
* changes reference of collidingSpaceMap
*/
export function changeExitContainerDirection(
collidingSpaceMap: CollidingSpaceMap,
immediateExitContainer: string | undefined,
direction: ReflowDirection,
) {
if (!immediateExitContainer || !collidingSpaceMap[immediateExitContainer]) {
return;
}
const oppDirection = getOppositeDirection(direction);
const { directionIndicator, oppositeDirection } = getAccessor(oppDirection);
const collidingSpaces: CollidingSpace[] = Object.values(collidingSpaceMap);
const oppositeFrom =
collidingSpaceMap[immediateExitContainer][oppositeDirection];
const oppositeSpaceIds = collidingSpaces
.filter((collidingSpace: CollidingSpace) => {
return compareNumbers(
collidingSpace[oppositeDirection],
oppositeFrom,
directionIndicator > 0,
true,
);
})
.map((collidingSpace: CollidingSpace) => collidingSpace.id);
for (const spaceId of oppositeSpaceIds) {
collidingSpaceMap[spaceId].direction = oppDirection;
}
}

View File

@ -0,0 +1,951 @@
import { ReflowDirection } from "reflow/reflowTypes";
import {
getAccessor,
getIsHorizontalMove,
shouldReplaceOldMovement,
getResizedDimensions,
sortCollidingSpacesByDistance,
getShouldReflow,
getDelta,
getCollidingSpaces,
getCollidingSpacesInDirection,
filterSpaceById,
getMaxX,
getMaxY,
getReflowDistance,
getResizedDimension,
} from "../reflowUtils";
import { HORIZONTAL_RESIZE_LIMIT, VERTICAL_RESIZE_LIMIT } from "../reflowTypes";
const gridProps = {
parentColumnSpace: 20,
parentRowSpace: 10,
maxGridColumns: 100,
};
describe("Test reflow util methods", () => {
describe("Test getIsHorizontalMove method", () => {
it("should return true when there is a difference in horizonatal direction coordinates", () => {
const newPositions = {
left: 20,
right: 40,
},
oldPositions = {
left: 10,
right: 30,
};
expect(getIsHorizontalMove(newPositions, oldPositions)).toBe(true);
});
it("should return false when there is no difference horizontal direction coordinates", () => {
const newPositions = {
left: 20,
right: 40,
},
oldPositions = {
left: 20,
right: 40,
};
expect(getIsHorizontalMove(newPositions, oldPositions)).toBe(false);
});
});
describe("Test shouldReplaceOldMovement method", () => {
it("should return true when Space's new movement coordinate is farther than the olds'", () => {
const newMovement = {
X: 20,
},
oldMovement = {
X: 10,
};
expect(
shouldReplaceOldMovement(
oldMovement,
newMovement,
ReflowDirection.RIGHT,
),
).toBe(true);
});
it("should return false when Space's new movement coordinate is not farther than the olds'", () => {
const newMovement = {
X: 10,
},
oldMovement = {
X: 20,
};
expect(
shouldReplaceOldMovement(oldMovement, newMovement, ReflowDirection.TOP),
).toBe(false);
});
});
describe("Test getResizedDimensions method", () => {
it("should return Resized Dimensions based on dimensionbeforeCollision and the emptySpaces in between", () => {
const collisionTree = {
right: 20,
},
distanceBeforeCollision = -20,
emptySpaces = 10,
accesssors = getAccessor(ReflowDirection.RIGHT);
const resizedDimension = {
right: 30,
};
expect(
getResizedDimensions(
collisionTree,
distanceBeforeCollision,
emptySpaces,
accesssors,
).right,
).toBe(resizedDimension.right);
});
});
describe("Test sortCollidingSpacesByDistance method", () => {
const collisionSpaces = [
{
direction: ReflowDirection.RIGHT,
left: 100,
},
{
direction: ReflowDirection.LEFT,
right: 30,
},
{
direction: ReflowDirection.BOTTOM,
top: 110,
},
{
direction: ReflowDirection.TOP,
bottom: 0,
},
],
staticPosition = {
right: 80,
left: 40,
bottom: 70,
top: 30,
};
it("should sort the collidingSpaces with respect to the distance from the staticPosition in a Ascending manner", () => {
const sortedCollisionSpaces = [
{
direction: ReflowDirection.LEFT,
right: 30,
},
{
direction: ReflowDirection.RIGHT,
left: 100,
},
{
direction: ReflowDirection.TOP,
bottom: 0,
},
{
direction: ReflowDirection.BOTTOM,
top: 110,
},
];
sortCollidingSpacesByDistance(collisionSpaces, staticPosition, true);
expect(collisionSpaces).toEqual(sortedCollisionSpaces);
});
it("should sort the collidingSpaces with respect to the distance from the staticPosition in a descending manner", () => {
const sortedCollisionSpaces = [
{
direction: ReflowDirection.BOTTOM,
top: 110,
},
{
direction: ReflowDirection.TOP,
bottom: 0,
},
{
direction: ReflowDirection.RIGHT,
left: 100,
},
{
direction: ReflowDirection.LEFT,
right: 30,
},
];
sortCollidingSpacesByDistance(collisionSpaces, staticPosition, false);
expect(collisionSpaces).toEqual(sortedCollisionSpaces);
});
});
describe("Test getShouldReflow method", () => {
const staticPosition = {
directionalMovements: [
{
maxMovement: 30,
directionalIndicator: 1,
coordinateKey: "X",
isHorizontal: true,
},
{
maxMovement: 30,
directionalIndicator: 1,
coordinateKey: "Y",
isHorizontal: false,
},
{
maxMovement: -30,
directionalIndicator: -1,
coordinateKey: "X",
isHorizontal: true,
},
{
maxMovement: -30,
directionalIndicator: -1,
coordinateKey: "Y",
isHorizontal: false,
},
],
};
it("should check canHorizontalMove or canVerticalMove when either direction movement has reached limit", () => {
expect(getShouldReflow(staticPosition, { X: 25, Y: 0 })).toEqual({
canHorizontalMove: true,
canVerticalMove: true,
});
expect(getShouldReflow(staticPosition, { X: 35, Y: 0 })).toEqual({
canHorizontalMove: false,
canVerticalMove: true,
});
expect(getShouldReflow(staticPosition, { X: -25, Y: 0 })).toEqual({
canHorizontalMove: true,
canVerticalMove: true,
});
expect(getShouldReflow(staticPosition, { X: -35, Y: 0 })).toEqual({
canHorizontalMove: false,
canVerticalMove: true,
});
expect(getShouldReflow(staticPosition, { X: 0, Y: 25 })).toEqual({
canHorizontalMove: true,
canVerticalMove: true,
});
expect(getShouldReflow(staticPosition, { X: 0, Y: 35 })).toEqual({
canHorizontalMove: true,
canVerticalMove: false,
});
expect(getShouldReflow(staticPosition, { X: 0, Y: -25 })).toEqual({
canHorizontalMove: true,
canVerticalMove: true,
});
expect(getShouldReflow(staticPosition, { X: 0, Y: -35 })).toEqual({
canHorizontalMove: true,
canVerticalMove: false,
});
});
});
describe("Test getDelta method", () => {
const OGPositions = {
id: "1234",
left: 50,
top: 50,
right: 110,
bottom: 110,
},
newPositions = {
id: "1234",
left: 40,
top: 30,
right: 80,
bottom: 70,
};
it("should check X and Y Coordinates for constant direction", () => {
expect(getDelta(OGPositions, newPositions, ReflowDirection.LEFT)).toEqual(
{
X: 10,
Y: 20,
},
);
expect(getDelta(OGPositions, newPositions, ReflowDirection.TOP)).toEqual({
X: 10,
Y: 20,
});
expect(
getDelta(OGPositions, newPositions, ReflowDirection.RIGHT),
).toEqual({
X: 30,
Y: 20,
});
expect(
getDelta(OGPositions, newPositions, ReflowDirection.BOTTOM),
).toEqual({
X: 10,
Y: 40,
});
});
it("should check X and Y Coordinates for composite direction", () => {
expect(
getDelta(OGPositions, newPositions, ReflowDirection.TOPLEFT),
).toEqual({
X: 10,
Y: 20,
});
expect(
getDelta(OGPositions, newPositions, ReflowDirection.TOPRIGHT),
).toEqual({
X: 30,
Y: 20,
});
expect(
getDelta(OGPositions, newPositions, ReflowDirection.BOTTOMLEFT),
).toEqual({
X: 10,
Y: 40,
});
expect(
getDelta(OGPositions, newPositions, ReflowDirection.BOTTOMRIGHT),
).toEqual({
X: 30,
Y: 40,
});
});
});
describe("Test getCollidingSpaces and getCollidingSpacesInDirection method", () => {
const newPositions = {
id: "1234",
left: 40,
top: 30,
right: 80,
bottom: 70,
},
occupiedSpaces = [
{
id: "1234",
left: 40,
top: 30,
right: 80,
bottom: 70,
},
{
id: "1235",
left: 10,
top: 10,
right: 35,
bottom: 25,
},
{
id: "1236",
left: 30,
top: 20,
right: 50,
bottom: 35,
},
{
id: "1237",
left: 10,
top: 10,
right: 90,
bottom: 80,
},
];
it("should return collidingSpaces with direction", () => {
const collidingSpaces = {
"1236": {
id: "1236",
left: 30,
top: 20,
right: 50,
bottom: 35,
direction: "BOTTOM",
},
"1237": {
id: "1237",
left: 10,
top: 10,
right: 90,
bottom: 80,
direction: "BOTTOM",
},
};
expect(
getCollidingSpaces(newPositions, ReflowDirection.BOTTOM, occupiedSpaces)
.collidingSpaceMap,
).toEqual(collidingSpaces);
});
it("should return collidingSpaces with predicted direction based on Previous positions", () => {
const collidingSpaces = {
"1236": {
id: "1236",
left: 30,
top: 20,
right: 50,
bottom: 35,
direction: "LEFT",
},
"1237": {
id: "1237",
left: 10,
top: 10,
right: 90,
bottom: 80,
direction: "BOTTOM",
},
},
prevPositions = {
id: "1234",
left: 50,
top: 30,
right: 90,
bottom: 70,
};
expect(
getCollidingSpaces(
newPositions,
ReflowDirection.BOTTOM,
occupiedSpaces,
true,
prevPositions,
).collidingSpaceMap,
).toEqual(collidingSpaces);
});
it("should return collidingSpaces In a particular direction", () => {
const collidingSpaces = [
{
id: "1236",
left: 30,
top: 20,
right: 50,
bottom: 35,
direction: "LEFT",
},
];
expect(
getCollidingSpacesInDirection(
newPositions,
ReflowDirection.LEFT,
occupiedSpaces,
).collidingSpaces,
).toEqual(collidingSpaces);
});
});
describe("Test filterSpaceById method", () => {
const occupiedSpaces = [
{
id: "1234",
left: 40,
top: 30,
right: 80,
bottom: 70,
},
{
id: "1235",
left: 10,
top: 10,
right: 35,
bottom: 25,
},
{
id: "1236",
left: 30,
top: 20,
right: 50,
bottom: 35,
},
];
it("should return filtered Spaces", () => {
const filteredSpaces = occupiedSpaces.slice(1);
expect(filterSpaceById("1234", occupiedSpaces)).toEqual(filteredSpaces);
});
});
describe("Test getMaxX method", () => {
const collisionTree = {
id: "1234",
left: 40,
top: 30,
right: 60,
bottom: 70,
children: [],
};
it("should return max number for LEFT Direction when not Resizing", () => {
const depth = 2;
expect(
getMaxX(
collisionTree,
gridProps,
ReflowDirection.LEFT,
depth,
30,
false,
),
).toBe(-1 * 10 * gridProps.parentColumnSpace);
});
it("should return max number for LEFT Direction when Resizing", () => {
const depth = 2;
expect(
getMaxX(
collisionTree,
gridProps,
ReflowDirection.LEFT,
depth,
30,
true,
),
).toBe(
-1 *
(collisionTree.left - depth * HORIZONTAL_RESIZE_LIMIT) *
gridProps.parentColumnSpace,
);
});
it("should return max number for RIGHT Direction when not Resizing", () => {
const depth = 2;
expect(
getMaxX(
collisionTree,
gridProps,
ReflowDirection.RIGHT,
depth,
30,
false,
),
).toBe(
(gridProps.maxGridColumns - collisionTree.right - 30) *
gridProps.parentColumnSpace,
);
});
it("should return max number for RIGHT Direction when Resizing", () => {
const depth = 2;
expect(
getMaxX(
collisionTree,
gridProps,
ReflowDirection.RIGHT,
depth,
30,
true,
),
).toBe(
(gridProps.maxGridColumns -
collisionTree.right -
depth * HORIZONTAL_RESIZE_LIMIT) *
gridProps.parentColumnSpace,
);
});
});
describe("Test getMaxY method", () => {
const collisionTree = {
id: "1234",
left: 40,
top: 30,
right: 60,
bottom: 70,
children: [],
};
it("should return max number for TOP Direction when not Resizing", () => {
const depth = 2;
expect(
getMaxY(
collisionTree,
gridProps,
ReflowDirection.TOP,
depth,
20,
false,
),
).toBe(-1 * 10 * gridProps.parentRowSpace);
});
it("should return max number for TOP Direction when Resizing", () => {
const depth = 2;
expect(
getMaxY(collisionTree, gridProps, ReflowDirection.TOP, depth, 20, true),
).toBe(
-1 *
(collisionTree.top - depth * VERTICAL_RESIZE_LIMIT) *
gridProps.parentRowSpace,
);
});
it("should return max number for BOTTOM Direction with or without Resizing", () => {
const depth = 2;
expect(
getMaxY(
collisionTree,
gridProps,
ReflowDirection.BOTTOM,
depth,
230,
false,
),
).toBe(Infinity);
});
});
describe("Test getReflowDistance method", () => {
const collisionTree = {
id: "1234",
left: 90,
top: 100,
right: 150,
bottom: 140,
children: [],
},
width =
(collisionTree.right - collisionTree.left) *
gridProps.parentColumnSpace,
height =
(collisionTree.bottom - collisionTree.top) * gridProps.parentRowSpace;
it("should return distance for space in TOP direction", () => {
//before it reaches max limit
let dimensionBeforeCollision = 40,
emptySpaces = 20,
maxDistance = -60 * gridProps.parentRowSpace;
expect(
getReflowDistance(
collisionTree,
ReflowDirection.TOP,
maxDistance,
dimensionBeforeCollision,
height,
emptySpaces,
gridProps.parentRowSpace,
),
).toBe(
-1 *
(dimensionBeforeCollision - emptySpaces) *
gridProps.parentRowSpace,
);
//reflowing past max limit
dimensionBeforeCollision = 90;
expect(
getReflowDistance(
collisionTree,
ReflowDirection.TOP,
maxDistance,
dimensionBeforeCollision,
height,
emptySpaces,
gridProps.parentRowSpace,
),
).toBe(maxDistance);
});
it("should return distance for space in LEFT direction", () => {
//before it reaches max limit
let dimensionBeforeCollision = 40,
emptySpaces = 20,
maxDistance = -60 * gridProps.parentRowSpace;
expect(
getReflowDistance(
collisionTree,
ReflowDirection.LEFT,
maxDistance,
dimensionBeforeCollision,
height,
emptySpaces,
gridProps.parentRowSpace,
),
).toBe(
-1 *
(dimensionBeforeCollision - emptySpaces) *
gridProps.parentRowSpace,
);
//reflowing past max limit
dimensionBeforeCollision = 90;
expect(
getReflowDistance(
collisionTree,
ReflowDirection.LEFT,
maxDistance,
dimensionBeforeCollision,
height,
emptySpaces,
gridProps.parentRowSpace,
),
).toBe(maxDistance);
});
it("should return distance for space in RIGHT direction", () => {
//before it reaches max limit
let dimensionBeforeCollision = -40,
emptySpaces = 20,
maxDistance = 60 * gridProps.parentRowSpace;
expect(
getReflowDistance(
collisionTree,
ReflowDirection.RIGHT,
maxDistance,
dimensionBeforeCollision,
width,
emptySpaces,
gridProps.parentColumnSpace,
),
).toBe(
-1 *
(dimensionBeforeCollision + emptySpaces) *
gridProps.parentColumnSpace,
);
//reflowing past max limit
dimensionBeforeCollision = -90;
expect(
getReflowDistance(
collisionTree,
ReflowDirection.RIGHT,
maxDistance,
dimensionBeforeCollision,
width,
emptySpaces,
gridProps.parentColumnSpace,
),
).toBe(maxDistance);
});
});
describe("Test getResizedDimension method", () => {
const collisionTree = {
id: "1234",
left: 90,
top: 100,
right: 150,
bottom: 140,
children: [],
},
width =
(collisionTree.right - collisionTree.left) *
gridProps.parentColumnSpace,
height =
(collisionTree.bottom - collisionTree.top) * gridProps.parentRowSpace;
describe("should return resized height in TOP direction", () => {
let dimensionBeforeCollision = 40,
emptySpaces = 20,
maxDistance = -60 * gridProps.parentRowSpace,
travelDistance = -50;
it("should return height when resize is off", () => {
expect(
getResizedDimension(
collisionTree,
ReflowDirection.TOP,
travelDistance * gridProps.parentRowSpace,
maxDistance,
dimensionBeforeCollision,
gridProps.parentRowSpace,
emptySpaces,
VERTICAL_RESIZE_LIMIT,
),
).toBe(height);
});
it("should return height before resize threshold", () => {
expect(
getResizedDimension(
collisionTree,
ReflowDirection.TOP,
travelDistance * gridProps.parentRowSpace,
maxDistance,
dimensionBeforeCollision,
gridProps.parentRowSpace,
emptySpaces,
VERTICAL_RESIZE_LIMIT,
true,
),
).toBe(height);
});
it("should return resized height after resize threshold before min height", () => {
dimensionBeforeCollision = 100;
const resizedHeight =
maxDistance +
(dimensionBeforeCollision - emptySpaces) * gridProps.parentRowSpace;
expect(
getResizedDimension(
collisionTree,
ReflowDirection.TOP,
travelDistance * gridProps.parentRowSpace,
maxDistance,
dimensionBeforeCollision,
gridProps.parentRowSpace,
emptySpaces,
VERTICAL_RESIZE_LIMIT,
true,
),
).toBe(resizedHeight);
});
it("should return min height after resize threshold after reaching min height", () => {
dimensionBeforeCollision = 130;
expect(
getResizedDimension(
collisionTree,
ReflowDirection.TOP,
travelDistance * gridProps.parentRowSpace,
maxDistance,
dimensionBeforeCollision,
gridProps.parentRowSpace,
emptySpaces,
VERTICAL_RESIZE_LIMIT,
true,
),
).toBe(VERTICAL_RESIZE_LIMIT * gridProps.parentRowSpace);
});
});
describe("should return resized width in LEFT direction", () => {
let dimensionBeforeCollision = 40,
emptySpaces = 20,
maxDistance = -60 * gridProps.parentColumnSpace,
travelDistance = -50;
it("should return width when resize is off", () => {
expect(
getResizedDimension(
collisionTree,
ReflowDirection.LEFT,
travelDistance * gridProps.parentColumnSpace,
maxDistance,
dimensionBeforeCollision,
gridProps.parentColumnSpace,
emptySpaces,
HORIZONTAL_RESIZE_LIMIT,
),
).toBe(width);
});
it("should return width before resize threshold", () => {
expect(
getResizedDimension(
collisionTree,
ReflowDirection.LEFT,
travelDistance * gridProps.parentColumnSpace,
maxDistance,
dimensionBeforeCollision,
gridProps.parentColumnSpace,
emptySpaces,
HORIZONTAL_RESIZE_LIMIT,
true,
),
).toBe(width);
});
it("should return resized width after resize threshold before min width", () => {
dimensionBeforeCollision = 110;
const resizedWidth =
maxDistance +
(dimensionBeforeCollision - emptySpaces) *
gridProps.parentColumnSpace;
expect(
getResizedDimension(
collisionTree,
ReflowDirection.LEFT,
travelDistance * gridProps.parentColumnSpace,
maxDistance,
dimensionBeforeCollision,
gridProps.parentColumnSpace,
emptySpaces,
HORIZONTAL_RESIZE_LIMIT,
true,
),
).toBe(resizedWidth);
});
it("should return min width after resize threshold after reaching min width", () => {
dimensionBeforeCollision = 150;
expect(
getResizedDimension(
collisionTree,
ReflowDirection.LEFT,
travelDistance * gridProps.parentColumnSpace,
maxDistance,
dimensionBeforeCollision,
gridProps.parentColumnSpace,
emptySpaces,
HORIZONTAL_RESIZE_LIMIT,
true,
),
).toBe(HORIZONTAL_RESIZE_LIMIT * gridProps.parentColumnSpace);
});
});
describe("should return resized width in RIGHT direction", () => {
let dimensionBeforeCollision = -40,
emptySpaces = 20,
maxDistance = 60 * gridProps.parentColumnSpace,
travelDistance = 50;
it("should return width when resize is off", () => {
expect(
getResizedDimension(
collisionTree,
ReflowDirection.RIGHT,
travelDistance * gridProps.parentColumnSpace,
maxDistance,
dimensionBeforeCollision,
gridProps.parentColumnSpace,
emptySpaces,
HORIZONTAL_RESIZE_LIMIT,
),
).toBe(width);
});
it("should return width before resize threshold", () => {
expect(
getResizedDimension(
collisionTree,
ReflowDirection.RIGHT,
travelDistance * gridProps.parentColumnSpace,
maxDistance,
dimensionBeforeCollision,
gridProps.parentColumnSpace,
emptySpaces,
HORIZONTAL_RESIZE_LIMIT,
true,
),
).toBe(width);
});
it("should return resized width after resize threshold before min width", () => {
dimensionBeforeCollision = -110;
const resizedWidth =
-1 *
(dimensionBeforeCollision + emptySpaces) *
gridProps.parentColumnSpace -
maxDistance;
expect(
getResizedDimension(
collisionTree,
ReflowDirection.RIGHT,
travelDistance * gridProps.parentColumnSpace,
maxDistance,
dimensionBeforeCollision,
gridProps.parentColumnSpace,
emptySpaces,
HORIZONTAL_RESIZE_LIMIT,
true,
),
).toBe(resizedWidth);
});
it("should return min width after resize threshold after reaching min width", () => {
dimensionBeforeCollision = -150;
expect(
getResizedDimension(
collisionTree,
ReflowDirection.RIGHT,
travelDistance * gridProps.parentColumnSpace,
maxDistance,
dimensionBeforeCollision,
gridProps.parentColumnSpace,
emptySpaces,
HORIZONTAL_RESIZE_LIMIT,
true,
),
).toBe(HORIZONTAL_RESIZE_LIMIT * gridProps.parentColumnSpace);
});
});
});
});

View File

@ -0,0 +1,507 @@
import React, { ReactNode, useState, useEffect, useRef, useMemo } from "react";
import styled, { StyledComponent } from "styled-components";
import { WIDGET_PADDING } from "constants/WidgetConstants";
import { useDrag } from "react-use-gesture";
import { Spring } from "react-spring/renderprops";
import PerformanceTracker, {
PerformanceTransactionName,
} from "utils/PerformanceTracker";
import { useReflow } from "utils/hooks/useReflow";
import {
getReflowSelector,
isReflowEnabled,
} from "selectors/widgetReflowSelectors";
import { useSelector } from "react-redux";
import { OccupiedSpace } from "constants/CanvasEditorConstants";
import { GridProps, ReflowDirection, ReflowedSpace } from "reflow/reflowTypes";
import { getNearestParentCanvas } from "utils/generators";
import { getOccupiedSpaces } from "selectors/editorSelectors";
import { isDropZoneOccupied } from "utils/WidgetPropsUtils";
const ResizeWrapper = styled.div<{ prevents: boolean }>`
display: block;
& {
* {
pointer-events: ${(props) => !props.prevents && "none"};
}
}
`;
const getSnappedValues = (
x: number,
y: number,
snapGrid: { x: number; y: number },
) => {
return {
x: Math.round(x / snapGrid.x) * snapGrid.x,
y: Math.round(y / snapGrid.y) * snapGrid.y,
};
};
export type DimensionProps = {
width: number;
height: number;
x: number;
y: number;
reset?: boolean;
direction: ReflowDirection;
X?: number;
Y?: number;
};
type ResizableHandleProps = {
allowResize: boolean;
scrollParent: HTMLDivElement | null;
checkForCollision: (widgetNewSize: {
left: number;
top: number;
bottom: number;
right: number;
}) => boolean;
dragCallback: (x: number, y: number) => void;
component: StyledComponent<"div", Record<string, unknown>>;
onStart: () => void;
onStop: () => void;
snapGrid: {
x: number;
y: number;
};
};
function ResizableHandle(props: ResizableHandleProps) {
const bind = useDrag((state) => {
const {
first,
last,
dragging,
memo,
movement: [mx, my],
} = state;
if (!props.allowResize) {
return;
}
const scrollParent = getNearestParentCanvas(props.scrollParent);
const initialScrollTop = memo ? memo.scrollTop : 0;
const currentScrollTop = scrollParent?.scrollTop || 0;
const deltaScrolledHeight = currentScrollTop - initialScrollTop;
const deltaY = my + deltaScrolledHeight;
const snapped = getSnappedValues(mx, deltaY, props.snapGrid);
if (first) {
props.onStart();
return { scrollTop: currentScrollTop, snapped };
}
const { snapped: snappedMemo } = memo;
if (
dragging &&
snappedMemo &&
(snapped.x !== snappedMemo.x || snapped.y !== snappedMemo.y)
) {
props.dragCallback(snapped.x, snapped.y);
}
if (last) {
props.onStop();
}
return { ...memo, snapped };
});
const propsToPass = {
...bind(),
showAsBorder: !props.allowResize,
};
return <props.component {...propsToPass} />;
}
type ResizableProps = {
allowResize: boolean;
handles: {
left?: StyledComponent<"div", Record<string, unknown>>;
top?: StyledComponent<"div", Record<string, unknown>>;
bottom?: StyledComponent<"div", Record<string, unknown>>;
right?: StyledComponent<"div", Record<string, unknown>>;
bottomRight?: StyledComponent<"div", Record<string, unknown>>;
topLeft?: StyledComponent<"div", Record<string, unknown>>;
topRight?: StyledComponent<"div", Record<string, unknown>>;
bottomLeft?: StyledComponent<"div", Record<string, unknown>>;
};
componentWidth: number;
componentHeight: number;
children: ReactNode;
updateBottomRow: (bottomRow: number) => void;
getResizedPositions: (
size: { width: number; height: number },
position: { x: number; y: number },
) => {
canResizeHorizontally: boolean;
canResizeVertically: boolean;
resizedPositions?: OccupiedSpace;
};
originalPositions: OccupiedSpace;
onStart: () => void;
onStop: (
size: { width: number; height: number },
position: { x: number; y: number },
) => void;
snapGrid: { x: number; y: number };
enable: boolean;
className?: string;
parentId?: string;
widgetId: string;
gridProps: GridProps;
zWidgetType?: string;
zWidgetId?: string;
};
export function ReflowResizable(props: ResizableProps) {
const resizableRef = useRef<HTMLDivElement>(null);
const [isResizing, setResizing] = useState(false);
const reflowEnabled = useSelector(isReflowEnabled);
const occupiedSpaces = useSelector(getOccupiedSpaces);
const occupiedSpacesBySiblingWidgets = useMemo(() => {
return occupiedSpaces && props.parentId && occupiedSpaces[props.parentId]
? occupiedSpaces[props.parentId]
: undefined;
}, [occupiedSpaces, props.parentId]);
const checkForCollision = (widgetNewSize: {
left: number;
top: number;
bottom: number;
right: number;
}) => {
return isDropZoneOccupied(
widgetNewSize,
props.widgetId,
occupiedSpacesBySiblingWidgets,
);
};
// Performance tracking start
const sentryPerfTags = props.zWidgetType
? [{ name: "widget_type", value: props.zWidgetType }]
: [];
PerformanceTracker.startTracking(
PerformanceTransactionName.SHOW_RESIZE_HANDLES,
{ widgetId: props.zWidgetId },
true,
sentryPerfTags,
);
const reflowSelector = getReflowSelector(props.widgetId);
const equal = (
reflowA: ReflowedSpace | undefined,
reflowB: ReflowedSpace | undefined,
) => {
if (
reflowA?.width !== reflowB?.width ||
reflowA?.height !== reflowB?.height
)
return false;
return true;
};
const reflowedPosition = useSelector(reflowSelector, equal);
const reflow = useReflow(
props.widgetId,
props.parentId || "",
props.gridProps,
);
useEffect(() => {
PerformanceTracker.stopTracking(
PerformanceTransactionName.SHOW_RESIZE_HANDLES,
);
}, []);
//end
const [pointerEvents, togglePointerEvents] = useState(true);
const [newDimensions, set] = useState<DimensionProps>({
width: props.componentWidth,
height: props.componentHeight,
x: 0,
y: 0,
reset: false,
direction: ReflowDirection.UNSET,
});
const setNewDimensions = (rect: DimensionProps) => {
const { direction, height, width, x, y } = rect;
const {
canResizeHorizontally,
canResizeVertically,
resizedPositions,
} = props.getResizedPositions({ width, height }, { x, y });
const canResize = canResizeHorizontally || canResizeVertically;
if (canResize) {
set((prevState) => {
let newRect = { ...rect };
let canVerticalMove = true,
canHorizontalMove = true,
bottomMostRow = 0;
if (!reflowEnabled && resizedPositions) {
const isColliding = checkForCollision(resizedPositions);
if (isColliding) {
return prevState;
}
}
if (resizedPositions) {
({ bottomMostRow, canHorizontalMove, canVerticalMove } = reflow(
resizedPositions,
props.originalPositions,
direction,
true,
));
}
if (!canHorizontalMove || !canResizeHorizontally) {
newRect = {
...newRect,
width: prevState.width,
x: prevState.x,
X: prevState.X,
};
}
if (!canVerticalMove || !canResizeVertically) {
newRect = {
...newRect,
height: prevState.height,
y: prevState.y,
Y: prevState.Y,
};
}
if (bottomMostRow) {
props.updateBottomRow(bottomMostRow);
}
return newRect;
});
}
};
useEffect(() => {
set((prevDimensions) => {
return {
...prevDimensions,
width: props.componentWidth,
height: props.componentHeight,
x: 0,
y: 0,
reset: true,
};
});
}, [props.componentHeight, props.componentWidth, isResizing]);
const handles = [];
if (props.handles.left) {
handles.push({
dragCallback: (x: number) => {
setNewDimensions({
width: props.componentWidth - x,
height: newDimensions.height,
x: x,
y: newDimensions.y,
direction: ReflowDirection.LEFT,
X: x,
});
},
component: props.handles.left,
});
}
if (props.handles.top) {
handles.push({
dragCallback: (x: number, y: number) => {
setNewDimensions({
width: newDimensions.width,
height: props.componentHeight - y,
y: y,
x: newDimensions.x,
direction: ReflowDirection.TOP,
Y: y,
});
},
component: props.handles.top,
});
}
if (props.handles.right) {
handles.push({
dragCallback: (x: number) => {
setNewDimensions({
width: props.componentWidth + x,
height: newDimensions.height,
x: newDimensions.x,
y: newDimensions.y,
direction: ReflowDirection.RIGHT,
X: x,
});
},
component: props.handles.right,
});
}
if (props.handles.bottom) {
handles.push({
dragCallback: (x: number, y: number) => {
setNewDimensions({
width: newDimensions.width,
height: props.componentHeight + y,
x: newDimensions.x,
y: newDimensions.y,
direction: ReflowDirection.BOTTOM,
Y: y,
});
},
component: props.handles.bottom,
});
}
if (props.handles.topLeft) {
handles.push({
dragCallback: (x: number, y: number) => {
setNewDimensions({
width: props.componentWidth - x,
height: props.componentHeight - y,
x: x,
y: y,
direction: ReflowDirection.TOPLEFT,
X: x,
Y: y,
});
},
component: props.handles.topLeft,
});
}
if (props.handles.topRight) {
handles.push({
dragCallback: (x: number, y: number) => {
setNewDimensions({
width: props.componentWidth + x,
height: props.componentHeight - y,
x: newDimensions.x,
y: y,
direction: ReflowDirection.TOPRIGHT,
X: x,
Y: y,
});
},
component: props.handles.topRight,
});
}
if (props.handles.bottomRight) {
handles.push({
dragCallback: (x: number, y: number) => {
setNewDimensions({
width: props.componentWidth + x,
height: props.componentHeight + y,
x: newDimensions.x,
y: newDimensions.y,
direction: ReflowDirection.BOTTOMRIGHT,
X: x,
Y: y,
});
},
component: props.handles.bottomRight,
});
}
if (props.handles.bottomLeft) {
handles.push({
dragCallback: (x: number, y: number) => {
setNewDimensions({
width: props.componentWidth - x,
height: props.componentHeight + y,
x,
y: newDimensions.y,
direction: ReflowDirection.BOTTOMLEFT,
X: x,
Y: y,
});
},
component: props.handles.bottomLeft,
});
}
const onResizeStop = () => {
togglePointerEvents(true);
props.onStop(
{
width: newDimensions.width,
height: newDimensions.height,
},
{
x: newDimensions.x,
y: newDimensions.y,
},
);
setResizing(false);
};
const renderHandles = handles.map((handle, index) => (
<ResizableHandle
{...handle}
allowResize={props.allowResize}
checkForCollision={checkForCollision}
key={index}
onStart={() => {
togglePointerEvents(false);
props.onStart();
setResizing(true);
}}
onStop={onResizeStop}
scrollParent={resizableRef.current}
snapGrid={props.snapGrid}
/>
));
const widgetWidth =
reflowedPosition?.width === undefined
? newDimensions.width
: reflowedPosition.width - 2 * WIDGET_PADDING;
const widgetHeight =
reflowedPosition?.height === undefined
? newDimensions.height
: reflowedPosition.height - 2 * WIDGET_PADDING;
return (
<Spring
config={{
clamp: true,
friction: 0,
tension: 999,
}}
from={{
width: props.componentWidth,
height: props.componentHeight,
}}
immediate={newDimensions.reset ? true : false}
to={{
width: widgetWidth,
height: widgetHeight,
transform: `translate3d(${newDimensions.x}px,${newDimensions.y}px,0)`,
}}
>
{(_props) => (
<ResizeWrapper
className={props.className}
prevents={pointerEvents}
ref={resizableRef}
style={_props}
>
{props.children}
{props.enable && renderHandles}
</ResizeWrapper>
)}
</Spring>
);
}
export default ReflowResizable;

View File

@ -10,21 +10,24 @@ import {
} from "reducers/entityReducers/canvasWidgetsReducer";
import { all, call, put, select, takeLatest } from "redux-saga/effects";
import { WidgetDraggingUpdateParams } from "utils/hooks/useBlocksToBeDraggedOnCanvas";
import { updateWidgetPosition } from "utils/WidgetPropsUtils";
import { getWidget, getWidgets } from "./selectors";
import log from "loglevel";
import { cloneDeep } from "lodash";
import { updateAndSaveLayout } from "actions/pageActions";
import { updateAndSaveLayout, WidgetAddChild } from "actions/pageActions";
import { calculateDropTargetRows } from "components/editorComponents/DropTargetUtils";
import { GridDefaults } from "constants/WidgetConstants";
import { WidgetProps } from "widgets/BaseWidget";
import { getOccupiedSpacesSelectorForContainer } from "selectors/editorSelectors";
import { OccupiedSpace } from "constants/CanvasEditorConstants";
import { collisionCheckPostReflow } from "utils/reflowHookUtils";
import { getUpdateDslAfterCreatingChild } from "./WidgetAdditionSagas";
export type WidgetMoveParams = {
widgetId: string;
leftColumn: number;
topRow: number;
bottomRow: number;
rightColumn: number;
parentId: string;
/*
If newParentId is different from what we have in redux store,
@ -86,6 +89,117 @@ const getBottomMostRowAfterMove = (
return widgetBottomRow;
};
function* addWidgetAndMoveWidgetsSaga(
actionPayload: ReduxAction<{
newWidget: WidgetAddChild;
draggedBlocksToUpdate: WidgetDraggingUpdateParams[];
canvasId: string;
}>,
) {
const start = performance.now();
const { canvasId, draggedBlocksToUpdate, newWidget } = actionPayload.payload;
try {
const updatedWidgetsOnAddAndMove: CanvasWidgetsReduxState = yield call(
addWidgetAndMoveWidgets,
newWidget,
draggedBlocksToUpdate,
canvasId,
);
if (
!collisionCheckPostReflow(
updatedWidgetsOnAddAndMove,
draggedBlocksToUpdate.map((block) => block.widgetId),
canvasId,
)
) {
throw Error;
}
yield put(updateAndSaveLayout(updatedWidgetsOnAddAndMove));
log.debug("move computations took", performance.now() - start, "ms");
} catch (error) {
yield put({
type: ReduxActionErrorTypes.WIDGET_OPERATION_ERROR,
payload: {
action: ReduxActionTypes.WIDGETS_ADD_CHILD_AND_MOVE,
error,
},
});
}
}
function* addWidgetAndMoveWidgets(
newWidget: WidgetAddChild,
draggedBlocksToUpdate: WidgetDraggingUpdateParams[],
canvasId: string,
) {
const allWidgets: CanvasWidgetsReduxState = yield select(getWidgets);
const updatedWidgetsOnAddition: CanvasWidgetsReduxState = yield call(
getUpdateDslAfterCreatingChild,
{ ...newWidget, widgetId: canvasId },
);
const bottomMostRowOnAddition = updatedWidgetsOnAddition[canvasId]
? updatedWidgetsOnAddition[canvasId].bottomRow
: 0;
const allWidgetsAfterAddition = {
...allWidgets,
...updatedWidgetsOnAddition,
};
const updatedWidgetsOnMove: CanvasWidgetsReduxState = yield call(
moveAndUpdateWidgets,
allWidgetsAfterAddition,
draggedBlocksToUpdate,
canvasId,
);
const bottomMostRowOnMove = updatedWidgetsOnMove[canvasId]
? updatedWidgetsOnMove[canvasId].bottomRow
: 0;
const bottomMostRow =
bottomMostRowOnAddition > bottomMostRowOnMove
? bottomMostRowOnAddition
: bottomMostRowOnMove;
const updatedWidgets = {
...updatedWidgetsOnMove,
};
updatedWidgets[canvasId].bottomRow = bottomMostRow;
return updatedWidgets;
}
function* moveAndUpdateWidgets(
allWidgets: CanvasWidgetsReduxState,
draggedBlocksToUpdate: WidgetDraggingUpdateParams[],
canvasId: string,
) {
const widgets = cloneDeep(allWidgets);
const bottomMostRowAfterMove = getBottomMostRowAfterMove(
draggedBlocksToUpdate,
allWidgets,
);
// draggedBlocksToUpdate is already sorted based on bottomRow
const updatedWidgets = draggedBlocksToUpdate.reduce((widgetsObj, each) => {
return moveWidget({
...each.updateWidgetParams.payload,
widgetId: each.widgetId,
allWidgets: widgetsObj,
});
}, widgets);
const updatedCanvasBottomRow: number = yield call(
getCanvasSizeAfterWidgetMove,
canvasId,
bottomMostRowAfterMove,
);
if (updatedCanvasBottomRow) {
const canvasWidget = updatedWidgets[canvasId];
updatedWidgets[canvasId] = {
...canvasWidget,
bottomRow: updatedCanvasBottomRow,
};
}
return updatedWidgets;
}
function* moveWidgetsSaga(
actionPayload: ReduxAction<{
draggedBlocksToUpdate: WidgetDraggingUpdateParams[];
@ -95,36 +209,25 @@ function* moveWidgetsSaga(
const start = performance.now();
const { canvasId, draggedBlocksToUpdate } = actionPayload.payload;
const allWidgets: CanvasWidgetsReduxState = yield select(getWidgets);
const widgets = cloneDeep(allWidgets);
const bottomMostRowAfterMove = getBottomMostRowAfterMove(
draggedBlocksToUpdate,
allWidgets,
);
// draggedBlocksToUpdate is already sorted based on bottomRow
try {
const updatedWidgets = draggedBlocksToUpdate.reduce((widgetsObj, each) => {
return moveWidget({
...each.updateWidgetParams.payload,
widgetId: each.widgetId,
allWidgets: widgetsObj,
});
}, widgets);
const allWidgets: CanvasWidgetsReduxState = yield select(getWidgets);
const updatedCanvasBottomRow: number = yield call(
getCanvasSizeAfterWidgetMove,
const updatedWidgetsOnMove: CanvasWidgetsReduxState = yield call(
moveAndUpdateWidgets,
allWidgets,
draggedBlocksToUpdate,
canvasId,
bottomMostRowAfterMove,
);
if (updatedCanvasBottomRow) {
const canvasWidget = updatedWidgets[canvasId];
updatedWidgets[canvasId] = {
...canvasWidget,
bottomRow: updatedCanvasBottomRow,
};
if (
!collisionCheckPostReflow(
updatedWidgetsOnMove,
draggedBlocksToUpdate.map((block) => block.widgetId),
canvasId,
)
) {
throw Error;
}
yield put(updateAndSaveLayout(updatedWidgets));
yield put(updateAndSaveLayout(updatedWidgetsOnMove));
log.debug("move computations took", performance.now() - start, "ms");
} catch (error) {
yield put({
@ -141,9 +244,11 @@ function moveWidget(widgetMoveParams: WidgetMoveParams) {
Toaster.clear();
const {
allWidgets,
bottomRow,
leftColumn,
newParentId,
parentId,
rightColumn,
topRow,
widgetId,
} = widgetMoveParams;
@ -158,7 +263,12 @@ function moveWidget(widgetMoveParams: WidgetMoveParams) {
children: [...(stateParent.children || [])],
};
// Update position of widget
const updatedPosition = updateWidgetPosition(widget, leftColumn, topRow);
const updatedPosition = {
topRow,
bottomRow,
leftColumn,
rightColumn,
};
widget = { ...widget, ...updatedPosition };
// Replace widget with update widget props
@ -189,5 +299,11 @@ function moveWidget(widgetMoveParams: WidgetMoveParams) {
}
export default function* draggingCanvasSagas() {
yield all([takeLatest(ReduxActionTypes.WIDGETS_MOVE, moveWidgetsSaga)]);
yield all([
takeLatest(ReduxActionTypes.WIDGETS_MOVE, moveWidgetsSaga),
takeLatest(
ReduxActionTypes.WIDGETS_ADD_CHILD_AND_MOVE,
addWidgetAndMoveWidgetsSaga,
),
]);
}

View File

@ -0,0 +1,40 @@
import { setEnableReflowAction } from "actions/reflowActions";
import {
ReduxActionErrorTypes,
ReduxActionTypes,
} from "constants/ReduxActionConstants";
import { User } from "constants/userConstants";
import { isBoolean } from "lodash";
import { all, put, select, takeLatest } from "redux-saga/effects";
import { getCurrentUser } from "selectors/usersSelectors";
import { getReflowBetaFlag, setReflowBetaFlag } from "utils/storage";
function* initReflowStates() {
try {
const user: User = yield select(getCurrentUser);
const { email } = user;
if (email) {
const enableReflow: boolean = yield getReflowBetaFlag(email);
const enableReflowHasBeenSet = isBoolean(enableReflow);
const appsmithEmailRegex = /@appsmith.com/g;
const canReflow = appsmithEmailRegex.test(email);
const enableReflowState = enableReflowHasBeenSet
? enableReflow
: canReflow;
yield put(setEnableReflowAction(enableReflowState));
if (canReflow && !enableReflowHasBeenSet) {
setReflowBetaFlag(email, true);
}
}
} catch (error) {
yield put({
type: ReduxActionErrorTypes.REFLOW_BETA_FLAGS_INIT_ERROR,
payload: {
error,
},
});
}
}
export default function* reflowSagas() {
yield all([takeLatest(ReduxActionTypes.INITIALIZE_EDITOR, initReflowStates)]);
}

View File

@ -213,7 +213,9 @@ function* generateChildWidgets(
return { widgetId: widget.widgetId, widgets };
}
function* getUpdateDslAfterCreatingChild(addChildPayload: WidgetAddChild) {
export function* getUpdateDslAfterCreatingChild(
addChildPayload: WidgetAddChild,
) {
// NOTE: widgetId here is the parentId of the dropped widget ( we should rename it to avoid confusion )
const { widgetId } = addChildPayload;
// Get the current parent widget whose child will be the new widget.

View File

@ -101,6 +101,10 @@ import { DataTree } from "entities/DataTree/dataTreeFactory";
import { getCanvasSizeAfterWidgetMove } from "./DraggingCanvasSagas";
import widgetAdditionSagas from "./WidgetAdditionSagas";
import widgetDeletionSagas from "./WidgetDeletionSagas";
import { getReflow } from "selectors/widgetReflowSelectors";
import { widgetReflowState } from "reducers/uiReducers/reflowReducer";
import { stopReflowAction } from "actions/reflowActions";
import { collisionCheckPostReflow } from "utils/reflowHookUtils";
export function* resizeSaga(resizeAction: ReduxAction<WidgetResize>) {
try {
@ -111,6 +115,8 @@ export function* resizeSaga(resizeAction: ReduxAction<WidgetResize>) {
leftColumn,
parentId,
rightColumn,
snapColumnSpace,
snapRowSpace,
topRow,
widgetId,
} = resizeAction.payload;
@ -121,21 +127,31 @@ export function* resizeSaga(resizeAction: ReduxAction<WidgetResize>) {
const widgets = { ...stateWidgets };
widget = { ...widget, leftColumn, rightColumn, topRow, bottomRow };
widgets[widgetId] = widget;
const movedWidgets: {
[widgetId: string]: FlattenedWidgetProps;
} = yield call(
reflowWidgets,
widgets,
widget,
snapColumnSpace,
snapRowSpace,
);
const updatedCanvasBottomRow: number = yield call(
getCanvasSizeAfterWidgetMove,
parentId,
bottomRow,
);
if (updatedCanvasBottomRow) {
const canvasWidget = widgets[parentId];
widgets[parentId] = {
const canvasWidget = movedWidgets[parentId];
movedWidgets[parentId] = {
...canvasWidget,
bottomRow: updatedCanvasBottomRow,
};
}
log.debug("resize computations took", performance.now() - start, "ms");
yield put(updateAndSaveLayout(widgets));
yield put(stopReflowAction());
yield put(updateAndSaveLayout(movedWidgets));
} catch (error) {
yield put({
type: ReduxActionErrorTypes.WIDGET_OPERATION_ERROR,
@ -147,6 +163,60 @@ export function* resizeSaga(resizeAction: ReduxAction<WidgetResize>) {
}
}
export function* reflowWidgets(
widgets: {
[widgetId: string]: FlattenedWidgetProps;
},
widget: FlattenedWidgetProps,
snapColumnSpace: number,
snapRowSpace: number,
) {
const reflowState: widgetReflowState = yield select(getReflow);
const currentWidgets: {
[widgetId: string]: FlattenedWidgetProps;
} = { ...widgets, [widget.widgetId]: { ...widget } };
if (!reflowState || !reflowState.isReflowing || !reflowState.reflowingWidgets)
return currentWidgets;
const reflowingWidgets = reflowState.reflowingWidgets;
const reflowWidgetKeys = Object.keys(reflowingWidgets || {});
if (reflowWidgetKeys.length <= 0) return widgets;
for (const reflowedWidgetId of reflowWidgetKeys) {
const reflowWidget = reflowingWidgets[reflowedWidgetId];
const canvasWidget = { ...currentWidgets[reflowedWidgetId] };
if (reflowWidget.X !== undefined && reflowWidget.width !== undefined) {
const leftColumn =
canvasWidget.leftColumn + reflowWidget.X / snapColumnSpace;
const rightColumn = leftColumn + reflowWidget.width / snapColumnSpace;
currentWidgets[reflowedWidgetId] = {
...canvasWidget,
leftColumn,
rightColumn,
};
} else if (
reflowWidget.Y !== undefined &&
reflowWidget.height !== undefined
) {
const topRow = canvasWidget.topRow + reflowWidget.Y / snapRowSpace;
const bottomRow = topRow + reflowWidget.height / snapRowSpace;
currentWidgets[reflowedWidgetId] = { ...canvasWidget, topRow, bottomRow };
}
}
if (
collisionCheckPostReflow(currentWidgets, reflowWidgetKeys, widget.parentId)
) {
return currentWidgets;
}
return widgets;
}
enum DynamicPathUpdateEffectEnum {
ADD = "ADD",
REMOVE = "REMOVE",

View File

@ -43,6 +43,8 @@ import log from "loglevel";
import * as sentry from "@sentry/react";
import formEvaluationChangeListener from "./FormEvaluationSaga";
import SuperUserSagas from "./SuperUserSagas";
import reflowSagas from "./ReflowSagas";
const sagas = [
initSagas,
pageSagas,
@ -85,6 +87,7 @@ const sagas = [
draggingCanvasSagas,
gitSyncSagas,
SuperUserSagas,
reflowSagas,
];
export function* rootSaga(sagasToRun = sagas) {

View File

@ -9,7 +9,7 @@ import {
} from "reducers/entityReducers/canvasWidgetsReducer";
import { PageListReduxState } from "reducers/entityReducers/pageListReducer";
import { OccupiedSpace } from "constants/CanvasEditorConstants";
import { OccupiedSpace, WidgetSpace } from "constants/CanvasEditorConstants";
import {
getActions,
getCanvasWidgets,
@ -246,6 +246,24 @@ const getOccupiedSpacesForContainer = (
});
};
const getWidgetSpacesForContainer = (
containerWidgetId: string,
widgets: FlattenedWidgetProps[],
): WidgetSpace[] => {
return widgets.map((widget) => {
const occupiedSpace: WidgetSpace = {
id: widget.widgetId,
parentId: containerWidgetId,
left: widget.leftColumn,
top: widget.topRow,
bottom: widget.bottomRow,
right: widget.rightColumn,
type: widget.type,
};
return occupiedSpace;
});
};
export const getOccupiedSpaces = createSelector(
getWidgets,
(
@ -312,6 +330,35 @@ export function getOccupiedSpacesSelectorForContainer(
});
}
// same as getOccupiedSpaces but gets only the container specific ocupied Spaces
export function getWidgetSpacesSelectorForContainer(
containerId: string | undefined,
) {
return createSelector(getWidgets, (widgets: CanvasWidgetsReduxState):
| WidgetSpace[]
| undefined => {
if (containerId === null || containerId === undefined) return undefined;
const containerWidget: FlattenedWidgetProps = widgets[containerId];
if (!containerWidget || !containerWidget.children) return undefined;
// Get child widgets for the container
const childWidgets = Object.keys(widgets).filter(
(widgetId) =>
containerWidget.children &&
containerWidget.children.indexOf(widgetId) > -1 &&
!widgets[widgetId].detachFromLayout,
);
const occupiedSpaces = getWidgetSpacesForContainer(
containerId,
childWidgets.map((widgetId) => widgets[widgetId]),
);
return occupiedSpaces;
});
}
export const getActionById = createSelector(
[getActions, (state: any, props: any) => props.match.params.apiId],
(actions, id) => {

View File

@ -0,0 +1,21 @@
import { AppState } from "reducers";
import { widgetReflowState } from "reducers/uiReducers/reflowReducer";
import { createSelector } from "reselect";
export const getReflow = (state: AppState): widgetReflowState =>
state.ui.widgetReflow;
export const isReflowEnabled = (state: any): boolean =>
state.ui.widgetReflow.enableReflow;
export const getIsReflowing = (state: AppState): boolean =>
state.ui.widgetReflow.isReflowing;
export const getReflowSelector = (widgetId: string) => {
return createSelector(getReflow, (reflowState: widgetReflowState) => {
if (reflowState?.reflowingWidgets) {
return reflowState?.reflowingWidgets[widgetId];
}
return undefined;
});
};

View File

@ -198,7 +198,8 @@ export type EventName =
| "GS_DEFAULT_CONFIGURATION_CHECKBOX_TOGGLED"
| "GS_CONNECT_BUTTON_ON_GIT_SYNC_MODAL_CLICK"
| "GS_IMPORT_VIA_GIT_CLICK"
| "GS_CONTACT_SALES_CLICK";
| "GS_CONTACT_SALES_CLICK"
| "REFLOW_BETA_FLAG";
function getApplicationId(location: Location) {
const pathSplit = location.pathname.split("/");

View File

@ -21,7 +21,7 @@ export type WidgetOperationParams = {
payload: any;
};
type Rect = {
export type Rect = {
top: number;
left: number;
right: number;
@ -161,6 +161,10 @@ export const widgetOperationParams = (
parentColumnSpace: number,
parentRowSpace: number,
parentWidgetId: string, // parentWidget
widgetSizeUpdates: {
width: number;
height: number;
},
): WidgetOperationParams => {
const [leftColumn, topRow] = getDropZoneOffsets(
parentColumnSpace,
@ -177,6 +181,12 @@ export const widgetOperationParams = (
payload: {
leftColumn,
topRow,
bottomRow: Math.round(
topRow + widgetSizeUpdates.height / parentRowSpace,
),
rightColumn: Math.round(
leftColumn + widgetSizeUpdates.width / parentColumnSpace,
),
parentId: widget.parentId,
newParentId: parentWidgetId,
},
@ -204,23 +214,6 @@ export const widgetOperationParams = (
};
};
export const updateWidgetPosition = (
widget: WidgetProps,
leftColumn: number,
topRow: number,
) => {
const newPositions = {
leftColumn,
topRow,
rightColumn: leftColumn + (widget.rightColumn - widget.leftColumn),
bottomRow: topRow + (widget.bottomRow - widget.topRow),
};
return {
...newPositions,
};
};
export const getCanvasSnapRows = (
bottomRow: number,
canExtend: boolean,

View File

@ -10,7 +10,7 @@ import { getSelectedWidgets } from "selectors/ui";
import { getOccupiedSpaces } from "selectors/editorSelectors";
import { getTableFilterState } from "selectors/tableFilterSelectors";
import { OccupiedSpace } from "constants/CanvasEditorConstants";
import { getDragDetails, getWidgets } from "sagas/selectors";
import { getDragDetails, getWidgetByID, getWidgets } from "sagas/selectors";
import {
getDropZoneOffsets,
WidgetOperationParams,
@ -18,7 +18,7 @@ import {
} from "utils/WidgetPropsUtils";
import { DropTargetContext } from "components/editorComponents/DropTargetComponent";
import { XYCord } from "utils/hooks/useCanvasDragging";
import { isEmpty } from "lodash";
import { isEmpty, isEqual } from "lodash";
import { CanvasDraggingArenaProps } from "pages/common/CanvasDraggingArena";
import { useDispatch } from "react-redux";
import { ReduxActionTypes } from "constants/ReduxActionConstants";
@ -26,6 +26,9 @@ import { EditorContext } from "components/editorComponents/EditorContextProvider
import { useWidgetSelection } from "./useWidgetSelection";
import AnalyticsUtil from "utils/AnalyticsUtil";
import { snapToGrid } from "utils/helpers";
import { stopReflowAction } from "actions/reflowActions";
import { DragDetails } from "reducers/uiReducers/dragResizeReducer";
import { getIsReflowing } from "selectors/widgetReflowSelectors";
export interface WidgetDraggingUpdateParams extends WidgetDraggingBlock {
updateWidgetParams: WidgetOperationParams;
@ -53,6 +56,7 @@ export const useBlocksToBeDraggedOnCanvas = ({
const dispatch = useDispatch();
const { selectWidget } = useWidgetSelection();
const containerPadding = noPad ? 0 : CONTAINER_GRID_PADDING;
const lastDraggedCanvas = useRef<string | undefined>(undefined);
// check any table filter is open or not
// if filter pane open, close before property pane open
@ -62,7 +66,21 @@ export const useBlocksToBeDraggedOnCanvas = ({
// which canvas is active(being dragged on),
// which widget is grabbed while dragging started,
// relative position of mouse pointer wrt to the last grabbed widget.
const dragDetails = useSelector(getDragDetails);
const dragDetails: DragDetails = useSelector(getDragDetails);
const draggingCanvas = useSelector(
getWidgetByID(dragDetails.draggedOn || ""),
);
const isReflowing = useSelector(getIsReflowing);
useEffect(() => {
if (
dragDetails.draggedOn &&
draggingCanvas &&
draggingCanvas.parentId &&
![widgetId, MAIN_CONTAINER_WIDGET_ID].includes(dragDetails.draggedOn)
) {
lastDraggedCanvas.current = draggingCanvas.parentId;
}
}, [dragDetails.draggedOn]);
const defaultHandlePositions = {
top: 20,
left: 20,
@ -76,7 +94,7 @@ export const useBlocksToBeDraggedOnCanvas = ({
(state: AppState) => state.ui.widgetDragResize.isResizing,
);
const selectedWidgets = useSelector(getSelectedWidgets);
const occupiedSpaces = useSelector(getOccupiedSpaces) || {};
const occupiedSpaces = useSelector(getOccupiedSpaces, isEqual) || {};
const isNewWidget = !!newWidget && !dragParent;
const childrenOccupiedSpaces: OccupiedSpace[] =
(dragParent && occupiedSpaces[dragParent]) || [];
@ -160,19 +178,45 @@ export const useBlocksToBeDraggedOnCanvas = ({
(each) => !selectedWidgets.includes(each.id),
);
const { updateDropTargetRows } = useContext(DropTargetContext);
const onDrop = (drawingBlocks: WidgetDraggingBlock[]) => {
const cannotDrop = drawingBlocks.some((each) => {
const stopReflowing = () => {
if (isReflowing) dispatch(stopReflowAction());
};
const onDrop = (
drawingBlocks: WidgetDraggingBlock[],
reflowedPositionsUpdatesWidgets: OccupiedSpace[],
) => {
const reflowedBlocks: WidgetDraggingBlock[] = reflowedPositionsUpdatesWidgets.map(
(each) => {
const widget = allWidgets[each.id];
return {
left: each.left * snapColumnSpace,
top: each.top * snapRowSpace,
width: (each.right - each.left) * snapColumnSpace,
height: (each.bottom - each.top) * snapRowSpace,
columnWidth: snapColumnSpace,
rowHeight: snapRowSpace,
widgetId: widget.widgetId,
isNotColliding: true,
detachFromLayout: widget.detachFromLayout,
};
},
);
const reflowedIds = reflowedPositionsUpdatesWidgets.map((each) => each.id);
const allUpdatedBlocks = [...drawingBlocks, ...reflowedBlocks];
const cannotDrop = allUpdatedBlocks.some((each) => {
return !each.isNotColliding;
});
if (!cannotDrop) {
const draggedBlocksToUpdate = drawingBlocks
const draggedBlocksToUpdate = allUpdatedBlocks
.sort(
(each1, each2) =>
each1.top + each1.height - (each2.top + each2.height),
)
.map((each) => {
const widget = newWidget ? newWidget : allWidgets[each.widgetId];
const widget =
newWidget && !reflowedIds.includes(each.widgetId)
? newWidget
: allWidgets[each.widgetId];
const updateWidgetParams = widgetOperationParams(
widget,
{ x: each.left, y: each.top },
@ -180,6 +224,7 @@ export const useBlocksToBeDraggedOnCanvas = ({
snapColumnSpace,
snapRowSpace,
widget.detachFromLayout ? MAIN_CONTAINER_WIDGET_ID : widgetId,
{ width: each.width, height: each.height },
);
return {
@ -195,7 +240,15 @@ export const useBlocksToBeDraggedOnCanvas = ({
draggedBlocksToUpdate: WidgetDraggingUpdateParams[],
) => {
if (isNewWidget) {
addNewWidget(draggedBlocksToUpdate[0]);
const newWidget = draggedBlocksToUpdate.find(
(each) => each.updateWidgetParams.operation === "ADD_CHILD",
);
const movedWidgets = draggedBlocksToUpdate.filter(
(each) => each.updateWidgetParams.operation !== "ADD_CHILD",
);
if (newWidget) {
addNewWidget(newWidget, movedWidgets);
}
} else {
bulkMoveWidgets(draggedBlocksToUpdate);
}
@ -213,14 +266,29 @@ export const useBlocksToBeDraggedOnCanvas = ({
});
};
const addNewWidget = (newWidget: WidgetDraggingUpdateParams) => {
const addNewWidget = (
newWidget: WidgetDraggingUpdateParams,
movedWidgets: WidgetDraggingUpdateParams[],
) => {
const { updateWidgetParams } = newWidget;
updateWidget &&
updateWidget(
updateWidgetParams.operation,
updateWidgetParams.widgetId,
updateWidgetParams.payload,
);
if (movedWidgets && movedWidgets.length) {
dispatch({
type: ReduxActionTypes.WIDGETS_ADD_CHILD_AND_MOVE,
payload: {
newWidget: updateWidgetParams.payload,
draggedBlocksToUpdate: movedWidgets,
canvasId: widgetId,
},
});
} else {
updateWidget &&
updateWidget(
updateWidgetParams.operation,
updateWidgetParams.widgetId,
updateWidgetParams.payload,
);
}
// close filter pane if any open, before property pane open
tableFilterPaneState.isVisible &&
dispatch({
@ -238,7 +306,10 @@ export const useBlocksToBeDraggedOnCanvas = ({
didDrop: true,
});
};
const updateRows = (drawingBlocks: WidgetDraggingBlock[], rows: number) => {
const updateRelativeRows = (
drawingBlocks: WidgetDraggingBlock[],
rows: number,
) => {
if (drawingBlocks.length) {
const sortedByTopBlocks = drawingBlocks.sort(
(each1, each2) => each2.top + each2.height - (each1.top + each1.height),
@ -253,9 +324,12 @@ export const useBlocksToBeDraggedOnCanvas = ({
} as XYCord,
{ x: 0, y: 0 },
);
if (top > rows - GridDefaults.CANVAS_EXTENSION_OFFSET) {
return updateDropTargetRows && updateDropTargetRows(widgetId, top);
}
return updateBottomRow(top, rows);
}
};
const updateBottomRow = (bottom: number, rows: number) => {
if (bottom > rows - GridDefaults.CANVAS_EXTENSION_OFFSET) {
return updateDropTargetRows && updateDropTargetRows(widgetId, bottom);
}
};
const rowRef = useRef(snapRows);
@ -312,11 +386,17 @@ export const useBlocksToBeDraggedOnCanvas = ({
isNewWidget,
isNewWidgetInitialTargetCanvas,
isResizing,
lastDraggedCanvas,
occSpaces,
onDrop,
parentDiff,
relativeStartPoints,
rowRef,
updateRows,
stopReflowing,
updateBottomRow,
updateRelativeRows,
widgetOccupiedSpace: childrenOccupiedSpaces.filter(
(each) => each.id === dragCenter?.widgetId,
)[0],
};
};

View File

@ -2,25 +2,28 @@ import {
CONTAINER_GRID_PADDING,
GridDefaults,
} from "constants/WidgetConstants";
import { debounce, throttle } from "lodash";
import { debounce, isEmpty, throttle } from "lodash";
import { CanvasDraggingArenaProps } from "pages/common/CanvasDraggingArena";
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { ReflowDirection, ReflowedSpaceMap } from "reflow/reflowTypes";
import { useReflow, ReflowInterface } from "utils/hooks/useReflow";
import { useSelector } from "react-redux";
import { getZoomLevel } from "selectors/editorSelectors";
import { getNearestParentCanvas } from "utils/generators";
import { noCollision } from "utils/WidgetPropsUtils";
import { getDropZoneOffsets, noCollision } from "utils/WidgetPropsUtils";
import { useWidgetDragResize } from "./dragResizeHooks";
import {
useBlocksToBeDraggedOnCanvas,
WidgetDraggingBlock,
} from "./useBlocksToBeDraggedOnCanvas";
import { useCanvasDragToScroll } from "./useCanvasDragToScroll";
import { OccupiedSpace } from "constants/CanvasEditorConstants";
import { isReflowEnabled } from "selectors/widgetReflowSelectors";
export interface XYCord {
x: number;
y: number;
}
export const useCanvasDragging = (
canvasRef: React.RefObject<HTMLDivElement>,
canvasDrawRef: React.RefObject<HTMLCanvasElement>,
@ -35,8 +38,9 @@ export const useCanvasDragging = (
}: CanvasDraggingArenaProps,
) => {
const canvasZoomLevel = useSelector(getZoomLevel);
const currentDirection = useRef<ReflowDirection>(ReflowDirection.UNSET);
const { devicePixelRatio: scale = 1 } = window;
const reflowEnabled = useSelector(isReflowEnabled);
const {
blocksToDraw,
defaultHandlePositions,
@ -47,12 +51,16 @@ export const useCanvasDragging = (
isNewWidget,
isNewWidgetInitialTargetCanvas,
isResizing,
lastDraggedCanvas,
occSpaces,
onDrop,
parentDiff,
relativeStartPoints,
rowRef,
updateRows,
stopReflowing,
updateBottomRow,
updateRelativeRows,
widgetOccupiedSpace,
} = useBlocksToBeDraggedOnCanvas({
canExtend,
noPad,
@ -61,6 +69,19 @@ export const useCanvasDragging = (
snapRowSpace,
widgetId,
});
const gridProps = {
parentColumnSpace: snapColumnSpace,
parentRowSpace: snapRowSpace,
maxGridColumns: GridDefaults.DEFAULT_GRID_COLUMNS,
paddingOffset: 0,
};
const reflow = useRef<ReflowInterface>();
reflow.current = useReflow(
widgetOccupiedSpace ? widgetOccupiedSpace.id : "",
widgetId || "",
gridProps,
);
const {
setDraggingCanvas,
@ -128,6 +149,32 @@ export const useCanvasDragging = (
return 0;
};
const mouseAttributesRef = useRef<{
prevEvent: any;
currentEvent: any;
prevSpeed: number;
prevAcceleration: number;
maxPositiveAcc: number;
maxNegativeAcc: number;
maxSpeed: number;
lastMousePositionOutsideCanvas: {
x: number;
y: number;
};
}>({
prevSpeed: 0,
prevAcceleration: 0,
maxPositiveAcc: 0,
maxNegativeAcc: 0,
maxSpeed: 0,
prevEvent: null,
currentEvent: null,
lastMousePositionOutsideCanvas: {
x: 0,
y: 0,
},
});
const canScroll = useCanvasDragToScroll(
canvasRef,
isCurrentDraggedCanvas,
@ -135,6 +182,57 @@ export const useCanvasDragging = (
snapRows,
canExtend,
);
useEffect(() => {
const speedCalculationInterval = setInterval(function() {
const {
currentEvent,
maxNegativeAcc,
maxPositiveAcc,
maxSpeed,
prevEvent,
prevSpeed,
} = mouseAttributesRef.current;
if (prevEvent && currentEvent) {
const movementX = Math.abs(currentEvent.screenX - prevEvent.screenX);
const movementY = Math.abs(currentEvent.screenY - prevEvent.screenY);
const movement = Math.sqrt(
movementX * movementX + movementY * movementY,
);
const speed = 10 * movement; //current speed
const acceleration = 10 * (speed - prevSpeed);
mouseAttributesRef.current.prevAcceleration = acceleration;
mouseAttributesRef.current.prevSpeed = speed;
if (speed > maxSpeed) {
mouseAttributesRef.current.maxSpeed = speed;
}
if (acceleration > 0 && acceleration > maxPositiveAcc) {
mouseAttributesRef.current.maxPositiveAcc = acceleration;
} else if (acceleration < 0 && acceleration < maxNegativeAcc) {
mouseAttributesRef.current.maxNegativeAcc = acceleration;
}
}
mouseAttributesRef.current.prevEvent = currentEvent;
}, 100);
const stopSpeedCalculation = () => {
clearInterval(speedCalculationInterval);
};
const registerMouseMoveEvent = (e: any) => {
mouseAttributesRef.current.currentEvent = e;
mouseAttributesRef.current.lastMousePositionOutsideCanvas = {
x: e.clientX,
y: e.clientY,
};
};
window.addEventListener("mousemove", registerMouseMoveEvent);
return () => {
stopSpeedCalculation();
window.removeEventListener("mousemove", registerMouseMoveEvent);
};
}, []);
useEffect(() => {
if (
canvasRef.current &&
@ -142,6 +240,8 @@ export const useCanvasDragging = (
isDragging &&
blocksToDraw.length > 0
) {
// doing throttling coz reflow moves are also throttled and resetCanvas can be called multiple times
const throttledStopReflowing = throttle(stopReflowing, 50);
const scrollParent: Element | null = getNearestParentCanvas(
canvasRef.current,
);
@ -150,7 +250,28 @@ export const useCanvasDragging = (
let currentRectanglesToDraw: WidgetDraggingBlock[] = [];
const scrollObj: any = {};
let currentReflowParams: {
canVerticalMove: boolean;
canHorizontalMove: boolean;
bottomMostRow: number;
movementMap: ReflowedSpaceMap;
} = {
canVerticalMove: false,
canHorizontalMove: false,
bottomMostRow: 0,
movementMap: {},
};
let lastMousePosition = {
x: 0,
y: 0,
};
let lastSnappedPosition = {
leftColumn: 0,
topRow: 0,
};
const resetCanvasState = () => {
throttledStopReflowing();
if (canvasDrawRef.current && canvasRef.current) {
const canvasCtx: any = canvasDrawRef.current.getContext("2d");
canvasCtx.clearRect(
@ -167,7 +288,43 @@ export const useCanvasDragging = (
const startPoints = defaultHandlePositions;
const onMouseUp = () => {
if (isDragging && canvasIsDragging) {
onDrop(currentRectanglesToDraw);
const { movementMap: reflowingWidgets } = currentReflowParams;
const reflowedPositionsUpdatesWidgets: OccupiedSpace[] = occSpaces
.filter((each) => !!reflowingWidgets[each.id])
.map((each) => {
const reflowedWidget = reflowingWidgets[each.id];
if (
reflowedWidget.X !== undefined &&
(Math.abs(reflowedWidget.X) || reflowedWidget.width)
) {
const movement = reflowedWidget.X / snapColumnSpace;
const newWidth = reflowedWidget.width
? reflowedWidget.width / snapColumnSpace
: each.right - each.left;
each = {
...each,
left: each.left + movement,
right: each.left + movement + newWidth,
};
}
if (
reflowedWidget.Y !== undefined &&
(Math.abs(reflowedWidget.Y) || reflowedWidget.height)
) {
const movement = reflowedWidget.Y / snapRowSpace;
const newHeight = reflowedWidget.height
? reflowedWidget.height / snapRowSpace
: each.bottom - each.top;
each = {
...each,
top: each.top + movement,
bottom: each.top + movement + newHeight,
};
}
return each;
});
onDrop(currentRectanglesToDraw, reflowedPositionsUpdatesWidgets);
}
startPoints.top = defaultHandlePositions.top;
startPoints.left = defaultHandlePositions.left;
@ -185,7 +342,7 @@ export const useCanvasDragging = (
}
};
const onFirstMoveOnCanvas = (e: any) => {
const onFirstMoveOnCanvas = (e: any, over = false) => {
if (
!isResizing &&
isDragging &&
@ -204,10 +361,158 @@ export const useCanvasDragging = (
}
canvasIsDragging = true;
canvasRef.current.style.zIndex = "2";
onMouseMove(e);
if (over) {
lastMousePosition = {
...mouseAttributesRef.current.lastMousePositionOutsideCanvas,
};
} else {
lastMousePosition = {
x: e.clientX,
y: e.clientY,
};
}
onMouseMove(e, over);
}
};
const onMouseMove = (e: any) => {
const canReflowForCurrentMouseMove = () => {
const {
maxNegativeAcc,
maxPositiveAcc,
maxSpeed,
prevAcceleration,
prevSpeed,
} = mouseAttributesRef.current;
const limit = Math.abs(
prevAcceleration < 0 ? maxNegativeAcc : maxPositiveAcc,
);
const acceleration = Math.abs(prevAcceleration);
return acceleration < limit / 5 || prevSpeed < maxSpeed / 5;
};
const getMouseMoveDirection = (event: any) => {
if (lastMousePosition) {
const deltaX = lastMousePosition.x - event.clientX,
deltaY = lastMousePosition.y - event.clientY;
lastMousePosition = {
x: event.clientX,
y: event.clientY,
};
if (
deltaX === 0 &&
["TOP", "BOTTOM"].includes(currentDirection.current)
) {
return currentDirection.current;
}
if (Math.abs(deltaY) > Math.abs(deltaX) && deltaY > 0) {
return ReflowDirection.TOP;
} else if (Math.abs(deltaY) > Math.abs(deltaX) && deltaY < 0) {
return ReflowDirection.BOTTOM;
}
if (
deltaY === 0 &&
["LEFT", "RIGHT"].includes(currentDirection.current)
) {
return currentDirection.current;
}
if (Math.abs(deltaX) > Math.abs(deltaY) && deltaX > 0) {
return ReflowDirection.LEFT;
} else if (Math.abs(deltaX) > Math.abs(deltaY) && deltaX < 0) {
return ReflowDirection.RIGHT;
}
}
return currentDirection.current;
};
const triggerReflow = (e: any, firstMove: boolean) => {
const canReflowBasedOnMouseSpeed = canReflowForCurrentMouseMove();
const isReflowing = !isEmpty(currentReflowParams.movementMap);
const canReflow =
reflowEnabled &&
currentRectanglesToDraw.length === 1 &&
!currentRectanglesToDraw[0].detachFromLayout;
const currentBlock = currentRectanglesToDraw[0];
const [leftColumn, topRow] = getDropZoneOffsets(
snapColumnSpace,
snapRowSpace,
{
x: currentBlock.left,
y: currentBlock.top,
},
{
x: 0,
y: 0,
},
);
const needsReflow = !(
lastSnappedPosition.leftColumn === leftColumn &&
lastSnappedPosition.topRow === topRow
);
lastSnappedPosition = {
leftColumn,
topRow,
};
if (canReflow && reflow.current) {
if (needsReflow) {
const resizedPositions = {
left: leftColumn,
top: topRow,
right: leftColumn + currentBlock.width / snapColumnSpace,
bottom: topRow + currentBlock.height / snapRowSpace,
id: currentBlock.widgetId,
};
const originalPositions = widgetOccupiedSpace
? { ...widgetOccupiedSpace }
: {
left: 0,
top: 0,
right: 0,
bottom: 0,
id: currentBlock.widgetId,
};
currentDirection.current = getMouseMoveDirection(e);
const immediateExitContainer = lastDraggedCanvas.current;
if (lastDraggedCanvas.current) {
lastDraggedCanvas.current = undefined;
}
currentReflowParams = reflow.current(
resizedPositions,
originalPositions,
currentDirection.current,
false,
!canReflowBasedOnMouseSpeed,
firstMove,
immediateExitContainer,
);
}
if (isReflowing) {
const block = currentRectanglesToDraw[0];
const isNotInParentBoundaries = noCollision(
{ x: block.left, y: block.top },
snapColumnSpace,
snapRowSpace,
{ x: 0, y: 0 },
block.columnWidth,
block.rowHeight,
block.widgetId,
[],
rowRef.current,
GridDefaults.DEFAULT_GRID_COLUMNS,
block.detachFromLayout,
);
const newRows = updateBottomRow(
currentReflowParams.bottomMostRow,
rowRef.current,
);
rowRef.current = newRows ? newRows : rowRef.current;
currentRectanglesToDraw[0].isNotColliding =
isNotInParentBoundaries &&
currentReflowParams.canHorizontalMove &&
currentReflowParams.canVerticalMove;
}
}
};
const onMouseMove = (e: any, firstMove = false) => {
if (isDragging && canvasIsDragging && canvasRef.current) {
const delta = {
left: e.offsetX - startPoints.left - parentDiff.left,
@ -219,7 +524,7 @@ export const useCanvasDragging = (
left: each.left + delta.left,
top: each.top + delta.top,
}));
const newRows = updateRows(drawingBlocks, rowRef.current);
const newRows = updateRelativeRows(drawingBlocks, rowRef.current);
const rowDelta = newRows ? newRows - rowRef.current : 0;
rowRef.current = newRows ? newRows : rowRef.current;
currentRectanglesToDraw = drawingBlocks.map((each) => ({
@ -245,6 +550,7 @@ export const useCanvasDragging = (
canScroll.current = false;
renderNewRows(delta);
} else if (!isUpdatingRows) {
triggerReflow(e, firstMove);
renderBlocks();
}
scrollObj.lastMouseMoveEvent = {
@ -411,17 +717,19 @@ export const useCanvasDragging = (
});
}
};
const captureMousePosition = (e: any) => {
if (isDragging && !canvasIsDragging) {
currentDirection.current = getMouseMoveDirection(e);
}
};
const onMouseOver = (e: any) => onFirstMoveOnCanvas(e, true);
const initializeListeners = () => {
canvasRef.current?.addEventListener("mousemove", onMouseMove, false);
canvasRef.current?.addEventListener("mouseup", onMouseUp, false);
scrollParent?.addEventListener("scroll", updateCanvasStyles, false);
scrollParent?.addEventListener("scroll", onScroll, false);
canvasRef.current?.addEventListener(
"mouseover",
onFirstMoveOnCanvas,
false,
);
canvasRef.current?.addEventListener("mouseover", onMouseOver, false);
canvasRef.current?.addEventListener(
"mouseout",
resetCanvasState,
@ -434,6 +742,7 @@ export const useCanvasDragging = (
);
document.body.addEventListener("mouseup", onMouseUp, false);
window.addEventListener("mouseup", onMouseUp, false);
window.addEventListener("mousemove", captureMousePosition);
};
const startDragging = () => {
if (canvasRef.current && canvasDrawRef.current && scrollParent) {
@ -460,10 +769,7 @@ export const useCanvasDragging = (
canvasRef.current?.removeEventListener("mouseup", onMouseUp);
scrollParent?.removeEventListener("scroll", updateCanvasStyles);
scrollParent?.removeEventListener("scroll", onScroll);
canvasRef.current?.removeEventListener(
"mouseover",
onFirstMoveOnCanvas,
);
canvasRef.current?.removeEventListener("mouseover", onMouseOver);
canvasRef.current?.removeEventListener("mouseout", resetCanvasState);
canvasRef.current?.removeEventListener(
"mouseleave",
@ -471,12 +777,20 @@ export const useCanvasDragging = (
);
document.body.removeEventListener("mouseup", onMouseUp);
window.removeEventListener("mouseup", onMouseUp);
window.removeEventListener("mousemove", captureMousePosition);
};
} else {
resetCanvasState();
}
}
}, [isDragging, isResizing, blocksToDraw, snapRows, canExtend]);
}, [
isDragging,
isResizing,
blocksToDraw,
snapRows,
canExtend,
reflowEnabled,
]);
return {
showCanvas: isDragging && !isResizing,
};

View File

@ -0,0 +1,138 @@
import { reflowMoveAction, stopReflowAction } from "actions/reflowActions";
import { OccupiedSpace, WidgetSpace } from "constants/CanvasEditorConstants";
import { isEmpty, throttle } from "lodash";
import { useRef } from "react";
import { useDispatch, useSelector } from "react-redux";
import { getWidgetSpacesSelectorForContainer } from "selectors/editorSelectors";
import { reflow } from "reflow";
import {
CollidingSpace,
GridProps,
ReflowDirection,
ReflowedSpaceMap,
} from "reflow/reflowTypes";
import { getLimitedMovementMap } from "reflow/reflowUtils";
import { getBottomRowAfterReflow } from "utils/reflowHookUtils";
import { checkIsDropTarget } from "components/designSystems/appsmith/PositionedContainer";
type WidgetCollidingSpace = CollidingSpace & {
type: string;
};
type WidgetCollidingSpaceMap = {
[key: string]: WidgetCollidingSpace;
};
export interface ReflowInterface {
(
newPositions: OccupiedSpace,
OGPositions: OccupiedSpace,
direction: ReflowDirection,
stopMoveAfterLimit?: boolean,
shouldSkipContainerReflow?: boolean,
forceDirection?: boolean,
immediateExitContainer?: string,
): {
canHorizontalMove: boolean;
canVerticalMove: boolean;
movementMap: ReflowedSpaceMap;
bottomMostRow: number;
};
}
export const useReflow = (
widgetId: string,
parentId: string,
gridProps: GridProps,
): ReflowInterface => {
const dispatch = useDispatch();
const throttledDispatch = throttle(dispatch, 50);
const isReflowing = useRef<boolean>(false);
const reflowSpacesSelector = getWidgetSpacesSelectorForContainer(parentId);
const widgetSpaces: WidgetSpace[] = useSelector(reflowSpacesSelector) || [];
const originalSpacePosition = widgetSpaces?.find(
(space) => space.id === widgetId,
);
const prevPositions = useRef<OccupiedSpace | undefined>(
originalSpacePosition,
);
const prevCollidingSpaces = useRef<WidgetCollidingSpaceMap>();
const prevMovementMap = useRef<ReflowedSpaceMap>({});
// will become a state if we decide that resize should be a "toggle on-demand" feature
const shouldResize = true;
return function reflowSpaces(
newPositions: OccupiedSpace,
OGPositions: OccupiedSpace,
direction: ReflowDirection,
stopMoveAfterLimit = false,
shouldSkipContainerReflow = false,
forceDirection = false,
immediateExitContainer?: string,
) {
const { collidingSpaceMap, movementLimit, movementMap } = reflow(
newPositions,
OGPositions,
widgetSpaces,
direction,
gridProps,
forceDirection,
shouldResize,
immediateExitContainer,
prevPositions.current,
prevCollidingSpaces.current,
);
prevPositions.current = newPositions;
prevCollidingSpaces.current = collidingSpaceMap as WidgetCollidingSpaceMap;
let correctedMovementMap = movementMap || {};
if (stopMoveAfterLimit)
correctedMovementMap = getLimitedMovementMap(
movementMap,
prevMovementMap.current,
movementLimit,
);
if (shouldSkipContainerReflow) {
const collidingSpaces = Object.values(
(collidingSpaceMap as WidgetCollidingSpaceMap) || {},
);
for (const collidingSpace of collidingSpaces) {
if (checkIsDropTarget(collidingSpace.type)) {
correctedMovementMap = {};
}
}
}
prevMovementMap.current = correctedMovementMap;
if (!isEmpty(correctedMovementMap)) {
isReflowing.current = true;
if (forceDirection) dispatch(reflowMoveAction(correctedMovementMap));
else throttledDispatch(reflowMoveAction(correctedMovementMap));
} else if (isReflowing.current) {
isReflowing.current = false;
throttledDispatch.cancel();
dispatch(stopReflowAction());
}
const bottomMostRow = getBottomRowAfterReflow(
movementMap,
newPositions.bottom,
widgetSpaces,
gridProps,
);
return {
...movementLimit,
movementMap: correctedMovementMap,
bottomMostRow,
};
};
};

View File

@ -0,0 +1,90 @@
import { OccupiedSpace } from "constants/CanvasEditorConstants";
import { GridDefaults } from "constants/WidgetConstants";
import { FlattenedWidgetProps } from "reducers/entityReducers/canvasWidgetsReducer";
import { GridProps, ReflowedSpace, ReflowedSpaceMap } from "reflow/reflowTypes";
export function collisionCheckPostReflow(
widgets: {
[widgetId: string]: FlattenedWidgetProps;
},
reflowWidgetKeys: string[],
parentId?: string,
) {
const widgetKeys = Object.keys(widgets).filter((widgetId) => {
if (!widgets[widgetId].parentId) return false;
if (widgets[widgetId].parentId !== parentId) return false;
if (widgets[widgetId].type === "MODAL_WIDGET") return false;
return true;
});
//boundary Check
for (const reflowedKey of reflowWidgetKeys) {
if (isOutOfCanvas(widgets[reflowedKey])) {
return false;
}
}
//overlapping check
for (const reflowedKey of reflowWidgetKeys) {
for (const widgetId of widgetKeys) {
if (areIntersecting(widgets[reflowedKey], widgets[widgetId])) {
return false;
}
}
}
return true;
}
function areIntersecting(r1: FlattenedWidgetProps, r2: FlattenedWidgetProps) {
if (r1.widgetId === r2.widgetId) return false;
return !(
r2.leftColumn >= r1.rightColumn ||
r2.rightColumn <= r1.leftColumn ||
r2.topRow >= r1.bottomRow ||
r2.bottomRow <= r1.topRow
);
}
function isOutOfCanvas(widget: FlattenedWidgetProps) {
return (
widget.leftColumn < 0 ||
widget.topRow < 0 ||
widget.rightColumn > GridDefaults.DEFAULT_GRID_COLUMNS
);
}
export function getBottomRowAfterReflow(
movementMap: ReflowedSpaceMap | undefined,
widgetBottom: number,
occupiedSpaces: OccupiedSpace[],
gridProps: GridProps,
) {
const reflowedWidgets: [string, ReflowedSpace][] = Object.entries(
movementMap || {},
);
const bottomReflowedWidgets = reflowedWidgets.filter((each) => !!each[1].Y);
const reflowedWidgetsBottomMostRow = bottomReflowedWidgets.reduce(
(bottomMostRow, each) => {
const [id, reflowedParams] = each;
const widget = occupiedSpaces.find((eachSpace) => eachSpace.id === id);
if (widget) {
const bottomMovement =
(reflowedParams.Y || 0) / gridProps.parentRowSpace;
const bottomRow = widget.bottom + bottomMovement;
if (bottomRow > bottomMostRow) {
return bottomRow;
}
}
return bottomMostRow;
},
0,
);
return Math.max(reflowedWidgetsBottomMostRow, widgetBottom);
}

View File

@ -18,6 +18,8 @@ const STORAGE_KEYS: { [id: string]: string } = {
FIRST_TIME_USER_ONBOARDING_INTRO_MODAL_VISIBILITY:
"FIRST_TIME_USER_ONBOARDING_INTRO_MODAL_VISIBILITY",
HIDE_CONCURRENT_EDITOR_WARNING_TOAST: "HIDE_CONCURRENT_EDITOR_WARNING_TOAST",
REFLOW_BETA_FLAG: "REFLOW_BETA_FLAG",
REFLOW_ONBOARDING_FLAG: "REFLOW_ONBOARDING_FLAG",
};
const store = localforage.createInstance({
@ -54,6 +56,28 @@ export const saveCopiedWidgets = async (widgetJSON: string) => {
}
};
const getStoredUsersBetaFlags = (email: any) => {
return store.getItem(email);
};
const setStoredUsersBetaFlags = (email: any, userBetaFlagsObj: any) => {
return store.setItem(email, userBetaFlagsObj);
};
export const setReflowBetaFlag = async (email: any, enable: boolean) => {
const userBetaFlagsObj: any = await getStoredUsersBetaFlags(email);
const updatedObj = {
...userBetaFlagsObj,
[STORAGE_KEYS.REFLOW_BETA_FLAG]: enable,
};
setStoredUsersBetaFlags(email, updatedObj);
};
export const getReflowBetaFlag = async (email: any) => {
const userBetaFlagsObj: any = await getStoredUsersBetaFlags(email);
return userBetaFlagsObj && userBetaFlagsObj[STORAGE_KEYS.REFLOW_BETA_FLAG];
};
export const getCopiedWidgets = async () => {
try {
const widget: string | null = await store.getItem(

View File

@ -271,6 +271,7 @@ abstract class BaseWidget<
return (
<PositionedContainer
focused={this.props.focused}
parentId={this.props.parentId}
resizeDisabled={this.props.resizeDisabled}
selected={this.props.selected}
style={style}

View File

@ -221,6 +221,7 @@ class ContainerWidget extends BaseWidget<
canExtend={props.canExtend}
dropDisabled={!!props.dropDisabled}
noPad={this.props.noPad}
parentId={props.parentId}
snapRows={snapRows}
widgetId={props.widgetId}
/>

View File

@ -29,6 +29,9 @@ describe("<DividerWidget />", () => {
editor: {
isPreviewMode: false,
},
widgetReflow: {
enableReflow: true,
},
},
entities: { canvasWidgets: {}, app: { mode: "canvas" } },
};

View File

@ -33,6 +33,9 @@ describe("<DropdownWidget />", () => {
editor: {
isPreviewMode: false,
},
widgetReflow: {
enableReflow: true,
},
},
entities: { canvasWidgets: {}, app: { mode: "canvas" } },
};

View File

@ -20,7 +20,7 @@ import {
BottomHandleStyles,
} from "components/editorComponents/ResizeStyledComponents";
import { Layers } from "constants/Layers";
import Resizable from "resizable";
import Resizable from "resizable/resize";
import { getCanvasClassName } from "utils/generators";
import { AppState } from "reducers";
import { useWidgetDragResize } from "utils/hooks/dragResizeHooks";