diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 4d63cbb9fb..61d41d13d0 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -144,6 +144,7 @@ Thanks to this capability, a specifier can easily retrieve a variable overriden - https://github.com/eclipse-sirius/sirius-web/issues/3096[#3096] [tree] Tree representations can now support filters - https://github.com/eclipse-sirius/sirius-web/issues/3158[#3158] [diagram] Add a new tool to apply adjust-size on diagram node. - https://github.com/eclipse-sirius/sirius-web/issues/3201[#3201] [diagram] Add support for tools on multiple diagram elements selection (hide, fade and pin). +- https://github.com/eclipse-sirius/sirius-web/issues/3243[#3243] [diagram] Add tools to help manual layout on multiple elements === Improvements diff --git a/integration-tests/cypress/e2e/project/diagrams/group-palette.cy.ts b/integration-tests/cypress/e2e/project/diagrams/group-palette.cy.ts new file mode 100644 index 0000000000..8c9fa2c34d --- /dev/null +++ b/integration-tests/cypress/e2e/project/diagrams/group-palette.cy.ts @@ -0,0 +1,105 @@ +/******************************************************************************* + * Copyright (c) 2024 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ +import { Project } from '../../../pages/Project'; +import { Explorer } from '../../../workbench/Explorer'; +import { Diagram } from '../../../workbench/Diagram'; +import { Flow } from '../../../usecases/Flow'; + +const projectName = 'Cypress - group palette'; +describe('Diagram - group palette', () => { + context('Given a flow project with a robot document', () => { + let projectId: string = ''; + beforeEach(() => { + new Flow().createRobotProject(projectName).then((createdProjectData) => { + projectId = createdProjectData.projectId; + new Project().visit(projectId); + }); + const explorer = new Explorer(); + explorer.expand('robot'); + explorer.createRepresentation('Robot', 'Topography', 'diagram'); + }); + + afterEach(() => cy.deleteProject(projectId)); + + it('Then the group palette is displayed when using multi selection', () => { + const diagram = new Diagram(); + const explorer = new Explorer(); + explorer.select('Wifi'); + explorer.select('Central_Unit', true); + diagram.fitToScreen(); + diagram.getNodes('diagram', 'Wifi').click(); + diagram.getPalette().should('not.exist'); + diagram.getGroupPalette().should('exist'); + + diagram.getNodes('diagram', 'DSP').click(); + diagram.getPalette().should('exist'); + diagram.getGroupPalette().should('not.exist'); + }); + + it('Then the default tools are available', () => { + const diagram = new Diagram(); + const explorer = new Explorer(); + explorer.select('Wifi'); + explorer.select('Central_Unit', true); + diagram.fitToScreen(); + diagram.getNodes('diagram', 'Wifi').click(); + diagram.getGroupPalette().should('exist'); + diagram.getGroupPalette().findByTestId('Hide elements').should('exist'); + diagram.getGroupPalette().findByTestId('Fade elements').should('exist'); + diagram.getGroupPalette().findByTestId('Pin elements').should('exist'); + }); + + it('Then the distribute elements tools are available', () => { + const diagram = new Diagram(); + const explorer = new Explorer(); + explorer.select('Wifi'); + explorer.select('Central_Unit', true); + diagram.fitToScreen(); + diagram.getNodes('diagram', 'Wifi').click(); + diagram.getGroupPalette().should('exist'); + diagram.getGroupPalette().findByTestId('expand').click(); + diagram.getGroupPalette().findByTestId('Distribute elements horizontally').should('exist'); + diagram.getGroupPalette().findByTestId('Distribute elements vertically').should('exist'); + diagram.getGroupPalette().findByTestId('Align left').should('exist'); + diagram.getGroupPalette().findByTestId('Align right').should('exist'); + diagram.getGroupPalette().findByTestId('Align center').should('exist'); + diagram.getGroupPalette().findByTestId('Align top').should('exist'); + diagram.getGroupPalette().findByTestId('Align bottom').should('exist'); + diagram.getGroupPalette().findByTestId('Justify horizontally').should('exist'); + diagram.getGroupPalette().findByTestId('Justify vertically').should('exist'); + diagram.getGroupPalette().findByTestId('Arrange in row').should('exist'); + diagram.getGroupPalette().findByTestId('Arrange in column').should('exist'); + diagram.getGroupPalette().findByTestId('Arrange in grid').should('exist'); + }); + + it('Then the last distribute elements tool used is memorized', () => { + const diagram = new Diagram(); + const explorer = new Explorer(); + explorer.select('Wifi'); + explorer.select('Central_Unit', true); + diagram.fitToScreen(); + diagram.getNodes('diagram', 'Wifi').click(); + diagram.getGroupPalette().should('exist'); + diagram.getGroupPalette().findByTestId('Distribute elements horizontally').should('exist'); + diagram.getGroupPalette().findByTestId('expand').click(); + diagram.getGroupPalette().findByTestId('Arrange in column').click(); + diagram.fitToScreen(); + diagram.getGroupPalette().should('not.exist'); + diagram.getNodes('diagram', 'Wifi').click(); + diagram.getGroupPalette().should('exist'); + diagram.getGroupPalette().findByTestId('Distribute elements horizontally').should('not.exist'); + diagram.getGroupPalette().findByTestId('Arrange in column').should('exist'); + }); + }); + +}); diff --git a/integration-tests/cypress/workbench/Diagram.ts b/integration-tests/cypress/workbench/Diagram.ts index e57833371c..47426555cb 100644 --- a/integration-tests/cypress/workbench/Diagram.ts +++ b/integration-tests/cypress/workbench/Diagram.ts @@ -35,6 +35,9 @@ export class Diagram { return cy.getByTestId('Palette'); } + public getGroupPalette(): Cypress.Chainable> { + return cy.getByTestId('GroupPalette'); + } public getDiagramScale(diagramLabel: string): Cypress.Chainable { return this.getDiagram(diagramLabel) .find('.react-flow__viewport') diff --git a/packages/diagrams/frontend/sirius-components-diagrams/src/icons/AlignHorizontalCenterIcon.tsx b/packages/diagrams/frontend/sirius-components-diagrams/src/icons/AlignHorizontalCenterIcon.tsx new file mode 100644 index 0000000000..a918e8a7e8 --- /dev/null +++ b/packages/diagrams/frontend/sirius-components-diagrams/src/icons/AlignHorizontalCenterIcon.tsx @@ -0,0 +1,28 @@ +/******************************************************************************* + * Copyright (c) 2024 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ + +import SvgIcon, { SvgIconProps } from '@material-ui/core/SvgIcon'; + +export const AlignHorizontalCenterIcon = (props: SvgIconProps) => { + return ( + + + + ); +}; diff --git a/packages/diagrams/frontend/sirius-components-diagrams/src/icons/AlignHorizontalLeftIcon.tsx b/packages/diagrams/frontend/sirius-components-diagrams/src/icons/AlignHorizontalLeftIcon.tsx new file mode 100644 index 0000000000..acba6a3df4 --- /dev/null +++ b/packages/diagrams/frontend/sirius-components-diagrams/src/icons/AlignHorizontalLeftIcon.tsx @@ -0,0 +1,28 @@ +/******************************************************************************* + * Copyright (c) 2024 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ + +import SvgIcon, { SvgIconProps } from '@material-ui/core/SvgIcon'; + +export const AlignHorizontalLeftIcon = (props: SvgIconProps) => { + return ( + + + + ); +}; diff --git a/packages/diagrams/frontend/sirius-components-diagrams/src/icons/AlignHorizontalRightIcon.tsx b/packages/diagrams/frontend/sirius-components-diagrams/src/icons/AlignHorizontalRightIcon.tsx new file mode 100644 index 0000000000..8f5bb3798d --- /dev/null +++ b/packages/diagrams/frontend/sirius-components-diagrams/src/icons/AlignHorizontalRightIcon.tsx @@ -0,0 +1,28 @@ +/******************************************************************************* + * Copyright (c) 2024 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ + +import SvgIcon, { SvgIconProps } from '@material-ui/core/SvgIcon'; + +export const AlignHorizontalRightIcon = (props: SvgIconProps) => { + return ( + + + + ); +}; diff --git a/packages/diagrams/frontend/sirius-components-diagrams/src/icons/AlignVerticalBottomIcon.tsx b/packages/diagrams/frontend/sirius-components-diagrams/src/icons/AlignVerticalBottomIcon.tsx new file mode 100644 index 0000000000..f30df7ef47 --- /dev/null +++ b/packages/diagrams/frontend/sirius-components-diagrams/src/icons/AlignVerticalBottomIcon.tsx @@ -0,0 +1,28 @@ +/******************************************************************************* + * Copyright (c) 2024 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ + +import SvgIcon, { SvgIconProps } from '@material-ui/core/SvgIcon'; + +export const AlignVerticalBottomIcon = (props: SvgIconProps) => { + return ( + + + + ); +}; diff --git a/packages/diagrams/frontend/sirius-components-diagrams/src/icons/AlignVerticalCenterIcon.tsx b/packages/diagrams/frontend/sirius-components-diagrams/src/icons/AlignVerticalCenterIcon.tsx new file mode 100644 index 0000000000..80233f27b1 --- /dev/null +++ b/packages/diagrams/frontend/sirius-components-diagrams/src/icons/AlignVerticalCenterIcon.tsx @@ -0,0 +1,28 @@ +/******************************************************************************* + * Copyright (c) 2024 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ + +import SvgIcon, { SvgIconProps } from '@material-ui/core/SvgIcon'; + +export const AlignVerticalCenterIcon = (props: SvgIconProps) => { + return ( + + + + ); +}; diff --git a/packages/diagrams/frontend/sirius-components-diagrams/src/icons/AlignVerticalTopIcon.tsx b/packages/diagrams/frontend/sirius-components-diagrams/src/icons/AlignVerticalTopIcon.tsx new file mode 100644 index 0000000000..84fd20490b --- /dev/null +++ b/packages/diagrams/frontend/sirius-components-diagrams/src/icons/AlignVerticalTopIcon.tsx @@ -0,0 +1,28 @@ +/******************************************************************************* + * Copyright (c) 2024 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ + +import SvgIcon, { SvgIconProps } from '@material-ui/core/SvgIcon'; + +export const AlignVerticalTopIcon = (props: SvgIconProps) => { + return ( + + + + ); +}; diff --git a/packages/diagrams/frontend/sirius-components-diagrams/src/icons/JustifyHorizontalIcon.tsx b/packages/diagrams/frontend/sirius-components-diagrams/src/icons/JustifyHorizontalIcon.tsx new file mode 100644 index 0000000000..c42aa1162b --- /dev/null +++ b/packages/diagrams/frontend/sirius-components-diagrams/src/icons/JustifyHorizontalIcon.tsx @@ -0,0 +1,30 @@ +/******************************************************************************* + * Copyright (c) 2024 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ + +import SvgIcon, { SvgIconProps } from '@material-ui/core/SvgIcon'; + +export const JustifyHorizontalIcon = (props: SvgIconProps) => { + return ( + + + + + ); +}; diff --git a/packages/diagrams/frontend/sirius-components-diagrams/src/icons/JustifyVerticalIcon.tsx b/packages/diagrams/frontend/sirius-components-diagrams/src/icons/JustifyVerticalIcon.tsx new file mode 100644 index 0000000000..1a6d87c144 --- /dev/null +++ b/packages/diagrams/frontend/sirius-components-diagrams/src/icons/JustifyVerticalIcon.tsx @@ -0,0 +1,29 @@ +/******************************************************************************* + * Copyright (c) 2024 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ + +import SvgIcon, { SvgIconProps } from '@material-ui/core/SvgIcon'; + +export const JustifyVerticalIcon = (props: SvgIconProps) => { + return ( + + + + + ); +}; diff --git a/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/DiagramRenderer.tsx b/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/DiagramRenderer.tsx index a1bba40da5..42bdd4f8e3 100644 --- a/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/DiagramRenderer.tsx +++ b/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/DiagramRenderer.tsx @@ -65,6 +65,7 @@ import { DiagramNodeType } from './node/NodeTypes.types'; import { useNodeType } from './node/useNodeType'; import { DiagramPalette } from './palette/DiagramPalette'; import { GroupPalette } from './palette/group-tool/GroupPalette'; +import { useGroupPalette } from './palette/group-tool/useGroupPalette'; import { useDiagramElementPalette } from './palette/useDiagramElementPalette'; import { useDiagramPalette } from './palette/useDiagramPalette'; import { DiagramPanel } from './panel/DiagramPanel'; @@ -74,7 +75,6 @@ import { useDiagramSelection } from './selection/useDiagramSelection'; import { useSnapToGrid } from './snap-to-grid/useSnapToGrid'; import 'reactflow/dist/style.css'; -import { useGroupPalette } from './palette/group-tool/useGroupPalette'; const GRID_STEP: number = 10; @@ -105,6 +105,7 @@ export const DiagramRenderer = ({ diagramRefreshedEventPayload }: DiagramRendere hideGroupPalette, position: groupPalettePosition, isOpened: isGroupPaletteOpened, + refElementId: groupPaletteRefElementId, } = useGroupPalette(); const { onConnect, onConnectStart, onConnectEnd } = useConnector(); @@ -263,14 +264,14 @@ export const DiagramRenderer = ({ diagramRefreshedEventPayload }: DiagramRendere const handleDiagramElementCLick = (event: React.MouseEvent, element: Node | Edge) => { diagramPaletteOnDiagramElementClick(); elementPaletteOnDiagramElementClick(event, element); - groupPaletteOnDiagramElementClick(event); + groupPaletteOnDiagramElementClick(event, element); }; const handleSelectionStart = () => { closeAllPalettes(); }; const handleSelectionEnd = (event: React.MouseEvent) => { - groupPaletteOnDiagramElementClick(event); + groupPaletteOnDiagramElementClick(event, null); }; const { onNodeMouseEnter, onNodeMouseLeave } = useNodeHover(); @@ -344,6 +345,8 @@ export const DiagramRenderer = ({ diagramRefreshedEventPayload }: DiagramRendere x={groupPalettePosition?.x} y={groupPalettePosition?.y} isOpened={isGroupPaletteOpened} + refElementId={groupPaletteRefElementId} + hidePalette={hideGroupPalette} /> {diagramDescription.debug ? : null} diff --git a/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/layout/useDistributeElements.ts b/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/layout/useDistributeElements.ts new file mode 100644 index 0000000000..ed4b12909b --- /dev/null +++ b/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/layout/useDistributeElements.ts @@ -0,0 +1,394 @@ +/******************************************************************************* + * Copyright (c) 2024 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ +import { useCallback } from 'react'; +import { Node, XYPosition, useReactFlow } from 'reactflow'; +import { UseDistributeElementsValue } from './useDistributeElements.types'; +import { NodeData, EdgeData } from '../DiagramRenderer.types'; +import { DiagramNodeType } from '../node/NodeTypes.types'; +import { useSynchronizeLayoutData } from './useSynchronizeLayoutData'; +import { RawDiagram } from './layout.types'; +import { useLayout } from './useLayout'; +import { useMultiToast } from '@eclipse-sirius/sirius-components-core'; + +function getComparePositionFn(direction: 'horizontal' | 'vertical') { + return (node1: Node, node2: Node) => { + const positionNode1: XYPosition = node1.position; + const positionNode2: XYPosition = node2.position; + if (positionNode1 && positionNode2) { + return direction === 'horizontal' ? positionNode1.x - positionNode2.x : positionNode1.y - positionNode2.y; + } + return 0; + }; +} + +const arrangeGapBetweenElements: number = 32; +export const useDistributeElements = (refreshEventPayloadId: string): UseDistributeElementsValue => { + const { getNodes, getEdges, setNodes } = useReactFlow(); + const { layout } = useLayout(); + const { synchronizeLayoutData } = useSynchronizeLayoutData(); + const { addMessages } = useMultiToast(); + + const processLayoutTool = ( + selectedNodeIds: string[], + layoutFn: (selectedNodes: Node[], refNode: Node) => Node[], + sortFn: ((node1: Node, node2: Node) => number) | null = null, + refElementId: string | null = null + ): void => { + const selectedNodes: Node[] = getNodes().filter((node) => selectedNodeIds.includes(node.id)); + const firstParent = selectedNodes[0]?.parentNode; + const sameParent: boolean = selectedNodes.reduce( + (isSameParent, node) => isSameParent && node.parentNode === firstParent, + true + ); + if (selectedNodes.length < 2) { + return; + } + if (!sameParent) { + addMessages([{ body: 'This tool can only be applied on elements on the same level', level: 'WARNING' }]); + return; + } + if (sortFn) { + selectedNodes.sort(sortFn); + } + + let refNode: Node | undefined = selectedNodes[0]; + if (refElementId) { + refNode = selectedNodes.find((node) => node.id === refElementId); + } + if (refNode) { + const updatedNodes: Node[] = layoutFn(selectedNodes, refNode); + const diagramToLayout: RawDiagram = { + nodes: [...updatedNodes] as Node[], + edges: getEdges(), + }; + layout(diagramToLayout, diagramToLayout, null, (laidOutDiagram) => { + setNodes(laidOutDiagram.nodes); + const finalDiagram: RawDiagram = { + nodes: laidOutDiagram.nodes, + edges: laidOutDiagram.edges, + }; + synchronizeLayoutData(refreshEventPayloadId, finalDiagram); + }); + } + }; + + const distributeNodesOnGap = (direction: 'horizontal' | 'vertical') => { + return useCallback((selectedNodeIds: string[]) => { + const selectedNodes: Node[] = getNodes().filter((node) => selectedNodeIds.includes(node.id)); + const firstParent = selectedNodes[0]?.parentNode; + const sameParent: boolean = selectedNodes.reduce( + (isSameParent, node) => isSameParent && node.parentNode === firstParent, + true + ); + if (selectedNodes.length < 3 || !sameParent) { + return; + } + + selectedNodes.sort(getComparePositionFn(direction)); + + const firstNode = selectedNodes[0]; + const lastNode = selectedNodes[selectedNodes.length - 1]; + + if (firstNode && lastNode) { + const totalSize: number = selectedNodes + .filter((node) => node.id !== firstNode.id && node.id !== lastNode.id) + .reduce((total, node) => total + (direction === 'horizontal' ? node.width ?? 0 : node.height ?? 0), 0); + const numberOfGap: number = selectedNodes.length - 1; + const gap: number = + ((direction === 'horizontal' + ? lastNode.position.x - firstNode.position.x - (firstNode.width ?? 0) + : lastNode.position.y - firstNode.position.y - (firstNode.height ?? 0)) - + totalSize) / + numberOfGap; + const updatedNodes = getNodes().map((node) => { + if (!selectedNodeIds.includes(node.id)) { + return node; + } + + const index: number = selectedNodes.findIndex((selectedNode) => selectedNode.id === node.id); + const currentSelectedNode = selectedNodes[index]; + const previousNode = selectedNodes[index - 1]; + + let newPosition: number = direction === 'horizontal' ? node.position.x : node.position.y; + + if (index > 0 && index < selectedNodes.length - 1 && previousNode && currentSelectedNode) { + newPosition = + direction === 'horizontal' + ? previousNode.position.x + (previousNode.width ?? 0) + gap + : previousNode.position.y + (previousNode.height ?? 0) + gap; + currentSelectedNode.position[direction === 'horizontal' ? 'x' : 'y'] = newPosition; + } + + return { + ...node, + position: { + ...node.position, + [direction === 'horizontal' ? 'x' : 'y']: newPosition, + }, + }; + }); + + setNodes(updatedNodes); + const finalDiagram: RawDiagram = { + nodes: [...updatedNodes] as Node[], + edges: getEdges(), + }; + synchronizeLayoutData(refreshEventPayloadId, finalDiagram); + } + }, []); + }; + + const distributeAlign = (orientation: 'left' | 'right' | 'top' | 'bottom' | 'center' | 'middle') => { + return useCallback((selectedNodeIds: string[], refElementId: string | null) => { + processLayoutTool( + selectedNodeIds, + (selectedNodes, refNode) => { + selectedNodes.sort(getComparePositionFn('horizontal')); + return getNodes().map((node) => { + if (!selectedNodeIds.includes(node.id)) { + return node; + } + const referencePositionValue: number = (() => { + switch (orientation) { + case 'left': + return refNode.position.x; + case 'right': + return refNode.position.x + (refNode.width ?? 0) - (node.width ?? 0); + case 'center': + return refNode.position.x + (refNode.width ?? 0) / 2 - (node.width ?? 0) / 2; + case 'top': + return refNode.position.y; + case 'bottom': + return refNode.position.y + (refNode.height ?? 0) - (node.height ?? 0); + case 'middle': + return refNode.position.y + (refNode.height ?? 0) / 2 - (node.height ?? 0) / 2; + } + })(); + + const referencePositionVariable: string = (() => { + switch (orientation) { + case 'left': + case 'right': + case 'center': + return 'x'; + case 'top': + case 'bottom': + case 'middle': + return 'y'; + } + })(); + + return { + ...node, + position: { + ...node.position, + [referencePositionVariable]: referencePositionValue, + }, + }; + }); + }, + null, + refElementId + ); + }, []); + }; + + const justifyElements = ( + justifyElementsFn: (selectedNodes: Node[], selectedNodeIds: string[], refNode: Node) => Node[] + ) => { + return useCallback((selectedNodeIds: string[], refElementId: string | null) => { + processLayoutTool( + selectedNodeIds, + (selectedNodes, refNode) => { + selectedNodes.sort(getComparePositionFn('horizontal')); + return justifyElementsFn(selectedNodes, selectedNodeIds, refNode); + }, + null, + refElementId + ); + }, []); + }; + + const justifyHorizontally = justifyElements( + (selectedNodes: Node[], selectedNodeIds: string[], refNode: Node): Node[] => { + const largestWidth: number = selectedNodes.reduce((width, node) => Math.max(width, node.width ?? 0), 0); + return getNodes().map((node) => { + if (!selectedNodeIds.includes(node.id)) { + return node; + } + return { + ...node, + width: largestWidth, + position: { + ...node.position, + x: refNode.position.x, + }, + data: { + ...node.data, + resizedByUser: true, + }, + }; + }); + } + ); + + const justifyVertically = justifyElements( + (selectedNodes: Node[], selectedNodeIds: string[], refNode: Node): Node[] => { + const largestHeight: number = selectedNodes.reduce((height, node) => Math.max(height, node.height ?? 0), 0); + return getNodes().map((node) => { + if (!selectedNodeIds.includes(node.id)) { + return node; + } + return { + ...node, + height: largestHeight, + position: { + ...node.position, + y: refNode.position.y, + }, + data: { + ...node.data, + resizedByUser: true, + }, + }; + }); + } + ); + + const arrangeInRow = (selectedNodeIds: string[]) => { + processLayoutTool( + selectedNodeIds, + (selectedNodes, refNode) => { + let nextXPosition: number = refNode.position.x; + const updatedSelectedNodes = selectedNodes.map((node) => { + const updatedNode = { + ...node, + position: { + ...node.position, + x: nextXPosition, + y: refNode.position.y, + }, + }; + nextXPosition = updatedNode.position.x + (updatedNode.width ?? 0) + arrangeGapBetweenElements; + return updatedNode; + }); + + return getNodes().map((node) => { + const replacedNode = updatedSelectedNodes.find((updatedSelectedNode) => updatedSelectedNode.id === node.id); + if (replacedNode) { + return replacedNode; + } + return node; + }); + }, + getComparePositionFn('horizontal') + ); + }; + + const arrangeInColumn = (selectedNodeIds: string[]) => { + processLayoutTool( + selectedNodeIds, + (selectedNodes, refNode) => { + let nextYPosition: number = refNode.position.y; + const updatedSelectedNodes = selectedNodes.map((node) => { + const updatedNode = { + ...node, + position: { + ...node.position, + x: refNode.position.x, + y: nextYPosition, + }, + }; + nextYPosition = updatedNode.position.y + (updatedNode.height ?? 0) + arrangeGapBetweenElements; + return updatedNode; + }); + + return getNodes().map((node) => { + const replacedNode = updatedSelectedNodes.find((updatedSelectedNode) => updatedSelectedNode.id === node.id); + if (replacedNode) { + return replacedNode; + } + return node; + }); + }, + getComparePositionFn('vertical') + ); + }; + + const arrangeInGrid = (selectedNodeIds: string[]) => { + processLayoutTool( + selectedNodeIds, + (selectedNodes, refNode) => { + const columnNumber: number = Math.round(Math.sqrt(selectedNodeIds.length)); + let nextXPosition: number = refNode.position.x; + let nextYPosition: number = refNode.position.y; + const updatedSelectedNodes = selectedNodes.map((node, index) => { + const columnIndex = index + 1; + const updatedNode = { + ...node, + position: { + ...node.position, + x: nextXPosition, + y: nextYPosition, + }, + }; + nextXPosition = updatedNode.position.x + (updatedNode.width ?? 0) + arrangeGapBetweenElements; + if (columnIndex % columnNumber === 0) { + nextXPosition = refNode.position.x; + nextYPosition = + updatedNode.position.y + + arrangeGapBetweenElements + + selectedNodes + .slice(columnIndex - columnNumber, columnIndex) + .reduce((maxHeight, rowNode) => Math.max(maxHeight, rowNode.height ?? 0), 0); + } + return updatedNode; + }); + + return getNodes().map((node) => { + const replacedNode = updatedSelectedNodes.find((updatedSelectedNode) => updatedSelectedNode.id === node.id); + if (replacedNode) { + return replacedNode; + } + return node; + }); + }, + getComparePositionFn('horizontal') + ); + }; + + const distributeGapHorizontally = distributeNodesOnGap('horizontal'); + const distributeGapVertically = distributeNodesOnGap('vertical'); + const distributeAlignLeft = distributeAlign('left'); + const distributeAlignRight = distributeAlign('right'); + const distributeAlignCenter = distributeAlign('center'); + const distributeAlignTop = distributeAlign('top'); + const distributeAlignBottom = distributeAlign('bottom'); + const distributeAlignMiddle = distributeAlign('middle'); + + return { + distributeGapVertically, + distributeGapHorizontally, + distributeAlignLeft, + distributeAlignRight, + distributeAlignCenter, + distributeAlignTop, + distributeAlignBottom, + distributeAlignMiddle, + justifyHorizontally, + justifyVertically, + arrangeInRow, + arrangeInColumn, + arrangeInGrid, + }; +}; diff --git a/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/layout/useDistributeElements.types.ts b/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/layout/useDistributeElements.types.ts new file mode 100644 index 0000000000..2d90b2ccba --- /dev/null +++ b/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/layout/useDistributeElements.types.ts @@ -0,0 +1,28 @@ +/******************************************************************************* + * Copyright (c) 2024 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ + +export interface UseDistributeElementsValue { + distributeGapVertically: (selectedNodeIds: string[]) => void; + distributeGapHorizontally: (selectedNodeIds: string[]) => void; + distributeAlignLeft: (selectedNodeIds: string[], refElementId: string | null) => void; + distributeAlignRight: (selectedNodeIds: string[], refElementId: string | null) => void; + distributeAlignCenter: (selectedNodeIds: string[], refElementId: string | null) => void; + distributeAlignTop: (selectedNodeIds: string[], refElementId: string | null) => void; + distributeAlignBottom: (selectedNodeIds: string[], refElementId: string | null) => void; + distributeAlignMiddle: (selectedNodeIds: string[], refElementId: string | null) => void; + justifyHorizontally: (selectedNodeIds: string[], refElementId: string | null) => void; + justifyVertically: (selectedNodeIds: string[], refElementId: string | null) => void; + arrangeInRow: (selectedNodeIds: string[]) => void; + arrangeInColumn: (selectedNodeIds: string[]) => void; + arrangeInGrid: (selectedNodeIds: string[]) => void; +} diff --git a/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/palette/PaletteTool.tsx b/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/palette/PaletteTool.tsx new file mode 100644 index 0000000000..7ba35b381e --- /dev/null +++ b/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/palette/PaletteTool.tsx @@ -0,0 +1,39 @@ +/******************************************************************************* + * Copyright (c) 2024 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ +import IconButton from '@material-ui/core/IconButton'; +import Tooltip from '@material-ui/core/Tooltip'; +import { makeStyles } from '@material-ui/core/styles'; +import { PaletteToolProps } from './PaletteTool.types'; + +const usePaletteToolStyle = makeStyles((theme) => ({ + toolIcon: { + color: theme.palette.text.primary, + }, +})); + +export const PaletteTool = ({ toolName, onClick, children }: PaletteToolProps) => { + const classes = usePaletteToolStyle(); + + return ( + + + {children} + + + ); +}; diff --git a/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/palette/PaletteTool.types.ts b/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/palette/PaletteTool.types.ts new file mode 100644 index 0000000000..0b60f333d1 --- /dev/null +++ b/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/palette/PaletteTool.types.ts @@ -0,0 +1,18 @@ +/******************************************************************************* + * Copyright (c) 2024 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ + +export interface PaletteToolProps { + toolName: string; + onClick: () => void; + children: React.ReactNode; +} diff --git a/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/palette/group-tool/GroupPalette.tsx b/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/palette/group-tool/GroupPalette.tsx index ed5c134672..b73e5d3451 100644 --- a/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/palette/group-tool/GroupPalette.tsx +++ b/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/palette/group-tool/GroupPalette.tsx @@ -11,21 +11,36 @@ * Obeo - initial API and implementation *******************************************************************************/ -import IconButton from '@material-ui/core/IconButton'; import Paper from '@material-ui/core/Paper'; -import Tooltip from '@material-ui/core/Tooltip'; import { makeStyles } from '@material-ui/core/styles'; +import ClickAwayListener from '@material-ui/core/ClickAwayListener'; +import Popper from '@material-ui/core/Popper'; +import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; import TonalityIcon from '@material-ui/icons/Tonality'; +import VerticalAlignCenterIcon from '@material-ui/icons/VerticalAlignCenter'; +import ViewColumnIcon from '@material-ui/icons/ViewColumn'; +import ViewModuleIcon from '@material-ui/icons/ViewModule'; +import ViewStreamIcon from '@material-ui/icons/ViewStream'; import VisibilityOffIcon from '@material-ui/icons/VisibilityOff'; -import { memo, useCallback, useState } from 'react'; +import { memo, useCallback, useRef, useState } from 'react'; import { useOnSelectionChange } from 'reactflow'; -import { PinIcon } from '../../../icons/PinIcon'; +import { GroupPaletteProps, GroupPaletteSectionTool, GroupPaletteState } from './GroupPalette.types'; +import { ContextualPaletteStyleProps } from '../Palette.types'; +import { PalettePortal } from '../PalettePortal'; +import { PaletteTool } from '../PaletteTool'; import { useFadeDiagramElements } from '../../fade/useFadeDiagramElements'; import { useHideDiagramElements } from '../../hide/useHideDiagramElements'; +import { useDistributeElements } from '../../layout/useDistributeElements'; import { usePinDiagramElements } from '../../pin/usePinDiagramElements'; -import { ContextualPaletteStyleProps } from '../Palette.types'; -import { PalettePortal } from '../PalettePortal'; -import { GroupPaletteProps } from './GroupPalette.types'; +import { AlignHorizontalLeftIcon } from '../../../icons/AlignHorizontalLeftIcon'; +import { AlignHorizontalRightIcon } from '../../../icons/AlignHorizontalRightIcon'; +import { AlignHorizontalCenterIcon } from '../../../icons/AlignHorizontalCenterIcon'; +import { AlignVerticalTopIcon } from '../../../icons/AlignVerticalTopIcon'; +import { AlignVerticalBottomIcon } from '../../../icons/AlignVerticalBottomIcon'; +import { AlignVerticalCenterIcon } from '../../../icons/AlignVerticalCenterIcon'; +import { PinIcon } from '../../../icons/PinIcon'; +import { JustifyHorizontalIcon } from '../../../icons/JustifyHorizontalIcon'; +import { JustifyVerticalIcon } from '../../../icons/JustifyVerticalIcon'; const usePaletteStyle = makeStyles((theme) => ({ palette: { @@ -39,77 +54,233 @@ const usePaletteStyle = makeStyles((theme) => ({ paletteContent: { display: 'grid', gridTemplateColumns: ({ toolCount }: ContextualPaletteStyleProps) => `repeat(${Math.min(toolCount, 10)}, 36px)`, - gridTemplateRows: '28px', - gridAutoRows: '28px', + gridTemplateRows: theme.spacing(3.5), + gridAutoRows: theme.spacing(3.5), placeItems: 'center', }, - toolIcon: { - color: theme.palette.text.primary, + toolSection: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + marginLeft: theme.spacing(1), + }, + toolList: { + padding: theme.spacing(0.5), + border: `1px solid ${theme.palette.divider}`, + borderRadius: '2px', + width: 'max-content', + }, + arrow: { + cursor: 'pointer', + height: '14px', + width: '14px', + marginLeft: -theme.spacing(0.5), + marginTop: theme.spacing(1.5), }, })); -export const GroupPalette = memo(({ x, y, isOpened }: GroupPaletteProps) => { - const { hideDiagramElements } = useHideDiagramElements(); - const { fadeDiagramElements } = useFadeDiagramElements(); - const { pinDiagramElements } = usePinDiagramElements(); - const [selectedElementIds, setSelectedElementIds] = useState([]); +export const GroupPalette = memo( + ({ refreshEventPayloadId, x, y, isOpened, refElementId, hidePalette }: GroupPaletteProps) => { + const { hideDiagramElements } = useHideDiagramElements(); + const { fadeDiagramElements } = useFadeDiagramElements(); + const { pinDiagramElements } = usePinDiagramElements(); + const { + distributeGapVertically, + distributeGapHorizontally, + distributeAlignLeft, + distributeAlignRight, + distributeAlignCenter, + distributeAlignTop, + distributeAlignBottom, + distributeAlignMiddle, + justifyHorizontally, + justifyVertically, + arrangeInRow, + arrangeInColumn, + arrangeInGrid, + } = useDistributeElements(refreshEventPayloadId); + const [selectedElementIds, setSelectedElementIds] = useState([]); + const [state, setState] = useState({ + isDistributeElementToolSectionExpand: false, + lastDistributeElementToolId: null, + }); - const onChange = useCallback(({ nodes, edges }) => { - const selectedElements = [...nodes, ...edges].filter((element) => element.selected); - if (selectedElements.length > 1) { - setSelectedElementIds(selectedElements.map((element) => element.id)); - } else { - setSelectedElementIds([]); + const onChange = useCallback(({ nodes, edges }) => { + const selectedElements = [...nodes, ...edges].filter((element) => element.selected); + if (selectedElements.length > 1) { + setSelectedElementIds(selectedElements.map((element) => element.id)); + } else { + setSelectedElementIds([]); + } + }, []); + useOnSelectionChange({ + onChange, + }); + + const toolCount = 4; + const classes = usePaletteStyle({ toolCount }); + const anchorRef = useRef(null); + + const distributeElementTools: GroupPaletteSectionTool[] = [ + { + id: 'distribute-element-horizontally', + title: 'Distribute elements horizontally', + action: () => distributeGapHorizontally(selectedElementIds), + icon: , + }, + { + id: 'distribute-element-vertically', + title: 'Distribute elements vertically', + action: () => distributeGapVertically(selectedElementIds), + icon: , + }, + { + id: 'align-left', + title: 'Align left', + action: () => distributeAlignLeft(selectedElementIds, refElementId), + icon: , + }, + { + id: 'align-right', + title: 'Align right', + action: () => distributeAlignRight(selectedElementIds, refElementId), + icon: , + }, + { + id: 'align-center', + title: 'Align center', + action: () => distributeAlignCenter(selectedElementIds, refElementId), + icon: , + }, + { + id: 'align-top', + title: 'Align top', + action: () => distributeAlignTop(selectedElementIds, refElementId), + icon: , + }, + { + id: 'align-bottom', + title: 'Align bottom', + action: () => distributeAlignBottom(selectedElementIds, refElementId), + icon: , + }, + { + id: 'align-middle', + title: 'Align middle', + action: () => distributeAlignMiddle(selectedElementIds, refElementId), + icon: , + }, + { + id: 'justify-horizontally', + title: 'Justify horizontally', + action: () => justifyHorizontally(selectedElementIds, refElementId), + icon: , + }, + { + id: 'justify-vertically', + title: 'Justify vertically', + action: () => justifyVertically(selectedElementIds, refElementId), + icon: , + }, + { + id: 'arrange-in-row', + title: 'Arrange in row', + action: () => arrangeInRow(selectedElementIds), + icon: , + }, + { + id: 'arrange-in-column', + title: 'Arrange in column', + action: () => arrangeInColumn(selectedElementIds), + icon: , + }, + { + id: 'arrange-in-grid', + title: 'Arrange in grid', + action: () => arrangeInGrid(selectedElementIds), + icon: , + }, + ]; + const shouldRender = selectedElementIds.length > 1 && isOpened && x && y; + if (!shouldRender) { + return null; } - }, []); - useOnSelectionChange({ - onChange, - }); - const toolCount = 3; - const classes = usePaletteStyle({ toolCount }); + let caretContent: JSX.Element | undefined; + caretContent = ( + { + event.stopPropagation(); + setState((prevState) => ({ ...prevState, isDistributeElementToolSectionExpand: true })); + }} + data-testid="expand" + ref={anchorRef} + /> + ); + const handleDistributeElementToolClick = (tool: GroupPaletteSectionTool) => { + tool.action(); + hidePalette(); + setState({ lastDistributeElementToolId: tool.id, isDistributeElementToolSectionExpand: false }); + }; - const shouldRender = selectedElementIds.length > 1 && isOpened && x && y; - if (!shouldRender) { - return null; - } + const defaultDistributeTool: GroupPaletteSectionTool | undefined = + distributeElementTools.find((tool) => tool.id === state.lastDistributeElementToolId) ?? distributeElementTools[0]; - return ( - - -
- - hideDiagramElements(selectedElementIds, true)} - data-testid="Hide-elements"> + return ( + + +
+ {defaultDistributeTool && ( +
+ handleDistributeElementToolClick(defaultDistributeTool)} + key={defaultDistributeTool.id}> + {defaultDistributeTool.icon} + + {caretContent} +
+ )} + + + + setState((prevState) => ({ ...prevState, isDistributeElementToolSectionExpand: false })) + }> +
+ {distributeElementTools.map((tool) => ( + handleDistributeElementToolClick(tool)} + key={tool.id}> + {tool.icon} + + ))} +
+
+
+
+ hideDiagramElements(selectedElementIds, true)}> - - - - fadeDiagramElements(selectedElementIds, true)} - data-testid="Fade-elements"> + + fadeDiagramElements(selectedElementIds, true)}> - - - - pinDiagramElements(selectedElementIds, true)} - data-testid="Pin-elements"> + + pinDiagramElements(selectedElementIds, true)}> - - -
-
-
- ); -}); + +
+
+
+ ); + } +); diff --git a/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/palette/group-tool/GroupPalette.types.ts b/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/palette/group-tool/GroupPalette.types.ts index 0250bf36a2..a2eb2555c6 100644 --- a/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/palette/group-tool/GroupPalette.types.ts +++ b/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/palette/group-tool/GroupPalette.types.ts @@ -15,5 +15,19 @@ export interface GroupPaletteProps { x?: number; y?: number; isOpened: boolean; + refElementId: string | null; refreshEventPayloadId: string; + hidePalette: () => void; +} + +export interface GroupPaletteState { + isDistributeElementToolSectionExpand: boolean; + lastDistributeElementToolId: string | null; +} + +export interface GroupPaletteSectionTool { + id: string; + title: string; + action: () => void; + icon: JSX.Element; } diff --git a/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/palette/group-tool/useGroupPalette.tsx b/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/palette/group-tool/useGroupPalette.tsx index 2a67bd3810..35586ba66e 100644 --- a/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/palette/group-tool/useGroupPalette.tsx +++ b/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/palette/group-tool/useGroupPalette.tsx @@ -11,7 +11,7 @@ * Obeo - initial API and implementation *******************************************************************************/ import { useCallback, useEffect, useState } from 'react'; -import { XYPosition, useKeyPress, useStoreApi } from 'reactflow'; +import { Edge, Node, XYPosition, useKeyPress, useStoreApi } from 'reactflow'; import { UseGroupPaletteValue, UseGroupPaletteState } from './useGroupPalette.types'; const computePalettePosition = (event: MouseEvent | React.MouseEvent, bounds?: DOMRect): XYPosition => { @@ -22,7 +22,7 @@ const computePalettePosition = (event: MouseEvent | React.MouseEvent, bounds?: D }; export const useGroupPalette = (): UseGroupPaletteValue => { - const [state, setState] = useState({ position: null, isOpened: false }); + const [state, setState] = useState({ position: null, isOpened: false, refElementId: null }); const store = useStoreApi(); @@ -33,12 +33,16 @@ export const useGroupPalette = (): UseGroupPaletteValue => { } }, [escapePressed]); - const onDiagramElementClick = useCallback((event: React.MouseEvent) => { - const { domNode } = store.getState(); - const element = domNode?.getBoundingClientRect(); - const palettePosition = computePalettePosition(event, element); - setState((prevState) => ({ ...prevState, position: palettePosition, isOpened: true })); - }, []); + const onDiagramElementClick = useCallback( + (event: React.MouseEvent, refElement: Node | Edge | null) => { + const { domNode } = store.getState(); + const element = domNode?.getBoundingClientRect(); + const palettePosition = computePalettePosition(event, element); + const refElementId: string | null = refElement?.id ?? null; + setState((prevState) => ({ ...prevState, position: palettePosition, isOpened: true, refElementId })); + }, + [] + ); const hideGroupPalette = () => { setState((prevState) => ({ ...prevState, position: null, isOpened: false })); @@ -46,6 +50,7 @@ export const useGroupPalette = (): UseGroupPaletteValue => { return { position: state.position, isOpened: state.isOpened, + refElementId: state.refElementId, hideGroupPalette, onDiagramElementClick, }; diff --git a/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/palette/group-tool/useGroupPalette.types.ts b/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/palette/group-tool/useGroupPalette.types.ts index bdd63a00cd..e511f46777 100644 --- a/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/palette/group-tool/useGroupPalette.types.ts +++ b/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/palette/group-tool/useGroupPalette.types.ts @@ -11,16 +11,18 @@ * Obeo - initial API and implementation *******************************************************************************/ -import { XYPosition } from 'reactflow'; +import { Edge, Node, XYPosition } from 'reactflow'; export interface UseGroupPaletteValue { position: XYPosition | null; isOpened: boolean; + refElementId: string | null; hideGroupPalette: () => void; - onDiagramElementClick: (event: React.MouseEvent) => void; + onDiagramElementClick: (event: React.MouseEvent, element: Node | Edge | null) => void; } export interface UseGroupPaletteState { position: XYPosition | null; isOpened: boolean; + refElementId: string | null; }