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 })); + }); +}); 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 }} + -
+