diff --git a/src/Blazor.Diagrams.Core/Behaviors/DragMovablesBehavior.cs b/src/Blazor.Diagrams.Core/Behaviors/DragMovablesBehavior.cs index 3a01eed7..aa2fb87c 100644 --- a/src/Blazor.Diagrams.Core/Behaviors/DragMovablesBehavior.cs +++ b/src/Blazor.Diagrams.Core/Behaviors/DragMovablesBehavior.cs @@ -3,23 +3,30 @@ using Blazor.Diagrams.Core.Geometry; using Blazor.Diagrams.Core.Models; using Blazor.Diagrams.Core.Models.Base; -using System; -using System.Collections.Generic; +using DiagramPoint = Blazor.Diagrams.Core.Geometry.Point; namespace Blazor.Diagrams.Core.Behaviors; public class DragMovablesBehavior : Behavior { - private readonly Dictionary _initialPositions; + record NodeMoveablePositions(Point position) + { + public Dictionary ChildPositions { get; } = new(); + } + + private readonly Dictionary _initialPositions; private double? _lastClientX; private double? _lastClientY; private bool _moved; - private double _totalMovedX = 0; - private double _totalMovedY = 0; + double _totalMovedX = 0; + double _totalMovedY = 0; + + public const double CHILD_NODE_MIN_OFFSET_TOP = 40; + public const double CHILD_NODE_MIN_OFFSET_BOTTOM = 5; public DragMovablesBehavior(Diagram diagram) : base(diagram) { - _initialPositions = new Dictionary(); + _initialPositions = new Dictionary(); Diagram.PointerDown += OnPointerDown; Diagram.PointerMove += OnPointerMove; Diagram.PointerUp += OnPointerUp; @@ -28,27 +35,31 @@ public DragMovablesBehavior(Diagram diagram) : base(diagram) private void OnPointerDown(Model? model, PointerEventArgs e) { - if (model is not MovableModel) + if (model is not NodeModel) return; _initialPositions.Clear(); foreach (var sm in Diagram.GetSelectedModels()) { - if (sm is not MovableModel movable || movable.Locked) + if (sm is not NodeModel node || node.Locked) + { continue; + } // Special case: groups without auto size on - if (sm is NodeModel node && node.Group != null && !node.Group.AutoSize) + if (node.Group != null && !node.Group.AutoSize) + { continue; + } + + var position = node.Position; - var position = movable.Position; - if (Diagram.Options.GridSnapToCenter && movable is NodeModel n) + if (Diagram.Options.GridSnapToCenter && node is NodeModel nodeModel) { - position = new Point(movable.Position.X + (n.Size?.Width ?? 0) / 2, - movable.Position.Y + (n.Size?.Height ?? 0) / 2); + position = new Point(node.Position.X + (nodeModel?.Size?.Width ?? 0) / 2, + node.Position.Y + (nodeModel?.Size?.Height ?? 0) / 2); } - - _initialPositions.Add(movable, position); + _initialPositions.Add(node, new NodeMoveablePositions(position)); } _lastClientX = e.ClientX; @@ -58,10 +69,49 @@ private void OnPointerDown(Model? model, PointerEventArgs e) private void OnPointerMove(Model? model, PointerEventArgs e) { + if (!_moved) + { + foreach (var node in _initialPositions.Keys.ToArray()) + { + if (node is NodeModel nodeModel) + { + var parent = nodeModel.GetParentNode(); + while (parent != null) + { + if (_initialPositions.ContainsKey(parent)) + { + _initialPositions.Remove(nodeModel); + break; + } + + parent = parent.GetParentNode(); + } + } + } + + foreach (var (node, positions) in _initialPositions) + { + if (node is NodeModel nodeModel) + { + foreach (var child in nodeModel.GetAllChildNodes()) + { + if (!child.Selected) + { + Diagram.SelectModel(child, false); + } + + positions.ChildPositions[child] = child.Position; + } + } + } + } if (_initialPositions.Count == 0 || _lastClientX == null || _lastClientY == null) + { return; + } _moved = true; + var deltaX = (e.ClientX - _lastClientX.Value) / Diagram.Zoom; var deltaY = (e.ClientY - _lastClientY.Value) / Diagram.Zoom; @@ -72,8 +122,8 @@ private void OnPointerMove(Model? model, PointerEventArgs e) _lastClientX = e.ClientX; _lastClientY = e.ClientY; - } + public void OnPanChanged(double deltaX, double deltaY) { if (_initialPositions.Count == 0 || _lastClientX == null || _lastClientY == null) @@ -89,21 +139,67 @@ public void OnPanChanged(double deltaX, double deltaY) private void MoveNodes(Model? model, double deltaX, double deltaY) { - foreach (var (movable, initialPosition) in _initialPositions) + foreach (var (node, positions) in _initialPositions) { - var ndx = ApplyGridSize(deltaX + initialPosition.X); - var ndy = ApplyGridSize(deltaY + initialPosition.Y); - if (Diagram.Options.GridSnapToCenter && movable is NodeModel node) + if (Diagram.Options.GridSnapToCenter && node is NodeModel nodeModel) { + var ndx = ApplyGridSize(deltaX + positions.position.X); + var ndy = ApplyGridSize(deltaY - positions.position.Y); + node.SetPosition(ndx - (node.Size?.Width ?? 0) / 2, ndy - (node.Size?.Height ?? 0) / 2); } else { - movable.SetPosition(ndx, ndy); + SetNodePosition(node, positions.position.X + deltaX, positions.position.Y + deltaY); + deltaX = node.Position.X - positions.position.X; + deltaY = node.Position.Y - positions.position.Y; + + foreach (var (childNode, childPosition) in positions.ChildPositions) + { + SetNodePosition(childNode, childPosition.X + deltaX, childPosition.Y + deltaY); + } + + if (node is NodeModel node1Model) + { + node1Model.TriggerMoving(); + } } } } + void SetNodePosition(NodeModel node, double x, double y) + { + if (node is NodeModel nodeModel && nodeModel.GetParentNode() != null) + { + x = Clamp(x, nodeModel.Size?.Width, nodeModel.GetParentNode().Position.X, nodeModel.GetParentNode().Size?.Width); + var parentY = nodeModel.GetParentNode().Position.Y + CHILD_NODE_MIN_OFFSET_TOP; + var parentH = nodeModel.GetParentNode().Size?.Height - CHILD_NODE_MIN_OFFSET_TOP - CHILD_NODE_MIN_OFFSET_BOTTOM; + y = Clamp(y, nodeModel.Size?.Height, parentY, parentH); + + } + + node.SetPosition(x, y); + } + + double Clamp(double position, double? size, double? parentPosition, double? parentSize) + { + var clamped = position; + + if (size != null && parentPosition != null && parentSize != null) + { + if (position < parentPosition) + { + clamped = (double)parentPosition; + } + else if (position + size > parentPosition + parentSize) + { + clamped = (double)(parentPosition + parentSize - size); + } + } + + return clamped; + } + private void OnPointerUp(Model? model, PointerEventArgs e) { if (_initialPositions.Count == 0) @@ -111,11 +207,22 @@ private void OnPointerUp(Model? model, PointerEventArgs e) if (_moved) { - foreach (var (movable, _) in _initialPositions) + foreach (var (node, positions) in _initialPositions) { - movable.TriggerMoved(); + node.TriggerMoved(); + if (node is NodeModel nodeModel) + { + foreach (var child in nodeModel.GetAllChildNodes()) + { + if (positions.ChildPositions.ContainsKey(child)) + { + child.TriggerMoved(); + } + } + } } } + _initialPositions.Clear(); _totalMovedX = 0; _totalMovedY = 0; @@ -135,9 +242,10 @@ private double ApplyGridSize(double n) public override void Dispose() { _initialPositions.Clear(); + Diagram.PointerDown -= OnPointerDown; Diagram.PointerMove -= OnPointerMove; Diagram.PointerUp -= OnPointerUp; Diagram.PanChanged -= OnPanChanged; } -} +} \ No newline at end of file diff --git a/src/Blazor.Diagrams.Core/Models/Base/IHasChild.cs b/src/Blazor.Diagrams.Core/Models/Base/IHasChild.cs new file mode 100644 index 00000000..6bb86167 --- /dev/null +++ b/src/Blazor.Diagrams.Core/Models/Base/IHasChild.cs @@ -0,0 +1,12 @@ +namespace Blazor.Diagrams.Core.Models.Base; + +public interface IHasChild +{ + public List GetAllChildNodes(); + + internal void AddChildNode(NodeModel child); + + internal void RemoveChildNode(NodeModel child); + + internal void ClearChildNodes(); +} \ No newline at end of file diff --git a/src/Blazor.Diagrams.Core/Models/Base/IHasParent.cs b/src/Blazor.Diagrams.Core/Models/Base/IHasParent.cs new file mode 100644 index 00000000..abe48204 --- /dev/null +++ b/src/Blazor.Diagrams.Core/Models/Base/IHasParent.cs @@ -0,0 +1,6 @@ +namespace Blazor.Diagrams.Core.Models.Base; + +public interface IHasParent +{ + public NodeModel GetParentNode(); +} \ No newline at end of file diff --git a/src/Blazor.Diagrams.Core/Models/NodeModel.cs b/src/Blazor.Diagrams.Core/Models/NodeModel.cs index 102a9fed..fc0b3dec 100644 --- a/src/Blazor.Diagrams.Core/Models/NodeModel.cs +++ b/src/Blazor.Diagrams.Core/Models/NodeModel.cs @@ -1,18 +1,19 @@ using Blazor.Diagrams.Core.Geometry; using Blazor.Diagrams.Core.Models.Base; -using System; -using System.Collections.Generic; -using System.Linq; namespace Blazor.Diagrams.Core.Models; -public class NodeModel : MovableModel, IHasBounds, IHasShape, ILinkable +public class NodeModel : MovableModel, IHasBounds, IHasShape, ILinkable, IHasChild, IHasParent { private readonly List _ports = new(); private readonly List _links = new(); + private readonly List _childNodes = new(); + private Size? _size; public Size MinimumDimensions { get; set; } = new Size(0, 0); + private NodeModel? _parent; + public event Action? SizeChanging; public event Action? SizeChanged; public event Action? Moving; @@ -190,7 +191,7 @@ private void UpdatePortPositions(Size oldSize, Size newSize) } } - protected void TriggerMoving() + public void TriggerMoving() { Moving?.Invoke(this); } @@ -198,4 +199,40 @@ protected void TriggerMoving() void ILinkable.AddLink(BaseLinkModel link) => _links.Add(link); void ILinkable.RemoveLink(BaseLinkModel link) => _links.Remove(link); + + public List GetAllChildNodes() + { + var allChildren = new List(); + foreach (var child in _childNodes) + { + allChildren.Add(child); + if (child is NodeModel childNodeModel) + { + allChildren.AddRange(childNodeModel.GetAllChildNodes()); + } + } + return allChildren; + } + + public void AddChildNode(NodeModel child) + { + child._parent = this; + _childNodes.Add(child); + } + + public void RemoveChildNode(NodeModel child) + { + child._parent = null; + _childNodes.Remove(child); + } + + public void ClearChildNodes() + { + _childNodes.Clear(); + } + + public NodeModel GetParentNode() + { + return _parent!; + } } \ No newline at end of file diff --git a/tests/Blazor.Diagrams.Core.Tests/Behaviors/DragMovablesBehaviorTests.cs b/tests/Blazor.Diagrams.Core.Tests/Behaviors/DragMovablesBehaviorTests.cs index 813322dc..dcd0f72f 100644 --- a/tests/Blazor.Diagrams.Core.Tests/Behaviors/DragMovablesBehaviorTests.cs +++ b/tests/Blazor.Diagrams.Core.Tests/Behaviors/DragMovablesBehaviorTests.cs @@ -31,10 +31,10 @@ public void Behavior_ShouldCallSetPosition() } [Theory] - [InlineData(false, 0, 0, 45, 45)] - [InlineData(true, 0, 0, 35, 35)] - [InlineData(false, 3, 3, 45, 45)] - [InlineData(true, 3, 3, 50, 50)] + [InlineData(false, 0, 0, 40, 40)] + [InlineData(true, 0, 0, 35, 20)] + [InlineData(false, 3, 3, 43, 43)] + [InlineData(true, 3, 3, 50, 20)] public void Behavior_SnapToGrid_ShouldCallSetPosition(bool gridSnapToCenter, double initialX, double initialY, double deltaX, double deltaY) { // Arrange @@ -161,4 +161,31 @@ public void Behavior_ShouldCallSetPosition_WhenPanChanges() // Assert nodeMock.Verify(n => n.SetPosition(100, 100), Times.Once); } + + [Fact] + public void Behavior_ShouldCallSetPosition_WhenNodeWithChildDragged() + { + // Arrange + var diagram = new TestDiagram(); + var parentNodeMock = new Mock(Point.Zero); + var childNodeMock = new Mock(new Point(50, 50)); + var parentNode = diagram.Nodes.Add(parentNodeMock.Object); + parentNode.Size = new Size(300, 300); + var childNode = diagram.Nodes.Add(childNodeMock.Object); + childNode.Size = new Size(150, 150); + + parentNode.AddChildNode(childNode); + + diagram.SelectModel(parentNode, false); + + // Act + diagram.TriggerPointerDown(parentNode, + new PointerEventArgs(100, 100, 0, 0, false, false, false, 0, 0, 0, 0, 0, 0, string.Empty, true)); + diagram.TriggerPointerMove(null, + new PointerEventArgs(150, 150, 0, 0, false, false, false, 0, 0, 0, 0, 0, 0, string.Empty, true)); + + // Assert + parentNodeMock.Verify(n => n.SetPosition(50, 50), Times.Once); + childNodeMock.Verify(n => n.SetPosition(50, 50), Times.Once); + } } \ No newline at end of file diff --git a/tests/Blazor.Diagrams.Core.Tests/Models/NodeModelTest.cs b/tests/Blazor.Diagrams.Core.Tests/Models/NodeModelTest.cs index b27edc99..467ba32a 100644 --- a/tests/Blazor.Diagrams.Core.Tests/Models/NodeModelTest.cs +++ b/tests/Blazor.Diagrams.Core.Tests/Models/NodeModelTest.cs @@ -1,5 +1,6 @@ using Blazor.Diagrams.Core.Geometry; using Blazor.Diagrams.Core.Models; +using FluentAssertions; using Moq; using Xunit; @@ -49,5 +50,36 @@ public void SetPortPositionOnNodeSizeChangedIsCalledOnSetSize() // Assert portMock.Verify(m => m.SetPortPositionOnNodeSizeChanged(deltaX, deltaY), Times.Once); } + + [Fact] + public void TestNodesChildFunctionalities() + { + var parentNodeModel = new NodeModel(new Point(100, 100)) { Size = new Size(600, 700) }; + var childNodeModel1 = new NodeModel(new Point(150, 150)) { Size = new Size(150, 150) }; + var childNodeModel2 = new NodeModel(new Point(350, 150)) { Size = new Size(150, 150) }; + + parentNodeModel.AddChildNode(childNodeModel1); + parentNodeModel.AddChildNode(childNodeModel2); + + + var childNodes = parentNodeModel.GetAllChildNodes(); + var parentNode = childNodeModel1.GetParentNode(); + + parentNode.Should().Be(parentNodeModel); + + Assert.Equal(2, childNodes.Count); + childNodes[0].Should().Be(childNodeModel1); + childNodes[1].Should().Be(childNodeModel2); + + parentNodeModel.RemoveChildNode(childNodeModel1); + + childNodes = parentNodeModel.GetAllChildNodes(); + Assert.Single(childNodes); + childNodes[0].Should().Be(childNodeModel2); + + parentNodeModel.ClearChildNodes(); + childNodes = parentNodeModel.GetAllChildNodes(); + Assert.Empty(childNodes); + } } -} +} \ No newline at end of file