From a0a2f7ca8599a9bdb5170a0a58804bf2d1787a39 Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 21 Oct 2026 20:38:31 -0400 Subject: [PATCH] fix(app): keep the side tab drop indicator inside the strip (#57972) --- .../app/src/runtime/extension/pane-drag.tsx | 37 ++++++++++++++++--- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/packages/app/src/runtime/extension/pane-drag.tsx b/packages/app/src/runtime/extension/pane-drag.tsx index 974e7e80f11..2b561a42e72 100644 --- a/packages/app/src/runtime/extension/pane-drag.tsx +++ b/packages/app/src/runtime/extension/pane-drag.tsx @@ -31,8 +31,9 @@ export function createPaneDrag(input: { const inside = (rect: DOMRect) => rect.width > 0 && rect.height > 0 && x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom + // A scrolled strip, or one wider than its animating pane, extends past what is on screen. const strip = Array.from(document.querySelectorAll("[data-pane-strip]")).find((element) => - inside(element.getBoundingClientRect()), + inside(visibleRect(element)), ) const stripPane = paneOf(strip?.dataset.paneStrip) @@ -49,14 +50,19 @@ export function createPaneDrag(input: { }) const anchor = (next ?? tabs.at(-1))?.getBoundingClientRect() - const bounds = strip.getBoundingClientRect() + const bounds = visibleRect(strip) return { pane: stripPane, before: next?.dataset.paneTab, strip: true, rect: anchor - ? { left: (next ? anchor.left : anchor.right) - 1, top: anchor.top + 2, width: 2, height: anchor.height - 4 } + ? { + left: Math.min(Math.max((next ? anchor.left : anchor.right) - 1, bounds.left), bounds.right - 2), + top: anchor.top + 2, + width: 2, + height: anchor.height - 4, + } : { left: bounds.left + 12, top: bounds.top + 12, width: 2, height: bounds.height - 24 }, } } @@ -105,10 +111,12 @@ export function createPaneDrag(input: { if (!drag.active) { drag.active = true document.body.style.userSelect = "none" - document.body.style.cursor = "grabbing" } - setState({ key, title, x: moved.clientX, y: moved.clientY, target: hit(moved.clientX, moved.clientY, key) }) + const target = hit(moved.clientX, moved.clientY, key) + // Away from any drop target the release cancels, so the cursor stays normal. + document.body.style.cursor = target ? "grabbing" : "" + setState({ key, title, x: moved.clientX, y: moved.clientY, target }) } const finish = (commit: boolean) => { @@ -192,6 +200,25 @@ function paneOf(value: string | undefined) { return SESSION_PANES.find((pane) => pane === value) } +/** The element's on-screen rect: its bounding rect clipped by every ancestor that clips overflow. */ +function visibleRect(element: HTMLElement) { + const rect = element.getBoundingClientRect() + const bounds = { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom } + + for (let parent = element.parentElement; parent; parent = parent.parentElement) { + const style = getComputedStyle(parent) + + if (style.overflowX === "visible" && style.overflowY === "visible") continue + const clip = parent.getBoundingClientRect() + bounds.left = Math.max(bounds.left, clip.left) + bounds.top = Math.max(bounds.top, clip.top) + bounds.right = Math.min(bounds.right, clip.right) + bounds.bottom = Math.min(bounds.bottom, clip.bottom) + } + + return new DOMRect(bounds.left, bounds.top, bounds.right - bounds.left, bounds.bottom - bounds.top) +} + function toRect(rect: DOMRect): Rect { return { left: rect.left, top: rect.top, width: rect.width, height: rect.height } }