From 1cd0971b4dacdc2e807996498eb0f33de90f1741 Mon Sep 17 00:00:00 2001 From: Misaki Date: Sat, 28 Mar 2026 14:34:35 +0900 Subject: [PATCH] feat(dock): implement tree mutation on drop and empty node cleanup --- .../Internal/Docking/DockGroupNode.cs | 25 +++- .../Ghost.Editor/View/Controls/DockLayout.cs | 112 ++++++++++++++++++ 2 files changed, 135 insertions(+), 2 deletions(-) diff --git a/src/Editor/Ghost.Editor.Core/Controls/Internal/Docking/DockGroupNode.cs b/src/Editor/Ghost.Editor.Core/Controls/Internal/Docking/DockGroupNode.cs index f916fee..f2d31f7 100644 --- a/src/Editor/Ghost.Editor.Core/Controls/Internal/Docking/DockGroupNode.cs +++ b/src/Editor/Ghost.Editor.Core/Controls/Internal/Docking/DockGroupNode.cs @@ -38,9 +38,27 @@ public partial class DockGroupNode : DockNode /// Thrown if node is null. /// Thrown if adding the node would create a cycle or if adding self. public void AddChild(DockNode node) + { + InsertChild(_children.Count, node); + } + + /// + /// Inserts a child node at the specified index, enforcing tree invariants. + /// + /// The zero-based index at which node should be inserted. + /// The node to insert. + /// Thrown if node is null. + /// Thrown if index is less than 0 or greater than Children.Count. + /// Thrown if adding the node would create a cycle or if adding self. + public void InsertChild(int index, DockNode node) { ArgumentNullException.ThrowIfNull(node); + if (index < 0 || index > _children.Count) + { + throw new ArgumentOutOfRangeException(nameof(index)); + } + if (node == this) { throw new InvalidOperationException("Cannot add a node to itself."); @@ -48,7 +66,10 @@ public partial class DockGroupNode : DockNode if (_children.Contains(node)) { - return; + int oldIndex = _children.IndexOf(node); + if (oldIndex == index || oldIndex == index - 1) return; + _children.RemoveAt(oldIndex); + if (index > oldIndex) index--; } // Check for cycles @@ -68,7 +89,7 @@ public partial class DockGroupNode : DockNode } node.Parent = this; - _children.Add(node); + _children.Insert(index, node); } /// diff --git a/src/Editor/Ghost.Editor/View/Controls/DockLayout.cs b/src/Editor/Ghost.Editor/View/Controls/DockLayout.cs index 99934da..81326ad 100644 --- a/src/Editor/Ghost.Editor/View/Controls/DockLayout.cs +++ b/src/Editor/Ghost.Editor/View/Controls/DockLayout.cs @@ -381,9 +381,121 @@ public sealed partial class DockLayout : Control private void TabView_Drop(object sender, DragEventArgs e) { + if (_dropTargetOverlay != null) _dropTargetOverlay.Visibility = Visibility.Collapsed; + + if (_draggedItem == null || _sourceNode == null || !(sender is FrameworkElement targetElement) || !(targetElement.Tag is DockPanelNode targetNode)) + { + ClearDragOperationState(); + return; + } + + if (_sourceNode == targetNode && _currentDropPosition == DockPosition.Center) + { + ClearDragOperationState(); + return; // Reordering within same tab is handled natively by TabView + } + + // 1. Remove from source + _sourceNode.Items.Remove(_draggedItem); + var sourceNodeCopy = _sourceNode; // Keep reference for cleanup + CleanupEmptyNodes(sourceNodeCopy); + + // 2. Add to target + if (_currentDropPosition == DockPosition.Center) + { + targetNode.Items.Add(_draggedItem); + } + else + { + // Split scenario + var parentGroup = targetNode.Parent; + if (parentGroup != null) + { + int targetIndex = parentGroup.Children.IndexOf(targetNode); + bool isHorizontalSplit = _currentDropPosition == DockPosition.Left || _currentDropPosition == DockPosition.Right; + bool isAfter = _currentDropPosition == DockPosition.Right || _currentDropPosition == DockPosition.Bottom; + + if ((isHorizontalSplit && parentGroup.Orientation == Orientation.Horizontal) || + (!isHorizontalSplit && parentGroup.Orientation == Orientation.Vertical)) + { + // Same orientation, just insert next to it + var newNode = new DockPanelNode(); + newNode.Items.Add(_draggedItem); + parentGroup.InsertChild(isAfter ? targetIndex + 1 : targetIndex, newNode); + } + else + { + // Different orientation, need to replace targetNode with a new group + parentGroup.RemoveChild(targetNode); + + var newGroup = new DockGroupNode + { + Orientation = isHorizontalSplit ? Orientation.Horizontal : Orientation.Vertical + }; + + var newNode = new DockPanelNode(); + newNode.Items.Add(_draggedItem); + + if (isAfter) + { + newGroup.AddChild(targetNode); + newGroup.AddChild(newNode); + } + else + { + newGroup.AddChild(newNode); + newGroup.AddChild(targetNode); + } + + parentGroup.InsertChild(targetIndex, newGroup); + } + } + } + ClearDragOperationState(); } + private void CleanupEmptyNodes(DockPanelNode panelNode) + { + if (panelNode.Items.Count > 0) return; + + var parentGroup = panelNode.Parent; + if (parentGroup != null) + { + parentGroup.RemoveChild(panelNode); + + // If group only has 1 child left, collapse it + if (parentGroup.Children.Count == 1) + { + var onlyChild = parentGroup.Children[0]; + var grandParent = parentGroup.Parent; + + if (grandParent != null) + { + int groupIndex = grandParent.Children.IndexOf(parentGroup); + grandParent.RemoveChild(parentGroup); + grandParent.InsertChild(groupIndex, onlyChild); + } + else if (parentGroup == Root) + { + // If root is collapsing, the only child becomes the new root + parentGroup.RemoveChild(onlyChild); + if (onlyChild is DockGroupNode newRootGroup) + { + Root = newRootGroup; + } + else + { + // Wrap panel in a new group to keep Root as a GroupNode + var wrapperGroup = new DockGroupNode(); + wrapperGroup.AddChild(onlyChild); + Root = wrapperGroup; + } + } + } + } + } + protected override void OnApplyTemplate() { base.OnApplyTemplate();