diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 0e9033290..8d29c4039 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -74,6 +74,7 @@ It leverages the selection dialog to either create an _occurrence timeslice/snap - https://github.com/eclipse-syson/syson/issues/2260[#2260] [diagrams] Add the _New Assume Constraint_ or _New Require Constraint_ edge tools to create _assume_ and _require_ graphical edges. - https://github.com/eclipse-syson/syson/issues/2113[#2113] [diagrams] Handle start/end/merge/decision... graphical nodes on Action Flow View diagram background - https://github.com/eclipse-syson/syson/issues/2303[#2303] [diagrams] Add support for list item inheritance in _states_ and _exhibit states_ compartments +- https://github.com/eclipse-syson/syson/issues/2239[#2239] [diagrams] Add the support for a frontend tool to rotate `ForkNode` and `JoinNode` graphical nodes == v2026.5.0 diff --git a/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/nodes/services/ForkJoinNodePaletteToolProvider.java b/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/nodes/services/ForkJoinNodePaletteToolProvider.java new file mode 100644 index 000000000..af485b6c4 --- /dev/null +++ b/backend/application/syson-application-configuration/src/main/java/org/eclipse/syson/application/nodes/services/ForkJoinNodePaletteToolProvider.java @@ -0,0 +1,97 @@ +/******************************************************************************* + * Copyright (c) 2026 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 + *******************************************************************************/ +package org.eclipse.syson.application.nodes.services; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import org.eclipse.sirius.components.collaborative.diagrams.DiagramContext; +import org.eclipse.sirius.components.collaborative.diagrams.dto.ITool; +import org.eclipse.sirius.components.collaborative.diagrams.dto.SingleClickOnDiagramElementTool; +import org.eclipse.sirius.components.collaborative.diagrams.dto.ToolSection; +import org.eclipse.sirius.components.core.api.IEditingContext; +import org.eclipse.sirius.components.core.api.IObjectSearchService; +import org.eclipse.sirius.components.diagrams.Node; +import org.eclipse.sirius.components.diagrams.description.IDiagramElementDescription; +import org.eclipse.sirius.components.view.emf.diagram.api.IPaletteToolsProvider; +import org.eclipse.syson.sysml.ForkNode; +import org.eclipse.syson.sysml.JoinNode; +import org.springframework.stereotype.Service; + +/** + * Used to contribute the Rotate tool for {@link ForkNode} and {@link JoinNode} graphical nodes. + *

+ * This {@link IPaletteToolsProvider} only declare the tool. + * The behavior is contributed in the frontend. + *

