From 1652866769233f0de80a36b588fdeda574fb2013 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:47:02 +0800 Subject: [PATCH] fix(workbench): Drag tool window stripe buttons with pointer events With the window's native file drop enabled, the webview never receives dragover / drop for HTML5 drags that start inside it, so stripe buttons could not be moved. Track the drag with pointer events instead: a ghost follows the pointer, the drop line shows the target, Escape cancels, and an empty right stripe appears as a drop target while dragging. --- .../toolwindows/ToolWindows.module.css | 14 ++ src/workbench/toolwindows/ToolWindows.tsx | 143 ++++++++++++------ 2 files changed, 109 insertions(+), 48 deletions(-) diff --git a/src/workbench/toolwindows/ToolWindows.module.css b/src/workbench/toolwindows/ToolWindows.module.css index df5475e2..866bed73 100644 --- a/src/workbench/toolwindows/ToolWindows.module.css +++ b/src/workbench/toolwindows/ToolWindows.module.css @@ -371,6 +371,20 @@ pointer-events: none; } +.stripeDragged { + opacity: 0.4; +} +.stripeGhost { + position: fixed; + z-index: 1000; + margin: 0; + pointer-events: none; + background: var(--bg-popup); + border-radius: var(--radius-toolbar-button); + box-shadow: var(--shadow-popup); + opacity: 0.9; +} + /* Settings › Appearance › "Different tool window background". */ :global(:root[data-tool-window-bg="different"]) .anchor { background: var(--bg-elev); diff --git a/src/workbench/toolwindows/ToolWindows.tsx b/src/workbench/toolwindows/ToolWindows.tsx index 84c72e77..72bf4122 100644 --- a/src/workbench/toolwindows/ToolWindows.tsx +++ b/src/workbench/toolwindows/ToolWindows.tsx @@ -4,7 +4,6 @@ import { useMemo, useRef, useState, - type DragEvent, type CSSProperties, type KeyboardEvent, type PointerEvent, @@ -251,8 +250,92 @@ function StripeHandle({ ); } -/** MIME type of a dragged stripe button (the tool window id). */ -export const TOOL_WINDOW_MIME = "application/x-openidea-toolwindow"; +// Stripe button drag. Pointer events rather than HTML5 drag and drop: with the window's native +// file drop enabled, the webview never sees dragover / drop for drags that start inside it. + +type StripeDrop = { group: StripeGroup; before: string | null }; + +const useStripeDrag = create<{ id: string | null; drop: StripeDrop | null }>(() => ({ id: null, drop: null })); + +/** Movement before a press on a stripe button becomes a drag, px. */ +const DRAG_THRESHOLD = 4; +/** Set when a drag ends on its own button, so the click that follows doesn't toggle the window. */ +let dragEnded = false; + +/** Drop position under the pointer: a stripe group and the button to insert before (null: at the end). */ +function stripeDropAt(x: number, y: number): StripeDrop | null { + const nav = document.elementFromPoint(x, y)?.closest("[data-stripe-side]"); + const side = nav?.dataset.stripeSide; + if (!nav || (side !== "left" && side !== "right")) return null; + const spacer = nav.querySelector("[data-stripe-spacer]")?.getBoundingClientRect(); + const lower = !!spacer && y > spacer.top + spacer.height / 2; + let before: string | null = null; + for (const b of nav.querySelectorAll("[data-stripe-button]")) { + const r = b.getBoundingClientRect(); + if (spacer && r.top > spacer.top !== lower) continue; + if (y < r.top + r.height / 2) { + before = b.dataset.stripeButton!; + break; + } + } + return { group: `${side}-${lower ? "bottom" : "top"}`, before }; +} + +function startStripeDrag(e: PointerEvent, id: string) { + if (e.button !== 0) return; + const button = e.currentTarget; + const start = { x: e.clientX, y: e.clientY }; + let ghost: HTMLElement | null = null; + const place = (x: number, y: number) => { + if (ghost) ghost.style.transform = `translate(${x - start.x}px, ${y - start.y}px)`; + }; + const onMove = (ev: globalThis.PointerEvent) => { + if (!ghost) { + if (Math.hypot(ev.clientX - start.x, ev.clientY - start.y) < DRAG_THRESHOLD) return; + const r = button.getBoundingClientRect(); + ghost = button.cloneNode(true) as HTMLElement; + ghost.removeAttribute("data-stripe-button"); + ghost.className = clsx(button.className, styles.stripeGhost); + Object.assign(ghost.style, { + left: `${r.left}px`, + top: `${r.top}px`, + width: `${r.width}px`, + height: `${r.height}px`, + }); + document.body.appendChild(ghost); + useStripeDrag.setState({ id, drop: null }); + } + place(ev.clientX, ev.clientY); + const drop = stripeDropAt(ev.clientX, ev.clientY); + const cur = useStripeDrag.getState().drop; + if (drop?.group !== cur?.group || drop?.before !== cur?.before) useStripeDrag.setState({ drop }); + }; + const finish = (commit: boolean) => { + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + window.removeEventListener("pointercancel", onCancel); + window.removeEventListener("keydown", onKey, true); + if (!ghost) return; + ghost.remove(); + const { drop } = useStripeDrag.getState(); + useStripeDrag.setState({ id: null, drop: null }); + dragEnded = true; + setTimeout(() => (dragEnded = false)); + if (commit && drop && drop.before !== id) moveToolWindow(id, drop.group, drop.before); + }; + const onUp = () => finish(true); + const onCancel = () => finish(false); + const onKey = (ev: globalThis.KeyboardEvent) => { + if (ev.key !== "Escape" || !ghost) return; + ev.preventDefault(); + ev.stopPropagation(); + finish(false); + }; + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + window.addEventListener("pointercancel", onCancel); + window.addEventListener("keydown", onKey, true); +} function StripeBadge({ useBadge }: { useBadge: () => number | string | null }) { const value = useBadge(); @@ -274,6 +357,7 @@ function StripeButton({ showName: boolean; }) { const shortcut = shortcutLabel(`toolwindow.${tw.id}`); + const dragged = useStripeDrag((s) => s.id === tw.id); return (