diff --git a/app/client/cypress/support/commands.js b/app/client/cypress/support/commands.js index 434a9ddc43..dbd8fe56e5 100644 --- a/app/client/cypress/support/commands.js +++ b/app/client/cypress/support/commands.js @@ -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(); }); diff --git a/app/client/src/actions/pageActions.tsx b/app/client/src/actions/pageActions.tsx index 15ddd932b9..f5f29e43cc 100644 --- a/app/client/src/actions/pageActions.tsx +++ b/app/client/src/actions/pageActions.tsx @@ -253,6 +253,8 @@ export type WidgetResize = { rightColumn: number; topRow: number; bottomRow: number; + snapColumnSpace: number; + snapRowSpace: number; }; export type ModalWidgetResize = { diff --git a/app/client/src/actions/reflowActions.ts b/app/client/src/actions/reflowActions.ts new file mode 100644 index 0000000000..20f6394196 --- /dev/null +++ b/app/client/src/actions/reflowActions.ts @@ -0,0 +1,27 @@ +import { + ReduxAction, + ReflowReduxActionTypes, +} from "constants/ReduxActionConstants"; +import { ReflowedSpaceMap } from "reflow/reflowTypes"; + +export const reflowMoveAction = ( + payload: ReflowedSpaceMap, +): ReduxAction => { + 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, + }; +}; diff --git a/app/client/src/assets/icons/menu/beta.svg b/app/client/src/assets/icons/menu/beta.svg index 7d6234fffd..1d63a3aa25 100644 --- a/app/client/src/assets/icons/menu/beta.svg +++ b/app/client/src/assets/icons/menu/beta.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/app/client/src/comments/CommentsShowcaseCarousel/CommentsCarouselModal.tsx b/app/client/src/comments/CommentsShowcaseCarousel/CommentsCarouselModal.tsx index 0a6fcb9c0b..75bc8577d8 100644 --- a/app/client/src/comments/CommentsShowcaseCarousel/CommentsCarouselModal.tsx +++ b/app/client/src/comments/CommentsShowcaseCarousel/CommentsCarouselModal.tsx @@ -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} diff --git a/app/client/src/components/designSystems/appsmith/PositionedContainer.tsx b/app/client/src/components/designSystems/appsmith/PositionedContainer.tsx index 6cb63f3cf1..c17bfecd8d 100644 --- a/app/client/src/components/designSystems/appsmith/PositionedContainer.tsx +++ b/app/client/src/components/designSystems/appsmith/PositionedContainer.tsx @@ -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) => { diff --git a/app/client/src/components/editorComponents/DropTargetComponent.tsx b/app/client/src/components/editorComponents/DropTargetComponent.tsx index 6302b0e14d..17b9303985 100644 --- a/app/client/src/components/editorComponents/DropTargetComponent.tsx +++ b/app/client/src/components/editorComponents/DropTargetComponent.tsx @@ -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 ( } - {((isDragging && draggedOn === props.widgetId) || isResizing) && ( + {showDragLayer && ( (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 ( { return { leftColumn: Math.round( diff --git a/app/client/src/constants/CanvasEditorConstants.tsx b/app/client/src/constants/CanvasEditorConstants.tsx index b8db735cb2..8583499427 100644 --- a/app/client/src/constants/CanvasEditorConstants.tsx +++ b/app/client/src/constants/CanvasEditorConstants.tsx @@ -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", diff --git a/app/client/src/constants/ReduxActionConstants.tsx b/app/client/src/constants/ReduxActionConstants.tsx index ec6e648bab..71ca48dc50 100644 --- a/app/client/src/constants/ReduxActionConstants.tsx +++ b/app/client/src/constants/ReduxActionConstants.tsx @@ -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", diff --git a/app/client/src/globalStyles/popover.ts b/app/client/src/globalStyles/popover.ts index 1f7d85c54f..0042247de8 100644 --- a/app/client/src/globalStyles/popover.ts +++ b/app/client/src/globalStyles/popover.ts @@ -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; } diff --git a/app/client/src/pages/Editor/MainContainer.test.tsx b/app/client/src/pages/Editor/MainContainer.test.tsx index bdf87b5fdb..54e6c15b45 100644 --- a/app/client/src/pages/Editor/MainContainer.test.tsx +++ b/app/client/src/pages/Editor/MainContainer.test.tsx @@ -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; @@ -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; + 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); diff --git a/app/client/src/pages/Editor/MainContainerLayoutControl.tsx b/app/client/src/pages/Editor/MainContainerLayoutControl.tsx index 49fcf4e475..ba6486f843 100644 --- a/app/client/src/pages/Editor/MainContainerLayoutControl.tsx +++ b/app/client/src/pages/Editor/MainContainerLayoutControl.tsx @@ -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 (

Canvas Size

@@ -123,6 +159,16 @@ export function MainContainerLayoutControl() { ); })}
+ {canReflow && ( + + + + + )} ); } diff --git a/app/client/src/pages/common/CanvasDraggingArena.tsx b/app/client/src/pages/common/CanvasDraggingArena.tsx index 2c0b680b57..07d670c44d 100644 --- a/app/client/src/pages/common/CanvasDraggingArena.tsx +++ b/app/client/src/pages/common/CanvasDraggingArena.tsx @@ -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, diff --git a/app/client/src/pages/common/CanvasSelectionArena.tsx b/app/client/src/pages/common/CanvasSelectionArena.tsx index 2af155a7d7..240f607166 100644 --- a/app/client/src/pages/common/CanvasSelectionArena.tsx +++ b/app/client/src/pages/common/CanvasSelectionArena.tsx @@ -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 ? ( { + 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, + ) => { + return { ...state, enableReflow: action.payload }; + }, +}); + +export type widgetReflow = { + isReflowing: boolean; + reflowingWidgets: ReflowedSpaceMap; +}; + +export type widgetReflowState = widgetReflow & { + enableReflow: boolean; +}; + +export type Reflow = { + reflowingWidgets?: ReflowedSpaceMap; +}; diff --git a/app/client/src/reflow/index.ts b/app/client/src/reflow/index.ts new file mode 100644 index 0000000000..541d4c0ee6 --- /dev/null +++ b/app/client/src/reflow/index.ts @@ -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, + }; +} diff --git a/app/client/src/reflow/reflowHelpers.ts b/app/client/src/reflow/reflowHelpers.ts new file mode 100644 index 0000000000..737ed77ccc --- /dev/null +++ b/app/client/src/reflow/reflowHelpers.ts @@ -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; +} diff --git a/app/client/src/reflow/reflowTypes.ts b/app/client/src/reflow/reflowTypes.ts new file mode 100644 index 0000000000..6659b507db --- /dev/null +++ b/app/client/src/reflow/reflowTypes.ts @@ -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; +}; diff --git a/app/client/src/reflow/reflowUtils.ts b/app/client/src/reflow/reflowUtils.ts new file mode 100644 index 0000000000..1fb13196bc --- /dev/null +++ b/app/client/src/reflow/reflowUtils.ts @@ -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; + } +} diff --git a/app/client/src/reflow/tests/reflowUtils.test.js b/app/client/src/reflow/tests/reflowUtils.test.js new file mode 100644 index 0000000000..d05243c105 --- /dev/null +++ b/app/client/src/reflow/tests/reflowUtils.test.js @@ -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); + }); + }); + }); +}); diff --git a/app/client/src/resizable/index.tsx b/app/client/src/resizable/resize/index.tsx similarity index 100% rename from app/client/src/resizable/index.tsx rename to app/client/src/resizable/resize/index.tsx diff --git a/app/client/src/resizable/resizenreflow/index.tsx b/app/client/src/resizable/resizenreflow/index.tsx new file mode 100644 index 0000000000..30be756316 --- /dev/null +++ b/app/client/src/resizable/resizenreflow/index.tsx @@ -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>; + 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 ; +} + +type ResizableProps = { + allowResize: boolean; + handles: { + left?: StyledComponent<"div", Record>; + top?: StyledComponent<"div", Record>; + bottom?: StyledComponent<"div", Record>; + right?: StyledComponent<"div", Record>; + bottomRight?: StyledComponent<"div", Record>; + topLeft?: StyledComponent<"div", Record>; + topRight?: StyledComponent<"div", Record>; + bottomLeft?: StyledComponent<"div", Record>; + }; + 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(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({ + 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) => ( + { + 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 ( + + {(_props) => ( + + {props.children} + {props.enable && renderHandles} + + )} + + ); +} + +export default ReflowResizable; diff --git a/app/client/src/sagas/DraggingCanvasSagas.ts b/app/client/src/sagas/DraggingCanvasSagas.ts index b7fed39368..4e776701eb 100644 --- a/app/client/src/sagas/DraggingCanvasSagas.ts +++ b/app/client/src/sagas/DraggingCanvasSagas.ts @@ -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, + ), + ]); } diff --git a/app/client/src/sagas/ReflowSagas.ts b/app/client/src/sagas/ReflowSagas.ts new file mode 100644 index 0000000000..d7f63c4f6c --- /dev/null +++ b/app/client/src/sagas/ReflowSagas.ts @@ -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)]); +} diff --git a/app/client/src/sagas/WidgetAdditionSagas.ts b/app/client/src/sagas/WidgetAdditionSagas.ts index 973d80c3ad..9ec829cb3c 100644 --- a/app/client/src/sagas/WidgetAdditionSagas.ts +++ b/app/client/src/sagas/WidgetAdditionSagas.ts @@ -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. diff --git a/app/client/src/sagas/WidgetOperationSagas.tsx b/app/client/src/sagas/WidgetOperationSagas.tsx index 64c397aa07..824cfcf6cd 100644 --- a/app/client/src/sagas/WidgetOperationSagas.tsx +++ b/app/client/src/sagas/WidgetOperationSagas.tsx @@ -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) { try { @@ -111,6 +115,8 @@ export function* resizeSaga(resizeAction: ReduxAction) { leftColumn, parentId, rightColumn, + snapColumnSpace, + snapRowSpace, topRow, widgetId, } = resizeAction.payload; @@ -121,21 +127,31 @@ export function* resizeSaga(resizeAction: ReduxAction) { 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) { } } +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", diff --git a/app/client/src/sagas/index.tsx b/app/client/src/sagas/index.tsx index 7b38cec2ca..856327350b 100644 --- a/app/client/src/sagas/index.tsx +++ b/app/client/src/sagas/index.tsx @@ -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) { diff --git a/app/client/src/selectors/editorSelectors.tsx b/app/client/src/selectors/editorSelectors.tsx index fcabf62567..1af5c91cd5 100644 --- a/app/client/src/selectors/editorSelectors.tsx +++ b/app/client/src/selectors/editorSelectors.tsx @@ -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) => { diff --git a/app/client/src/selectors/widgetReflowSelectors.tsx b/app/client/src/selectors/widgetReflowSelectors.tsx new file mode 100644 index 0000000000..3864d818e5 --- /dev/null +++ b/app/client/src/selectors/widgetReflowSelectors.tsx @@ -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; + }); +}; diff --git a/app/client/src/utils/AnalyticsUtil.tsx b/app/client/src/utils/AnalyticsUtil.tsx index ad4e4e7518..f737237e76 100644 --- a/app/client/src/utils/AnalyticsUtil.tsx +++ b/app/client/src/utils/AnalyticsUtil.tsx @@ -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("/"); diff --git a/app/client/src/utils/WidgetPropsUtils.tsx b/app/client/src/utils/WidgetPropsUtils.tsx index 8f24efd4eb..7882f4a341 100644 --- a/app/client/src/utils/WidgetPropsUtils.tsx +++ b/app/client/src/utils/WidgetPropsUtils.tsx @@ -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, diff --git a/app/client/src/utils/hooks/useBlocksToBeDraggedOnCanvas.ts b/app/client/src/utils/hooks/useBlocksToBeDraggedOnCanvas.ts index 771212bcb7..ea057f31b8 100644 --- a/app/client/src/utils/hooks/useBlocksToBeDraggedOnCanvas.ts +++ b/app/client/src/utils/hooks/useBlocksToBeDraggedOnCanvas.ts @@ -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(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], }; }; diff --git a/app/client/src/utils/hooks/useCanvasDragging.ts b/app/client/src/utils/hooks/useCanvasDragging.ts index 5119908d21..0f01782d82 100644 --- a/app/client/src/utils/hooks/useCanvasDragging.ts +++ b/app/client/src/utils/hooks/useCanvasDragging.ts @@ -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, canvasDrawRef: React.RefObject, @@ -35,8 +38,9 @@ export const useCanvasDragging = ( }: CanvasDraggingArenaProps, ) => { const canvasZoomLevel = useSelector(getZoomLevel); + const currentDirection = useRef(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(); + 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, }; diff --git a/app/client/src/utils/hooks/useReflow.ts b/app/client/src/utils/hooks/useReflow.ts new file mode 100644 index 0000000000..5544b6043e --- /dev/null +++ b/app/client/src/utils/hooks/useReflow.ts @@ -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(false); + + const reflowSpacesSelector = getWidgetSpacesSelectorForContainer(parentId); + const widgetSpaces: WidgetSpace[] = useSelector(reflowSpacesSelector) || []; + + const originalSpacePosition = widgetSpaces?.find( + (space) => space.id === widgetId, + ); + + const prevPositions = useRef( + originalSpacePosition, + ); + const prevCollidingSpaces = useRef(); + const prevMovementMap = useRef({}); + // 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, + }; + }; +}; diff --git a/app/client/src/utils/reflowHookUtils.ts b/app/client/src/utils/reflowHookUtils.ts new file mode 100644 index 0000000000..1fe708e839 --- /dev/null +++ b/app/client/src/utils/reflowHookUtils.ts @@ -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); +} diff --git a/app/client/src/utils/storage.ts b/app/client/src/utils/storage.ts index 91fb95a81b..6af540af99 100644 --- a/app/client/src/utils/storage.ts +++ b/app/client/src/utils/storage.ts @@ -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( diff --git a/app/client/src/widgets/BaseWidget.tsx b/app/client/src/widgets/BaseWidget.tsx index 7ba2cf9bbb..ee632b8a22 100644 --- a/app/client/src/widgets/BaseWidget.tsx +++ b/app/client/src/widgets/BaseWidget.tsx @@ -271,6 +271,7 @@ abstract class BaseWidget< return ( diff --git a/app/client/src/widgets/DividerWidget/widget/index.test.tsx b/app/client/src/widgets/DividerWidget/widget/index.test.tsx index f17732b277..041a8a3c9e 100644 --- a/app/client/src/widgets/DividerWidget/widget/index.test.tsx +++ b/app/client/src/widgets/DividerWidget/widget/index.test.tsx @@ -29,6 +29,9 @@ describe("", () => { editor: { isPreviewMode: false, }, + widgetReflow: { + enableReflow: true, + }, }, entities: { canvasWidgets: {}, app: { mode: "canvas" } }, }; diff --git a/app/client/src/widgets/DropdownWidget/widget/index.test.tsx b/app/client/src/widgets/DropdownWidget/widget/index.test.tsx index 2c6a79e947..d3c62bf2a9 100644 --- a/app/client/src/widgets/DropdownWidget/widget/index.test.tsx +++ b/app/client/src/widgets/DropdownWidget/widget/index.test.tsx @@ -33,6 +33,9 @@ describe("", () => { editor: { isPreviewMode: false, }, + widgetReflow: { + enableReflow: true, + }, }, entities: { canvasWidgets: {}, app: { mode: "canvas" } }, }; diff --git a/app/client/src/widgets/ModalWidget/component/index.tsx b/app/client/src/widgets/ModalWidget/component/index.tsx index ff59aaf92f..12de4f6088 100644 --- a/app/client/src/widgets/ModalWidget/component/index.tsx +++ b/app/client/src/widgets/ModalWidget/component/index.tsx @@ -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";