fix(dock): migrate primary editor to DockLayout, add persistent sizing, and refactor DockLayout

This commit is contained in:
2026-03-28 18:11:35 +09:00
parent 9a1b8dcab0
commit 4713bfe7da
5 changed files with 461 additions and 410 deletions

View File

@@ -0,0 +1,186 @@
using Ghost.Core;
using Ghost.Editor.Core.Controls.Internal.Docking;
using Ghost.Editor.View.Windows;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
namespace Ghost.Editor.View.Controls;
public sealed partial class DockLayout
{
private DockPosition _currentDropPosition = DockPosition.None;
private FrameworkElement? _lastTargetElement;
private record DockDragPayload(object Item, DockPanelNode SourceNode);
public event EventHandler<TabTornOffEventArgs>? TabTornOff;
private void TabView_TabDragStarting(TabView sender, TabViewTabDragStartingEventArgs args)
{
if (args.Item != null && sender.Tag is DockPanelNode sourceNode)
{
var payload = new DockDragPayload(args.Item, sourceNode);
args.Data.Properties.Add(DRAG_PROPERTY_DOCK_TAB, payload); // Identify our drag
}
}
private void TabView_DragOver(object sender, DragEventArgs e)
{
if (e.DataView.Properties.TryGetValue(DRAG_PROPERTY_DOCK_TAB, out var payloadObj) &&
payloadObj is DockDragPayload &&
sender is FrameworkElement targetElement)
{
e.AcceptedOperation = global::Windows.ApplicationModel.DataTransfer.DataPackageOperation.Move;
var position = e.GetPosition(targetElement);
var newPosition = DockMath.CalculateDockPosition(targetElement.ActualWidth, targetElement.ActualHeight, position.X, position.Y, DROP_EDGE_THRESHOLD);
if (newPosition != _currentDropPosition || targetElement != _lastTargetElement)
{
_currentDropPosition = newPosition;
_lastTargetElement = targetElement;
UpdateDropOverlay(targetElement, _currentDropPosition);
}
}
}
private void TabView_DragLeave(object sender, DragEventArgs e)
{
_lastTargetElement = null;
ClearOverlayState();
}
private void ClearOverlayState()
{
if (_dropTargetOverlay != null)
{
_dropTargetOverlay.Visibility = Visibility.Collapsed;
}
_currentDropPosition = DockPosition.None;
}
private void ClearDragOperationState()
{
ClearOverlayState();
}
private void UpdateDropOverlay(FrameworkElement targetElement, DockPosition position)
{
if (_dropTargetOverlay == null) return;
if (position == DockPosition.None)
{
_dropTargetOverlay.Visibility = Visibility.Collapsed;
return;
}
var transform = targetElement.TransformToVisual(this);
var bounds = transform.TransformBounds(new global::Windows.Foundation.Rect(0, 0, targetElement.ActualWidth, targetElement.ActualHeight));
_dropTargetOverlay.Visibility = Visibility.Visible;
_dropTargetOverlay.Width = double.NaN;
_dropTargetOverlay.Height = double.NaN;
switch (position)
{
case DockPosition.Center:
_dropTargetOverlay.Margin = new Thickness(bounds.Left, bounds.Top, ActualWidth - bounds.Right, ActualHeight - bounds.Bottom);
break;
case DockPosition.Left:
_dropTargetOverlay.Margin = new Thickness(bounds.Left, bounds.Top, ActualWidth - (bounds.Left + bounds.Width / 2), ActualHeight - bounds.Bottom);
break;
case DockPosition.Right:
_dropTargetOverlay.Margin = new Thickness(bounds.Left + bounds.Width / 2, bounds.Top, ActualWidth - bounds.Right, ActualHeight - bounds.Bottom);
break;
case DockPosition.Top:
_dropTargetOverlay.Margin = new Thickness(bounds.Left, bounds.Top, ActualWidth - bounds.Right, ActualHeight - (bounds.Top + bounds.Height / 2));
break;
case DockPosition.Bottom:
_dropTargetOverlay.Margin = new Thickness(bounds.Left, bounds.Top + bounds.Height / 2, ActualWidth - bounds.Right, ActualHeight - bounds.Bottom);
break;
}
}
private void TabView_Drop(object sender, DragEventArgs e)
{
if (_dropTargetOverlay != null) _dropTargetOverlay.Visibility = Visibility.Collapsed;
if (!e.DataView.Properties.TryGetValue(DRAG_PROPERTY_DOCK_TAB, out var payloadObj) ||
payloadObj is not DockDragPayload payload ||
!(sender is FrameworkElement targetElement) ||
!(targetElement.Tag is DockPanelNode targetNode))
{
ClearDragOperationState();
return;
}
if (_currentDropPosition == DockPosition.None)
{
ClearDragOperationState();
return;
}
if (payload.SourceNode == targetNode && _currentDropPosition == DockPosition.Center)
{
ClearDragOperationState();
return; // Reordering within same tab is handled natively by TabView
}
if (Root == null)
{
ClearDragOperationState();
return;
}
// 1. Execute mutation
if (DockMutationEngine.TryApplyDropMutation(Root, targetNode, payload.SourceNode, payload.Item, _currentDropPosition))
{
DockMutationEngine.CleanupEmptyNodes(payload.SourceNode);
}
ClearDragOperationState();
}
private void TabView_TabDroppedOutside(TabView sender, TabViewTabDroppedOutsideEventArgs args)
{
try
{
if (sender.Tag is DockPanelNode sourceNode && args.Item != null)
{
// Validate that the item actually belongs to this source node before attempting tear-off
if (sourceNode.Items.Contains(args.Item))
{
var handler = TabTornOff;
if (handler == null)
{
Logger.LogWarning("Tab dropped outside but no TabTornOff subscribers found.");
return;
}
var result = TabTearOffService.TryTearOffTab(sourceNode.Items, args.Item, (tab) =>
{
// Raise event to let the host handle window creation
handler.Invoke(this, new TabTornOffEventArgs(tab, sourceNode));
}, sourceNode);
if (result.IsSuccess)
{
DockMutationEngine.CleanupEmptyNodes(sourceNode);
}
else
{
Logger.LogWarning($"Tab tear-off failed: {result.Message}");
}
}
else
{
string itemInfo = args.Item is FrameworkElement fe ? fe.GetType().Name : args.Item.ToString() ?? "unknown";
Logger.LogWarning($"TabDroppedOutside: Item '{itemInfo}' not found in source node (Items count: {sourceNode.Items.Count}).");
}
}
}
finally
{
ClearDragOperationState();
}
}
}

