From 16e08d3dc9e0c202b98731cfeebc10f2d41ece27 Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Wed, 10 Jun 2026 15:42:37 -0400 Subject: [PATCH 1/2] Add pointer drag/resize tests for WindowManagerWindow These demonstrate galaxyproject/galaxy#22875: dragging or resizing the floating window has to capture the pointer, otherwise the interaction dies as soon as the cursor crosses an iframe (the center frame, or the window's own iframe body). The tests drive the header drag and corner resize through pointer events and assert the handle captures the pointer; they fail before the fix since the component only listens for mouse events. --- .../WindowManager/WindowManagerWindow.test.ts | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 client/src/components/WindowManager/WindowManagerWindow.test.ts diff --git a/client/src/components/WindowManager/WindowManagerWindow.test.ts b/client/src/components/WindowManager/WindowManagerWindow.test.ts new file mode 100644 index 00000000000..6aff6ba8d58 --- /dev/null +++ b/client/src/components/WindowManager/WindowManagerWindow.test.ts @@ -0,0 +1,149 @@ +import { createTestingPinia } from "@pinia/testing"; +import { getLocalVue } from "@tests/vitest/helpers"; +import { mount } from "@vue/test-utils"; +import { setActivePinia } from "pinia"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { useWindowManagerStore } from "@/stores/windowManagerStore"; + +import WindowManagerWindow from "./WindowManagerWindow.vue"; + +const localVue = getLocalVue(); + +// happy-dom does not implement pointer capture, so stub it. Capturing the +// pointer is what keeps drag/resize alive when the cursor crosses an iframe +// (the center frame or the window's own body), so we assert on these calls. +const setPointerCapture = vi.fn(); + +function pointerEvent(type: string, options: PointerEventInit = {}) { + return new PointerEvent(type, { bubbles: true, cancelable: true, button: 0, pointerId: 7, ...options }); +} + +function mountWindow() { + const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false }); + setActivePinia(pinia); + const store = useWindowManagerStore(); + store.add({ title: "GalaxyAI", url: "/galaxyai", x: 50, y: 60, width: 400, height: 300 }); + const win = store.windows[0]!; + const wrapper = mount(WindowManagerWindow as object, { + localVue, + pinia, + propsData: { window: win }, + }); + return { wrapper, win }; +} + +describe("WindowManagerWindow", () => { + beforeEach(() => { + (HTMLElement.prototype as any).setPointerCapture = setPointerCapture; + }); + + afterEach(() => { + vi.clearAllMocks(); + delete (HTMLElement.prototype as any).setPointerCapture; + }); + + it("resizes with the pointer and captures it on the handle", () => { + const { wrapper, win } = mountWindow(); + const handle = wrapper.find(".window-manager-resize-handle"); + + handle.element.dispatchEvent(pointerEvent("pointerdown", { clientX: 450, clientY: 360 })); + expect(setPointerCapture).toHaveBeenCalledWith(7); + + document.dispatchEvent(pointerEvent("pointermove", { clientX: 550, clientY: 410 })); + expect(win.width).toBe(500); + expect(win.height).toBe(350); + + document.dispatchEvent(pointerEvent("pointerup", { clientX: 550, clientY: 410 })); + document.dispatchEvent(pointerEvent("pointermove", { clientX: 650, clientY: 460 })); + expect(win.width).toBe(500); + expect(win.height).toBe(350); + }); + + it("drags with the pointer and captures it on the header", () => { + const { wrapper, win } = mountWindow(); + const header = wrapper.find(".window-manager-window-header"); + + header.element.dispatchEvent(pointerEvent("pointerdown", { clientX: 200, clientY: 80 })); + expect(setPointerCapture).toHaveBeenCalledWith(7); + + document.dispatchEvent(pointerEvent("pointermove", { clientX: 230, clientY: 120 })); + expect(win.x).toBe(80); + expect(win.y).toBe(100); + + document.dispatchEvent(pointerEvent("pointerup", { clientX: 230, clientY: 120 })); + document.dispatchEvent(pointerEvent("pointermove", { clientX: 300, clientY: 200 })); + expect(win.x).toBe(80); + expect(win.y).toBe(100); + }); + + it("stops resizing when the pointer is cancelled", () => { + const { wrapper, win } = mountWindow(); + const handle = wrapper.find(".window-manager-resize-handle"); + + handle.element.dispatchEvent(pointerEvent("pointerdown", { clientX: 450, clientY: 360 })); + document.dispatchEvent(pointerEvent("pointermove", { clientX: 550, clientY: 410 })); + expect(win.width).toBe(500); + + document.dispatchEvent(pointerEvent("pointercancel", { clientX: 550, clientY: 410 })); + document.dispatchEvent(pointerEvent("pointermove", { clientX: 650, clientY: 460 })); + expect(win.width).toBe(500); + expect(win.height).toBe(350); + }); + + it("does not start a drag from the window controls", () => { + const { wrapper, win } = mountWindow(); + const controls = wrapper.find(".window-manager-window-controls"); + + controls.element.dispatchEvent(pointerEvent("pointerdown", { clientX: 420, clientY: 70 })); + document.dispatchEvent(pointerEvent("pointermove", { clientX: 500, clientY: 200 })); + expect(win.x).toBe(50); + expect(win.y).toBe(60); + }); + + it("ignores a second pointer while dragging", () => { + const { wrapper, win } = mountWindow(); + const header = wrapper.find(".window-manager-window-header"); + + header.element.dispatchEvent(pointerEvent("pointerdown", { clientX: 200, clientY: 80 })); + header.element.dispatchEvent(pointerEvent("pointerdown", { pointerId: 9, clientX: 300, clientY: 200 })); + + document.dispatchEvent(pointerEvent("pointermove", { pointerId: 9, clientX: 400, clientY: 300 })); + expect(win.x).toBe(50); + expect(win.y).toBe(60); + + document.dispatchEvent(pointerEvent("pointermove", { clientX: 230, clientY: 120 })); + expect(win.x).toBe(80); + expect(win.y).toBe(100); + + document.dispatchEvent(pointerEvent("pointerup", { clientX: 230, clientY: 120 })); + }); + + it("does not steal focus when clicking the controls of an unfocused window", () => { + const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false }); + setActivePinia(pinia); + const store = useWindowManagerStore(); + store.add({ title: "First", url: "/a", x: 50, y: 60, width: 400, height: 300 }); + store.add({ title: "Second", url: "/b" }); + const first = store.windows[0]!; + const second = store.windows[1]!; + const wrapper = mount(WindowManagerWindow as object, { + localVue, + pinia, + propsData: { window: first }, + }); + expect(store.focusedId).toBe(second.id); + + // a real click delivers pointerdown plus a compatibility mousedown; + // neither may bubble to the root focus handler from the controls + const controls = wrapper.find(".window-manager-window-controls"); + controls.element.dispatchEvent(pointerEvent("pointerdown", { clientX: 420, clientY: 70 })); + controls.element.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0 })); + expect(store.focusedId).toBe(second.id); + + const header = wrapper.find(".window-manager-window-header"); + header.element.dispatchEvent(pointerEvent("pointerdown", { clientX: 200, clientY: 80 })); + expect(store.focusedId).toBe(first.id); + document.dispatchEvent(pointerEvent("pointerup", { clientX: 200, clientY: 80 })); + }); +}); From a8218f172e40ff1ed2ec800cea64b84ad2399046 Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Wed, 10 Jun 2026 15:42:57 -0400 Subject: [PATCH 2/2] Capture the pointer for window manager drag and resize Fixes galaxyproject/galaxy#22875. The floating window's drag and resize listened for mouse events on the document, but mouse events stop reaching the parent document the moment the cursor moves over an iframe -- and with the center frame loaded, most of the screen is iframe. The resize would freeze mid-drag, and a mouseup over an iframe was never seen, leaving the window glued to the cursor. Switching to pointer events and calling setPointerCapture on the header and resize handle keeps every move/up/cancel event flowing to the parent document for the duration of the drag, no matter what the pointer is over. Same bug class FlexPanel hit in #20349; that fix used an interaction overlay because vueuse's useDraggable can't capture, but these hand-rolled handlers can do it natively. Pointer events also make touch real, so the handlers now track the active pointerId and the drag surfaces get touch-action: none. --- .../WindowManager/WindowManagerWindow.vue | 76 +++++++++++++------ 1 file changed, 53 insertions(+), 23 deletions(-) diff --git a/client/src/components/WindowManager/WindowManagerWindow.vue b/client/src/components/WindowManager/WindowManagerWindow.vue index 714e67f16e9..13e7956fdb3 100644 --- a/client/src/components/WindowManager/WindowManagerWindow.vue +++ b/client/src/components/WindowManager/WindowManagerWindow.vue @@ -56,23 +56,31 @@ const windowStyle = computed(() => { const iframeSrc = computed(() => store.buildUrl(props.window.url)); // --- Drag --- +let dragPointerId = -1; let dragStartMouseX = 0; let dragStartMouseY = 0; let dragStartX = 0; let dragStartY = 0; -function onDragStart(e: MouseEvent) { - if (props.window.maximized || props.window.minimized) { +function onDragStart(e: PointerEvent) { + // ignore further pointers while a drag is active (first pointer wins) + if (props.window.maximized || props.window.minimized || dragPointerId !== -1) { return; } e.preventDefault(); store.focus(props.window.id); + dragPointerId = e.pointerId; dragStartMouseX = e.clientX; dragStartMouseY = e.clientY; dragStartX = props.window.x; dragStartY = props.window.y; - document.addEventListener("mousemove", onDragMove); - document.addEventListener("mouseup", onDragEnd); + // Capture the pointer so move/up events keep reaching this document even + // when the cursor crosses an iframe (the center frame or this window's own + // body), which would otherwise swallow them and leave the drag stuck. + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + document.addEventListener("pointermove", onDragMove); + document.addEventListener("pointerup", onDragEnd); + document.addEventListener("pointercancel", onDragEnd); } function clampPosition(rawX: number, rawY: number): { x: number; y: number } { @@ -86,42 +94,57 @@ function clampPosition(rawX: number, rawY: number): { x: number; y: number } { }; } -function onDragMove(e: MouseEvent) { +function onDragMove(e: PointerEvent) { + if (e.pointerId !== dragPointerId) { + return; + } const rawX = dragStartX + (e.clientX - dragStartMouseX); const rawY = dragStartY + (e.clientY - dragStartMouseY); const { x, y } = clampPosition(rawX, rawY); store.updatePosition(props.window.id, x, y); } -function onDragEnd() { - document.removeEventListener("mousemove", onDragMove); - document.removeEventListener("mouseup", onDragEnd); +function onDragEnd(e?: PointerEvent) { + if (e && e.pointerId !== dragPointerId) { + return; + } + dragPointerId = -1; + document.removeEventListener("pointermove", onDragMove); + document.removeEventListener("pointerup", onDragEnd); + document.removeEventListener("pointercancel", onDragEnd); } // --- Resize --- const MIN_WIDTH = 200; const MIN_HEIGHT = 120; +let resizePointerId = -1; let resizeStartMouseX = 0; let resizeStartMouseY = 0; let resizeStartW = 0; let resizeStartH = 0; -function onResizeStart(e: MouseEvent) { - if (props.window.maximized) { +function onResizeStart(e: PointerEvent) { + if (props.window.maximized || resizePointerId !== -1) { return; } e.preventDefault(); e.stopPropagation(); store.focus(props.window.id); + resizePointerId = e.pointerId; resizeStartMouseX = e.clientX; resizeStartMouseY = e.clientY; resizeStartW = props.window.width; resizeStartH = props.window.height; - document.addEventListener("mousemove", onResizeMove); - document.addEventListener("mouseup", onResizeEnd); + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + document.addEventListener("pointermove", onResizeMove); + document.addEventListener("pointerup", onResizeEnd); + document.addEventListener("pointercancel", onResizeEnd); } -function onResizeMove(e: MouseEvent) { +function onResizeMove(e: PointerEvent) { + if (e.pointerId !== resizePointerId) { + return; + } const maxW = window.innerWidth - props.window.x; const maxH = window.innerHeight - props.window.y; store.updateSize( @@ -131,9 +154,14 @@ function onResizeMove(e: MouseEvent) { ); } -function onResizeEnd() { - document.removeEventListener("mousemove", onResizeMove); - document.removeEventListener("mouseup", onResizeEnd); +function onResizeEnd(e?: PointerEvent) { + if (e && e.pointerId !== resizePointerId) { + return; + } + resizePointerId = -1; + document.removeEventListener("pointermove", onResizeMove); + document.removeEventListener("pointerup", onResizeEnd); + document.removeEventListener("pointercancel", onResizeEnd); } // --- Actions --- @@ -154,10 +182,8 @@ function onMaximize() { } onBeforeUnmount(() => { - document.removeEventListener("mousemove", onDragMove); - document.removeEventListener("mouseup", onDragEnd); - document.removeEventListener("mousemove", onResizeMove); - document.removeEventListener("mouseup", onResizeEnd); + onDragEnd(); + onResizeEnd(); }); @@ -170,10 +196,12 @@ onBeforeUnmount(() => {
+ @pointerdown.left="window.minimized ? onMinimize() : onDragStart($event)"> {{ window.title }} + -
+