From e128b2daf351586fb37d5a81cd79b4abcc2aa9a3 Mon Sep 17 00:00:00 2001 From: rahulramesha <71900764+rahulramesha@users.noreply.github.com> Date: Wed, 4 May 2022 13:28:57 +0530 Subject: [PATCH] feat: new Widget Copy paste experience (#12906) * copy paste commit * class name generator changes * modal widget fixes change * addressing review comments * bug fix for after deleting a widget by undoing action * additional fix for modal widget * additional tests for fixes --- .../cypress/fixtures/WidgetCopyPaste.json | 149 ++++++ .../WidgetCopyPaste/WidgetCopyPaste_spec.js | 108 ++++ app/client/src/actions/widgetActions.tsx | 6 +- .../appsmith/PositionedContainer.tsx | 4 +- .../constants/componentClassNameConstants.ts | 13 + .../GlobalHotKeys.test.tsx | 64 ++- .../{ => GlobalHotKeys}/GlobalHotKeys.tsx | 10 +- .../src/pages/Editor/GlobalHotKeys/index.tsx | 16 + .../Editor/GlobalHotKeys/useMouseLocation.tsx | 26 + .../GuidedTour/useComputeCurrentStep.ts | 5 +- .../pages/Editor/WidgetsMultiSelectBox.tsx | 3 +- .../CanvasArenas/CanvasSelectionArena.tsx | 8 +- app/client/src/sagas/WidgetOperationSagas.tsx | 425 ++++++++++++++- .../src/sagas/WidgetOperationUtils.test.ts | 356 +++++++++++++ app/client/src/sagas/WidgetOperationUtils.ts | 491 +++++++++++++++++- app/client/src/utils/generators.tsx | 3 +- .../BaseInputWidget/component/index.tsx | 5 +- .../widgets/InputWidget/component/index.tsx | 7 +- .../widgets/ModalWidget/component/index.tsx | 3 + .../src/widgets/ModalWidget/widget/index.tsx | 1 + 20 files changed, 1661 insertions(+), 42 deletions(-) create mode 100644 app/client/cypress/fixtures/WidgetCopyPaste.json create mode 100644 app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/WidgetCopyPaste/WidgetCopyPaste_spec.js create mode 100644 app/client/src/constants/componentClassNameConstants.ts rename app/client/src/pages/Editor/{ => GlobalHotKeys}/GlobalHotKeys.test.tsx (92%) rename app/client/src/pages/Editor/{ => GlobalHotKeys}/GlobalHotKeys.tsx (97%) create mode 100644 app/client/src/pages/Editor/GlobalHotKeys/index.tsx create mode 100644 app/client/src/pages/Editor/GlobalHotKeys/useMouseLocation.tsx diff --git a/app/client/cypress/fixtures/WidgetCopyPaste.json b/app/client/cypress/fixtures/WidgetCopyPaste.json new file mode 100644 index 0000000000..50569b707b --- /dev/null +++ b/app/client/cypress/fixtures/WidgetCopyPaste.json @@ -0,0 +1,149 @@ +{ + "dsl": { + "widgetName": "MainContainer", + "backgroundColor": "none", + "rightColumn": 1936, + "snapColumns": 64, + "detachFromLayout": true, + "widgetId": "0", + "topRow": 0, + "bottomRow": 1160, + "containerStyle": "none", + "snapRows": 116, + "parentRowSpace": 1, + "type": "CANVAS_WIDGET", + "canExtend": true, + "version": 54, + "minHeight": 1170, + "parentColumnSpace": 1, + "dynamicBindingPathList": [], + "leftColumn": 0, + "children": [ + { + "boxShadow": "NONE", + "widgetName": "Container1", + "borderColor": "transparent", + "isCanvas": true, + "displayName": "Container", + "iconSVG": "/static/media/icon.1977dca3.svg", + "topRow": 18, + "bottomRow": 58, + "parentRowSpace": 10, + "type": "CONTAINER_WIDGET", + "hideCard": false, + "animateLoading": true, + "parentColumnSpace": 30.0625, + "leftColumn": 6, + "children": [ + { + "widgetName": "Canvas1", + "rightColumn": 721.5, + "detachFromLayout": true, + "displayName": "Canvas", + "widgetId": "79a7avach5", + "containerStyle": "none", + "topRow": 0, + "bottomRow": 400, + "parentRowSpace": 1, + "isVisible": true, + "type": "CANVAS_WIDGET", + "canExtend": false, + "version": 1, + "hideCard": true, + "parentId": "drqlbbf2jm", + "minHeight": 400, + "renderMode": "CANVAS", + "isLoading": false, + "parentColumnSpace": 1, + "leftColumn": 0, + "children": [], + "key": "wv7g2n64td" + } + ], + "borderWidth": "0", + "key": "t9ac12itzf", + "backgroundColor": "#FFFFFF", + "rightColumn": 30, + "widgetId": "drqlbbf2jm", + "containerStyle": "card", + "isVisible": true, + "version": 1, + "parentId": "0", + "renderMode": "CANVAS", + "isLoading": false, + "borderRadius": "0" + }, + { + "widgetName": "Chart1", + "allowScroll": false, + "displayName": "Chart", + "iconSVG": "/static/media/icon.6adbe31e.svg", + "topRow": 20, + "bottomRow": 52, + "parentRowSpace": 10, + "type": "CHART_WIDGET", + "hideCard": false, + "chartData": { + "0jqgz3wqpx": { + "seriesName": "Sales", + "data": [ + { + "x": "Product1", + "y": 20000 + }, + { + "x": "Product2", + "y": 22000 + }, + { + "x": "Product3", + "y": 32000 + } + ] + } + }, + "animateLoading": true, + "parentColumnSpace": 30.0625, + "leftColumn": 35, + "customFusionChartConfig": { + "type": "column2d", + "dataSource": { + "chart": { + "caption": "Sales Report", + "xAxisName": "Product Line", + "yAxisName": "Revenue($)", + "theme": "fusion" + }, + "data": [ + { + "label": "Product1", + "value": 20000 + }, + { + "label": "Product2", + "value": 22000 + }, + { + "label": "Product3", + "value": 32000 + } + ] + } + }, + "key": "5dh7y0hcpk", + "rightColumn": 59, + "widgetId": "mxhtzoaizs", + "isVisible": true, + "version": 1, + "parentId": "0", + "labelOrientation": "auto", + "renderMode": "CANVAS", + "isLoading": false, + "yAxisName": "Revenue($)", + "chartName": "Sales Report", + "xAxisName": "Product Line", + "chartType": "COLUMN_CHART" + } + ] + } +} \ No newline at end of file diff --git a/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/WidgetCopyPaste/WidgetCopyPaste_spec.js b/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/WidgetCopyPaste/WidgetCopyPaste_spec.js new file mode 100644 index 0000000000..5234504354 --- /dev/null +++ b/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/WidgetCopyPaste/WidgetCopyPaste_spec.js @@ -0,0 +1,108 @@ +const widgetsPage = require("../../../../locators/Widgets.json"); +const commonLocators = require("../../../../locators/commonlocators.json"); +const explorer = require("../../../../locators/explorerlocators.json"); +const dsl = require("../../../../fixtures/WidgetCopyPaste.json"); + +describe("Widget Copy paste", function() { + const modifierKey = Cypress.platform === "darwin" ? "meta" : "ctrl"; + before(() => { + cy.addDsl(dsl); + }); + + it("when non Layout widget is selected, it should place below the widget selected", function() { + // Selection + cy.get(`#${dsl.dsl.children[1].widgetId}`).click({ + ctrlKey: true, + }); + cy.get(`div[data-testid='t--selected']`).should("have.length", 1); + + //copy + cy.get("body").type(`{${modifierKey}}{c}`); + cy.get(commonLocators.toastmsg).contains("Copied"); + + //paste + cy.get("body").type(`{${modifierKey}}{v}`); + cy.get(widgetsPage.chartWidget).should("have.length", 2); + + // verify the position + cy.get(widgetsPage.chartWidget) + .eq(0) + .then((element) => { + const elementTop = parseFloat(element.css("top")); + const elementHeight = parseFloat(element.css("height")); + const pastedWidgetTop = + (elementTop + elementHeight + 10).toString() + "px"; + cy.get(widgetsPage.chartWidget) + .eq(1) + .invoke("attr", "style") + .should("contain", `left: ${element.css("left")}`) + .should("contain", `top: ${pastedWidgetTop}`); + }); + }); + + it("when Layout widget is selected, it should place it inside the layout widget", function() { + cy.get(`#div-selection-0`).click({ + force: true, + }); + + // Selection + cy.get(`#${dsl.dsl.children[0].widgetId}`).click({ + ctrlKey: true, + }); + cy.get(`div[data-testid='t--selected']`).should("have.length", 1); + + //paste + cy.get("body").type(`{${modifierKey}}{v}`); + + cy.get(`#${dsl.dsl.children[0].widgetId}`) + .find(widgetsPage.chartWidget) + .should("have.length", 1); + }); + + it("when widget inside the layout widget is selected, then it should paste inside the layout widget below the selected widget", function() { + cy.get(`#div-selection-0`).click({ + force: true, + }); + + // Selection + cy.get(`#${dsl.dsl.children[0].widgetId}`) + .find(widgetsPage.chartWidget) + .click({ + ctrlKey: true, + }); + cy.get(`div[data-testid='t--selected']`).should("have.length", 1); + + //paste + cy.get("body").type(`{${modifierKey}}{v}`); + cy.get(`#${dsl.dsl.children[0].widgetId}`) + .find(widgetsPage.chartWidget) + .should("have.length", 2); + }); + + it("when modal is open, it should paste inside the modal", () => { + //add modal widget + cy.get(explorer.addWidget).click(); + cy.dragAndDropToCanvas("modalwidget", { x: 300, y: 700 }); + cy.get(".t--modal-widget").should("exist"); + + //paste + cy.get("body").type(`{${modifierKey}}{v}`); + cy.get(".t--modal-widget") + .find(widgetsPage.chartWidget) + .should("have.length", 1); + }); + + it("when widget Inside a modal is selected, it should paste inside the modal", () => { + //verify modal and selected widget + cy.get(".t--modal-widget").should("exist"); + cy.get(".t--modal-widget") + .find(`div[data-testid='t--selected']`) + .should("have.length", 1); + + //paste + cy.get("body").type(`{${modifierKey}}{v}`); + cy.get(".t--modal-widget") + .find(widgetsPage.chartWidget) + .should("have.length", 2); + }); +}); diff --git a/app/client/src/actions/widgetActions.tsx b/app/client/src/actions/widgetActions.tsx index 1855f1189b..db4609c3d5 100644 --- a/app/client/src/actions/widgetActions.tsx +++ b/app/client/src/actions/widgetActions.tsx @@ -103,11 +103,15 @@ export const copyWidget = (isShortcut: boolean) => { }; }; -export const pasteWidget = (groupWidgets = false) => { +export const pasteWidget = ( + groupWidgets = false, + mouseLocation: { x: number; y: number }, +) => { return { type: ReduxActionTypes.PASTE_COPIED_WIDGET_INIT, payload: { groupWidgets: groupWidgets, + mouseLocation, }, }; }; diff --git a/app/client/src/components/designSystems/appsmith/PositionedContainer.tsx b/app/client/src/components/designSystems/appsmith/PositionedContainer.tsx index acffc2b0af..fd67df21aa 100644 --- a/app/client/src/components/designSystems/appsmith/PositionedContainer.tsx +++ b/app/client/src/components/designSystems/appsmith/PositionedContainer.tsx @@ -11,6 +11,7 @@ import WidgetFactory from "utils/WidgetFactory"; import { isEqual, memoize } from "lodash"; import { getReflowSelector } from "selectors/widgetReflowSelectors"; import { AppState } from "reducers"; +import { POSITIONED_WIDGET } from "constants/componentClassNameConstants"; const PositionedWidget = styled.div<{ zIndexOnHover: number }>` &:hover { @@ -44,8 +45,7 @@ export function PositionedContainer(props: PositionedContainerProps) { const containerClassName = useMemo(() => { return ( generateClassName(props.widgetId) + - " positioned-widget " + - `t--widget-${props.widgetType + ` ${POSITIONED_WIDGET} t--widget-${props.widgetType .split("_") .join("") .toLowerCase()}` diff --git a/app/client/src/constants/componentClassNameConstants.ts b/app/client/src/constants/componentClassNameConstants.ts new file mode 100644 index 0000000000..fb79f40505 --- /dev/null +++ b/app/client/src/constants/componentClassNameConstants.ts @@ -0,0 +1,13 @@ +export function getStickyCanvasName(widgetId: string) { + return `div-selection-${widgetId}`; +} + +export function getSlidingCanvasName(widgetId: string) { + return `canvas-selection-${widgetId}`; +} + +export function getBaseWidgetClassName(id?: string) { + return `appsmith_widget_${id}`; +} + +export const POSITIONED_WIDGET = "positioned-widget"; diff --git a/app/client/src/pages/Editor/GlobalHotKeys.test.tsx b/app/client/src/pages/Editor/GlobalHotKeys/GlobalHotKeys.test.tsx similarity index 92% rename from app/client/src/pages/Editor/GlobalHotKeys.test.tsx rename to app/client/src/pages/Editor/GlobalHotKeys/GlobalHotKeys.test.tsx index 345fb07217..3c399a951f 100644 --- a/app/client/src/pages/Editor/GlobalHotKeys.test.tsx +++ b/app/client/src/pages/Editor/GlobalHotKeys/GlobalHotKeys.test.tsx @@ -7,7 +7,7 @@ import { } from "test/factories/WidgetFactoryUtils"; import { act, render, fireEvent, waitFor } from "test/testUtils"; import GlobalHotKeys from "./GlobalHotKeys"; -import MainContainer from "./MainContainer"; +import MainContainer from "../MainContainer"; import { MemoryRouter } from "react-router-dom"; import * as utilities from "selectors/editorSelectors"; import store from "store"; @@ -56,7 +56,7 @@ describe("Canvas Hot Keys", () => { rootSaga: mockGenerator, })); - // only the deafault exports are mocked to avoid overriding utilities exported out of them. defaults are marked to avoid worker initiation and page api calls in tests. + // only the default exports are mocked to avoid overriding utilities exported out of them. defaults are marked to avoid worker initiation and page api calls in tests. jest.mock("sagas/EvaluationsSaga", () => ({ ...jest.requireActual("sagas/EvaluationsSaga"), default: mockGenerator, @@ -84,7 +84,11 @@ describe("Canvas Hot Keys", () => { initialEntries={["/app/applicationSlug/pageSlug-page_id/edit"]} > - + { + return { x: 0, y: 0 }; + }} + > @@ -204,7 +208,11 @@ describe("Canvas Hot Keys", () => { initialEntries={["/app/applicationSlug/pageSlug-page_id/edit"]} > - + { + return { x: 0, y: 0 }; + }} + > @@ -246,7 +254,11 @@ describe("Canvas Hot Keys", () => { initialEntries={["/app/applicationSlug/pageSlug-page_id/edit"]} > - + { + return { x: 0, y: 0 }; + }} + > @@ -331,7 +343,11 @@ describe("Canvas Hot Keys", () => { initialEntries={["/app/applicationSlug/pageSlug-page_id/edit"]} > - + { + return { x: 0, y: 0 }; + }} + > @@ -388,7 +404,11 @@ describe("Cut/Copy/Paste hotkey", () => { }); const component = render( - + { + return { x: 0, y: 0 }; + }} + > , @@ -469,7 +489,11 @@ describe("Cut/Copy/Paste hotkey", () => { }); const component = render( - + { + return { x: 0, y: 0 }; + }} + > , @@ -540,7 +564,11 @@ describe("Undo/Redo hotkey", () => { const dispatchSpy = jest.spyOn(store, "dispatch"); const component = render( - + { + return { x: 0, y: 0 }; + }} + > , @@ -566,7 +594,11 @@ describe("Undo/Redo hotkey", () => { const dispatchSpy = jest.spyOn(store, "dispatch"); const component = render( - + { + return { x: 0, y: 0 }; + }} + > , @@ -592,7 +624,11 @@ describe("Undo/Redo hotkey", () => { const dispatchSpy = jest.spyOn(store, "dispatch"); const component = render( - + { + return { x: 0, y: 0 }; + }} + > , @@ -628,7 +664,11 @@ describe("cmd + s hotkey", () => { pauseOnHover={false} transition={Slide} /> - + { + return { x: 0, y: 0 }; + }} + >
, diff --git a/app/client/src/pages/Editor/GlobalHotKeys.tsx b/app/client/src/pages/Editor/GlobalHotKeys/GlobalHotKeys.tsx similarity index 97% rename from app/client/src/pages/Editor/GlobalHotKeys.tsx rename to app/client/src/pages/Editor/GlobalHotKeys/GlobalHotKeys.tsx index c873597ee0..1445839b61 100644 --- a/app/client/src/pages/Editor/GlobalHotKeys.tsx +++ b/app/client/src/pages/Editor/GlobalHotKeys/GlobalHotKeys.tsx @@ -55,7 +55,7 @@ import { commentModeSelector } from "selectors/commentsSelectors"; type Props = { copySelectedWidget: () => void; - pasteCopiedWidget: () => void; + pasteCopiedWidget: (mouseLocation: { x: number; y: number }) => void; deleteSelectedWidget: () => void; cutSelectedWidget: () => void; groupSelectedWidget: () => void; @@ -80,6 +80,7 @@ type Props = { isExplorerPinned: boolean; setExplorerPinnedAction: (shouldPinned: boolean) => void; showCommitModal: () => void; + getMousePosition: () => { x: number; y: number }; }; @HotkeysTarget @@ -215,7 +216,9 @@ class GlobalHotKeys extends React.Component { group="Canvas" label="Paste Widget" onKeyDown={() => { - this.props.pasteCopiedWidget(); + this.props.pasteCopiedWidget( + this.props.getMousePosition() || { x: 0, y: 0 }, + ); }} /> ({ const mapDispatchToProps = (dispatch: any) => { return { copySelectedWidget: () => dispatch(copyWidget(true)), - pasteCopiedWidget: () => dispatch(pasteWidget()), + pasteCopiedWidget: (mouseLocation: { x: number; y: number }) => + dispatch(pasteWidget(false, mouseLocation)), deleteSelectedWidget: () => dispatch(deleteSelectedWidget(true)), cutSelectedWidget: () => dispatch(cutWidget()), groupSelectedWidget: () => dispatch(groupWidgets()), diff --git a/app/client/src/pages/Editor/GlobalHotKeys/index.tsx b/app/client/src/pages/Editor/GlobalHotKeys/index.tsx new file mode 100644 index 0000000000..4685d4240b --- /dev/null +++ b/app/client/src/pages/Editor/GlobalHotKeys/index.tsx @@ -0,0 +1,16 @@ +import GlobalHotKeys from "./GlobalHotKeys"; +import React from "react"; +import { useMouseLocation } from "./useMouseLocation"; + +//HOC to track user's mouse location, separated out so that it doesn't render the component on every mouse move +function HotKeysHOC(props: any) { + const getMousePosition = useMouseLocation(); + + return ( + + {props.children} + + ); +} + +export default HotKeysHOC; diff --git a/app/client/src/pages/Editor/GlobalHotKeys/useMouseLocation.tsx b/app/client/src/pages/Editor/GlobalHotKeys/useMouseLocation.tsx new file mode 100644 index 0000000000..a9a4015230 --- /dev/null +++ b/app/client/src/pages/Editor/GlobalHotKeys/useMouseLocation.tsx @@ -0,0 +1,26 @@ +import { useEffect, useRef } from "react"; + +export const useMouseLocation = () => { + const mousePosition = useRef<{ x: number; y: number }>({ + x: 0, + y: 0, + }); + + const setMousePosition = (e: any) => { + if (e) { + mousePosition.current = { x: e.clientX, y: e.clientY }; + } + }; + + useEffect(() => { + window.addEventListener("mousemove", setMousePosition); + + () => { + window.removeEventListener("mousemove", setMousePosition); + }; + }, []); + + return function() { + return mousePosition.current; + }; +}; diff --git a/app/client/src/pages/Editor/GuidedTour/useComputeCurrentStep.ts b/app/client/src/pages/Editor/GuidedTour/useComputeCurrentStep.ts index df58874a93..8cf03e0b15 100644 --- a/app/client/src/pages/Editor/GuidedTour/useComputeCurrentStep.ts +++ b/app/client/src/pages/Editor/GuidedTour/useComputeCurrentStep.ts @@ -28,6 +28,7 @@ import { countryInputSelector, imageWidgetSelector, } from "selectors/onboardingSelectors"; +import { getBaseWidgetClassName } from "constants/componentClassNameConstants"; import { GUIDED_TOUR_STEPS, Steps } from "./constants"; import { hideIndicator, highlightSection, showIndicator } from "./utils"; @@ -230,11 +231,11 @@ function useComputeCurrentStep(showInfoMessage: boolean) { // Highlight the selected row and the NameInput widget highlightSection( "selected-row", - `appsmith_widget_${isTableWidgetBound}`, + getBaseWidgetClassName(isTableWidgetBound), "class", ); highlightSection( - `appsmith_widget_${nameInputWidgetId}`, + getBaseWidgetClassName(nameInputWidgetId), undefined, "class", ); diff --git a/app/client/src/pages/Editor/WidgetsMultiSelectBox.tsx b/app/client/src/pages/Editor/WidgetsMultiSelectBox.tsx index 454f109a70..c3e02fcde1 100644 --- a/app/client/src/pages/Editor/WidgetsMultiSelectBox.tsx +++ b/app/client/src/pages/Editor/WidgetsMultiSelectBox.tsx @@ -25,6 +25,7 @@ import WidgetFactory from "utils/WidgetFactory"; import { AppState } from "reducers"; import { useWidgetDragResize } from "utils/hooks/dragResizeHooks"; import { commentModeSelector } from "selectors/commentsSelectors"; +import { POSITIONED_WIDGET } from "constants/componentClassNameConstants"; const WidgetTypes = WidgetFactory.widgetTypes; const StyledSelectionBox = styled.div` @@ -239,7 +240,7 @@ function WidgetsMultiSelectBox(props: { const { height, left, top, width } = useMemo(() => { if (shouldRender) { const widgetClasses = selectedWidgetIDs - .map((id) => `.${generateClassName(id)}.positioned-widget`) + .map((id) => `.${generateClassName(id)}.${POSITIONED_WIDGET}`) .join(","); const elements = document.querySelectorAll(widgetClasses); diff --git a/app/client/src/pages/common/CanvasArenas/CanvasSelectionArena.tsx b/app/client/src/pages/common/CanvasArenas/CanvasSelectionArena.tsx index ae129d1f6e..37d74407f0 100644 --- a/app/client/src/pages/common/CanvasArenas/CanvasSelectionArena.tsx +++ b/app/client/src/pages/common/CanvasArenas/CanvasSelectionArena.tsx @@ -23,6 +23,10 @@ import { getIsDraggingForSelection } from "selectors/canvasSelectors"; import { commentModeSelector } from "selectors/commentsSelectors"; import { StickyCanvasArena } from "./StickyCanvasArena"; import { getAbsolutePixels } from "utils/helpers"; +import { + getSlidingCanvasName, + getStickyCanvasName, +} from "constants/componentClassNameConstants"; export interface SelectedArenaDimensions { top: number; @@ -482,10 +486,10 @@ export function CanvasSelectionArena({ return shouldShow ? ( ) { try { @@ -739,7 +766,10 @@ function* copyWidgetSaga(action: ReduxAction<{ isShortcut: boolean }>) { * @param parentId * @param canvasWidgets * @param parentBottomRow - * @param persistColumnPosition + * @param newPastingPositionMap + * @param shouldPersistColumnPosition + * @param isThereACollision + * @param shouldGroup * @returns */ export function calculateNewWidgetPosition( @@ -747,6 +777,7 @@ export function calculateNewWidgetPosition( parentId: string, canvasWidgets: { [widgetId: string]: FlattenedWidgetProps }, parentBottomRow?: number, + newPastingPositionMap?: SpaceMap, shouldPersistColumnPosition = false, isThereACollision = false, shouldGroup = false, @@ -756,6 +787,20 @@ export function calculateNewWidgetPosition( leftColumn: number; rightColumn: number; } { + if ( + !shouldGroup && + newPastingPositionMap && + newPastingPositionMap[widget.widgetId] + ) { + const newPastingPosition = newPastingPositionMap[widget.widgetId]; + return { + topRow: newPastingPosition.top, + bottomRow: newPastingPosition.bottom, + leftColumn: newPastingPosition.left, + rightColumn: newPastingPosition.right, + }; + } + const nextAvailableRow = parentBottomRow ? parentBottomRow : nextAvailableRowInContainer(parentId, canvasWidgets); @@ -779,10 +824,335 @@ export function calculateNewWidgetPosition( }; } +/** + * Method to provide the new positions where the widgets can be pasted. + * It will return an empty object if it doesn't have any selected widgets, or if the mouse is outside the canvas. + * + * @param copiedWidgetGroups Contains information on the copied widgets + * @param mouseLocation location of the mouse in absolute pixels + * @param copiedTotalWidth total width of the copied widgets + * @param copiedTopMostRow top row of the top most copied widget + * @param copiedLeftMostColumn left column of the left most copied widget + * @returns + */ +const getNewPositions = function*( + copiedWidgetGroups: CopiedWidgetGroup[], + mouseLocation: { x: number; y: number }, + copiedTotalWidth: number, + copiedTopMostRow: number, + copiedLeftMostColumn: number, +) { + const selectedWidgetIDs: string[] = yield select(getSelectedWidgets); + const canvasWidgets: CanvasWidgetsReduxState = yield select(getWidgets); + const selectedWidgets = getWidgetsFromIds(selectedWidgetIDs, canvasWidgets); + + //if the copied widget is a modal widget, then it has to paste on the main container + if ( + copiedWidgetGroups.length === 1 && + copiedWidgetGroups[0].list[0] && + copiedWidgetGroups[0].list[0].type === "MODAL_WIDGET" + ) + return {}; + + //if multiple widgets are selected or if a single non-layout widget is selected, + // then call the method to calculate and return positions based on selected widgets. + if ( + !( + selectedWidgets.length === 1 && + isDropTarget(selectedWidgets[0].type, true) + ) && + selectedWidgets.length > 0 + ) { + const newPastingPositionDetails: NewPastePositionVariables = yield call( + getNewPositionsBasedOnSelectedWidgets, + copiedWidgetGroups, + selectedWidgets, + canvasWidgets, + copiedTotalWidth, + copiedTopMostRow, + copiedLeftMostColumn, + ); + return newPastingPositionDetails; + } + + //if a layout widget is selected or mouse is on the main canvas + // then call the method to calculate and return positions mouse positions. + const newPastingPositionDetails: NewPastePositionVariables = yield call( + getNewPositionsBasedOnMousePositions, + copiedWidgetGroups, + mouseLocation, + selectedWidgets, + canvasWidgets, + copiedTotalWidth, + copiedTopMostRow, + copiedLeftMostColumn, + ); + return newPastingPositionDetails; +}; + +/** + * Calculates the new positions of the pasting widgets, based on the selected widgets + * The new positions will be just below the selected widgets + * + * @param copiedWidgetGroups Contains information on the copied widgets + * @param selectedWidgets array of selected widgets + * @param canvasWidgets canvas widgets from the DSL + * @param copiedTotalWidth total width of the copied widgets + * @param copiedTopMostRow top row of the top most copied widget + * @param copiedLeftMostColumn left column of the left most copied widget + * @returns + */ +function* getNewPositionsBasedOnSelectedWidgets( + copiedWidgetGroups: CopiedWidgetGroup[], + selectedWidgets: WidgetProps[], + canvasWidgets: CanvasWidgetsReduxState, + copiedTotalWidth: number, + copiedTopMostRow: number, + copiedLeftMostColumn: number, +) { + //get Parent canvasId + const parentId = selectedWidgets[0].parentId || ""; + + // get the Id of the container widget based on the canvasId + const containerId = getContainerIdForCanvas(parentId); + + const containerWidget = canvasWidgets[containerId]; + const canvasDOM = document.querySelector( + `#${getSlidingCanvasName(parentId)}`, + ); + + if (!canvasDOM || !containerWidget) return {}; + + const rect = canvasDOM.getBoundingClientRect(); + + // get Grid values such as snapRowSpace and snapColumnSpace + const { snapGrid } = getSnappedGrid(containerWidget, rect.width); + + const selectedWidgetsArray = selectedWidgets.length ? selectedWidgets : []; + //from selected widgets get some information required for position calculation + const { + leftMostColumn: selectedLeftMostColumn, + maxThickness, + topMostRow: selectedTopMostRow, + totalWidth, + } = getBoundariesFromSelectedWidgets(selectedWidgetsArray); + + // calculation of left most column of where widgets are to be pasted + let pasteLeftMostColumn = + selectedLeftMostColumn - (copiedTotalWidth - totalWidth) / 2; + + pasteLeftMostColumn = Math.round(pasteLeftMostColumn); + + // conditions to adjust to the edges of the boundary, so that it doesn't go out of canvas + if (pasteLeftMostColumn < 0) pasteLeftMostColumn = 0; + if ( + pasteLeftMostColumn + copiedTotalWidth > + GridDefaults.DEFAULT_GRID_COLUMNS + ) + pasteLeftMostColumn = GridDefaults.DEFAULT_GRID_COLUMNS - copiedTotalWidth; + + // based on the above calculation get the new Positions that are aligned to the top left of selected widgets + // i.e., the top of the selected widgets will be equal to the top of copied widgets and both are horizontally centered + const newPositionsForCopiedWidgets = getNewPositionsForCopiedWidgets( + copiedWidgetGroups, + copiedTopMostRow, + selectedTopMostRow, + copiedLeftMostColumn, + pasteLeftMostColumn, + ); + + // with the new positions, calculate the map of new position, which are moved down to the point where + // it doesn't overlap with any of the selected widgets. + const newPastingPositionMap = getVerticallyAdjustedPositions( + newPositionsForCopiedWidgets, + getOccupiedSpacesFromProps(selectedWidgetsArray), + maxThickness, + ); + + if (!newPastingPositionMap) return {}; + + const gridProps = { + parentColumnSpace: snapGrid.snapColumnSpace, + parentRowSpace: snapGrid.snapRowSpace, + maxGridColumns: GridDefaults.DEFAULT_GRID_COLUMNS, + }; + + const reflowSpacesSelector = getWidgetSpacesSelectorForContainer(parentId); + const widgetSpaces: WidgetSpace[] = yield select(reflowSpacesSelector) || []; + + // Ids of each pasting are changed just for reflow + const newPastePositions = changeIdsOfPastePositions(newPastingPositionMap); + + const { movementMap: reflowedMovementMap } = reflow( + newPastePositions, + newPastePositions, + widgetSpaces, + ReflowDirection.BOTTOM, + gridProps, + true, + false, + { prevSpacesMap: {} } as PrevReflowState, + ); + + // calculate the new bottom most row of the canvas + const bottomMostRow = getBottomRowAfterReflow( + reflowedMovementMap, + getBottomMostRow(newPastePositions), + widgetSpaces, + gridProps, + ); + + return { + bottomMostRow: + (bottomMostRow + GridDefaults.CANVAS_EXTENSION_OFFSET) * + gridProps.parentRowSpace, + gridProps, + newPastingPositionMap, + reflowedMovementMap, + canvasId: parentId, + }; +} + +/** + * Calculates the new positions of the pasting widgets, based on the mouse position + * If the mouse position is on the canvas it the top left of the new positions aligns itself to the mouse position + * returns a empty object if the mouse is out of canvas + * + * @param copiedWidgetGroups Contains information on the copied widgets + * @param mouseLocation location of the mouse in absolute pixels + * @param selectedWidgets array of selected widgets + * @param canvasWidgets canvas widgets from the DSL + * @param copiedTotalWidth total width of the copied widgets + * @param copiedTopMostRow top row of the top most copied widget + * @param copiedLeftMostColumn left column of the left most copied widget + * @returns + */ +function* getNewPositionsBasedOnMousePositions( + copiedWidgetGroups: CopiedWidgetGroup[], + mouseLocation: { x: number; y: number }, + selectedWidgets: WidgetProps[], + canvasWidgets: CanvasWidgetsReduxState, + copiedTotalWidth: number, + copiedTopMostRow: number, + copiedLeftMostColumn: number, +) { + let { canvasDOM, canvasId, containerWidget } = getDefaultCanvas( + canvasWidgets, + ); + + //if the selected widget is a layout widget then change the pasting canvas. + if (selectedWidgets.length === 1 && isDropTarget(selectedWidgets[0].type)) { + containerWidget = selectedWidgets[0]; + ({ canvasDOM, canvasId } = getCanvasIdForContainer(containerWidget)); + } + + if (!canvasDOM || !containerWidget || !canvasId) return {}; + + const canvasRect = canvasDOM.getBoundingClientRect(); + + // get Grid values such as snapRowSpace and snapColumnSpace + const { padding, snapGrid } = getSnappedGrid( + containerWidget, + canvasRect.width, + ); + + // get mouse positions in terms of grid rows and columns of the pasting canvas + const mousePositions = getMousePositions( + canvasRect, + canvasId, + snapGrid, + padding, + mouseLocation, + ); + + if (!snapGrid || !mousePositions) return {}; + + const reflowSpacesSelector = getWidgetSpacesSelectorForContainer(canvasId); + const widgetSpaces: WidgetSpace[] = yield select(reflowSpacesSelector) || []; + + let mouseTopRow = mousePositions.top; + let mouseLeftColumn = mousePositions.left; + + // if the mouse position is on another widget on the canvas, then new positions are below it. + for (const widgetSpace of widgetSpaces) { + if ( + widgetSpace.top < mousePositions.top && + widgetSpace.left < mousePositions.left && + widgetSpace.bottom > mousePositions.top && + widgetSpace.right > mousePositions.left + ) { + mouseTopRow = widgetSpace.bottom + WIDGET_PASTE_PADDING; + mouseLeftColumn = + widgetSpace.left - + (copiedTotalWidth - (widgetSpace.right - widgetSpace.left)) / 2; + break; + } + } + + mouseLeftColumn = Math.round(mouseLeftColumn); + + // adjust the top left based on the edges of the canvas + if (mouseLeftColumn < 0) mouseLeftColumn = 0; + if (mouseLeftColumn + copiedTotalWidth > GridDefaults.DEFAULT_GRID_COLUMNS) + mouseLeftColumn = GridDefaults.DEFAULT_GRID_COLUMNS - copiedTotalWidth; + + // get the new Pasting positions of the widgets based on the adjusted mouse top-left + const newPastingPositionMap = getPastePositionMapFromMousePointer( + copiedWidgetGroups, + copiedTopMostRow, + mouseTopRow, + copiedLeftMostColumn, + mouseLeftColumn, + ); + + const gridProps = { + parentColumnSpace: snapGrid.snapColumnSpace, + parentRowSpace: snapGrid.snapRowSpace, + maxGridColumns: GridDefaults.DEFAULT_GRID_COLUMNS, + }; + + // Ids of each pasting are changed just for reflow + const newPastePositions = changeIdsOfPastePositions(newPastingPositionMap); + + const { movementMap: reflowedMovementMap } = reflow( + newPastePositions, + newPastePositions, + widgetSpaces, + ReflowDirection.BOTTOM, + gridProps, + true, + false, + { prevSpacesMap: {} } as PrevReflowState, + ); + + // calculate the new bottom most row of the canvas. + const bottomMostRow = getBottomRowAfterReflow( + reflowedMovementMap, + getBottomMostRow(newPastePositions), + widgetSpaces, + gridProps, + ); + + return { + bottomMostRow: + (bottomMostRow + GridDefaults.CANVAS_EXTENSION_OFFSET) * + gridProps.parentRowSpace, + gridProps, + newPastingPositionMap, + reflowedMovementMap, + canvasId, + }; +} + /** * this saga create a new widget from the copied one to store */ -function* pasteWidgetSaga(action: ReduxAction<{ groupWidgets: boolean }>) { +function* pasteWidgetSaga( + action: ReduxAction<{ + groupWidgets: boolean; + mouseLocation: { x: number; y: number }; + }>, +) { let copiedWidgetGroups: CopiedWidgetGroup[] = yield getCopiedWidgets(); const shouldGroup: boolean = action.payload.groupWidgets; @@ -836,14 +1206,36 @@ function* pasteWidgetSaga(action: ReduxAction<{ groupWidgets: boolean }>) { ) return; - const { topMostWidget } = getBoundaryWidgetsFromCopiedGroups( - copiedWidgetGroups, - ); + const { + leftMostWidget, + topMostWidget, + totalWidth: copiedTotalWidth, + } = getBoundaryWidgetsFromCopiedGroups(copiedWidgetGroups); + const nextAvailableRow: number = nextAvailableRowInContainer( pastingIntoWidgetId, widgets, ); + // new pasting positions, the variables are undefined if the positions cannot be calculated, + // then it pastes the regular way at the bottom of the canvas + const { + bottomMostRow, + canvasId, + gridProps, + newPastingPositionMap, + reflowedMovementMap, + }: NewPastePositionVariables = yield call( + getNewPositions, + copiedWidgetGroups, + action.payload.mouseLocation, + copiedTotalWidth, + topMostWidget.topRow, + leftMostWidget.leftColumn, + ); + + if (canvasId) pastingIntoWidgetId = canvasId; + yield all( copiedWidgetGroups.map((copiedWidgets) => call(function*() { @@ -883,6 +1275,7 @@ function* pasteWidgetSaga(action: ReduxAction<{ groupWidgets: boolean }>) { pastingIntoWidgetId, widgets, nextAvailableRow, + newPastingPositionMap, true, isThereACollision, shouldGroup, @@ -1002,7 +1395,7 @@ function* pasteWidgetSaga(action: ReduxAction<{ groupWidgets: boolean }>) { ...widgets, [pastingIntoWidgetId]: { ...widgets[pastingIntoWidgetId], - bottomRow: parentBottomRow, + bottomRow: Math.max(parentBottomRow, bottomMostRow || 0), children: parentChildren, }, }; @@ -1064,9 +1457,19 @@ function* pasteWidgetSaga(action: ReduxAction<{ groupWidgets: boolean }>) { ), ); - yield put(updateAndSaveLayout(widgets)); + //calculate the new positions of the reflowed widgets + const reflowedWidgets = getReflowedPositions( + widgets, + gridProps, + reflowedMovementMap, + ); - flashElementsById(newlyCreatedWidgetIds, 100); + yield put(updateAndSaveLayout(reflowedWidgets)); + + //if pasting at the bottom of the canvas, then flash it. + if (shouldGroup || !newPastingPositionMap) { + flashElementsById(newlyCreatedWidgetIds, 100); + } yield put(selectMultipleWidgetsInitAction(newlyCreatedWidgetIds)); } diff --git a/app/client/src/sagas/WidgetOperationUtils.test.ts b/app/client/src/sagas/WidgetOperationUtils.test.ts index a084d3dd8d..126fa671eb 100644 --- a/app/client/src/sagas/WidgetOperationUtils.test.ts +++ b/app/client/src/sagas/WidgetOperationUtils.test.ts @@ -1,5 +1,7 @@ +import { OccupiedSpace } from "constants/CanvasEditorConstants"; import { get } from "lodash"; import { WidgetProps } from "widgets/BaseWidget"; +import { FlattenedWidgetProps } from "widgets/constants"; import { handleIfParentIsListWidgetWhilePasting, handleSpecificCasesWhilePasting, @@ -7,6 +9,15 @@ import { checkIfPastingIntoListWidget, updateListWidgetPropertiesOnChildDelete, purgeOrphanedDynamicPaths, + getBoundariesFromSelectedWidgets, + getSnappedGrid, + changeIdsOfPastePositions, + getVerticallyAdjustedPositions, + getNewPositionsForCopiedWidgets, + CopiedWidgetGroup, + getPastePositionMapFromMousePointer, + getReflowedPositions, + getWidgetsFromIds, } from "./WidgetOperationUtils"; describe("WidgetOperationSaga", () => { @@ -622,4 +633,349 @@ describe("WidgetOperationSaga", () => { const result = purgeOrphanedDynamicPaths((input as any) as WidgetProps); expect(result).toStrictEqual(expected); }); + it("should return boundaries of selected Widgets", () => { + const selectedWidgets = ([ + { + id: "1234", + topRow: 10, + leftColumn: 20, + rightColumn: 45, + bottomRow: 40, + }, + { + id: "1233", + topRow: 45, + leftColumn: 30, + rightColumn: 60, + bottomRow: 70, + }, + ] as any) as WidgetProps[]; + expect(getBoundariesFromSelectedWidgets(selectedWidgets)).toEqual({ + totalWidth: 40, + maxThickness: 30, + topMostRow: 10, + leftMostColumn: 20, + }); + }); + describe("test getSnappedGrid", () => { + it("should return snapGrids for a ContainerWidget", () => { + const canvasWidget = ({ + widgetId: "1234", + type: "CONTAINER_WIDGET", + noPad: true, + } as any) as WidgetProps; + expect(getSnappedGrid(canvasWidget, 250)).toEqual({ + padding: 4, + snapGrid: { + snapColumnSpace: 3.78125, + snapRowSpace: 10, + }, + }); + }); + it("should return snapGrids for non ContainerWidget", () => { + const canvasWidget = ({ + widgetId: "1234", + type: "LIST_WIDGET", + noPad: false, + } as any) as WidgetProps; + expect(getSnappedGrid(canvasWidget, 250)).toEqual({ + padding: 10, + snapGrid: { + snapColumnSpace: 3.59375, + snapRowSpace: 10, + }, + }); + }); + }); + it("should test changeIdsOfPastePositions", () => { + const newPastingPositionMap = { + "1234": { + id: "1234", + left: 10, + right: 20, + top: 10, + bottom: 20, + }, + "1235": { + id: "1235", + left: 11, + right: 22, + top: 11, + bottom: 22, + }, + }; + expect(changeIdsOfPastePositions(newPastingPositionMap)).toEqual([ + { + id: "1", + left: 10, + right: 20, + top: 10, + bottom: 20, + }, + { + id: "2", + left: 11, + right: 22, + top: 11, + bottom: 22, + }, + ]); + }); + + it("should offset widgets vertically so that it doesn't overlap with selected widgets", () => { + const selectedWidgets = [ + { + id: "1234", + top: 10, + left: 20, + right: 45, + bottom: 40, + }, + { + id: "1233", + top: 45, + left: 30, + right: 60, + bottom: 70, + }, + { + id: "1235", + topRow: 80, + left: 10, + right: 50, + bottom: 100, + }, + ] as OccupiedSpace[]; + const copiedWidgets = ([ + { + id: "1234", + top: 10, + left: 20, + right: 45, + bottom: 40, + }, + { + id: "1233", + top: 45, + left: 30, + right: 60, + bottom: 70, + }, + ] as any) as OccupiedSpace[]; + expect( + getVerticallyAdjustedPositions(copiedWidgets, selectedWidgets, 30), + ).toEqual({ + "1234": { + id: "1234", + top: 71, + left: 20, + right: 45, + bottom: 101, + }, + "1233": { + id: "1233", + top: 106, + left: 30, + right: 60, + bottom: 131, + }, + }); + }); + it("should test getNewPositionsForCopiedWidgets", () => { + const copiedGroups = ([ + { + widgetId: "1234", + list: [ + { + topRow: 10, + leftColumn: 20, + rightColumn: 45, + bottomRow: 40, + }, + ], + }, + { + widgetId: "1235", + list: [ + { + topRow: 45, + leftColumn: 25, + rightColumn: 40, + bottomRow: 80, + }, + ], + }, + ] as any) as CopiedWidgetGroup[]; + expect( + getNewPositionsForCopiedWidgets(copiedGroups, 10, 40, 20, 10), + ).toEqual([ + { + id: "1234", + top: 40, + left: 10, + right: 35, + bottom: 70, + }, + { + id: "1235", + top: 75, + left: 15, + right: 30, + bottom: 110, + }, + ]); + }); + it("should test getPastePositionMapFromMousePointer", () => { + const copiedGroups = ([ + { + widgetId: "1234", + list: [ + { + topRow: 10, + leftColumn: 20, + rightColumn: 45, + bottomRow: 40, + }, + ], + }, + { + widgetId: "1235", + list: [ + { + topRow: 45, + leftColumn: 25, + rightColumn: 40, + bottomRow: 80, + }, + ], + }, + ] as any) as CopiedWidgetGroup[]; + expect( + getPastePositionMapFromMousePointer(copiedGroups, 10, 40, 20, 10), + ).toEqual({ + "1234": { + id: "1234", + top: 40, + left: 10, + right: 35, + bottom: 70, + }, + "1235": { + id: "1235", + top: 75, + left: 15, + right: 30, + bottom: 110, + }, + }); + }); + it("should test getReflowedPositions", () => { + const widgets = { + "1234": { + widgetId: "1234", + topRow: 40, + leftColumn: 10, + rightColumn: 35, + bottomRow: 70, + } as FlattenedWidgetProps, + "1233": { + widgetId: "1233", + topRow: 45, + leftColumn: 30, + rightColumn: 60, + bottomRow: 70, + } as FlattenedWidgetProps, + "1235": { + widgetId: "1235", + topRow: 75, + leftColumn: 15, + rightColumn: 30, + bottomRow: 110, + } as FlattenedWidgetProps, + }; + + const gridProps = { + parentRowSpace: 10, + parentColumnSpace: 10, + maxGridColumns: 64, + }; + + const reflowingWidgets = { + "1234": { + X: 30, + width: 200, + }, + "1235": { + X: 40, + width: 250, + Y: 50, + height: 250, + }, + }; + + expect(getReflowedPositions(widgets, gridProps, reflowingWidgets)).toEqual({ + "1234": { + widgetId: "1234", + topRow: 40, + leftColumn: 13, + rightColumn: 33, + bottomRow: 70, + }, + "1233": { + widgetId: "1233", + topRow: 45, + leftColumn: 30, + rightColumn: 60, + bottomRow: 70, + }, + "1235": { + widgetId: "1235", + topRow: 80, + leftColumn: 19, + rightColumn: 44, + bottomRow: 105, + }, + }); + }); + it("should test getWidgetsFromIds", () => { + const widgets = { + "1234": { + widgetId: "1234", + topRow: 40, + leftColumn: 10, + rightColumn: 35, + bottomRow: 70, + } as FlattenedWidgetProps, + "1233": { + widgetId: "1233", + topRow: 45, + leftColumn: 30, + rightColumn: 60, + bottomRow: 70, + } as FlattenedWidgetProps, + "1235": { + widgetId: "1235", + topRow: 75, + leftColumn: 15, + rightColumn: 30, + bottomRow: 110, + } as FlattenedWidgetProps, + }; + expect(getWidgetsFromIds(["1235", "1234", "1237"], widgets)).toEqual([ + { + widgetId: "1235", + topRow: 75, + leftColumn: 15, + rightColumn: 30, + bottomRow: 110, + }, + { + widgetId: "1234", + topRow: 40, + leftColumn: 10, + rightColumn: 35, + bottomRow: 70, + }, + ]); + }); }); diff --git a/app/client/src/sagas/WidgetOperationUtils.ts b/app/client/src/sagas/WidgetOperationUtils.ts index a170a36118..d2b2b3c95d 100644 --- a/app/client/src/sagas/WidgetOperationUtils.ts +++ b/app/client/src/sagas/WidgetOperationUtils.ts @@ -6,10 +6,12 @@ import { } from "./selectors"; import _, { isString, remove } from "lodash"; import { + CONTAINER_GRID_PADDING, GridDefaults, MAIN_CONTAINER_WIDGET_ID, RenderModes, WidgetType, + WIDGET_PADDING, } from "constants/WidgetConstants"; import { all, call } from "redux-saga/effects"; import { DataTree } from "entities/DataTree/dataTreeFactory"; @@ -31,6 +33,15 @@ import { import { getNextEntityName } from "utils/AppsmithUtils"; import WidgetFactory from "utils/WidgetFactory"; import { getParentWithEnhancementFn } from "./WidgetEnhancementHelpers"; +import { OccupiedSpace } from "constants/CanvasEditorConstants"; +import { areIntersecting } from "utils/WidgetPropsUtils"; +import { GridProps, ReflowedSpaceMap, SpaceMap } from "reflow/reflowTypes"; +import { + getBaseWidgetClassName, + getSlidingCanvasName, + getStickyCanvasName, + POSITIONED_WIDGET, +} from "constants/componentClassNameConstants"; export interface CopiedWidgetGroup { widgetId: string; @@ -38,6 +49,16 @@ export interface CopiedWidgetGroup { list: WidgetProps[]; } +export type NewPastePositionVariables = { + bottomMostRow?: number; + gridProps?: GridProps; + newPastingPositionMap?: SpaceMap; + reflowedMovementMap?: ReflowedSpaceMap; + canvasId?: string; +}; + +export const WIDGET_PASTE_PADDING = 1; + /** * checks if triggerpaths contains property path passed * @@ -310,6 +331,8 @@ export const getParentWidgetIdForPasting = function*( if (childWidget && childWidget.type === "CANVAS_WIDGET") { newWidgetParentId = childWidget.widgetId; } + } else if (selectedWidget && selectedWidget.type === "CANVAS_WIDGET") { + newWidgetParentId = selectedWidget.widgetId; } return newWidgetParentId; }; @@ -365,7 +388,7 @@ export const checkIfPastingIntoListWidget = function( }; /** - * get top, left, right, bottom most widgets from copied groups when pasting + * get top, left, right, bottom most widgets and and totalWidth from copied groups when pasting * * @param copiedWidgetGroups * @returns @@ -385,15 +408,43 @@ export const getBoundaryWidgetsFromCopiedGroups = function( const bottomMostWidget = copiedWidgetGroups.sort( (a, b) => b.list[0].bottomRow - a.list[0].bottomRow, )[0].list[0]; - return { topMostWidget, leftMostWidget, rightMostWidget, bottomMostWidget, + totalWidth: rightMostWidget.rightColumn - leftMostWidget.leftColumn, }; }; +/** + * get totalWidth, maxThickness, topMostRow and leftMostColumn from selected Widgets + * + * @param selectedWidgets + * @returns + */ +export function getBoundariesFromSelectedWidgets( + selectedWidgets: WidgetProps[], +) { + const topMostWidget = selectedWidgets.sort((a, b) => a.topRow - b.topRow)[0]; + const leftMostWidget = selectedWidgets.sort( + (a, b) => a.leftColumn - b.leftColumn, + )[0]; + const rightMostWidget = selectedWidgets.sort( + (a, b) => b.rightColumn - a.rightColumn, + )[0]; + const thickestWidget = selectedWidgets.sort( + (a, b) => b.bottomRow - b.topRow - a.bottomRow + a.topRow, + )[0]; + + return { + totalWidth: rightMostWidget.rightColumn - leftMostWidget.leftColumn, + maxThickness: thickestWidget.bottomRow - thickestWidget.topRow, + topMostRow: topMostWidget.topRow, + leftMostColumn: leftMostWidget.leftColumn, + }; +} + /** * ------------------------------------------------------------------------------- * OPERATION = PASTING @@ -432,6 +483,442 @@ export const getSelectedWidgetWhenPasting = function*() { return selectedWidget; }; +/** + * calculates mouse positions in terms of grid values + * + * @param canvasRect canvas DOM rect + * @param canvasId Id of the canvas widget + * @param snapGrid grid parameters + * @param padding padding inside of widget + * @param mouseLocation mouse Location in terms of absolute pixel values + * @returns + */ +export function getMousePositions( + canvasRect: DOMRect, + canvasId: string, + snapGrid: { snapRowSpace: number; snapColumnSpace: number }, + padding: number, + mouseLocation?: { x: number; y: number }, +) { + //check if the mouse location is inside of the container widget + if ( + !mouseLocation || + !( + canvasRect.top < mouseLocation.y && + canvasRect.left < mouseLocation.x && + canvasRect.bottom > mouseLocation.y && + canvasRect.right > mouseLocation.x + ) + ) + return; + + //get DOM of the overall canvas including it's total scroll height + const stickyCanvasDOM = document.querySelector( + `#${getStickyCanvasName(canvasId)}`, + ); + if (!stickyCanvasDOM) return; + + const rect = stickyCanvasDOM.getBoundingClientRect(); + + // get mouse position relative to the canvas. + const relativeMouseLocation = { + y: mouseLocation.y - rect.top - padding, + x: mouseLocation.x - rect.left - padding, + }; + + return { + top: Math.floor(relativeMouseLocation.y / snapGrid.snapRowSpace), + left: Math.floor(relativeMouseLocation.x / snapGrid.snapColumnSpace), + }; +} + +/** + * This method calculates the snap Grid dimensions. + * + * @param LayoutWidget + * @param canvasWidth + * @returns + */ +export function getSnappedGrid(LayoutWidget: WidgetProps, canvasWidth: number) { + let padding = (CONTAINER_GRID_PADDING + WIDGET_PADDING) * 2; + if ( + LayoutWidget.widgetId === MAIN_CONTAINER_WIDGET_ID || + LayoutWidget.type === "CONTAINER_WIDGET" + ) { + //For MainContainer and any Container Widget padding doesn't exist coz there is already container padding. + padding = CONTAINER_GRID_PADDING * 2; + } + if (LayoutWidget.noPad) { + // Widgets like ListWidget choose to have no container padding so will only have widget padding + padding = WIDGET_PADDING * 2; + } + const width = canvasWidth - padding; + return { + snapGrid: { + snapRowSpace: GridDefaults.DEFAULT_GRID_ROW_HEIGHT, + snapColumnSpace: canvasWidth + ? width / GridDefaults.DEFAULT_GRID_COLUMNS + : 0, + }, + padding: padding / 2, + }; +} + +/** + * method to return default canvas, + * It is MAIN_CONTAINER_WIDGET_ID by default or + * if a modal is open, then default canvas is a Modal's canvas + * + * @param canvasWidgets + * @returns + */ +export function getDefaultCanvas(canvasWidgets: CanvasWidgetsReduxState) { + const containerDOM = document.querySelector(".t--modal-widget"); + //if a modal is open, then get it's canvas Id + if (containerDOM && containerDOM.id && canvasWidgets[containerDOM.id]) { + const containerWidget = canvasWidgets[containerDOM.id]; + const { canvasDOM, canvasId } = getCanvasIdForContainer(containerWidget); + return { + canvasId, + canvasDOM, + containerWidget, + }; + } else { + //default canvas is set as MAIN_CONTAINER_WIDGET_ID + return { + canvasId: MAIN_CONTAINER_WIDGET_ID, + containerWidget: canvasWidgets[MAIN_CONTAINER_WIDGET_ID], + canvasDOM: document.querySelector( + `#${getSlidingCanvasName(MAIN_CONTAINER_WIDGET_ID)}`, + ), + }; + } +} + +/** + * This method returns the Id of the parent widget of the canvas widget + * + * @param canvasId + * @returns + */ +export function getContainerIdForCanvas(canvasId: string) { + if (canvasId === MAIN_CONTAINER_WIDGET_ID) return canvasId; + + const selector = `#${getSlidingCanvasName(canvasId)}`; + const canvasDOM = document.querySelector(selector); + if (!canvasDOM) return ""; + //check for positionedWidget parent + let containerDOM = canvasDOM.closest(`.${POSITIONED_WIDGET}`); + //if positioned widget parent is not found, most likely is a modal widget + if (!containerDOM) containerDOM = canvasDOM.closest(".t--modal-widget"); + + return containerDOM ? containerDOM.id : ""; +} + +/** + * This method returns Id of the child canvas inside of the Layout Widget + * + * @param layoutWidget + * @returns + */ +export function getCanvasIdForContainer(layoutWidget: WidgetProps) { + const selector = + layoutWidget.type === "MODAL_WIDGET" + ? `.${getBaseWidgetClassName(layoutWidget.widgetId)}` + : `.${POSITIONED_WIDGET}.${getBaseWidgetClassName( + layoutWidget.widgetId, + )}`; + const containerDOM = document.querySelector(selector); + if (!containerDOM) return {}; + const canvasDOM = containerDOM.getElementsByTagName("canvas"); + + return { + canvasId: canvasDOM ? canvasDOM[0]?.id.split("-")[2] : undefined, + canvasDOM: canvasDOM[0], + }; +} + +/** + * This method returns array of occupiedSpaces with changes Ids + * + * @param newPastingPositionMap + * @returns + */ +export function changeIdsOfPastePositions(newPastingPositionMap: SpaceMap) { + const newPastePositions = []; + const newPastingPositionArray = Object.values(newPastingPositionMap); + let count = 1; + for (const position of newPastingPositionArray) { + newPastePositions.push({ + ...position, + id: count.toString(), + }); + count++; + } + + return newPastePositions; +} + +/** + * Iterates over the selected widgets to find the next available space below the selected widgets + * where in the new pasting positions dont overlap with the selected widgets + * + * @param copiedSpaces + * @param selectedSpaces + * @param thickness + * @returns + */ +export function getVerticallyAdjustedPositions( + copiedSpaces: OccupiedSpace[], + selectedSpaces: OccupiedSpace[], + thickness: number, +) { + let verticalOffset = thickness; + + const newPastingPositionMap: SpaceMap = {}; + + //iterate over the widgets to calculate verticalOffset + //TODO: find a better way to do this. + for (let i = 0; i < copiedSpaces.length; i++) { + const copiedSpace = { + ...copiedSpaces[i], + top: copiedSpaces[i].top + verticalOffset, + bottom: copiedSpaces[i].bottom + verticalOffset, + }; + + for (let j = 0; j < selectedSpaces.length; j++) { + const selectedSpace = selectedSpaces[j]; + if (areIntersecting(copiedSpace, selectedSpace)) { + const increment = selectedSpace.bottom - copiedSpace.top; + if (increment > 0) { + verticalOffset += increment; + i = 0; + j = 0; + break; + } else return; + } + } + } + + verticalOffset += WIDGET_PASTE_PADDING; + + // offset the pasting positions down + for (const copiedSpace of copiedSpaces) { + newPastingPositionMap[copiedSpace.id] = { + ...copiedSpace, + top: copiedSpace.top + verticalOffset, + bottom: copiedSpace.bottom + verticalOffset, + }; + } + + return newPastingPositionMap; +} + +/** + * Simple method to convert widget props to occupied spaces + * + * @param widgets + * @returns + */ +export function getOccupiedSpacesFromProps( + widgets: WidgetProps[], +): OccupiedSpace[] { + const occupiedSpaces = []; + for (const widget of widgets) { + const currentSpace = { + id: widget.widgetId, + top: widget.topRow, + left: widget.leftColumn, + bottom: widget.bottomRow, + right: widget.rightColumn, + } as OccupiedSpace; + occupiedSpaces.push(currentSpace); + } + + return occupiedSpaces; +} + +/** + * Method that adjusts the positions of copied spaces using, + * the top-left of copied widgets and top left of where it should be placed + * + * @param copiedWidgetGroups + * @param copiedTopMostRow + * @param selectedTopMostRow + * @param copiedLeftMostColumn + * @param pasteLeftMostColumn + * @returns + */ +export function getNewPositionsForCopiedWidgets( + copiedWidgetGroups: CopiedWidgetGroup[], + copiedTopMostRow: number, + selectedTopMostRow: number, + copiedLeftMostColumn: number, + pasteLeftMostColumn: number, +): OccupiedSpace[] { + const copiedSpacePositions = []; + + // the logic is that, when subtracted by top-left of copied widget, the new position's top-left will be zero + // by adding the selectedTopMostRow or pasteLeftMostColumn, copied widget's top row is aligned there + + const leftOffSet = copiedLeftMostColumn - pasteLeftMostColumn; + const topOffSet = copiedTopMostRow - selectedTopMostRow; + + for (const copiedWidgetGroup of copiedWidgetGroups) { + const copiedWidget = copiedWidgetGroup.list[0]; + + const currentSpace = { + id: copiedWidgetGroup.widgetId, + top: copiedWidget.topRow - topOffSet, + left: copiedWidget.leftColumn - leftOffSet, + bottom: copiedWidget.bottomRow - topOffSet, + right: copiedWidget.rightColumn - leftOffSet, + } as OccupiedSpace; + + copiedSpacePositions.push(currentSpace); + } + + return copiedSpacePositions; +} + +/** + * Method that adjusts the positions of copied spaces using, + * the top-left of copied widgets and top left of where it should be placed + * + * @param copiedWidgetGroups + * @param copiedTopMostRow + * @param mouseTopRow + * @param copiedLeftMostColumn + * @param mouseLeftColumn + * @returns + */ +export function getPastePositionMapFromMousePointer( + copiedWidgetGroups: CopiedWidgetGroup[], + copiedTopMostRow: number, + mouseTopRow: number, + copiedLeftMostColumn: number, + mouseLeftColumn: number, +): SpaceMap { + const newPastingPositionMap: SpaceMap = {}; + + // the logic is that, when subtracted by top-left of copied widget, the new position's top-left will be zero + // by adding the selectedTopMostRow or pasteLeftMostColumn, copied widget's top row is aligned there + + const leftOffSet = copiedLeftMostColumn - mouseLeftColumn; + const topOffSet = copiedTopMostRow - mouseTopRow; + + for (const copiedWidgetGroup of copiedWidgetGroups) { + const copiedWidget = copiedWidgetGroup.list[0]; + + newPastingPositionMap[copiedWidgetGroup.widgetId] = { + id: copiedWidgetGroup.widgetId, + top: copiedWidget.topRow - topOffSet, + left: copiedWidget.leftColumn - leftOffSet, + bottom: copiedWidget.bottomRow - topOffSet, + right: copiedWidget.rightColumn - leftOffSet, + type: copiedWidget.type, + } as OccupiedSpace; + } + + return newPastingPositionMap; +} + +/** + * Take the canvas widgets and move them with the reflowed values + * + * + * @param widgets + * @param gridProps + * @param reflowingWidgets + * @returns + */ +export function getReflowedPositions( + widgets: { + [widgetId: string]: FlattenedWidgetProps; + }, + gridProps?: GridProps, + reflowingWidgets?: ReflowedSpaceMap, +) { + const currentWidgets: { + [widgetId: string]: FlattenedWidgetProps; + } = { ...widgets }; + + const reflowWidgetKeys = Object.keys(reflowingWidgets || {}); + + // if there are no reflowed widgets return the original widgets + if (!reflowingWidgets || !gridProps || reflowWidgetKeys.length <= 0) + return widgets; + + for (const reflowedWidgetId of reflowWidgetKeys) { + const reflowWidget = reflowingWidgets[reflowedWidgetId]; + const canvasWidget = { ...currentWidgets[reflowedWidgetId] }; + + let { bottomRow, leftColumn, rightColumn, topRow } = canvasWidget; + + // adjust the positions with respect to the reflowed positions + if (reflowWidget.X !== undefined && reflowWidget.width !== undefined) { + leftColumn = Math.round( + canvasWidget.leftColumn + reflowWidget.X / gridProps.parentColumnSpace, + ); + rightColumn = Math.round( + leftColumn + reflowWidget.width / gridProps.parentColumnSpace, + ); + } + + if (reflowWidget.Y !== undefined && reflowWidget.height !== undefined) { + topRow = Math.round( + canvasWidget.topRow + reflowWidget.Y / gridProps.parentRowSpace, + ); + bottomRow = Math.round( + topRow + reflowWidget.height / gridProps.parentRowSpace, + ); + } + + currentWidgets[reflowedWidgetId] = { + ...canvasWidget, + topRow, + leftColumn, + bottomRow, + rightColumn, + }; + } + + return currentWidgets; +} + +/** + * method to return array of widget properties from widgetsIds, without any undefined values + * + * @param widgetsIds + * @param canvasWidgets + * @returns array of widgets properties + */ +export function getWidgetsFromIds( + widgetsIds: string[], + canvasWidgets: CanvasWidgetsReduxState, +) { + const widgets = []; + for (const currentId of widgetsIds) { + if (canvasWidgets[currentId]) widgets.push(canvasWidgets[currentId]); + } + + return widgets; +} + +/** + * Check if it is drop target Including the CANVAS_WIDGET + * + * @param type + * @returns + */ +export function isDropTarget(type: WidgetType, includeCanvasWidget = false) { + const isLayoutWidget = !!WidgetFactory.widgetConfigMap.get(type)?.isCanvas; + + if (includeCanvasWidget) return isLayoutWidget || type === "CANVAS_WIDGET"; + + return isLayoutWidget; +} + /** * group copied widgets into a container * diff --git a/app/client/src/utils/generators.tsx b/app/client/src/utils/generators.tsx index ea18eede87..660693c106 100644 --- a/app/client/src/utils/generators.tsx +++ b/app/client/src/utils/generators.tsx @@ -1,5 +1,6 @@ import { WidgetType } from "constants/WidgetConstants"; import generate from "nanoid/generate"; +import { getBaseWidgetClassName } from "../constants/componentClassNameConstants"; const ALPHANUMERIC = "1234567890abcdefghijklmnopqrstuvwxyz"; // const ALPHABET = "abcdefghijklmnopqrstuvwxyz"; @@ -16,7 +17,7 @@ export const generateReactKey = ({ // 2. Property pane reference for positioning // 3. Table widget filter pan reference for positioning export const generateClassName = (seed?: string) => { - return `appsmith_widget_${seed}`; + return getBaseWidgetClassName(seed); }; export const getCanvasClassName = () => "canvas"; diff --git a/app/client/src/widgets/BaseInputWidget/component/index.tsx b/app/client/src/widgets/BaseInputWidget/component/index.tsx index bb452af4c1..ff80dca595 100644 --- a/app/client/src/widgets/BaseInputWidget/component/index.tsx +++ b/app/client/src/widgets/BaseInputWidget/component/index.tsx @@ -27,6 +27,7 @@ import { InputTypes } from "../constants"; import ErrorTooltip from "components/editorComponents/ErrorTooltip"; import Icon from "components/ads/Icon"; import { InputType } from "widgets/InputWidget/constants"; +import { getBaseWidgetClassName } from "constants/componentClassNameConstants"; import { LabelPosition } from "components/constants"; import LabelWithTooltip, { labelLayoutStyles, @@ -331,7 +332,7 @@ class BaseInputComponent extends React.Component< componentDidMount() { if (isNumberInputType(this.props.inputHTMLType) && this.props.onStep) { const element = document.querySelector( - `.appsmith_widget_${this.props.widgetId} .bp3-button-group`, + `.${getBaseWidgetClassName(this.props.widgetId)} .bp3-button-group`, ); if (element !== null && element.childNodes) { @@ -350,7 +351,7 @@ class BaseInputComponent extends React.Component< componentWillUnmount() { if (isNumberInputType(this.props.inputHTMLType) && this.props.onStep) { const element = document.querySelector( - `.appsmith_widget_${this.props.widgetId} .bp3-button-group`, + `.${getBaseWidgetClassName(this.props.widgetId)} .bp3-button-group`, ); if (element !== null && element.childNodes) { diff --git a/app/client/src/widgets/InputWidget/component/index.tsx b/app/client/src/widgets/InputWidget/component/index.tsx index 541ee5ab91..a068f73116 100644 --- a/app/client/src/widgets/InputWidget/component/index.tsx +++ b/app/client/src/widgets/InputWidget/component/index.tsx @@ -36,6 +36,7 @@ import ISDCodeDropdown, { import ErrorTooltip from "components/editorComponents/ErrorTooltip"; import Icon from "components/ads/Icon"; import { limitDecimalValue, getSeparators } from "./utilities"; +import { getBaseWidgetClassName } from "constants/componentClassNameConstants"; import { LabelPosition } from "components/constants"; import LabelWithTooltip, { labelLayoutStyles, @@ -298,7 +299,7 @@ class InputComponent extends React.Component< componentDidMount() { if (this.props.inputType === InputTypes.CURRENCY) { const element: any = document.querySelectorAll( - `.appsmith_widget_${this.props.widgetId} .bp3-button`, + `.${getBaseWidgetClassName(this.props.widgetId)} .bp3-button`, ); if (element !== null) { element[0].addEventListener("click", this.onIncrementButtonClick); @@ -313,7 +314,7 @@ class InputComponent extends React.Component< this.props.inputType !== prevProps.inputType ) { const element: any = document.querySelectorAll( - `.appsmith_widget_${this.props.widgetId} .bp3-button`, + `.${getBaseWidgetClassName(this.props.widgetId)} .bp3-button`, ); if (element !== null) { element[0].addEventListener("click", this.onIncrementButtonClick); @@ -325,7 +326,7 @@ class InputComponent extends React.Component< componentWillUnmount() { if (this.props.inputType === InputTypes.CURRENCY) { const element: any = document.querySelectorAll( - `.appsmith_widget_${this.props.widgetId} .bp3-button`, + `.${getBaseWidgetClassName(this.props.widgetId)} .bp3-button`, ); if (element !== null) { element[0].removeEventListener("click", this.onIncrementButtonClick); diff --git a/app/client/src/widgets/ModalWidget/component/index.tsx b/app/client/src/widgets/ModalWidget/component/index.tsx index 12de4f6088..afeca158cf 100644 --- a/app/client/src/widgets/ModalWidget/component/index.tsx +++ b/app/client/src/widgets/ModalWidget/component/index.tsx @@ -122,6 +122,7 @@ export type ModalComponentProps = { resizeModal?: (dimensions: UIElementSize) => void; maxWidth?: number; minSize?: number; + widgetId: string; widgetName: string; }; @@ -198,6 +199,7 @@ export default function ModalComponent(props: ModalComponentProps) { }; const getResizableContent = () => { + //id for Content is required for Copy Paste inside the modal return ( diff --git a/app/client/src/widgets/ModalWidget/widget/index.tsx b/app/client/src/widgets/ModalWidget/widget/index.tsx index b0a27bdd8d..94355fafcb 100644 --- a/app/client/src/widgets/ModalWidget/widget/index.tsx +++ b/app/client/src/widgets/ModalWidget/widget/index.tsx @@ -198,6 +198,7 @@ export class ModalWidget extends BaseWidget { portalContainer={portalContainer} resizeModal={this.onModalResize} scrollContents={!!this.props.shouldScrollContents} + widgetId={this.props.widgetId} widgetName={this.props.widgetName} width={this.getModalWidth(this.props.width)} >