View File

@@ -0,0 +1,170 @@
using Ghost.Editor.Core.Controls.Internal.Docking;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Data;
using System.Diagnostics;
namespace Ghost.Editor.View.Controls;
public sealed partial class DockLayout
{
private void RenderTree()
{
if (GetTemplateChild(PART_ROOT_GRID) is Grid rootGrid)
{
rootGrid.Children.Clear();
if (Root != null)
{
var ui = CreateUIForNode(Root);
rootGrid.Children.Add(ui);
}
}
}
private UIElement CreateUIForNode(DockNode node)
{
if (node is DockGroupNode groupNode)
{
return CreateGroupUI(groupNode);
}
else if (node is DockPanelNode panelNode)
{
return CreatePanelUI(panelNode);
}
throw new InvalidOperationException($"Unsupported node type: {node.GetType().Name}");
}
private UIElement CreateGroupUI(DockGroupNode groupNode)
{
var grid = new Grid();
bool isHorizontal = groupNode.Orientation == Orientation.Horizontal;
int childCount = groupNode.Children.Count;
for (int i = 0; i < childCount; i++)
{
var childNode = groupNode.Children[i];
var childUI = CreateUIForNode(childNode);
if (isHorizontal)
{
var width = (i < groupNode.Sizes.Count) ? groupNode.Sizes[i] : new GridLength(1, GridUnitType.Star);
var colDef = new ColumnDefinition
{
Width = width,
MinWidth = MIN_PANE_SIZE
};
grid.ColumnDefinitions.Add(colDef);
Grid.SetColumn((FrameworkElement)childUI, i * 2);
}
else
{
var height = (i < groupNode.Sizes.Count) ? groupNode.Sizes[i] : new GridLength(1, GridUnitType.Star);
var rowDef = new RowDefinition
{
Height = height,
MinHeight = MIN_PANE_SIZE
};
grid.RowDefinitions.Add(rowDef);
Grid.SetRow((FrameworkElement)childUI, i * 2);
}
grid.Children.Add(childUI);
// Add GridSplitter between children
if (i < childCount - 1)
{
if (isHorizontal)
{
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
var splitter = new CommunityToolkit.WinUI.Controls.GridSplitter
{
Width = SPLITTER_THICKNESS,
HorizontalAlignment = HorizontalAlignment.Center,
ResizeDirection = CommunityToolkit.WinUI.Controls.GridSplitter.GridResizeDirection.Columns
};
Grid.SetColumn(splitter, (i * 2) + 1);
grid.Children.Add(splitter);
}
else
{
grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
var splitter = new CommunityToolkit.WinUI.Controls.GridSplitter
{
Height = SPLITTER_THICKNESS,
VerticalAlignment = VerticalAlignment.Center,
ResizeDirection = CommunityToolkit.WinUI.Controls.GridSplitter.GridResizeDirection.Rows
};
Grid.SetRow(splitter, (i * 2) + 1);
grid.Children.Add(splitter);
}
}
}
// Capture size changes to persist back to model
grid.SizeChanged += (s, e) => SyncSizesToModel(groupNode, grid);
return grid;
}
private void SyncSizesToModel(DockGroupNode groupNode, Grid grid)
{
bool isHorizontal = groupNode.Orientation == Orientation.Horizontal;
if (isHorizontal)
{
for (int i = 0; i < groupNode.Children.Count; i++)
{
if (i < groupNode.Sizes.Count && i * 2 < grid.ColumnDefinitions.Count)
{
groupNode.Sizes[i] = grid.ColumnDefinitions[i * 2].Width;
}
}
}
else
{
for (int i = 0; i < groupNode.Children.Count; i++)
{
if (i < groupNode.Sizes.Count && i * 2 < grid.RowDefinitions.Count)
{
groupNode.Sizes[i] = grid.RowDefinitions[i * 2].Height;
}
}
}
}
private UIElement CreatePanelUI(DockPanelNode panelNode)
{
var tabView = new Ghost.Editor.Controls.NavigationTabView
{
TabItemsSource = panelNode.Items,
HorizontalAlignment = HorizontalAlignment.Stretch,
VerticalAlignment = VerticalAlignment.Stretch,
CanDragTabs = true,
AllowDrop = true,
Tag = panelNode // Store reference to data node
};
// Bind selection state using TabView DPs
tabView.SetBinding(TabView.SelectedIndexProperty, new Binding
{
Source = panelNode,
Path = new PropertyPath(nameof(DockPanelNode.SelectedIndex)),
Mode = BindingMode.TwoWay
});
tabView.SetBinding(TabView.SelectedItemProperty, new Binding
{
Source = panelNode,
Path = new PropertyPath(nameof(DockPanelNode.SelectedItem)),
Mode = BindingMode.TwoWay
});
tabView.DragOver += TabView_DragOver;
tabView.DragLeave += TabView_DragLeave;
tabView.Drop += TabView_Drop;
tabView.TabDragStarting += TabView_TabDragStarting;
tabView.TabDroppedOutside += TabView_TabDroppedOutside;
return tabView;
}
}

