import React, { useMemo, useRef } from "react"; import styled from "styled-components"; import { get, minBy, maxBy } from "lodash"; import { useSelector, useDispatch } from "react-redux"; import { copyWidget, cutWidget, groupWidgets, deleteSelectedWidget, } from "actions/widgetActions"; import { modText } from "utils/helpers"; import { Layers } from "constants/Layers"; import { FormIcons } from "icons/FormIcons"; import Tooltip from "components/ads/Tooltip"; import { ControlIcons } from "icons/ControlIcons"; import { getSelectedWidgets } from "selectors/ui"; import { generateClassName } from "utils/generators"; import { stopEventPropagation } from "utils/AppsmithUtils"; import { getCanvasWidgets } from "selectors/entitiesSelector"; import { IPopoverSharedProps, Position } from "@blueprintjs/core"; import { useWidgetSelection } from "utils/hooks/useWidgetSelection"; 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` position: absolute; cursor: grab; `; const StyledActionsContainer = styled.div` position: relative; height: 100%; width: 100%; `; const StyledActions = styled.div` left: calc(100% - 38px); padding: 5px 0; width: max-content; z-index: ${Layers.contextMenu}; position: absolute; background-color: ${(props) => props.theme.colors.appBackground}; `; const StyledAction = styled.button` cursor: pointer; height: 28px; width: 28px; display: flex; align-items: center; justify-content: center; margin: 0 5px; outline: none; border: none; background: transparent; z-index: ${Layers.contextMenu}; position: relative; &:hover, &:active, &.active { background: ${(props) => props.disabled ? "initial" : props.theme.colors.widgetGroupingContextMenu.actionActiveBg}; } &:focus { outline: none; } `; const StyledSelectBoxHandleTop = styled.div` width: 100%; height: 1px; position: absolute; z-index: ${Layers.contextMenu}; border-top: 1px dashed ${(props) => props.theme.colors.widgetGroupingContextMenu.border}; top: 0px; left: -1px; `; const StyledSelectBoxHandleLeft = styled.div` width: 0px; height: 100%; position: absolute; z-index: ${Layers.contextMenu}; border-left: 1px dashed ${(props) => props.theme.colors.widgetGroupingContextMenu.border}; top: 0px; left: -1px; `; const StyledSelectBoxHandleRight = styled.div` width: 0px; height: 100%; position: absolute; z-index: ${Layers.contextMenu}; border-left: 1px dashed ${(props) => props.theme.colors.widgetGroupingContextMenu.border}; top: 0px; left: calc(100% - 1px); `; const StyledSelectBoxHandleBottom = styled.div` width: 100%; height: 1px; position: absolute; z-index: ${Layers.contextMenu}; border-bottom: 1px dashed ${(props) => props.theme.colors.widgetGroupingContextMenu.border}; top: 100%; left: -1px; `; export const PopoverModifiers: IPopoverSharedProps["modifiers"] = { offset: { enabled: true, }, arrow: { enabled: false, }, }; const CopyIcon = ControlIcons.COPY2_CONTROL; const DeleteIcon = FormIcons.DELETE_ICON; const CutIcon = ControlIcons.CUT_CONTROL; const GroupIcon = ControlIcons.GROUP_CONTROL; /** * helper text that comes in popover on hover of actions in context menu * @returns */ const copyHelpText = ( <> Click or {modText()} C & {modText()} V ); const cutHelpText = ( <> Click or {modText()} X & {modText()} V ); const deleteHelpText = ( <> Click or Del ); const groupHelpText = ( <> Click or {modText()} G to group ); interface OffsetBox { top: number; left: number; width: number; height: number; } function WidgetsMultiSelectBox(props: { widgetId: string; widgetType: string; snapColumnSpace: number; snapRowSpace: number; }): any { const dispatch = useDispatch(); const isCommentMode = useSelector(commentModeSelector); const canvasWidgets = useSelector(getCanvasWidgets); const selectedWidgetIDs = useSelector(getSelectedWidgets); const selectedWidgets = selectedWidgetIDs.map( (widgetID) => canvasWidgets[widgetID], ); const { focusWidget } = useWidgetSelection(); const isDragging = useSelector( (state: AppState) => state.ui.widgetDragResize.isDragging, ); /** * the multi-selection bounding box should only render when: * * 1. the widgetID is equal to the parent id of selected widget * 2. has common parent * 3. multiple widgets are selected */ const shouldRender = useMemo(() => { if (isDragging || isCommentMode) { return false; } const parentIDs = selectedWidgets .filter(Boolean) .map((widget) => widget.parentId); const hasCommonParent = parentIDs.every((v) => v === parentIDs[0]); const isMultipleWidgetsSelected = selectedWidgetIDs.length > 1; return ( props.widgetType === WidgetTypes.CANVAS_WIDGET && isMultipleWidgetsSelected && hasCommonParent && get(selectedWidgets, "0.parentId") === props.widgetId ); }, [selectedWidgets, isDragging, isCommentMode]); const draggableRef = useRef(null); const { setDraggingState } = useWidgetDragResize(); const onDragStart = (e: any) => { e.preventDefault(); e.stopPropagation(); if (draggableRef.current) { const bounds = draggableRef.current.getBoundingClientRect(); const parentId = get(selectedWidgets, "0.parentId"); const startPoints = { top: (e.clientY - bounds.top) / props.snapRowSpace, left: (e.clientX - bounds.left) / props.snapColumnSpace, }; const top = minBy(selectedWidgets, (rect) => rect.topRow)?.topRow; const left = minBy(selectedWidgets, (rect) => rect.leftColumn) ?.leftColumn; setDraggingState({ isDragging: true, dragGroupActualParent: parentId || "", draggingGroupCenter: { top, left, }, startPoints, }); } }; /** * calculate bounding box */ const { height, left, top, width } = useMemo(() => { if (shouldRender) { const widgetClasses = selectedWidgetIDs .map((id) => `.${generateClassName(id)}.${POSITIONED_WIDGET}`) .join(","); const elements = document.querySelectorAll(widgetClasses); const rects: OffsetBox[] = []; elements.forEach((el) => { rects.push({ top: el.offsetTop, left: el.offsetLeft, width: el.offsetWidth, height: el.offsetHeight, }); }); return { top: minBy(rects, (rect) => rect.top), left: minBy(rects, (rect) => rect.left), height: maxBy(rects, (rect) => rect.top + rect.height), width: maxBy(rects, (rect) => rect.left + rect.width), }; } return {}; }, [selectedWidgets]); /** * copies the selected widgets * * @param e */ const onCopySelectedWidgets = ( e: React.MouseEvent, ) => { e.preventDefault(); e.stopPropagation(); dispatch(copyWidget(true)); return false; }; /** * cut the selected widgets * * @param e */ const onCutSelectedWidgets = ( e: React.MouseEvent, ) => { e.preventDefault(); e.stopPropagation(); dispatch(cutWidget()); }; /** * cut the selected widgets * * @param e */ const onDeleteSelectedWidgets = ( e: React.MouseEvent, ) => { e.preventDefault(); e.stopPropagation(); dispatch(deleteSelectedWidget(true)); }; /** * group widgets into container * * @param e */ const onGroupWidgets = ( e: React.MouseEvent, ) => { e.preventDefault(); e.stopPropagation(); dispatch(groupWidgets()); }; if (!shouldRender) return false; return ( focusWidget()} onMouseOver={() => focusWidget()} ref={draggableRef} style={{ left: left?.left, top: top?.top, height: get(height, "top", 0) + get(height, "height", 0) - get(top, "top", 0), width: get(width, "left", 0) + get(width, "width", 0) - get(left, "left", 0), }} > {/* copy widgets */} {/* cut widgets */} {/* delete widgets */} {/* group widgets */} ); } export default WidgetsMultiSelectBox;