Merge pull request #22881 from dannon/window-manager-pointer-capture

[26.1] Fix floating window drag/resize over iframes with pointer capture
This commit is contained in:
Marius van den Beek
2026-06-12 16:52:25 +02:00
committed by GitHub
2 changed files with 202 additions and 23 deletions
@@ -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 }));
});
});
@@ -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();
});
</script>
@@ -170,10 +196,12 @@ onBeforeUnmount(() => {
<!-- eslint-disable-next-line vuejs-accessibility/no-static-element-interactions -->
<div
class="window-manager-window-header"
@mousedown.left="window.minimized ? onMinimize() : onDragStart($event)">
@pointerdown.left="window.minimized ? onMinimize() : onDragStart($event)">
<span class="window-manager-window-title">{{ window.title }}</span>
<!-- pointerdown.stop keeps control presses from starting a drag; mousedown.stop
keeps the compatibility mouse event from reaching the root focus handler -->
<!-- eslint-disable-next-line vuejs-accessibility/no-static-element-interactions -->
<div class="window-manager-window-controls" @mousedown.stop>
<div class="window-manager-window-controls" @pointerdown.stop @mousedown.stop>
<button class="window-manager-window-btn" aria-label="Minimize" @click="onMinimize">&#8722;</button>
<button class="window-manager-window-btn" aria-label="Maximize" @click="onMaximize">
<span v-if="window.maximized">&#9724;</span>
@@ -193,7 +221,7 @@ onBeforeUnmount(() => {
<div v-if="!isFocused" class="iframe-focus-overlay" @mousedown="onFocus" />
</div>
<!-- eslint-disable-next-line vuejs-accessibility/no-static-element-interactions -->
<div v-if="!window.maximized" class="window-manager-resize-handle" @mousedown.left="onResizeStart" />
<div v-if="!window.maximized" class="window-manager-resize-handle" @pointerdown.left="onResizeStart" />
</template>
</div>
</template>
@@ -234,6 +262,7 @@ onBeforeUnmount(() => {
border-bottom: 1px solid var(--color-grey-200);
cursor: move;
user-select: none;
touch-action: none;
}
.window-manager-window-title {
@@ -303,5 +332,6 @@ onBeforeUnmount(() => {
width: 12px;
height: 12px;
cursor: nwse-resize;
touch-action: none;
}
</style>