View File

@@ -0,0 +1,105 @@
using System.Collections.Specialized;
using Ghost.Editor.Core.Controls.Internal.Docking;
namespace Ghost.Editor.View.Controls;
public sealed partial class DockLayout
{
private readonly HashSet<DockNode> _subscribedNodes = new();
private void SubscribeToNode(DockNode node)
{
if (!_subscribedNodes.Add(node))
{
return;
}
node.PropertyChanged += OnNodePropertyChanged;
if (node is DockGroupNode groupNode)
{
((INotifyCollectionChanged)groupNode.Children).CollectionChanged += OnChildrenCollectionChanged;
foreach (var child in groupNode.Children)
{
SubscribeToNode(child);
}
}
}
private void UnsubscribeFromNode(DockNode node)
{
if (!_subscribedNodes.Remove(node))
{
return;
}
node.PropertyChanged -= OnNodePropertyChanged;
if (node is DockGroupNode groupNode)
{
((INotifyCollectionChanged)groupNode.Children).CollectionChanged -= OnChildrenCollectionChanged;
foreach (var child in groupNode.Children)
{
UnsubscribeFromNode(child);
}
}
}
private void UnsubscribeFromAll()
{
// Copy to array to avoid modification during enumeration
var nodes = _subscribedNodes.ToArray();
foreach (var node in nodes)
{
node.PropertyChanged -= OnNodePropertyChanged;
if (node is DockGroupNode groupNode)
{
((INotifyCollectionChanged)groupNode.Children).CollectionChanged -= OnChildrenCollectionChanged;
}
}
_subscribedNodes.Clear();
}
private void OnNodePropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
// Filter to structural property names
if (e.PropertyName == nameof(DockGroupNode.Orientation))
{
RenderTree();
}
}
private void OnChildrenCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (e.Action == NotifyCollectionChangedAction.Reset)
{
// On Reset, we don't know what was removed.
// We must unsubscribe from everything and resubscribe from Root to avoid leaks.
UnsubscribeFromAll();
if (Root != null)
{
SubscribeToNode(Root);
}
}
else
{
if (e.OldItems != null)
{
foreach (DockNode oldNode in e.OldItems)
{
UnsubscribeFromNode(oldNode);
}
}
if (e.NewItems != null)
{
foreach (DockNode newNode in e.NewItems)
{
SubscribeToNode(newNode);
}
}
}
RenderTree();
}
}

