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