+ * + * @author gcoutable + */ +@Service +public class ForkJoinNodePaletteToolProvider implements IPaletteToolsProvider { + + private static final String FORK_JOIN_NODE_ROTATE_TOOL_ID = "fork_join_node_rotate_tool"; + + private static final String FORK_JOIN_NODE_ROTATE_TOOL_LABEL = "Rotate"; + + private static final String FORK_JOIN_NODE_ROTATE_TOOL_SECTION_ID = "edit-section"; + + private static final String FORK_JOIN_NODE_ROTATE_TOOL_SECTION_LABEL = "Edit"; + + private final IObjectSearchService objectSearchService; + + public ForkJoinNodePaletteToolProvider(IObjectSearchService objectSearchService) { + this.objectSearchService = Objects.requireNonNull(objectSearchService); + } + + @Override + public List createExtraToolSections(IEditingContext editingContext, DiagramContext diagramContext, Object diagramElementDescription, Object diagramElement) { + List extraTool = new ArrayList<>(); + + var optionalTargetObjectId = this.getTargetObjectId(diagramElement); + + if (optionalTargetObjectId.isPresent() && diagramElementDescription instanceof IDiagramElementDescription nodeDescription) { + var semanticObject = this.objectSearchService.getObject(editingContext, optionalTargetObjectId.get()); + if (semanticObject.isPresent() && (semanticObject.get() instanceof ForkNode || semanticObject.get() instanceof JoinNode)) { + ITool tool = SingleClickOnDiagramElementTool.newSingleClickOnDiagramElementTool(FORK_JOIN_NODE_ROTATE_TOOL_ID) + .label(FORK_JOIN_NODE_ROTATE_TOOL_LABEL) + .targetDescriptions(List.of(nodeDescription)) + .iconURL(List.of()) + .keyBindings(List.of()) + .build(); + extraTool.add(tool); + } + } + + if (!extraTool.isEmpty()) { + return List.of(new ToolSection(FORK_JOIN_NODE_ROTATE_TOOL_SECTION_ID, FORK_JOIN_NODE_ROTATE_TOOL_SECTION_LABEL, List.of(), extraTool)); + } else { + return List.of(); + } + } + + private Optional getTargetObjectId(Object diagramElement) { + Optional result = Optional.empty(); + if (diagramElement instanceof Node node) { + result = Optional.of(node.getTargetObjectId()); + } + return result; + } + + @Override + public List createQuickAccessTools(IEditingContext editingContext, DiagramContext diagramContext, Object diagramElementDescription, Object diagramElement) { + return List.of(); + } +} diff --git a/backend/views/syson-diagram-common-view/src/main/java/org/eclipse/syson/diagram/common/view/nodes/ForkActionNodeDescriptionProvider.java b/backend/views/syson-diagram-common-view/src/main/java/org/eclipse/syson/diagram/common/view/nodes/ForkActionNodeDescriptionProvider.java index 96c93efab..c6abd2cd7 100644 --- a/backend/views/syson-diagram-common-view/src/main/java/org/eclipse/syson/diagram/common/view/nodes/ForkActionNodeDescriptionProvider.java +++ b/backend/views/syson-diagram-common-view/src/main/java/org/eclipse/syson/diagram/common/view/nodes/ForkActionNodeDescriptionProvider.java @@ -49,7 +49,7 @@ protected String getRemoveToolLabel() { @Override protected UserResizableDirection isNodeResizable() { - return UserResizableDirection.HORIZONTAL; + return UserResizableDirection.BOTH; } @Override diff --git a/doc/content/modules/user-manual/assets/images/release-notes-rotate-fork-join-graphical-nodes.png b/doc/content/modules/user-manual/assets/images/release-notes-rotate-fork-join-graphical-nodes.png new file mode 100644 index 000000000..12bd15f47 Binary files /dev/null and b/doc/content/modules/user-manual/assets/images/release-notes-rotate-fork-join-graphical-nodes.png differ diff --git a/doc/content/modules/user-manual/pages/release-notes/2026.7.0.adoc b/doc/content/modules/user-manual/pages/release-notes/2026.7.0.adoc index 6f629058a..a9453ae74 100644 --- a/doc/content/modules/user-manual/pages/release-notes/2026.7.0.adoc +++ b/doc/content/modules/user-manual/pages/release-notes/2026.7.0.adoc @@ -47,6 +47,9 @@ image::release-notes-assume-and-require-edge-tools.png[list available edge tools Users should use the dedicated tools to edit expressions instead, as they ensure only valid expressions (with all names resolving) are accepted. ** Add support for list item inheritance in _states_ and _exhibit states_ compartments. ** Add support for creating _Start_, _Done_, _Decision_, _Fork_, _Join_, and _Merge_ actions directly on the background of an `Action Flow View` diagram if its root element is an `ActionUsage` or `ActionDefinition`. +** Add the support for a tool to rotate `ForkNode` and `JoinNode` graphical nodes. ++ +image::release-notes-rotate-fork-join-graphical-nodes.png[For both fork nodes and two join nodes, display one horizontal and one vertical graphical node. Also display the palette with the rotate tool, width=60%,height=60%] * In the _Explorer_ view: diff --git a/frontend/syson-components/src/extensions/registry/SysONExtensionRegistry.tsx b/frontend/syson-components/src/extensions/registry/SysONExtensionRegistry.tsx index e4c4fd41d..c0cb7e4ff 100644 --- a/frontend/syson-components/src/extensions/registry/SysONExtensionRegistry.tsx +++ b/frontend/syson-components/src/extensions/registry/SysONExtensionRegistry.tsx @@ -65,6 +65,7 @@ import { NewSysMLExpressionMenuContribution } from '../expressions/NewSysMLExpre import { InsertTextualSysMLMenuContribution } from '../InsertTextualSysMLv2MenuContribution'; import { SysONNavigationBarMenuIcon } from '../navigationBarMenu/SysONNavigationBarMenuIcon'; import { PublishProjectSysMLContentsAsLibraryCommand } from '../omnibox/PublishProjectSysMLContentsAsLibraryCommand'; +import { RotateNodeToolOverriddenContribution } from '../rotateNodeTool/RotateNodeToolOverriddenContribution'; import { SysONDiagramPanelMenu } from '../SysONDiagramPanelMenu'; const sysONExtensionRegistry: ExtensionRegistry = new ExtensionRegistry(); @@ -272,6 +273,12 @@ const diagramPaletteToolOverriddenContributions: PaletteToolOverriddenContributi }, component: DeleteExpressionDiagramToolOverriddenContribution, }, + { + canHandle: (tool: GQLTool) => { + return tool.id === 'fork_join_node_rotate_tool'; + }, + component: RotateNodeToolOverriddenContribution, + }, ]; sysONExtensionRegistry.putData(paletteToolOverrideExtensionPoint, { diff --git a/frontend/syson-components/src/extensions/rotateNodeTool/RotateNodeToolOverriddenContribution.tsx b/frontend/syson-components/src/extensions/rotateNodeTool/RotateNodeToolOverriddenContribution.tsx new file mode 100644 index 000000000..7dead179d --- /dev/null +++ b/frontend/syson-components/src/extensions/rotateNodeTool/RotateNodeToolOverriddenContribution.tsx @@ -0,0 +1,46 @@ +/******************************************************************************* + * Copyright (c) 2026 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 { DiagramContext, DiagramContextValue } from '@eclipse-sirius/sirius-components-diagrams'; +import { PaletteToolContributionComponentProps } from '@eclipse-sirius/sirius-components-palette'; +import Rotate90DegreesCwIcon from '@mui/icons-material/Rotate90DegreesCw'; +import ListItemButton from '@mui/material/ListItemButton'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import ListItemText from '@mui/material/ListItemText'; +import { Fragment, useContext } from 'react'; +import { useRotateNode } from './useRotateNode'; + +export const RotateNodeToolOverriddenContribution = ({ + representationElementIds, +}: PaletteToolContributionComponentProps) => { + const { readOnly } = useContext(DiagramContext); + const { rotate } = useRotateNode(); + + return ( + + rotate(representationElementIds)} + data-testid="overridden_tool_node-rotate" + disabled={readOnly} + sx={{ paddingTop: 0, paddingBottom: 0 }}> + theme.spacing(2), color: (theme) => theme.palette.text.primary }}> + + + + + + ); +}; diff --git a/frontend/syson-components/src/extensions/rotateNodeTool/useRotateNode.ts b/frontend/syson-components/src/extensions/rotateNodeTool/useRotateNode.ts new file mode 100644 index 000000000..053165f99 --- /dev/null +++ b/frontend/syson-components/src/extensions/rotateNodeTool/useRotateNode.ts @@ -0,0 +1,93 @@ +/******************************************************************************* + * Copyright (c) 2026 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 { EdgeData, NodeData, useLayout, useSynchronizeLayoutData } from '@eclipse-sirius/sirius-components-diagrams'; +import { Edge, Node, useReactFlow } from '@xyflow/react'; +import { UseRotateNodeValue } from './useRotateNode.types'; + +export const useRotateNode = (): UseRotateNodeValue => { + const { getNodes, getEdges, setNodes, setEdges } = useReactFlow, Edge>(); + const { synchronizeLayoutData } = useSynchronizeLayoutData(); + const { layout } = useLayout(); + + const rotate = (representationElementIds: string[]) => { + const updatedNodes: Node[] = getNodes().map((node) => { + if (representationElementIds.length > 0 && node.id === representationElementIds[0]) { + const cx = node.position.x + (node.width ?? 0) / 2; + const cy = node.position.y + (node.height ?? 0) / 2; + + const newWidth = node.height ?? 0; + const newHeight = node.width ?? 0; + + const newX = cx - newWidth / 2; + const newY = cy - newHeight / 2; + return { + ...node, + position: { x: newX, y: newY }, + width: newWidth, + height: newHeight, + data: { ...node.data, resizedByUser: true, connectionHandles: [] }, + }; + } + return node; + }); + + const updatedEdges: Edge[] = getEdges().map((edge) => { + if ( + ((representationElementIds.length > 0 && edge.source === representationElementIds[0]) || + edge.target === representationElementIds[0]) && + edge.data + ) { + return { + ...edge, + data: { + ...edge.data, + bendingPoints: null, + }, + }; + } + return edge; + }); + + const diagramToLayout = { + nodes: updatedNodes, + edges: updatedEdges, + }; + + layout(diagramToLayout, diagramToLayout, null, 'UNDEFINED', (laidOutDiagram) => { + const updatedNodesAfterLayout = updatedNodes.map((node) => { + if (representationElementIds.find((nodeId) => nodeId === node.id)) { + return laidOutDiagram.nodes.find((laidOutNode) => laidOutNode.id === node.id) ?? node; + } + return node; + }); + + const updatedEdgesAfterLayout = updatedEdges.map((edge) => { + if (representationElementIds.find((nodeId) => nodeId === edge.source || nodeId === edge.target)) { + return laidOutDiagram.edges.find((laidOutEdge) => laidOutEdge.id === edge.id) ?? edge; + } + return edge; + }); + + setNodes(updatedNodesAfterLayout); + setEdges(updatedEdgesAfterLayout); + const finalDiagram = { + nodes: updatedNodesAfterLayout, + edges: updatedEdgesAfterLayout, + }; + synchronizeLayoutData(crypto.randomUUID(), 'layout', finalDiagram, 'UNCHANGED'); + }); + }; + + return { rotate }; +}; diff --git a/frontend/syson-components/src/extensions/rotateNodeTool/useRotateNode.types.ts b/frontend/syson-components/src/extensions/rotateNodeTool/useRotateNode.types.ts new file mode 100644 index 000000000..e3e9cd519 --- /dev/null +++ b/frontend/syson-components/src/extensions/rotateNodeTool/useRotateNode.types.ts @@ -0,0 +1,16 @@ +/******************************************************************************* + * Copyright (c) 2026 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 UseRotateNodeValue { + rotate: (representationElementIds: string[]) => void; +} diff --git a/integration-tests-playwright/playwright/e2e/rotate-node.spec.ts b/integration-tests-playwright/playwright/e2e/rotate-node.spec.ts new file mode 100644 index 000000000..295914ba5 --- /dev/null +++ b/integration-tests-playwright/playwright/e2e/rotate-node.spec.ts @@ -0,0 +1,82 @@ +/******************************************************************************* + * Copyright (c) 2026 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 { expect, test } from '@playwright/test'; +import { PlaywrightExplorer } from '../helpers/PlaywrightExplorer'; +import { PlaywrightNode } from '../helpers/PlaywrightNode'; +import { PlaywrightProject } from '../helpers/PlaywrightProject'; + +test.describe('diagram - general view', () => { + let projectId; + test.beforeEach(async ({ page, request }) => { + await page.addInitScript(() => { + // @ts-expect-error: we use a variable in the DOM to disable `fitView` functionality for Cypress tests. + window.document.DEACTIVATE_FIT_VIEW_FOR_CYPRESS_TESTS = true; + }); + const project = await new PlaywrightProject(request).createSysMLV2Project('general'); + projectId = project.projectId; + + await page.goto(`/projects/${projectId}/edit`); + const playwrightExplorer = new PlaywrightExplorer(page); + await playwrightExplorer.uploadDocument('SysMLv2WithGeneralView.sysml'); + await playwrightExplorer.expand('SysMLv2WithGeneralView.sysml'); + await playwrightExplorer.expand('Package1'); + await playwrightExplorer.createRepresentation('view1 [GeneralView]', 'General View', 'view1'); + + await page.getByTestId('arrange-all-main-button').click(); + + const partNode = new PlaywrightNode(page, 'part1', 'List'); + await expect(partNode.nodeLocator).toBeAttached(); + + // Keep the diagram content visible without depending on Sirius Web's arrange-all toolbar controls. + await page.getByTestId('zoom-out').click(); + }); + + test.afterEach(async ({ request }) => { + await new PlaywrightProject(request).deleteProject(projectId); + }); + + test('WHEN applying the rotate tool on a fork node, THEN the node is rotated', async ({ page, browserName }) => { + if (browserName === 'firefox') { + test.skip(); //The test fails inexplicably in Firefox. + } + + // Create a new Action node + await page.getByTestId('rf__wrapper').click({ button: 'right', position: { x: 250, y: 250 } }); + await expect(page.getByTestId('Palette')).toBeAttached(); + await page.getByTestId('toolSection-Behavior').click(); + await page.getByTestId('tool-New Action').click(); + const playwrightActionNode = new PlaywrightNode(page, 'action1', 'List'); + await expect(playwrightActionNode.nodeLocator).toBeAttached(); + + // Create a new Fork node in the Action node + await playwrightActionNode.openPalette(); + await page.getByTestId('toolSection-Behavior').click(); + await page.getByTestId('tool-New Fork').click(); + const playwrightForkNode = new PlaywrightNode(page, 'forkNode1'); + await expect(playwrightForkNode.nodeLocator).toBeAttached(); + const {width, height} = await playwrightForkNode.getReactFlowSize(); + const expectedRotatedWidth = height; + const expectedRotatedHeight = width; + + // Apply the rotate tool on the Fork node + await playwrightForkNode.openPalette(); + await page.getByTestId('toolSection-Edit').click(); + await page.getByTestId('overridden_tool_node-rotate').click(); + await playwrightForkNode.closePalette(); + const rotatedSize = await playwrightForkNode.getReactFlowSize(); + + expect(rotatedSize.width).toBeCloseTo(expectedRotatedWidth, 0); + expect(rotatedSize.height).toBeCloseTo(expectedRotatedHeight, 0); + }); + +}); \ No newline at end of file