View File

@@ -24,7 +24,6 @@ public sealed partial class DockLayout : Control
private const double DROP_EDGE_THRESHOLD = 0.25;
private FrameworkElement? _dropTargetOverlay;
private readonly HashSet<DockNode> _subscribedNodes = new();
public DockLayout()
{
@@ -71,409 +70,6 @@ public sealed partial class DockLayout : Control
}
}
private void SubscribeToNode(DockNode node)
{
if (!_subscribedNodes.Add(node))
{
return;
}
node.PropertyChanged += OnNodePropertyChanged;
if (node is DockGroupNode groupNode)
{
((INotifyCollectionChanged)groupNode.Children).CollectionChanged += OnChildrenCollectionChanged;
foreach (var child in groupNode.Children)
{
SubscribeToNode(child);
}
}
}
private void UnsubscribeFromNode(DockNode node)
{
if (!_subscribedNodes.Remove(node))
{
return;
}
node.PropertyChanged -= OnNodePropertyChanged;
if (node is DockGroupNode groupNode)
{
((INotifyCollectionChanged)groupNode.Children).CollectionChanged -= OnChildrenCollectionChanged;
foreach (var child in groupNode.Children)
{
UnsubscribeFromNode(child);
}
}
}
private void UnsubscribeFromAll()
{
// Copy to array to avoid modification during enumeration
var nodes = _subscribedNodes.ToArray();
foreach (var node in nodes)
{
node.PropertyChanged -= OnNodePropertyChanged;
if (node is DockGroupNode groupNode)
{
((INotifyCollectionChanged)groupNode.Children).CollectionChanged -= OnChildrenCollectionChanged;
}
}
_subscribedNodes.Clear();
}
private void OnNodePropertyChanged(object? sender, PropertyChangedEventArgs e)
{
// Filter to structural property names
if (e.PropertyName == nameof(DockGroupNode.Orientation))
{
RenderTree();
}
}
private void OnChildrenCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (e.Action == NotifyCollectionChangedAction.Reset)
{
// On Reset, we don't know what was removed.
// We must unsubscribe from everything and resubscribe from Root to avoid leaks.
UnsubscribeFromAll();
if (Root != null)
{
SubscribeToNode(Root);
}
}
else
{
if (e.OldItems != null)
{
foreach (DockNode oldNode in e.OldItems)
{
UnsubscribeFromNode(oldNode);
}
}
if (e.NewItems != null)
{
foreach (DockNode newNode in e.NewItems)
{
SubscribeToNode(newNode);
}
}
}
RenderTree();
}
private void RenderTree()
{
if (GetTemplateChild(PART_ROOT_GRID) is Grid rootGrid)
{
rootGrid.Children.Clear();
if (Root != null)
{
var ui = CreateUIForNode(Root);
rootGrid.Children.Add(ui);
}
}
}
private UIElement CreateUIForNode(DockNode node)
{
if (node is DockGroupNode groupNode)
{
return CreateGroupUI(groupNode);
}
else if (node is DockPanelNode panelNode)
{
return CreatePanelUI(panelNode);
}
Debug.Fail($"Unsupported node type: {node.GetType().Name}");
return new Grid(); // Fallback
}
private UIElement CreateGroupUI(DockGroupNode groupNode)
{
var grid = new Grid();
bool isHorizontal = groupNode.Orientation == Orientation.Horizontal;
int childCount = groupNode.Children.Count;
for (int i = 0; i < childCount; i++)
{
var childNode = groupNode.Children[i];
var childUI = CreateUIForNode(childNode);
if (isHorizontal)
{
var width = (i < groupNode.Sizes.Count) ? groupNode.Sizes[i] : new GridLength(1, GridUnitType.Star);
grid.ColumnDefinitions.Add(new ColumnDefinition
{
Width = width,
MinWidth = MIN_PANE_SIZE
});
Grid.SetColumn((FrameworkElement)childUI, i * 2);
}
else
{
var height = (i < groupNode.Sizes.Count) ? groupNode.Sizes[i] : new GridLength(1, GridUnitType.Star);
grid.RowDefinitions.Add(new RowDefinition
{
Height = height,
MinHeight = MIN_PANE_SIZE
});
Grid.SetRow((FrameworkElement)childUI, i * 2);
}
grid.Children.Add(childUI);
// Add GridSplitter between children
if (i < childCount - 1)
{
if (isHorizontal)
{
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
var splitter = new CommunityToolkit.WinUI.Controls.GridSplitter
{
Width = SPLITTER_THICKNESS,
HorizontalAlignment = HorizontalAlignment.Center,
ResizeDirection = CommunityToolkit.WinUI.Controls.GridSplitter.GridResizeDirection.Columns
};
Grid.SetColumn(splitter, (i * 2) + 1);
grid.Children.Add(splitter);
}
else
{
grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
var splitter = new CommunityToolkit.WinUI.Controls.GridSplitter
{
Height = SPLITTER_THICKNESS,
VerticalAlignment = VerticalAlignment.Center,
ResizeDirection = CommunityToolkit.WinUI.Controls.GridSplitter.GridResizeDirection.Rows
};
Grid.SetRow(splitter, (i * 2) + 1);
grid.Children.Add(splitter);
}
}
}
return grid;
}
private UIElement CreatePanelUI(DockPanelNode panelNode)
{
var tabView = new Ghost.Editor.Controls.NavigationTabView
{
TabItemsSource = panelNode.Items,
HorizontalAlignment = HorizontalAlignment.Stretch,
VerticalAlignment = VerticalAlignment.Stretch,
CanDragTabs = true,
AllowDrop = true,
Tag = panelNode // Store reference to data node
};
// Bind selection state using TabView DPs
tabView.SetBinding(TabView.SelectedIndexProperty, new Binding
{
Source = panelNode,
Path = new PropertyPath(nameof(DockPanelNode.SelectedIndex)),
Mode = BindingMode.TwoWay
});
tabView.SetBinding(TabView.SelectedItemProperty, new Binding
{
Source = panelNode,
Path = new PropertyPath(nameof(DockPanelNode.SelectedItem)),
Mode = BindingMode.TwoWay
});
tabView.DragOver += TabView_DragOver;
tabView.DragLeave += TabView_DragLeave;
tabView.Drop += TabView_Drop;
tabView.TabDragStarting += TabView_TabDragStarting;
tabView.TabDroppedOutside += TabView_TabDroppedOutside;
return tabView;
}
private DockPosition _currentDropPosition = DockPosition.None;
private FrameworkElement? _lastTargetElement;
private record DockDragPayload(object Item, DockPanelNode SourceNode);
public event EventHandler<TabTornOffEventArgs>? TabTornOff;
private void TabView_TabDragStarting(Microsoft.UI.Xaml.Controls.TabView sender, Microsoft.UI.Xaml.Controls.TabViewTabDragStartingEventArgs args)
{
if (args.Item != null && sender.Tag is DockPanelNode sourceNode)
{
var payload = new DockDragPayload(args.Item, sourceNode);
args.Data.Properties.Add(DRAG_PROPERTY_DOCK_TAB, payload); // Identify our drag
}
}
private void TabView_DragOver(object sender, DragEventArgs e)
{
if (e.DataView.Properties.TryGetValue(DRAG_PROPERTY_DOCK_TAB, out var payloadObj) &&
payloadObj is DockDragPayload &&
sender is FrameworkElement targetElement)
{
e.AcceptedOperation = global::Windows.ApplicationModel.DataTransfer.DataPackageOperation.Move;
var position = e.GetPosition(targetElement);
var newPosition = DockMath.CalculateDockPosition(targetElement.ActualWidth, targetElement.ActualHeight, position.X, position.Y, DROP_EDGE_THRESHOLD);
if (newPosition != _currentDropPosition || targetElement != _lastTargetElement)
{
_currentDropPosition = newPosition;
_lastTargetElement = targetElement;
UpdateDropOverlay(targetElement, _currentDropPosition);
}
}
}
private void TabView_DragLeave(object sender, DragEventArgs e)
{
_lastTargetElement = null;
ClearOverlayState();
}
private void ClearOverlayState()
{
if (_dropTargetOverlay != null)
{
_dropTargetOverlay.Visibility = Visibility.Collapsed;
}
_currentDropPosition = DockPosition.None;
}
private void ClearDragOperationState()
{
ClearOverlayState();
}
private void UpdateDropOverlay(FrameworkElement targetElement, DockPosition position)
{
if (_dropTargetOverlay == null) return;
if (position == DockPosition.None)
{
_dropTargetOverlay.Visibility = Visibility.Collapsed;
return;
}
var transform = targetElement.TransformToVisual(this);
var bounds = transform.TransformBounds(new global::Windows.Foundation.Rect(0, 0, targetElement.ActualWidth, targetElement.ActualHeight));
_dropTargetOverlay.Visibility = Visibility.Visible;
_dropTargetOverlay.Width = double.NaN;
_dropTargetOverlay.Height = double.NaN;
switch (position)
{
case DockPosition.Center:
_dropTargetOverlay.Margin = new Thickness(bounds.Left, bounds.Top, ActualWidth - bounds.Right, ActualHeight - bounds.Bottom);
break;
case DockPosition.Left:
_dropTargetOverlay.Margin = new Thickness(bounds.Left, bounds.Top, ActualWidth - (bounds.Left + bounds.Width / 2), ActualHeight - bounds.Bottom);
break;
case DockPosition.Right:
_dropTargetOverlay.Margin = new Thickness(bounds.Left + bounds.Width / 2, bounds.Top, ActualWidth - bounds.Right, ActualHeight - bounds.Bottom);
break;
case DockPosition.Top:
_dropTargetOverlay.Margin = new Thickness(bounds.Left, bounds.Top, ActualWidth - bounds.Right, ActualHeight - (bounds.Top + bounds.Height / 2));
break;
case DockPosition.Bottom:
_dropTargetOverlay.Margin = new Thickness(bounds.Left, bounds.Top + bounds.Height / 2, ActualWidth - bounds.Right, ActualHeight - bounds.Bottom);
break;
}
}
private void TabView_Drop(object sender, DragEventArgs e)
{
if (_dropTargetOverlay != null) _dropTargetOverlay.Visibility = Visibility.Collapsed;
if (!e.DataView.Properties.TryGetValue(DRAG_PROPERTY_DOCK_TAB, out var payloadObj) ||
payloadObj is not DockDragPayload payload ||
!(sender is FrameworkElement targetElement) ||
!(targetElement.Tag is DockPanelNode targetNode))
{
ClearDragOperationState();
return;
}
if (_currentDropPosition == DockPosition.None)
{
ClearDragOperationState();
return;
}
if (payload.SourceNode == targetNode && _currentDropPosition == DockPosition.Center)
{
ClearDragOperationState();
return; // Reordering within same tab is handled natively by TabView
}
if (Root == null)
{
ClearDragOperationState();
return;
}
// 1. Execute mutation
if (DockMutationEngine.TryApplyDropMutation(Root, targetNode, payload.SourceNode, payload.Item, _currentDropPosition))
{
DockMutationEngine.CleanupEmptyNodes(payload.SourceNode);
}
ClearDragOperationState();
}
private void TabView_TabDroppedOutside(Microsoft.UI.Xaml.Controls.TabView sender, Microsoft.UI.Xaml.Controls.TabViewTabDroppedOutsideEventArgs args)
{
try
{
if (sender.Tag is DockPanelNode sourceNode && args.Item != null)
{
// Validate that the item actually belongs to this source node before attempting tear-off
if (sourceNode.Items.Contains(args.Item))
{
var handler = TabTornOff;
if (handler == null)
{
Logger.LogWarning("Tab dropped outside but no TabTornOff subscribers found.");
return;
}
var result = TabTearOffService.TryTearOffTab(sourceNode.Items, args.Item, (tab) =>
{
// Raise event to let the host handle window creation
handler.Invoke(this, new TabTornOffEventArgs(tab, sourceNode));
}, sourceNode);
if (result.IsSuccess)
{
DockMutationEngine.CleanupEmptyNodes(sourceNode);
}
else
{
Logger.LogWarning($"Tab tear-off failed: {result.Message}");
}
}
else
{
string itemInfo = args.Item is FrameworkElement fe ? fe.GetType().Name : args.Item.ToString() ?? "unknown";
Logger.LogWarning($"TabDroppedOutside: Item '{itemInfo}' not found in source node (Items count: {sourceNode.Items.Count}).");
}
}
}
finally
{
ClearDragOperationState();
}
}
protected override void OnApplyTemplate()
{
base.OnApplyTemplate();

View File

@@ -69,12 +69,6 @@ internal sealed partial class EngineEditorWindow : WindowEx
PART_DockLayout.TabTornOff += (s, e) => App.CreateAndShowDockWindow(e.TabContent);
}
private void OnTabDroppedOutside(Microsoft.UI.Xaml.Controls.TabView sender, Microsoft.UI.Xaml.Controls.TabViewTabDroppedOutsideEventArgs args)
{
// This is now handled by DockLayout.TabTornOff for dynamic tabs.
// If we still have static tabs, we'd handle them here.
}
private void MainGrid_Loaded(object sender, Microsoft.UI.Xaml.RoutedEventArgs e)
{
PART_TitleBar.Title = EditorApplication.ProjectName;