340 lines
8.5 KiB
TypeScript
340 lines
8.5 KiB
TypeScript
|
|
import React, { useMemo } from "react";
|
||
|
|
import styled from "styled-components";
|
||
|
|
import { get, minBy, maxBy } from "lodash";
|
||
|
|
import { useSelector, useDispatch } from "react-redux";
|
||
|
|
|
||
|
|
import {
|
||
|
|
copyWidget,
|
||
|
|
cutWidget,
|
||
|
|
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";
|
||
|
|
import { WidgetTypes } from "constants/WidgetConstants";
|
||
|
|
|
||
|
|
const StyledSelectionBox = styled.div`
|
||
|
|
position: absolute;
|
||
|
|
`;
|
||
|
|
|
||
|
|
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;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 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>
|
||
|
|
</>
|
||
|
|
);
|
||
|
|
|
||
|
|
interface OffsetBox {
|
||
|
|
top: number;
|
||
|
|
left: number;
|
||
|
|
width: number;
|
||
|
|
height: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
function WidgetsMultiSelectBox(props: {
|
||
|
|
widgetId: string;
|
||
|
|
widgetType: string;
|
||
|
|
}): any {
|
||
|
|
const dispatch = useDispatch();
|
||
|
|
const canvasWidgets = useSelector(getCanvasWidgets);
|
||
|
|
const selectedWidgetIDs = useSelector(getSelectedWidgets);
|
||
|
|
const selectedWidgets = selectedWidgetIDs.map(
|
||
|
|
(widgetID) => canvasWidgets[widgetID],
|
||
|
|
);
|
||
|
|
const { focusWidget } = useWidgetSelection();
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 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(() => {
|
||
|
|
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]);
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 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));
|
||
|
|
};
|
||
|
|
|
||
|
|
if (!shouldRender) return false;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<StyledSelectionBox
|
||
|
|
className="t--multi-selection-box"
|
||
|
|
key={`selection-box-${props.widgetId}`}
|
||
|
|
onMouseMove={() => focusWidget()}
|
||
|
|
onMouseOver={() => focusWidget()}
|
||
|
|
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>
|
||
|
|
</StyledActions>
|
||
|
|
</StyledActionsContainer>
|
||
|
|
</StyledSelectionBox>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export default WidgetsMultiSelectBox;
|