diff --git a/app/client/package.json b/app/client/package.json index 9ae341a773..9d876ff5aa 100644 --- a/app/client/package.json +++ b/app/client/package.json @@ -123,6 +123,7 @@ "js-sha256": "^0.9.0", "jshint": "^2.13.4", "klona": "^2.0.5", + "konva": "8.0.1", "libphonenumber-js": "^1.9.44", "linkedom": "^0.14.20", "localforage": "^1.7.3", @@ -166,6 +167,7 @@ "react-hook-form": "^7.28.0", "react-instantsearch-dom": "^6.4.0", "react-json-view": "^1.21.3", + "react-konva": "17.0.2-6", "react-masonry-css": "^1.0.16", "react-media-recorder": "^1.6.1", "react-modal": "^3.15.1", diff --git a/app/client/src/ce/reducers/entityReducers/index.ts b/app/client/src/ce/reducers/entityReducers/index.ts index fab414a7f6..a19eac5818 100644 --- a/app/client/src/ce/reducers/entityReducers/index.ts +++ b/app/client/src/ce/reducers/entityReducers/index.ts @@ -11,6 +11,7 @@ import pageListReducer from "reducers/entityReducers/pageListReducer"; import pluginsReducer from "reducers/entityReducers/pluginsReducer"; import autoHeightLayoutTreeReducer from "reducers/entityReducers/autoHeightReducers/autoHeightLayoutTreeReducer"; import canvasLevelsReducer from "reducers/entityReducers/autoHeightReducers/canvasLevelsReducer"; +import widgetPositionsReducer from "layoutSystems/anvil/integrations/reducers/widgetPositionsReducer"; export const entityReducerObject = { canvasWidgets: canvasWidgetsReducer, @@ -26,4 +27,5 @@ export const entityReducerObject = { jsActions: jsActionsReducer, autoHeightLayoutTree: autoHeightLayoutTreeReducer, canvasLevels: canvasLevelsReducer, + widgetPositions: widgetPositionsReducer, }; diff --git a/app/client/src/ce/reducers/index.tsx b/app/client/src/ce/reducers/index.tsx index 14cc2a9960..9b594ebd3a 100644 --- a/app/client/src/ce/reducers/index.tsx +++ b/app/client/src/ce/reducers/index.tsx @@ -76,6 +76,10 @@ import type { MetaWidgetsReduxState } from "reducers/entityReducers/metaWidgetsR import type { layoutConversionReduxState } from "reducers/uiReducers/layoutConversionReducer"; import type { OneClickBindingState } from "reducers/uiReducers/oneClickBindingReducer"; +/* Reducers which are integrated into the core system when registering a pluggable module + or done so by a module that is designed to be eventually pluggable */ +import type { WidgetPositionsReduxState } from "layoutSystems/anvil/integrations/reducers/widgetPositionsReducer"; + export const reducerObject = { entities: entityReducer, ui: uiReducer, @@ -152,6 +156,7 @@ export interface AppState { jsActions: JSCollectionDataState; autoHeightLayoutTree: AutoHeightLayoutTreeReduxState; canvasLevels: CanvasLevelsReduxState; + widgetPositions: WidgetPositionsReduxState; }; evaluations: { tree: EvaluatedTreeState; diff --git a/app/client/src/ce/sagas/index.tsx b/app/client/src/ce/sagas/index.tsx index fe343e02b5..6d0247f843 100644 --- a/app/client/src/ce/sagas/index.tsx +++ b/app/client/src/ce/sagas/index.tsx @@ -52,6 +52,9 @@ import oneClickBindingSaga from "sagas/OneClickBindingSaga"; import entityNavigationSaga from "sagas/NavigationSagas"; import communityTemplateSagas from "sagas/CommunityTemplatesSagas"; +/* Sagas that are registered by a module that is designed to be independent of the core platform */ +import WidgetPositionSaga from "layoutSystems/anvil/integrations/sagas/WidgetPositionsSaga"; + export const sagas = [ initSagas, pageSagas, @@ -105,5 +108,6 @@ export const sagas = [ snapshotSagas, oneClickBindingSaga, entityNavigationSaga, + WidgetPositionSaga, communityTemplateSagas, ]; diff --git a/app/client/src/ce/selectors/entitiesSelector.ts b/app/client/src/ce/selectors/entitiesSelector.ts index 3343ae987f..e7c414f902 100644 --- a/app/client/src/ce/selectors/entitiesSelector.ts +++ b/app/client/src/ce/selectors/entitiesSelector.ts @@ -77,6 +77,21 @@ export const getDatasourceStructureById = ( return state.entities.datasources.structure[id]; }; +/** + * Selector to indicate if the widget name should be shown/drawn on canvas + */ +export const getShouldShowWidgetName = createSelector( + (state: AppState) => state.ui.widgetDragResize.isResizing, + (state: AppState) => state.ui.widgetDragResize.isDragging, + (state: AppState) => state.ui.editor.isPreviewMode, + (state: AppState) => state.ui.widgetDragResize.isAutoCanvasResizing, + (isResizing, isDragging, isPreviewMode, isAutoCanvasResizing) => { + return ( + !isResizing && !isDragging && !isPreviewMode && !isAutoCanvasResizing + ); + }, +); + export const getDatasourceTableColumns = (datasourceId: string, tableName: string) => (state: AppState) => { const structure = getDatasourceStructureById(state, datasourceId); diff --git a/app/client/src/constants/componentClassNameConstants.ts b/app/client/src/constants/componentClassNameConstants.ts index 5f34218e9f..237ddceeb6 100644 --- a/app/client/src/constants/componentClassNameConstants.ts +++ b/app/client/src/constants/componentClassNameConstants.ts @@ -12,6 +12,8 @@ export function getBaseWidgetClassName(id?: string) { export const CANVAS_VIEWPORT = "canvas-viewport"; +export const CANVAS_ART_BOARD = "art-board"; + export const POSITIONED_WIDGET = "positioned-widget"; export const WIDGET_COMPONENT_BOUNDARY_CLASS = "widget-component-boundary"; diff --git a/app/client/src/layoutSystems/anvil/integrations/actions/actionTypes.ts b/app/client/src/layoutSystems/anvil/integrations/actions/actionTypes.ts new file mode 100644 index 0000000000..97ef0dc919 --- /dev/null +++ b/app/client/src/layoutSystems/anvil/integrations/actions/actionTypes.ts @@ -0,0 +1,9 @@ +export type AnvilReduxAction = { + type: AnvilReduxActionTypes; + payload: T; +}; + +export enum AnvilReduxActionTypes { + READ_WIDGET_POSITIONS = "READ_WIDGET_POSITIONS", + UPDATE_WIDGET_POSITIONS = "UPDATE_WIDGET_POSITIONS", +} diff --git a/app/client/src/layoutSystems/anvil/integrations/actions/index.ts b/app/client/src/layoutSystems/anvil/integrations/actions/index.ts new file mode 100644 index 0000000000..e153a11992 --- /dev/null +++ b/app/client/src/layoutSystems/anvil/integrations/actions/index.ts @@ -0,0 +1,14 @@ +import { AnvilReduxActionTypes } from "./actionTypes"; + +export const readWidgetPositions = ( + widgetsProcessQueue: { + [widgetDOMId: string]: boolean; + }, + layersProcessQueue: { [canvasId: string]: number }, + layoutsProcessQueue: { [layoutId: string]: boolean }, +) => { + return { + type: AnvilReduxActionTypes.READ_WIDGET_POSITIONS, + payload: { widgetsProcessQueue, layersProcessQueue, layoutsProcessQueue }, + }; +}; diff --git a/app/client/src/layoutSystems/anvil/integrations/reducers/widgetPositionsReducer.ts b/app/client/src/layoutSystems/anvil/integrations/reducers/widgetPositionsReducer.ts new file mode 100644 index 0000000000..5558fd3bae --- /dev/null +++ b/app/client/src/layoutSystems/anvil/integrations/reducers/widgetPositionsReducer.ts @@ -0,0 +1,33 @@ +import { AnvilReduxActionTypes } from "layoutSystems/anvil/integrations/actions/actionTypes"; +import type { AnvilReduxAction } from "layoutSystems/anvil/integrations/actions/actionTypes"; +import { createImmerReducer } from "utils/ReducerUtils"; +import type { WidgetPositions } from "layoutSystems/common/types"; + +const initialState: WidgetPositions = {}; + +export type WidgetPositionsReduxState = typeof initialState; + +/** + * Reducer used for storing Position of all widgets in the current layout + * This reducer is useful for all on canvas UI (Ex: Dropzone Highlights, Widget Name component position, etc) + */ +const widgetPositionsReducer = createImmerReducer(initialState, { + [AnvilReduxActionTypes.UPDATE_WIDGET_POSITIONS]: ( + WidgetPositionState: WidgetPositions, + action: AnvilReduxAction, + ) => { + const widgetPositions = action.payload; + + const widgetIds = Object.keys(widgetPositions); + + for (const widgetId of widgetIds) { + const newPosition = widgetPositions[widgetId]; + WidgetPositionState[widgetId].height = newPosition.height; + WidgetPositionState[widgetId].width = newPosition.width; + WidgetPositionState[widgetId].left = newPosition.left; + WidgetPositionState[widgetId].top = newPosition.top; + } + }, +}); + +export default widgetPositionsReducer; diff --git a/app/client/src/layoutSystems/anvil/integrations/sagas/WidgetPositionsSaga.ts b/app/client/src/layoutSystems/anvil/integrations/sagas/WidgetPositionsSaga.ts new file mode 100644 index 0000000000..80cced95e6 --- /dev/null +++ b/app/client/src/layoutSystems/anvil/integrations/sagas/WidgetPositionsSaga.ts @@ -0,0 +1,78 @@ +import { AnvilReduxActionTypes } from "layoutSystems/anvil/integrations/actions/actionTypes"; +import type { ReduxAction } from "@appsmith/constants/ReduxActionConstants"; +import type { CanvasWidgetsReduxState } from "reducers/entityReducers/canvasWidgetsReducer"; +import type { WidgetPositions } from "layoutSystems/common/types"; +import { all, put, select, takeEvery } from "redux-saga/effects"; +import { getAnvilWidgetId } from "layoutSystems/common/utils/WidgetPositionsObserver/utils"; +import { getAffectedWidgetsFromLayers } from "layoutSystems/anvil/integrations/utils"; +import { getCanvasWidgets } from "@appsmith/selectors/entitiesSelector"; +import { CANVAS_ART_BOARD } from "constants/componentClassNameConstants"; + +/** + * This saga is used to read and update widget position from the the list of widgets, + * layers and layouts received from widget positions observer + * Widgets : When triggered by the ResizeObserver wrapping the widget + * Layers: When triggered by the ResizeObserver wrapping the layer (Flex layers) + * Layouts: When triggered by the ResizeObserver wrapping the layout (Layout Components) + * @param action All the widgets, layers and layouts that have changed and currently in queue to be processed + */ +function* readAndUpdateWidgetPositions( + action: ReduxAction<{ + widgetsProcessQueue: { + [widgetId: string]: boolean; + }; + layersProcessQueue: { [canvasId: string]: number }; + layoutsProcessQueue: { [key: string]: boolean }; + }>, +) { + const widgets: CanvasWidgetsReduxState = yield select(getCanvasWidgets); + + const { layersProcessQueue, widgetsProcessQueue } = action.payload; + + //get additional widgets from affected layers + const affectedWidgetsFromLayers: { + [widgetDOMId: string]: boolean; + } = getAffectedWidgetsFromLayers(layersProcessQueue, widgets); + + const widgetsToProcess = { + ...widgetsProcessQueue, + ...affectedWidgetsFromLayers, + }; + + const widgetDimensions: WidgetPositions = {}; + + const mainContainerDOMNode = document.getElementById(CANVAS_ART_BOARD); + + const mainContainerDOMRect = mainContainerDOMNode?.getBoundingClientRect(); + + const { left = 0, top = 0 } = mainContainerDOMRect || {}; + + //for every affected widget get the bounding client Rect + // If they do, we don't have to update the positions here. + for (const widgetId of Object.keys(widgetsToProcess)) { + const element = document.getElementById(getAnvilWidgetId(widgetId)); + if (element) { + const rect = element.getBoundingClientRect(); + widgetDimensions[widgetId] = { + left: rect.left - left, + top: rect.top - top, + height: rect.height, + width: rect.width, + }; + } + } + + yield put({ + type: AnvilReduxActionTypes.UPDATE_WIDGET_POSITIONS, + payload: widgetDimensions, + }); +} + +export default function* WidgetPositionSaga() { + yield all([ + takeEvery( + AnvilReduxActionTypes.READ_WIDGET_POSITIONS, + readAndUpdateWidgetPositions, + ), + ]); +} diff --git a/app/client/src/layoutSystems/anvil/integrations/utils.test.ts b/app/client/src/layoutSystems/anvil/integrations/utils.test.ts new file mode 100644 index 0000000000..dbdb1d030b --- /dev/null +++ b/app/client/src/layoutSystems/anvil/integrations/utils.test.ts @@ -0,0 +1,151 @@ +import type { CanvasWidgetsReduxState } from "reducers/entityReducers/canvasWidgetsReducer"; +import type { WidgetProps } from "widgets/BaseWidget"; +import { getAffectedWidgetsFromLayers, getAllChildWidgets } from "./utils"; + +const widgets = { + "0": { + children: ["1", "3", "4"], + detachFromLayout: true, + flexLayers: [ + { children: [{ id: "1" }, { id: "3" }] }, + { children: [{ id: "4" }] }, + ], + }, + "1": { + children: ["2"], + }, + "2": { + children: ["5", "6", "7", "8"], + detachFromLayout: true, + flexLayers: [ + { children: [{ id: "5" }] }, + { children: [{ id: "6" }, { id: "7" }] }, + { children: [{ id: "8" }] }, + ], + }, + "3": { children: [] }, + "4": { children: [] }, + "5": { children: [] }, + "6": { children: [] }, + "7": { children: [] }, + "8": { children: [] }, +} as unknown as CanvasWidgetsReduxState; + +describe("should test getAffectedWidgetsFromLayers", () => { + const layerQueue1 = { + "0": 0, + }; + + const layerQueue2 = { + "0": 1, + "2": 1, + }; + + const layerQueue3 = { + "2": 0, + }; + + const layerQueue4 = { + "0": 1, + "2": 2, + }; + + const affectedWidgets1 = { + "1": true, + "3": true, + "4": true, + "5": true, + "6": true, + "7": true, + "8": true, + }; + + const affectedWidgets2 = { + "4": true, + "6": true, + "7": true, + "8": true, + }; + + const affectedWidgets3 = { + "5": true, + "6": true, + "7": true, + "8": true, + }; + + const affectedWidgets4 = { + "4": true, + "8": true, + }; + + it("should return all the affected widgets derived from layer queue", () => { + expect(getAffectedWidgetsFromLayers(layerQueue1, widgets)).toEqual( + affectedWidgets1, + ); + expect(getAffectedWidgetsFromLayers(layerQueue2, widgets)).toEqual( + affectedWidgets2, + ); + expect(getAffectedWidgetsFromLayers(layerQueue3, widgets)).toEqual( + affectedWidgets3, + ); + expect(getAffectedWidgetsFromLayers(layerQueue4, widgets)).toEqual( + affectedWidgets4, + ); + }); +}); + +describe("should test getAllChildWidgets", () => { + const widget1 = { + widgetId: "0", + children: ["1", "3", "4"], + } as unknown as WidgetProps; + + const widget2 = { + widgetId: "1", + children: ["2"], + } as unknown as WidgetProps; + + const widget3 = { + widgetId: "2", + children: ["5", "6", "7", "8"], + } as unknown as WidgetProps; + + const widget4 = { + widgetId: "3", + children: [], + } as unknown as WidgetProps; + + const childWidgets1 = { + "1": true, + "3": true, + "4": true, + "5": true, + "6": true, + "7": true, + "8": true, + }; + + const childWidgets2 = { + "5": true, + "6": true, + "7": true, + "8": true, + }; + + const childWidgets3 = { + "5": true, + "6": true, + "7": true, + "8": true, + }; + + const childWidgets4 = {}; + + it("should return all the child widgets except canvas widgets", () => { + expect(getAllChildWidgets(widget1, widgets)).toEqual(childWidgets1); + expect(getAllChildWidgets(widget2, widgets)).toEqual(childWidgets2); + expect(getAllChildWidgets(widget3, widgets)).toEqual(childWidgets3); + expect(getAllChildWidgets(widget4, widgets)).toEqual(childWidgets4); + }); +}); diff --git a/app/client/src/layoutSystems/anvil/integrations/utils.ts b/app/client/src/layoutSystems/anvil/integrations/utils.ts new file mode 100644 index 0000000000..e7c71f1572 --- /dev/null +++ b/app/client/src/layoutSystems/anvil/integrations/utils.ts @@ -0,0 +1,90 @@ +import type { CanvasWidgetsReduxState } from "reducers/entityReducers/canvasWidgetsReducer"; +import type { WidgetProps } from "widgets/BaseWidget"; + +/** + * This method is used to determine all the affected widgets from all the layers that have changed + * @param layersProcessQueue Changed layer Ids, will have one layer id per canvas + * @param widgets all widget dsl Properties + * @returns list of all affected widgets + */ +export function getAffectedWidgetsFromLayers( + layersProcessQueue: { + [canvasId: string]: number; + }, + widgets: CanvasWidgetsReduxState, +) { + let affectedWidgets: { [widgetDOMId: string]: boolean } = {}; + + //Even though it has many nested iterations it will go through all teh affected widgets only once + //Iterate through all the canvases and it's first layer that got affected + for (const [canvasId, layerIndex] of Object.entries(layersProcessQueue)) { + const flexLayers = widgets[canvasId]?.flexLayers || []; + + //iterate through all the layers below the changed layer id inculuding the layer + for (let i = layerIndex; i < flexLayers.length; i++) { + const children = flexLayers[i]?.children || []; + //iterate through all the child widgets inside the layer + for (const child of children) { + const childWidget = widgets[child.id]; + + if (!childWidget) continue; + + affectedWidgets[child.id] = true; + + //if the widget has children get all the nested children + if (childWidget.children && childWidget.children.length > 0) { + affectedWidgets = { + ...affectedWidgets, + ...getAllChildWidgets(childWidget, widgets, layersProcessQueue), + }; + } + } + } + } + + return affectedWidgets; +} + +/** + * This Method gets all the nested child widgets, + * within the given widgets ignoring the canvas type widgets + * @param widget Widget whose nested children have to be found + * @param widgets all widget dsl Properties + * @returns list of all the nested child widgets of widget + */ +export function getAllChildWidgets( + widget: WidgetProps, + widgets: CanvasWidgetsReduxState, + layersProcessQueue?: { + [canvasId: string]: number; + }, +) { + let childWidgets: { [widgetDOMId: string]: boolean } = {}; + + const children = widget.children; + + //iterate through children if widget + for (const childId of children) { + const childWidget = widgets[childId]; + + if (!childWidget) continue; + + //if the child widget is not a canvas add it to the list + if (!childWidget.detachFromLayout) { + childWidgets[childId] = true; + } else if (layersProcessQueue) { + //If it is a canvas widget remove the widget from the layer queue to avoid processing it again + delete layersProcessQueue[childId]; + } + + //if the widget further has nested children call the getAllChildWidgets recursively. + if (childWidget.children && childWidget.children.length > 0) { + childWidgets = { + ...childWidgets, + ...getAllChildWidgets(childWidget, widgets, layersProcessQueue), + }; + } + } + + return childWidgets; +} diff --git a/app/client/src/layoutSystems/common/WidgetNamesCanvas/WidgetNameConstants.ts b/app/client/src/layoutSystems/common/WidgetNamesCanvas/WidgetNameConstants.ts new file mode 100644 index 0000000000..e9f721015f --- /dev/null +++ b/app/client/src/layoutSystems/common/WidgetNamesCanvas/WidgetNameConstants.ts @@ -0,0 +1,50 @@ +import { Colors } from "constants/Colors"; +import type { CSSProperties } from "react"; + +export const WIDGET_NAME_CANVAS = "widget-name-canvas"; +export const WIDGET_NAME_FONT_SIZE = 14; +export const WIDGET_NAME_LINE_HEIGHT = Math.floor(WIDGET_NAME_FONT_SIZE * 1.2); +export const WIDGET_NAME_VERTICAL_PADDING = 4; +export const WIDGET_NAME_HORIZONTAL_PADDING = 6; +export const WIDGET_NAME_ICON_PADDING = 16; + +export const DEFAULT_WIDGET_NAME_CANVAS_HEIGHT = 600; +export const WIDGET_NAME_CANVAS_PADDING = 20; + +export const WIDGET_NAME_HEIGHT = Math.floor( + WIDGET_NAME_LINE_HEIGHT + WIDGET_NAME_VERTICAL_PADDING * 1.5, +); + +export const WIDGET_NAME_TEXT_COLOR = Colors.WHITE; + +//Adding this here as Konva accepts this type of path for SVG +export const warningSVGPath = + "M 18 9 C 18 13.9706 13.9706 18 9 18 C 4.0294 18 0 13.9706 0 9 C 0 4.0294 4.0294 0 9 0 C 13.9706 0 18 4.0294 18 9 Z M 7.875 3.9375 V 10.125 H 10.125 V 3.9375 H 7.875 Z M 9 14.0625 C 9.6213 14.0625 10.125 13.5588 10.125 12.9375 C 10.125 12.3162 9.6213 11.8125 9 11.8125 C 8.3787 11.8125 7.875 12.3162 7.875 12.9375 C 7.875 13.5588 8.3787 14.0625 9 14.0625 Z"; + +//Indicates the state of widget name +export enum WidgetNameState { + SELECTED = "SELECTED", + ERROR = "ERROR", + FOCUSED = "FOCUSED", +} + +//fill colors of widget name based on state +export const WIDGET_NAME_FILL_COLORS = { + [WidgetNameState.SELECTED]: Colors.JAFFA_DARK, + [WidgetNameState.FOCUSED]: Colors.WATUSI, + [WidgetNameState.ERROR]: Colors.DANGER_SOLID, +}; + +//CSS properties of the wrapper object of the html canvas +export const widgetNameWrapperStyle: CSSProperties = { + position: "absolute", + top: 0, + left: 0, + zIndex: 2, + pointerEvents: "none", + display: "flex", + flexDirection: "column", + alignItems: "center", + width: "100%", + height: "100%", +}; diff --git a/app/client/src/layoutSystems/common/WidgetNamesCanvas/WidgetNameTypes.ts b/app/client/src/layoutSystems/common/WidgetNamesCanvas/WidgetNameTypes.ts new file mode 100644 index 0000000000..7336cb6a9f --- /dev/null +++ b/app/client/src/layoutSystems/common/WidgetNamesCanvas/WidgetNameTypes.ts @@ -0,0 +1,33 @@ +import type { WidgetPosition } from "layoutSystems/common/types"; +import type { WidgetNameState } from "./WidgetNameConstants"; + +export type WIDGET_NAME_TYPE = "selected" | "focused"; + +//Contains the data of widget which are required to draw widget names on canvas +export type WidgetNameData = { + id: string; + position: WidgetPosition; + widgetName: string; + parentId: string; + nameState: WidgetNameState; + dragDisabled: boolean; +}; + +//Position of the widget name on canvas, required to enable interaction on canvas +export type WidgetNamePositionData = { + left: number; + top: number; + width: number; + height: number; + widgetNameData: WidgetNameData; +}; + +//Position of canvas with respect to client browser +export type CanvasPositions = { + top: number; + left: number; + xDiff: number; + width: number; + yDiff: number; + height: number; +}; diff --git a/app/client/src/layoutSystems/common/WidgetNamesCanvas/index.tsx b/app/client/src/layoutSystems/common/WidgetNamesCanvas/index.tsx new file mode 100644 index 0000000000..a0682423d1 --- /dev/null +++ b/app/client/src/layoutSystems/common/WidgetNamesCanvas/index.tsx @@ -0,0 +1,382 @@ +import type { DragEventHandler, DragEvent } from "react"; +import React, { useEffect, useRef } from "react"; +import { useSelector } from "react-redux"; +import { throttle } from "lodash"; +import { Layer, Stage } from "react-konva/lib/ReactKonvaCore"; +import { useWidgetSelection } from "utils/hooks/useWidgetSelection"; +import { SelectionRequestType } from "sagas/WidgetSelectUtils"; + +import { + useShowTableFilterPane, + useWidgetDragResize, +} from "utils/hooks/dragResizeHooks"; +import type { + CanvasPositions, + WidgetNameData, + WidgetNamePositionData, + WIDGET_NAME_TYPE, +} from "./WidgetNameTypes"; +import { + DEFAULT_WIDGET_NAME_CANVAS_HEIGHT, + WIDGET_NAME_CANVAS_PADDING, + widgetNameWrapperStyle, + WIDGET_NAME_CANVAS, +} from "./WidgetNameConstants"; +import { + getFocusedWidgetNameData, + getSelectedWidgetNameData, +} from "../selectors"; +import type { WidgetPosition } from "layoutSystems/common/types"; +import { getShouldAllowDrag } from "selectors/widgetDragSelectors"; +import type { Stage as CanvasStageType } from "konva/lib/Stage"; +import type { Layer as KonvaLayer } from "konva/lib/Layer"; +import { getWidgetNameComponent } from "./utils"; + +/** + * This Component contains logic to draw widget name on canvas + * and also to make the widget name Intractable like selection of widget or dragging of widget + * @param props Object that contains + * @prop canvasWidth width of canvas in pixels + * @prop containerRef ref of PageViewWrapper component + * @prop parentRef ref of the MainContainerWrapper component i.e, the parent of the canvas component + */ +const OverlayCanvasContainer = (props: { + canvasWidth: number; + containerRef: React.RefObject; + parentRef: React.RefObject; +}) => { + //widget name data of widgets + const selectedWidgetNameData: WidgetNameData | undefined = useSelector( + getSelectedWidgetNameData, + ); + const focusedWidgetNameData: WidgetNameData | undefined = useSelector( + getFocusedWidgetNameData, + ); + + const shouldAllowDrag = useSelector(getShouldAllowDrag); + + const wrapperRef = useRef(null); + + // used to keep track of positions of widgetName drawn on canvas to make it intractable + const widgetNamePositions = useRef<{ + selected: WidgetNamePositionData | undefined; + focused: WidgetNamePositionData | undefined; + }>({ selected: undefined, focused: undefined }); + + const { setDraggingState } = useWidgetDragResize(); + const showTableFilterPane = useShowTableFilterPane(); + + //Positions of canvas + const canvasPositions = useRef({ + top: 0, + left: 0, + xDiff: 0, + width: 0, + yDiff: 0, + height: 0, + }); + + const scrollTop = useRef(0); + const isScrolling = useRef(0); + const hasScroll = useRef(false); + const stageRef = useRef(null); + + const { selectWidget } = useWidgetSelection(); + + //used to set canvasPositions, which is used further to calculate the exact positions of widgets + useEffect(() => { + if (!stageRef?.current?.content || !wrapperRef?.current) return; + + const HTMLCanvas: HTMLDivElement = stageRef?.current?.content; + const rect: DOMRect = HTMLCanvas.getBoundingClientRect(); + + const wrapper: HTMLDivElement = wrapperRef?.current as HTMLDivElement; + const wrapperRect: DOMRect = wrapper.getBoundingClientRect(); + + if (rect && wrapperRect) { + canvasPositions.current = { + ...canvasPositions.current, + height: wrapperRect.height, + left: rect.left, + top: rect.top, + width: rect.width, + }; + } + }, [wrapperRef?.current, props.canvasWidth]); + + /** + * Method used to add widget name to the Konva canvas' layer + * @param layer Konva layer onto which the widget name is to be added + * @param widgetNameData widget name data contains more information regarding the widget that is used in drawing the name + * @param position position of widget in pixels + * @param type if it's either selected or focused widget name + */ + const addWidgetNameToCanvas = ( + layer: KonvaLayer, + widgetNameData: WidgetNameData, + position: WidgetPosition, + type: WIDGET_NAME_TYPE, + ) => { + if (!position) return; + + const { id: widgetId, widgetName } = widgetNameData; + + //Get Widget Name + if (widgetName) { + const { + canvasLeftOffset, + canvasTopOffset, + widgetNameComponent, + widgetNamePosition, + } = getWidgetNameComponent( + position, + widgetName, + widgetNameData, + props?.parentRef?.current, + stageRef?.current?.content, + scrollTop.current, + ); + + widgetNamePositions.current[type] = { ...widgetNamePosition }; + + canvasPositions.current = { + ...canvasPositions.current, + xDiff: canvasLeftOffset, + yDiff: canvasTopOffset, + }; + + //Make widget name clickable + widgetNameComponent.on("click", () => { + selectWidget(SelectionRequestType.One, [widgetId]); + }); + + //Add widget name to canvas + layer.add(widgetNameComponent); + } + }; + + /** + * This method is called whenever there is a change in state of canvas, + * i.e, widget position is changed, canvas resized, selected widget changes + * @param widgetPosition + */ + const updateSelectedWidgetPositions = (widgetPosition?: WidgetPosition) => { + if (!stageRef?.current) return; + + const stage = stageRef.current; + const layer = stage.getLayers()[0]; + //destroy all drawings on canvas + layer.destroyChildren(); + + //Check and draw selected Widget + if (selectedWidgetNameData) { + const { position: selectedWidgetPosition } = selectedWidgetNameData; + + const position = widgetPosition || selectedWidgetPosition; + + addWidgetNameToCanvas( + layer, + selectedWidgetNameData, + position, + "selected", + ); + } + + //Check and draw focused Widget + if (focusedWidgetNameData) { + const { position } = focusedWidgetNameData; + + addWidgetNameToCanvas(layer, focusedWidgetNameData, position, "focused"); + } + + layer.draw(); + }; + + /** + * Mouse Move event function, this tracks every mouse move on canvas such that + * if the mouse position coincides with the positions of widget name, it makes the canvas intractable + * This is throttled since it tracks every single mouse move + */ + const handleMouseMove = throttle((e: MouseEvent) => { + const wrapper = wrapperRef?.current as HTMLDivElement; + if (!wrapper) return; + + //check if the mouse is coinciding with the widget name drawing on canvas + const { cursor, isMouseOver } = getMouseOverDetails(e); + + //if mouse over make the canvas intractable + if (isMouseOver) { + if (wrapper.style.pointerEvents === "none") { + wrapper.style.pointerEvents = "auto"; + } + } // if not mouse over then keep it default + else if (wrapper.style.pointerEvents !== "none") { + wrapper.style.pointerEvents = "none"; + wrapper.style.cursor = "default"; + } + + //set cursor based on intractability + if (!cursor) { + wrapper.style.cursor = "default"; + } else if (wrapper.style.cursor !== cursor) { + wrapper.style.cursor = cursor; + } + }, 20); + + /** + * on Drag Start event handler to enable drag of widget from the widget name component drawing on canvas + * @param e + */ + const handleDragStart: DragEventHandler = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + + //checks if the mouse is over the widget name, if so return it's details + const { isMouseOver, widgetNameData } = getMouseOverDetails( + e as unknown as MouseEvent, + ); + + if (!isMouseOver || !shouldAllowDrag || widgetNameData?.dragDisabled) + return; + + //set dragging state + const startPoints = { + top: 0, + left: 0, + }; + showTableFilterPane(); + setDraggingState({ + isDragging: true, + dragGroupActualParent: widgetNameData?.parentId, + draggingGroupCenter: { widgetId: widgetNameData?.id }, + startPoints, + draggedOn: widgetNameData?.parentId, + }); + }; + + /** + * handle Scroll of the canvas, this helps in keeping track og canvas scroll + * so that the widget name remains accurately placed even when the canvas is scrolled + */ + const handleScroll = () => { + if (!props.parentRef?.current) return; + + const currentScrollTop: number = props.parentRef?.current?.scrollTop; + + if (!isScrolling.current) { + resetCanvas(); + } + + clearTimeout(isScrolling.current); + isScrolling.current = setTimeout(() => { + scrollTop.current = currentScrollTop; + //while scrolling update the widget name position + updateSelectedWidgetPositions(); + isScrolling.current = 0; + if ( + (props.parentRef?.current?.scrollHeight || 0) > + (props.parentRef?.current?.clientHeight || 0) + ) + hasScroll.current = true; + }, 100); + }; + + //Add event listeners + useEffect(() => { + if ( + !props.containerRef?.current || + !props.parentRef?.current || + !wrapperRef?.current + ) + return; + + const container: HTMLDivElement = props.containerRef + ?.current as HTMLDivElement; + const parent: HTMLDivElement = props.parentRef?.current as HTMLDivElement; + + container.addEventListener("mousemove", handleMouseMove); + parent.addEventListener("scroll", handleScroll); + return () => { + container.removeEventListener("mousemove", handleMouseMove); + parent.removeEventListener("scroll", handleScroll); + }; + }, [ + props.containerRef?.current, + props.parentRef?.current, + wrapperRef?.current, + widgetNamePositions.current, + canvasPositions.current, + ]); + + /** + * This Method verifies if the mouse position coincides with any widget name drawn on canvas + * and returns details regarding the widget + * @param e Mouse event + * @returns Mainly isMouseOver indicating if the mouse is on any one of the widget name + * if true also returns data regarding the widget + */ + const getMouseOverDetails = (e: MouseEvent) => { + const x = e.clientX - canvasPositions.current.left; + const y = e.clientY - canvasPositions.current.top; + const widgetNamePositionsArray = Object.values(widgetNamePositions.current); + + //for selected and focused widget names check the widget name positions with respect to mouse positions + for (const widgetNamePosition of widgetNamePositionsArray) { + if (widgetNamePosition) { + const { height, left, top, widgetNameData, width } = widgetNamePosition; + if (x > left && x < left + width && y > top && y < top + height) { + return { isMouseOver: true, cursor: "pointer", widgetNameData }; + } + } + } + + return { isMouseOver: false }; + }; + + //Used when the position of selected or focused widget changes + useEffect(() => { + if (!selectedWidgetNameData && !focusedWidgetNameData) { + resetCanvas(); + } else { + updateSelectedWidgetPositions(); + } + }, [selectedWidgetNameData, focusedWidgetNameData]); + + /** + * Resets canvas when there is nothing to be drawn on canvas + */ + const resetCanvas = () => { + // Resets stored widget position names + widgetNamePositions.current = { selected: undefined, focused: undefined }; + + // clears all drawings on canvas + const stage = stageRef.current; + if (!stage) return; + const layer = stage.getLayers()[0]; + if (!layer) return; + layer.destroyChildren(); + layer.draw(); + }; + + return ( +
+ + + +
+ ); +}; + +export default OverlayCanvasContainer; diff --git a/app/client/src/layoutSystems/common/WidgetNamesCanvas/utils.ts b/app/client/src/layoutSystems/common/WidgetNamesCanvas/utils.ts new file mode 100644 index 0000000000..e367afa4a5 --- /dev/null +++ b/app/client/src/layoutSystems/common/WidgetNamesCanvas/utils.ts @@ -0,0 +1,147 @@ +import Konva from "konva"; +import type { WidgetPosition } from "layoutSystems/common/types"; +import type { WidgetNameData, WidgetNamePositionData } from "./WidgetNameTypes"; +import { + warningSVGPath, + WidgetNameState, + WIDGET_NAME_FILL_COLORS, + WIDGET_NAME_FONT_SIZE, + WIDGET_NAME_HEIGHT, + WIDGET_NAME_HORIZONTAL_PADDING, + WIDGET_NAME_ICON_PADDING, + WIDGET_NAME_TEXT_COLOR, + WIDGET_NAME_VERTICAL_PADDING, +} from "./WidgetNameConstants"; + +/** + * used to get the Konva Group Element that is a group of all the elements + * that are to be drawn as part of widget name on canvas + * @param position Position of widget + * @param widgetName widget name + * @param widgetNameData widget name data contains more information regarding the widget that helps in determining the state of widget name + * @param parentDOM DOM element the MainContainerWrapper component i.e, the parent of the canvas component + * @param htmlCanvasDOM DOM element of the html canvas on which the widget name is drawn + * @param scrollTop amount of pixels scrolled by canvas + * @returns an object that contains + * widgetName Group on Konva, position of widgetName on canvas and canvas offsets + */ +export const getWidgetNameComponent = ( + position: WidgetPosition, + widgetName: string, + widgetNameData: WidgetNameData, + parentDOM: HTMLDivElement | null, + htmlCanvasDOM: HTMLDivElement | undefined, + scrollTop: number, +) => { + let showIcon = false; + + const { nameState } = widgetNameData; + + if (nameState === WidgetNameState.ERROR) { + showIcon = true; + } + + //Defining Text Element + const textEl = new Konva.Text({ + fill: WIDGET_NAME_TEXT_COLOR, + fontFamily: "sans-serif", + fontSize: WIDGET_NAME_FONT_SIZE, + text: widgetName, + x: showIcon + ? WIDGET_NAME_ICON_PADDING + WIDGET_NAME_HORIZONTAL_PADDING + : WIDGET_NAME_HORIZONTAL_PADDING, + y: WIDGET_NAME_VERTICAL_PADDING, + }); + + const textWidth: number = textEl.width(); + const componentWidth: number = + textWidth + + WIDGET_NAME_HORIZONTAL_PADDING * 2 + + (showIcon ? WIDGET_NAME_ICON_PADDING : 0); + + const { + canvasLeftOffset, + canvasTopOffset, + left: widgetLeft, + top: widgetTop, + } = getPositionsForBoundary(parentDOM, htmlCanvasDOM, position, scrollTop); + const left: number = widgetLeft + position.width - componentWidth + 0.5; + const top: number = widgetTop - WIDGET_NAME_HEIGHT; + + //Store the widget name positions for future use + const widgetNamePosition: WidgetNamePositionData = { + left: left, + top: top, + width: componentWidth, + height: WIDGET_NAME_HEIGHT, + widgetNameData: widgetNameData, + }; + + //rectangle encompassing the widget name + const rectEl = new Konva.Rect({ + cornerRadius: [4, 4, 0, 0], + fill: WIDGET_NAME_FILL_COLORS[nameState], + height: WIDGET_NAME_HEIGHT, + width: componentWidth, + x: 0, + y: 0, + }); + + //Icon in widget name componenet in case of error + const iconEl = new Konva.Path({ + x: WIDGET_NAME_HORIZONTAL_PADDING, + y: WIDGET_NAME_VERTICAL_PADDING, + data: warningSVGPath, + fill: WIDGET_NAME_TEXT_COLOR, + scaleX: 0.7, + scaleY: 0.7, + }); + + //Group Containing all the elements of that particular widget name + const groupEl = new Konva.Group({ + height: WIDGET_NAME_HEIGHT, + width: componentWidth, + x: left, + y: top, + }); + + groupEl.add(rectEl); + groupEl.add(textEl); + showIcon && groupEl.add(iconEl); + + return { + widgetNameComponent: groupEl, + widgetNamePosition, + canvasLeftOffset, + canvasTopOffset, + }; +}; + +/** + * Used to calculate the positions of the widget with respect to the HTML Canvas that is rendered by Konva + * @param parentDOM DOM element the MainContainerWrapper component i.e, the parent of the canvas component + * @param htmlCanvasDOM DOM element of the html canvas on which the widget name is drawn + * @param position position of widget with respect to client window in pixels + * @param scrollTop amount of pixels scrolled by canvas + * @returns mainly the left and top of widget with respect to the html canvas + * and also the canvas offset + */ +const getPositionsForBoundary = ( + parentDOM: HTMLDivElement | null, + htmlCanvasDOM: HTMLDivElement | undefined, + position: WidgetPosition, + scrollTop: number, +) => { + const { left: parentLeft = 0, top: parentTop = 0 } = + parentDOM?.getBoundingClientRect() || {}; + const { left: canvasLeft = 0, top: canvasTop = 0 } = + htmlCanvasDOM?.getBoundingClientRect() || {}; + + const canvasLeftOffset = parentLeft - canvasLeft; + const canvasTopOffset = parentTop - canvasTop; + + const left: number = position.left + canvasLeftOffset; + const top: number = position.top + canvasTopOffset - scrollTop; + + return { left, top, canvasLeftOffset, canvasTopOffset }; +}; diff --git a/app/client/src/layoutSystems/common/draggable/DraggableComponent.test.tsx b/app/client/src/layoutSystems/common/draggable/DraggableComponent.test.tsx deleted file mode 100644 index 1802e0ee63..0000000000 --- a/app/client/src/layoutSystems/common/draggable/DraggableComponent.test.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { canDrag } from "./DraggableComponent"; - -describe("DraggableComponent", () => { - it("it tests draggable canDrag helper function", () => { - expect( - canDrag(false, false, { dragDisabled: false }, false, false, false), - ).toBe(true); - expect( - canDrag(true, false, { dragDisabled: false }, false, false, false), - ).toBe(false); - expect( - canDrag(false, true, { dragDisabled: false }, false, false, false), - ).toBe(false); - expect( - canDrag(false, false, { dragDisabled: true }, false, false, false), - ).toBe(false); - }); -}); diff --git a/app/client/src/layoutSystems/common/draggable/DraggableComponent.tsx b/app/client/src/layoutSystems/common/draggable/DraggableComponent.tsx index c32aa5015a..6ad39ab64b 100644 --- a/app/client/src/layoutSystems/common/draggable/DraggableComponent.tsx +++ b/app/client/src/layoutSystems/common/draggable/DraggableComponent.tsx @@ -5,10 +5,6 @@ import type { CSSProperties, DragEventHandler, ReactNode } from "react"; import React, { useMemo, useRef } from "react"; import styled from "styled-components"; import { useSelector } from "react-redux"; -import { - previewModeSelector, - snipingModeSelector, -} from "selectors/editorSelectors"; import { isCurrentWidgetFocused, isWidgetSelected, @@ -19,7 +15,7 @@ import { useShowTableFilterPane, useWidgetDragResize, } from "utils/hooks/dragResizeHooks"; -import { getIsAppSettingsPaneWithNavigationTabOpen } from "selectors/appSettingsPaneSelectors"; +import { getShouldAllowDrag } from "selectors/widgetDragSelectors"; const DraggableWrapper = styled.div` display: block; @@ -43,6 +39,7 @@ type DraggableComponentProps = { parentRowSpace: number; parentColumnSpace: number; children: ReactNode; + dragDisabled?: boolean; }; // Widget Boundaries which is shown to indicate the boundaries of the widget @@ -59,42 +56,11 @@ const WidgetBoundaries = styled.div` left: 0; `; -/** - * can drag helper function to know if drag and drop should apply - * - * @param isResizingOrDragging - * @param isDraggingDisabled - * @param props - * @param isSnipingMode - * @param isPreviewMode - * @returns - */ -export const canDrag = ( - isResizingOrDragging: boolean, - isDraggingDisabled: boolean, - props: any, - isSnipingMode: boolean, - isPreviewMode: boolean, - isAppSettingsPaneWithNavigationTabOpen: boolean, -) => { - return ( - !isResizingOrDragging && - !isDraggingDisabled && - !props?.dragDisabled && - !isSnipingMode && - !isPreviewMode && - !isAppSettingsPaneWithNavigationTabOpen - ); -}; - function DraggableComponent(props: DraggableComponentProps) { // Dispatch hook handy to set a widget as focused/selected const { focusWidget, selectWidget } = useWidgetSelection(); - const isSnipingMode = useSelector(snipingModeSelector); - const isPreviewMode = useSelector(previewModeSelector); - const isAppSettingsPaneWithNavigationTabOpen = useSelector( - getIsAppSettingsPaneWithNavigationTabOpen, - ); + + const shouldAllowDrag = useSelector(getShouldAllowDrag); // Dispatch hook handy to set any `DraggableComponent` as dragging/ not dragging // The value is boolean const { setDraggingState } = useWidgetDragResize(); @@ -120,13 +86,6 @@ function DraggableComponent(props: DraggableComponentProps) { state.ui.widgetDragResize?.dragDetails?.draggedOn === props.parentId, ); - // This state tells us to disable dragging, - // This is usually true when widgets themselves implement drag/drop - // This flag resolves conflicting drag/drop triggers. - const isDraggingDisabled: boolean = useSelector( - (state: AppState) => state.ui.widgetDragResize.isDraggingDisabled, - ); - // True when any widget is dragging or resizing, including this one const isResizingOrDragging = !!isResizing || !!isDragging; const isCurrentWidgetDragging = isDragging && isSelected; @@ -159,14 +118,7 @@ function DraggableComponent(props: DraggableComponentProps) { .join("") .toLowerCase()}`; - const allowDrag = canDrag( - isResizingOrDragging, - isDraggingDisabled, - props, - isSnipingMode, - isPreviewMode, - isAppSettingsPaneWithNavigationTabOpen, - ); + const allowDrag = !props?.dragDisabled && shouldAllowDrag; const className = `${classNameForTesting}`; const draggableRef = useRef(null); const onDragStart: DragEventHandler = (e) => { diff --git a/app/client/src/layoutSystems/common/selectors.ts b/app/client/src/layoutSystems/common/selectors.ts new file mode 100644 index 0000000000..97d88fc27c --- /dev/null +++ b/app/client/src/layoutSystems/common/selectors.ts @@ -0,0 +1,124 @@ +import type { AppState } from "@appsmith/reducers"; +import { createSelector } from "reselect"; +import { getFocusedWidget, getSelectedWidgets } from "selectors/ui"; +import { getDataTree } from "selectors/dataTreeSelectors"; +import { getWidgets } from "sagas/selectors"; +import { getShouldShowWidgetName } from "@appsmith/selectors/entitiesSelector"; +import { WidgetNameState } from "./WidgetNamesCanvas/WidgetNameConstants"; +import { MAIN_CONTAINER_WIDGET_ID } from "constants/WidgetConstants"; +import { EVAL_ERROR_PATH } from "utils/DynamicBindingUtils"; +import get from "lodash/get"; +import { getErrorCount } from "layoutSystems/common/widgetName/utils"; +import type { WidgetPositions } from "./types"; +import type { WidgetProps } from "widgets/BaseWidget"; +import type { WidgetNameData } from "./WidgetNamesCanvas/WidgetNameTypes"; +import type { DataTree } from "@appsmith/entities/DataTree/types"; + +export const getWidgetPositions = (state: AppState) => + state.entities.widgetPositions; + +/** + * method to get the widget data required to draw widget name component on canvas + * @param widget widget whose widget name will be drawn on canvas + * @param dataTree contains evaluated widget information that is used to check of the widget has any errors + * @param positions positions of all the widgets in pixels + * @param isFocused boolean to indicate if the widget is focused + * @returns WidgetNameData object which contains information regarding the widget to draw it's widget name on canvas + */ +const getWidgetNameState = ( + widget: WidgetProps, + dataTree: DataTree, + positions: WidgetPositions, + isFocused = false, +): WidgetNameData => { + let nameState = isFocused + ? WidgetNameState.FOCUSED + : WidgetNameState.SELECTED; + + const widgetName = widget.widgetName; + + const widgetEntity = dataTree[widgetName]; + + const parentId = widget.parentId || MAIN_CONTAINER_WIDGET_ID; + + if (widgetEntity) { + const errorObj = get(widgetEntity, EVAL_ERROR_PATH, {}); + const errorCount = getErrorCount(errorObj); + + if (errorCount > 0) { + nameState = WidgetNameState.ERROR; + } + } + + const widgetNameData = { + id: widget.widgetId, + position: positions[widget.widgetId], + widgetName: widgetName, + parentId, + dragDisabled: widget.dragDisabled, + nameState, + }; + + return widgetNameData; +}; + +/** + * selector to get information regarding the selected widget to draw it's widget name on canvas + */ +export const getSelectedWidgetNameData = createSelector( + getWidgetPositions, + getSelectedWidgets, + getWidgets, + getDataTree, + getShouldShowWidgetName, + ( + positions, + selectedWidgets, + widgets, + dataTree, + shouldShowWidgetName, + ): WidgetNameData | undefined => { + if ( + !selectedWidgets || + selectedWidgets.length !== 1 || + !shouldShowWidgetName + ) + return; + + const selectedWidgetId = selectedWidgets[0]; + + const selectedWidget = widgets[selectedWidgetId]; + + if (!selectedWidget) return; + + return getWidgetNameState(selectedWidget, dataTree, positions); + }, +); + +/** + * selector to get information regarding the focused widget to draw it's widget name on canvas + */ +export const getFocusedWidgetNameData = createSelector( + getWidgetPositions, + getFocusedWidget, + getSelectedWidgets, + getWidgets, + getDataTree, + getShouldShowWidgetName, + ( + positions, + focusedWidgetId, + selectedWidgets, + widgets, + dataTree, + shouldShowWidgetName, + ): WidgetNameData | undefined => { + if (!focusedWidgetId || !widgets || !shouldShowWidgetName) return; + + const focusedWidget = widgets[focusedWidgetId]; + + if (!focusedWidget || selectedWidgets.indexOf(focusedWidgetId) > -1) return; + + return getWidgetNameState(focusedWidget, dataTree, positions, true); + }, +); diff --git a/app/client/src/layoutSystems/common/types.ts b/app/client/src/layoutSystems/common/types.ts new file mode 100644 index 0000000000..217e81240f --- /dev/null +++ b/app/client/src/layoutSystems/common/types.ts @@ -0,0 +1,13 @@ +/* + Hols the position of a widget in pixels from the top left of the MainContainer +*/ +export type WidgetPosition = { + left: number; + top: number; + height: number; + width: number; +}; + +export interface WidgetPositions { + [widgetId: string]: WidgetPosition; +} diff --git a/app/client/src/layoutSystems/common/utils/WidgetPositionsObserver/index.ts b/app/client/src/layoutSystems/common/utils/WidgetPositionsObserver/index.ts new file mode 100644 index 0000000000..bacc52c974 --- /dev/null +++ b/app/client/src/layoutSystems/common/utils/WidgetPositionsObserver/index.ts @@ -0,0 +1,187 @@ +import { debounce } from "lodash"; +import type { RefObject } from "react"; +import { ANVIL_LAYER, ANVIL_WIDGET, LAYOUT } from "./utils"; +import store from "store"; +import { readWidgetPositions } from "layoutSystems/anvil/integrations/actions"; + +/** + * This Class's main function is to batch all the registered widgets, Flex layers and layout components + * and dispatch an action to process all the affected widgets to determine all the widgets' positions + * + * This inturn acts as an observer to find out update widgets positions whenever the widget position changes + */ +class WidgetPositionsObserver { + // Objects to store registered elements + private registeredWidgets: { + [widgetDOMId: string]: { ref: RefObject; id: string }; + } = {}; + private registeredLayers: { + [layerId: string]: { + ref: RefObject; + canvasId: string; + layerIndex: number; + }; + } = {}; + private registeredLayouts: { + [layoutId: string]: { + ref: RefObject; + layoutId: string; + canvasId: string; + }; + } = {}; + + //Queues to process the registered elements that changed + private widgetsProcessQueue: { + [widgetId: string]: boolean; + } = {}; + private layersProcessQueue: { [canvasId: string]: number } = {}; + private layoutsProcessQueue: { [key: string]: boolean } = {}; + + private debouncedProcessBatch = debounce(this.processWidgetBatch, 200); + + // All the registered elements are registered with this Resize observer + // When any of the elements changes size this observer triggers it + // Add the elements are added to queue to further batch and process + private resizeObserver = new ResizeObserver( + (entries: ResizeObserverEntry[]) => { + for (const entry of entries) { + if (entry?.target?.id) { + const DOMId = entry?.target?.id; + if (DOMId.indexOf(ANVIL_WIDGET) > -1) { + this.addWidgetToProcess(DOMId); + } else if (DOMId.indexOf(ANVIL_LAYER) > -1) { + this.addLayerToProcess(DOMId); + } else if (DOMId.indexOf(LAYOUT) > -1) { + this.addLayoutToProcess(DOMId); + } + } + } + }, + ); + + //Method to register widgets for resize observer changes + public observeWidget( + widgetId: string, + widgetDOMId: string, + ref: RefObject, + ) { + if (ref.current) { + this.registeredWidgets[widgetDOMId] = { ref, id: widgetId }; + this.resizeObserver.observe(ref.current); + this.addWidgetToProcess(widgetDOMId); + } + } + + //Method to de register widgets for resize observer changes + public unObserveWidget(widgetDOMId: string) { + const element = this.registeredWidgets[widgetDOMId]?.ref?.current; + if (element) { + this.resizeObserver.unobserve(element); + } + + delete this.registeredWidgets[widgetDOMId]; + } + + //Method to register layers for resize observer changes + public observeLayer( + layerId: string, + canvasId: string, + layerIndex: number, + ref: RefObject, + ) { + if (ref?.current) { + this.registeredLayers[layerId] = { ref, canvasId, layerIndex }; + this.resizeObserver.observe(ref.current); + } + } + + //Method to de register layers for resize observer changes + public unObserveLayer(layerId: string) { + const element = this.registeredLayers[layerId]?.ref?.current; + if (element) { + this.resizeObserver.unobserve(element); + } + + delete this.registeredLayers[layerId]; + } + + //Method to register layouts for resize observer changes + public observeLayout( + layoutId: string, + ref: RefObject, + canvasId: string, + id: string, + ) { + if (ref?.current) { + this.registeredLayouts[layoutId] = { ref, canvasId, layoutId: id }; + this.resizeObserver.observe(ref.current); + } + } + + //Method to de register layouts for resize observer changes + public unObserveLayout(layoutId: string) { + const element = this.registeredLayouts[layoutId]?.ref?.current; + if (element) { + this.resizeObserver.unobserve(element); + } + + delete this.registeredLayouts[layoutId]; + } + + //This method is triggered from the resize observer to add widgets to queue + private addWidgetToProcess(widgetDOMId: string) { + if (this.registeredWidgets[widgetDOMId]) { + const widgetId = this.registeredWidgets[widgetDOMId].id; + this.widgetsProcessQueue[widgetId] = true; + this.debouncedProcessBatch(); + } + } + + //This method is triggered from the resize observer to add layer to queue + private addLayerToProcess(LayerId: string) { + if (this.registeredLayers[LayerId]) { + const { canvasId, layerIndex } = this.registeredLayers[LayerId]; + + //If the layer in canvas already exist + //and if the current layer is further higher than the previous one + //add this layer to queue + if ( + this.layersProcessQueue[canvasId] === undefined || + this.layersProcessQueue[canvasId] > layerIndex + ) { + this.layersProcessQueue[canvasId] = layerIndex; + } + this.debouncedProcessBatch(); + } + } + + //This method is triggered from the resize observer to add layout to queue + private addLayoutToProcess(layoutId: string) { + if (this.registeredLayouts[layoutId]) { + const id = this.registeredLayouts[layoutId].layoutId; + this.layoutsProcessQueue[id] = true; + this.debouncedProcessBatch(); + } + } + + //Clear all process queues + private clearProcessQueue() { + this.widgetsProcessQueue = {}; + this.layersProcessQueue = {}; + this.layoutsProcessQueue = {}; + } + + //Dispatch all the changed elements to saga for further processing to update widget positions + private processWidgetBatch() { + store.dispatch( + readWidgetPositions( + { ...this.widgetsProcessQueue }, + { ...this.layersProcessQueue }, + { ...this.layoutsProcessQueue }, + ), + ); + this.clearProcessQueue(); + } +} + +export const widgetPositionsObserver = new WidgetPositionsObserver(); diff --git a/app/client/src/layoutSystems/common/utils/WidgetPositionsObserver/utils.ts b/app/client/src/layoutSystems/common/utils/WidgetPositionsObserver/utils.ts new file mode 100644 index 0000000000..f9b17171a0 --- /dev/null +++ b/app/client/src/layoutSystems/common/utils/WidgetPositionsObserver/utils.ts @@ -0,0 +1,31 @@ +export const ANVIL_LAYER = "anvil_layer"; +export const ANVIL_WIDGET = "anvil_widget"; +export const LAYOUT = "layout"; + +/** + * Method to return Id of widget with widgetId + * @param widgetId + * @returns + */ +export const getAnvilWidgetId = (widgetId: string) => { + return ANVIL_WIDGET + "_" + widgetId; +}; + +/** + * Method to return Id of layer in canvasId of index layerIndex + * @param canvasId + * @param layerIndex + * @returns + */ +export const getAnvilLayerId = (canvasId: string, layerIndex: number) => { + return ANVIL_LAYER + "_" + canvasId + "_" + layerIndex; +}; + +/** + * Method to return Id of layout with layoutId + * @param layoutId + * @returns + */ +export const getLayoutId = (layoutId: string) => { + return LAYOUT + "_" + layoutId; +}; diff --git a/app/client/src/pages/Editor/Canvas.tsx b/app/client/src/pages/Editor/Canvas.tsx index 921d5c5be3..8f6ec070e4 100644 --- a/app/client/src/pages/Editor/Canvas.tsx +++ b/app/client/src/pages/Editor/Canvas.tsx @@ -15,6 +15,7 @@ import { useTheme, } from "@design-system/theming"; import { getIsAppSettingsPaneWithNavigationTabOpen } from "selectors/appSettingsPaneSelectors"; +import { CANVAS_ART_BOARD } from "constants/componentClassNameConstants"; import { renderAppsmithCanvas } from "layoutSystems/CanvasFactory"; import type { WidgetProps } from "widgets/BaseWidget"; @@ -83,8 +84,8 @@ const Canvas = (props: CanvasProps) => { className={`relative t--canvas-artboard ${paddingBottomClass} transition-all duration-400 ${marginHorizontalClass} ${getViewportClassName( canvasWidth, )}`} - data-testid="t--canvas-artboard" - id="art-board" + data-testid={"t--canvas-artboard"} + id={CANVAS_ART_BOARD} ref={focusRef} width={canvasWidth} > diff --git a/app/client/src/reducers/entityReducers/index.ts b/app/client/src/reducers/entityReducers/index.ts new file mode 100644 index 0000000000..fd9feb4532 --- /dev/null +++ b/app/client/src/reducers/entityReducers/index.ts @@ -0,0 +1,37 @@ +import { combineReducers } from "redux"; +import appReducer from "./appReducer"; +import canvasWidgetsReducer from "./canvasWidgetsReducer"; +import canvasWidgetsStructureReducer from "./canvasWidgetsStructureReducer"; +import metaWidgetsReducer from "./metaWidgetsReducer"; +import datasourceReducer from "./datasourceReducer"; +import jsActionsReducer from "./jsActionsReducer"; +import jsExecutionsReducer from "./jsExecutionsReducer"; +import metaReducer from "./metaReducer"; +import pageListReducer from "./pageListReducer"; +import pluginsReducer from "reducers/entityReducers/pluginsReducer"; +import autoHeightLayoutTreeReducer from "./autoHeightReducers/autoHeightLayoutTreeReducer"; +import canvasLevelsReducer from "./autoHeightReducers/canvasLevelsReducer"; +import actionsReducer from "@appsmith/reducers/entityReducers/actionsReducer"; + +/* Reducers which are integrated into the core system when registering a pluggable module + or done so by a module that is designed to be eventually pluggable */ +import widgetPositionsReducer from "layoutSystems/anvil/integrations/reducers/widgetPositionsReducer"; + +const entityReducer = combineReducers({ + canvasWidgets: canvasWidgetsReducer, + canvasWidgetsStructure: canvasWidgetsStructureReducer, + metaWidgets: metaWidgetsReducer, + actions: actionsReducer, + datasources: datasourceReducer, + pageList: pageListReducer, + jsExecutions: jsExecutionsReducer, + plugins: pluginsReducer, + meta: metaReducer, + app: appReducer, + jsActions: jsActionsReducer, + autoHeightLayoutTree: autoHeightLayoutTreeReducer, + canvasLevels: canvasLevelsReducer, + widgetPositions: widgetPositionsReducer, +}); + +export default entityReducer; diff --git a/app/client/src/selectors/widgetDragSelectors.ts b/app/client/src/selectors/widgetDragSelectors.ts new file mode 100644 index 0000000000..3947ebddbd --- /dev/null +++ b/app/client/src/selectors/widgetDragSelectors.ts @@ -0,0 +1,42 @@ +import type { AppState } from "@appsmith/reducers"; +import { createSelector } from "reselect"; +import { getIsAppSettingsPaneWithNavigationTabOpen } from "./appSettingsPaneSelectors"; +import { previewModeSelector, snipingModeSelector } from "./editorSelectors"; + +export const getIsDragging = (state: AppState) => + state.ui.widgetDragResize.isDragging; + +export const getIsResizing = (state: AppState) => + state.ui.widgetDragResize.isResizing; + +export const getIsDraggingDisabledInEditor = (state: AppState) => + state.ui.widgetDragResize.isDraggingDisabled; + +/** + * getShouldAllowDrag is a Selector that indicates if the widget could be dragged on canvas based on different states + */ +export const getShouldAllowDrag = createSelector( + getIsResizing, + getIsDragging, + getIsDraggingDisabledInEditor, + previewModeSelector, + snipingModeSelector, + getIsAppSettingsPaneWithNavigationTabOpen, + ( + isResizing, + isDragging, + isDraggingDisabled, + isPreviewMode, + isSnipingMode, + isAppSettingsPaneWithNavigationTabOpen, + ) => { + return ( + !isResizing && + !isDragging && + !isDraggingDisabled && + !isSnipingMode && + !isPreviewMode && + !isAppSettingsPaneWithNavigationTabOpen + ); + }, +); diff --git a/app/client/src/widgets/CameraWidget/component/index.tsx b/app/client/src/widgets/CameraWidget/component/index.tsx index a68f41c409..b571cfac0b 100644 --- a/app/client/src/widgets/CameraWidget/component/index.tsx +++ b/app/client/src/widgets/CameraWidget/component/index.tsx @@ -47,6 +47,7 @@ import type { ThemeProp } from "WidgetProvider/constants"; import { isAirgapped } from "@appsmith/utils/airgapHelpers"; import { importSvg } from "design-system-old"; import { getVideoConstraints } from "../../utils"; +import { CANVAS_ART_BOARD } from "constants/componentClassNameConstants"; const CameraOfflineIcon = importSvg( () => import("assets/icons/widget/camera/camera-offline.svg"), @@ -806,7 +807,9 @@ function DevicePopover(props: DevicePopoverProps) { content={} disabled={disabledMenu} minimal - portalContainer={document.getElementById("art-board") || undefined} + portalContainer={ + document.getElementById(CANVAS_ART_BOARD) || undefined + } >