From 8f051fd1d1adcca451e78864a67c27f253d0dfe5 Mon Sep 17 00:00:00 2001 From: ssdfasd <2156608475@qq.com> Date: Fri, 17 Apr 2026 22:16:34 +0800 Subject: [PATCH] new_editor: harden cross-window drag validation --- .../XCUI_NewEditor收口重构计划_2026-04-17.md | 2 + .../Platform/Win32/WindowManager/TabDrag.cpp | 252 +++++++++++++++++- 2 files changed, 251 insertions(+), 3 deletions(-) diff --git a/docs/plan/XCUI_NewEditor收口重构计划_2026-04-17.md b/docs/plan/XCUI_NewEditor收口重构计划_2026-04-17.md index 690f8a8f..1db5b393 100644 --- a/docs/plan/XCUI_NewEditor收口重构计划_2026-04-17.md +++ b/docs/plan/XCUI_NewEditor收口重构计划_2026-04-17.md @@ -53,6 +53,7 @@ - 已补对应窗口工作区单元测试 - 已补 detached single-panel transfer 的 source root / open-visible 约束,避免脏 source 请求绕过可见性校验 - 已补同窗口 `move/dock` 的 controller 级结果校验与回滚,避免 mutation 回归直接污染 window-set 状态 + - 已补 detached window 起始 global-tab-drag 的源请求校验,避免 stale `nodeId/panelId` 直接进入跨窗口拖拽状态机 9. integration 测试构建模板已继续收口: - 已把 `tests/UI/Editor/integration` 叶子目标收敛到 shared helper @@ -549,6 +550,7 @@ - 已完成 `window-workspace` 跨窗口 `panelId` 重复校验与单元测试 - 已完成 detached single-panel transfer 对 `sourceNodeId` 与 `open/visible` 的约束补齐,并补单元测试 - 已完成同窗口 `move/dock` 的结果校验与回滚防线补齐 +- 已完成 detached window 起始 global-tab-drag 的源请求校验 - 尚未完成 transfer 前后 session/state 完整性约束 - 尚未完成 cross-window drag/drop 状态机约束收口 diff --git a/new_editor/app/Platform/Win32/WindowManager/TabDrag.cpp b/new_editor/app/Platform/Win32/WindowManager/TabDrag.cpp index 013db2d3..48674778 100644 --- a/new_editor/app/Platform/Win32/WindowManager/TabDrag.cpp +++ b/new_editor/app/Platform/Win32/WindowManager/TabDrag.cpp @@ -1,10 +1,12 @@ #include "Platform/Win32/WindowManager/Internal.h" -#include "Platform/Win32/WindowManager/CrossWindowDropInternal.h" #include "State/EditorContext.h" #include "Platform/Win32/EditorWindow.h" +#include +#include #include +#include #include #include @@ -13,12 +15,19 @@ namespace XCEngine::UI::Editor::App::Internal { namespace { -using Win32::Internal::CrossWindowDockDropTarget; -using Win32::Internal::TryResolveCrossWindowDockDropTarget; +using ::XCEngine::UI::UIPoint; +using ::XCEngine::UI::UIRect; constexpr LONG kFallbackDragHotspotX = 40; constexpr LONG kFallbackDragHotspotY = 12; +struct CrossWindowDockDropTarget { + bool valid = false; + std::string nodeId = {}; + UIEditorWorkspaceDockPlacement placement = UIEditorWorkspaceDockPlacement::Center; + std::size_t insertionIndex = Widgets::UIEditorTabStripInvalidIndex; +}; + POINT BuildFallbackGlobalTabDragHotspot() { POINT hotspot = {}; hotspot.x = kFallbackDragHotspotX; @@ -35,6 +44,124 @@ float ResolveWindowDpiScale(HWND hwnd) { return dpi == 0u ? 1.0f : static_cast(dpi) / 96.0f; } +bool IsPointInsideRect(const UIRect& rect, const UIPoint& point) { + return point.x >= rect.x && + point.x <= rect.x + rect.width && + point.y >= rect.y && + point.y <= rect.y + rect.height; +} + +bool CanStartGlobalTabDragFromWindow( + const EditorWindow& sourceWindow, + std::string_view sourceNodeId, + std::string_view panelId) { + UIEditorWorkspaceModel workspace = + sourceWindow.GetWorkspaceController().GetWorkspace(); + UIEditorWorkspaceSession session = + sourceWindow.GetWorkspaceController().GetSession(); + UIEditorWorkspaceExtractedPanel extractedPanel = {}; + return TryExtractUIEditorWorkspaceVisiblePanel( + workspace, + session, + sourceNodeId, + panelId, + extractedPanel); +} + +std::size_t ResolveDropInsertionIndex( + const Widgets::UIEditorDockHostTabStackLayout& tabStack, + const UIPoint& point) { + if (!IsPointInsideRect(tabStack.tabStripLayout.headerRect, point)) { + return Widgets::UIEditorTabStripInvalidIndex; + } + + std::size_t insertionIndex = 0u; + for (const UIRect& rect : tabStack.tabStripLayout.tabHeaderRects) { + const float midpoint = rect.x + rect.width * 0.5f; + if (point.x > midpoint) { + ++insertionIndex; + } + } + return insertionIndex; +} + +UIEditorWorkspaceDockPlacement ResolveDropPlacement( + const Widgets::UIEditorDockHostTabStackLayout& tabStack, + const UIPoint& point) { + if (IsPointInsideRect(tabStack.tabStripLayout.headerRect, point)) { + return UIEditorWorkspaceDockPlacement::Center; + } + + const float leftDistance = point.x - tabStack.bounds.x; + const float rightDistance = tabStack.bounds.x + tabStack.bounds.width - point.x; + const float topDistance = point.y - tabStack.bounds.y; + const float bottomDistance = tabStack.bounds.y + tabStack.bounds.height - point.y; + const float minHorizontalThreshold = tabStack.bounds.width * 0.25f; + const float minVerticalThreshold = tabStack.bounds.height * 0.25f; + const float nearestEdge = (std::min)( + (std::min)(leftDistance, rightDistance), + (std::min)(topDistance, bottomDistance)); + + if (nearestEdge == leftDistance && leftDistance <= minHorizontalThreshold) { + return UIEditorWorkspaceDockPlacement::Left; + } + if (nearestEdge == rightDistance && rightDistance <= minHorizontalThreshold) { + return UIEditorWorkspaceDockPlacement::Right; + } + if (nearestEdge == topDistance && topDistance <= minVerticalThreshold) { + return UIEditorWorkspaceDockPlacement::Top; + } + if (nearestEdge == bottomDistance && bottomDistance <= minVerticalThreshold) { + return UIEditorWorkspaceDockPlacement::Bottom; + } + + return UIEditorWorkspaceDockPlacement::Center; +} + +bool TryResolveCrossWindowDockDropTarget( + const Widgets::UIEditorDockHostLayout& layout, + const UIPoint& point, + CrossWindowDockDropTarget& outTarget) { + outTarget = {}; + if (!IsPointInsideRect(layout.bounds, point)) { + return false; + } + + const Widgets::UIEditorDockHostHitTarget hitTarget = + Widgets::HitTestUIEditorDockHost(layout, point); + for (const Widgets::UIEditorDockHostTabStackLayout& tabStack : layout.tabStacks) { + if ((!hitTarget.nodeId.empty() && tabStack.nodeId != hitTarget.nodeId) || + !IsPointInsideRect(tabStack.bounds, point)) { + continue; + } + + outTarget.valid = true; + outTarget.nodeId = tabStack.nodeId; + outTarget.placement = ResolveDropPlacement(tabStack, point); + if (outTarget.placement == UIEditorWorkspaceDockPlacement::Center) { + outTarget.insertionIndex = ResolveDropInsertionIndex(tabStack, point); + if (outTarget.insertionIndex == Widgets::UIEditorTabStripInvalidIndex) { + outTarget.insertionIndex = tabStack.items.size(); + } + } + return true; + } + + for (const Widgets::UIEditorDockHostTabStackLayout& tabStack : layout.tabStacks) { + if (IsPointInsideRect(tabStack.bounds, point)) { + outTarget.valid = true; + outTarget.nodeId = tabStack.nodeId; + outTarget.placement = ResolveDropPlacement(tabStack, point); + if (outTarget.placement == UIEditorWorkspaceDockPlacement::Center) { + outTarget.insertionIndex = tabStack.items.size(); + } + return true; + } + } + + return false; +} + } // namespace bool EditorWindowWorkspaceCoordinator::IsGlobalTabDragActive() const { @@ -231,6 +358,81 @@ bool EditorWindowWorkspaceCoordinator::HandleGlobalTabDragPointerMove(HWND hwnd) return true; } +bool EditorWindowWorkspaceCoordinator::HandleGlobalTabDragPointerButtonUp(HWND hwnd) { + if (!m_globalTabDragSession.active) { + return false; + } + + const EditorWindow* ownerWindow = m_hostRuntime.FindWindow(m_globalTabDragSession.panelWindowId); + if (ownerWindow == nullptr || ownerWindow->GetHwnd() != hwnd) { + return false; + } + + POINT screenPoint = m_globalTabDragSession.screenPoint; + GetCursorPos(&screenPoint); + + const std::string panelWindowId = m_globalTabDragSession.panelWindowId; + const std::string sourceNodeId = m_globalTabDragSession.sourceNodeId; + const std::string panelId = m_globalTabDragSession.panelId; + EndGlobalTabDragSession(); + + EditorWindow* targetWindow = FindTopmostWindowAtScreenPoint(screenPoint, panelWindowId); + if (targetWindow == nullptr || targetWindow->GetHwnd() == nullptr) { + return true; + } + + const UIPoint targetPoint = + targetWindow->ConvertScreenPixelsToClientDips(screenPoint); + const Widgets::UIEditorDockHostLayout& targetLayout = + targetWindow->GetShellFrame().workspaceInteractionFrame.dockHostFrame.layout; + CrossWindowDockDropTarget dropTarget = {}; + if (!TryResolveCrossWindowDockDropTarget(targetLayout, targetPoint, dropTarget)) { + return true; + } + + UIEditorWindowWorkspaceController windowWorkspaceController = + BuildLiveWindowWorkspaceController(targetWindow->GetWindowId()); + const UIEditorWindowWorkspaceOperationResult result = + dropTarget.placement == UIEditorWorkspaceDockPlacement::Center + ? windowWorkspaceController.MovePanelToStack( + panelWindowId, + sourceNodeId, + panelId, + targetWindow->GetWindowId(), + dropTarget.nodeId, + dropTarget.insertionIndex) + : windowWorkspaceController.DockPanelRelative( + panelWindowId, + sourceNodeId, + panelId, + targetWindow->GetWindowId(), + dropTarget.nodeId, + dropTarget.placement); + if (result.status != UIEditorWindowWorkspaceOperationStatus::Changed) { + LogRuntimeTrace("drag", "cross-window drop rejected: " + result.message); + return true; + } + + if (!SynchronizeWindowsFromController( + windowWorkspaceController, + {}, + screenPoint)) { + LogRuntimeTrace("drag", "failed to synchronize windows after cross-window drop"); + return true; + } + + if (EditorWindow* updatedTargetWindow = m_hostRuntime.FindWindow(targetWindow->GetWindowId()); + updatedTargetWindow != nullptr && + updatedTargetWindow->GetHwnd() != nullptr) { + SetForegroundWindow(updatedTargetWindow->GetHwnd()); + } + LogRuntimeTrace( + "drag", + "committed cross-window drop panel '" + panelId + + "' into window '" + std::string(targetWindow->GetWindowId()) + "'"); + return true; +} + bool EditorWindowWorkspaceCoordinator::TryStartGlobalTabDrag( EditorWindow& sourceWindow, const EditorWindowPanelTransferRequest& request) { @@ -288,6 +490,13 @@ bool EditorWindowWorkspaceCoordinator::TryStartGlobalTabDrag( } sourceWindow.ResetInteractionState(); + if (!CanStartGlobalTabDragFromWindow(sourceWindow, request.nodeId, request.panelId)) { + LogRuntimeTrace( + "drag", + "failed to start global tab drag from detached window: invalid source panel request"); + return false; + } + BeginGlobalTabDragSession( sourceWindow.GetWindowId(), request.nodeId, @@ -306,6 +515,43 @@ bool EditorWindowWorkspaceCoordinator::TryStartGlobalTabDrag( return true; } +bool EditorWindowWorkspaceCoordinator::TryProcessDetachRequest( + EditorWindow& sourceWindow, + const EditorWindowPanelTransferRequest& request) { + const std::string sourceWindowId(sourceWindow.GetWindowId()); + UIEditorWindowWorkspaceController windowWorkspaceController = + BuildLiveWindowWorkspaceController(sourceWindowId); + const UIEditorWindowWorkspaceOperationResult result = + windowWorkspaceController.DetachPanelToNewWindow( + sourceWindowId, + request.nodeId, + request.panelId); + if (result.status != UIEditorWindowWorkspaceOperationStatus::Changed) { + LogRuntimeTrace("detach", "detach request rejected: " + result.message); + return false; + } + + if (!SynchronizeWindowsFromController( + windowWorkspaceController, + result.targetWindowId, + request.screenPoint)) { + LogRuntimeTrace("detach", "failed to synchronize detached window state"); + return false; + } + + if (EditorWindow* detachedWindow = m_hostRuntime.FindWindow(result.targetWindowId); + detachedWindow != nullptr && + detachedWindow->GetHwnd() != nullptr) { + SetForegroundWindow(detachedWindow->GetHwnd()); + } + + LogRuntimeTrace( + "detach", + "detached panel '" + request.panelId + "' from window '" + sourceWindowId + + "' to window '" + result.targetWindowId + "'"); + return true; +} + EditorWindow* EditorWindowWorkspaceCoordinator::FindTopmostWindowAtScreenPoint( const POINT& screenPoint, std::string_view excludedWindowId) {