diff --git a/packages/client/src/assets/img/Group.svg b/packages/client/src/assets/img/Group.svg
new file mode 100644
index 000000000..108bc7b32
--- /dev/null
+++ b/packages/client/src/assets/img/Group.svg
@@ -0,0 +1,5 @@
+
diff --git a/packages/client/src/assets/img/Multiple_Block.svg b/packages/client/src/assets/img/Multiple_Block.svg
new file mode 100644
index 000000000..a2064c9e3
--- /dev/null
+++ b/packages/client/src/assets/img/Multiple_Block.svg
@@ -0,0 +1,5 @@
+
diff --git a/packages/client/src/assets/img/VariationLogo.svg b/packages/client/src/assets/img/VariationLogo.svg
new file mode 100644
index 000000000..f7611e3e6
--- /dev/null
+++ b/packages/client/src/assets/img/VariationLogo.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/client/src/components/blocks-workspace/panels/SelectedBlockPanel.tsx b/packages/client/src/components/blocks-workspace/panels/SelectedBlockPanel.tsx
index 1bd849270..e4f9ba268 100644
--- a/packages/client/src/components/blocks-workspace/panels/SelectedBlockPanel.tsx
+++ b/packages/client/src/components/blocks-workspace/panels/SelectedBlockPanel.tsx
@@ -13,21 +13,20 @@ import {
Stack,
Typography,
IconButton,
- Divider,
TextField,
Collapse,
useNotification,
- Modal,
- Tabs,
- Tab,
ToggleTabsGroup,
+ AlertTitle,
} from '@semoss/ui';
import { useDesigner } from '@/hooks';
-import { BlockAvatar, SelectedMenuSection } from '@/components/designer';
+import { SelectedMenuSection } from '@/components/designer';
import { AddVariableModal } from '@/components/notebook';
import { Panel } from '@/components/workspace';
-import VariationIcon from '../../../../../../libs/renderer/src/assets/img/VariationLogo.svg';
+import MultiBlockIcon from '../../../assets/img/Multiple_Block.svg';
+import GroupIcon from '../../../assets/img/Group.svg';
+import VariationIcon from '../../../assets/img/VariationLogo.svg';
const StyledTitle = styled(Typography)(() => ({
textTransform: 'capitalize',
@@ -72,6 +71,35 @@ const StyledMessage = styled('div')(({ theme }) => ({
width: '100%',
alignItems: 'center',
justifyContent: 'center',
+ padding: '6px 0px',
+}));
+const StyledMultiBlockMessage = styled('div')(({ theme }) => ({
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'flex-start',
+ justifyContent: 'center',
+ padding: '8px 0px',
+ flex: '1 0 0',
+}));
+const StyledAlertTitle = styled(AlertTitle)(({ theme }) => ({
+ alignSelf: 'stretch',
+ color: '#666',
+ fontFamily: 'Inter',
+ fontSize: '16px',
+ fontStyle: 'normal',
+ fontWeight: 500,
+ lineHeight: '150%',
+ letterSpacing: '0.15px',
+}));
+const StyledTypography = styled(Typography)(({ theme }) => ({
+ alignSelf: 'stretch',
+ color: '#666',
+ fontFamily: 'Inter',
+ fontSize: '14px',
+ fontStyle: 'normal',
+ fontWeight: 400,
+ lineHeight: '150%',
+ letterSpacing: '0.17px',
}));
//Tab group with custom style with width and margin
const StyledToggleTabsGroup = styled(ToggleTabsGroup)(({ theme }) => ({
@@ -112,6 +140,27 @@ const StyledToggleTabsGroupItem = styled(ToggleTabsGroup.Item)(({ theme }) => ({
}));
const StyledCustomTabPanel = styled('div')(({ theme }) => ({}));
+const StyledParentDiv = styled('div')(({ theme }) => ({
+ padding: '16px 8px',
+}));
+
+const StyledDiv = styled('div')(({ theme }) => ({
+ display: 'flex',
+ alignItems: 'center',
+ padding: '6px 16px',
+ gap: '12px',
+ alignSelf: 'stretch',
+ borderRadius: '4px',
+ background: '#F5F5F5',
+}));
+
+const StyledImgDiv = styled('div')(({ theme }) => ({
+ display: 'flex',
+ alignItems: 'flex-start',
+ width: '22px',
+ height: '22px',
+}));
+
const StyledVariationIcon = styled('img')(({ theme }) => ({
width: theme.spacing(4),
height: theme.spacing(4),
@@ -283,16 +332,50 @@ export const SelectedBlockPanel = observer(() => {
return '';
}
};
+ if (designer.selectedBlocks.length > 1) {
+ return (
+
+
+
+
+
+
+
+
+ Multiple Blocks Selected
+
+
+ Select a single block to view its setting
+
+
+
+
+
+ );
+ }
// ignore if there is no menu
if (!block) {
return (
-
-
- Select a block to update
-
-
+
+
+
+
+
+
+
+ No Block Selected
+
+
+ Select a block to view its setting
+
+
+
+
);
}
diff --git a/packages/client/src/components/designer/AddBlocksMenuCard.tsx b/packages/client/src/components/designer/AddBlocksMenuCard.tsx
index fcd3312b9..ad27f3005 100644
--- a/packages/client/src/components/designer/AddBlocksMenuCard.tsx
+++ b/packages/client/src/components/designer/AddBlocksMenuCard.tsx
@@ -247,6 +247,9 @@ export const AddBlocksMenuCard = observer((props: AddBlocksMenuItemProps) => {
// clear the selected
designer.setSelected(id ? id : '');
+ // clear the selectedBlocks
+ designer.addBlockToSelected('clear');
+
// set as active
setLocal(false);
}, [
diff --git a/packages/client/src/components/designer/Screen.tsx b/packages/client/src/components/designer/Screen.tsx
index b2d0586c6..9f068f9e4 100644
--- a/packages/client/src/components/designer/Screen.tsx
+++ b/packages/client/src/components/designer/Screen.tsx
@@ -104,6 +104,26 @@ export const Screen = observer((props: ScreenProps) => {
designer.setSelected(designer.hovered);
};
+ const handleMultipleSelection = (event) => {
+ if (!designer.hovered || designer.hovered === designer.selected) {
+ return;
+ }
+ const id = getNearestBlock(event.target as Element);
+
+ // prevent events for elements until selected
+ event.stopPropagation();
+ event.preventDefault();
+ if (designer.selectedBlocks.includes(id)) {
+ return; // Do nothing if the id is already selected
+ }
+
+ designer.setSelected(id);
+ designer.addBlockToSelected(id);
+ if (designer.selectedBlocks.length > 1) {
+ designer.setSelected('');
+ }
+ };
+
/**
* Handle the mouseover on the page. This will hover the nearest block.
*
@@ -256,7 +276,8 @@ export const Screen = observer((props: ScreenProps) => {
{eleRef.current ? (
<>
- {designer.selected && (
+ {(designer.selected ||
+ designer.selectedBlocks.length > 1) && (
)}
{designer.hovered && (
@@ -279,7 +300,16 @@ export const Screen = observer((props: ScreenProps) => {
{
+ if (e.ctrlKey || e.metaKey || e.shiftKey) {
+ e.stopPropagation();
+ e.preventDefault();
+ handleMultipleSelection(e);
+ } else {
+ designer.addBlockToSelected('clear');
+ handleClickCapture(e);
+ }
+ }}
>
{children}
diff --git a/packages/client/src/components/designer/SelectedMask.tsx b/packages/client/src/components/designer/SelectedMask.tsx
index a4cfe6b9c..a4d18ac7d 100644
--- a/packages/client/src/components/designer/SelectedMask.tsx
+++ b/packages/client/src/components/designer/SelectedMask.tsx
@@ -72,34 +72,70 @@ export const SelectedMask = observer((props: SelectedMaskProps) => {
const isDraggable =
block && registry[block.widget] && block.widget !== 'page';
+ // check if all blocks are draggable
+ const areAllBlocksDraggable = (): boolean => {
+ return designer.selectedBlocks.every((id) => {
+ const block = state.getBlock(id);
+ return block && registry[block.widget] && block.widget !== 'page';
+ });
+ };
+
/**
* Handle the mousedown on the block.
*/
const handleMouseDown = () => {
- if (!isDraggable) {
- return;
- }
-
- // set the dragged
- designer.activateDrag(
- block.widget,
- (parent) => {
- // if the parent block is a child of the selected, we cannot add
- if (state.containsBlock(designer.selected, parent)) {
- return false;
- }
+ if (designer.selectedBlocks.length > 1) {
+ if (!areAllBlocksDraggable()) {
+ return;
+ }
+ // Handle drag for multiple selected blocks
+ designer.activateDrag(
+ designer.selectedBlocks
+ .map((id) => state.getBlock(id).widget)
+ .join(','),
+ (parent) => {
+ // Ensure none of the selected blocks are children of the parent
+ return !designer.selectedBlocks.some((id) =>
+ state.containsBlock(id, parent),
+ );
+ },
+ designer.selectedBlocks.join(','),
+ designer.selectedBlocks.map(
+ (id) => registry[state.getBlock(id).widget].icon,
+ ),
+ );
+
+ // Clear the hovered block
+ designer.setHovered('');
+
+ // Set as inactive
+ setLocal(true);
+ } else {
+ // Existing logic for single block drag
+ if (!isDraggable) {
+ return;
+ }
+ // set the dragged
+ designer.activateDrag(
+ block.widget,
+ (parent) => {
+ // if the parent block is a child of the selected, we cannot add
+ if (state.containsBlock(designer.selected, parent)) {
+ return false;
+ }
- return true;
- },
- block.id,
- registry[block.widget].icon,
- );
+ return true;
+ },
+ block.id,
+ registry[block.widget].icon,
+ );
- // clear the hovered
- designer.setHovered('');
+ // Clear the hovered block
+ designer.setHovered('');
- // set as inactive
- setLocal(true);
+ // Set as inactive
+ setLocal(true);
+ }
};
/**
@@ -109,79 +145,152 @@ export const SelectedMask = observer((props: SelectedMaskProps) => {
if (!designer.drag.active) {
return;
}
-
// apply the action
const placeholderAction = designer.drag.placeholderAction;
- const sw = state.getBlock(placeholderAction.id);
if (placeholderAction) {
- if (
- placeholderAction.type === 'before' ||
- placeholderAction.type === 'after'
- ) {
- const siblingWidget = state.getBlock(placeholderAction.id);
-
- if (siblingWidget.parent) {
- const parent = state.getBlock(sw.parent.id);
- if (parent.widget === 'iteration') {
- if (parent.slots.children.children.length) {
- notification.add({
- color: 'error',
- message:
- 'Please delete block within iterator before adding another child',
+ if (designer.selectedBlocks.length > 1) {
+ // Handle multiple block movements
+ let lastSiblingId = placeholderAction.id; // Start with the placeholder ID
+ designer.selectedBlocks.forEach((id) => {
+ const sw = state.getBlock(placeholderAction.id);
+
+ if (
+ placeholderAction.type === 'before' ||
+ placeholderAction.type === 'after'
+ ) {
+ const siblingWidget = state.getBlock(lastSiblingId); // Use the last sibling ID
+
+ if (siblingWidget.parent) {
+ const parent = state.getBlock(sw.parent.id);
+ if (parent.widget === 'iteration') {
+ if (parent.slots.children.children.length) {
+ notification.add({
+ color: 'error',
+ message:
+ 'Please delete block within iterator before adding another child',
+ });
+ designer.deactivateDrag();
+ return;
+ }
+ }
+ state.dispatch({
+ message: ActionMessages.MOVE_BLOCK,
+ payload: {
+ id,
+ position: {
+ parent: siblingWidget.parent.id,
+ slot: siblingWidget.parent.slot,
+ sibling:
+ placeholderAction.type === 'before'
+ ? siblingWidget.id
+ : lastSiblingId,
+ type: placeholderAction.type,
+ },
+ },
+ });
+
+ // Update the lastSiblingId to the current block
+ lastSiblingId = id;
+ }
+ } else if (placeholderAction.type === 'replace') {
+ if (sw.widget !== 'iteration') {
+ state.dispatch({
+ message: ActionMessages.SET_BLOCK_DATA,
+ payload: {
+ id: placeholderAction.id,
+ path: 'child',
+ value: state.getBlock(id),
+ },
});
- designer.deactivateDrag();
- return;
}
+ if (sw.widget !== 'iteration') {
+ state.dispatch({
+ message: ActionMessages.MOVE_BLOCK,
+ payload: {
+ id,
+ position: {
+ parent: placeholderAction.id,
+ slot: placeholderAction.slot,
+ },
+ },
+ });
+ }
+ }
+ });
+ } else {
+ // Existing logic for single block movement
+ const sw = state.getBlock(placeholderAction.id);
+
+ if (
+ placeholderAction.type === 'before' ||
+ placeholderAction.type === 'after'
+ ) {
+ const siblingWidget = state.getBlock(placeholderAction.id);
+
+ if (siblingWidget.parent) {
+ const parent = state.getBlock(sw.parent.id);
+ if (parent.widget === 'iteration') {
+ if (parent.slots.children.children.length) {
+ notification.add({
+ color: 'error',
+ message:
+ 'Please delete block within iterator before adding another child',
+ });
+ designer.deactivateDrag();
+ return;
+ }
+ }
+ state.dispatch({
+ message: ActionMessages.MOVE_BLOCK,
+ payload: {
+ id: designer.selected,
+ position: {
+ parent: siblingWidget.parent.id,
+ slot: siblingWidget.parent.slot,
+ sibling: siblingWidget.id,
+ type: placeholderAction.type,
+ },
+ },
+ });
+ }
+ } else if (placeholderAction.type === 'replace') {
+ if (sw.widget === 'iteration') {
+ state.dispatch({
+ message: ActionMessages.SET_BLOCK_DATA,
+ payload: {
+ id: placeholderAction.id,
+ path: 'child',
+ value: state.getBlock(designer.selected),
+ },
+ });
}
+
state.dispatch({
message: ActionMessages.MOVE_BLOCK,
payload: {
id: designer.selected,
position: {
- parent: siblingWidget.parent.id,
- slot: siblingWidget.parent.slot,
- sibling: siblingWidget.id,
- type: placeholderAction.type,
+ parent: placeholderAction.id,
+ slot: placeholderAction.slot,
},
},
});
}
- } else if (placeholderAction.type === 'replace') {
- if (sw.widget === 'iteration') {
- state.dispatch({
- message: ActionMessages.SET_BLOCK_DATA,
- payload: {
- id: placeholderAction.id,
- path: 'child',
- value: state.getBlock(designer.selected),
- },
- });
- }
-
- state.dispatch({
- message: ActionMessages.MOVE_BLOCK,
- payload: {
- id: designer.selected,
- position: {
- parent: placeholderAction.id,
- slot: placeholderAction.slot,
- },
- },
- });
}
}
- // clear the drag
+ // Clear the drag
designer.deactivateDrag();
- // clear the hovered
+ // Clear the hovered block
designer.setHovered('');
- // set as active
+ // Set as active
setLocal(false);
}, [
designer.selected,
+ designer.selectedBlocks,
designer.drag.active,
designer.drag.placeholderAction,
state,
@@ -190,14 +299,19 @@ export const SelectedMask = observer((props: SelectedMaskProps) => {
// reposition the mask
const repositionMask = () => {
- // get the block elemenent
- const blockEle = getBlockElement(designer.selected);
+ // Use designer.selected or fallback to the first ID in designer.selectedIds
+ const selectedId = designer.selected || designer.selectedBlocks[0];
+ if (!selectedId) {
+ return;
+ }
+
+ const blockEle = getBlockElement(selectedId);
if (!blockEle) {
return;
}
- // calculate and set the side
+ // Calculate and set the size
const updated = getRelativeSize(blockEle, screenEle);
setSize(updated);
};
@@ -247,29 +361,69 @@ export const SelectedMask = observer((props: SelectedMaskProps) => {
return <>>;
}
- return (
-
-
-
-
- {variableName ? variableName : designer.selected}
-
-
- {isDraggable && (
-
- )}
-
-
- );
+ if (designer.selectedBlocks.length > 1) {
+ return (
+ <>
+ {designer.selectedBlocks.map((id, index) => {
+ const blockElement = getBlockElement(id);
+ if (!blockElement) return null;
+
+ const blockSize = getRelativeSize(blockElement, screenEle);
+
+ return (
+
+
+
+
+ {variableName ? variableName : id}
+
+
+ {areAllBlocksDraggable() && (
+
+ )}
+
+
+ );
+ })}
+ >
+ );
+ } else {
+ return (
+
+
+
+
+ {variableName ? variableName : designer.selected}
+
+
+ {isDraggable && (
+
+ )}
+
+
+ );
+ }
});
diff --git a/packages/client/src/stores/designer/designer.store.ts b/packages/client/src/stores/designer/designer.store.ts
index 47ffde628..59e809ea0 100644
--- a/packages/client/src/stores/designer/designer.store.ts
+++ b/packages/client/src/stores/designer/designer.store.ts
@@ -11,6 +11,8 @@ export interface DesignerStoreInterface {
selected: string;
/** Current hovered block */
hovered: string;
+ /** Current selected multiple block ids */
+ selectedBlocks: string[];
/** drag information */
drag: {
/** Is the drag active? */
@@ -74,6 +76,7 @@ export class DesignerStore {
placeholderSize: null,
placeholderAction: null,
},
+ selectedBlocks: [],
};
constructor(state: StateStore, config: DesignerConfigInterface) {
@@ -99,6 +102,15 @@ export class DesignerStore {
return this._store.selected;
}
+ /**
+ * Getter for retrieving the currently selected IDs from the store.
+ *
+ * @returns An array of selected IDs from the internal store.
+ */
+ get selectedBlocks() {
+ return this._store.selectedBlocks;
+ }
+
/**
* Get the rendered block
* @returns the rendered block
@@ -134,6 +146,18 @@ export class DesignerStore {
this._store.selected = id;
}
+ /**
+ * Set the multi-selected block IDs
+ * @param id - id of the block to add to the multi-selected list, or "clear" to reset the list
+ */
+ addBlockToSelected(id?: string) {
+ if (id === 'clear') {
+ this._store.selectedBlocks = [];
+ } else {
+ this._store.selectedBlocks.push(id);
+ }
+ }
+
/**
* Set the rendered block
* @param id - id of the block that is rendered