Fix panel docking bugs and polish its behavior (#4087)

* Fix panel docking bugs and polish its behavior

* Fix bug
This commit is contained in:
Keavon Chambers
2026-05-01 17:02:10 +00:00
committed by Timon
parent 4c1974c200
commit 83d03ad67d
14 changed files with 417 additions and 255 deletions
+34 -33
View File
@@ -94,7 +94,7 @@
// Only start a group drag from the tab bar background (not from a tab or button)
if (e.button !== BUTTON_LEFT) return;
if (e.target !== e.currentTarget) return;
if (!crossPanelDropAction) return;
if (!crossPanelDropAction && !splitDropAction) return;
dragStartState = { tabIndex: tabActiveIndex, pointerX: e.clientX, pointerY: e.clientY, isGroupDrag: true };
dragging = false;
@@ -142,13 +142,12 @@
dragging = true;
if (crossPanelDropAction) {
if (dragStartState.isGroupDrag) {
startCrossPanelDrag(panelId, [...panelTypes], tabActiveIndex, true);
} else {
const draggedTab = panelTypes[dragStartState.tabIndex];
startCrossPanelDrag(panelId, [draggedTab], dragStartState.tabIndex, false);
}
// Group drags enter cross-panel state for edge docking even without crossPanelDropAction
if (dragStartState.isGroupDrag && (crossPanelDropAction || splitDropAction)) {
startCrossPanelDrag(panelId, [...panelTypes], tabActiveIndex, true);
} else if (!dragStartState.isGroupDrag && crossPanelDropAction) {
const draggedTab = panelTypes[dragStartState.tabIndex];
startCrossPanelDrag(panelId, [draggedTab], dragStartState.tabIndex, false);
}
}
@@ -165,7 +164,9 @@
insertionIndex = undefined;
insertionMarkerLeft = undefined;
// Check if the pointer is over any other dockable panel's tab bar
// Skip cross-panel hover detection for sources that can't dock anywhere
if (!crossPanelDropAction && !splitDropAction) return;
if (crossPanelDropAction) {
const tabBarTarget = Array.from(document.querySelectorAll("[data-panel-tab-bar]")).find((element) => {
const targetPanelId = element.getAttribute("data-panel-tab-bar");
@@ -180,35 +181,35 @@
calculateForeignInsertionIndex(e.clientX, tabBarTargetId, tabBarTarget);
return;
}
}
// Check if the pointer is over any panel body's edge zone for split docking
const panelBody = Array.from(document.querySelectorAll("[data-panel-body]")).find((element) => {
const rect = element.getBoundingClientRect();
return e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom;
});
// Check for edge-zone split docking
const panelBody = Array.from(document.querySelectorAll("[data-panel-body]")).find((element) => {
const rect = element.getBoundingClientRect();
return e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom;
});
const bodyPanelId = panelBody && panelBody.getAttribute("data-panel-body");
if (bodyPanelId) {
const rect = panelBody.getBoundingClientRect();
let edge: DockingEdge | undefined = detectDockingEdge(e.clientX, e.clientY, rect);
const bodyPanelId = panelBody && panelBody.getAttribute("data-panel-body");
if (bodyPanelId) {
const rect = panelBody.getBoundingClientRect();
let edge: DockingEdge | undefined = detectDockingEdge(e.clientX, e.clientY, rect);
// Block center drops between document and non-document panels
if (edge === "Center") {
const targetIsDockable = panelBody.hasAttribute("data-panel-dockable");
const sourceIsDockable = crossPanelDropAction !== undefined;
if (targetIsDockable !== sourceIsDockable) edge = undefined;
}
if (edge) {
updateDockingHover(bodyPanelId, edge);
return;
}
// Center drops between different panels require both to be cross-panel-dockable (self-drops are always allowed as a no-op)
if (edge === "Center" && bodyPanelId !== panelId) {
const targetIsDockable = panelBody.hasAttribute("data-panel-dockable");
const sourceIsDockable = crossPanelDropAction !== undefined;
if (!sourceIsDockable || !targetIsDockable) edge = undefined;
}
// Not hovering any drop target
updateCrossPanelHover(undefined, undefined, undefined);
updateDockingHover(undefined, undefined);
if (edge) {
updateDockingHover(bodyPanelId, edge);
return;
}
}
// Not hovering any drop target
updateCrossPanelHover(undefined, undefined, undefined);
updateDockingHover(undefined, undefined);
}
function dragPointerUp() {
@@ -273,7 +274,7 @@
dragging = false;
insertionIndex = undefined;
insertionMarkerLeft = undefined;
if (crossPanelDropAction) endCrossPanelDrag();
endCrossPanelDrag();
removeDragListeners();
}
@@ -8,6 +8,9 @@
const MIN_PANEL_SIZE = 100;
const DOUBLE_CLICK_MILLISECONDS = 500;
// Must match DOCUMENT_PANEL_SHARE / NON_DOCUMENT_PANEL_SHARE in utility_types.rs
const DOCUMENT_PANEL_SHARE = 0.8;
const EQUAL_PANEL_SHARE = 0.5;
const editor = getContext<EditorWrapper>("editor");
const portfolio = getContext<PortfolioStore>("portfolio");
@@ -24,11 +27,15 @@
let activeResizeCleanup: (() => void) | undefined = undefined;
let lastGutterClickTarget: EventTarget | undefined = undefined;
let lastGutterClickTime = 0;
let lastSubdivisionRef: PanelLayoutSubdivision | undefined = undefined;
// At even depths (0, 2, 4...) children are in a row, at odd depths (1, 3, 5...) in a column
$: horizontal = depth % 2 === 0;
// Reset overrides when the subdivision changes (e.g., backend sends a new layout)
$: if (subdivision) sizeOverrides = {};
// Compare by reference because `safe_not_equal` treats any store update as changed, which would wipe drag overrides
$: if (subdivision !== lastSubdivisionRef) {
sizeOverrides = {};
lastSubdivisionRef = subdivision;
}
// Reactive array of resolved sizes (merging backend defaults with local overrides)
$: resolvedSizes = subdivision && "Split" in subdivision ? subdivision.Split.children.map((child, index) => sizeOverrides[index] ?? child.size) : [];
$: documentTabLabels = $portfolio.documents.map((doc: DocumentInfo) => {
@@ -55,26 +62,41 @@
const parentElement = gutter.parentElement;
if (!(nextSibling instanceof HTMLDivElement) || !(prevSibling instanceof HTMLDivElement) || !(parentElement instanceof HTMLDivElement)) return;
// Double-click resets both adjacent panels to their default sizes
// Double-click resets the two adjacent panels to the default ratio (80:20 near document, otherwise 50:50)
const now = Date.now();
const isDoubleClick = now - lastGutterClickTime < DOUBLE_CLICK_MILLISECONDS && lastGutterClickTarget === gutter;
lastGutterClickTime = now;
lastGutterClickTarget = gutter;
if (isDoubleClick) {
sizeOverrides = {};
editor.resetPanelGroupSizes(splitPath);
const children = subdivision.Split.children;
const adjacentSum = resolvedSizes[prevIndex] + resolvedSizes[nextIndex];
const prevHasDocument = subtreeContainsDocument(children[prevIndex].subdivision);
const nextHasDocument = subtreeContainsDocument(children[nextIndex].subdivision);
let prevShare = EQUAL_PANEL_SHARE;
if (prevHasDocument && !nextHasDocument) prevShare = DOCUMENT_PANEL_SHARE;
else if (!prevHasDocument && nextHasDocument) prevShare = 1 - DOCUMENT_PANEL_SHARE;
sizeOverrides[prevIndex] = adjacentSum * prevShare;
sizeOverrides[nextIndex] = adjacentSum * (1 - prevShare);
sizeOverrides = sizeOverrides;
const allSizes = children.map((child, i) => sizeOverrides[i] ?? child.size);
editor.setPanelGroupSizes(splitPath, allSizes);
return;
}
const isHorizontal = horizontal;
const gutterSize = isHorizontal ? gutter.getBoundingClientRect().width : gutter.getBoundingClientRect().height;
const nextSiblingSize = isHorizontal ? nextSibling.getBoundingClientRect().width : nextSibling.getBoundingClientRect().height;
const prevSiblingSize = isHorizontal ? prevSibling.getBoundingClientRect().width : prevSibling.getBoundingClientRect().height;
const parentElementSize = isHorizontal ? parentElement.getBoundingClientRect().width : parentElement.getBoundingClientRect().height;
const totalResizingSpaceOccupied = gutterSize + nextSiblingSize + prevSiblingSize;
const proportionBeingResized = totalResizingSpaceOccupied / parentElementSize;
// Only redistribute within the two adjacent panels' combined flex-grow total
const adjacentFlexGrowTotal = resolvedSizes[prevIndex] + resolvedSizes[nextIndex];
const adjacentPixelTotal = prevSiblingSize + nextSiblingSize;
pointerCaptureId = e.pointerId;
gutter.setPointerCapture(pointerCaptureId);
@@ -88,7 +110,9 @@
activeResizeCleanup = undefined;
if (gutterResizeRestore !== undefined) {
sizeOverrides = { ...sizeOverrides, [nextIndex]: gutterResizeRestore[0], [prevIndex]: gutterResizeRestore[1] };
sizeOverrides[nextIndex] = gutterResizeRestore[0];
sizeOverrides[prevIndex] = gutterResizeRestore[1];
sizeOverrides = sizeOverrides;
gutterResizeRestore = undefined;
}
};
@@ -102,11 +126,9 @@
if (gutterResizeRestore === undefined) gutterResizeRestore = [resolvedSizes[nextIndex], resolvedSizes[prevIndex]];
sizeOverrides = {
...sizeOverrides,
[nextIndex]: ((nextSiblingSize + mouseDelta) / totalResizingSpaceOccupied) * proportionBeingResized * 100,
[prevIndex]: ((prevSiblingSize - mouseDelta) / totalResizingSpaceOccupied) * proportionBeingResized * 100,
};
sizeOverrides[nextIndex] = (adjacentFlexGrowTotal * (nextSiblingSize + mouseDelta)) / adjacentPixelTotal;
sizeOverrides[prevIndex] = (adjacentFlexGrowTotal * (prevSiblingSize - mouseDelta)) / adjacentPixelTotal;
sizeOverrides = sizeOverrides;
};
const onPointerUp = () => {
@@ -164,6 +186,12 @@
function isDocumentGroup(state: PanelGroupState): boolean {
return state.tabs.some((t) => t === "Document" || t === "Welcome");
}
function subtreeContainsDocument(node: PanelLayoutSubdivision): boolean {
if ("PanelGroup" in node) return isDocumentGroup(node.PanelGroup.state);
if ("Split" in node) return node.Split.children.some((child) => subtreeContainsDocument(child.subdivision));
return false;
}
</script>
{#if subdivision && "PanelGroup" in subdivision}