mirror of
https://github.com/galaxyproject/galaxy.git
synced 2026-09-24 16:30:27 +08:00
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:
@@ -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">−</button>
|
||||
<button class="window-manager-window-btn" aria-label="Maximize" @click="onMaximize">
|
||||
<span v-if="window.maximized">◼</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>
|
||||
|
||||
Reference in New Issue
Block a user