2021-08-12 05:45:38 +00:00
|
|
|
import React, { useMemo, useRef } from "react";
|
2021-07-08 06:30:19 +00:00
|
|
|
import styled from "styled-components";
|
|
|
|
|
import { get, minBy, maxBy } from "lodash";
|
|
|
|
|
import { useSelector, useDispatch } from "react-redux";
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
copyWidget,
|
|
|
|
|
cutWidget,
|
2021-08-25 05:00:31 +00:00
|
|
|
groupWidgets,
|
2021-07-08 06:30:19 +00:00
|
|
|
deleteSelectedWidget,
|
|
|
|
|
} from "actions/widgetActions";
|
|
|
|
|
import { isMac } 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";
|
2021-09-09 15:10:22 +00:00
|
|
|
import WidgetFactory from "utils/WidgetFactory";
|
2021-08-12 05:45:38 +00:00
|
|
|
import { AppState } from "reducers";
|
|
|
|
|
import { useWidgetDragResize } from "utils/hooks/dragResizeHooks";
|
2021-07-08 06:30:19 +00:00
|
|
|
|
2021-09-09 15:10:22 +00:00
|
|
|
const WidgetTypes = WidgetFactory.widgetTypes;
|
2021-07-08 06:30:19 +00:00
|
|
|
const StyledSelectionBox = styled.div`
|
|
|
|
|
position: absolute;
|
2021-08-12 05:45:38 +00:00
|
|
|
cursor: grab;
|
2021-07-08 06:30:19 +00:00
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
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;
|
2021-08-25 05:00:31 +00:00
|
|
|
const GroupIcon = ControlIcons.GROUP_CONTROL;
|
2021-07-08 06:30:19 +00:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* helper text that comes in popover on hover of actions in context menu
|
|
|
|
|
* @returns
|
|
|
|
|
*/
|
|
|
|
|
const modText = () => (isMac() ? <span>⌘</span> : "ctrl");
|
|
|
|
|
const copyHelpText = (
|
|
|
|
|
<>
|
|
|
|
|
Click or <b>{modText()} + C</b> & {modText()} + V
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
const cutHelpText = (
|
|
|
|
|
<>
|
|
|
|
|
Click or <b>{modText()} + X</b> & {modText()} + V
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
const deleteHelpText = (
|
|
|
|
|
<>
|
|
|
|
|
Click or <b> Del </b>
|
|
|
|
|
</>
|
|
|
|
|
);
|
2021-08-25 05:00:31 +00:00
|
|
|
const groupHelpText = (
|
|
|
|
|
<>
|
|
|
|
|
Click or <b>{modText()} + G to group</b>
|
|
|
|
|
</>
|
|
|
|
|
);
|
2021-07-08 06:30:19 +00:00
|
|
|
|
|
|
|
|
interface OffsetBox {
|
|
|
|
|
top: number;
|
|
|
|
|
left: number;
|
|
|
|
|
width: number;
|
|
|
|
|
height: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function WidgetsMultiSelectBox(props: {
|
|
|
|
|
widgetId: string;
|
|
|
|
|
widgetType: string;
|
2021-08-12 05:45:38 +00:00
|
|
|
snapColumnSpace: number;
|
|
|
|
|
snapRowSpace: number;
|
2021-07-08 06:30:19 +00:00
|
|
|
}): any {
|
|
|
|
|
const dispatch = useDispatch();
|
|
|
|
|
const canvasWidgets = useSelector(getCanvasWidgets);
|
|
|
|
|
const selectedWidgetIDs = useSelector(getSelectedWidgets);
|
|
|
|
|
const selectedWidgets = selectedWidgetIDs.map(
|
|
|
|
|
(widgetID) => canvasWidgets[widgetID],
|
|
|
|
|
);
|
|
|
|
|
const { focusWidget } = useWidgetSelection();
|
2021-08-12 05:45:38 +00:00
|
|
|
const isDragging = useSelector(
|
|
|
|
|
(state: AppState) => state.ui.widgetDragResize.isDragging,
|
|
|
|
|
);
|
2021-07-08 06:30:19 +00:00
|
|
|
/**
|
|
|
|
|
* 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(() => {
|
2021-08-12 05:45:38 +00:00
|
|
|
if (isDragging) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2021-07-08 06:30:19 +00:00
|
|
|
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
|
|
|
|
|
);
|
2021-08-12 05:45:38 +00:00
|
|
|
}, [selectedWidgets, isDragging]);
|
|
|
|
|
const draggableRef = useRef<HTMLDivElement>(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,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
};
|
2021-07-08 06:30:19 +00:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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<HTMLElement>(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<HTMLButtonElement, MouseEvent>,
|
|
|
|
|
) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
dispatch(copyWidget(true));
|
|
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* cut the selected widgets
|
|
|
|
|
*
|
|
|
|
|
* @param e
|
|
|
|
|
*/
|
|
|
|
|
const onCutSelectedWidgets = (
|
|
|
|
|
e: React.MouseEvent<HTMLButtonElement, MouseEvent>,
|
|
|
|
|
) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
|
|
|
|
dispatch(cutWidget());
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* cut the selected widgets
|
|
|
|
|
*
|
|
|
|
|
* @param e
|
|
|
|
|
*/
|
|
|
|
|
const onDeleteSelectedWidgets = (
|
|
|
|
|
e: React.MouseEvent<HTMLButtonElement, MouseEvent>,
|
|
|
|
|
) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
|
|
|
|
dispatch(deleteSelectedWidget(true));
|
|
|
|
|
};
|
|
|
|
|
|
2021-08-25 05:00:31 +00:00
|
|
|
/**
|
|
|
|
|
* group widgets into container
|
|
|
|
|
*
|
|
|
|
|
* @param e
|
|
|
|
|
*/
|
|
|
|
|
const onGroupWidgets = (
|
|
|
|
|
e: React.MouseEvent<HTMLButtonElement, MouseEvent>,
|
|
|
|
|
) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
|
|
|
|
dispatch(groupWidgets());
|
|
|
|
|
};
|
|
|
|
|
|
2021-07-08 06:30:19 +00:00
|
|
|
if (!shouldRender) return false;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<StyledSelectionBox
|
|
|
|
|
className="t--multi-selection-box"
|
2021-08-12 05:45:38 +00:00
|
|
|
data-testid="t--selection-box"
|
|
|
|
|
draggable
|
2021-07-08 06:30:19 +00:00
|
|
|
key={`selection-box-${props.widgetId}`}
|
2021-08-12 05:45:38 +00:00
|
|
|
onDragStart={onDragStart}
|
2021-07-08 06:30:19 +00:00
|
|
|
onMouseMove={() => focusWidget()}
|
|
|
|
|
onMouseOver={() => focusWidget()}
|
2021-08-12 05:45:38 +00:00
|
|
|
ref={draggableRef}
|
2021-07-08 06:30:19 +00:00
|
|
|
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),
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<StyledSelectBoxHandleTop />
|
|
|
|
|
<StyledSelectBoxHandleLeft />
|
|
|
|
|
<StyledSelectBoxHandleRight />
|
|
|
|
|
<StyledSelectBoxHandleBottom />
|
|
|
|
|
<StyledActionsContainer>
|
|
|
|
|
<StyledActions>
|
|
|
|
|
{/* copy widgets */}
|
|
|
|
|
<Tooltip
|
|
|
|
|
boundary="viewport"
|
|
|
|
|
content={copyHelpText}
|
|
|
|
|
maxWidth="400px"
|
|
|
|
|
modifiers={PopoverModifiers}
|
|
|
|
|
position={Position.RIGHT}
|
|
|
|
|
>
|
|
|
|
|
<StyledAction
|
|
|
|
|
onClick={stopEventPropagation}
|
|
|
|
|
onClickCapture={onCopySelectedWidgets}
|
|
|
|
|
>
|
|
|
|
|
<CopyIcon color="black" height={16} width={16} />
|
|
|
|
|
</StyledAction>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
{/* cut widgets */}
|
|
|
|
|
<Tooltip
|
|
|
|
|
boundary="viewport"
|
|
|
|
|
content={cutHelpText}
|
|
|
|
|
maxWidth="400px"
|
|
|
|
|
modifiers={PopoverModifiers}
|
|
|
|
|
position={Position.RIGHT}
|
|
|
|
|
>
|
|
|
|
|
<StyledAction
|
|
|
|
|
onClick={stopEventPropagation}
|
|
|
|
|
onClickCapture={onCutSelectedWidgets}
|
|
|
|
|
>
|
|
|
|
|
<CutIcon color="black" height={16} width={16} />
|
|
|
|
|
</StyledAction>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
{/* delete widgets */}
|
|
|
|
|
<Tooltip
|
|
|
|
|
boundary="viewport"
|
|
|
|
|
content={deleteHelpText}
|
|
|
|
|
maxWidth="400px"
|
|
|
|
|
modifiers={PopoverModifiers}
|
|
|
|
|
position={Position.RIGHT}
|
|
|
|
|
>
|
|
|
|
|
<StyledAction
|
|
|
|
|
onClick={stopEventPropagation}
|
|
|
|
|
onClickCapture={onDeleteSelectedWidgets}
|
|
|
|
|
>
|
|
|
|
|
<DeleteIcon color="black" height={16} width={16} />
|
|
|
|
|
</StyledAction>
|
|
|
|
|
</Tooltip>
|
2021-08-25 05:00:31 +00:00
|
|
|
{/* group widgets */}
|
|
|
|
|
<Tooltip
|
|
|
|
|
boundary="viewport"
|
|
|
|
|
content={groupHelpText}
|
|
|
|
|
maxWidth="400px"
|
|
|
|
|
modifiers={PopoverModifiers}
|
|
|
|
|
position={Position.RIGHT}
|
|
|
|
|
>
|
|
|
|
|
<StyledAction
|
|
|
|
|
onClick={stopEventPropagation}
|
|
|
|
|
onClickCapture={onGroupWidgets}
|
|
|
|
|
>
|
|
|
|
|
<GroupIcon color="black" height={16} width={16} />
|
|
|
|
|
</StyledAction>
|
|
|
|
|
</Tooltip>
|
2021-07-08 06:30:19 +00:00
|
|
|
</StyledActions>
|
|
|
|
|
</StyledActionsContainer>
|
|
|
|
|
</StyledSelectionBox>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default WidgetsMultiSelectBox;
|