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 + + + + 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 Blocks Selected + + + + 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