Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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 <em>Rotate</em> tool for {@link ForkNode} and {@link JoinNode} graphical nodes.
* <p>
* This {@link IPaletteToolsProvider} only declare the tool.
* The behavior is contributed in the frontend.
* </p>
*
* @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<ToolSection> createExtraToolSections(IEditingContext editingContext, DiagramContext diagramContext, Object diagramElementDescription, Object diagramElement) {
List<ITool> 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<String> getTargetObjectId(Object diagramElement) {
Optional<String> result = Optional.empty();
if (diagramElement instanceof Node node) {
result = Optional.of(node.getTargetObjectId());
}
return result;
}

@Override
public List<ITool> createQuickAccessTools(IEditingContext editingContext, DiagramContext diagramContext, Object diagramElementDescription, Object diagramElement) {
return List.of();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ protected String getRemoveToolLabel() {

@Override
protected UserResizableDirection isNodeResizable() {
return UserResizableDirection.HORIZONTAL;
return UserResizableDirection.BOTH;
}

@Override
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -272,6 +273,12 @@ const diagramPaletteToolOverriddenContributions: PaletteToolOverriddenContributi
},
component: DeleteExpressionDiagramToolOverriddenContribution,
},
{
canHandle: (tool: GQLTool) => {
return tool.id === 'fork_join_node_rotate_tool';
},
component: RotateNodeToolOverriddenContribution,
},
];

sysONExtensionRegistry.putData<PaletteToolOverriddenContributionProps[]>(paletteToolOverrideExtensionPoint, {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<DiagramContextValue>(DiagramContext);
const { rotate } = useRotateNode();

return (
<Fragment key="overridden_tool_node-rotate-contribution">
<ListItemButton
onClick={() => rotate(representationElementIds)}
data-testid="overridden_tool_node-rotate"
disabled={readOnly}
sx={{ paddingTop: 0, paddingBottom: 0 }}>
<ListItemIcon
sx={{ minWidth: 0, marginRight: (theme) => theme.spacing(2), color: (theme) => theme.palette.text.primary }}>
<Rotate90DegreesCwIcon fontSize="small" />
</ListItemIcon>
<ListItemText
primary={'Rotate'}
sx={{ '& .MuiListItemText-primary': { whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' } }}
/>
</ListItemButton>
</Fragment>
);
};
Original file line number Diff line number Diff line change
@@ -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<Node<NodeData>, Edge<EdgeData>>();
const { synchronizeLayoutData } = useSynchronizeLayoutData();
const { layout } = useLayout();

const rotate = (representationElementIds: string[]) => {
const updatedNodes: Node<NodeData>[] = 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<EdgeData>[] = 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 };
};
Original file line number Diff line number Diff line change
@@ -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;
}
82 changes: 82 additions & 0 deletions integration-tests-playwright/playwright/e2e/rotate-node.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});

});
Loading